## What
Centralises the truthy-string → bool coercion that six different classes
were reimplementing (and which two implementations disagreed about).
The shared helper lives in
`services/nginx/app/traits/boolean_normalization_t.php`:
```php
namespace traits;
trait boolean_normalization_t {
public static function normalizeBoolean(mixed $value): bool {
if (is_bool($value)) return $value;
return in_array(strtolower(trim((string)$value)), ['1','true','yes','on'], true);
}
}
```
`traits/module_config_variable_t::inputToBool` now delegates to it. The
seven call sites that previously inlined the same expression (or wrapped
it in a private `toBool`/`boolValue`/`isEnabled`) are reduced to a
single `self::normalizeBoolean(...)` call:
| Class | Old helper | New |
| --- | --- | --- |
| `classes/cron_worker.php` | inline in `boolOption` |
`self::normalizeBoolean(...)` (after empty-value short-circuit) |
| `classes/replica_failover_manager.php` | `boolValue` |
`self::normalizeBoolean(...)` |
| `classes/release_manager.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/releasemanager.php` | `isEnabled` |
`self::normalizeBoolean(...)` |
| `classes/superuser_system_status_service.php` | inline in
`parseModuleConfigValue` | `self::normalizeBoolean(...)` |
| `classes/module_usage_service.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/account_deletion_service.php` | inline in `apiEnabled` |
`self::normalizeBoolean(...)` |
| `traits/module_config_variable_t.php` | `inputToBool` (narrow set) |
`self::normalizeBoolean(...)` (full set) |
## Why
The pre-PR repo had two silent bugs:
1. **`inputToBool` accepted only `'true'`/`'1'`** while the six inline
copies accepted the wider `['1','true','yes','on']` set. Config values
such as `"yes"` or `" ON "` would round-trip to `false` through
`inputToBool` but `true` through any of the inline copies. This PR picks
the wider set as the single source of truth; the change is a strict
superset, so no caller flips from truthy to falsy.
2. **Six copies of the same expression** to drift in any of the seven
places (whitespace handling, case sensitivity, empty-string semantics).
One trait replaces them.
## Tests
* `tests/Unit/Traits/BooleanNormalizationTest.php` — Pest, runs the
helper directly through anonymous-class composition (no DB/HTTP).
* `tests/Smoke/boolean_normalization_smoke.php` — standalone PHP smoke
runner for environments without composer installed. Verified locally:
7/7 consumer wiring checks pass, 19/19 normalization cases pass (`true`,
`false`, `1`, `0`, `'true'`, `'TRUE'`, `'1'`, `'yes'`, `'YES'`, `'on'`,
`' ON '`, `'false'`, `'no'`, `'off'`, `''`, `null`, `'0'`, `[]`,
stdClass).
* All eight touched files pass `php -l` syntax check.
## Risk
* Behavioural change is a strict superset for the shared expression
path, so no caller can flip from truthy → falsy. The only consumer that
saw a behaviour change for *negative* inputs is `inputToBool` itself,
which previously rejected `'yes'`/`'on'`. Worth a CI pass on the
unit/integration suites before merge.
## Co-author
Co-authored-by: openhands <openhands@all-hands.dev>
---
_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._
---------
Co-authored-by: openhands <openhands@all-hands.dev>
400 lines
13 KiB
PHP
400 lines
13 KiB
PHP
<?php
|
|
|
|
namespace traits;
|
|
|
|
use classes\system_search_cache;
|
|
use Exception;
|
|
|
|
require_once __DIR__ . '/boolean_normalization_t.php';
|
|
|
|
trait module_config_variable
|
|
{
|
|
public string $module_name; // The name of the module
|
|
public string $config_variable; // The name of the config variable in the database (e.g. layout_id)
|
|
public string $config_variable_type; // The type of the config variable (e.g. int)
|
|
public bool $config_variable_required; // Whether the config variable is required or not
|
|
public array|null $allowed_values; // An array of allowed values for the config variable. If this is null, any value is allowed
|
|
public string $config_variable_description; // A description of the config variable
|
|
public string $config_variable_example; // An example of the config variable
|
|
public bool $config_variable_is_secret; // Whether the config variable is a secret or not
|
|
|
|
/**
|
|
* Set up the config variable
|
|
* @param string $module_name
|
|
* @param string $config_variable
|
|
* @param string $config_variable_type
|
|
* @param bool $config_variable_required
|
|
* @param array|null $allowed_values
|
|
* @param string $config_variable_description
|
|
* @param string $config_variable_example
|
|
* @param bool $config_variable_is_secret
|
|
* @param mixed $default_value
|
|
* @return void
|
|
* @throws Exception
|
|
*/
|
|
function setupConfigVariable(
|
|
string $module_name,
|
|
string $config_variable,
|
|
string $config_variable_type,
|
|
bool $config_variable_required,
|
|
array|null $allowed_values,
|
|
string $config_variable_description,
|
|
string $config_variable_example,
|
|
bool $config_variable_is_secret = false,
|
|
mixed $default_value = null
|
|
): void
|
|
{
|
|
$this->module_name = $module_name;
|
|
$this->config_variable = $config_variable;
|
|
$this->config_variable_type = $config_variable_type;
|
|
$this->config_variable_required = $config_variable_required;
|
|
$this->allowed_values = $allowed_values;
|
|
$this->config_variable_description = $config_variable_description;
|
|
$this->config_variable_example = $config_variable_example;
|
|
$this->config_variable_is_secret = $config_variable_is_secret;
|
|
|
|
// Check if the config variable is set in the database
|
|
if (!self::isVariableSet($module_name, $config_variable)) {
|
|
// If the config variable is required, throw an exception
|
|
if ($config_variable_required && $default_value === null) {
|
|
throw new Exception('Config variable not set: ' . $config_variable);
|
|
} else {
|
|
// If the config variable is not required, set the default value
|
|
self::insertVariableValue($module_name, $config_variable, $default_value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the config variable is set in the database
|
|
* @param string $module
|
|
* @param string $variable
|
|
* @return bool
|
|
*/
|
|
static function isVariableSet(string $module, string $variable): bool
|
|
{
|
|
$db = self::getModuleConfigDatabase();
|
|
$module = $db->escape_string($module);
|
|
$variable = $db->escape_string($variable);
|
|
$sql = "SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable'";
|
|
$result = $db->query($sql);
|
|
return $result->num_rows > 0;
|
|
}
|
|
|
|
/**
|
|
* Insert the value of the config variable in the database
|
|
* @param string $module
|
|
* @param string $variable
|
|
* @param mixed $value
|
|
*/
|
|
function insertVariableValue(string $module, string $variable, mixed $value): void
|
|
{
|
|
$db = self::getModuleConfigDatabase();
|
|
$module = $db->escape_string($module);
|
|
$variable = $db->escape_string($variable);
|
|
$value = $db->escape_string((string)$value);
|
|
$type = $db->escape_string(self::getVariableType());
|
|
$sql = "INSERT INTO module_config (module, variable, value, type) VALUES ('$module', '$variable', '$value', '$type')";
|
|
$db->query($sql);
|
|
system_search_cache::markDirtyTable('module_config');
|
|
}
|
|
|
|
/**
|
|
* Get the variable type
|
|
* @return string
|
|
*/
|
|
function getVariableType(): string
|
|
{
|
|
return $this->config_variable_type;
|
|
}
|
|
|
|
/**
|
|
* Is boolean variable true?
|
|
* @return bool
|
|
* @throws Exception
|
|
*/
|
|
function isTrue(): bool
|
|
{
|
|
// Make sure the variable is a boolean
|
|
if ($this->config_variable_type !== 'bool') {
|
|
throw new Exception('Config variable is not a boolean: ' . $this->config_variable . ' (' . $this->config_variable_type . ')');
|
|
}
|
|
return $this->getVariableValue() === 'true';
|
|
}
|
|
|
|
/**
|
|
* Get the value of the config variable from the database
|
|
* @return mixed
|
|
*/
|
|
function getVariableValue(): mixed
|
|
{
|
|
$db = self::getModuleConfigDatabase();
|
|
$module = $db->escape_string($this->module_name);
|
|
$variable = $db->escape_string($this->config_variable);
|
|
$sql = "SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable'";
|
|
$result = $db->query($sql);
|
|
// return the value of the config variable
|
|
return $result->fetch_assoc()['value'];
|
|
}
|
|
|
|
public function __toString(): string
|
|
{
|
|
// Return the config variable value as a string
|
|
return $this->getVariableValue();
|
|
}
|
|
|
|
/**
|
|
* Set the value of the config variable in the database
|
|
* @param mixed $value
|
|
* @throws Exception
|
|
*/
|
|
function setVariableValue(mixed $value): void
|
|
{
|
|
// Check if the variable value is valid
|
|
if (!$this->validateVariableValue($value)) {
|
|
throw new Exception('Invalid value for config variable: ' . $this->config_variable . ' (' . $value . ')');
|
|
}
|
|
// If the type is a boolean, convert the value to a real boolean first.
|
|
// PHP's truthy semantics treat any non-empty string — including the
|
|
// literal 'false' — as truthy, so a naive `$value ? 'true' : 'false'`
|
|
// would persist the string 'false' as 'true'. The frontend sends JSON
|
|
// booleans as the strings 'true' / 'false', so this branch runs on
|
|
// every toggle. inputToBool() canonicalises both string and boolean
|
|
// inputs to a real bool before the ternary.
|
|
if ($this->config_variable_type === 'bool') {
|
|
$value = is_string($value) ? self::inputToBool($value) : (bool)$value;
|
|
$value = $value ? 'true' : 'false';
|
|
}
|
|
// Update the value of the config variable in the database, and insert it if it does not exist
|
|
if (self::isVariableSet($this->module_name, $this->config_variable)) {
|
|
self::updateVariableValue($this->module_name, $this->config_variable, $value);
|
|
} else {
|
|
self::insertVariableValue($this->module_name, $this->config_variable, $value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate the value of the config variable
|
|
* @param mixed $value
|
|
* @return bool
|
|
*/
|
|
function validateVariableValue(mixed $value): bool
|
|
{
|
|
if ($value === null) {
|
|
return !$this->config_variable_required;
|
|
}
|
|
|
|
if ($value === '' && !$this->config_variable_required && $this->config_variable_type === 'int') {
|
|
return true;
|
|
}
|
|
|
|
// Check if the value is empty and the variable is required
|
|
if ($this->config_variable_required && empty($value)) {
|
|
// If the value is empty and the type is not a boolean, return false
|
|
if ($this->config_variable_type != 'bool') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check if the value is in the list of allowed values
|
|
if ($this->allowed_values && !in_array($value, $this->allowed_values)) {
|
|
return false;
|
|
}
|
|
|
|
// Check if the value is of the correct type
|
|
switch ($this->config_variable_type) {
|
|
case 'int':
|
|
if (!is_numeric($value)) {
|
|
return false;
|
|
}
|
|
break;
|
|
case 'string':
|
|
if (!is_string($value)) {
|
|
return false;
|
|
}
|
|
break;
|
|
case 'bool':
|
|
// If the value is a string, convert it to "true" or "false"
|
|
if (is_string($value)) {
|
|
$value = self::inputToBool($value);
|
|
}
|
|
if (!is_bool($value)) {
|
|
return false;
|
|
}
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Convert a string to a boolean
|
|
* @param string $value
|
|
* @return bool
|
|
*/
|
|
static function inputToBool(string $value): bool
|
|
{
|
|
// Delegate to the shared boolean_normalization_t helper so the
|
|
// truthy-string set ('1', 'true', 'yes', 'on') lives in exactly one
|
|
// place across the codebase.
|
|
return boolean_normalization_t::normalizeBoolean($value);
|
|
}
|
|
|
|
/**
|
|
* Update the value of the config variable in the database
|
|
* @param string $module
|
|
* @param string $variable
|
|
* @param mixed $value
|
|
*/
|
|
static function updateVariableValue(string $module, string $variable, mixed $value): void
|
|
{
|
|
$db = self::getModuleConfigDatabase();
|
|
$module = $db->escape_string($module);
|
|
$variable = $db->escape_string($variable);
|
|
$value = $db->escape_string((string)$value);
|
|
$sql = "UPDATE module_config SET value = '$value' WHERE module = '$module' AND variable = '$variable'";
|
|
$db->query($sql);
|
|
system_search_cache::markDirtyTable('module_config');
|
|
}
|
|
|
|
private static function getModuleConfigDatabase(): \classes\db
|
|
{
|
|
global $db, $CONFIG_DB;
|
|
|
|
if ($db instanceof \classes\db) {
|
|
return $db;
|
|
}
|
|
|
|
if (!is_array($CONFIG_DB) || $CONFIG_DB === []) {
|
|
$CONFIG_DB = self::readDatabaseConfigFromEnvironment();
|
|
}
|
|
|
|
if (!is_array($CONFIG_DB) || $CONFIG_DB === []) {
|
|
throw new Exception('Database configuration is not available');
|
|
}
|
|
|
|
$db = new \classes\db($CONFIG_DB);
|
|
$db->connect();
|
|
|
|
return $db;
|
|
}
|
|
|
|
private static function readDatabaseConfigFromEnvironment(): array
|
|
{
|
|
$readEnv = static function (string $key, string $default = ''): string {
|
|
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
|
|
if ($value === false || $value === null) {
|
|
return $default;
|
|
}
|
|
|
|
return trim((string)$value);
|
|
};
|
|
|
|
$dbTarget = strtolower(trim($readEnv('CONFIG_DB_TARGET', 'live')));
|
|
if ($dbTarget !== 'live' && $dbTarget !== 'debug') {
|
|
$dbTarget = 'live';
|
|
}
|
|
|
|
$resolveDbValue = static function (string $key) use ($dbTarget, $readEnv): string {
|
|
$liveValue = $readEnv('CONFIG_DB_' . $key);
|
|
$debugValue = $readEnv('CONFIG_DB_DEBUG_' . $key);
|
|
|
|
if ($dbTarget === 'debug' && $debugValue !== '') {
|
|
return $debugValue;
|
|
}
|
|
|
|
return $liveValue;
|
|
};
|
|
|
|
$host = $resolveDbValue('HOST');
|
|
$user = $resolveDbValue('USER');
|
|
$database = $resolveDbValue('DATABASE');
|
|
|
|
if ($host === '' || $user === '' || $database === '') {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'host' => $host,
|
|
'user' => $user,
|
|
'password' => $resolveDbValue('PASSWORD'),
|
|
'database' => $database,
|
|
'port' => (int)($resolveDbValue('PORT') ?: '3306'),
|
|
'ssl_mode' => $resolveDbValue('SSL_MODE') ?: 'DISABLED',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the config variable as a JSON string
|
|
* @return string
|
|
*/
|
|
function asJson(): string
|
|
{
|
|
return json_encode($this->asArray());
|
|
}
|
|
|
|
/**
|
|
* Get the config variable as an array
|
|
* @return array
|
|
*/
|
|
function asArray(): array
|
|
{
|
|
return [
|
|
'module' => $this->module_name,
|
|
'variable' => $this->config_variable,
|
|
'type' => $this->config_variable_type,
|
|
'required' => $this->config_variable_required,
|
|
'allowed_values' => $this->allowed_values,
|
|
'description' => $this->config_variable_description,
|
|
'example' => $this->config_variable_example,
|
|
'value' => $this->getVariableValue(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the variable name
|
|
* @return string
|
|
*/
|
|
function getVariableName(): string
|
|
{
|
|
return $this->config_variable;
|
|
}
|
|
|
|
/**
|
|
* Get the variable description
|
|
* @return string
|
|
*/
|
|
function getVariableDescription(): string
|
|
{
|
|
return $this->config_variable_description;
|
|
}
|
|
|
|
/**
|
|
* Get the variable example
|
|
* @return string
|
|
*/
|
|
function getVariableExample(): string
|
|
{
|
|
return $this->config_variable_example;
|
|
}
|
|
|
|
/**
|
|
* Get the variable is secret
|
|
* @return bool
|
|
*/
|
|
function getVariableIsSecret(): bool
|
|
{
|
|
return $this->config_variable_is_secret;
|
|
}
|
|
|
|
/**
|
|
* Get the variable required
|
|
* @return bool
|
|
*/
|
|
function getVariableRequired(): bool
|
|
{
|
|
return $this->config_variable_required;
|
|
}
|
|
}
|