Merge pull request #116 from acelaya/feature/1.7.0

Feature/1.7.0
This commit is contained in:
Alejandro Celaya 2018-01-21 10:00:11 +01:00 committed by GitHub
commit bf8e14708b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
116 changed files with 1779 additions and 1170 deletions

1
.gitattributes vendored
View file

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

View file

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

View file

@ -1,5 +1,23 @@
## CHANGELOG ## CHANGELOG
### 1.7.0
**Features**
* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88)
* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108)
**Enhancements:**
* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113)
* [112: Configure cli commands lazy loading](https://github.com/shlinkio/shlink/issues/112)
**Tasks**
* [117: Make every module which throws exceptions have its own ExceptionInterface, and make them all extend Throwable](https://github.com/shlinkio/shlink/issues/117)
* [115: Add phpstan to build matrix on PHP >=7.1 envs](https://github.com/shlinkio/shlink/issues/115)
* [114: Replace vlucas/phpdotenv dev requirement by symfony/env](https://github.com/shlinkio/shlink/issues/114)
### 1.6.2 ### 1.6.2
**Bugs** **Bugs**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -116,6 +116,7 @@
} }
} }
}, },
"post": { "post": {
"tags": [ "tags": [
"ShortCodes" "ShortCodes"
@ -140,6 +141,34 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "validSince",
"in": "formData",
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"required": false,
"type": "string"
},
{
"name": "validUntil",
"in": "formData",
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"required": false,
"type": "string"
},
{
"name": "customSlug",
"in": "formData",
"description": "A unique custom slug to be used instead of the generated short code",
"required": false,
"type": "string"
},
{
"name": "maxVisits",
"in": "formData",
"description": "The maximum number of allowed visits for this short code",
"required": false,
"type": "number"
},
{ {
"$ref": "../parameters/Authorization.json" "$ref": "../parameters/Authorization.json"
} }

View file

@ -54,5 +54,69 @@
} }
} }
} }
},
"put": {
"tags": [
"ShortCodes"
],
"summary": "Edit short code",
"description": "Update certain meta arguments from an existing short URL.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"type": "string",
"description": "The short code to edit.",
"required": true
},
{
"name": "validSince",
"in": "formData",
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"required": false,
"type": "string"
},
{
"name": "validUntil",
"in": "formData",
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"required": false,
"type": "string"
},
{
"name": "maxVisits",
"in": "formData",
"description": "The maximum number of allowed visits for this short code",
"required": false,
"type": "number"
},
{
"$ref": "../parameters/Authorization.json"
}
],
"responses": {
"204": {
"description": "The short code has been properly updated."
},
"400": {
"description": "Provided meta arguments are invalid.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"404": {
"description": "No short URL was found for provided short code.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
} }
} }

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Service\VisitsTracker;
use ShlinkioTest\Shlink\Common\Util\TestUtils; use ShlinkioTest\Shlink\Common\Util\TestUtils;
@ -26,13 +27,21 @@ class RedirectActionTest extends TestCase
* @var ObjectProphecy * @var ObjectProphecy
*/ */
protected $urlShortener; protected $urlShortener;
/**
* @var ObjectProphecy
*/
protected $visitTracker;
public function setUp() public function setUp()
{ {
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlShortener = $this->prophesize(UrlShortener::class);
$visitTracker = $this->prophesize(VisitsTracker::class); $this->visitTracker = $this->prophesize(VisitsTracker::class);
$visitTracker->track(Argument::any());
$this->action = new RedirectAction($this->urlShortener->reveal(), $visitTracker->reveal()); $this->action = new RedirectAction(
$this->urlShortener->reveal(),
$this->visitTracker->reveal(),
new AppOptions(['disableTrackParam' => 'foobar'])
);
} }
/** /**
@ -44,6 +53,8 @@ class RedirectActionTest extends TestCase
$expectedUrl = 'http://domain.com/foo/bar'; $expectedUrl = 'http://domain.com/foo/bar';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl) $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl)
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->willReturn(null)
->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, TestUtils::createDelegateMock()->reveal()); $response = $this->action->process($request, TestUtils::createDelegateMock()->reveal());
@ -62,6 +73,9 @@ class RedirectActionTest extends TestCase
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->willReturn(null)
->shouldNotBeCalled();
$delegate = $this->prophesize(DelegateInterface::class); $delegate = $this->prophesize(DelegateInterface::class);
/** @var MethodProphecy $process */ /** @var MethodProphecy $process */
$process = $delegate->process(Argument::any())->willReturn(new Response()); $process = $delegate->process(Argument::any())->willReturn(new Response());
@ -71,4 +85,26 @@ class RedirectActionTest extends TestCase
$process->shouldHaveBeenCalledTimes(1); $process->shouldHaveBeenCalledTimes(1);
} }
/**
* @test
*/
public function visitIsNotTrackedIfDisableParamIsProvided()
{
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl)
->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->willReturn(null)
->shouldNotBeCalled();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode)
->withQueryParams(['foobar' => true]);
$response = $this->action->process($request, TestUtils::createDelegateMock()->reveal());
$this->assertInstanceOf(Response\RedirectResponse::class, $response);
$this->assertEquals(302, $response->getStatusCode());
$this->assertTrue($response->hasHeader('Location'));
$this->assertEquals($expectedUrl, $response->getHeaderLine('Location'));
}
} }

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Model;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
class ShortUrlMetaTest extends TestCase
{
/**
* @param array $data
* @test
* @dataProvider provideInvalidData
*/
public function exceptionIsThrownIfProvidedDataIsInvalid(array $data)
{
$this->expectException(ValidationException::class);
ShortUrlMeta::createFromRawData($data);
}
public function provideInvalidData(): array
{
return [
[[
ShortUrlMetaInputFilter::VALID_SINCE => '',
ShortUrlMetaInputFilter::VALID_UNTIL => '',
ShortUrlMetaInputFilter::CUSTOM_SLUG => 'foobar',
ShortUrlMetaInputFilter::MAX_VISITS => 'invalid',
]],
[[
ShortUrlMetaInputFilter::VALID_SINCE => '2017',
ShortUrlMetaInputFilter::MAX_VISITS => 5,
]],
];
}
/**
* @test
*/
public function properlyCreatedInstanceReturnsValues()
{
$meta = ShortUrlMeta::createFromParams((new \DateTime('2015-01-01'))->format(\DateTime::ATOM), null, 'foobar');
$this->assertTrue($meta->hasValidSince());
$this->assertEquals(new \DateTime('2015-01-01'), $meta->getValidSince());
$this->assertFalse($meta->hasValidUntil());
$this->assertNull($meta->getValidUntil());
$this->assertTrue($meta->hasCustomSlug());
$this->assertEquals('foobar', $meta->getCustomSlug());
$this->assertFalse($meta->hasMaxVisits());
$this->assertNull($meta->getMaxVisits());
}
}

