diff --git a/config/backend.spec.php b/config/backend.spec.php deleted file mode 100644 index 40bb1d8f..00000000 --- a/config/backend.spec.php +++ /dev/null @@ -1,37 +0,0 @@ - true, - 'type' => true, - 'url' => true, - 'token' => true, - 'uuid' => true, - 'user' => true, - 'export.enabled' => true, - 'export.lastSync' => true, - 'import.enabled' => true, - 'import.lastSync' => true, - 'webhook.token' => true, - 'webhook.match.user' => true, - 'webhook.match.uuid' => true, - 'options.ignore' => true, - 'options.LIBRARY_SEGMENT' => true, - 'options.ADMIN_TOKEN' => false, - 'options.DUMP_PAYLOAD' => false, - 'options.DEBUG_TRACE' => false, - 'options.IMPORT_METADATA_ONLY' => false, - 'options.DRY_RUN' => false, - 'options.client.timeout' => false, - 'options.client.http_version' => false, - 'options.use_old_progress_endpoint' => false, - 'options.MAX_EPISODE_RANGE' => false, -]; - diff --git a/config/servers.spec.php b/config/servers.spec.php index ad91c7c7..d54619e7 100644 --- a/config/servers.spec.php +++ b/config/servers.spec.php @@ -1,7 +1,7 @@ 'string', 'visible' => true, 'description' => 'The name of the backend.', + 'immutable' => true, ], [ 'key' => 'type', 'type' => 'string', 'visible' => true, 'description' => 'The type of the backend.', - 'choices' => ['plex', 'emby', 'jellyfin'] + 'choices' => ['plex', 'emby', 'jellyfin'], + 'immutable' => true, ], [ 'key' => 'url', @@ -54,7 +56,7 @@ return [ ], [ 'key' => 'export.lastSync', - 'type' => 'integer', + 'type' => 'int', 'visible' => true, 'description' => 'The last time data was exported to the backend.', ], @@ -66,7 +68,7 @@ return [ ], [ 'key' => 'import.lastSync', - 'type' => 'integer', + 'type' => 'int', 'visible' => true, 'description' => 'The last time data was imported from the backend.', ], @@ -139,7 +141,7 @@ return [ ], [ 'key' => 'options.client.timeout', - 'type' => 'integer', + 'type' => 'int', 'visible' => false, 'description' => 'The http timeout per request to the backend.', ], diff --git a/src/API/Backend/Option.php b/src/API/Backend/Option.php index cf5bc22a..24e90f74 100644 --- a/src/API/Backend/Option.php +++ b/src/API/Backend/Option.php @@ -31,26 +31,39 @@ final class Option return api_error('Invalid value for option path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); } + if (false === str_starts_with($option, 'options.')) { + return api_error( + "Invalid option. Option path parameter 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); } - $key = $name . '.options.' . $option; - - if (false === $list->has($key)) { - return api_error(r("Option '{option}' not found in backend '{name}'.", [ + 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($key); + $value = $list->get($name . '.' . $option); + settype($value, ag($spec, 'type', 'string')); + return api_response(HTTP_STATUS::HTTP_OK, [ - 'key' => $option, + 'key' => $spec['key'], 'value' => $value, - 'type' => get_debug_type($value), + 'type' => ag($spec, 'type', 'string'), + 'description' => ag($spec, 'description', ''), ]); } @@ -70,31 +83,38 @@ final class Option $data = DataUtil::fromRequest($request); if (null === ($option = $data->get('key'))) { - return api_error('Invalid value for key.', HTTP_STATUS::HTTP_BAD_REQUEST); + return api_error('No option key was given.', HTTP_STATUS::HTTP_BAD_REQUEST); } - $spec = require __DIR__ . '/../../../config/backend.spec.php'; - $found = false; - - foreach ($spec as $supportedKey => $_) { - if (str_ends_with($supportedKey, 'options.' . $option)) { - $found = true; - break; - } + 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 + ); } - if (false === $found) { - return api_error(r("Option '{key}' is not supported.", ['key' => $option]), 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 . '.options.' . $option, $value)->persist(); + $list->set($name . '.' . $option, $value)->persist(); return api_response(HTTP_STATUS::HTTP_OK, [ 'key' => $option, 'value' => $value, - 'type' => get_debug_type($value), + 'type' => ag($spec, 'type', 'string'), + 'description' => ag($spec, 'description', ''), ]); } @@ -109,34 +129,60 @@ final class Option return api_error('Invalid value for option parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); } - $list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true); + 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); } - $key = $name . '.options.' . $option; - if (false === $list->has($key)) { - return api_error(r("Option '{option}' not found in backend '{name}'.", [ + 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' => $key, + 'key' => $option, ]), HTTP_STATUS::HTTP_BAD_REQUEST); } - $list->set($key, $value)->persist(); + 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' => get_debug_type($value), + 'type' => ag($spec, 'type', 'string'), + 'description' => ag($spec, 'description', ''), ]); } @@ -151,21 +197,40 @@ final class Option return api_error('Invalid value for option option parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); } - $list = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true); + 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); } - $key = $name . '.options.' . $option; + 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($key); - $list->delete($key)->persist(); + $value = $list->get($name . '.' . $option); + settype($value, ag($spec, 'type', 'string')); + + $list->delete($option)->persist(); return api_response(HTTP_STATUS::HTTP_OK, [ 'key' => $option, 'value' => $value, - 'type' => get_debug_type($value), + 'type' => ag($spec, 'type', 'string'), + 'description' => ag($spec, 'description', ''), ]); } } diff --git a/src/API/Backend/Update.php b/src/API/Backend/Update.php index da290ee6..779c0e09 100644 --- a/src/API/Backend/Update.php +++ b/src/API/Backend/Update.php @@ -20,6 +20,16 @@ final class Update { use APITraits; + private const array IMMUTABLE_KEYS = [ + 'name', + 'type', + 'options', + 'webhook', + 'webhook.match', + 'import', + 'export', + ]; + private ConfigFile $backendFile; public function __construct() @@ -72,20 +82,23 @@ final class Update HTTP_STATUS::HTTP_BAD_REQUEST); } - $spec = array_keys(require __DIR__ . '/../../../config/backend.spec.php'); - foreach ($data as $update) { - $key = ag($update, 'key'); $value = ag($update, 'value'); - if (null === $key) { + if (null === ($key = ag($update, 'key'))) { return api_error('No key to update was present.', HTTP_STATUS::HTTP_BAD_REQUEST); } - if (false === in_array($key, $spec, true)) { + $spec = getServerColumnSpec($key); + + if (empty($spec)) { return api_error(r('Invalid key to update: {key}', ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST); } + if (in_array($key, self::IMMUTABLE_KEYS, true)) { + return api_error(r('Key {key} is immutable.', ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST); + } + $this->backendFile->set("{$name}.{$key}", $value); } @@ -125,14 +138,15 @@ final class Update ], ]; - $spec = require __DIR__ . '/../../../config/backend.spec.php'; - foreach ($data->get('options', []) as $key => $value) { - if (false === ag_exists($spec, "options.{$key}") || null === $value) { + $key = "options.{$key}"; + $spec = getServerColumnSpec($key); + + if (empty($spec) || null === $value) { continue; } - $newData = ag_set($newData, "options.{$key}", $value); + $newData = ag_set($newData, $key, $value); } return deepArrayMerge([$config, $client->fromRequest($newData, $request)]); diff --git a/src/API/Backends/Add.php b/src/API/Backends/Add.php index 466f48dd..3c82f370 100644 --- a/src/API/Backends/Add.php +++ b/src/API/Backends/Add.php @@ -148,14 +148,15 @@ final class Add 'options' => [], ]; - $spec = require __DIR__ . '/../../../config/backend.spec.php'; - foreach ($data->get('options', []) as $key => $value) { - if (false === ag_exists($spec, "options.{$key}") || null === $value) { + $key = "options.{$key}"; + $spec = getServerColumnSpec($key); + + if (empty($spec) || null === $value) { continue; } - $config = ag_set($config, "options.{$key}", $value); + $config = ag_set($config, $key, $value); } return $client->fromRequest($config, $request); diff --git a/src/Commands/Config/EditCommand.php b/src/Commands/Config/EditCommand.php index 6fff16e4..89fc8edb 100644 --- a/src/Commands/Config/EditCommand.php +++ b/src/Commands/Config/EditCommand.php @@ -24,7 +24,7 @@ use Throwable; #[Cli(command: self::ROUTE)] final class EditCommand extends Command { - public const ROUTE = 'config:edit'; + public const string ROUTE = 'config:edit'; public function __construct(private LoggerInterface $logger) { @@ -75,13 +75,7 @@ final class EditCommand extends Command ', ', array_map( fn($val) => '' . $val . '', - array_keys( - array_filter( - array: require __DIR__ . '/../../../config/backend.spec.php', - callback: fn($val, $key) => $val, - mode: ARRAY_FILTER_USE_BOTH - ) - ) + array_column(require __DIR__ . '/../../../config/servers.spec.php', 'key') ), ) ] @@ -232,13 +226,13 @@ final class EditCommand extends Command $suggest = []; - foreach (require __DIR__ . '/../../../config/backend.spec.php' as $name => $val) { - if (false === $val) { + foreach (require __DIR__ . '/../../../config/servers.spec.php' as $column) { + if (false === (bool)ag($column, 'visible', false)) { continue; } - if (empty($currentValue) || str_starts_with($name, $currentValue)) { - $suggest[] = $name; + if (empty($currentValue) || str_starts_with(ag($column, 'key', ''), $currentValue)) { + $suggest[] = ag($column, 'key', ''); } } diff --git a/src/Commands/Config/ViewCommand.php b/src/Commands/Config/ViewCommand.php index 920d87ba..25e6aa25 100644 --- a/src/Commands/Config/ViewCommand.php +++ b/src/Commands/Config/ViewCommand.php @@ -26,7 +26,7 @@ use Symfony\Component\Yaml\Yaml; #[Cli(command: self::ROUTE)] final class ViewCommand extends Command { - public const ROUTE = 'config:view'; + public const string ROUTE = 'config:view'; /** * Configure the command. @@ -177,8 +177,10 @@ final class ViewCommand extends Command $suggest = []; - foreach (require __DIR__ . '/../../../config/backend.spec.php' as $name => $val) { - if (true === $val && (empty($currentValue) || str_starts_with($name, $currentValue))) { + foreach (require __DIR__ . '/../../../config/servers.spec.php' as $column) { + $name = ag($column, 'key', ''); + $visible = (bool)ag($column, 'visible', false); + if ($visible && (empty($currentValue) || str_starts_with($name, $currentValue))) { $suggest[] = $name; } } diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 68c716fd..d70012ba 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -1442,3 +1442,55 @@ if (!function_exists('APIRequest')) { return new APIResponse($response->getStatusCode(), $response->getHeaders(), [], $response->getBody()); } } + +if (!function_exists('getServerColumnSpec')) { + /** + * Returns the spec for the given server column. + * + * @param string $column + * + * @return array The spec for the given column. Otherwise, an empty array. + */ + function getServerColumnSpec(string $column): array + { + static $_serverSpec = null; + + if (null === $_serverSpec) { + $_serverSpec = require __DIR__ . '/../../config/servers.spec.php'; + } + + foreach ($_serverSpec as $spec) { + if (ag($spec, 'key') === $column) { + return $spec; + } + } + + return []; + } +} + +if (!function_exists('getEnvSpec')) { + /** + * Returns the spec for the given environment variable. + * + * @param string $env + * + * @return array The spec for the given column. Otherwise, an empty array. + */ + function getEnvSpec(string $env): array + { + static $_envSpec = null; + + if (null === $_envSpec) { + $_envSpec = require __DIR__ . '/../../config/env.spec.php'; + } + + foreach ($_envSpec as $spec) { + if (ag($spec, 'key') === $env) { + return $spec; + } + } + + return []; + } +}