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 App\Libs\Uri;
|
||||||
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;
|
||||||
|
use Random\RandomException;
|
||||||
|
|
||||||
final class Add
|
final class Add
|
||||||
{
|
{
|
||||||
use APITraits;
|
use APITraits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws RandomException
|
||||||
|
*/
|
||||||
#[Post(Index::URL . '[/]', name: 'backends.add')]
|
#[Post(Index::URL . '[/]', name: 'backends.add')]
|
||||||
public function BackendAdd(iRequest $request): iResponse
|
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());
|
$data = DataUtil::fromArray($request->getParsedBody());
|
||||||
|
|
||||||
if (null === ($type = $data->get('type'))) {
|
if (null === ($type = $data->get('type'))) {
|
||||||
@@ -46,6 +56,18 @@ final class Add
|
|||||||
]), HTTP_STATUS::HTTP_CONFLICT);
|
]), 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))) {
|
if (null === ($class = Config::get("supported.{$type}", null))) {
|
||||||
throw api_error(r("Unexpected client type '{type}' was given.", [
|
throw api_error(r("Unexpected client type '{type}' was given.", [
|
||||||
'type' => $type
|
'type' => $type
|
||||||
@@ -70,22 +92,29 @@ final class Add
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (false === $instance->validateContext($context)) {
|
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');
|
if (!$config->has('webhook.token')) {
|
||||||
$configFile->set($name, $config);
|
$config = $config->with('webhook.token', bin2hex(random_bytes(Config::get('webhook.tokenLength'))));
|
||||||
$configFile->persist();
|
}
|
||||||
|
|
||||||
|
ConfigFile::open(Config::get('backends_file'), 'yaml')
|
||||||
|
->set($name, $config->getAll())
|
||||||
|
->persist();
|
||||||
} catch (InvalidContextException $e) {
|
} catch (InvalidContextException $e) {
|
||||||
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
|
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = [
|
$data = $this->getBackends(name: $name);
|
||||||
'backends' => [],
|
$data = array_pop($data);
|
||||||
'links' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
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
|
private function fromRequest(string $type, iRequest $request, ClientInterface|null $client = null): array
|
||||||
@@ -123,7 +152,7 @@ final class Add
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($optionals as $key => $type) {
|
foreach ($optionals as $key => $type) {
|
||||||
if (null !== ($value = $data->get($key))) {
|
if (null !== ($value = $data->get('options.' . $key))) {
|
||||||
$val = $data->get($value, $type);
|
$val = $data->get($value, $type);
|
||||||
settype($val, $type);
|
settype($val, $type);
|
||||||
$config = ag_set($config, "options.{$key}", $val);
|
$config = ag_set($config, "options.{$key}", $val);
|
||||||
@@ -131,7 +160,7 @@ final class Add
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (null !== $client) {
|
if (null !== $client) {
|
||||||
$config = ag_set($config, 'options', $client->fromRequest($request));
|
$config = $client->fromRequest($config, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $config;
|
return $config;
|
||||||
|
|||||||
@@ -202,12 +202,14 @@ interface ClientInterface
|
|||||||
public function listLibraries(array $opts = []): array;
|
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.
|
* @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.
|
* Validate backend context.
|
||||||
|
|||||||
@@ -539,9 +539,9 @@ class EmbyClient implements iClient
|
|||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @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
|
* @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
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
public function fromRequest(ServerRequestInterface $request): array
|
public function fromRequest(array $config, ServerRequestInterface $request): array
|
||||||
{
|
{
|
||||||
$params = DataUtil::fromArray($request->getParsedBody());
|
$params = DataUtil::fromArray($request->getParsedBody());
|
||||||
|
|
||||||
$opts = [];
|
if (null !== ($uuid = $params->get('options.' . Options::PLEX_USER_UUID))) {
|
||||||
|
$config = ag_set($config, 'options.' . Options::PLEX_USER_UUID, $uuid);
|
||||||
if (null !== ($uuid = $params->get('plex_user_uuid'))) {
|
|
||||||
$opts['plex_user_uuid'] = $uuid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== ($adminToken = $params->get(Options::ADMIN_TOKEN))) {
|
if (null !== ($adminToken = $params->get('options.' . Options::ADMIN_TOKEN))) {
|
||||||
$opts[Options::ADMIN_TOKEN] = $adminToken;
|
$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
|
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 STATE_UPDATE_EVENT = 'STATE_UPDATE_EVENT';
|
||||||
public const string DUMP_PAYLOAD = 'DUMP_PAYLOAD';
|
public const string DUMP_PAYLOAD = 'DUMP_PAYLOAD';
|
||||||
public const string ADMIN_TOKEN = 'ADMIN_TOKEN';
|
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_THROW = 'NO_THROW';
|
||||||
public const string NO_LOGGING = 'NO_LOGGING';
|
public const string NO_LOGGING = 'NO_LOGGING';
|
||||||
public const string MAX_EPISODE_RANGE = 'MAX_EPISODE_RANGE';
|
public const string MAX_EPISODE_RANGE = 'MAX_EPISODE_RANGE';
|
||||||
|
|||||||
Reference in New Issue
Block a user