Files
FreshRSS/cli/reconfigure-user.php
Julien Herr 5bddc6bed5 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>
2026-05-24 13:25:04 +02:00

149 lines
4.5 KiB
PHP
Executable File

#!/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());