Merge pull request #224 from acelaya/feature/config-params

Feature/config params
This commit is contained in:
Alejandro Celaya 2018-10-06 11:25:56 +02:00 committed by GitHub
commit d68dc38959
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 525 additions and 237 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,11 @@ use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use function array_column;
use function array_key_exists;
use function is_array;
use function key;
use function Shlinkio\Shlink\Common\contains;
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
{
@ -55,19 +60,19 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
'shortCode' => 'shortCode',
'dateCreated' => 'dateCreated',
];
$fieldName = \is_array($orderBy) ? \key($orderBy) : $orderBy;
$order = \is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
if (\in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
if (contains($fieldName, ['visits', 'visitsCount', 'visitCount'])) {
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
->orderBy('totalVisits', $order);
return \array_column($qb->getQuery()->getResult(), 0);
return array_column($qb->getQuery()->getResult(), 0);
}
if (\array_key_exists($fieldName, $fieldNameMap)) {
if (array_key_exists($fieldName, $fieldNameMap)) {
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
}
return $qb->getQuery()->getResult();

View File

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

View File

@ -8,6 +8,7 @@ use Psr\Container\NotFoundExceptionInterface;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
@ -23,6 +24,8 @@ use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command
{
use AskUtilsTrait;
public const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
/**
@ -143,7 +146,7 @@ class InstallCommand extends Command
$this->io->writeln(['<info>Custom configuration properly generated!</info>', '']);
// If current command is not update, generate database
if (! $this->isUpdate) {
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
@ -186,7 +189,10 @@ class InstallCommand extends Command
$config = new CustomizableAppConfig();
// Ask the user if he/she wants to import an older configuration
$importConfig = $this->io->confirm('Do you want to import configuration from previous installation?');
$importConfig = $this->io->confirm(
'Do you want to import configuration from previous installation? (You will still be asked for any new '
. 'config option that did not exist in previous shlink versions)'
);
if (! $importConfig) {
return $config;
}
@ -194,7 +200,9 @@ class InstallCommand extends Command
// Ask the user for the older shlink path
$keepAsking = true;
do {
$config->setImportedInstallationPath($this->io->ask(
$config->setImportedInstallationPath($this->askRequired(
$this->io,
'previous installation path',
'Previous shlink installation path from which to import config'
));
$configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH;

View File

@ -6,28 +6,51 @@ namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_diff;
use function array_keys;
class ApplicationConfigCustomizer implements ConfigCustomizerInterface
{
use StringUtilsTrait;
public const SECRET = 'SECRET';
public const DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
private const EXPECTED_KEYS = [
self::SECRET,
self::DISABLE_TRACK_PARAM,
];
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$io->title('APPLICATION');
$app = $appConfig->getApp();
$keysToAskFor = $appConfig->hasApp() ? array_diff(self::EXPECTED_KEYS, array_keys($app)) : self::EXPECTED_KEYS;
if ($appConfig->hasApp() && $io->confirm('Do you want to keep imported application config?')) {
if (empty($keysToAskFor)) {
return;
}
$appConfig->setApp([
'SECRET' => $io->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one) '
. '<fg=red>[DEPRECATED. TO BE REMOVED]</>'
) ?: $this->generateRandomString(32),
'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)'
),
]);
$io->title('APPLICATION');
foreach ($keysToAskFor as $key) {
$app[$key] = $this->ask($io, $key);
}
$appConfig->setApp($app);
}
private function ask(SymfonyStyle $io, string $key)
{
switch ($key) {
case self::SECRET:
return $io->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one) '
. '<fg=red>[DEPRECATED. TO BE REMOVED]</>'
) ?: $this->generateRandomString(32);
case self::DISABLE_TRACK_PARAM:
return $io->ask(
'Provide a parameter name that you will be able to use to disable tracking on specific request to '
. 'short URLs (leave empty and this feature won\'t be enabled)'
);
}
return '';
}
}

View File

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

View File

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

View File

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

View File

@ -3,7 +3,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Model;
use Shlinkio\Shlink\Installer\Config\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\UrlShortenerConfigCustomizer;
use Zend\Stdlib\ArraySerializableInterface;
use function Shlinkio\Shlink\Common\array_get_path;
use function Shlinkio\Shlink\Common\array_path_exists;
final class CustomizableAppConfig implements ArraySerializableInterface
{
@ -112,51 +118,52 @@ final class CustomizableAppConfig implements ArraySerializableInterface
public function exchangeArray(array $array): void
{
$this->setApp([
'SECRET' => $array['app_options']['secret_key'] ?? null,
]);
$this->setApp($this->mapExistingPathsToKeys([
ApplicationConfigCustomizer::SECRET => ['app_options', 'secret_key'],
ApplicationConfigCustomizer::DISABLE_TRACK_PARAM => ['app_options', 'disable_track_param'],
], $array));
$this->deserializeDatabase($array['entity_manager']['connection'] ?? []);
$this->setDatabase($this->mapExistingPathsToKeys([
DatabaseConfigCustomizer::DRIVER => ['entity_manager', 'connection', 'driver'],
DatabaseConfigCustomizer::USER => ['entity_manager', 'connection', 'user'],
DatabaseConfigCustomizer::PASSWORD => ['entity_manager', 'connection', 'password'],
DatabaseConfigCustomizer::NAME => ['entity_manager', 'connection', 'dbname'],
DatabaseConfigCustomizer::HOST => ['entity_manager', 'connection', 'host'],
DatabaseConfigCustomizer::PORT => ['entity_manager', 'connection', 'port'],
], $array));
$this->setLanguage([
'DEFAULT' => $array['translator']['locale'] ?? null,
'CLI' => $array['cli']['locale'] ?? null,
]);
$this->setLanguage($this->mapExistingPathsToKeys([
LanguageConfigCustomizer::DEFAULT_LANG => ['translator', 'locale'],
LanguageConfigCustomizer::CLI_LANG => ['cli', 'locale'],
], $array));
$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,
]);
$this->setUrlShortener($this->mapExistingPathsToKeys([
UrlShortenerConfigCustomizer::SCHEMA => ['url_shortener', 'domain', 'schema'],
UrlShortenerConfigCustomizer::HOSTNAME => ['url_shortener', 'domain', 'hostname'],
UrlShortenerConfigCustomizer::CHARS => ['url_shortener', 'shortcode_chars'],
UrlShortenerConfigCustomizer::VALIDATE_URL => ['url_shortener', 'validate_url'],
], $array));
}
private function deserializeDatabase(array $conn): void
private function mapExistingPathsToKeys(array $map, array $config): array
{
if (! isset($conn['driver'])) {
return;
}
$driver = $conn['driver'];
$params = ['DRIVER' => $driver];
if ($driver !== 'pdo_sqlite') {
$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;
$result = [];
foreach ($map as $key => $path) {
if (array_path_exists($path, $config)) {
$result[$key] = array_get_path($path, $config);
}
}
$this->setDatabase($params);
return $result;
}
public function getArrayCopy(): array
{
$dbDriver = $this->database['DRIVER'] ?? '';
$dbDriver = $this->database[DatabaseConfigCustomizer::DRIVER] ?? '';
$config = [
'app_options' => [
'secret_key' => $this->app['SECRET'] ?? '',
'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'] ?? null,
'secret_key' => $this->app[ApplicationConfigCustomizer::SECRET] ?? '',
'disable_track_param' => $this->app[ApplicationConfigCustomizer::DISABLE_TRACK_PARAM] ?? null,
],
'entity_manager' => [
'connection' => [
@ -164,18 +171,18 @@ final class CustomizableAppConfig implements ArraySerializableInterface
],
],
'translator' => [
'locale' => $this->language['DEFAULT'] ?? 'en',
'locale' => $this->language[LanguageConfigCustomizer::DEFAULT_LANG] ?? 'en',
],
'cli' => [
'locale' => $this->language['CLI'] ?? 'en',
'locale' => $this->language[LanguageConfigCustomizer::CLI_LANG] ?? 'en',
],
'url_shortener' => [
'domain' => [
'schema' => $this->urlShortener['SCHEMA'] ?? 'http',
'hostname' => $this->urlShortener['HOSTNAME'] ?? '',
'schema' => $this->urlShortener[UrlShortenerConfigCustomizer::SCHEMA] ?? 'http',
'hostname' => $this->urlShortener[UrlShortenerConfigCustomizer::HOSTNAME] ?? '',
],
'shortcode_chars' => $this->urlShortener['CHARS'] ?? '',
'validate_url' => $this->urlShortener['VALIDATE_URL'] ?? true,
'shortcode_chars' => $this->urlShortener[UrlShortenerConfigCustomizer::CHARS] ?? '',
'validate_url' => $this->urlShortener[UrlShortenerConfigCustomizer::VALIDATE_URL] ?? true,
],
];
@ -183,11 +190,12 @@ final class CustomizableAppConfig implements ArraySerializableInterface
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[DatabaseConfigCustomizer::USER] ?? '';
$config['entity_manager']['connection']['password'] =
$this->database[DatabaseConfigCustomizer::PASSWORD] ?? '';
$config['entity_manager']['connection']['dbname'] = $this->database[DatabaseConfigCustomizer::NAME] ?? '';
$config['entity_manager']['connection']['host'] = $this->database[DatabaseConfigCustomizer::HOST] ?? '';
$config['entity_manager']['connection']['port'] = $this->database[DatabaseConfigCustomizer::PORT] ?? '';
if ($dbDriver === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [

View File

@ -17,6 +17,8 @@ trait AskUtilsTrait
if (empty($value)) {
throw MissingRequiredOptionException::fromOption($optionName);
};
return $value;
});
}
}

View File

@ -50,10 +50,9 @@ class ApplicationConfigCustomizerTest extends TestCase
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
public function onlyMissingOptionsAreAsked()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('the_new_secret');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$ask = $this->io->ask(Argument::cetera())->willReturn('disable_param');
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
@ -62,30 +61,31 @@ class ApplicationConfigCustomizerTest extends TestCase
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'the_new_secret',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'disable_param',
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$ask = $this->io->ask(Argument::cetera())->willReturn('the_new_secret');
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
], $config->getApp());
$confirm->shouldHaveBeenCalledTimes(1);
$ask->shouldNotHaveBeenCalled();
}
}

View File

@ -62,64 +62,62 @@ class DatabaseConfigCustomizerTest extends TestCase
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$ask = $this->io->ask(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'NAME' => 'foo',
'PASSWORD' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_mysql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'DRIVER' => 'pdo_pgsql',
'NAME' => 'foo',
'USER' => 'asked',
'PASSWORD' => 'foo',
'HOST' => 'asked',
'PORT' => 'asked',
], $config->getDatabase());
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledTimes(5);
$choice->shouldNotHaveBeenCalled();
$ask->shouldHaveBeenCalledTimes(3);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'NAME' => 'foo',
'USER' => 'foo',
'PASSWORD' => 'foo',
'HOST' => 'foo',
'PORT' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'MySQL',
'USER' => 'MySQL',
'PASSWORD' => 'MySQL',
'HOST' => 'MySQL',
'PORT' => 'MySQL',
'NAME' => 'foo',
'USER' => 'foo',
'PASSWORD' => 'foo',
'HOST' => 'foo',
'PORT' => 'foo',
], $config->getDatabase());
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldNotHaveBeenCalled();
$ask->shouldNotHaveBeenCalled();
}
/**
@ -127,7 +125,6 @@ class DatabaseConfigCustomizerTest extends TestCase
*/
public function sqliteDatabaseIsImportedWhenRequested()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$copy = $this->filesystem->copy(Argument::cetera())->willReturn(null);
$config = new CustomizableAppConfig();
@ -140,7 +137,6 @@ class DatabaseConfigCustomizerTest extends TestCase
$this->assertEquals([
'DRIVER' => 'pdo_sqlite',
], $config->getDatabase());
$confirm->shouldHaveBeenCalledTimes(1);
$copy->shouldHaveBeenCalledTimes(1);
}
}

View File

@ -33,7 +33,7 @@ class LanguageConfigCustomizerTest extends TestCase
*/
public function configIsRequestedToTheUser()
{
$ask = $this->io->choice(Argument::cetera())->willReturn('en');
$choice = $this->io->choice(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
@ -43,38 +43,35 @@ class LanguageConfigCustomizerTest extends TestCase
'DEFAULT' => 'en',
'CLI' => 'en',
], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('es');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setLanguage([
'DEFAULT' => 'en',
'CLI' => 'en',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DEFAULT' => 'es',
'DEFAULT' => 'en',
'CLI' => 'es',
], $config->getLanguage());
$choice->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$ask = $this->io->confirm(Argument::cetera())->willReturn(true);
$choice = $this->io->choice(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$config->setLanguage([
@ -88,6 +85,6 @@ class LanguageConfigCustomizerTest extends TestCase
'DEFAULT' => 'es',
'CLI' => 'es',
], $config->getLanguage());
$ask->shouldHaveBeenCalledTimes(1);
$choice->shouldNotHaveBeenCalled();
}
}

View File

@ -33,8 +33,8 @@ class UrlShortenerConfigCustomizerTest extends TestCase
*/
public function configIsRequestedToTheUser()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('something');
$ask = $this->io->ask(Argument::cetera())->willReturn('something');
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
@ -42,9 +42,9 @@ class UrlShortenerConfigCustomizerTest extends TestCase
$this->assertTrue($config->hasUrlShortener());
$this->assertEquals([
'SCHEMA' => 'something',
'HOSTNAME' => 'something',
'CHARS' => 'something',
'SCHEMA' => 'chosen',
'HOSTNAME' => 'asked',
'CHARS' => 'asked',
'VALIDATE_URL' => true,
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(2);
@ -55,16 +55,44 @@ class UrlShortenerConfigCustomizerTest extends TestCase
/**
* @test
*/
public function overwriteIsRequestedIfValueIsAlreadySet()
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('foo');
$ask = $this->io->ask(Argument::cetera())->willReturn('foo');
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'bar',
'HOSTNAME' => 'bar',
'CHARS' => 'bar',
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'asked',
'VALIDATE_URL' => false,
], $config->getUrlShortener());
$choice->shouldNotHaveBeenCalled();
$ask->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => true,
]);
@ -74,36 +102,10 @@ class UrlShortenerConfigCustomizerTest extends TestCase
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => false,
'VALIDATE_URL' => true,
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function existingValueIsKeptIfRequested()
{
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => 'foo',
], $config->getUrlShortener());
$confirm->shouldHaveBeenCalledTimes(1);
$choice->shouldNotHaveBeenCalled();
$ask->shouldNotHaveBeenCalled();
$confirm->shouldNotHaveBeenCalled();
}
}