View file

@ -10,6 +10,8 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Core\Service\ShortUrlService;
@ -55,7 +57,6 @@ class ShortUrlServiceTest extends TestCase
/** /**
* @test * @test
* @expectedException \Shlinkio\Shlink\Core\Exception\InvalidShortCodeException
*/ */
public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode() public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode()
{ {
@ -65,6 +66,7 @@ class ShortUrlServiceTest extends TestCase
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(InvalidShortCodeException::class);
$this->service->setTagsByShortCode($shortCode); $this->service->setTagsByShortCode($shortCode);
} }
@ -88,4 +90,32 @@ class ShortUrlServiceTest extends TestCase
$this->service->setTagsByShortCode($shortCode, ['foo', 'bar']); $this->service->setTagsByShortCode($shortCode, ['foo', 'bar']);
} }
/**
* @test
*/
public function updateMetadataByShortCodeUpdatesProvidedData()
{
$shortUrl = new ShortUrl();
$repo = $this->prophesize(ShortUrlRepository::class);
$findShortUrl = $repo->findOneBy(['shortCode' => 'abc123'])->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$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),
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(5, $shortUrl->getMaxVisits());
$findShortUrl->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
}
} }

View file

@ -99,7 +99,7 @@ class UrlShortenerTest extends TestCase
/** /**
* @test * @test
* @expectedException \Shlinkio\Shlink\Common\Exception\RuntimeException * @expectedException \Shlinkio\Shlink\Core\Exception\RuntimeException
*/ */
public function exceptionIsThrownWhenOrmThrowsException() public function exceptionIsThrownWhenOrmThrowsException()
{ {

View file

@ -21,6 +21,7 @@ return [
Action\AuthenticateAction::class => ConfigAbstractFactory::class, Action\AuthenticateAction::class => ConfigAbstractFactory::class,
Action\CreateShortcodeAction::class => ConfigAbstractFactory::class, Action\CreateShortcodeAction::class => ConfigAbstractFactory::class,
Action\EditShortCodeAction::class => ConfigAbstractFactory::class,
Action\ResolveUrlAction::class => ConfigAbstractFactory::class, Action\ResolveUrlAction::class => ConfigAbstractFactory::class,
Action\GetVisitsAction::class => ConfigAbstractFactory::class, Action\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\ListShortcodesAction::class => ConfigAbstractFactory::class, Action\ListShortcodesAction::class => ConfigAbstractFactory::class,
@ -48,6 +49,7 @@ return [
'config.url_shortener.domain', 'config.url_shortener.domain',
'Logger_Shlink', 'Logger_Shlink',
], ],
Action\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink',],
Action\ResolveUrlAction::class => [Service\UrlShortener::class, 'translator'], Action\ResolveUrlAction::class => [Service\UrlShortener::class, 'translator'],
Action\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'], Action\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'],
Action\ListShortcodesAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'], Action\ListShortcodesAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'],

