diff --git a/config/env.spec.php b/config/env.spec.php index 1872dfa8..54ff3d5b 100644 --- a/config/env.spec.php +++ b/config/env.spec.php @@ -7,7 +7,7 @@ * 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; return (function () { @@ -147,19 +147,26 @@ return (function () { ], ]; - $validateCronExpression = function ($value): bool { + $validateCronExpression = function (string $value): string { if (empty($value)) { - return false; + throw new ValidationException('Invalid cron expression. Empty value.'); } 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.'); + + $status = (new CronExpression($value))->getNextRunDate()->getTimestamp() >= 0; + + 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. diff --git a/config/servers.spec.php b/config/servers.spec.php index d0e98102..2fb65744 100644 --- a/config/servers.spec.php +++ b/config/servers.spec.php @@ -8,6 +8,9 @@ * This file defines the backend spec. * The dot (.) means the string past the dot is sub key of the string preceding it. */ + +use App\Libs\Exceptions\ValidationException; + return [ [ 'key' => 'name', @@ -99,9 +102,15 @@ return [ ], [ 'key' => 'options.LIBRARY_SEGMENT', - 'type' => 'string', + 'type' => 'int', 'visible' => true, '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', diff --git a/src/API/Backend/Option.php b/src/API/Backend/Option.php index 2c8e1998..7ef32a3d 100644 --- a/src/API/Backend/Option.php +++ b/src/API/Backend/Option.php @@ -4,13 +4,11 @@ declare(strict_types=1); namespace App\API\Backend; -use App\Libs\Attributes\Route\Delete; -use App\Libs\Attributes\Route\Get; -use App\Libs\Attributes\Route\Patch; -use App\Libs\Attributes\Route\Post; +use App\Libs\Attributes\Route\Route; use App\Libs\Config; use App\Libs\ConfigFile; use App\Libs\DataUtil; +use App\Libs\Exceptions\ValidationException; use App\Libs\HTTP_STATUS; use App\Libs\Traits\APITraits; use Psr\Http\Message\ResponseInterface as iResponse; @@ -20,20 +18,30 @@ final class Option { use APITraits; - #[Get(Index::URL . '/{name:backend}/option/{option}[/]', name: 'backend.option')] - public function viewOption(iRequest $request, array $args = []): iResponse + #[Route(['GET', 'POST', 'PATCH', 'DELETE'], Index::URL . '/{name:backend}/option[/{option}[/]]')] + public function __invoke(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 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); } - 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( - "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 ); } @@ -43,21 +51,63 @@ final class Option 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 api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND); + return $this->viewOption($spec, $list->get("{$name}.{$option}")); } - 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.", [ 'option' => $option, 'name' => $name ]), HTTP_STATUS::HTTP_NOT_FOUND); } - $value = $list->get($name . '.' . $option); - settype($value, ag($spec, 'type', 'string')); + if ('DELETE' === $request->getMethod()) { + 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, [ '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', ''), - ]); - } } diff --git a/src/API/System/Env.php b/src/API/System/Env.php index a3125929..e46ce4bf 100644 --- a/src/API/System/Env.php +++ b/src/API/System/Env.php @@ -9,7 +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\Exceptions\ValidationException; use App\Libs\HTTP_STATUS; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; @@ -124,14 +124,21 @@ final class Env return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED); } - try { $value = $this->setType($spec, $value); - if (false === $this->checkValue($spec, $value)) { - throw new InvalidArgumentException(r("Invalid value for '{key}'.", ['key' => $key])); + 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 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}", [ 'key' => $key, '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. * diff --git a/src/Commands/Config/EditCommand.php b/src/Commands/Config/EditCommand.php index 89fc8edb..4354a393 100644 --- a/src/Commands/Config/EditCommand.php +++ b/src/Commands/Config/EditCommand.php @@ -6,15 +6,13 @@ namespace App\Commands\Config; use App\Command; use App\Libs\Attributes\Route\Cli; -use App\Libs\Config; -use App\Libs\ConfigFile; +use App\Libs\HTTP_STATUS; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Throwable; /** * Class EditCommand @@ -38,11 +36,9 @@ final class EditCommand extends Command { $this->setName(self::ROUTE) ->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('set', 'e', InputOption::VALUE_REQUIRED, 'Value to set.') ->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.') ->setHelp( r( @@ -113,98 +109,54 @@ final class EditCommand extends Command return self::FAILURE; } - $configFile = ConfigFile::open(Config::get('backends_file'), 'yaml'); - $configFile->setLogger($this->logger); - - if (null === $configFile->get("{$name}.type", null)) { - $output->writeln(r('ERROR: Backend \'{name}\' not found.', ['name' => $name])); + if (null === ($key = $input->getOption('key'))) { + $output->writeln('ERROR: [-k, --key] flag is required.'); return self::FAILURE; } - if ($input->getOption('regenerate-webhook-token')) { - try { - $webhookToken = bin2hex(random_bytes(Config::get('webhook.tokenLength'))); - - $output->writeln(r('The webhook token for \'{name}\' is: \'{token}\'.', [ - 'name' => $name, - 'token' => $webhookToken - ])); - - $configFile->set("{$name}.webhook.token", $webhookToken); - } catch (Throwable $e) { - $output->writeln(r('ERROR: {error}', ['error' => $e->getMessage()])); - return self::FAILURE; - } + $json = []; + if ($input->getOption('delete')) { + $method = 'DELETE'; + } elseif ($input->getOption('set')) { + $method = 'POST'; + $json['value'] = $input->getOption('set'); } else { - if (null === ($key = $input->getOption('key'))) { - $output->writeln('ERROR: [-k, --key] flag is required.'); - return self::FAILURE; - } - - $value = $input->getOption('set'); - - if (null !== $value && $input->getOption('delete')) { - $output->writeln( - 'ERROR: cannot use both [-s, --set] and [-d, --delete] flags as the same time.' - ); - - 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('Not updating. Value already matches.'); - return self::SUCCESS; - } - - $configFile->set("{$name}.{$key}", $value); - - $output->writeln(r("{name}: Updated '{key}' key value to '{value}'.", [ - '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("{name}: '{key}' key does not exist.", [ - 'name' => $name, - 'key' => $key - ])); - return self::FAILURE; - } - - $configFile->delete("{$name}.{$key}"); - $output->writeln(r("{name}: Removed '{key}' key.", [ - 'name' => $name, - 'key' => $key - ])); - } + $method = 'GET'; } - $configFile->persist(); + $response = apiRequest($method, "/backend/{$name}/option/{$key}", $json); + + if (HTTP_STATUS::HTTP_OK !== $response->status) { + $output->writeln(r("API error. {status}: {message}", [ + 'status' => $response->status->value, + 'message' => ag($response->body, 'error.message', 'Unknown error.') + ])); + return self::FAILURE; + } + + if ($input->getOption('delete')) { + $output->writeln(r("Key '{key}' was deleted.", ['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("Key '{key}' was updated with value '{value}'.", [ + 'key' => $key, + 'value' => $value, + ]) + ); + + return self::SUCCESS; + } + + $output->writeln((string)ag($response->body, 'value', '[not_set]')); return self::SUCCESS; } diff --git a/src/Libs/Exceptions/ValidationException.php b/src/Libs/Exceptions/ValidationException.php new file mode 100644 index 00000000..beaccdd6 --- /dev/null +++ b/src/Libs/Exceptions/ValidationException.php @@ -0,0 +1,12 @@ +withoutAttribute('INTERNAL_REQUEST'); + if (($attributes = $request->getAttributes()) && count($attributes) >= 1) { $context['attributes'] = $attributes; } diff --git a/src/Libs/Middlewares/APIKeyRequiredMiddleware.php b/src/Libs/Middlewares/APIKeyRequiredMiddleware.php index 14a90113..876c7300 100644 --- a/src/Libs/Middlewares/APIKeyRequiredMiddleware.php +++ b/src/Libs/Middlewares/APIKeyRequiredMiddleware.php @@ -37,7 +37,7 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { 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(), '/');