View File

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

View File

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

View File

@ -21,7 +21,7 @@ use Zend\Diactoros\Response\JsonResponse;
use Zend\Expressive\Router\RouteResult;
use Zend\I18n\Translator\TranslatorInterface;
use function implode;
use function in_array;
use function Shlinkio\Shlink\Common\contains;
use function sprintf;
class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterface, RequestMethodInterface
@ -72,7 +72,7 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa
if ($routeResult === null
|| $routeResult->isFailure()
|| $request->getMethod() === self::METHOD_OPTIONS
|| in_array($routeResult->getMatchedRouteName(), $this->routesWhitelist, true)
|| contains($routeResult->getMatchedRouteName(), $this->routesWhitelist)
) {
return $handler->handle($request);
}

View File

@ -8,7 +8,12 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Exception\RuntimeException;
use function array_shift;
use function explode;
use function parse_str;
use function Shlinkio\Shlink\Common\contains;
use function Shlinkio\Shlink\Common\json_decode;
use function trim;
class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterface
{
@ -27,17 +32,17 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
$currentParams = $request->getParsedBody();
// In requests that do not allow body or if the body has already been parsed, continue to next middleware
if (! empty($currentParams) || \in_array($method, [
if (! empty($currentParams) || contains($method, [
self::METHOD_GET,
self::METHOD_HEAD,
self::METHOD_OPTIONS,
], true)) {
])) {
return $handler->handle($request);
}
// If the accepted content is JSON, try to parse the body from JSON
$contentType = $this->getRequestContentType($request);
if (\in_array($contentType, ['application/json', 'text/json', 'application/x-json'], true)) {
if (contains($contentType, ['application/json', 'text/json', 'application/x-json'])) {
return $handler->handle($this->parseFromJson($request));
}
@ -51,14 +56,13 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
private function getRequestContentType(Request $request): string
{
$contentType = $request->getHeaderLine('Content-type');
$contentTypes = \explode(';', $contentType);
return \trim(\array_shift($contentTypes));
$contentTypes = explode(';', $contentType);
return trim(array_shift($contentTypes));
}
/**
* @param Request $request
* @return Request
* @throws RuntimeException
*/
private function parseFromJson(Request $request): Request
{
@ -67,11 +71,7 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
return $request;
}
$parsedJson = \json_decode($rawBody, true);
if (\json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException(\sprintf('Error when parsing JSON request body: %s', \json_last_error_msg()));
}
$parsedJson = json_decode($rawBody);
return $request->withParsedBody($parsedJson);
}

View File

@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use Zend\Diactoros\ServerRequestFactory;
use function Shlinkio\Shlink\Common\json_decode;
class ListTagsActionTest extends TestCase
{
@ -42,7 +43,7 @@ class ListTagsActionTest extends TestCase
'tags' => [
'data' => ['foo', 'bar'],
],
], \json_decode((string) $resp->getBody(), true));
], json_decode((string) $resp->getBody()));
$listTags->shouldHaveBeenCalled();
}
}

View File

@ -24,10 +24,7 @@
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./module/Common/src</directory>
<directory suffix=".php">./module/Core/src</directory>
<directory suffix=".php">./module/Rest/src</directory>
<directory suffix=".php">./module/CLI/src</directory>
<directory suffix=".php">./module/*/src</directory>
<exclude>
<directory suffix=".php">./module/Core/src/Repository</directory>