View file

@ -9,7 +9,7 @@ return [
'routes' => [ 'routes' => [
[ [
'name' => Action\AuthenticateAction::class, 'name' => Action\AuthenticateAction::class,
'path' => '/rest/v{version:1}/authenticate', 'path' => '/authenticate',
'middleware' => Action\AuthenticateAction::class, 'middleware' => Action\AuthenticateAction::class,
'allowed_methods' => [RequestMethod::METHOD_POST], 'allowed_methods' => [RequestMethod::METHOD_POST],
], ],
@ -17,25 +17,31 @@ return [
// Short codes // Short codes
[ [
'name' => Action\CreateShortcodeAction::class, 'name' => Action\CreateShortcodeAction::class,
'path' => '/rest/v{version:1}/short-codes', 'path' => '/short-codes',
'middleware' => Action\CreateShortcodeAction::class, 'middleware' => Action\CreateShortcodeAction::class,
'allowed_methods' => [RequestMethod::METHOD_POST], 'allowed_methods' => [RequestMethod::METHOD_POST],
], ],
[
'name' => Action\EditShortCodeAction::class,
'path' => '/short-codes/{shortCode}',
'middleware' => Action\EditShortCodeAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT],
],
[ [
'name' => Action\ResolveUrlAction::class, 'name' => Action\ResolveUrlAction::class,
'path' => '/rest/v{version:1}/short-codes/{shortCode}', 'path' => '/short-codes/{shortCode}',
'middleware' => Action\ResolveUrlAction::class, 'middleware' => Action\ResolveUrlAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET], 'allowed_methods' => [RequestMethod::METHOD_GET],
], ],
[ [
'name' => Action\ListShortcodesAction::class, 'name' => Action\ListShortcodesAction::class,
'path' => '/rest/v{version:1}/short-codes', 'path' => '/short-codes',
'middleware' => Action\ListShortcodesAction::class, 'middleware' => Action\ListShortcodesAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET], 'allowed_methods' => [RequestMethod::METHOD_GET],
], ],
[ [
'name' => Action\EditShortcodeTagsAction::class, 'name' => Action\EditShortcodeTagsAction::class,
'path' => '/rest/v{version:1}/short-codes/{shortCode}/tags', 'path' => '/short-codes/{shortCode}/tags',
'middleware' => Action\EditShortcodeTagsAction::class, 'middleware' => Action\EditShortcodeTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT], 'allowed_methods' => [RequestMethod::METHOD_PUT],
], ],
@ -43,7 +49,7 @@ return [
// Visits // Visits
[ [
'name' => Action\GetVisitsAction::class, 'name' => Action\GetVisitsAction::class,
'path' => '/rest/v{version:1}/short-codes/{shortCode}/visits', 'path' => '/short-codes/{shortCode}/visits',
'middleware' => Action\GetVisitsAction::class, 'middleware' => Action\GetVisitsAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET], 'allowed_methods' => [RequestMethod::METHOD_GET],
], ],
@ -51,25 +57,25 @@ return [
// Tags // Tags
[ [
'name' => Action\Tag\ListTagsAction::class, 'name' => Action\Tag\ListTagsAction::class,
'path' => '/rest/v{version:1}/tags', 'path' => '/tags',
'middleware' => Action\Tag\ListTagsAction::class, 'middleware' => Action\Tag\ListTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET], 'allowed_methods' => [RequestMethod::METHOD_GET],
], ],
[ [
'name' => Action\Tag\DeleteTagsAction::class, 'name' => Action\Tag\DeleteTagsAction::class,
'path' => '/rest/v{version:1}/tags', 'path' => '/tags',
'middleware' => Action\Tag\DeleteTagsAction::class, 'middleware' => Action\Tag\DeleteTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_DELETE], 'allowed_methods' => [RequestMethod::METHOD_DELETE],
], ],
[ [
'name' => Action\Tag\CreateTagsAction::class, 'name' => Action\Tag\CreateTagsAction::class,
'path' => '/rest/v{version:1}/tags', 'path' => '/tags',
'middleware' => Action\Tag\CreateTagsAction::class, 'middleware' => Action\Tag\CreateTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_POST], 'allowed_methods' => [RequestMethod::METHOD_POST],
], ],
[ [
'name' => Action\Tag\UpdateTagAction::class, 'name' => Action\Tag\UpdateTagAction::class,
'path' => '/rest/v{version:1}/tags', 'path' => '/tags',
'middleware' => Action\Tag\UpdateTagAction::class, 'middleware' => Action\Tag\UpdateTagAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT], 'allowed_methods' => [RequestMethod::METHOD_PUT],
], ],

