Better environment variables management UI, able to report invalid values, added support for validation in env.spec.php that will prevent saving invalid ENV value.

This commit is contained in:
Abdulmhsen B. A. A
2024-05-15 17:16:40 +03:00
parent 090b3872aa
commit c4a8e72164
4 changed files with 205 additions and 37 deletions

View File

@@ -7,6 +7,9 @@
* Avoid using complex datatypes, the value should be a simple scalar value. * Avoid using complex datatypes, the value should be a simple scalar value.
*/ */
use App\Libs\Exceptions\InvalidArgumentException;
use Cron\CronExpression;
return (function () { return (function () {
$env = [ $env = [
[ [
@@ -139,30 +142,46 @@ return (function () {
], ],
]; ];
$validateCronExpression = function ($value): bool {
if (empty($value)) {
return false;
}
try {
if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
$value = substr($value, 1, -1);
}
return (new CronExpression($value))->getNextRunDate()->getTimestamp() >= 0;
} catch (Throwable) {
throw new InvalidArgumentException('Invalid cron expression.');
}
};
// -- Do not forget to update the tasks list if you add a new task. // -- Do not forget to update the tasks list if you add a new task.
$tasks = ['import', 'export', 'push', 'progress', 'backup', 'prune', 'indexes', 'requests']; $tasks = ['import', 'export', 'push', 'progress', 'backup', 'prune', 'indexes', 'requests'];
$task_env = [ $task_env = [
[ [
'key' => 'WS_CRON_{task}', 'key' => 'WS_CRON_{TASK}',
'description' => 'Enable the {task} task.', 'description' => 'Enable the {TASK} task.',
'type' => 'bool', 'type' => 'bool',
], ],
[ [
'key' => 'WS_CRON_{task}_AT', 'key' => 'WS_CRON_{TASK}_AT',
'description' => 'The time to run the {task} task.', 'description' => 'The time to run the {TASK} task.',
'type' => 'string', 'type' => 'string',
'validate' => $validateCronExpression(...),
], ],
[ [
'key' => 'WS_CRON_{task}_ARGS', 'key' => 'WS_CRON_{TASK}_ARGS',
'description' => 'The arguments to pass to the {task} task.', 'description' => 'The arguments to pass to the {TASK} task.',
'type' => 'string', 'type' => 'string',
], ],
]; ];
foreach ($tasks as $task) { foreach ($tasks as $task) {
foreach ($task_env as $info) { foreach ($task_env as $info) {
$info['key'] = r($info['key'], ['task' => strtoupper($task)]); $info['key'] = r($info['key'], ['TASK' => strtoupper($task)]);
$info['description'] = r($info['description'], ['task' => $task]); $info['description'] = r($info['description'], ['TASK' => $task]);
$env[] = $info; $env[] = $info;
} }
} }

View File

@@ -124,11 +124,19 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="is-hidden-mobile help"> </div>
</div>
<div class="column is-12 is-hidden-mobile" v-if="envs">
<div class="content">
<ul>
<li>
Some variables values are masked for security reasons. If you need to see the value, click on edit. Some variables values are masked for security reasons. If you need to see the value, click on edit.
</li>
</ul>
</div> </div>
</div> </div>
</div>
</div> </div>
</template> </template>
@@ -140,8 +148,8 @@ useHead({title: 'Environment Variables'})
const envs = ref([]) const envs = ref([])
const toggleForm = ref(false) const toggleForm = ref(false)
const form_key = ref('') const form_key = ref()
const form_value = ref(null) const form_value = ref()
const file = ref('.env') const file = ref('.env')
const copyAPI = navigator.clipboard const copyAPI = navigator.clipboard
@@ -177,17 +185,36 @@ const addVariable = async () => {
return return
} }
// -- check if value is empty or the same
if (!form_value.value) {
notification('error', 'Error', 'Value cannot be empty.', 5000)
return
}
const data = envs.value.filter(i => i.key === key)
if (data.length > 0 && data[0].value === form_value.value) {
return cancelForm();
}
const response = await request(`/system/env/${key}`, { const response = await request(`/system/env/${key}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({value: form_value.value}) body: JSON.stringify({value: form_value.value})
}) })
if (response.ok) { if (304 === response.status) {
await loadContent() return cancelForm();
form_key.value = null
form_value.value = null
toggleForm.value = false
} }
const json = await response.json()
if (!response.ok) {
notification('error', 'Error', `${json.error.code}: ${json.error.message}`, 5000)
return
}
notification('success', 'Success', 'Environment variable successfully updated.', 5000)
await loadContent()
return cancelForm();
} }
const editEnv = (env) => { const editEnv = (env) => {
@@ -196,6 +223,12 @@ const editEnv = (env) => {
toggleForm.value = true toggleForm.value = true
} }
const cancelForm = () => {
form_key.value = null
form_value.value = null
toggleForm.value = false
}
const copyValue = (env) => navigator.clipboard.writeText(env.value) const copyValue = (env) => navigator.clipboard.writeText(env.value)
watch(toggleForm, (value) => { watch(toggleForm, (value) => {

View File

@@ -9,6 +9,7 @@ use App\Libs\Attributes\Route\Route;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\DataUtil; use App\Libs\DataUtil;
use App\Libs\EnvFile; use App\Libs\EnvFile;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\HTTP_STATUS; use App\Libs\HTTP_STATUS;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Message\ServerRequestInterface as iRequest;
@@ -19,25 +20,39 @@ final class Env
private EnvFile $envFile; private EnvFile $envFile;
private array $envSpec;
public function __construct() public function __construct()
{ {
$this->envFile = (new EnvFile(file: Config::get('path') . '/config/.env', create: true)); $this->envFile = (new EnvFile(file: Config::get('path') . '/config/.env', create: true));
}
#[Get(self::URL . '[/]', name: 'system.env')]
public function envList(iRequest $request): iResponse
{
$spec = require __DIR__ . '/../../../config/env.spec.php'; $spec = require __DIR__ . '/../../../config/env.spec.php';
foreach ($spec as &$info) { foreach ($spec as &$info) {
if (!$this->envFile->has($info['key'])) { if (!$this->envFile->has($info['key'])) {
continue; continue;
} }
$info['value'] = $this->envFile->get($info['key']); $info['value'] = $this->envFile->get($info['key']);
} }
$this->envSpec = $spec;
}
#[Get(self::URL . '[/]', name: 'system.env')]
public function envList(iRequest $request): iResponse
{
$list = [];
foreach ($this->envSpec as $info) {
if (array_key_exists('validate', $info)) {
unset($info['validate']);
}
$list[] = $info;
}
return api_response(HTTP_STATUS::HTTP_OK, [ return api_response(HTTP_STATUS::HTTP_OK, [
'data' => $spec, 'data' => $list,
'file' => Config::get('path') . '/config/.env', 'file' => Config::get('path') . '/config/.env',
]); ]);
} }
@@ -50,7 +65,9 @@ final class Env
return api_error('Invalid value for key path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); return api_error('Invalid value for key path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
} }
if (false === str_starts_with($key, 'WS_')) { $spec = $this->getSpec($key);
if (empty($spec)) {
return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST); return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST);
} }
@@ -61,6 +78,8 @@ final class Env
return api_response(HTTP_STATUS::HTTP_OK, [ return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $key, 'key' => $key,
'value' => $this->envFile->get($key), 'value' => $this->envFile->get($key),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
]); ]);
} }
@@ -68,33 +87,98 @@ final class Env
public function envUpdate(iRequest $request, array $args = []): iResponse public function envUpdate(iRequest $request, array $args = []): iResponse
{ {
$key = strtoupper((string)ag($args, 'key', '')); $key = strtoupper((string)ag($args, 'key', ''));
if (empty($key)) { if (empty($key)) {
return api_error('Invalid value for key path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); return api_error('Invalid value for key path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
} }
if (false === str_starts_with($key, 'WS_')) { $spec = $this->getSpec($key);
if (empty($spec)) {
return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST); return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST);
} }
if ('DELETE' === $request->getMethod()) { if ('DELETE' === $request->getMethod()) {
$this->envFile->remove($key); $this->envFile->remove($key)->persist();
} else {
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $key,
'value' => $this->envFile->get($key, fn() => env($key)),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
]);
}
$params = DataUtil::fromRequest($request); $params = DataUtil::fromRequest($request);
if (null === ($value = $params->get('value', null))) { if (null === ($value = $params->get('value', null))) {
return api_error(r("No value was provided for '{key}'.", [ return api_error(r("No value was provided for '{key}'.", [
'key' => $key, 'key' => $key,
]), HTTP_STATUS::HTTP_BAD_REQUEST); ]), HTTP_STATUS::HTTP_BAD_REQUEST);
} }
$this->envFile->set($key, $value); if ($value === $this->envFile->get($key)) {
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED);
} }
$this->envFile->persist(); // -- check if the string contains space but not quoted.
// symfony/dotenv throws an exception if the value contains a space but not quoted.
if (str_contains($value, ' ') && (!str_starts_with($value, '"') || !str_ends_with($value, '"'))) {
return api_error(r("The value for '{key}' must be \"quoted\", as it contains a space.", [
'key' => $key,
]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
if (false === $this->checkValue($spec, $value)) {
throw new InvalidArgumentException(r("Invalid value for '{key}'.", ['key' => $key]));
}
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$this->envFile->set($key, $value)->persist();
return api_response(HTTP_STATUS::HTTP_OK, [ return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $key, 'key' => $key,
'value' => $this->envFile->get($key, fn() => env($key)), 'value' => $this->envFile->get($key, fn() => env($key)),
'description' => ag($spec, 'description'),
'type' => ag($spec, 'type'),
]); ]);
} }
/**
* Check if the value is valid.
*
* @param array $spec the specification for the key.
* @param mixed $value the value to check.
*
* @return bool true if the value is valid, false otherwise.
*/
private function checkValue(array $spec, mixed $value): bool
{
if (ag_exists($spec, 'validate')) {
return (bool)$spec['validate']($value);
}
return true;
}
/**
* Get Information about the key.
*
* @param string $key the key to get information about.
*
* @return array the information about the key. Or an empty array if not found.
*/
private function getSpec(string $key): array
{
foreach ($this->envSpec as $info) {
if ($info['key'] !== $key) {
continue;
}
return $info;
}
return [];
}
} }

View File

@@ -1257,7 +1257,15 @@ if (!function_exists('addCors')) {
} }
if (!function_exists('deepArrayMerge')) { if (!function_exists('deepArrayMerge')) {
function deepArrayMerge(array $arrays, $preserve_integer_keys = false) /**
* Recursively merge arrays.
*
* @param array $arrays The arrays to merge.
* @param bool $preserve_integer_keys (Optional) Whether to preserve integer keys.
*
* @return array The merged array.
*/
function deepArrayMerge(array $arrays, bool $preserve_integer_keys = false): array
{ {
$result = []; $result = [];
foreach ($arrays as $array) { foreach ($arrays as $array) {
@@ -1309,3 +1317,27 @@ if (!function_exists('runCommand')) {
return $output; return $output;
} }
} }
if (!function_exists('tryCatch')) {
/**
* Try to execute a callback and catch any exceptions.
*
* @param Closure $callback The callback to execute.
* @param Closure(Throwable):mixed|null $catch (Optional) Executes when an exception is caught.
* @param Closure|null $finally (Optional) Executes after the callback and catch.
*
* @return mixed The result of the callback or the catch. or null if no catch is provided.
*/
function tryCatch(Closure $callback, Closure|null $catch = null, Closure|null $finally = null): mixed
{
try {
return $callback();
} catch (Throwable $e) {
return null !== $catch ? $catch($e) : null;
} finally {
if (null !== $finally) {
$finally();
}
}
}
}