Files
api/services/nginx/app/traits/module_config_variable_t.php
T
Jeppe B 7174e3be6c fix(api): coerce string booleans before persisting module_config bool values (#359)
PHP truthy semantics treat the literal `'false'` as truthy, so `$value ? 'true' : 'false'` in `setVariableValue()` persisted every 'switch off' request as `'true'` for ~30 modules with bool config variables. Canonicalise via the existing `inputToBool()` helper before the ternary so JSON.stringify(boolean) inputs land on the right storage string.

Live repro (api-v2.truckwash.io, 2026-08-10 09:25):
```
POST /minimax/config {variable:"enabled", value:"false"} → 200
GET  /minimax/config?variable=enabled → {value:true, ...}      ← unchanged
POST /minimax/config {variable:"enabled", value:false}   → 200
GET  /minimax/config?variable=enabled → {value:false, ...}     ← JSON bool works
```

Tests: `tests/Unit/MiniMax/MiniMaxEnabledSetVariableValueTest.php` (6 assertions, captures the UPDATE column value via stubbed `updateVariableValue`/`insertVariableValue` for string/boolean × true/false × insert/update paths).

Companion pleno-vue PR #281 lands the matching UI-side fix (`onMiniMaxEnabledSwitch` re-fetches + optimistic rollback).
2026-08-10 09:21:56 +02:00

400 lines
13 KiB
PHP

<?php
namespace traits;
use classes\system_search_cache;
use Exception;
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
{
// If the value is true, 1, or "true", return true
if ($value === 'true' || $value === '1') {
return true;
}
// If the value is false, 0, or "false", return false
return false;
}
/**
* 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;
}
}