Binary file not shown.

View file

@ -1,15 +1,15 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Shlink 1.0\n" "Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2017-10-21 20:20+0200\n" "POT-Creation-Date: 2018-01-21 09:40+0100\n"
"PO-Revision-Date: 2017-10-21 20:20+0200\n" "PO-Revision-Date: 2018-01-21 09:40+0100\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n" "Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n" "Language-Team: \n"
"Language: es_ES\n" "Language: es_ES\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.1\n" "X-Generator: Poedit 2.0.4\n"
"X-Poedit-Basepath: ..\n" "X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n" "X-Poedit-SourceCharset: UTF-8\n"
@ -39,13 +39,16 @@ msgstr "El slug proporcionado \"%s\" ya está en uso. Prueba con uno diferente."
msgid "Unexpected error occurred" msgid "Unexpected error occurred"
msgstr "Ocurrió un error inesperado" msgstr "Ocurrió un error inesperado"
msgid "A list of tags was not provided"
msgstr "No se ha proporcionado una lista de etiquetas"
#, php-format #, php-format
msgid "No URL found for short code \"%s\"" msgid "No URL found for short code \"%s\""
msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
msgid "Provided data is invalid."
msgstr "Los datos proporcionados son inválidos."
msgid "A list of tags was not provided"
msgstr "No se ha proporcionado una lista de etiquetas"
#, php-format #, php-format
msgid "Provided short code %s does not exist" msgid "Provided short code %s does not exist"
msgstr "El código corto \"%s\" proporcionado no existe" msgstr "El código corto \"%s\" proporcionado no existe"

View file

