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">
Some variables values are masked for security reasons. If you need to see the value, click on edit.
</div>
</div> </div>
</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.
</li>
</ul>
</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 {
$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->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, [ 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();
}
}
}
}