Remapped backend endpoints to /backend instead of /backends.

This commit is contained in:
abdulmohsen
2024-05-04 10:57:34 +03:00
parent f1417a554b
commit 041a325e3e
19 changed files with 307 additions and 114 deletions

157
src/API/Backend/Ignore.php Normal file
View File

@@ -0,0 +1,157 @@
<?php
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\Post;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\DataUtil;
use App\Libs\HTTP_STATUS;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
final class Ignore
{
use APITraits;
private ConfigFile $file;
public function __construct()
{
$this->file = new ConfigFile(Config::get('path') . '/config/ignore.yaml', type: 'yaml', autoCreate: true);
}
#[Get(Index::URL . '/{name:backend}/ignore[/]', name: 'backends.backend.ignoredIds')]
public function ignoredIds(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('No backend was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$list = [];
foreach ($this->file->getAll() as $guid => $date) {
$urlParts = parse_url($guid);
$backend = ag($urlParts, 'host');
$type = ag($urlParts, 'scheme');
$db = ag($urlParts, 'user');
$id = ag($urlParts, 'pass');
$scope = ag($urlParts, 'query');
if ($name !== $backend) {
continue;
}
$rule = makeIgnoreId($guid);
$list[] = [
'rule' => (string)$rule,
'type' => ucfirst($type),
'backend' => $backend,
'db' => $db,
'id' => $id,
'scoped' => null === $scope ? 'No' : 'Yes',
'created' => makeDate($date),
];
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$response = [
'ignore' => $list,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
#[Delete(Index::URL . '/{name:backend}/ignore[/]', name: 'backends.backend.ignoredIds.delete')]
public function deleteRule(iRequest $request, array $args = []): iResponse
{
$params = DataUtil::fromRequest($request);
if (null === ($rule = $params->get('rule'))) {
return api_error('No rule was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
checkIgnoreRule($rule);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (!$this->file->has($rule)) {
return api_error('Rule not found.', HTTP_STATUS::HTTP_NOT_FOUND);
}
$this->file->delete($rule)->persist();
return api_response(HTTP_STATUS::HTTP_OK);
}
#[Post(Index::URL . '/{name:backend}/ignore[/]', name: 'backends.backend.ignoredIds.add')]
public function addRule(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('No backend was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = $this->getBackends(name: $name);
if (empty($data)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$params = DataUtil::fromRequest($request);
if (null === ($rule = $params->get('rule'))) {
$partial = [
'type' => $params->get('type'),
'backend' => $name,
'db' => $params->get('db'),
'id' => $params->get('id'),
];
foreach ($partial as $k => $v) {
if (empty($v)) {
return api_error(r('No {key} was given.', ['key' => $k]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
}
$partial['type'] = strtolower($partial['type']);
$rule = r('{type}://{db}:{id}@{backend}', $partial);
if (null !== ($scoped = $params->get('scoped'))) {
$rule .= '?id=' . $scoped;
}
}
try {
checkIgnoreRule($rule);
$id = makeIgnoreId($rule);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (true === $this->file->has((string)$id)) {
return api_error('Rule already exists.', HTTP_STATUS::HTTP_CONFLICT);
}
if (true === $this->file->has((string)$id->withQuery(''))) {
return api_error('Global rule already exists.', HTTP_STATUS::HTTP_CONFLICT);
}
$this->file->set((string)$id, time())->persist();
return api_response(HTTP_STATUS::HTTP_CREATED);
}
}

45
src/API/Backend/Index.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Get;
use App\Libs\HTTP_STATUS;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Index
{
use APITraits;
public const string URL = '%{api.prefix}/backend';
#[Get(self::URL . '/{name:backend}[/]', name: 'backends.view')]
public function __invoke(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = $this->getBackends(name: $name);
if (empty($data)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$data = array_pop($data);
$response = [
...$data,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, ['backend' => $response]);
}
}

59
src/API/Backend/Info.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
final class Info
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/info[/]', name: 'backends.backend.info')]
public function backendsView(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient(name: $name);
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$opts = [];
$params = DataUtil::fromRequest($request, true);
if (true === (bool)$params->get('raw', false)) {
$opts[Options::RAW_RESPONSE] = true;
}
try {
$data = $client->getInfo($opts);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$response = [
'data' => $data,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\API\Backend\Library;
use App\API\Backend\Index as BackendsIndex;
use App\Libs\Attributes\Route\Route;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\DataUtil;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Ignore
{
use APITraits;
#[Route(['POST', 'DELETE'], BackendsIndex::URL . '/{name:backend}/library[/]', name: 'backends.library.ignore')]
public function _invoke(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('No backend was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (null === ($id = DataUtil::fromRequest($request, true)->get('id', null))) {
return api_error('No library id was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$remove = 'DELETE' === $request->getMethod();
$config = ConfigFile::open(Config::get('backends_file'), 'yaml');
if (null === $config->get($name)) {
return api_error(r("Backend '{backend}' not found.", ['backend' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$ignoreIds = array_map(
fn($v) => trim($v),
explode(',', (string)$config->get("{$name}.options." . Options::IGNORE, ''))
);
$mode = !(true === $remove);
if ($mode === in_array($id, $ignoreIds)) {
return api_error(r("Library id '{id}' is {message} ignored.", [
'id' => $id,
'message' => $remove ? "not" : 'already',
]), $remove ? HTTP_STATUS::HTTP_NOT_FOUND : HTTP_STATUS::HTTP_CONFLICT);
}
$found = false;
$libraries = $this->getClient(name: $name)->listLibraries();
foreach ($libraries as &$library) {
if ((string)ag($library, 'id') === (string)$id) {
$ignoreIds[] = $id;
$library['ignored'] = !$remove;
$found = true;
break;
}
}
if (false === $found) {
return api_error(r("The library id '{id}' is incorrect.", ['id' => $name]), HTTP_STATUS::HTTP_NOT_FOUND, [
'possible_ids' => array_column($libraries, 'id'),
]);
}
if (true === $remove) {
$ignoreIds = array_diff($ignoreIds, [$id]);
}
$config->set("{$name}.options." . Options::IGNORE, implode(',', array_values($ignoreIds)))->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'type' => $config->get("{$name}.type"),
'libraries' => $libraries,
'links' => [
'self' => (string)parseConfigValue(BackendsIndex::URL . "/{$name}/library"),
'backend' => (string)parseConfigValue(BackendsIndex::URL . "/{$name}"),
],
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\API\Backend\Library;
use App\API\Backend\Index as BackendsIndex;
use App\Libs\Attributes\Route\Get;
use App\Libs\Config;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\HTTP_STATUS;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Index
{
use APITraits;
#[Get(BackendsIndex::URL . '/{name:backend}/library[/]', name: 'backends.library.list')]
public function listLibraries(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('No backend was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient(name: $name);
} catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$response = [
'type' => ag(array_flip(Config::get('supported')), $client::class),
'libraries' => $client->listLibraries(),
'links' => [
'self' => (string)$request->getUri()->withHost('')->withPort(0)->withScheme(''),
'backend' => (string)parseConfigValue(BackendsIndex::URL . "/{$name}"),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\API\Backend\Library;
use App\API\Backend\Index as BackendsIndex;
use App\Commands\Backend\Library\MismatchCommand;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Mismatched
{
use APITraits;
#[Get(BackendsIndex::URL . '/{name:backend}/mismatched[/[{id}[/]]]', name: 'backends.library.mismatched')]
public function listLibraries(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('No backend was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$params = DataUtil::fromArray($request->getQueryParams());
$backendOpts = $opts = $list = [];
if ($params->get('timeout')) {
$backendOpts = ag_set($backendOpts, 'client.timeout', (float)$params->get('timeout'));
}
if ($params->get('raw')) {
$opts[Options::RAW_RESPONSE] = true;
}
$percentage = (float)$params->get('percentage', MismatchCommand::DEFAULT_PERCENT);
$method = $params->get('method', MismatchCommand::METHODS[0]);
if (false === in_array($method, MismatchCommand::METHODS, true)) {
return api_error('Invalid comparison method.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient(name: $name, config: $backendOpts);
} catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$ids = [];
if (null !== ($id = ag($args, 'id'))) {
$ids[] = $id;
} else {
foreach ($client->listLibraries() as $library) {
if (false === (bool)ag($library, 'supported') || true === (bool)ag($library, 'ignored')) {
continue;
}
$ids[] = ag($library, 'id');
}
}
foreach ($ids as $libraryId) {
foreach ($client->getLibrary(id: $libraryId, opts: $opts) as $item) {
$processed = MismatchCommand::compare(item: $item, method: $method);
if (empty($processed) || $processed['percent'] >= $percentage) {
continue;
}
$list[] = $processed;
}
}
$response = [
'items' => $list,
'links' => [
'self' => (string)$request->getUri()->withHost('')->withPort(0)->withScheme(''),
'backend' => (string)parseConfigValue(BackendsIndex::URL . "/{$name}"),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\API\Backend\Library;
use App\API\Backend\Index as BackendsIndex;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Unmatched
{
use APITraits;
#[Get(BackendsIndex::URL . '/{name:backend}/unmatched[/[{id}[/]]]', name: 'backends.library.unmatched')]
public function listLibraries(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('No backend was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$params = DataUtil::fromArray($request->getQueryParams());
$backendOpts = $opts = $list = [];
if ($params->get('timeout')) {
$backendOpts = ag_set($backendOpts, 'client.timeout', (float)$params->get('timeout'));
}
if ($params->get('raw')) {
$opts[Options::RAW_RESPONSE] = true;
}
try {
$client = $this->getClient(name: $name, config: $backendOpts);
} catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$ids = [];
if (null !== ($id = ag($args, 'id'))) {
$ids[] = $id;
} else {
foreach ($client->listLibraries() as $library) {
if (false === (bool)ag($library, 'supported') || true === (bool)ag($library, 'ignored')) {
continue;
}
$ids[] = ag($library, 'id');
}
}
foreach ($ids as $libraryId) {
foreach ($client->getLibrary(id: $libraryId, opts: $opts) as $item) {
if (null === ($externals = ag($item, 'guids', null)) || empty($externals)) {
$list[] = $item;
}
}
}
$response = [
'items' => $list,
'links' => [
'self' => (string)$request->getUri()->withHost('')->withPort(0)->withScheme(''),
'backend' => (string)parseConfigValue(BackendsIndex::URL . "/{$name}"),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Patch;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\HTTP_STATUS;
use App\Libs\Traits\APITraits;
use JsonException;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class PartialUpdate
{
use APITraits;
#[Patch(Index::URL . '/{name:backend}[/]', name: 'backends.view')]
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);
}
$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);
}
try {
$data = json_decode((string)$request->getBody(), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
return api_error(r('Invalid JSON data. {error}', ['error' => $e->getMessage()]),
HTTP_STATUS::HTTP_BAD_REQUEST);
}
foreach ($data as $update) {
if (!ag_exists($update, 'key')) {
return api_error('No key to update was present.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$list->set($name . '.' . ag($update, 'key'), ag($update, 'value'));
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$list->persist();
$backend = $this->getBackends(name: $name);
if (empty($backend)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$backend = array_pop($backend);
return api_response(HTTP_STATUS::HTTP_OK, [
'backend' => array_filter(
$backend,
fn($key) => false === in_array($key, ['options', 'webhook'], true),
ARRAY_FILTER_USE_KEY
),
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
]);
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Search
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/search[/[{id}[/]]]', name: 'backends.backend.search.id')]
public function searchById(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);
}
$params = DataUtil::fromRequest($request, true);
$id = ag($args, 'id', $params->get('id', null));
$query = $params->get('query', null);
if (null === $id && null === $query) {
return api_error('No search id or query string was provided.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$backend = $this->getClient(name: $name);
} catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
if (null !== $id) {
$data = $backend->searchId($id, [Options::RAW_RESPONSE => (bool)$params->get('raw', false)]);
} else {
$data = $backend->search(
query: $query,
limit: (int)$params->get('limit', 25),
opts: [Options::RAW_RESPONSE => (bool)$params->get('raw', false)]
);
}
if (count($data) < 1) {
return api_error(r("No results are found for '{query}'.", [
'query' => $id ?? $query
]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$response = [
'results' => $id ? [$data] : $data,
'links' => [
'self' => (string)$apiUrl,
'backend' => (string)$apiUrl->withPath(parseConfigValue(Index::URL . '/' . $name)),
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
'options' => [
'raw' => (bool)$params->get('raw', false),
],
];
if (null === $id && $query) {
$response['options']['limit'] = (int)$params->get('limit', 25);
}
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
final class Sessions
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/sessions[/]', name: 'backends.backend.sessions')]
public function backendsView(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient(name: $name);
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$opts = [];
$params = DataUtil::fromRequest($request, true);
if (true === (bool)$params->get('raw', false)) {
$opts[Options::RAW_RESPONSE] = true;
}
try {
$sessions = $client->getSessions($opts);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$response = [
'sessions' => ag($sessions, 'sessions', []),
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

63
src/API/Backend/Users.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
final class Users
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/users[/]', name: 'backends.backend.users')]
public function backendsView(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient(name: $name);
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$opts = [];
$params = DataUtil::fromRequest($request, true);
if (true === (bool)$params->get('tokens', false)) {
$opts['tokens'] = true;
}
if (true === (bool)$params->get('raw', false)) {
$opts[Options::RAW_RESPONSE] = true;
}
try {
$users = $client->getUsersList($opts);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$response = [
'users' => $users,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
final class Version
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/version[/]', name: 'backends.backend.info')]
public function backendsView(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient(name: $name);
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$opts = [];
$params = DataUtil::fromRequest($request, true);
if (true === (bool)$params->get('raw', false)) {
$opts[Options::RAW_RESPONSE] = true;
}
try {
$version = $client->getVersion($opts);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$response = [
'version' => $version,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, $response);
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Libs\Attributes\Route\Route;
use App\Libs\Config;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Extends\LogMessageProcessor;
use App\Libs\HTTP_STATUS;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use App\Libs\Uri;
use DateInterval;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface as iCache;
use Psr\SimpleCache\InvalidArgumentException;
final class Webhooks
{
use APITraits;
private iLogger $accessLog;
public function __construct(private iCache $cache)
{
$this->accessLog = new Logger(name: 'http', processors: [new LogMessageProcessor()]);
$level = Config::get('webhook.debug') ? Level::Debug : Level::Info;
if (null !== ($logfile = Config::get('webhook.logfile'))) {
$this->accessLog = $this->accessLog->pushHandler(new StreamHandler($logfile, $level, true));
}
if (true === inContainer()) {
$this->accessLog->pushHandler(new StreamHandler('php://stderr', $level, true));
}
}
/**
* Receive a webhook request from a backend.
*
* @param iRequest $request The incoming request object.
* @param array $args The request path arguments.
*
* @return iResponse The response object.
* @throws InvalidArgumentException if cache key is invalid.
*/
#[Route(['POST', 'PUT'], Index::URL . '/{name:backend}/webhook[/]', name: 'webhooks.receive')]
public function __invoke(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$backend = $this->getBackends(name: $name);
if (empty($backend)) {
throw new RuntimeException(r("Backend '{backend}' not found.", ['backend ' => $name]));
}
$backend = array_pop($backend);
$client = $this->getClient(name: $name);
} catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
if (true === Config::get('webhook.dumpRequest')) {
saveRequestPayload(clone $request);
}
$request = $client->processRequest($request);
$attr = $request->getAttributes();
if (null !== ($userId = ag($backend, 'user', null)) && true === (bool)ag($backend, 'webhook.match.user')) {
if (null === ($requestUser = ag($attr, 'user.id'))) {
$message = "Request payload didn't contain a user id. Backend requires a user check.";
$this->write($request, Level::Info, $message);
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
}
if (false === hash_equals((string)$userId, (string)$requestUser)) {
$message = r('Request user id [{req_user}] does not match configured value [{config_user}]', [
'req_user' => $requestUser ?? 'NOT SET',
'config_user' => $userId,
]);
$this->write($request, Level::Info, $message);
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
}
}
if (null !== ($uuid = ag($backend, 'uuid', null)) && true === (bool)ag($backend, 'webhook.match.uuid')) {
if (null === ($requestBackendId = ag($attr, 'backend.id'))) {
$message = "Request payload didn't contain the backend unique id.";
$this->write($request, Level::Info, $message);
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
}
if (false === hash_equals((string)$uuid, (string)$requestBackendId)) {
$message = r('Request backend unique id [{req_uid}] does not match backend uuid [{config_uid}].', [
'req_uid' => $requestBackendId ?? 'NOT SET',
'config_uid' => $uuid,
]);
$this->write($request, Level::Info, $message);
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
}
}
if (true === (bool)ag($backend, 'import.enabled')) {
if (true === ag_exists($backend, 'options.' . Options::IMPORT_METADATA_ONLY)) {
$backend = ag_delete($backend, 'options.' . Options::IMPORT_METADATA_ONLY);
}
}
$metadataOnly = true === (bool)ag($backend, 'options.' . Options::IMPORT_METADATA_ONLY);
if (true !== $metadataOnly && true !== (bool)ag($backend, 'import.enabled')) {
$response = api_response(HTTP_STATUS::HTTP_NOT_ACCEPTABLE);
$this->write($request, Level::Error, r('Import are disabled for [{backend}].', [
'backend' => $client->getName(),
]), forceContext: true);
return $response->withHeader('X-Log-Response', '0');
}
$entity = $client->parseWebhook($request);
if (true === (bool)ag($backend, 'options.' . Options::DUMP_PAYLOAD)) {
saveWebhookPayload($entity, $request);
}
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
$this->write(
$request,
Level::Info,
'Ignoring [{backend}] {item.type} [{item.title}]. No valid/supported external ids.',
[
'backend' => $entity->via,
'item' => [
'title' => $entity->getName(),
'type' => $entity->type,
],
]
);
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED)->withHeader('X-Log-Response', '0');
}
if ((0 === (int)$entity->episode || null === $entity->season) && $entity->isEpisode()) {
$this->write(
$request,
Level::Notice,
'Ignoring [{backend}] {item.type} [{item.title}]. No episode/season number present.',
[
'backend' => $entity->via,
'item' => [
'title' => $entity->getName(),
'type' => $entity->type,
'season' => (string)($entity->season ?? 'None'),
'episode' => (string)($entity->episode ?? 'None'),
]
]
);
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED)->withHeader('X-Log-Response', '0');
}
$items = $this->cache->get('requests', []);
$itemId = r('{type}://{id}:{tainted}@{backend}', [
'type' => $entity->type,
'backend' => $entity->via,
'tainted' => $entity->isTainted() ? 'tainted' : 'untainted',
'id' => ag($entity->getMetadata($entity->via), iState::COLUMN_ID, '??'),
]);
$items[$itemId] = [
'options' => [
Options::IMPORT_METADATA_ONLY => $metadataOnly,
],
'entity' => $entity,
];
$this->cache->set('requests', $items, new DateInterval('P3D'));
if (false === $metadataOnly && true === $entity->hasPlayProgress()) {
$progress = $this->cache->get('progress', []);
$progress[$itemId] = $entity;
$this->cache->set('progress', $progress, new DateInterval('P1D'));
}
$this->write($request, Level::Info, 'Queued [{backend}: {event}] {item.type} [{item.title}].', [
'backend' => $entity->via,
'event' => ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_EVENT),
'has_progress' => $entity->hasPlayProgress() ? 'Yes' : 'No',
'item' => [
'title' => $entity->getName(),
'type' => $entity->type,
'played' => $entity->isWatched() ? 'Yes' : 'No',
'queue_id' => $itemId,
'progress' => $entity->hasPlayProgress() ? $entity->getPlayProgress() : null,
]
]
);
return api_response(HTTP_STATUS::HTTP_OK)->withHeader('X-Log-Response', '0');
}
/**
* Write a log entry to the access log.
*
* @param iRequest $request The incoming request object.
* @param int|string|Level $level The log level or priority.
* @param string $message The log message.
* @param array $context Additional data/context for the log entry.
*/
private function write(
iRequest $request,
int|string|Level $level,
string $message,
array $context = [],
bool $forceContext = false
): void {
$params = $request->getServerParams();
$uri = new Uri((string)ag($params, 'REQUEST_URI', '/'));
if (false === empty($uri->getQuery())) {
$query = [];
parse_str($uri->getQuery(), $query);
if (true === ag_exists($query, 'apikey')) {
$query['apikey'] = 'api_key_removed';
$uri = $uri->withQuery(http_build_query($query));
}
}
$context = array_replace_recursive([
'request' => [
'method' => $request->getMethod(),
'id' => ag($params, 'X_REQUEST_ID'),
'ip' => getClientIp($request),
'agent' => ag($params, 'HTTP_USER_AGENT'),
'uri' => (string)$uri,
],
], $context);
if (($attributes = $request->getAttributes()) && count($attributes) >= 1) {
$context['attributes'] = $attributes;
}
if (true === (Config::get('logs.context') || $forceContext)) {
$this->accessLog->log($level, $message, $context);
} else {
$this->accessLog->log($level, r($message, $context));
}
}
}