@ -50,7 +50,7 @@ class CreateShortcodeAction extends AbstractRestAction
*/ */
public function process(Request $request, DelegateInterface $delegate) public function process(Request $request, DelegateInterface $delegate)
{ {
$postData = $request->getParsedBody(); $postData = (array) $request->getParsedBody();
if (! isset($postData['longUrl'])) { if (! isset($postData['longUrl'])) {
return new JsonResponse([ return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR, 'error' => RestUtils::INVALID_ARGUMENT_ERROR,

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class EditShortCodeAction extends AbstractRestAction
{
/**
* @var ShortUrlServiceInterface
*/
private $shortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(
ShortUrlServiceInterface $shortUrlService,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->shortUrlService = $shortUrlService;
$this->translator = $translator;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
* @param DelegateInterface $delegate
*
* @return ResponseInterface
* @throws \InvalidArgumentException
*/
public function process(ServerRequestInterface $request, DelegateInterface $delegate): ResponseInterface
{
$postData = (array) $request->getParsedBody();
$shortCode = $request->getAttribute('shortCode', '');
try {
$this->shortUrlService->updateMetadataByShortCode(
$shortCode,
ShortUrlMeta::createFromRawData($postData)
);
return new EmptyResponse();
} catch (Exception\InvalidShortCodeException $e) {
$this->logger->warning('Provided data is invalid.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => \sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode),
], self::STATUS_NOT_FOUND);
} catch (Exception\ValidationException $e) {
$this->logger->warning('Provided data is invalid.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => $this->translator->translate('Provided data is invalid.'),
], self::STATUS_BAD_REQUEST);
}
}
}

View file

@ -75,10 +75,10 @@ class GetVisitsAction extends AbstractRestAction
/** /**
* @param Request $request * @param Request $request
* @param $key * @param string $key
* @return \DateTime|null * @return \DateTime|null
*/ */
protected function getDateQueryParam(Request $request, $key) private function getDateQueryParam(Request $request, string $key)
{ {
$query = $request->getQueryParams(); $query = $request->getQueryParams();
if (! isset($query[$key]) || empty($query[$key])) { if (! isset($query[$key]) || empty($query[$key])) {

View file

@ -27,7 +27,7 @@ class JWTService implements JWTServiceInterface
* @param int $lifetime * @param int $lifetime
* @return string * @return string
*/ */
public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME) public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME): string
{ {
$currentTimestamp = time(); $currentTimestamp = time();
@ -48,7 +48,7 @@ class JWTService implements JWTServiceInterface
* @return string * @return string
* @throws AuthenticationException If the token has expired * @throws AuthenticationException If the token has expired
*/ */
public function refresh($jwt, $lifetime = self::DEFAULT_LIFETIME) public function refresh(string $jwt, $lifetime = self::DEFAULT_LIFETIME): string
{ {
$payload = $this->getPayload($jwt); $payload = $this->getPayload($jwt);
$payload['exp'] = time() + $lifetime; $payload['exp'] = time() + $lifetime;
@ -61,7 +61,7 @@ class JWTService implements JWTServiceInterface
* @param string $jwt * @param string $jwt
* @return bool * @return bool
*/ */
public function verify($jwt) public function verify(string $jwt): bool
{ {
try { try {
// If no exception is thrown while decoding the token, it is considered valid // If no exception is thrown while decoding the token, it is considered valid
@ -79,7 +79,7 @@ class JWTService implements JWTServiceInterface
* @return array * @return array
* @throws AuthenticationException If the token has expired * @throws AuthenticationException If the token has expired
*/ */
public function getPayload($jwt) public function getPayload(string $jwt): array
{ {
try { try {
return $this->decode($jwt); return $this->decode($jwt);
@ -92,16 +92,16 @@ class JWTService implements JWTServiceInterface
* @param array $data * @param array $data
* @return string * @return string
*/ */
protected function encode(array $data) protected function encode(array $data): string
{ {
return JWT::encode($data, $this->appOptions->getSecretKey(), self::DEFAULT_ENCRYPTION_ALG); return JWT::encode($data, $this->appOptions->getSecretKey(), self::DEFAULT_ENCRYPTION_ALG);
} }
/** /**
* @param $jwt * @param string $jwt
* @return array * @return array
*/ */
protected function decode($jwt) protected function decode(string $jwt): array
{ {
return (array) JWT::decode($jwt, $this->appOptions->getSecretKey(), [self::DEFAULT_ENCRYPTION_ALG]); return (array) JWT::decode($jwt, $this->appOptions->getSecretKey(), [self::DEFAULT_ENCRYPTION_ALG]);
} }

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