From 5bddc6bed5c6eb2eb285b9af7aa7144c71b3ca22 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 24 May 2026 13:25:04 +0200 Subject: [PATCH] feat(cli): add reconfigure-user.php to read/write per-user config attributes (#8873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add reconfigure-user.php to read/write per-user config attributes Closes #8869. Adds `cli/reconfigure-user.php`, a first-class CLI for per-user configuration attributes — the user-level equivalent of the existing `reconfigure.php` (system config). ### Usage ```sh # List all attributes (sensitive keys redacted by default) ./cli/reconfigure-user.php --user alice --list ./cli/reconfigure-user.php --user alice --list --show-secrets # Read a single attribute (exit 2 if key not found) ./cli/reconfigure-user.php --user alice --key language # Set an attribute (type inferred from existing value: bool, int, string) ./cli/reconfigure-user.php --user alice --key language --set --value fr # Set from stdin (recommended for secrets — keeps value out of shell history / ps) ./cli/reconfigure-user.php --user alice --key some_token --set --value-stdin < token.txt # Create a new key, e.g. for an extension (unknown keys rejected by default) ./cli/reconfigure-user.php --user alice --key my_ext_setting --set --value hello --force # Delete an attribute (exit 2 if key not found) ./cli/reconfigure-user.php --user alice --key some_token --unset ``` ### Changes - `cli/reconfigure-user.php` — new command - `lib/Minz/Configuration::toArray()` — exposes the full config array (used by `--list`) - `cli/README.md` — documents the new command - `tests/cli/UserConfigOptionsParserTest.php` — PHPUnit tests for the options parser, following the existing `CliOptionsParserTest` pattern (shared `cli-parser-test.php` helper) ### Test plan - `make test-all` passes - Tested manually against a local FreshRSS instance: `--list`, `--key` (get), `--set` (bool/int/string inference), `--value-stdin`, `--unset`, `--force`, error paths (unknown key without `--force`, wrong type) * unserialize allowed_classes --------- Co-authored-by: Alexandre Alapetite --- cli/README.md | 19 +++ cli/reconfigure-user.php | 148 ++++++++++++++++++++++ lib/Minz/Configuration.php | 5 + tests/cli/UserConfigOptionsParserTest.php | 113 +++++++++++++++++ tests/cli/cli-parser-test.php | 4 + 5 files changed, 289 insertions(+) create mode 100755 cli/reconfigure-user.php create mode 100644 tests/cli/UserConfigOptionsParserTest.php diff --git a/cli/README.md b/cli/README.md index cecf47278..dccade656 100644 --- a/cli/README.md +++ b/cli/README.md @@ -81,6 +81,25 @@ cd /usr/share/FreshRSS > ℹ️ More options for [the configuration of users](../config-user.default.php#L3-L5) may be set in `./data/config-user.custom.php` prior to creating new users, or in `./data/users/*/config.php` for existing users. +```sh +./cli/reconfigure-user.php --user username --list [ --show-secrets ] +# List all configuration attributes of the user, one per line as key=value. +# Known-sensitive keys (matching *hash, *key, *password, *token, *secret) are redacted as *** unless --show-secrets is given. + +./cli/reconfigure-user.php --user username --key attribute +# Read the value of a single attribute. Exit code 2 if the key does not exist. + +./cli/reconfigure-user.php --user username --key attribute --set --value 'new-value' +# Set an attribute to the given value. Type is inferred from the existing value (bool, int, string). +# Fails if the key does not exist; use --force to create a new key (e.g. for extension-specific config). + +./cli/reconfigure-user.php --user username --key attribute --set --value-stdin +# Read the new value from stdin instead of --value (recommended for secrets to avoid leaking in shell history / ps). + +./cli/reconfigure-user.php --user username --key attribute --unset +# Remove the attribute from the user's configuration. Exit code 2 if the key does not exist. +``` + ```sh ./cli/actualize-user.php --user username # Fetch feeds for the specified user. diff --git a/cli/reconfigure-user.php b/cli/reconfigure-user.php new file mode 100755 index 000000000..651e979d3 --- /dev/null +++ b/cli/reconfigure-user.php @@ -0,0 +1,148 @@ +#!/usr/bin/env php +addRequiredOption('user', (new CliOption('user'))); + $this->addOption('key', (new CliOption('key'))); + $this->addOption('value', (new CliOption('value'))); + $this->addOption('list', (new CliOption('list'))->withValueNone()); + $this->addOption('set', (new CliOption('set'))->withValueNone()); + $this->addOption('unset', (new CliOption('unset'))->withValueNone()); + $this->addOption('valueStdin', (new CliOption('value-stdin'))->withValueNone()); + $this->addOption('force', (new CliOption('force'))->withValueNone()); + $this->addOption('showSecrets', (new CliOption('show-secrets'))->withValueNone()); + parent::__construct(); + } +}; + +if (!empty($cliOptions->errors)) { + fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); +} + +$hasList = $cliOptions->list; +$hasSet = $cliOptions->set; +$hasUnset = $cliOptions->unset; +$hasStdin = $cliOptions->valueStdin; +$force = $cliOptions->force; +$showSecrets = $cliOptions->showSecrets; +$hasKey = isset($cliOptions->key); +$hasValue = isset($cliOptions->value); + +if ($hasList && ($hasSet || $hasUnset || $hasKey)) { + fail('FreshRSS error: --list cannot be combined with --key, --set, or --unset' . "\n" . $cliOptions->usage); +} +if (!$hasList && !$hasKey) { + fail('FreshRSS error: --list or --key is required' . "\n" . $cliOptions->usage); +} +if ($hasSet && $hasUnset) { + fail('FreshRSS error: --set and --unset are mutually exclusive' . "\n" . $cliOptions->usage); +} +if ($hasSet && $hasValue && $hasStdin) { + fail('FreshRSS error: --value and --value-stdin are mutually exclusive' . "\n" . $cliOptions->usage); +} +if ($hasSet && !$hasValue && !$hasStdin) { + fail('FreshRSS error: --set requires --value or --value-stdin' . "\n" . $cliOptions->usage); +} + +$username = cliInitUser($cliOptions->user); +$userConf = FreshRSS_Context::userConf(); + +function isSecretKey(string $key): bool { + return (bool) preg_match('/(hash|key|password|token|secret)$/i', $key); +} + +function formatValue(mixed $v): string { + if (is_array($v)) { + return (string) json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + if (is_bool($v)) { + return $v ? 'true' : 'false'; + } + if (!is_scalar($v)) { + return ''; + } + return (string) $v; +} + +if ($hasList) { + $allData = $userConf->toArray(); + ksort($allData); + foreach ($allData as $k => $v) { + $display = !$showSecrets && isSecretKey($k) && $v !== '' ? '***' : formatValue($v); + echo $k, '=', $display, "\n"; + } + done(); +} + +$key = $cliOptions->key; +if ($key === '') { + fail('FreshRSS error: --key cannot be empty' . "\n" . $cliOptions->usage); +} + +if (!$hasSet && !$hasUnset) { + if (!$userConf->hasParam($key)) { + fail('FreshRSS error: key not found: ' . $key, 2); + } + echo formatValue($userConf->toArray()[$key] ?? null), "\n"; + done(); +} + +if ($hasUnset) { + if (!$userConf->hasParam($key)) { + fail('FreshRSS error: key not found: ' . $key, 2); + } + $userConf->_attribute($key, null); + done($userConf->save()); +} + +if (!$userConf->hasParam($key) && !$force) { + fail('FreshRSS error: unknown key "' . $key . '". Use --force to set it anyway, or use the "extensions" sub-key for extension-specific config.'); +} + +$rawValue = $hasStdin + ? rtrim((string) stream_get_contents(STDIN), "\n\r") + : $cliOptions->value; + +if ($userConf->hasParam($key)) { + $existing = $userConf->toArray()[$key] ?? null; + if (is_array($existing)) { + fail('FreshRSS error: key "' . $key . '" is an array type and cannot be set via CLI'); + } elseif (is_bool($existing)) { + $typed = filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($typed === null) { + fail('FreshRSS error: key "' . $key . '" expects a boolean value (true/false/1/0)'); + } + } elseif (is_int($existing)) { + if (!ctype_digit(ltrim($rawValue, '-'))) { + fail('FreshRSS error: key "' . $key . '" expects an integer value'); + } + $typed = (int) $rawValue; + } else { + $typed = $rawValue; + } +} else { + $lower = strtolower($rawValue); + if (in_array($lower, ['true', 'false'], true)) { + $typed = $lower === 'true'; + } elseif ($rawValue !== '' && ctype_digit(ltrim($rawValue, '-'))) { + $typed = (int) $rawValue; + } else { + $typed = $rawValue; + } +} + +$userConf->_attribute($key, $typed); +done($userConf->save()); diff --git a/lib/Minz/Configuration.php b/lib/Minz/Configuration.php index bb4a8b09e..968132533 100644 --- a/lib/Minz/Configuration.php +++ b/lib/Minz/Configuration.php @@ -150,6 +150,11 @@ class Minz_Configuration { return isset($this->data[$key]); } + /** @return array */ + public function toArray(): array { + return $this->data; + } + /** * Return the value of the given param. * diff --git a/tests/cli/UserConfigOptionsParserTest.php b/tests/cli/UserConfigOptionsParserTest.php new file mode 100644 index 000000000..1c60459b2 --- /dev/null +++ b/tests/cli/UserConfigOptionsParserTest.php @@ -0,0 +1,113 @@ +addRequiredOption('user', (new CliOption('user'))); + $this->addOption('key', (new CliOption('key'))); + $this->addOption('value', (new CliOption('value'))); + $this->addOption('list', (new CliOption('list'))->withValueNone()); + $this->addOption('set', (new CliOption('set'))->withValueNone()); + $this->addOption('unset', (new CliOption('unset'))->withValueNone()); + $this->addOption('valueStdin', (new CliOption('value-stdin'))->withValueNone()); + $this->addOption('force', (new CliOption('force'))->withValueNone()); + $this->addOption('showSecrets', (new CliOption('show-secrets'))->withValueNone()); + parent::__construct(); + } +} + +class UserConfigOptionsParserTest extends TestCase { + + public static function testUserIsRequired(): void { + $result = self::runOptions(''); + self::assertArrayHasKey('user', $result->errors); + } + + public static function testUserProvided(): void { + $result = self::runOptions('--user=alice'); + self::assertEmpty($result->errors); + self::assertSame('alice', $result->user); + } + + public static function testListFlag(): void { + $result = self::runOptions('--user=alice --list'); + self::assertTrue($result->list); + self::assertFalse($result->set); + self::assertFalse($result->unset); + } + + public static function testShowSecretsFlag(): void { + $result = self::runOptions('--user=alice --list --show-secrets'); + self::assertTrue($result->list); + self::assertTrue($result->showSecrets); + } + + public static function testSetFlagWithValue(): void { + $result = self::runOptions('--user=alice --key=language --set --value=fr'); + self::assertTrue($result->set); + self::assertFalse($result->list); + self::assertFalse($result->unset); + self::assertSame('language', $result->key); + self::assertSame('fr', $result->value); + } + + public static function testUnsetFlag(): void { + $result = self::runOptions('--user=alice --key=language --unset'); + self::assertTrue($result->unset); + self::assertFalse($result->set); + } + + public static function testValueStdinFlag(): void { + $result = self::runOptions('--user=alice --key=token --set --value-stdin'); + self::assertTrue($result->set); + self::assertTrue($result->valueStdin); + } + + public static function testForceFlag(): void { + $result = self::runOptions('--user=alice --key=custom --set --value=hello --force'); + self::assertTrue($result->set); + self::assertTrue($result->force); + } + + public static function testGetKey(): void { + $result = self::runOptions('--user=alice --key=language'); + self::assertEmpty($result->errors); + self::assertSame('language', $result->key); + self::assertFalse($result->set); + self::assertFalse($result->unset); + self::assertFalse($result->list); + } + + public static function testUnknownOptionReturnsError(): void { + $result = self::runOptions('--user=alice --unknown'); + self::assertArrayHasKey('unknown', $result->errors); + } + + private static function runOptions(string $cliOptions = ''): UserConfigCliOptionsTest { + $command = __DIR__ . '/cli-parser-test.php'; + $className = UserConfigCliOptionsTest::class; + + $result = shell_exec("CLI_PARSER_TEST_OPTIONS_CLASS='$className' $command $cliOptions 2>/dev/null"); + $result = is_string($result) ? + unserialize($result, ['allowed_classes' => [UserConfigCliOptionsTest::class]]) : + new UserConfigCliOptionsTest(); + + /** @var UserConfigCliOptionsTest $result */ + return $result; + } +} diff --git a/tests/cli/cli-parser-test.php b/tests/cli/cli-parser-test.php index a6b157474..8af28475f 100755 --- a/tests/cli/cli-parser-test.php +++ b/tests/cli/cli-parser-test.php @@ -4,6 +4,7 @@ declare(strict_types=1); require dirname(__DIR__, 2) . '/vendor/autoload.php'; require __DIR__ . '/CliOptionsParserTest.php'; +require __DIR__ . '/UserConfigOptionsParserTest.php'; $optionsClass = getenv('CLI_PARSER_TEST_OPTIONS_CLASS'); if (!is_string($optionsClass) || !class_exists($optionsClass)) { @@ -17,6 +18,9 @@ switch ($optionsClass) { case CliOptionsOptionalAndRequiredTest::class: $options = new CliOptionsOptionalAndRequiredTest(); break; + case UserConfigCliOptionsTest::class: + $options = new UserConfigCliOptionsTest(); + break; default: die('Unknown test static method!'); }