From c4a8e72164dd990f97472bb4a675ba4c45753d9b Mon Sep 17 00:00:00 2001 From: "Abdulmhsen B. A. A" Date: Wed, 15 May 2024 17:16:40 +0300 Subject: [PATCH] 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. --- config/env.spec.php | 35 +++++++++--- frontend/pages/env.vue | 53 ++++++++++++++---- src/API/System/Env.php | 120 ++++++++++++++++++++++++++++++++++------- src/Libs/helpers.php | 34 +++++++++++- 4 files changed, 205 insertions(+), 37 deletions(-) diff --git a/config/env.spec.php b/config/env.spec.php index 973143c6..0a59816e 100644 --- a/config/env.spec.php +++ b/config/env.spec.php @@ -7,6 +7,9 @@ * Avoid using complex datatypes, the value should be a simple scalar value. */ +use App\Libs\Exceptions\InvalidArgumentException; +use Cron\CronExpression; + return (function () { $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. $tasks = ['import', 'export', 'push', 'progress', 'backup', 'prune', 'indexes', 'requests']; $task_env = [ [ - 'key' => 'WS_CRON_{task}', - 'description' => 'Enable the {task} task.', + 'key' => 'WS_CRON_{TASK}', + 'description' => 'Enable the {TASK} task.', 'type' => 'bool', ], [ - 'key' => 'WS_CRON_{task}_AT', - 'description' => 'The time to run the {task} task.', + 'key' => 'WS_CRON_{TASK}_AT', + 'description' => 'The time to run the {TASK} task.', 'type' => 'string', + 'validate' => $validateCronExpression(...), ], [ - 'key' => 'WS_CRON_{task}_ARGS', - 'description' => 'The arguments to pass to the {task} task.', + 'key' => 'WS_CRON_{TASK}_ARGS', + 'description' => 'The arguments to pass to the {TASK} task.', 'type' => 'string', ], ]; foreach ($tasks as $task) { foreach ($task_env as $info) { - $info['key'] = r($info['key'], ['task' => strtoupper($task)]); - $info['description'] = r($info['description'], ['task' => $task]); + $info['key'] = r($info['key'], ['TASK' => strtoupper($task)]); + $info['description'] = r($info['description'], ['TASK' => $task]); $env[] = $info; } } diff --git a/frontend/pages/env.vue b/frontend/pages/env.vue index 4fdf2eef..f16be7bb 100644 --- a/frontend/pages/env.vue +++ b/frontend/pages/env.vue @@ -124,11 +124,19 @@ -
- 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. +
  • +
+
+
+ @@ -140,8 +148,8 @@ useHead({title: 'Environment Variables'}) const envs = ref([]) const toggleForm = ref(false) -const form_key = ref('') -const form_value = ref(null) +const form_key = ref() +const form_value = ref() const file = ref('.env') const copyAPI = navigator.clipboard @@ -177,17 +185,36 @@ const addVariable = async () => { 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}`, { method: 'POST', body: JSON.stringify({value: form_value.value}) }) - if (response.ok) { - await loadContent() - form_key.value = null - form_value.value = null - toggleForm.value = false + if (304 === response.status) { + return cancelForm(); } + + 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) => { @@ -196,6 +223,12 @@ const editEnv = (env) => { toggleForm.value = true } +const cancelForm = () => { + form_key.value = null + form_value.value = null + toggleForm.value = false +} + const copyValue = (env) => navigator.clipboard.writeText(env.value) watch(toggleForm, (value) => { diff --git a/src/API/System/Env.php b/src/API/System/Env.php index e8f5244e..c51efb9f 100644 --- a/src/API/System/Env.php +++ b/src/API/System/Env.php @@ -9,6 +9,7 @@ use App\Libs\Attributes\Route\Route; use App\Libs\Config; use App\Libs\DataUtil; use App\Libs\EnvFile; +use App\Libs\Exceptions\InvalidArgumentException; use App\Libs\HTTP_STATUS; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; @@ -19,25 +20,39 @@ final class Env private EnvFile $envFile; + private array $envSpec; + public function __construct() { $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'; foreach ($spec as &$info) { if (!$this->envFile->has($info['key'])) { continue; } + $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, [ - 'data' => $spec, + 'data' => $list, '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); } - 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); } @@ -61,6 +78,8 @@ final class Env return api_response(HTTP_STATUS::HTTP_OK, [ 'key' => $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 { $key = strtoupper((string)ag($args, 'key', '')); - if (empty($key)) { 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); } if ('DELETE' === $request->getMethod()) { - $this->envFile->remove($key); - } else { - $params = DataUtil::fromRequest($request); - if (null === ($value = $params->get('value', null))) { - return api_error(r("No value was provided for '{key}'.", [ - 'key' => $key, - ]), HTTP_STATUS::HTTP_BAD_REQUEST); - } + $this->envFile->remove($key)->persist(); - $this->envFile->set($key, $value); + 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'), + ]); } - $this->envFile->persist(); + $params = DataUtil::fromRequest($request); + + if (null === ($value = $params->get('value', null))) { + return api_error(r("No value was provided for '{key}'.", [ + 'key' => $key, + ]), HTTP_STATUS::HTTP_BAD_REQUEST); + } + + if ($value === $this->envFile->get($key)) { + return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED); + } + + // -- 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, [ 'key' => $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 []; + } } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 273fd433..94905472 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -1257,7 +1257,15 @@ if (!function_exists('addCors')) { } 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 = []; foreach ($arrays as $array) { @@ -1309,3 +1317,27 @@ if (!function_exists('runCommand')) { 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(); + } + } + } +}