diff options
author | Kasimir Cash <kasimir.cash@outlook.com> | 2024-02-28 12:23:28 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-02-28 13:23:28 +0100 |
commit | 4b29e666b06762b4b36438c9370c38bc43121f78 (patch) | |
tree | 2b92dcbc5879aa7de8eeec81ccde208f572b3cf0 /cli | |
parent | 5de794ee0fbbce2fdf0af3787b9b89299be8698e (diff) | |
download | freshrss-4b29e666b06762b4b36438c9370c38bc43121f78.tar.gz freshrss-4b29e666b06762b4b36438c9370c38bc43121f78.zip |
Command Line Parser Concept (#6099)
* Adds logic for validation
* Adds validation to do-install
* Adds help to do-install
* Adds validation & help to reconfigure
* Adds validation to check.translation
* Adds validation to manipulate.translation
* Small fixes to help texts
* Refactors language option validation
* Adds default options to validation
* Fixes validation with regex
* Refactors readAs functions
* Updates to new regex validation format
* Fixes typing around default values
* Adds file extension validation
* Restandardises validation & parsing typing around array of strings
* Adds NotOneOf validation
* Adds ArrayOfString read as
* Refactors existing validation
* Adds validation throughout cli
* Removes unused file
* Adds new CL parser with goal of wrapping CLI behaviour
* Hides parsing and validation
* Rewites CL parser to make better use of classes
* Rolls out new parser across CL
* Fixes error during unknown option check
* Fixes misnamed property calls
* Seperates validations into more appropriate locations
* Adds common boolean forms to validation
* Moves CommandLineParser and Option classes into their own files
* Fixes error when validating Int type
* Rewrites appendTypedValues -> appendTypedValidValues now filters invalid values from output
* Renames -> for clarity
* Adds some docs clarifying option defaults and value taking behaviour
* Refactors getUsageMessage for readability
* Minor formatting changes
* Adds tests for CommandLineParser
* Adds more tests
* Adds minor fixs
* Reconfigure now correctly updates config
* More fixes to reconfigure
* Fixes required files for CommandLineParserTest
* Use .php extension for PHP file
* PHPStan ignore instead of wrong typing
* Refactors to support php 7.4
* Moves away from dynamic properties by adding 'Definintions' to all commands
* Renames target to definition for clarity
* Stops null from being returned as a valid value in a certain edge case
* Adds PHPStan ignore instead of incorrect typing
* Refactors tests to take account of new typing solution
* Marks file as executable
* Draft CLI rework
* Finish rewrite as object-oriented
* Fix PHPStan ignore and make more strongly typed
* Rename class Option to CliOption
* Light renaming + anonymous classes
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Diffstat (limited to 'cli')
-rw-r--r-- | cli/CliOption.php | 104 | ||||
-rw-r--r-- | cli/CliOptionsParser.php | 247 | ||||
-rwxr-xr-x[-rw-r--r--] | cli/_cli.php | 119 | ||||
-rw-r--r-- | cli/_update-or-create-user.php | 71 | ||||
-rwxr-xr-x | cli/actualize-user.php | 28 | ||||
-rwxr-xr-x | cli/check.translation.php | 53 | ||||
-rwxr-xr-x | cli/create-user.php | 87 | ||||
-rwxr-xr-x | cli/db-optimize.php | 25 | ||||
-rwxr-xr-x | cli/delete-user.php | 36 | ||||
-rwxr-xr-x | cli/do-install.php | 182 | ||||
-rwxr-xr-x | cli/export-opml-for-user.php | 25 | ||||
-rwxr-xr-x | cli/export-sqlite-for-user.php | 33 | ||||
-rwxr-xr-x | cli/export-zip-for-user.php | 30 | ||||
-rwxr-xr-x | cli/import-for-user.php | 33 | ||||
-rwxr-xr-x | cli/import-sqlite-for-user.php | 38 | ||||
-rwxr-xr-x | cli/manipulate.translation.php | 103 | ||||
-rwxr-xr-x | cli/reconfigure.php | 207 | ||||
-rwxr-xr-x | cli/update-user.php | 75 | ||||
-rwxr-xr-x | cli/user-info.php | 51 |
19 files changed, 907 insertions, 640 deletions
diff --git a/cli/CliOption.php b/cli/CliOption.php new file mode 100644 index 000000000..d0eace311 --- /dev/null +++ b/cli/CliOption.php @@ -0,0 +1,104 @@ +<?php +declare(strict_types=1); + +final class CliOption { + public const VALUE_NONE = 'none'; + public const VALUE_REQUIRED = 'required'; + public const VALUE_OPTIONAL = 'optional'; + + private string $longAlias; + private ?string $shortAlias; + private string $valueTaken = self::VALUE_REQUIRED; + /** @var array{type:string,isArray:bool} $types */ + private array $types = ['type' => 'string', 'isArray' => false]; + private string $optionalValueDefault = ''; + private ?string $deprecatedAlias = null; + + public function __construct(string $longAlias, ?string $shortAlias = null) { + $this->longAlias = $longAlias; + $this->shortAlias = $shortAlias; + } + + /** Sets this option to be treated as a flag. */ + public function withValueNone(): self { + $this->valueTaken = static::VALUE_NONE; + return $this; + } + + /** Sets this option to always require a value when used. */ + public function withValueRequired(): self { + $this->valueTaken = static::VALUE_REQUIRED; + return $this; + } + + /** + * Sets this option to accept both values and flag behavior. + * @param string $optionalValueDefault When this option is used as a flag it receives this value as input. + */ + public function withValueOptional(string $optionalValueDefault = ''): self { + $this->valueTaken = static::VALUE_OPTIONAL; + $this->optionalValueDefault = $optionalValueDefault; + return $this; + } + + public function typeOfString(): self { + $this->types = ['type' => 'string', 'isArray' => false]; + return $this; + } + + public function typeOfInt(): self { + $this->types = ['type' => 'int', 'isArray' => false]; + return $this; + } + + public function typeOfBool(): self { + $this->types = ['type' => 'bool', 'isArray' => false]; + return $this; + } + + public function typeOfArrayOfString(): self { + $this->types = ['type' => 'string', 'isArray' => true]; + return $this; + } + + public function deprecatedAs(string $deprecated): self { + $this->deprecatedAlias = $deprecated; + return $this; + } + + public function getValueTaken(): string { + return $this->valueTaken; + } + + public function getOptionalValueDefault(): string { + return $this->optionalValueDefault; + } + + public function getDeprecatedAlias(): ?string { + return $this->deprecatedAlias; + } + + public function getLongAlias(): string { + return $this->longAlias; + } + + public function getShortAlias(): ?string { + return $this->shortAlias; + } + + /** @return array{type:string,isArray:bool} */ + public function getTypes(): array { + return $this->types; + } + + /** @return string[] */ + public function getAliases(): array { + $aliases = [ + $this->longAlias, + $this->shortAlias, + $this->deprecatedAlias, + ]; + + return array_filter($aliases); + } +} diff --git a/cli/CliOptionsParser.php b/cli/CliOptionsParser.php new file mode 100644 index 000000000..be325bd91 --- /dev/null +++ b/cli/CliOptionsParser.php @@ -0,0 +1,247 @@ +<?php +declare(strict_types=1); + +abstract class CliOptionsParser { + /** @var array<string,CliOption> */ + private array $options = []; + /** @var array<string,array{defaultInput:?string[],required:?bool,aliasUsed:?string,values:?string[]}> */ + private array $inputs = []; + /** @var array<string,string> $errors */ + public array $errors = []; + public string $usage = ''; + + public function __construct() { + global $argv; + + $this->usage = $this->getUsageMessage($argv[0]); + + $this->parseInput(); + $this->appendUnknownAliases($argv); + $this->appendInvalidValues(); + $this->appendTypedValidValues(); + } + + private function parseInput(): void { + $getoptInputs = $this->getGetoptInputs(); + $this->getoptOutputTransformer(getopt($getoptInputs['short'], $getoptInputs['long'])); + $this->checkForDeprecatedAliasUse(); + } + + /** Adds an option that produces an error message if not set. */ + protected function addRequiredOption(string $name, CliOption $option): void { + $this->inputs[$name] = [ + 'defaultInput' => null, + 'required' => true, + 'aliasUsed' => null, + 'values' => null, + ]; + $this->options[$name] = $option; + } + + /** + * Adds an optional option. + * @param string $defaultInput If not null this value is received as input in all cases where no + * user input is present. e.g. set this if you want an option to always return a value. + */ + protected function addOption(string $name, CliOption $option, string $defaultInput = null): void { + $this->inputs[$name] = [ + 'defaultInput' => is_string($defaultInput) ? [$defaultInput] : $defaultInput, + 'required' => null, + 'aliasUsed' => null, + 'values' => null, + ]; + $this->options[$name] = $option; + } + + private function appendInvalidValues(): void { + foreach ($this->options as $name => $option) { + if ($this->inputs[$name]['required'] && $this->inputs[$name]['values'] === null) { + $this->errors[$name] = 'invalid input: ' . $option->getLongAlias() . ' cannot be empty'; + } + } + + foreach ($this->inputs as $name => $input) { + foreach ($input['values'] ?? $input['defaultInput'] ?? [] as $value) { + switch ($this->options[$name]->getTypes()['type']) { + case 'int': + if (!ctype_digit($value)) { + $this->errors[$name] = 'invalid input: ' . $input['aliasUsed'] . ' must be an integer'; + } + break; + case 'bool': + if (filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) === null) { + $this->errors[$name] = 'invalid input: ' . $input['aliasUsed'] . ' must be a boolean'; + } + break; + } + } + } + } + + private function appendTypedValidValues(): void { + foreach ($this->inputs as $name => $input) { + $values = $input['values'] ?? $input['defaultInput'] ?? null; + $types = $this->options[$name]->getTypes(); + if ($values) { + $validValues = []; + $typedValues = []; + + switch ($types['type']) { + case 'string': + $typedValues = $values; + break; + case 'int': + $validValues = array_filter($values, static fn($value) => ctype_digit($value)); + $typedValues = array_map(static fn($value) => (int) $value, $validValues); + break; + case 'bool': + $validValues = array_filter($values, static fn($value) => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null); + $typedValues = array_map(static fn($value) => (bool) filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE), $validValues); + break; + } + + if (!empty($typedValues)) { + // @phpstan-ignore-next-line (change to `@phpstan-ignore property.dynamicName` when upgrading to PHPStan 1.11+) + $this->$name = $types['isArray'] ? $typedValues : array_pop($typedValues); + } + } + } + } + + /** @param array<string,string|false>|false $getoptOutput */ + private function getoptOutputTransformer($getoptOutput): void { + $getoptOutput = is_array($getoptOutput) ? $getoptOutput : []; + + foreach ($getoptOutput as $alias => $value) { + foreach ($this->options as $name => $data) { + if (in_array($alias, $data->getAliases(), true)) { + $this->inputs[$name]['aliasUsed'] = $alias; + $this->inputs[$name]['values'] = $value === false + ? [$data->getOptionalValueDefault()] + : (is_array($value) + ? $value + : [$value]); + } + } + } + } + + /** + * @param array<string> $userInputs + * @return array<string> + */ + private function getAliasesUsed(array $userInputs, string $regex): array { + $foundAliases = []; + + foreach ($userInputs as $input) { + preg_match($regex, $input, $matches); + + if(!empty($matches['short'])) { + $foundAliases = array_merge($foundAliases, str_split($matches['short'])); + } + if(!empty($matches['long'])) { + $foundAliases[] = $matches['long']; + } + } + + return $foundAliases; + } + + /** + * @param array<string> $input List of user command-line inputs. + */ + private function appendUnknownAliases(array $input): void { + $valid = []; + foreach ($this->options as $option) { + $valid = array_merge($valid, $option->getAliases()); + } + + $sanitizeInput = $this->getAliasesUsed($input, $this->makeInputRegex()); + $unknownAliases = array_diff($sanitizeInput, $valid); + if (empty($unknownAliases)) { + return; + } + + foreach ($unknownAliases as $unknownAlias) { + $this->errors[$unknownAlias] = 'unknown option: ' . $unknownAlias; + } + } + + /** + * Checks for presence of deprecated aliases. + * @return bool Returns TRUE and generates a deprecation warning if deprecated aliases are present, FALSE otherwise. + */ + private function checkForDeprecatedAliasUse(): bool { + $deprecated = []; + $replacements = []; + + foreach ($this->inputs as $name => $data) { + if ($data['aliasUsed'] !== null && $data['aliasUsed'] === $this->options[$name]->getDeprecatedAlias()) { + $deprecated[] = $this->options[$name]->getDeprecatedAlias(); + $replacements[] = $this->options[$name]->getLongAlias(); + } + } + + if (empty($deprecated)) { + return false; + } + + fwrite(STDERR, "FreshRSS deprecation warning: the CLI option(s): " . implode(', ', $deprecated) . + " are deprecated and will be removed in a future release. Use: " . implode(', ', $replacements) . + " instead\n"); + return true; + } + + /** @return array{long:array<string>,short:string}*/ + private function getGetoptInputs(): array { + $getoptNotation = [ + 'none' => '', + 'required' => ':', + 'optional' => '::', + ]; + + $long = []; + $short = ''; + + foreach ($this->options as $option) { + $long[] = $option->getLongAlias() . $getoptNotation[$option->getValueTaken()]; + $long[] = $option->getDeprecatedAlias() ? $option->getDeprecatedAlias() . $getoptNotation[$option->getValueTaken()] : ''; + $short .= $option->getShortAlias() ? $option->getShortAlias() . $getoptNotation[$option->getValueTaken()] : ''; + } + + return [ + 'long' => array_filter($long), + 'short' => $short + ]; + } + + private function getUsageMessage(string $command): string { + $required = ['Usage: ' . basename($command)]; + $optional = []; + + foreach ($this->options as $name => $option) { + $shortAlias = $option->getShortAlias() ? '-' . $option->getShortAlias() . ' ' : ''; + $longAlias = '--' . $option->getLongAlias() . ($option->getValueTaken() === 'required' ? '=<' . strtolower($name) . '>' : ''); + if ($this->inputs[$name]['required']) { + $required[] = $shortAlias . $longAlias; + } else { + $optional[] = '[' . $shortAlias . $longAlias . ']'; + } + } + + return implode(' ', $required) . ' ' . implode(' ', $optional); + } + + private function makeInputRegex() : string { + $shortWithValues = ''; + foreach ($this->options as $option) { + if (($option->getValueTaken() === 'required' || $option->getValueTaken() === 'optional') && $option->getShortAlias()) { + $shortWithValues .= $option->getShortAlias(); + } + } + + return $shortWithValues === '' + ? "/^--(?'long'[^=]+)|^-(?<short>\w+)/" + : "/^--(?'long'[^=]+)|^-(?<short>(?(?=\w*[$shortWithValues])[^$shortWithValues]*[$shortWithValues]|\w+))/"; + } +} diff --git a/cli/_cli.php b/cli/_cli.php index c51dd69a3..9d9d9c32d 100644..100755 --- a/cli/_cli.php +++ b/cli/_cli.php @@ -6,11 +6,12 @@ if (php_sapi_name() !== 'cli') { } const EXIT_CODE_ALREADY_EXISTS = 3; -const REGEX_INPUT_OPTIONS = '/^-{2}|^-{1}/'; require(__DIR__ . '/../constants.php'); require(LIB_PATH . '/lib_rss.php'); //Includes class autoloader require(LIB_PATH . '/lib_install.php'); +require_once(__DIR__ . '/CliOption.php'); +require_once(__DIR__ . '/CliOptionsParser.php'); Minz_Session::init('FreshRSS', true); FreshRSS_Context::initSystem(); @@ -73,119 +74,3 @@ function performRequirementCheck(string $databaseType): void { fail($message); } } - -/** - * Parses parameters used with FreshRSS' CLI commands. - * @param array{'long':array<string,string>,'short':array<string,string>,'deprecated':array<string,string>} $parameters - * Matrix of 'long': map of long option names as keys and their respective getopt() notations as values, - * 'short': map of short option names as values and their equivalent long options as keys, 'deprecated': map of - * replacement option names as keys and their respective deprecated option names as values. - * @return array{'valid':array<string,string>,'invalid':array<string>} Matrix of 'valid': map of of all known - * option names used and their respective values and 'invalid': list of all unknown options used. - */ -function parseCliParams(array $parameters): array { - global $argv; - $longOptions = []; - $shortOptions = ''; - - foreach ($parameters['long'] as $name => $getopt_note) { - $longOptions[] = $name . $getopt_note; - } - foreach ($parameters['deprecated'] as $name => $deprecatedName) { - $longOptions[] = $deprecatedName . $parameters['long'][$name]; - } - foreach ($parameters['short'] as $name => $shortName) { - $shortOptions .= $shortName . $parameters['long'][$name]; - } - - $options = getopt($shortOptions, $longOptions); - - $valid = is_array($options) ? $options : []; - - array_walk($valid, static fn(&$option) => $option = $option === false ? '' : $option); - - /** @var array<string,string> $valid */ - checkForDeprecatedOptions(array_keys($valid), $parameters['deprecated']); - - $valid = replaceOptions($valid, $parameters['short']); - $valid = replaceOptions($valid, $parameters['deprecated']); - - $invalid = findInvalidOptions( - $argv, - array_merge(array_keys($parameters['long']), array_values($parameters['short']), array_values($parameters['deprecated'])) - ); - - return [ - 'valid' => $valid, - 'invalid' => $invalid - ]; -} - -/** - * @param array<string> $options - * @return array<string> - */ -function getOptions(array $options, string $regex): array { - $longOptions = array_filter($options, static function (string $a) use ($regex) { - return preg_match($regex, $a) === 1; - }); - return array_map(static function (string $a) use ($regex) { - return preg_replace($regex, '', $a) ?? ''; - }, $longOptions); -} - -/** - * Checks for presence of unknown options. - * @param array<string> $input List of command line arguments to check for validity. - * @param array<string> $params List of valid options to check against. - * @return array<string> Returns a list all unknown options found. - */ -function findInvalidOptions(array $input, array $params): array { - $sanitizeInput = getOptions($input, REGEX_INPUT_OPTIONS); - $unknownOptions = array_diff($sanitizeInput, $params); - - if (0 === count($unknownOptions)) { - return []; - } - - fwrite(STDERR, sprintf("FreshRSS error: unknown options: %s\n", implode (', ', $unknownOptions))); - return $unknownOptions; -} - -/** - * Checks for presence of deprecated options. - * @param array<string> $optionNames Command line option names to check for deprecation. - * @param array<string,string> $params Map of replacement options as keys and their respective deprecated - * options as values. - * @return bool Returns TRUE and generates a deprecation warning if deprecated options are present, FALSE otherwise. - */ -function checkForDeprecatedOptions(array $optionNames, array $params): bool { - $deprecatedOptions = array_intersect($optionNames, $params); - $replacements = array_map(static fn($option) => array_search($option, $params, true), $deprecatedOptions); - - if (0 === count($deprecatedOptions)) { - return false; - } - - fwrite(STDERR, "FreshRSS deprecation warning: the CLI option(s): " . implode(', ', $deprecatedOptions) . - " are deprecated and will be removed in a future release. Use: " - . implode(', ', $replacements) . " instead\n"); - return true; -} - -/** - * Switches items in a list to their provided replacements. - * @param array<string,string> $options Map with items to check for replacement as keys. - * @param array<string,string> $replacements Map of replacement items as keys and the item they replace as their values. - * @return array<string,string> Returns $options with replacements. - */ -function replaceOptions(array $options, array $replacements): array { - $updatedOptions = []; - - foreach ($options as $name => $value) { - $replacement = array_search($name, $replacements, true); - $updatedOptions[$replacement ? $replacement : $name] = $value; - } - - return $updatedOptions; -} diff --git a/cli/_update-or-create-user.php b/cli/_update-or-create-user.php deleted file mode 100644 index 1cc08bd25..000000000 --- a/cli/_update-or-create-user.php +++ /dev/null @@ -1,71 +0,0 @@ -<?php -declare(strict_types=1); -require(__DIR__ . '/_cli.php'); - -performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); - -$parameters = [ - 'long' => [ - 'user' => ':', - 'password' => ':', - 'api-password' => ':', - 'language' => ':', - 'email' => ':', - 'token' => ':', - 'purge-after-months' => ':', - 'feed-min-articles-default' => ':', - 'feed-ttl-default' => ':', - 'since-hours-posts-per-rss' => ':', - 'max-posts-per-rss' => ':', - ], - 'short' => [], - 'deprecated' => [ - 'api-password' => 'api_password', - 'purge-after-months' => 'purge_after_months', - 'feed-min-articles-default' => 'feed_min_articles_default', - 'feed-ttl-default' => 'feed_ttl_default', - 'since-hours-posts-per-rss' => 'since_hours_posts_per_rss', - 'max-posts-per-rss' => 'max_posts_per_rss', - ], -]; - -if (!isset($isUpdate)) { - $isUpdate = false; -} elseif (!$isUpdate) { - $parameters['long']['no-default-feeds'] = ''; //Only for creating new users - $parameters['deprecated']['no-default-feeds'] = 'no_default_feeds'; -} - -$GLOBALS['options'] = parseCliParams($parameters); - -if (!empty($options['invalid']) || empty($options['valid']['user'])) { - fail('Usage: ' . basename($_SERVER['SCRIPT_FILENAME']) . - " --user username ( --password 'password' --api-password 'api_password'" . - " --language en --email user@example.net --token 'longRandomString'" . - ($isUpdate ? '' : ' --no-default-feeds') . - " --purge-after-months 3 --feed-min-articles-default 50 --feed-ttl-default 3600" . - " --since-hours-posts-per-rss 168 --max-posts-per-rss 400 )"); -} - -function strParam(string $name): ?string { - global $options; - return isset($options['valid'][$name]) ? strval($options['valid'][$name]) : null; -} - -function intParam(string $name): ?int { - global $options; - return isset($options['valid'][$name]) && ctype_digit($options['valid'][$name]) ? intval($options['valid'][$name]) : null; -} - -$values = array( - 'language' => strParam('language'), - 'mail_login' => strParam('email'), - 'token' => strParam('token'), - 'old_entries' => intParam('purge-after-months'), //TODO: Update with new mechanism - 'keep_history_default' => intParam('feed-min-articles-default'), //TODO: Update with new mechanism - 'ttl_default' => intParam('feed-ttl-default'), - 'since_hours_posts_per_rss' => intParam('since-hours-posts-per-rss'), - 'max_posts_per_rss' => intParam('max-posts-per-rss'), - ); - -$values = array_filter($values); diff --git a/cli/actualize-user.php b/cli/actualize-user.php index 03af5f5c8..c07cf6d0e 100755 --- a/cli/actualize-user.php +++ b/cli/actualize-user.php @@ -5,21 +5,23 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':' - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) { - fail('Usage: ' . basename(__FILE__) . " --user username"); +$cliOptions = new class extends CliOptionsParser { + public string $user; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$username = cliInitUser($options['valid']['user']); +$username = cliInitUser($cliOptions->user); + +Minz_ExtensionManager::callHookVoid('freshrss_user_maintenance'); + fwrite(STDERR, 'FreshRSS actualizing user “' . $username . "”…\n"); $databaseDAO = FreshRSS_Factory::createDatabaseDAO(); diff --git a/cli/check.translation.php b/cli/check.translation.php index 10a346dee..0da415d85 100755 --- a/cli/check.translation.php +++ b/cli/check.translation.php @@ -8,38 +8,39 @@ require_once __DIR__ . '/i18n/I18nFile.php'; require_once __DIR__ . '/i18n/I18nUsageValidator.php'; require_once __DIR__ . '/../constants.php'; -$i18nFile = new I18nFile(); -$i18nData = new I18nData($i18nFile->load()); +$cliOptions = new class extends CliOptionsParser { + /** @var array<int,string> $language */ + public array $language; + public string $displayResult; + public string $help; + public string $displayReport; + + public function __construct() { + $this->addOption('language', (new CliOption('language', 'l'))->typeOfArrayOfString()); + $this->addOption('displayResult', (new CliOption('display-result', 'd'))->withValueNone()); + $this->addOption('help', (new CliOption('help', 'h'))->withValueNone()); + $this->addOption('displayReport', (new CliOption('display-report', 'r'))->withValueNone()); + parent::__construct(); + } +}; -$parameters = [ - 'long' => [ - 'display-result' => '', - 'help' => '', - 'language' => ':', - 'display-report' => '', - ], - 'short' => [ - 'display-result' => 'd', - 'help' => 'h', - 'language' => 'l', - 'display-report' => 'r', - ], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) || array_key_exists('help', $options['valid'])) { +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); +} +if (isset($cliOptions->help)) { checkHelp(); } -if (array_key_exists('language', $options['valid'])) { - $languages = [$options['valid']['language']]; +$i18nFile = new I18nFile(); +$i18nData = new I18nData($i18nFile->load()); + +if (isset($cliOptions->language)) { + $languages = $cliOptions->language; } else { $languages = $i18nData->getAvailableLanguages(); } -$displayResults = array_key_exists('display-result', $options['valid']); -$displayReport = array_key_exists('display-report', $options['valid']); +$displayResults = isset($cliOptions->displayResult); +$displayReport = isset($cliOptions->displayReport); $isValidated = true; $result = []; @@ -122,5 +123,5 @@ DESCRIPTION -r, --display-report display completion report. HELP; - exit; + exit(); } diff --git a/cli/create-user.php b/cli/create-user.php index 53e0335bc..61bbc3563 100755 --- a/cli/create-user.php +++ b/cli/create-user.php @@ -1,38 +1,93 @@ #!/usr/bin/env php <?php declare(strict_types=1); +require(__DIR__ . '/_cli.php'); -$isUpdate = false; -require(__DIR__ . '/_update-or-create-user.php'); +$cliOptions = new class extends CliOptionsParser { + public string $user; + public string $password; + public string $apiPassword; + public string $language; + public string $email; + public string $token; + public int $purgeAfterMonths; + public int $feedMinArticles; + public int $feedTtl; + public int $sinceHoursPostsPerRss; + public int $maxPostsPerRss; + public bool $noDefaultFeeds; -$username = $GLOBALS['options']['valid']['user']; -if (!FreshRSS_user_Controller::checkUsername($username)) { - fail('FreshRSS error: invalid username “' . $username . - '”! Must be matching ' . FreshRSS_user_Controller::USERNAME_PATTERN); -} + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + $this->addOption('password', (new CliOption('password'))); + $this->addOption('apiPassword', (new CliOption('api-password'))->deprecatedAs('api_password')); + $this->addOption('language', (new CliOption('language'))); + $this->addOption('email', (new CliOption('email'))); + $this->addOption('token', (new CliOption('token'))); + $this->addOption( + 'purgeAfterMonths', + (new CliOption('purge-after-months'))->typeOfInt()->deprecatedAs('purge_after_months') + ); + $this->addOption( + 'feedMinArticles', + (new CliOption('feed-min-articles-default'))->typeOfInt()->deprecatedAs('feed_min_articles_default') + ); + $this->addOption( + 'feedTtl', + (new CliOption('feed-ttl-default'))->typeOfInt()->deprecatedAs('feed_ttl_default') + ); + $this->addOption( + 'sinceHoursPostsPerRss', + (new CliOption('since-hours-posts-per-rss'))->typeOfInt()->deprecatedAs('since_hours_posts_per_rss') + ); + $this->addOption( + 'maxPostsPerRss', + (new CliOption('max-posts-per-rss'))->typeOfInt()->deprecatedAs('max_posts_per_rss') + ); + $this->addOption( + 'noDefaultFeeds', + (new CliOption('no-default-feeds'))->withValueNone()->deprecatedAs('no_default_feeds') + ); + parent::__construct(); + } +}; -$usernames = listUsers(); -if (preg_grep("/^$username$/i", $usernames)) { - fail('FreshRSS warning: username already exists “' . $username . '”', EXIT_CODE_ALREADY_EXISTS); +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } +$username = $cliOptions->user; + echo 'FreshRSS creating user “', $username, "”…\n"; +$values = [ + 'language' => $cliOptions->language ?? null, + 'mail_login' => $cliOptions->email ?? null, + 'token' => $cliOptions->token ?? null, + 'old_entries' => $cliOptions->purgeAfterMonths ?? null, + 'keep_history_default' => $cliOptions->feedMinArticles ?? null, + 'ttl_default' => $cliOptions->feedTtl ?? null, + 'since_hours_posts_per_rss' => $cliOptions->sinceHoursPostsPerRss ?? null, + 'max_posts_per_rss' => $cliOptions->maxPostsPerRss ?? null, +]; + +$values = array_filter($values); + $ok = FreshRSS_user_Controller::createUser( $username, - empty($options['valid']['email']) ? '' : $options['valid']['email'], - empty($options['valid']['password']) ? '' : $options['valid']['password'], - $GLOBALS['values'], - !isset($options['valid']['no-default-feeds']) + isset($cliOptions->email) ? $cliOptions->email : null, + $cliOptions->password ?? '', + $values, + !isset($cliOptions->noDefaultFeeds) ); if (!$ok) { fail('FreshRSS could not create user!'); } -if (!empty($options['valid']['api-password'])) { +if (isset($cliOptions->apiPassword)) { $username = cliInitUser($username); - $error = FreshRSS_api_Controller::updatePassword($options['valid']['api-password']); + $error = FreshRSS_api_Controller::updatePassword($cliOptions->apiPassword); if ($error !== false) { fail($error); } diff --git a/cli/db-optimize.php b/cli/db-optimize.php index d553b64d9..f6d3884b9 100755 --- a/cli/db-optimize.php +++ b/cli/db-optimize.php @@ -5,21 +5,20 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':', - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) { - fail('Usage: ' . basename(__FILE__) . " --user username"); +$cliOptions = new class extends CliOptionsParser { + public string $user; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$username = cliInitUser($options['valid']['user']); +$username = cliInitUser($cliOptions->user); echo 'FreshRSS optimizing database for user “', $username, "”…\n"; diff --git a/cli/delete-user.php b/cli/delete-user.php index b4f042847..18efa4253 100755 --- a/cli/delete-user.php +++ b/cli/delete-user.php @@ -5,29 +5,27 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':', - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) { - fail('Usage: ' . basename(__FILE__) . " --user username"); +$cliOptions = new class extends CliOptionsParser { + public string $user; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$username = $options['valid']['user']; + +$username = $cliOptions->user; + if (!FreshRSS_user_Controller::checkUsername($username)) { - fail('FreshRSS error: invalid username “' . $username . '”'); + fail('FreshRSS error: invalid username: ' . $username . "\n"); } - -$usernames = listUsers(); -if (!preg_grep("/^$username$/i", $usernames)) { - fail('FreshRSS error: username not found “' . $username . '”'); +if (!FreshRSS_user_Controller::userExists($username)) { + fail('FreshRSS error: user not found: ' . $username . "\n"); } - if (strcasecmp($username, FreshRSS_Context::systemConf()->default_user) === 0) { fail('FreshRSS error: default user must not be deleted: “' . $username . '”'); } diff --git a/cli/do-install.php b/cli/do-install.php index 2d73c2e98..77acc58ed 100755 --- a/cli/do-install.php +++ b/cli/do-install.php @@ -7,74 +7,91 @@ if (file_exists(DATA_PATH . '/applied_migrations.txt')) { fail('FreshRSS seems to be already installed!' . "\n" . 'Please use `./cli/reconfigure.php` instead.', EXIT_CODE_ALREADY_EXISTS); } -$parameters = [ - 'long' => [ - 'environment' => ':', - 'base-url' => ':', - 'language' => ':', - 'title' => ':', - 'default-user' => ':', - 'allow-anonymous' => '', - 'allow-anonymous-refresh' => '', - 'auth-type' => ':', - 'api-enabled' => '', - 'allow-robots' => '', - 'disable-update' => '', - 'db-type' => ':', - 'db-host' => ':', - 'db-user' => ':', - 'db-password' => ':', - 'db-base' => ':', - 'db-prefix' => '::', - ], - 'short' => [], - 'deprecated' => [ - 'base-url' => 'base_url', - 'default-user' => 'default_user', - 'allow-anonymous' => 'allow_anonymous', - 'allow-anonymous-refresh' => 'allow_anonymous_refresh', - 'auth-type' => 'auth_type', - 'api-enabled' => 'api_enabled', - 'allow-robots' => 'allow_robots', - 'disable-update' => 'disable_update', - ], -]; - -$configParams = [ - 'environment' => 'environment', - 'base-url' => 'base_url', - 'language' => 'language', - 'title' => 'title', - 'default-user' => 'default_user', - 'allow-anonymous' => 'allow_anonymous', - 'allow-anonymous-refresh' => 'allow_anonymous_refresh', - 'auth-type' => 'auth_type', - 'api-enabled' => 'api_enabled', - 'allow-robots' => 'allow_robots', - 'disable-update' => 'disable_update', -]; - -$dBconfigParams = [ - 'db-type' => 'type', - 'db-host' => 'host', - 'db-user' => 'user', - 'db-password' => 'password', - 'db-base' => 'base', - 'db-prefix' => 'prefix', -]; - -$options = parseCliParams($parameters); +$cliOptions = new class extends CliOptionsParser { + public string $defaultUser; + public string $environment; + public string $baseUrl; + public string $language; + public string $title; + public bool $allowAnonymous; + public bool $allowAnonymousRefresh; + public string $authType; + public bool $apiEnabled; + public bool $allowRobots; + public bool $disableUpdate; + public string $dbType; + public string $dbHost; + public string $dbUser; + public string $dbPassword; + public string $dbBase; + public string $dbPrefix; + + public function __construct() { + $this->addRequiredOption('defaultUser', (new CliOption('default-user'))->deprecatedAs('default_user')); + $this->addOption('environment', (new CliOption('environment'))); + $this->addOption('baseUrl', (new CliOption('base-url'))->deprecatedAs('base_url')); + $this->addOption('language', (new CliOption('language'))); + $this->addOption('title', (new CliOption('title'))); + $this->addOption( + 'allowAnonymous', + (new CliOption('allow-anonymous'))->withValueOptional('true')->deprecatedAs('allow_anonymous')->typeOfBool() + ); + $this->addOption( + 'allowAnonymousRefresh', + (new CliOption('allow-anonymous-refresh'))->withValueOptional('true')->deprecatedAs('allow_anonymous_refresh')->typeOfBool() + ); + $this->addOption('authType', (new CliOption('auth-type'))->deprecatedAs('auth_type')); + $this->addOption( + 'apiEnabled', + (new CliOption('api-enabled'))->withValueOptional('true')->deprecatedAs('api_enabled')->typeOfBool() + ); + $this->addOption( + 'allowRobots', + (new CliOption('allow-robots'))->withValueOptional('true')->deprecatedAs('allow_robots')->typeOfBool() + ); + $this->addOption( + 'disableUpdate', + (new CliOption('disable-update'))->withValueOptional('true')->deprecatedAs('disable_update')->typeOfBool() + ); + $this->addOption('dbType', (new CliOption('db-type'))); + $this->addOption('dbHost', (new CliOption('db-host'))); + $this->addOption('dbUser', (new CliOption('db-user'))); + $this->addOption('dbPassword', (new CliOption('db-password'))); + $this->addOption('dbBase', (new CliOption('db-base'))); + $this->addOption('dbPrefix', (new CliOption('db-prefix'))->withValueOptional()); + parent::__construct(); + } +}; -if (!empty($options['invalid']) || empty($options['valid']['default-user']) || !is_string($options['valid']['default-user'])) { - fail('Usage: ' . basename(__FILE__) . " --default-user admin ( --auth-type form" . - " --environment production --base-url https://rss.example.net --allow-robots" . - " --language en --title FreshRSS --allow-anonymous --allow-anonymous-refresh --api-enabled" . - " --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" . - " --db-base freshrss --db-prefix freshrss_ --disable-update )"); +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } fwrite(STDERR, 'FreshRSS install…' . "\n"); +$values = [ + 'default_user' => $cliOptions->defaultUser ?? null, + 'environment' => $cliOptions->environment ?? null, + 'base_url' => $cliOptions->baseUrl ?? null, + 'language' => $cliOptions->language ?? null, + 'title' => $cliOptions->title ?? null, + 'allow_anonymous' => $cliOptions->allowAnonymous ?? null, + 'allow_anonymous_refresh' => $cliOptions->allowAnonymousRefresh ?? null, + 'auth_type' => $cliOptions->authType ?? null, + 'api_enabled' => $cliOptions->apiEnabled ?? null, + 'allow_robots' => $cliOptions->allowRobots ?? null, + 'disable_update' => $cliOptions->disableUpdate ?? null, +]; + +$dbValues = [ + 'type' => $cliOptions->dbType ?? null, + 'host' => $cliOptions->dbHost ?? null, + 'user' => $cliOptions->dbUser ?? null, + 'password' => $cliOptions->dbPassword ?? null, + 'base' => $cliOptions->dbBase ?? null, + 'prefix' => $cliOptions->dbPrefix ?? null, +]; + $config = array( 'salt' => generateSalt(), 'db' => FreshRSS_Context::systemConf()->db, @@ -88,10 +105,26 @@ if (file_exists($customConfigPath)) { } } -foreach ($configParams as $param => $configParam) { - if (isset($options['valid'][$param])) { - $isFlag = $parameters['long'][$param] === ''; - $config[$configParam] = $isFlag ? true : $options['valid'][$param]; +foreach ($values as $name => $value) { + if ($value !== null) { + switch ($name) { + case 'default_user': + if (!FreshRSS_user_Controller::checkUsername($value)) { + fail('FreshRSS invalid default username! default_user must be ASCII alphanumeric'); + } + break; + case 'environment': + if (!in_array($value, ['development', 'production', 'silent'], true)) { + fail('FreshRSS invalid environment! environment must be one of { development, production, silent }'); + } + break; + case 'auth_type': + if (!in_array($value, ['form', 'http_auth', 'none'], true)) { + fail('FreshRSS invalid authentication method! auth_type must be one of { form, http_auth, none }'); + } + break; + } + $config[$name] = $value; } } @@ -99,23 +132,10 @@ if ((!empty($config['base_url'])) && is_string($config['base_url']) && Minz_Requ $config['pubsubhubbub_enabled'] = true; } -foreach ($dBconfigParams as $dBparam => $configDbParam) { - if (isset($options['valid'][$dBparam])) { - $config['db'][$configDbParam] = $options['valid'][$dBparam]; - } -} +$config['db'] = array_merge($config['db'], array_filter($dbValues)); performRequirementCheck($config['db']['type']); -if (!FreshRSS_user_Controller::checkUsername($options['valid']['default-user'])) { - fail('FreshRSS error: invalid default username “' . $options['valid']['default-user'] - . '”! Must be matching ' . FreshRSS_user_Controller::USERNAME_PATTERN); -} - -if (isset($options['valid']['auth-type']) && !in_array($options['valid']['auth-type'], ['form', 'http_auth', 'none'], true)) { - fail('FreshRSS invalid authentication method (auth-type must be one of { form, http_auth, none })'); -} - if (file_put_contents(join_path(DATA_PATH, 'config.php'), "<?php\n return " . var_export($config, true) . ";\n") === false) { fail('FreshRSS could not write configuration file!: ' . join_path(DATA_PATH, 'config.php')); diff --git a/cli/export-opml-for-user.php b/cli/export-opml-for-user.php index 7c59c65ef..4866a848f 100755 --- a/cli/export-opml-for-user.php +++ b/cli/export-opml-for-user.php @@ -5,21 +5,20 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':', - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) { - fail('Usage: ' . basename(__FILE__) . " --user username > /path/to/file.opml.xml"); +$cliOptions = new class extends CliOptionsParser { + public string $user; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$username = cliInitUser($options['valid']['user']); +$username = cliInitUser($cliOptions->user); fwrite(STDERR, 'FreshRSS exporting OPML for user “' . $username . "”…\n"); diff --git a/cli/export-sqlite-for-user.php b/cli/export-sqlite-for-user.php index e67896df9..98e05da22 100755 --- a/cli/export-sqlite-for-user.php +++ b/cli/export-sqlite-for-user.php @@ -5,26 +5,23 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':', - 'filename' => ':', - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) - || empty($options['valid']['user']) || empty($options['valid']['filename']) - || !is_string($options['valid']['user']) || !is_string($options['valid']['filename']) -) { - fail('Usage: ' . basename(__FILE__) . ' --user username --filename /path/to/db.sqlite'); +$cliOptions = new class extends CliOptionsParser { + public string $user; + public string $filename; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + $this->addRequiredOption('filename', (new CliOption('filename'))); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$username = cliInitUser($options['valid']['user']); -$filename = $options['valid']['filename']; +$username = cliInitUser($cliOptions->user); +$filename = $cliOptions->filename; if (pathinfo($filename, PATHINFO_EXTENSION) !== 'sqlite') { fail('Only *.sqlite files are supported!'); diff --git a/cli/export-zip-for-user.php b/cli/export-zip-for-user.php index d818096e4..304acc392 100755 --- a/cli/export-zip-for-user.php +++ b/cli/export-zip-for-user.php @@ -5,31 +5,31 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':', - 'max-feed-entries' => ':', - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) || empty($options['valid']['user']) || !is_string($options['valid']['user'])) { - fail('Usage: ' . basename(__FILE__) . " --user username ( --max-feed-entries 100 ) > /path/to/file.zip"); +$cliOptions = new class extends CliOptionsParser { + public string $user; + public int $maxFeedEntries; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + $this->addOption('maxFeedEntries', (new CliOption('max-feed-entries'))->typeOfInt(), '100'); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } if (!extension_loaded('zip')) { fail('FreshRSS error: Lacking php-zip extension!'); } -$username = cliInitUser($options['valid']['user']); +$username = cliInitUser($cliOptions->user); fwrite(STDERR, 'FreshRSS exporting ZIP for user “' . $username . "”…\n"); $export_service = new FreshRSS_Export_Service($username); -$number_entries = empty($options['valid']['max-feed-entries']) ? 100 : intval($options['valid']['max-feed-entries']); +$number_entries = $cliOptions->maxFeedEntries; $exported_files = []; // First, we generate the OPML file diff --git a/cli/import-for-user.php b/cli/import-for-user.php index 6969a8946..4c4db8405 100755 --- a/cli/import-for-user.php +++ b/cli/import-for-user.php @@ -5,27 +5,24 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':', - 'filename' => ':', - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) - || empty($options['valid']['user']) || empty($options['valid']['filename']) - || !is_string($options['valid']['user']) || !is_string($options['valid']['filename']) -) { - fail('Usage: ' . basename(__FILE__) . " --user username --filename /path/to/file.ext"); +$cliOptions = new class extends CliOptionsParser { + public string $user; + public string $filename; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + $this->addRequiredOption('filename', (new CliOption('filename'))); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$username = cliInitUser($options['valid']['user']); +$username = cliInitUser($cliOptions->user); +$filename = $cliOptions->filename; -$filename = $options['valid']['filename']; if (!is_readable($filename)) { fail('FreshRSS error: file is not readable “' . $filename . '”'); } diff --git a/cli/import-sqlite-for-user.php b/cli/import-sqlite-for-user.php index 29b7c1b0c..45ecb3597 100755 --- a/cli/import-sqlite-for-user.php +++ b/cli/import-sqlite-for-user.php @@ -5,27 +5,25 @@ require(__DIR__ . '/_cli.php'); performRequirementCheck(FreshRSS_Context::systemConf()->db['type'] ?? ''); -$parameters = [ - 'long' => [ - 'user' => ':', - 'filename' => ':', - 'force-overwrite' => '', - ], - 'short' => [], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) - || empty($options['valid']['user']) || empty($options['valid']['filename']) - || !is_string($options['valid']['user']) || !is_string($options['valid']['filename']) -) { - fail('Usage: ' . basename(__FILE__) . ' --user username --force-overwrite --filename /path/to/db.sqlite'); +$cliOptions = new class extends CliOptionsParser { + public string $user; + public string $filename; + public string $forceOverwrite; + + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + $this->addRequiredOption('filename', (new CliOption('filename'))); + $this->addOption('forceOverwrite', (new CliOption('force-overwrite'))->withValueNone()); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$username = cliInitUser($options['valid']['user']); -$filename = $options['valid']['filename']; +$username = cliInitUser($cliOptions->user); +$filename = $cliOptions->filename; if (pathinfo($filename, PATHINFO_EXTENSION) !== 'sqlite') { fail('Only *.sqlite files are supported!'); @@ -34,7 +32,7 @@ if (pathinfo($filename, PATHINFO_EXTENSION) !== 'sqlite') { echo 'FreshRSS importing database from SQLite for user “', $username, "”…\n"; $databaseDAO = FreshRSS_Factory::createDatabaseDAO($username); -$clearFirst = array_key_exists('force-overwrite', $options['valid']); +$clearFirst = isset($cliOptions->forceOverwrite); $ok = $databaseDAO->dbCopy($filename, FreshRSS_DatabaseDAO::SQLITE_IMPORT, $clearFirst); if (!$ok) { echo 'If you would like to clear the user database first, use the option --force-overwrite', "\n"; diff --git a/cli/manipulate.translation.php b/cli/manipulate.translation.php index 358a3ec33..740f3c4b7 100755 --- a/cli/manipulate.translation.php +++ b/cli/manipulate.translation.php @@ -6,70 +6,65 @@ require_once __DIR__ . '/i18n/I18nData.php'; require_once __DIR__ . '/i18n/I18nFile.php'; require_once __DIR__ . '/../constants.php'; -$parameters = [ - 'long' => [ - 'action' => ':', - 'help' => '', - 'key' => ':', - 'language' => ':', - 'origin-language' => ':', - 'revert' => '', - 'value' => ':', - ], - 'short' => [ - 'action' => 'a', - 'help' => 'h', - 'key' => 'k', - 'language' => 'l', - 'origin-language' => 'o', - 'revert' => 'r', - 'value' => 'v', - ], - 'deprecated' => [], -]; - -$options = parseCliParams($parameters); - -if (!empty($options['invalid']) || array_key_exists('help', $options['valid'])) { - manipulateHelp(); - exit(); +$cliOptions = new class extends CliOptionsParser { + public string $action; + public string $key; + public string $value; + public string $language; + public string $originLanguage; + public string $revert; + public string $help; + + public function __construct() { + $this->addRequiredOption('action', (new CliOption('action', 'a'))); + $this->addOption('key', (new CliOption('key', 'k'))); + $this->addOption('value', (new CliOption('value', 'v'))); + $this->addOption('language', (new CliOption('language', 'l'))); + $this->addOption('originLanguage', (new CliOption('origin-language', 'o'))); + $this->addOption('revert', (new CliOption('revert', 'r'))->withValueNone()); + $this->addOption('help', (new CliOption('help', 'h'))->withValueNone()); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } - -if (!array_key_exists('action', $options['valid'])) { - error('You need to specify the action to perform.'); +if (isset($cliOptions->help)) { + manipulateHelp(); } $data = new I18nFile(); $i18nData = new I18nData($data->load()); -switch ($options['valid']['action']) { +switch ($cliOptions->action) { case 'add' : - if (array_key_exists('key', $options['valid']) && array_key_exists('value', $options['valid']) && array_key_exists('language', $options['valid'])) { - $i18nData->addValue($options['valid']['key'], $options['valid']['value'], $options['valid']['language']); - } elseif (array_key_exists('key', $options['valid']) && array_key_exists('value', $options['valid'])) { - $i18nData->addKey($options['valid']['key'], $options['valid']['value']); - } elseif (array_key_exists('language', $options['valid'])) { + if (isset($cliOptions->key) && isset($cliOptions->value) && isset($cliOptions->language)) { + $i18nData->addValue($cliOptions->key, $cliOptions->value, $cliOptions->language); + } elseif (isset($cliOptions->key) && isset($cliOptions->value)) { + $i18nData->addKey($cliOptions->key, $cliOptions->value); + } elseif (isset($cliOptions->language)) { $reference = null; - if (array_key_exists('origin-language', $options['valid'])) { - $reference = $options['valid']['origin-language']; + if (isset($cliOptions->originLanguage)) { + $reference = $cliOptions->originLanguage; } - $i18nData->addLanguage($options['valid']['language'], $reference); + $i18nData->addLanguage($cliOptions->language, $reference); } else { error('You need to specify a valid set of options.'); exit; } break; case 'delete' : - if (array_key_exists('key', $options['valid'])) { - $i18nData->removeKey($options['valid']['key']); + if (isset($cliOptions->key)) { + $i18nData->removeKey($cliOptions->key); } else { error('You need to specify the key to delete.'); exit; } break; case 'exist': - if (array_key_exists('key', $options['valid'])) { - $key = $options['valid']['key']; + if (isset($cliOptions->key)) { + $key = $cliOptions->key; if ($i18nData->isKnown($key)) { echo "The '{$key}' key is known.\n\n"; } else { @@ -83,16 +78,16 @@ switch ($options['valid']['action']) { case 'format' : break; case 'ignore' : - if (array_key_exists('language', $options['valid']) && array_key_exists('key', $options['valid'])) { - $i18nData->ignore($options['valid']['key'], $options['valid']['language'], array_key_exists('revert', $options['valid'])); + if (isset($cliOptions->language) && isset($cliOptions->key)) { + $i18nData->ignore($cliOptions->key, $cliOptions->language, isset($cliOptions->revert)); } else { error('You need to specify a valid set of options.'); exit; } break; case 'ignore_unmodified' : - if (array_key_exists('language', $options['valid'])) { - $i18nData->ignore_unmodified($options['valid']['language'], array_key_exists('revert', $options['valid'])); + if (isset($cliOptions->language)) { + $i18nData->ignore_unmodified($cliOptions->language, isset($cliOptions->revert)); } else { error('You need to specify a valid set of options.'); exit; @@ -122,6 +117,7 @@ ERROR; */ function manipulateHelp(): void { $file = str_replace(__DIR__ . '/', '', __FILE__); + echo <<<HELP NAME $file @@ -144,17 +140,17 @@ DESCRIPTION select the origin language (only for add language action) EXAMPLES -Example 1: add a language. It adds a new language by duplicating the referential. +Example 1: add a language. Adds a new language by duplicating the reference language. php $file -a add -l my_lang php $file -a add -l my_lang -o ref_lang -Example 2: add a new key. It adds the key for all supported languages. +Example 2: add a new key. Adds a key to all supported languages. php $file -a add -k my_key -v my_value -Example 3: add a new value. It adds a new value for the selected key in the selected language. +Example 3: add a new value. Sets a new value for the selected key in the selected language. php $file -a add -k my_key -v my_value -l my_lang -Example 4: delete a key. It deletes the selected key from all supported languages. +Example 4: delete a key. Deletes the selected key from all supported languages. php $file -a delete -k my_key Example 5: format i18n files. @@ -170,11 +166,12 @@ Example 8: ignore all unmodified keys. Adds IGNORE comments to all unmodified ke php $file -a ignore_unmodified -l my_lang Example 9: revert ignore on all unmodified keys. Removes IGNORE comments from all unmodified keys in the selected language. - Warning: will also revert individually added unmodified keys. + Warning: will also revert individually added IGNOREs on unmodified keys. php $file -a ignore_unmodified -r -l my_lang Example 10: check if a key exist. - php $file -a exist -k my_key\n\n + php $file -a exist -k my_key HELP; + exit(); } diff --git a/cli/reconfigure.php b/cli/reconfigure.php index fde16c921..853e05297 100755 --- a/cli/reconfigure.php +++ b/cli/reconfigure.php @@ -3,133 +3,120 @@ declare(strict_types=1); require(__DIR__ . '/_cli.php'); -$parameters = [ - 'long' => [ - 'environment' => ':', - 'base-url' => ':', - 'language' => ':', - 'title' => ':', - 'default-user' => ':', - 'allow-anonymous' => '', - 'allow-anonymous-refresh' => '', - 'auth-type' => ':', - 'api-enabled' => '', - 'allow-robots' => '', - 'disable-update' => '', - 'db-type' => ':', - 'db-host' => ':', - 'db-user' => ':', - 'db-password' => ':', - 'db-base' => ':', - 'db-prefix' => '::', - ], - 'short' => [], - 'deprecated' => [ - 'base-url' => 'base_url', - 'default-user' => 'default_user', - 'allow-anonymous' => 'allow_anonymous', - 'allow-anonymous-refresh' => 'allow_anonymous_refresh', - 'auth-type' => 'auth_type', - 'api-enabled' => 'api_enabled', - 'allow-robots' => 'allow_robots', - 'disable-update' => 'disable_update', - ], -]; - -$configParams = [ - 'environment', - 'base-url', - 'language', - 'title', - 'default-user', - 'allow-anonymous', - 'allow-anonymous-refresh', - 'auth-type', - 'api-enabled', - 'allow-robots', - 'disable-update', -]; +$cliOptions = new class extends CliOptionsParser { + public string $defaultUser; + public string $environment; + public string $baseUrl; + public string $language; + public string $title; + public bool $allowAnonymous; + public bool $allowAnonymousRefresh; + public string $authType; + public bool $apiEnabled; + public bool $allowRobots; + public bool $disableUpdate; + public string $dbType; + public string $dbHost; + public string $dbUser; + public string $dbPassword; + public string $dbBase; + public string $dbPrefix; -$dBconfigParams = [ - 'db-type' => 'type', - 'db-host' => 'host', - 'db-user' => 'user', - 'db-password' => 'password', - 'db-base' => 'base', - 'db-prefix' => 'prefix', -]; - -$options = parseCliParams($parameters); + public function __construct() { + $this->addOption('defaultUser', (new CliOption('default-user'))->deprecatedAs('default_user')); + $this->addOption('environment', (new CliOption('environment'))); + $this->addOption('baseUrl', (new CliOption('base-url'))->deprecatedAs('base_url')); + $this->addOption('language', (new CliOption('language'))); + $this->addOption('title', (new CliOption('title'))); + $this->addOption( + 'allowAnonymous', + (new CliOption('allow-anonymous'))->withValueOptional('true')->deprecatedAs('allow_anonymous')->typeOfBool() + ); + $this->addOption( + 'allowAnonymousRefresh', + (new CliOption('allow-anonymous-refresh'))->withValueOptional('true')->deprecatedAs('allow_anonymous_refresh')->typeOfBool() + ); + $this->addOption('authType', (new CliOption('auth-type'))->deprecatedAs('auth_type')); + $this->addOption( + 'apiEnabled', + (new CliOption('api-enabled'))->withValueOptional('true')->deprecatedAs('api_enabled')->typeOfBool() + ); + $this->addOption( + 'allowRobots', + (new CliOption('allow-robots'))->withValueOptional('true')->deprecatedAs('allow_robots')->typeOfBool() + ); + $this->addOption( + 'disableUpdate', + (new CliOption('disable-update'))->withValueOptional('true')->deprecatedAs('disable_update')->typeOfBool() + ); + $this->addOption('dbType', (new CliOption('db-type'))); + $this->addOption('dbHost', (new CliOption('db-host'))); + $this->addOption('dbUser', (new CliOption('db-user'))); + $this->addOption('dbPassword', (new CliOption('db-password'))); + $this->addOption('dbBase', (new CliOption('db-base'))); + $this->addOption('dbPrefix', (new CliOption('db-prefix'))->withValueOptional()); + parent::__construct(); + } +}; -if (!empty($options['invalid'])) { - fail('Usage: ' . basename(__FILE__) . " --default-user admin ( --auth-type form" . - " --environment production --base-url https://rss.example.net --allow-robots" . - " --language en --title FreshRSS --allow-anonymous --allow-anonymous-refresh --api-enabled" . - " --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" . - " --db-base freshrss --db-prefix freshrss_ --disable-update )"); +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } fwrite(STDERR, 'Reconfiguring FreshRSS…' . "\n"); -foreach ($configParams as $param) { - if (isset($options['valid'][$param])) { - switch ($param) { - case 'allow-anonymous-refresh': - FreshRSS_Context::systemConf()->allow_anonymous_refresh = true; - break; - case 'allow-anonymous': - FreshRSS_Context::systemConf()->allow_anonymous = true; - break; - case 'allow-robots': - FreshRSS_Context::systemConf()->allow_robots = true; - break; - case 'api-enabled': - FreshRSS_Context::systemConf()->api_enabled = true; - break; - case 'auth-type': - if (in_array($options['valid'][$param], ['form', 'http_auth', 'none'], true)) { - FreshRSS_Context::systemConf()->auth_type = $options['valid'][$param]; - } else { - fail('FreshRSS invalid authentication method! auth_type must be one of { form, http_auth, none }'); - } - break; - case 'base-url': - FreshRSS_Context::systemConf()->base_url = (string) $options['valid'][$param]; - break; - case 'default-user': - if (FreshRSS_user_Controller::checkUsername((string) $options['valid'][$param])) { - FreshRSS_Context::systemConf()->default_user = (string) $options['valid'][$param]; - } else { +$values = [ + 'default_user' => $cliOptions->defaultUser ?? null, + 'environment' => $cliOptions->environment ?? null, + 'base_url' => $cliOptions->baseUrl ?? null, + 'language' => $cliOptions->language ?? null, + 'title' => $cliOptions->title ?? null, + 'allow_anonymous' => $cliOptions->allowAnonymous ?? null, + 'allow_anonymous_refresh' => $cliOptions->allowAnonymousRefresh ?? null, + 'auth_type' => $cliOptions->authType ?? null, + 'api_enabled' => $cliOptions->apiEnabled ?? null, + 'allow_robots' => $cliOptions->allowRobots ?? null, + 'disable_update' => $cliOptions->disableUpdate ?? null, +]; + +$dbValues = [ + 'type' => $cliOptions->dbType ?? null, + 'host' => $cliOptions->dbHost ?? null, + 'user' => $cliOptions->dbUser ?? null, + 'password' => $cliOptions->dbPassword ?? null, + 'base' => $cliOptions->dbBase ?? null, + 'prefix' => $cliOptions->dbPrefix ?? null, +]; + +$systemConf = FreshRSS_Context::systemConf(); +foreach ($values as $name => $value) { + if ($value !== null) { + switch ($name) { + case 'default_user': + if (!FreshRSS_user_Controller::checkUsername($value)) { fail('FreshRSS invalid default username! default_user must be ASCII alphanumeric'); } break; - case 'disable-update': - FreshRSS_Context::systemConf()->disable_update = true; - break; case 'environment': - if (in_array($options['valid'][$param], ['development', 'production', 'silent'], true)) { - FreshRSS_Context::systemConf()->environment = $options['valid'][$param]; - } else { + if (!in_array($value, ['development', 'production', 'silent'], true)) { fail('FreshRSS invalid environment! environment must be one of { development, production, silent }'); } break; - case 'language': - FreshRSS_Context::systemConf()->language = (string) $options['valid'][$param]; - break; - case 'title': - FreshRSS_Context::systemConf()->title = (string) $options['valid'][$param]; + case 'auth_type': + if (!in_array($value, ['form', 'http_auth', 'none'], true)) { + fail('FreshRSS invalid authentication method! auth_type must be one of { form, http_auth, none }'); + } break; } + // @phpstan-ignore-next-line (change to `@phpstan-ignore property.dynamicName` when upgrading to PHPStan 1.11+) + $systemConf->$name = $value; } } -$db = FreshRSS_Context::systemConf()->db; -foreach ($dBconfigParams as $dBparam => $configDbParam) { - if (isset($options['valid'][$dBparam])) { - $db[$configDbParam] = $options['valid'][$dBparam]; - } -} -/** @var array{'type':string,'host':string,'user':string,'password':string,'base':string,'prefix':string, - * 'connection_uri_params':string,'pdo_options':array<int,int|string|bool>} $db */ + +$db = array_merge(FreshRSS_Context::systemConf()->db, array_filter($dbValues)); + +performRequirementCheck($db['type']); + FreshRSS_Context::systemConf()->db = $db; FreshRSS_Context::systemConf()->save(); diff --git a/cli/update-user.php b/cli/update-user.php index 9bb3ea7ff..ff0177997 100755 --- a/cli/update-user.php +++ b/cli/update-user.php @@ -1,26 +1,85 @@ #!/usr/bin/env php <?php declare(strict_types=1); +require(__DIR__ . '/_cli.php'); -$isUpdate = true; -require(__DIR__ . '/_update-or-create-user.php'); +$cliOptions = new class extends CliOptionsParser { + public string $user; + public string $password; + public string $apiPassword; + public string $language; + public string $email; + public string $token; + public int $purgeAfterMonths; + public int $feedMinArticles; + public int $feedTtl; + public int $sinceHoursPostsPerRss; + public int $maxPostsPerRss; -$username = cliInitUser($GLOBALS['options']['valid']['user']); + public function __construct() { + $this->addRequiredOption('user', (new CliOption('user'))); + $this->addOption('password', (new CliOption('password'))); + $this->addOption('apiPassword', (new CliOption('api-password'))->deprecatedAs('api_password')); + $this->addOption('language', (new CliOption('language'))); + $this->addOption('email', (new CliOption('email'))); + $this->addOption('token', (new CliOption('token'))); + $this->addOption( + 'purgeAfterMonths', + (new CliOption('purge-after-months'))->typeOfInt()->deprecatedAs('purge_after_months') + ); + $this->addOption( + 'feedMinArticles', + (new CliOption('feed-min-articles-default'))->typeOfInt()->deprecatedAs('feed_min_articles_default') + ); + $this->addOption( + 'feedTtl', + (new CliOption('feed-ttl-default'))->typeOfInt()->deprecatedAs('feed_ttl_default') + ); + $this->addOption( + 'sinceHoursPostsPerRss', + (new CliOption('since-hours-posts-per-rss'))->typeOfInt()->deprecatedAs('since_hours_posts_per_rss') + ); + $this->addOption( + 'maxPostsPerRss', + (new CliOption('max-posts-per-rss'))->typeOfInt()->deprecatedAs('max_posts_per_rss') + ); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); +} + +$username = cliInitUser($cliOptions->user); echo 'FreshRSS updating user “', $username, "”…\n"; +$values = [ + 'language' => $cliOptions->language ?? null, + 'mail_login' => $cliOptions->email ?? null, + 'token' => $cliOptions->token ?? null, + 'old_entries' => $cliOptions->purgeAfterMonths ?? null, + 'keep_history_default' => $cliOptions->feedMinArticles ?? null, + 'ttl_default' => $cliOptions->feedTtl ?? null, + 'since_hours_posts_per_rss' => $cliOptions->sinceHoursPostsPerRss ?? null, + 'max_posts_per_rss' => $cliOptions->maxPostsPerRss ?? null, +]; + +$values = array_filter($values); + $ok = FreshRSS_user_Controller::updateUser( $username, - empty($options['valid']['email']) ? null : $options['valid']['email'], - empty($options['valid']['password']) ? '' : $options['valid']['password'], - $GLOBALS['values']); + isset($cliOptions->email) ? $cliOptions->email : null, + $cliOptions->password ?? '', + $values); if (!$ok) { fail('FreshRSS could not update user!'); } -if (!empty($options['valid']['api_password'])) { - $error = FreshRSS_api_Controller::updatePassword($options['valid']['api_password']); +if (isset($cliOptions->apiPassword)) { + $error = FreshRSS_api_Controller::updatePassword($cliOptions->apiPassword); if ($error) { fail($error); } diff --git a/cli/user-info.php b/cli/user-info.php index f492d26b2..6674ebc6b 100755 --- a/cli/user-info.php +++ b/cli/user-info.php @@ -5,45 +5,38 @@ require(__DIR__ . '/_cli.php'); const DATA_FORMAT = "%-7s | %-20s | %-5s | %-7s | %-25s | %-15s | %-10s | %-10s | %-10s | %-10s | %-10s | %-10s | %-5s | %-10s\n"; -$parameters = [ - 'long' => [ - 'user' => ':', - 'header' => '', - 'json' => '', - 'human-readable' => '', - ], - 'short' => [ - 'human-readable' => 'h', - ], - 'deprecated' => [], -]; +$cliOptions = new class extends CliOptionsParser { + /** @var array<int,string> $user */ + public array $user; + public string $header; + public string $json; + public string $humanReadable; -$options = parseCliParams($parameters); + public function __construct() { + $this->addOption('user', (new CliOption('user'))->typeOfArrayOfString()); + $this->addOption('header', (new CliOption('header'))->withValueNone()); + $this->addOption('json', (new CliOption('json'))->withValueNone()); + $this->addOption('humanReadable', (new CliOption('human-readable', 'h'))->withValueNone()); + parent::__construct(); + } +}; -if (!empty($options['invalid'])) { - fail('Usage: ' . basename(__FILE__) . ' (--human-readable --header --json --user username --user username …)'); +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -if (empty($options['valid']['user'])) { - $users = listUsers(); -} elseif (is_array($options['valid']['user'])) { - /** @var array<string> $users */ - $users = $options['valid']['user']; -} else { - /** @var array<string> $users */ - $users = [$options['valid']['user']]; -} +$users = $cliOptions->user ?? listUsers(); sort($users); -$formatJson = isset($options['valid']['json']); +$formatJson = isset($cliOptions->json); $jsonOutput = []; if ($formatJson) { - unset($options['valid']['header']); - unset($options['valid']['human-readable']); + unset($cliOptions->header); + unset($cliOptions->humanReadable); } -if (array_key_exists('header', $options['valid'])) { +if (isset($cliOptions->header)) { printf( DATA_FORMAT, 'default', @@ -92,7 +85,7 @@ foreach ($users as $username) { 'lang' => FreshRSS_Context::userConf()->language, 'mail_login' => FreshRSS_Context::userConf()->mail_login, ); - if (isset($options['valid']['human-readable'])) { //Human format + if (isset($cliOptions->humanReadable)) { //Human format $data['last_user_activity'] = date('c', $data['last_user_activity']); $data['database_size'] = format_bytes($data['database_size']); } |