Made it possible to validate servers.spec options when updating them via API. Migrated config:edit to use the API to reduce code duplication.

This commit is contained in:
Abdulmhsen B. A. A.
2024-05-20 18:41:58 +03:00
parent 1ec70ca69e
commit c85037f9cd
8 changed files with 159 additions and 311 deletions

View File

@@ -7,7 +7,7 @@
* 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 App\Libs\Exceptions\ValidationException;
use Cron\CronExpression; use Cron\CronExpression;
return (function () { return (function () {
@@ -147,19 +147,26 @@ return (function () {
], ],
]; ];
$validateCronExpression = function ($value): bool { $validateCronExpression = function (string $value): string {
if (empty($value)) { if (empty($value)) {
return false; throw new ValidationException('Invalid cron expression. Empty value.');
} }
try { try {
if (str_starts_with($value, '"') && str_ends_with($value, '"')) { if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
$value = substr($value, 1, -1); $value = substr($value, 1, -1);
} }
return (new CronExpression($value))->getNextRunDate()->getTimestamp() >= 0;
} catch (Throwable) { $status = (new CronExpression($value))->getNextRunDate()->getTimestamp() >= 0;
throw new InvalidArgumentException('Invalid cron expression.');
if (!$status) {
throw new ValidationException('Invalid cron expression. The next run date is in the past.');
}
} catch (Throwable $e) {
throw new ValidationException(r('Invalid cron expression. {error}', ['error' => $e->getMessage()]));
} }
return $value;
}; };
// -- 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.

View File

@@ -8,6 +8,9 @@
* This file defines the backend spec. * This file defines the backend spec.
* The dot (.) means the string past the dot is sub key of the string preceding it. * The dot (.) means the string past the dot is sub key of the string preceding it.
*/ */
use App\Libs\Exceptions\ValidationException;
return [ return [
[ [
'key' => 'name', 'key' => 'name',
@@ -99,9 +102,15 @@ return [
], ],
[ [
'key' => 'options.LIBRARY_SEGMENT', 'key' => 'options.LIBRARY_SEGMENT',
'type' => 'string', 'type' => 'int',
'visible' => true, 'visible' => true,
'description' => 'How many items to per request.', 'description' => 'How many items to per request.',
'validate' => function ($value) {
if ((int)$value < 100) {
throw new ValidationException('The value must be greater than 100 items.');
}
return (int)$value;
},
], ],
[ [
'key' => 'options.ADMIN_TOKEN', 'key' => 'options.ADMIN_TOKEN',

View File

@@ -4,13 +4,11 @@ declare(strict_types=1);
namespace App\API\Backend; namespace App\API\Backend;
use App\Libs\Attributes\Route\Delete; use App\Libs\Attributes\Route\Route;
use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Patch;
use App\Libs\Attributes\Route\Post;
use App\Libs\Config; use App\Libs\Config;
use App\Libs\ConfigFile; use App\Libs\ConfigFile;
use App\Libs\DataUtil; use App\Libs\DataUtil;
use App\Libs\Exceptions\ValidationException;
use App\Libs\HTTP_STATUS; use App\Libs\HTTP_STATUS;
use App\Libs\Traits\APITraits; use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
@@ -20,20 +18,30 @@ final class Option
{ {
use APITraits; use APITraits;
#[Get(Index::URL . '/{name:backend}/option/{option}[/]', name: 'backend.option')] #[Route(['GET', 'POST', 'PATCH', 'DELETE'], Index::URL . '/{name:backend}/option[/{option}[/]]')]
public function viewOption(iRequest $request, array $args = []): iResponse public function __invoke(iRequest $request, array $args = []): iResponse
{ {
if (null === ($name = ag($args, 'name'))) { if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
} }
if (null === ($option = ag($args, 'option'))) { $list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
return api_error('Invalid value for option path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
if (false === $list->has($name)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
} }
if (false === str_starts_with($option, 'options.')) { $data = DataUtil::fromRequest($request);
if (null === ($option = ag($args, 'option', $data->get('key')))) {
return api_error('No option key was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$isInternalRequest = true === (bool)$request->getAttribute('INTERNAL_REQUEST', false);
if (false === str_starts_with($option, 'options.') && !$isInternalRequest) {
return api_error( return api_error(
"Invalid option. Option path parameter keys must start with 'options.'", "Invalid option key was given. Option keys must start with 'options.'",
HTTP_STATUS::HTTP_BAD_REQUEST HTTP_STATUS::HTTP_BAD_REQUEST
); );
} }
@@ -43,21 +51,63 @@ final class Option
return api_error(r("Invalid option '{key}'.", ['key' => $option]), HTTP_STATUS::HTTP_BAD_REQUEST); return api_error(r("Invalid option '{key}'.", ['key' => $option]), HTTP_STATUS::HTTP_BAD_REQUEST);
} }
$list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true); if ('GET' === $request->getMethod()) {
if (false === $list->has($name . '.' . $option)) {
return api_error(r("Option '{option}' not found in backend '{name}' config.", [
'option' => $option,
'name' => $name
]), HTTP_STATUS::HTTP_NOT_FOUND);
}
if (false === $list->has($name)) { return $this->viewOption($spec, $list->get("{$name}.{$option}"));
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
} }
if (false === $list->has($name . '.' . $option)) { if ('DELETE' === $request->getMethod() && false === $list->has("{$name}.{$option}")) {
return api_error(r("Option '{option}' not found in backend '{name}' config.", [ return api_error(r("Option '{option}' not found in backend '{name}' config.", [
'option' => $option, 'option' => $option,
'name' => $name 'name' => $name
]), HTTP_STATUS::HTTP_NOT_FOUND); ]), HTTP_STATUS::HTTP_NOT_FOUND);
} }
$value = $list->get($name . '.' . $option); if ('DELETE' === $request->getMethod()) {
settype($value, ag($spec, 'type', 'string')); if (null !== ($value = $list->get($name . '.' . $option))) {
settype($value, ag($spec, 'type', 'string'));
}
$list->delete("{$name}.{$option}");
} else {
if (null !== ($value = $data->get('value'))) {
settype($value, ag($spec, 'type', 'string'));
}
if (ag_exists($spec, 'validate')) {
try {
$value = $spec['validate']($value, $spec);
} catch (ValidationException $e) {
return api_error(r("Value validation for '{key}' failed. {error}", [
'key' => $option,
'error' => $e->getMessage()
]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
}
$list->set("{$name}.{$option}", $value);
}
$list->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => ag($spec, 'type', 'string'),
'description' => ag($spec, 'description', ''),
]);
}
public function viewOption(array $spec, mixed $value): iResponse
{
if (null !== $value) {
settype($value, ag($spec, 'type', 'string'));
}
return api_response(HTTP_STATUS::HTTP_OK, [ return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $spec['key'], 'key' => $spec['key'],
@@ -67,170 +117,4 @@ final class Option
]); ]);
} }
#[Post(Index::URL . '/{name:backend}/option[/]', name: 'backend.option.add')]
public function addOption(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
if (false === $list->has($name)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$data = DataUtil::fromRequest($request);
if (null === ($option = $data->get('key'))) {
return api_error('No option key was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === str_starts_with($option, 'options.')) {
return api_error(
"Invalid option key was given. Option keys must start with 'options.'",
HTTP_STATUS::HTTP_BAD_REQUEST
);
}
$spec = getServerColumnSpec($option);
if (empty($spec)) {
return api_error(r("Invalid option '{key}'.", ['key' => $option]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
if ($list->has($name . '.' . $option)) {
return api_error(r("Option '{option}' already exists in backend '{name}'.", [
'option' => $option,
'name' => $name
]), HTTP_STATUS::HTTP_CONFLICT);
}
$value = $data->get('value');
settype($value, ag($spec, 'type', 'string'));
$list->set($name . '.' . $option, $value)->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => ag($spec, 'type', 'string'),
'description' => ag($spec, 'description', ''),
]);
}
#[Patch(Index::URL . '/{name:backend}/option/{option}[/]', name: 'backend.option.update')]
public function updateOption(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (null === ($option = ag($args, 'option'))) {
return api_error('Invalid value for option parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === str_starts_with($option, 'options.')) {
return api_error(
"Invalid option key was given. Option keys must start with 'options.'",
HTTP_STATUS::HTTP_BAD_REQUEST
);
}
$spec = getServerColumnSpec($option);
if (empty($spec)) {
return api_error(r("Invalid option '{key}'.", ['key' => $option]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
if (false === $list->has($name)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
if (false === $list->has($name . '.' . $option)) {
return api_error(r("Option '{option}' not found in backend '{name}' config.", [
'option' => $option,
'name' => $name
]), HTTP_STATUS::HTTP_NOT_FOUND);
}
if (true === (bool)ag($spec, 'immutable', false)) {
return api_error(r("Option '{option}' is immutable.", [
'option' => $option,
]), HTTP_STATUS::HTTP_CONFLICT);
}
$data = DataUtil::fromRequest($request);
if (null === ($value = $data->get('value'))) {
return api_error(r("No value was provided for '{key}'.", [
'key' => $option,
]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
settype($value, ag($spec, 'type', 'string'));
$oldValue = $list->get($name . '.' . $option);
if (null !== $oldValue) {
settype($oldValue, ag($spec, 'type', 'string'));
}
if ($oldValue !== $value) {
$list->set($name . '.' . $option, $value)->persist();
}
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => ag($spec, 'type', 'string'),
'description' => ag($spec, 'description', ''),
]);
}
#[Delete(Index::URL . '/{name:backend}/option/{option}[/]', name: 'backend.option.delete')]
public function deleteOption(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (null === ($option = ag($args, 'option'))) {
return api_error('Invalid value for option option parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === str_starts_with($option, 'options.')) {
return api_error(
"Invalid option key was given. Option keys must start with 'options.'",
HTTP_STATUS::HTTP_BAD_REQUEST
);
}
$spec = getServerColumnSpec($option);
if (empty($spec)) {
return api_error(r("Invalid option '{key}'.", ['key' => $option]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
if (false === $list->has($name)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
if (false === $list->has($name . '.' . $option)) {
return api_error(r("Option '{option}' not found in backend '{name}' config.", [
'option' => $option,
'name' => $name
]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$value = $list->get($name . '.' . $option);
settype($value, ag($spec, 'type', 'string'));
$list->delete($name . '.' . $option)->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => ag($spec, 'type', 'string'),
'description' => ag($spec, 'description', ''),
]);
}
} }

View File

@@ -9,7 +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\Exceptions\ValidationException;
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;
@@ -124,14 +124,21 @@ final class Env
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED); return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED);
} }
try { try {
$value = $this->setType($spec, $value); $value = $this->setType($spec, $value);
if (false === $this->checkValue($spec, $value)) { if (true === is_string($value)) {
throw new InvalidArgumentException(r("Invalid value for '{key}'.", ['key' => $key])); // -- 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, '"'))) {
throw new ValidationException('The value must be "quoted string", as it contains a space.');
}
} }
} catch (InvalidArgumentException $e) {
if (true === ag_exists($spec, 'validate')) {
$value = $spec['validate']($value, $spec);
}
} catch (ValidationException $e) {
return api_error(r("Value validation for '{key}' failed. {error}", [ return api_error(r("Value validation for '{key}' failed. {error}", [
'key' => $key, 'key' => $key,
'error' => $e->getMessage() 'error' => $e->getMessage()
@@ -148,31 +155,6 @@ final class Env
]); ]);
} }
/**
* 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 (true === is_string($value)) {
// -- 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, '"'))) {
throw new InvalidArgumentException('The value must be "quoted string", as it contains a space.');
}
}
if (ag_exists($spec, 'validate')) {
return (bool)$spec['validate']($value);
}
return true;
}
/** /**
* Get Information about the key. * Get Information about the key.
* *

View File

@@ -6,15 +6,13 @@ namespace App\Commands\Config;
use App\Command; use App\Command;
use App\Libs\Attributes\Route\Cli; use App\Libs\Attributes\Route\Cli;
use App\Libs\Config; use App\Libs\HTTP_STATUS;
use App\Libs\ConfigFile;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/** /**
* Class EditCommand * Class EditCommand
@@ -38,11 +36,9 @@ final class EditCommand extends Command
{ {
$this->setName(self::ROUTE) $this->setName(self::ROUTE)
->setDescription('Edit backend settings inline.') ->setDescription('Edit backend settings inline.')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Use Alternative config file.')
->addOption('key', 'k', InputOption::VALUE_REQUIRED, 'Key to update.') ->addOption('key', 'k', InputOption::VALUE_REQUIRED, 'Key to update.')
->addOption('set', 'e', InputOption::VALUE_REQUIRED, 'Value to set.') ->addOption('set', 'e', InputOption::VALUE_REQUIRED, 'Value to set.')
->addOption('delete', 'd', InputOption::VALUE_NONE, 'Delete value.') ->addOption('delete', 'd', InputOption::VALUE_NONE, 'Delete value.')
->addOption('regenerate-webhook-token', 'g', InputOption::VALUE_NONE, 'Re-generate backend webhook token.')
->addOption('select-backend', 's', InputOption::VALUE_REQUIRED, 'Select backend.') ->addOption('select-backend', 's', InputOption::VALUE_REQUIRED, 'Select backend.')
->setHelp( ->setHelp(
r( r(
@@ -113,98 +109,54 @@ final class EditCommand extends Command
return self::FAILURE; return self::FAILURE;
} }
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml'); if (null === ($key = $input->getOption('key'))) {
$configFile->setLogger($this->logger); $output->writeln('<error>ERROR: [-k, --key] flag is required.</error>');
if (null === $configFile->get("{$name}.type", null)) {
$output->writeln(r('<error>ERROR: Backend \'{name}\' not found.</error>', ['name' => $name]));
return self::FAILURE; return self::FAILURE;
} }
if ($input->getOption('regenerate-webhook-token')) { $json = [];
try { if ($input->getOption('delete')) {
$webhookToken = bin2hex(random_bytes(Config::get('webhook.tokenLength'))); $method = 'DELETE';
} elseif ($input->getOption('set')) {
$output->writeln(r('<info>The webhook token for \'{name}\' is: \'{token}\'.</info>', [ $method = 'POST';
'name' => $name, $json['value'] = $input->getOption('set');
'token' => $webhookToken
]));
$configFile->set("{$name}.webhook.token", $webhookToken);
} catch (Throwable $e) {
$output->writeln(r('<error>ERROR: {error}</error>', ['error' => $e->getMessage()]));
return self::FAILURE;
}
} else { } else {
if (null === ($key = $input->getOption('key'))) { $method = 'GET';
$output->writeln('<error>ERROR: [-k, --key] flag is required.</error>');
return self::FAILURE;
}
$value = $input->getOption('set');
if (null !== $value && $input->getOption('delete')) {
$output->writeln(
'<error>ERROR: cannot use both [-s, --set] and [-d, --delete] flags as the same time.</error>'
);
return self::FAILURE;
}
if (null === $value && !$input->getOption('delete')) {
if ($configFile->has("{$name}.{$key}")) {
$val = $configFile->get("{$name}.{$key}", '[No value]');
} else {
$val = '[Not set]';
}
$output->writeln(is_scalar($val) ? (string)$val : r('Type({type})', ['type' => get_debug_type($val)]));
return self::SUCCESS;
}
if (null !== $value) {
if (true === ctype_digit($value)) {
$value = (int)$value;
} elseif (true === is_numeric($value) && true === str_contains($value, '.')) {
$value = (float)$value;
} elseif ('true' === strtolower((string)$value) || 'false' === strtolower((string)$value)) {
$value = 'true' === $value;
} else {
$value = (string)$value;
}
if ($value === $configFile->get("{$name}.{$key}", null)) {
$output->writeln('<comment>Not updating. Value already matches.</comment>');
return self::SUCCESS;
}
$configFile->set("{$name}.{$key}", $value);
$output->writeln(r("<info>{name}: Updated '{key}' key value to '{value}'.</info>", [
'name' => $name,
'key' => $key,
'value' => is_bool($value) ? (true === $value ? 'true' : 'false') : $value,
]));
}
if ($input->getOption('delete')) {
if (false === $configFile->has("{$name}.{$key}")) {
$output->writeln(r("<error>{name}: '{key}' key does not exist.</error>", [
'name' => $name,
'key' => $key
]));
return self::FAILURE;
}
$configFile->delete("{$name}.{$key}");
$output->writeln(r("<info>{name}: Removed '{key}' key.</info>", [
'name' => $name,
'key' => $key
]));
}
} }
$configFile->persist(); $response = apiRequest($method, "/backend/{$name}/option/{$key}", $json);
if (HTTP_STATUS::HTTP_OK !== $response->status) {
$output->writeln(r("<error>API error. {status}: {message}</error>", [
'status' => $response->status->value,
'message' => ag($response->body, 'error.message', 'Unknown error.')
]));
return self::FAILURE;
}
if ($input->getOption('delete')) {
$output->writeln(r("<info>Key '{key}' was deleted.</info>", ['key' => $key]));
return self::SUCCESS;
}
if ($input->getOption('set')) {
if ('bool' === ag($response->body, 'type', 'string')) {
$value = true === (bool)ag($response->body, 'value') ? 'On (True)' : 'Off (False)';
} else {
$value = ag($json, 'value');
}
$output->writeln(
r("<info>Key '<value>{key}</value>' was updated with value '<value>{value}</value>'.</info>", [
'key' => $key,
'value' => $value,
])
);
return self::SUCCESS;
}
$output->writeln((string)ag($response->body, 'value', '[not_set]'));
return self::SUCCESS; return self::SUCCESS;
} }

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Libs\Exceptions;
/**
* Class ValidationException
*/
class ValidationException extends RuntimeException
{
}

View File

@@ -578,6 +578,8 @@ final class Initializer
], ],
], $context); ], $context);
$request = $request->withoutAttribute('INTERNAL_REQUEST');
if (($attributes = $request->getAttributes()) && count($attributes) >= 1) { if (($attributes = $request->getAttributes()) && count($attributes) >= 1) {
$context['attributes'] = $attributes; $context['attributes'] = $attributes;
} }

View File

@@ -37,7 +37,7 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
if ('OPTIONS' === $request->getMethod() || true === (bool)$request->getAttribute('INTERNAL_REQUEST', false)) { if ('OPTIONS' === $request->getMethod() || true === (bool)$request->getAttribute('INTERNAL_REQUEST', false)) {
return $handler->handle($request->withoutAttribute('INTERNAL_REQUEST')); return $handler->handle($request);
} }
$requestPath = rtrim($request->getUri()->getPath(), '/'); $requestPath = rtrim($request->getUri()->getPath(), '/');