Merge pull request #222 from acelaya/feature/required-installation-config

Feature/required installation config
This commit is contained in:
Alejandro Celaya 2018-09-30 14:10:02 +02:00 committed by GitHub
commit 3a75ac0486
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 260 additions and 209 deletions

View File

@ -1,5 +1,39 @@
# CHANGELOG
## Unreleased
#### Added
* [#197](https://github.com/shlinkio/shlink/issues/197) Added [cakephp/chronos](https://book.cakephp.org/3.0/en/chronos.html) library for date manipulations.
* [#214](https://github.com/shlinkio/shlink/issues/214) Improved build script, which allows builds to be done without "jumping" outside the project directory, and generates smaller dist files.
It also allows automating the dist file generation in travis-ci builds.
#### Changed
* [#211](https://github.com/shlinkio/shlink/issues/211) Extracted installer to its own module, which will simplify moving it to a separated package in the future.
* [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) Renamed REST Action classes and CLI Command classes to use the concept of `ShortUrl` instead of the concept of `ShortCode` when referring to the entity, and left the `short code` concept to the identifier which is used as a unique code for a specific `Short URL`.
#### Deprecated
* [#205](https://github.com/shlinkio/shlink/issues/205) Deprecated `[POST /authenticate]` endpoint, and allowed any API request to be automatically authenticated using the `X-Api-Key` header with a valid API key.
This effectively deprecates the `Authorization: Bearer <JWT>` authentication form, but it will keep working.
* As of [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) REST urls have changed from `/short-codes/...` to `/short-urls/...`, and the command namespaces have changed from `short-code:...` to `short-url:...`.
In both cases, backwards compatibility has been retained and the old ones are aliases for the new ones, but the old ones are considered deprecated.
#### Removed
* *Nothing*
#### Fixed
* [#203](https://github.com/shlinkio/shlink/issues/203) Fixed some warnings thrown while unzipping distributable files.
* [#206](https://github.com/shlinkio/shlink/issues/206) An error is now thrown during installation if any required param is left empty, making the installer display a message and ask again until a value is set.
## 1.12.0 - 2018-09-15
#### Added

View File

@ -27,7 +27,7 @@
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"symfony/console": "^4.0",
"symfony/console": "^4.0 <4.1.5",
"symfony/filesystem": "^4.0",
"symfony/process": "^4.0",
"theorchard/monolog-cascade": "^0.4",
@ -89,30 +89,44 @@
"@cs",
"@stan",
"@test",
"@func-test",
"@infect"
],
"cs": "phpcs",
"cs-fix": "phpcbf",
"serve": "php -S 0.0.0.0:8000 -t public/",
"test": "phpunit --coverage-php build/coverage-unit.cov",
"pretty-test": "phpunit --coverage-html build/coverage",
"func-test": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov",
"complete-pretty-test": [
"@test",
"@func-test",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"test": [
"@test:unit",
"@test:func"
],
"test:unit": "phpunit --coverage-php build/coverage-unit.cov",
"test:func": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov",
"test:pretty": [
"@test:unit",
"@test:func",
"phpcov merge build --html build/html"
],
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"test:unit:pretty": "phpunit --coverage-html build/coverage",
"infect": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2",
"infect-show": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2 --show-mutations",
"expressive": "expressive"
"infect:show": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2 --show-mutations"
},
"scripts-descriptions": {
"check": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test\" and \"infect\"</>",
"cs": "<fg=blue;options=bold>Checks coding styles</>",
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
"test": "<fg=blue;options=bold>Runs all test suites</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:func": "<fg=blue;options=bold>Runs functional test suites (covering entity repositories)</>",
"test:pretty": "<fg=blue;options=bold>Runs all test suites and generates an HTML code coverage report</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>"
},
"config": {
"process-timeout": 0,
"sort-packages": true,
"platform": {
"php": "7.1.8"
}
"sort-packages": true
}
}

View File

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

View File

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

View File

@ -11,10 +11,11 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
class GenerateKeyCommand extends Command
{
const NAME = 'api-key:generate';
public const NAME = 'api-key:generate';
/**
* @var ApiKeyServiceInterface
@ -32,7 +33,7 @@ class GenerateKeyCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Generates a new valid API key.'))
@ -44,7 +45,7 @@ class GenerateKeyCommand extends Command
);
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);

View File

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

View File

@ -9,10 +9,12 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sprintf;
use function str_shuffle;
class GenerateCharsetCommand extends Command
{
const NAME = 'config:generate-charset';
public const NAME = 'config:generate-charset';
/**
* @var TranslatorInterface
@ -25,20 +27,20 @@ class GenerateCharsetCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$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". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable'
), UrlShortener::DEFAULT_CHARS));
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(
\sprintf($this->translator->translate('Character set: "%s"'), $charSet)
sprintf($this->translator->translate('Character set: "%s"'), $charSet)
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,20 +19,14 @@ class ApplicationConfigCustomizer implements ConfigCustomizerInterface
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
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one) '
. '<fg=red>[DEPRECATED. TO BE REMOVED]</>'
) ?: $this->generateRandomString(32),
'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
. 'short URLs (leave empty and this feature won\'t be enabled)'
),
]);
}

View File

@ -4,12 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizer implements ConfigCustomizerInterface
{
use AskUtilsTrait;
private const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
@ -59,8 +62,8 @@ class DatabaseConfigCustomizer implements ConfigCustomizerInterface
// 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['USER'] = $this->askRequired($io, 'username', 'Database username');
$params['PASSWORD'] = $this->askRequired($io, 'password', 'Database password');
$params['HOST'] = $io->ask('Database host', 'localhost');
$params['PORT'] = $io->ask('Database port', $this->getDefaultDbPort($params['DRIVER']));
}

View File

@ -19,12 +19,12 @@ class LanguageConfigCustomizer implements ConfigCustomizerInterface
}
$appConfig->setLanguage([
'DEFAULT' => $this->chooseLanguage('Select default language for the application in general', $io),
'CLI' => $this->chooseLanguage('Select default language for CLI executions', $io),
'DEFAULT' => $this->chooseLanguage($io, 'Select default language for the application in general'),
'CLI' => $this->chooseLanguage($io, 'Select default language for CLI executions'),
]);
}
private function chooseLanguage(string $message, SymfonyStyle $io): string
private function chooseLanguage(SymfonyStyle $io, string $message): string
{
return $io->choice($message, self::SUPPORTED_LANGUAGES, self::SUPPORTED_LANGUAGES[0]);
}

View File

@ -5,11 +5,14 @@ namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use function str_shuffle;
class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
{
use AskUtilsTrait;
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$io->title('URL SHORTENER');
@ -25,14 +28,9 @@ class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
['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),
'HOSTNAME' => $this->askRequired($io, 'hostname', 'Hostname for generated URLs'),
'CHARS' => $io->ask('Character set for generated short codes (leave empty to autogenerate one)')
?: 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

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

View File

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

View File

@ -12,191 +12,126 @@ final class CustomizableAppConfig implements ArraySerializableInterface
/**
* @var array
*/
private $database;
private $database = [];
/**
* @var array
*/
private $urlShortener;
private $urlShortener = [];
/**
* @var array
*/
private $language;
private $language = [];
/**
* @var array
*/
private $app;
private $app = [];
/**
* @var string
* @var string|null
*/
private $importedInstallationPath;
/**
* @return array
*/
public function getDatabase()
public function getDatabase(): array
{
return $this->database;
}
/**
* @param array $database
* @return $this
*/
public function setDatabase(array $database)
public function setDatabase(array $database): self
{
$this->database = $database;
return $this;
}
/**
* @return bool
*/
public function hasDatabase()
public function hasDatabase(): bool
{
return ! empty($this->database);
}
/**
* @return array
*/
public function getUrlShortener()
public function getUrlShortener(): array
{
return $this->urlShortener;
}
/**
* @param array $urlShortener
* @return $this
*/
public function setUrlShortener(array $urlShortener)
public function setUrlShortener(array $urlShortener): self
{
$this->urlShortener = $urlShortener;
return $this;
}
/**
* @return bool
*/
public function hasUrlShortener()
public function hasUrlShortener(): bool
{
return ! empty($this->urlShortener);
}
/**
* @return array
*/
public function getLanguage()
public function getLanguage(): array
{
return $this->language;
}
/**
* @param array $language
* @return $this
*/
public function setLanguage(array $language)
public function setLanguage(array $language): self
{
$this->language = $language;
return $this;
}
/**
* @return bool
*/
public function hasLanguage()
public function hasLanguage(): bool
{
return ! empty($this->language);
}
/**
* @return array
*/
public function getApp()
public function getApp(): array
{
return $this->app;
}
/**
* @param array $app
* @return $this
*/
public function setApp(array $app)
public function setApp(array $app): self
{
$this->app = $app;
return $this;
}
/**
* @return bool
*/
public function hasApp()
public function hasApp(): bool
{
return ! empty($this->app);
}
/**
* @return string
*/
public function getImportedInstallationPath()
public function getImportedInstallationPath(): ?string
{
return $this->importedInstallationPath;
}
/**
* @param string $importedInstallationPath
* @return $this|self
*/
public function setImportedInstallationPath($importedInstallationPath)
public function setImportedInstallationPath(string $importedInstallationPath): self
{
$this->importedInstallationPath = $importedInstallationPath;
return $this;
}
/**
* @return bool
*/
public function hasImportedInstallationPath()
public function hasImportedInstallationPath(): bool
{
return $this->importedInstallationPath !== null;
}
/**
* Exchange internal values from provided array
*
* @param array $array
* @return void
*/
public function exchangeArray(array $array)
public function exchangeArray(array $array): void
{
if (isset($array['app_options'], $array['app_options']['secret_key'])) {
$this->setApp([
'SECRET' => $array['app_options']['secret_key'],
]);
}
$this->setApp([
'SECRET' => $array['app_options']['secret_key'] ?? null,
]);
if (isset($array['entity_manager'], $array['entity_manager']['connection'])) {
$this->deserializeDatabase($array['entity_manager']['connection']);
}
$this->deserializeDatabase($array['entity_manager']['connection'] ?? []);
if (isset($array['translator'], $array['translator']['locale'], $array['cli'], $array['cli']['locale'])) {
$this->setLanguage([
'DEFAULT' => $array['translator']['locale'],
'CLI' => $array['cli']['locale'],
]);
}
$this->setLanguage([
'DEFAULT' => $array['translator']['locale'] ?? null,
'CLI' => $array['cli']['locale'] ?? null,
]);
if (isset($array['url_shortener'])) {
$urlShortener = $array['url_shortener'];
$this->setUrlShortener([
'SCHEMA' => $urlShortener['domain']['schema'],
'HOSTNAME' => $urlShortener['domain']['hostname'],
'CHARS' => $urlShortener['shortcode_chars'],
'VALIDATE_URL' => $urlShortener['validate_url'] ?? true,
]);
}
$this->setUrlShortener([
'SCHEMA' => $array['url_shortener']['domain']['schema'] ?? null,
'HOSTNAME' => $array['url_shortener']['domain']['hostname'] ?? null,
'CHARS' => $array['url_shortener']['shortcode_chars'] ?? null,
'VALIDATE_URL' => $array['url_shortener']['validate_url'] ?? true,
]);
}
private function deserializeDatabase(array $conn)
private function deserializeDatabase(array $conn): void
{
if (! isset($conn['driver'])) {
return;
@ -205,60 +140,56 @@ final class CustomizableAppConfig implements ArraySerializableInterface
$params = ['DRIVER' => $driver];
if ($driver !== 'pdo_sqlite') {
$params['USER'] = $conn['user'];
$params['PASSWORD'] = $conn['password'];
$params['NAME'] = $conn['dbname'];
$params['HOST'] = $conn['host'];
$params['PORT'] = $conn['port'];
$params['USER'] = $conn['user'] ?? null;
$params['PASSWORD'] = $conn['password'] ?? null;
$params['NAME'] = $conn['dbname'] ?? null;
$params['HOST'] = $conn['host'] ?? null;
$params['PORT'] = $conn['port'] ?? null;
}
$this->setDatabase($params);
}
/**
* Return an array representation of the object
*
* @return array
*/
public function getArrayCopy()
public function getArrayCopy(): array
{
$dbDriver = $this->database['DRIVER'] ?? '';
$config = [
'app_options' => [
'secret_key' => $this->app['SECRET'],
'secret_key' => $this->app['SECRET'] ?? '',
'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'] ?? null,
],
'entity_manager' => [
'connection' => [
'driver' => $this->database['DRIVER'],
'driver' => $dbDriver,
],
],
'translator' => [
'locale' => $this->language['DEFAULT'],
'locale' => $this->language['DEFAULT'] ?? 'en',
],
'cli' => [
'locale' => $this->language['CLI'],
'locale' => $this->language['CLI'] ?? 'en',
],
'url_shortener' => [
'domain' => [
'schema' => $this->urlShortener['SCHEMA'],
'hostname' => $this->urlShortener['HOSTNAME'],
'schema' => $this->urlShortener['SCHEMA'] ?? 'http',
'hostname' => $this->urlShortener['HOSTNAME'] ?? '',
],
'shortcode_chars' => $this->urlShortener['CHARS'],
'validate_url' => $this->urlShortener['VALIDATE_URL'],
'shortcode_chars' => $this->urlShortener['CHARS'] ?? '',
'validate_url' => $this->urlShortener['VALIDATE_URL'] ?? true,
],
];
// Build dynamic database config based on selected driver
if ($this->database['DRIVER'] === 'pdo_sqlite') {
if ($dbDriver === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH;
} else {
$config['entity_manager']['connection']['user'] = $this->database['USER'];
$config['entity_manager']['connection']['password'] = $this->database['PASSWORD'];
$config['entity_manager']['connection']['dbname'] = $this->database['NAME'];
$config['entity_manager']['connection']['host'] = $this->database['HOST'];
$config['entity_manager']['connection']['port'] = $this->database['PORT'];
$config['entity_manager']['connection']['user'] = $this->database['USER'] ?? '';
$config['entity_manager']['connection']['password'] = $this->database['PASSWORD'] ?? '';
$config['entity_manager']['connection']['dbname'] = $this->database['NAME'] ?? '';
$config['entity_manager']['connection']['host'] = $this->database['HOST'] ?? '';
$config['entity_manager']['connection']['port'] = $this->database['PORT'] ?? '';
if ($this->database['DRIVER'] === 'pdo_mysql') {
if ($dbDriver === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
];

View File

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

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\Exception\MissingRequiredOptionException;
class MissingRequiredOptionExceptionTest extends TestCase
{
/**
* @test
*/
public function fromOptionsGeneratesExpectedMessage()
{
$e = MissingRequiredOptionException::fromOption('foo');
$this->assertEquals('The "foo" is required and can\'t be empty', $e->getMessage());
}
}