Allow adding plex backend via API.
This commit is contained in:
@@ -20,14 +20,24 @@ use App\Libs\Traits\APITraits;
|
||||
use App\Libs\Uri;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Random\RandomException;
|
||||
|
||||
final class Add
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
/**
|
||||
* @throws RandomException
|
||||
*/
|
||||
#[Post(Index::URL . '[/]', name: 'backends.add')]
|
||||
public function BackendAdd(iRequest $request): iResponse
|
||||
{
|
||||
$requestData = $request->getParsedBody();
|
||||
|
||||
if (!is_array($requestData)) {
|
||||
return api_error('Invalid request data.', HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$data = DataUtil::fromArray($request->getParsedBody());
|
||||
|
||||
if (null === ($type = $data->get('type'))) {
|
||||
@@ -46,6 +56,18 @@ final class Add
|
||||
]), HTTP_STATUS::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
if (false === isValidName($name)) {
|
||||
return api_error('Invalid name was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (null === ($url = $data->get('url'))) {
|
||||
return api_error('No url was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (false === isValidUrl($url)) {
|
||||
return api_error('Invalid url was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (null === ($class = Config::get("supported.{$type}", null))) {
|
||||
throw api_error(r("Unexpected client type '{type}' was given.", [
|
||||
'type' => $type
|
||||
@@ -70,22 +92,29 @@ final class Add
|
||||
);
|
||||
|
||||
if (false === $instance->validateContext($context)) {
|
||||
return api_error('Invalid context information was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
return api_error('Context information validation failed.', HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml');
|
||||
$configFile->set($name, $config);
|
||||
$configFile->persist();
|
||||
if (!$config->has('webhook.token')) {
|
||||
$config = $config->with('webhook.token', bin2hex(random_bytes(Config::get('webhook.tokenLength'))));
|
||||
}
|
||||
|
||||
ConfigFile::open(Config::get('backends_file'), 'yaml')
|
||||
->set($name, $config->getAll())
|
||||
->persist();
|
||||
} catch (InvalidContextException $e) {
|
||||
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$response = [
|
||||
'backends' => [],
|
||||
'links' => [],
|
||||
];
|
||||
$data = $this->getBackends(name: $name);
|
||||
$data = array_pop($data);
|
||||
|
||||
return api_response(HTTP_STATUS::HTTP_OK, $response);
|
||||
return api_response(HTTP_STATUS::HTTP_CREATED, [
|
||||
...$data,
|
||||
'links' => [
|
||||
'self' => parseConfigValue(Index::URL) . '/' . $name,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function fromRequest(string $type, iRequest $request, ClientInterface|null $client = null): array
|
||||
@@ -123,7 +152,7 @@ final class Add
|
||||
];
|
||||
|
||||
foreach ($optionals as $key => $type) {
|
||||
if (null !== ($value = $data->get($key))) {
|
||||
if (null !== ($value = $data->get('options.' . $key))) {
|
||||
$val = $data->get($value, $type);
|
||||
settype($val, $type);
|
||||
$config = ag_set($config, "options.{$key}", $val);
|
||||
@@ -131,7 +160,7 @@ final class Add
|
||||
}
|
||||
|
||||
if (null !== $client) {
|
||||
$config = ag_set($config, 'options', $client->fromRequest($request));
|
||||
$config = $client->fromRequest($config, $request);
|
||||
}
|
||||
|
||||
return $config;
|
||||
|
||||
@@ -202,12 +202,14 @@ interface ClientInterface
|
||||
public function listLibraries(array $opts = []): array;
|
||||
|
||||
/**
|
||||
* Parse client specific options.
|
||||
* Parse client specific options from request.
|
||||
*
|
||||
* @param array $config The already pre-filled config.
|
||||
* @param ServerRequestInterface $request request to parse.
|
||||
* @return array parsed options.
|
||||
*
|
||||
* @return array Return updated config.
|
||||
*/
|
||||
public function fromRequest(ServerRequestInterface $request): array;
|
||||
public function fromRequest(array $config, ServerRequestInterface $request): array;
|
||||
|
||||
/**
|
||||
* Validate backend context.
|
||||
|
||||
@@ -539,9 +539,9 @@ class EmbyClient implements iClient
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function fromRequest(ServerRequestInterface $request): array
|
||||
public function fromRequest(array $config, ServerRequestInterface $request): array
|
||||
{
|
||||
return [];
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -572,9 +572,9 @@ class JellyfinClient implements iClient
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function fromRequest(ServerRequestInterface $request): array
|
||||
public function fromRequest(array $config, ServerRequestInterface $request): array
|
||||
{
|
||||
return [];
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
132
src/Backends/Plex/Action/GetUser.php
Normal file
132
src/Backends/Plex/Action/GetUser.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Backends\Plex\Action;
|
||||
|
||||
use App\Backends\Common\CommonTrait;
|
||||
use App\Backends\Common\Context;
|
||||
use App\Backends\Common\Error;
|
||||
use App\Backends\Common\Levels;
|
||||
use App\Backends\Common\Response;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\HTTP_STATUS;
|
||||
use App\Libs\Options;
|
||||
use JsonException;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
|
||||
|
||||
final class GetUser
|
||||
{
|
||||
private int $maxRetry = 3;
|
||||
private string $action = 'plex.getUser';
|
||||
|
||||
use CommonTrait;
|
||||
|
||||
public function __construct(protected iHttp $http, protected iLogger $logger)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Users list.
|
||||
*
|
||||
* @param Context $context
|
||||
* @param array $opts optional options.
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function __invoke(Context $context, array $opts = []): Response
|
||||
{
|
||||
return $this->tryResponse(
|
||||
context: $context,
|
||||
fn: fn() => $this->getUser($context, $opts),
|
||||
action: $this->action
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get User list.
|
||||
*
|
||||
* @throws ExceptionInterface
|
||||
* @throws JsonException
|
||||
*/
|
||||
private function getUser(Context $context, array $opts = []): Response
|
||||
{
|
||||
$url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')
|
||||
->withHost('clients.plex.tv')->withPath('/api/v2/user');
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'X-Plex-Token' => $context->backendToken,
|
||||
'X-Plex-Client-Identifier' => $context->backendId,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->logger->debug("Requesting '{backend}' user info.", [
|
||||
'backend' => $context->backendName,
|
||||
'url' => (string)$url,
|
||||
]);
|
||||
|
||||
if (HTTP_STATUS::HTTP_OK->value !== $response->getStatusCode()) {
|
||||
return new Response(
|
||||
status: false,
|
||||
error: new Error(
|
||||
message: "Request for '{backend}' user info returned with unexpected '{status_code}' status code.",
|
||||
context: [
|
||||
'backend' => $context->backendName,
|
||||
'status_code' => $response->getStatusCode(),
|
||||
],
|
||||
level: Levels::ERROR
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
$json = json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
);
|
||||
|
||||
if ($context->trace) {
|
||||
$this->logger->debug("Parsing '{backend}' user info payload.", [
|
||||
'backend' => $context->backendName,
|
||||
'url' => (string)$url,
|
||||
'trace' => $json,
|
||||
]);
|
||||
}
|
||||
|
||||
$name = '??';
|
||||
$possibleName = ['friendlyName', 'username', 'title', 'email'];
|
||||
foreach ($possibleName as $key) {
|
||||
$val = ag($json, $key);
|
||||
if (empty($val)) {
|
||||
continue;
|
||||
}
|
||||
$name = $val;
|
||||
break;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => ag($json, 'id'),
|
||||
'uuid' => ag($json, 'uuid'),
|
||||
'name' => $name,
|
||||
'home' => (bool)ag($json, 'home'),
|
||||
'guest' => (bool)ag($json, 'guest'),
|
||||
'restricted' => (bool)ag($json, 'restricted'),
|
||||
'joinedAt' => isset($json['joinedAt']) ? makeDate($json['joinedAt']) : 'Unknown',
|
||||
];
|
||||
|
||||
if (true === (bool)ag($opts, 'tokens')) {
|
||||
$data['token'] = ag($json, 'authToken');
|
||||
}
|
||||
|
||||
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
|
||||
$data['raw'] = $json;
|
||||
}
|
||||
|
||||
return new Response(status: true, response: $data);
|
||||
}
|
||||
}
|
||||
@@ -554,21 +554,23 @@ class PlexClient implements iClient
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function fromRequest(ServerRequestInterface $request): array
|
||||
public function fromRequest(array $config, ServerRequestInterface $request): array
|
||||
{
|
||||
$params = DataUtil::fromArray($request->getParsedBody());
|
||||
|
||||
$opts = [];
|
||||
|
||||
if (null !== ($uuid = $params->get('plex_user_uuid'))) {
|
||||
$opts['plex_user_uuid'] = $uuid;
|
||||
if (null !== ($uuid = $params->get('options.' . Options::PLEX_USER_UUID))) {
|
||||
$config = ag_set($config, 'options.' . Options::PLEX_USER_UUID, $uuid);
|
||||
}
|
||||
|
||||
if (null !== ($adminToken = $params->get(Options::ADMIN_TOKEN))) {
|
||||
$opts[Options::ADMIN_TOKEN] = $adminToken;
|
||||
if (null !== ($adminToken = $params->get('options.' . Options::ADMIN_TOKEN))) {
|
||||
$config = ag_set($config, 'options.' . Options::ADMIN_TOKEN, $adminToken);
|
||||
}
|
||||
|
||||
return $opts;
|
||||
if (null !== ($userId = ag($config, 'user')) && !is_int($userId)) {
|
||||
$config = ag_set($config, 'user', (int)$userId);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,7 +578,7 @@ class PlexClient implements iClient
|
||||
*/
|
||||
public function validateContext(Context $context): bool
|
||||
{
|
||||
return false;
|
||||
return Container::get(PlexValidateContext::class)($context);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
119
src/Backends/Plex/PlexValidateContext.php
Normal file
119
src/Backends/Plex/PlexValidateContext.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Backends\Plex;
|
||||
|
||||
use App\Backends\Common\Context;
|
||||
use App\Backends\Plex\Action\GetUser;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Exceptions\Backends\InvalidContextException;
|
||||
use App\Libs\HTTP_STATUS;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
|
||||
|
||||
final readonly class PlexValidateContext
|
||||
{
|
||||
public function __construct(private iHttp $http)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backend context.
|
||||
*
|
||||
* @param Context $context Backend context.
|
||||
* @throws InvalidContextException on failure.
|
||||
*/
|
||||
public function __invoke(Context $context): bool
|
||||
{
|
||||
$response = $this->validateUrl($context);
|
||||
$data = ag(json_decode($response, true), 'MediaContainer', []);
|
||||
$backendId = ag($data, 'machineIdentifier');
|
||||
|
||||
if (empty($backendId)) {
|
||||
throw new InvalidContextException('Failed to get backend id.');
|
||||
}
|
||||
|
||||
if ($backendId !== $context->backendId) {
|
||||
throw new InvalidContextException(
|
||||
r("Backend id mismatch. Expected '{expected}', server responded with '{actual}'.", [
|
||||
'expected' => $context->backendId,
|
||||
'actual' => $backendId,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
$action = Container::get(GetUser::class)($context);
|
||||
if ($action->hasError()) {
|
||||
throw new InvalidContextException(r('Failed to get user info. {error}', [
|
||||
'error' => $action->error->format()
|
||||
]));
|
||||
}
|
||||
|
||||
$userId = ag($action->response, 'id');
|
||||
if (empty($userId)) {
|
||||
throw new InvalidContextException('Failed to get user id.');
|
||||
}
|
||||
|
||||
if ((string)$context->backendUser !== (string)$userId) {
|
||||
throw new InvalidContextException(
|
||||
r("User id mismatch. Expected '{expected}', server responded with '{actual}'.", [
|
||||
'expected' => $context->backendUser,
|
||||
'actual' => $userId,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backend url.
|
||||
*
|
||||
* @param Context $context
|
||||
*
|
||||
* @return string
|
||||
* @throws InvalidContextException
|
||||
*/
|
||||
private function validateUrl(Context $context): string
|
||||
{
|
||||
try {
|
||||
$url = $context->backendUrl->withPath('/');
|
||||
$request = $this->http->request('GET', (string)$url, [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'X-Plex-Token' => $context->backendToken,
|
||||
],
|
||||
]);
|
||||
|
||||
if (HTTP_STATUS::HTTP_UNAUTHORIZED->value === $request->getStatusCode()) {
|
||||
throw new InvalidContextException('Backend responded with 401. Most likely means token is invalid.');
|
||||
}
|
||||
|
||||
if (HTTP_STATUS::HTTP_NOT_FOUND->value === $request->getStatusCode()) {
|
||||
throw new InvalidContextException('Backend responded with 404. Most likely means url is incorrect.');
|
||||
}
|
||||
|
||||
return $request->getContent(true);
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
throw new InvalidContextException(r('Failed to connect to backend. {error}', ['error' => $e->getMessage()]),
|
||||
previous: $e);
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
throw new InvalidContextException(r('Got non 200 response. {error}', ['error' => $e->getMessage()]),
|
||||
previous: $e);
|
||||
} catch (RedirectionExceptionInterface $e) {
|
||||
throw new InvalidContextException(
|
||||
r('Redirection recursion detected. {error}', ['error' => $e->getMessage()]),
|
||||
previous: $e
|
||||
);
|
||||
} catch (ServerExceptionInterface $e) {
|
||||
throw new InvalidContextException(
|
||||
r('Backend responded with 5xx error. {error}', ['error' => $e->getMessage()]),
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ final class Options
|
||||
public const string STATE_UPDATE_EVENT = 'STATE_UPDATE_EVENT';
|
||||
public const string DUMP_PAYLOAD = 'DUMP_PAYLOAD';
|
||||
public const string ADMIN_TOKEN = 'ADMIN_TOKEN';
|
||||
public const string PLEX_USER_UUID = 'plex_user_uuid';
|
||||
public const string NO_THROW = 'NO_THROW';
|
||||
public const string NO_LOGGING = 'NO_LOGGING';
|
||||
public const string MAX_EPISODE_RANGE = 'MAX_EPISODE_RANGE';
|
||||
|
||||
Reference in New Issue
Block a user