Browse Source

feat(cli): add reconfigure-user.php to read/write per-user config attributes (#8873)

* 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 <alexandre@alapetite.fr>
Julien Herr 8 hours ago
parent
commit
5bddc6bed5

+ 19 - 0
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.

+ 148 - 0
cli/reconfigure-user.php

@@ -0,0 +1,148 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+require __DIR__ . '/_cli.php';
+
+$cliOptions = new class extends CliOptionsParser {
+	public string $user;
+	public string $key;
+	public string $value;
+	public bool $list;
+	public bool $set;
+	public bool $unset;
+	public bool $valueStdin;
+	public bool $force;
+	public bool $showSecrets;
+
+	public function __construct() {
+		$this->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());

+ 5 - 0
lib/Minz/Configuration.php

@@ -150,6 +150,11 @@ class Minz_Configuration {
 		return isset($this->data[$key]);
 	}
 
+	/** @return array<string,mixed> */
+	public function toArray(): array {
+		return $this->data;
+	}
+
 	/**
 	 * Return the value of the given param.
 	 *

+ 113 - 0
tests/cli/UserConfigOptionsParserTest.php

@@ -0,0 +1,113 @@
+<?php
+declare(strict_types=1);
+
+use PHPUnit\Framework\TestCase;
+
+require_once dirname(__DIR__, 2) . '/cli/CliOption.php';
+require_once dirname(__DIR__, 2) . '/cli/CliOptionsParser.php';
+
+final class UserConfigCliOptionsTest extends CliOptionsParser {
+	public string $user;
+	public string $key;
+	public string $value;
+	public bool $list;
+	public bool $set;
+	public bool $unset;
+	public bool $valueStdin;
+	public bool $force;
+	public bool $showSecrets;
+
+	public function __construct() {
+		$this->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;
+	}
+}

+ 4 - 0
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!');
 }