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();
+ }
+ }
+ }
+}