Massive API & WebUI changes.

This commit is contained in:
abdulmohsen
2024-05-04 19:09:19 +03:00
parent af41ac6171
commit 9d966b9b40
50 changed files with 1117 additions and 662 deletions

35
FAQ.md
View File

@@ -341,27 +341,13 @@ $ docker exec -ti watchstate console system:tasks
### How to add webhooks?
The Webhook URL is backend specific, the request path is `/v1/api/backend/[BACKEND_NAME]/webhook?apikey=[APIKEY]`,
Where `[BACKEND_NAME]` is the name of the backend you want to add webhook for, and `[APIKEY]` is the global api key
which you can get via the `system:apikey` command. Typically, the full path
is `http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook?apikey=[APIKEY]`. if the tool
port is directly exposed or via the reverse proxy you have setup.
If your media backend support sending headers then remove query parameter `?apikey=[APIKEY]`, and add this header
```
Authorization: Bearer [APIKEY]
```
To see your global api key run the following command:
```bash
$ docker exec -ti watchstate console system:apikey
```
The Webhook URL is backend specific, the request path is `/v1/api/backend/[BACKEND_NAME]/webhook`,
Where `[BACKEND_NAME]` is the name of the backend you want to add webhook for. Typically, the full URL
is `http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook]`.
> [!NOTE]
> You will keep seeing the `webhook.token` key, it's being kept for backward compatibility, and will be removed in the
> future.
> future. It has no effect except as pointer to the new method.
-----
@@ -371,10 +357,9 @@ Go to your Manage Emby Server > Server > Webhooks > (Click Add Webhook)
##### Webhook/Notifications URL:
`http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook?apikey=[APIKEY]`
`http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook`
* Replace `[BACKEND_NAME]` with the name you have chosen for your backend.
* Replace `[APIKEY]` with the global apikey.
##### Request content type (Emby v4.9+):
@@ -412,10 +397,9 @@ Go to your Plex Web UI > Settings > Your Account > Webhooks > (Click ADD WEBHOOK
##### URL:
`http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook?apikey=[APIKEY]`
`http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook`
* Replace `[BACKEND_NAME]` with the name you have chosen for your backend.
* Replace `[APIKEY]` with the global apikey.
> [!NOTE]
> If you use multiple plex servers and use the same PlexPass account for all of them, You have to add each backend
@@ -465,13 +449,6 @@ go back again to dashboard > plugins > webhook. Add `Add Generic Destination`,
Toggle this checkbox.
### Add Request Header
* Key: `Authorization`
* Value: `Bearer [APIKEY]`
Replace `[APIKEY]` with the global apikey.
Click `Save`
---

View File

@@ -9,6 +9,11 @@ out of the box, this tool support `Jellyfin`, `Plex` and `Emby` media servers.
## updates
### 2024-05-04
The new webhook endpoint no longer requires a key, and it's now open to public you just need to specify the backend
name.
### 2024-04-30 - [BREAKING CHANGE]
We are going to retire the old webhooks endpoint, please refer to the [FAQ](FAQ.md#how-to-add-webhooks) to know how to

View File

@@ -73,6 +73,8 @@ return (function () {
$dbFile = ag($config, 'path') . '/db/watchstate_' . ag($config, 'database.version') . '.db';
$config['api']['logfile'] = ag($config, 'tmpDir') . '/logs/access.' . $logDateFormat . '.log';
$config['database'] += [
'file' => $dbFile,
'dsn' => 'sqlite:' . $dbFile,
@@ -91,7 +93,7 @@ return (function () {
];
$config['webhook'] = [
'logfile' => ag($config, 'tmpDir') . '/logs/access.' . $logDateFormat . '.log',
'logfile' => ag($config, 'tmpDir') . '/logs/webhook.' . $logDateFormat . '.log',
'dumpRequest' => (bool)env('WS_WEBHOOK_DUMP_REQUEST', false),
'tokenLength' => (int)env('WS_WEBHOOK_TOKEN_LENGTH', 16),
'file_format' => (string)env('WS_WEBHOOK_LOG_FILE_FORMAT', 'webhook.{backend}.{event}.{id}.json'),

View File

@@ -0,0 +1,330 @@
<template>
<div class="columns is-multiline">
<div class="column is-12 is-clearfix">
<span class="title is-4">
<NuxtLink href="/backends">Backends</NuxtLink>
- Edit:
<NuxtLink :href="'/backend/' + id">{{ id }}</NuxtLink>
</span>
<div class="is-pulled-right">
<div class="field is-grouped"></div>
</div>
</div>
<div class="column is-12" v-if="isLoading">
<Message message_class="is-info" title="Information">
<span class="icon"><i class="fas fa-spinner fa-pulse"></i></span>
<span>Loading backend settings, please wait...</span>
</Message>
</div>
<div v-else class="column is-12">
<form id="backend_edit_form" @submit.prevent="saveContent">
<div class="box">
<div class="field">
<label class="label">Backend Name</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.name" required readonly disabled>
<div class="icon is-small is-left">
<i class="fas fa-user"></i>
</div>
<p class="help">
Choose a unique name for this backend. You cannot change it later. Backend name must be in <code>lower
case a-z, 0-9 and _</code> only.
</p>
</div>
</div>
<div class="field">
<label class="label">Backend Type</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.type" readonly disabled>
<div class="icon is-small is-left">
<i class="fas fa-globe"></i>
</div>
</div>
</div>
<div class="field">
<label class="label">Backend URL</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.url" required>
<div class="icon is-small is-left">
<i class="fas fa-link"></i>
</div>
<p class="help">
Enter the URL of the backend.
<a v-if="'plex' === backend.type" href="javascript:void(0)">Get associated servers with token. NYI</a>
</p>
</div>
</div>
<div class="field">
<label class="label">
Backend API token
</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.token" required>
<div class="icon is-small is-left">
<i class="fas fa-key"></i>
</div>
<p class="help">
<template v-if="'plex'===backend.type">
Enter the <code>X-Plex-Token</code>. <a target="_blank"
href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/">
Visit This article for more information
</a>.
</template>
<template v-else>
Generate a new API token from <code>Dashboard > Settings > API Keys</code>.
</template>
</p>
</div>
</div>
<div class="field">
<label class="label">Backend Unique ID</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.uuid" required>
<div class="icon is-small is-left">
<i class="fas fa-server" v-if="!uuidLoading"></i>
<i class="fas fa-spinner fa-pulse" v-else></i>
</div>
<p class="help">
The Unique identifier for the backend.
<a href="javascript:void(0)" @click="getUUid">Get from the backend.</a>
</p>
</div>
</div>
<div class="field">
<label class="label">Backend User ID</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select v-model="backend.user" class="is-capitalized">
<option v-for="user in users" :key="'uid-'+user.id" :value="user.id">
<template v-if="'plex' === backend">
{{ user.id }} - {{ user.name }}
</template>
<template v-else>{{ user.name }}</template>
</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-user-tie" v-if="!usersLoading"></i>
<i class="fas fa-spinner fa-pulse" v-else></i>
</div>
<p class="help">
The user ID of the backend.
<a href="javascript:void(0)" @click="getUsers">
Retrieve User ids from backend.
</a>
</p>
</div>
</div>
<div class="field" v-if="backend.import">
<label class="label" for="backend_import">Import data from this backend</label>
<div class="control">
<input id="backend_import" type="checkbox" class="switch is-success" v-model="backend.import.enabled">
<label for="backend_import">Enable</label>
<p class="help">
Import means to get the data from the backend and store it in the database.
</p>
</div>
</div>
<div class="field" v-if="backend.export">
<label class="label" for="backend_export">Export data to this backend</label>
<div class="control">
<input id="backend_export" type="checkbox" class="switch is-success" v-model="backend.export.enabled">
<label for="backend_export">Enable</label>
<p class="help">
Export means to send the data from the database to this backend.
</p>
</div>
</div>
<div class="field" v-if="backend.webhook">
<label class="label" for="webhook_match_user">Webhook match user</label>
<div class="control">
<input id="webhook_match_user" type="checkbox" class="switch is-success"
v-model="backend.webhook.match.user">
<label for="webhook_match_user">Enable</label>
<p class="help">
Check webhook payload for user id match. if it does not match, the payload will be ignored.
</p>
</div>
</div>
<div class="field" v-if="backend.webhook">
<label class="label" for="webhook_match_uuid">Webhook match backend id</label>
<div class="control">
<input id="webhook_match_uuid" type="checkbox" class="switch is-success"
v-model="backend.webhook.match.uuid">
<label for="webhook_match_uuid">Enable</label>
<p class="help">
Check webhook payload for backend unique id. if it does not match, the payload will be ignored.
</p>
</div>
</div>
<div class="field">
<label class="label" @click="showOptions = !showOptions">
<span class="icon-text">
<span class="icon">
<i v-if="showOptions" class="fas fa-arrow-up"></i>
<i v-else class="fas fa-arrow-down"></i>
</span>
<span>Optional options</span>
</span>
</label>
<div class="columns is-multiline is-mobile" v-if="showOptions && backend.options">
<template v-for="(val, key) in backend.options" :key="'bo-'+key">
<div class="column is-5">
<input type="text" class="input" :value="key" readonly disabled>
</div>
<div class="column is-6">
<input type="text" class="input" v-model="backend.options[key]">
</div>
<div class="column is-1">
<button class="button is-danger" @click.prevent="removeOption(key)">
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
</button>
</div>
</template>
</div>
</div>
<div class="field has-text-right">
<div class="control">
<button class="button is-primary" type="submit">
<span class="icon is-small">
<i class="fas fa-save"></i>
</span>
<span>Save Settings</span>
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
import 'assets/css/bulma-switch.css'
const id = useRoute().params.backend
const backend = ref({})
const showOptions = ref(false)
const isLoading = ref(true)
const users = ref([])
const usersLoading = ref(false)
const uuidLoading = ref(false)
useHead({title: 'Backends - Edit: ' + id})
const loadContent = async () => {
const content = await request(`/backend/${id}`)
backend.value = await content.json()
await getUsers()
isLoading.value = false
}
const saveContent = async () => {
const response = await request(`/backend/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(backend.value)
})
const json = await response.json()
if (!response.ok) {
alert('Failed to save backend settings.')
}
}
const removeOption = async (key) => {
if (!confirm(`Are you sure you want to remove this option [${key}]?`)) {
return
}
delete backend.value.options[key]
const response = await request(`/backend/${id}/option/${key}`, {method: 'DELETE'})
}
const getUUid = async () => {
const required_values = ['type', 'token', 'url'];
if (required_values.some(v => !backend.value[v])) {
alert('Please fill all the required fields.')
return
}
uuidLoading.value = true
const response = await request(`/backends/uuid/${backend.value.type}`, {
method: 'POST',
body: JSON.stringify({
token: backend.value.token,
url: backend.value.url
})
})
const json = await response.json()
uuidLoading.value = false
if (!response.ok) {
alert('Failed to get the UUID from the backend.')
return
}
backend.value.uuid = json.identifier
}
const getUsers = async (showAlert = true) => {
const required_values = ['type', 'token', 'url', 'uuid'];
usersLoading.value = true
if (required_values.some(v => !backend.value[v])) {
if (showAlert) {
alert('Please fill all the required fields.')
}
return
}
const response = await request(`/backends/users/${backend.value.type}`, {
method: 'POST',
body: JSON.stringify({
token: backend.value.token,
url: backend.value.url,
uuid: backend.value.uuid,
})
})
const json = await response.json()
usersLoading.value = false
if (200 !== response.status) {
alert(`${json.error.code}: ${json.error.message}`)
return
}
users.value = json
}
onMounted(() => loadContent())
</script>

View File

@@ -14,6 +14,5 @@
</template>
<script setup>
const backend = useRoute().params.backend
</script>

View File

@@ -4,7 +4,7 @@
<span class="title is-4">
<NuxtLink href="/backends">Backends</NuxtLink>
-
<NuxtLink :href="'/backends/' + backend">{{ backend }}</NuxtLink>
<NuxtLink :href="'/backend/' + backend">{{ backend }}</NuxtLink>
: Info
</span>

View File

@@ -1,130 +0,0 @@
<template>
<div class="columns is-multiline">
<div class="column is-12 is-clearfix">
<span class="title is-4">
<NuxtLink href="/backends">Backends</NuxtLink>
: Edit -
<NuxtLink :href="'/backends/' + id">{{ id }}</NuxtLink>
</span>
<div class="is-pulled-right">
<div class="field is-grouped"></div>
</div>
</div>
<div class="column is-12">
<form id="backend_edit_form" @submit.prevent="saveContent">
<div class="box">
<div class="field">
<label class="label">Backend Name</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.name" required readonly disabled>
<div class="icon is-small is-left">
<i class="fas fa-user"></i>
</div>
<p class="help">
Choose a unique name for this backend. You cannot change it later. Backend name must be in <code>lower
case a-z, 0-9 and _</code> only.
</p>
</div>
</div>
<div class="field">
<label class="label">Backend Type</label>
<div class="control has-icons-left">
<div class="select is-fullwidth" disabled>
<select v-model="backend.type" disabled class="is-capitalized">
<option v-for="(bType, index) in supported" :key="'btype-'+index" :value="bType">
{{ bType }}
</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-globe"></i>
</div>
<p class="help">
Select the correct backend type.
</p>
</div>
</div>
<div class="field">
<label class="label">Backend URL</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.url" required>
<div class="icon is-small is-left">
<i class="fas fa-link"></i>
</div>
<p class="help">
Enter the URL of the backend.
<a v-if="'plex' === backend.type" href="javascript:void(0)">Get associated servers with token.</a>
</p>
</div>
</div>
<div class="field">
<label class="label">Backend Token</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.token" required>
<div class="icon is-small is-left">
<i class="fas fa-key"></i>
</div>
<p class="help">
Enter the token of the backend.
</p>
</div>
</div>
<div class="field">
<label class="label">Backend User ID</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.user" required>
<div class="icon is-small is-left">
<i class="fas fa-user-tie"></i>
</div>
<p class="help">
The user ID of the backend. <a href="javascript:void(0)">Pull User ids from backend.</a>
</p>
</div>
</div>
<div class="field">
<label class="label">Backend Unique ID</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="backend.uuid" required>
<div class="icon is-small is-left">
<i class="fas fa-server"></i>
</div>
<p class="help">
The Unique identifier for the backend.
<a href="javascript:void(0)">Pull from the backend</a>
</p>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
const id = useRoute().params.backend
const backend = ref({})
const supported = ref([])
const loadContent = async () => {
let content = await request('/system/supported')
let json = await content.json()
supported.value = json.supported
content = await request(`/backend/${id}`)
json = await content.json()
backend.value = json.backend
}
onMounted(() => loadContent())
</script>

View File

@@ -27,11 +27,16 @@
<div v-for="backend in backends" :key="backend.name" class="column is-6-tablet is-12-mobile">
<div class="card">
<header class="card-header">
<div class="card-header-title is-centered is-word-break">
<NuxtLink :href="'/backends/' + backend.name">
<p class="card-header-title is-centered is-word-break">
<NuxtLink :href="'/backend/' + backend.name">
{{ backend.name }}
</NuxtLink>
</div>
</p>
<span class="card-header-icon" v-tooltip="'Edit Backend settings'">
<NuxtLink :href="`/backend/${backend.name}/edit`">
<span class="icon"><i class="fas fa-cog"></i></span>
</NuxtLink>
</span>
</header>
<div class="card-content">
<div class="columns is-multiline is-mobile has-text-centered">
@@ -82,8 +87,7 @@ const backends = ref([])
const loadContent = async () => {
backends.value = []
const response = await request('/backends')
const json = await response.json();
backends.value = json.backends
backends.value = await response.json()
}
onMounted(() => loadContent())
@@ -97,8 +101,7 @@ const updateValue = async (backend, key, newValue) => {
}])
});
const json = await response.json();
backends.value[backends.value.findIndex(b => b.name === backend.name)] = json.backend
backends.value[backends.value.findIndex(b => b.name === backend.name)] = await response.json();
}
</script>

View File

@@ -60,9 +60,9 @@
<table class="table is-fullwidth is-bordered is-striped is-hoverable has-text-centered">
<thead>
<tr>
<th width="25%">Key</th>
<th style="width: 25%;">Key</th>
<th>Value</th>
<th width="10%">Actions</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody>

View File

@@ -26,7 +26,7 @@
{{ moment(history.updated).fromNow() }}
</div>
<div class="column is-6-mobile">
<NuxtLink :href="'/backends/'+history.via">
<NuxtLink :href="'/backend/'+history.via">
{{ history.via }}
</NuxtLink>
</div>

View File

@@ -25,6 +25,7 @@
<span class="icon" v-if="'access' === item.type"><i class="fas fa-key"></i></span>
<span class="icon" v-if="'task' === item.type"><i class="fas fa-tasks"></i></span>
<span class="icon" v-if="'app' === item.type"><i class="fas fa-bugs"></i></span>
<span class="icon" v-if="'webhook' === item.type"><i class="fas fa-book"></i></span>
</span>
</header>
<div class="card-content">
@@ -63,8 +64,8 @@ const logs = ref([])
const loadContent = async () => {
logs.value = []
const response = await request('/logs')
const json = await response.json();
let data = json.logs;
let data = await response.json();
data.sort((a, b) => new Date(b.modified) - new Date(a.modified));
logs.value = data;

View File

@@ -83,10 +83,10 @@
<script setup>
import 'assets/css/bulma-switch.css'
import moment from "moment";
import request from "~/utils/request.js";
import moment from 'moment'
import request from '~/utils/request.js'
useHead({title: 'tasks'})
useHead({title: 'Tasks'})
const tasks = ref([])
const queued = ref([])
@@ -96,7 +96,7 @@ const loadContent = async (clear = false) => {
tasks.value = []
}
const response = await request('/tasks')
const json = await response.json();
const json = await response.json()
tasks.value = json.tasks
queued.value = json.queued
}
@@ -111,9 +111,7 @@ const toggleTask = async (task) => {
});
const response = await request(`/tasks/${task.name}`)
const json = await response.json();
tasks.value[tasks.value.findIndex(b => b.name === task.name)] = json.task
tasks.value[tasks.value.findIndex(b => b.name === task.name)] = await response.json()
}
const queueTask = async (task) => {
@@ -123,8 +121,7 @@ const queueTask = async (task) => {
const response = await request(`/tasks/${task.name}/queue`, {method: 'POST'})
if (response.ok) {
await loadContent();
await loadContent()
}
}
</script>

View File

@@ -2,47 +2,43 @@
declare(strict_types=1);
namespace App\API\Plex;
namespace App\API\Backend;
use App\Libs\Attributes\Route\Post;
use App\Libs\DataUtil;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Exceptions\InvalidArgumentException;
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 Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
use Throwable;
final class GenerateAccessToken
final class AccessToken
{
use APITraits;
public const string URL = '%{api.prefix}/plex/accesstoken';
public function __construct(private iHttp $http)
public function __construct(private readonly iHttp $http)
{
}
#[Post(self::URL . '/{id:backend}[/]', name: 'plex.accesstoken')]
public function gAccesstoken(iRequest $request, array $args = []): iResponse
#[Post(Index::URL . '/{name:backend}/accesstoken[/]', name: 'backend.accesstoken')]
public function __invoke(iRequest $request, array $args = []): iResponse
{
$backend = ag($args, 'id');
if (null === ($name = ag($args, 'name'))) {
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = DataUtil::fromArray($request->getParsedBody());
if (null === ($uuid = $data->get('uuid'))) {
return api_error('No User (uuid) was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient($backend);
} catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
if (null === ($id = $data->get('id'))) {
return api_error('No id was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getClient(name: $name);
$token = $client->getUserToken(
userId: $uuid,
userId: $id,
username: $data->get('username', $client->getContext()->backendName . '_user'),
);
@@ -59,7 +55,9 @@ final class GenerateAccessToken
}
return api_response(HTTP_STATUS::HTTP_OK, $arr);
} catch (\Throwable $e) {
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Backends\Plex\PlexClient;
use App\Libs\Attributes\Route\Get;
use App\Libs\Exceptions\InvalidArgumentException;
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 Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
use Throwable;
final class Discover
{
use APITraits;
public function __construct(private readonly iHttp $http)
{
}
#[Get(Index::URL . '/{name:backend}/discover[/]', name: 'backend.discover')]
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);
}
try {
$client = $this->getClient(name: $name);
if (PlexClient::CLIENT_NAME !== $client->getType()) {
return api_error('Discover is only available for Plex backends.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
assert($client instanceof PlexClient);
$list = $client::discover($this->http, $client->getContext()->backendToken);
return api_response(HTTP_STATUS::HTTP_OK, ag($list, 'list', []));
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -27,11 +27,11 @@ final class Ignore
$this->file = new ConfigFile(Config::get('path') . '/config/ignore.yaml', type: 'yaml', autoCreate: true);
}
#[Get(Index::URL . '/{name:backend}/ignore[/]', name: 'backends.backend.ignoredIds')]
#[Get(Index::URL . '/{name:backend}/ignore[/]', name: '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);
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$list = [];
@@ -61,22 +61,23 @@ final class Ignore
'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);
return api_response(HTTP_STATUS::HTTP_OK, $list);
}
#[Delete(Index::URL . '/{name:backend}/ignore[/]', name: 'backends.backend.ignoredIds.delete')]
#[Delete(Index::URL . '/{name:backend}/ignore[/]', name: 'backend.ignoredIds.delete')]
public function deleteRule(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);
}
$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'))) {
@@ -98,11 +99,11 @@ final class Ignore
return api_response(HTTP_STATUS::HTTP_OK);
}
#[Post(Index::URL . '/{name:backend}/ignore[/]', name: 'backends.backend.ignoredIds.add')]
#[Post(Index::URL . '/{name:backend}/ignore[/]', name: '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);
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = $this->getBackends(name: $name);

View File

@@ -16,11 +16,11 @@ final class Index
public const string URL = '%{api.prefix}/backend';
#[Get(self::URL . '/{name:backend}[/]', name: 'backends.view')]
#[Get(self::URL . '/{name:backend}[/]', name: 'backend.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);
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$data = $this->getBackends(name: $name);
@@ -29,17 +29,8 @@ final class Index
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]);
return api_response(HTTP_STATUS::HTTP_OK, $data);
}
}

View File

@@ -18,11 +18,11 @@ final class Info
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/info[/]', name: 'backends.backend.info')]
public function backendsView(iRequest $request, array $args = []): iResponse
#[Get(Index::URL . '/{name:backend}/info[/]', name: 'backend.info')]
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);
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
@@ -40,20 +40,9 @@ final class Info
try {
$data = $client->getInfo($opts);
return api_response(HTTP_STATUS::HTTP_OK, $data);
} 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

@@ -2,32 +2,51 @@
declare(strict_types=1);
namespace App\API\Backend\Library;
namespace App\API\Backend;
use App\API\Backend\Index as BackendsIndex;
use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Route;
use App\Libs\Config;
use App\Libs\ConfigFile;
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;
use Throwable;
final class Ignore
final class Library
{
use APITraits;
#[Route(['POST', 'DELETE'], BackendsIndex::URL . '/{name:backend}/library[/]', name: 'backends.library.ignore')]
public function _invoke(iRequest $request, array $args = []): iResponse
#[Get(BackendsIndex::URL . '/{name:backend}/library[/]', name: 'backend.library')]
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);
return api_error('Invalid value for name path parameter.', 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);
try {
$client = $this->getClient(name: $name);
return api_response(HTTP_STATUS::HTTP_OK, $client->listLibraries());
} catch (RuntimeException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route(['POST', 'DELETE'], BackendsIndex::URL . '/{name:backend}/library/{id}[/]', name: 'backend.library.ignore')]
public function ignoreLibrary(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 === ($id = ag($args, 'id'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$remove = 'DELETE' === $request->getMethod();
@@ -75,13 +94,6 @@ final class Ignore
$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}"),
],
]);
return api_response(HTTP_STATUS::HTTP_OK, $libraries);
}
}

View File

@@ -1,44 +0,0 @@
<?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

@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\API\Backend\Library;
namespace App\API\Backend;
use App\API\Backend\Index as BackendsIndex;
use App\API\Backend\Index as backendIndex;
use App\Commands\Backend\Library\MismatchCommand;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
@@ -19,11 +19,11 @@ final class Mismatched
{
use APITraits;
#[Get(BackendsIndex::URL . '/{name:backend}/mismatched[/[{id}[/]]]', name: 'backends.library.mismatched')]
public function listLibraries(iRequest $request, array $args = []): iResponse
#[Get(backendIndex::URL . '/{name:backend}/mismatched[/[{id}[/]]]', name: 'backend.mismatched')]
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);
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$params = DataUtil::fromArray($request->getQueryParams());
@@ -74,15 +74,6 @@ final class Mismatched
$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);
return api_response(HTTP_STATUS::HTTP_OK, $list);
}
}

171
src/API/Backend/Option.php Normal file
View File

@@ -0,0 +1,171 @@
<?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\Patch;
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;
final class Option
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/option/{option}[/]', name: 'backend.option')]
public function viewOption(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);
}
$key = $name . '.options.' . $option;
if (false === $list->has($key)) {
return api_error(r("Option '{option}' not found in backend '{name}'.", [
'option' => $option,
'name' => $name
]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$value = $list->get($key);
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => get_debug_type($value),
]);
}
#[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('Invalid value for key.', 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 === $found) {
return api_error(r("Option '{key}' is not supported.", ['key' => $option]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$value = $data->get('value');
$list->set($name . '.options.' . $option, $value)->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => get_debug_type($value),
]);
}
#[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);
}
$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}'.", [
'option' => $option,
'name' => $name
]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$data = DataUtil::fromRequest($request);
if (null === ($value = $data->get('value'))) {
return api_error(r("No value was provided for '{key}'.", [
'key' => $key,
]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$list->set($key, $value)->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => get_debug_type($value),
]);
}
#[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);
}
$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;
$value = $list->get($key);
$list->delete($key)->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $option,
'value' => $value,
'type' => get_debug_type($value),
]);
}
}

View File

@@ -1,71 +0,0 @@
<?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

@@ -17,8 +17,8 @@ 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
#[Get(Index::URL . '/{name:backend}/search[/[{id}[/]]]', name: 'backend.search')]
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);
@@ -55,14 +55,8 @@ final class Search
]), 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),
],

View File

@@ -18,11 +18,11 @@ final class Sessions
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/sessions[/]', name: 'backends.backend.sessions')]
public function backendsView(iRequest $request, array $args = []): iResponse
#[Get(Index::URL . '/{name:backend}/sessions[/]', name: 'backend.sessions')]
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);
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
@@ -40,20 +40,9 @@ final class Sessions
try {
$sessions = $client->getSessions($opts);
return api_response(HTTP_STATUS::HTTP_OK, ag($sessions, 'sessions', []));
} 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);
}
}

View File

@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\API\Backend\Library;
namespace App\API\Backend;
use App\API\Backend\Index as BackendsIndex;
use App\API\Backend\Index as backendIndex;
use App\Libs\Attributes\Route\Get;
use App\Libs\DataUtil;
use App\Libs\Exceptions\RuntimeException;
@@ -18,11 +18,11 @@ final class Unmatched
{
use APITraits;
#[Get(BackendsIndex::URL . '/{name:backend}/unmatched[/[{id}[/]]]', name: 'backends.library.unmatched')]
public function listLibraries(iRequest $request, array $args = []): iResponse
#[Get(backendIndex::URL . '/{name:backend}/unmatched[/[{id}[/]]]', name: 'backend.unmatched')]
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);
return api_error('Invalid value for name path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$params = DataUtil::fromArray($request->getQueryParams());
@@ -63,14 +63,6 @@ final class Unmatched
}
}
$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);
return api_response(HTTP_STATUS::HTTP_OK, $list);
}
}

144
src/API/Backend/Update.php Normal file
View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\API\Backend;
use App\Backends\Common\ClientInterface as iClient;
use App\Libs\Attributes\Route\Patch;
use App\Libs\Attributes\Route\Put;
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 JsonException;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
final class Update
{
use APITraits;
private ConfigFile $backendFile;
public function __construct()
{
$this->backendFile = ConfigFile::open(Config::get('backends_file'), 'yaml', autoCreate: true);
}
#[Put(Index::URL . '/{name:backend}[/]', name: 'backend.update')]
public function update(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 (false === $this->backendFile->has($name)) {
return api_error(r("Backend '{name}' not found.", ['name' => $name]), HTTP_STATUS::HTTP_NOT_FOUND);
}
$this->backendFile->set(
$name,
$this->fromRequest($this->backendFile->get($name), $request, $this->getClient($name))
)->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);
}
#[Patch(Index::URL . '/{name:backend}[/]', name: 'backend.patch')]
public function patchUpdate(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 (false === $this->backendFile->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);
}
$spec = require __DIR__ . '/../../../config/backend.spec.php';
foreach ($data as $update) {
$key = ag($update, 'key');
$value = ag($update, 'value');
if (null === $key) {
return api_error('No key to update was present.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === in_array($key, $spec, true)) {
return api_error(r('Invalid key to update: {key}', ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$this->backendFile->set("{$name}.{$key}", $value);
}
$this->backendFile->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);
}
private function fromRequest(array $config, iRequest $request, iClient $client): array
{
$data = DataUtil::fromArray($request->getParsedBody());
$newData = [
'url' => $data->get('url'),
'token' => $data->get('token'),
'user' => $data->get('user'),
'uuid' => $data->get('uuid'),
'export' => [
'enabled' => (bool)$data->get('export.enabled', false),
],
'import' => [
'enabled' => (bool)$data->get('import.enabled', false),
],
'webhook' => [
'match' => [
'user' => (bool)$data->get('webhook.match.user'),
'uuid' => (bool)$data->get('webhook.match.uuid'),
],
],
];
$optionals = [
Options::DUMP_PAYLOAD => 'bool',
Options::LIBRARY_SEGMENT => 'int',
Options::IGNORE => 'string',
];
foreach ($optionals as $key => $type) {
if (null !== ($value = $data->get('options.' . $key))) {
settype($value, $type);
$newData = ag_set($newData, "options.{$key}", $value);
}
}
return deepArrayMerge([$config, $client->fromRequest($newData, $request)]);
}
}

View File

@@ -18,19 +18,13 @@ final class Users
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/users[/]', name: 'backends.backend.users')]
public function backendsView(iRequest $request, array $args = []): iResponse
#[Get(Index::URL . '/{name:backend}/users[/]', name: 'backend.users')]
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 {
$client = $this->getClient(name: $name);
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
}
$opts = [];
$params = DataUtil::fromRequest($request, true);
@@ -43,21 +37,11 @@ final class Users
}
try {
$users = $client->getUsersList($opts);
return api_response(HTTP_STATUS::HTTP_OK, $this->getClient(name: $name)->getUsersList($opts));
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
} 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

@@ -5,10 +5,8 @@ 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;
@@ -18,42 +16,19 @@ final class Version
{
use APITraits;
#[Get(Index::URL . '/{name:backend}/version[/]', name: 'backends.backend.info')]
public function backendsView(iRequest $request, array $args = []): iResponse
#[Get(Index::URL . '/{name:backend}/version[/]', name: 'backend.version')]
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 {
$client = $this->getClient(name: $name);
return api_response(HTTP_STATUS::HTTP_OK, ['version' => $this->getClient(name: $name)->getVersion()]);
} 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

@@ -27,20 +27,20 @@ final class Webhooks
{
use APITraits;
private iLogger $accessLog;
private iLogger $logfile;
public function __construct(private iCache $cache)
{
$this->accessLog = new Logger(name: 'http', processors: [new LogMessageProcessor()]);
$this->logfile = new Logger(name: 'webhook', 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));
$this->logfile = $this->logfile->pushHandler(new StreamHandler($logfile, $level, true));
}
if (true === inContainer()) {
$this->accessLog->pushHandler(new StreamHandler('php://stderr', $level, true));
$this->logfile->pushHandler(new StreamHandler('php://stderr', $level, true));
}
}
@@ -53,13 +53,27 @@ final class Webhooks
* @return iResponse The response object.
* @throws InvalidArgumentException if cache key is invalid.
*/
#[Route(['POST', 'PUT'], Index::URL . '/{name:backend}/webhook[/]', name: 'webhooks.receive')]
#[Route(['POST', 'PUT'], Index::URL . '/{name:backend}/webhook[/]', name: 'backend.webhook')]
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);
}
return $this->process($name, $request)->withHeader('X-Log-Response', '0');
}
/**
* Process the incoming webhook request.
*
* @param string $name The backend name.
* @param iRequest $request The incoming request object.
*
* @return iResponse The response object.
* @throws InvalidArgumentException if cache key is invalid.
*/
private function process(string $name, iRequest $request): iResponse
{
try {
$backend = $this->getBackends(name: $name);
if (empty($backend)) {
@@ -84,7 +98,7 @@ final class Webhooks
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');
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === hash_equals((string)$userId, (string)$requestUser)) {
@@ -93,7 +107,7 @@ final class Webhooks
'config_user' => $userId,
]);
$this->write($request, Level::Info, $message);
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST);
}
}
@@ -101,7 +115,7 @@ final class Webhooks
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');
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === hash_equals((string)$uuid, (string)$requestBackendId)) {
@@ -110,7 +124,7 @@ final class Webhooks
'config_uid' => $uuid,
]);
$this->write($request, Level::Info, $message);
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST);
}
}
@@ -128,7 +142,7 @@ final class Webhooks
'backend' => $client->getName(),
]), forceContext: true);
return $response->withHeader('X-Log-Response', '0');
return $response;
}
$entity = $client->parseWebhook($request);
@@ -151,7 +165,7 @@ final class Webhooks
]
);
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED)->withHeader('X-Log-Response', '0');
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED);
}
if ((0 === (int)$entity->episode || null === $entity->season) && $entity->isEpisode()) {
@@ -170,7 +184,7 @@ final class Webhooks
]
);
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED)->withHeader('X-Log-Response', '0');
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED);
}
$items = $this->cache->get('requests', []);
@@ -211,7 +225,7 @@ final class Webhooks
]
);
return api_response(HTTP_STATUS::HTTP_OK)->withHeader('X-Log-Response', '0');
return api_response(HTTP_STATUS::HTTP_OK);
}
/**
@@ -257,9 +271,9 @@ final class Webhooks
}
if (true === (Config::get('logs.context') || $forceContext)) {
$this->accessLog->log($level, $message, $context);
$this->logfile->log($level, $message, $context);
} else {
$this->accessLog->log($level, r($message, $context));
$this->logfile->log($level, r($message, $context));
}
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\API\Backends;
use App\Backends\Common\Cache as BackendCache;
use App\Backends\Common\ClientInterface;
use App\Backends\Common\ClientInterface as iClient;
use App\Backends\Common\Context;
use App\Libs\Attributes\Route\Post;
use App\Libs\Config;
@@ -80,7 +80,7 @@ final class Add
}
$instance = Container::getNew($class);
assert($instance instanceof ClientInterface, new RuntimeException('Invalid client class.'));
assert($instance instanceof iClient, new RuntimeException('Invalid client class.'));
try {
$config = DataUtil::fromArray($this->fromRequest($type, $request, $instance));
@@ -118,15 +118,10 @@ final class Add
$data = $this->getBackends(name: $name);
$data = array_pop($data);
return api_response(HTTP_STATUS::HTTP_CREATED, [
...$data,
'links' => [
'self' => parseConfigValue(Index::URL) . '/' . $name,
],
]);
return api_response(HTTP_STATUS::HTTP_CREATED, $data);
}
private function fromRequest(string $type, iRequest $request, ClientInterface|null $client = null): array
private function fromRequest(string $type, iRequest $request, iClient $client): array
{
$data = DataUtil::fromArray($request->getParsedBody());
@@ -162,16 +157,11 @@ final class Add
foreach ($optionals as $key => $type) {
if (null !== ($value = $data->get('options.' . $key))) {
$val = $data->get($value, $type);
settype($val, $type);
$config = ag_set($config, "options.{$key}", $val);
settype($value, $type);
$config = ag_set($config, "options.{$key}", $value);
}
}
if (null !== $client) {
$config = $client->fromRequest($config, $request);
}
return $config;
return $client->fromRequest($config, $request);
}
}

View File

@@ -23,36 +23,19 @@ final class Index
'options.' . Options::ADMIN_TOKEN
];
#[Get(self::URL . '[/]', name: 'backends.index')]
#[Get(self::URL . '[/]', name: 'backends')]
public function __invoke(iRequest $request): iResponse
{
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$urlPath = $request->getUri()->getPath();
$response = [
'backends' => [],
'links' => [
'self' => (string)$apiUrl,
],
];
$list = [];
foreach ($this->getBackends() as $backend) {
$backend = array_filter(
$list[] = array_filter(
$backend,
fn($key) => false === in_array($key, ['options', 'webhook'], true),
ARRAY_FILTER_USE_KEY
);
$backend['links'] = [
'self' => (string)$apiUrl->withPath(
parseConfigValue(\App\API\Backend\Index::URL) . '/' . $backend['name']
),
];
$response['backends'][] = $backend;
}
return api_response(HTTP_STATUS::HTTP_OK, $response);
return api_response(HTTP_STATUS::HTTP_OK, $list);
}
}

38
src/API/Backends/UUid.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\API\Backends;
use App\Libs\Attributes\Route\Route;
use App\Libs\DataUtil;
use App\Libs\Exceptions\InvalidArgumentException;
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 UUid
{
use APITraits;
#[Route(['GET', 'POST'], Index::URL . '/uuid/{type}[/]', name: 'backends.get.unique_id')]
public function __invoke(iRequest $request, array $args = []): iResponse
{
if (null === ($type = ag($args, 'type'))) {
return api_error('Invalid value for type path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getBasicClient($type, DataUtil::fromRequest($request, true));
return api_response(HTTP_STATUS::HTTP_OK, [
'identifier' => $client->getIdentifier(true)
]);
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
} catch (\Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\API\Backends;
use App\Libs\Attributes\Route\Route;
use App\Libs\DataUtil;
use App\Libs\Exceptions\InvalidArgumentException;
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 Users
{
use APITraits;
#[Route(['GET', 'POST'], Index::URL . '/users/{type}[/]', name: 'backends.get.users')]
public function __invoke(iRequest $request, array $args = []): iResponse
{
if (null === ($type = ag($args, 'type'))) {
return api_error('Invalid value for type path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getBasicClient($type, DataUtil::fromRequest($request, true));
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$users = [];
foreach ($client->getUsersList() as $user) {
$users[] = [
'id' => $user['id'],
'name' => $user['name']
];
}
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
return api_response(HTTP_STATUS::HTTP_OK, $users);
}
}

View File

@@ -27,7 +27,7 @@ final class Index
$this->pdo = $this->db->getPDO();
}
#[Get(self::URL . '[/]', name: 'history.index')]
#[Get(self::URL . '[/]', name: 'history')]
public function historyIndex(iRequest $request): iResponse
{
$es = fn(string $val) => $this->db->identifier($val);
@@ -334,22 +334,12 @@ final class Index
return api_error('Not found', HTTP_STATUS::HTTP_NOT_FOUND);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$item = $item->getAll();
$item[iState::COLUMN_WATCHED] = $entity->isWatched();
$item[iState::COLUMN_UPDATED] = makeDate($entity->updated);
$item = [
...$item,
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, ['history' => $item]);
return api_response(HTTP_STATUS::HTTP_OK, $item);
}
}

View File

@@ -24,7 +24,7 @@ final class Index
private const int DEFAULT_LIMIT = 1000;
private int $counter = 1;
#[Get(self::URL . '[/]', name: 'logs.list')]
#[Get(self::URL . '[/]', name: 'logs')]
public function logsList(iRequest $request): iResponse
{
$path = fixPath(Config::get('tmpDir') . '/logs');
@@ -38,7 +38,6 @@ final class Index
foreach (glob($path . '/*.*.log') as $file) {
preg_match('/(\w+)\.(\w+)\.log/i', basename($file), $matches);
$url = $apiUrl->withPath(parseConfigValue(self::URL . "/" . basename($file)));
$builder = [
'filename' => basename($file),
@@ -46,23 +45,19 @@ final class Index
'date' => $matches[2] ?? '??',
'size' => filesize($file),
'modified' => makeDate(filemtime($file)),
'urls' => [
'self' => (string)$url,
'stream' => (string)$url->withQuery($query),
],
];
$list[] = $builder;
}
return api_response(HTTP_STATUS::HTTP_OK, ['logs' => $list]);
return api_response(HTTP_STATUS::HTTP_OK, $list);
}
#[Get(Index::URL . '/{filename}[/]', name: 'logs.view')]
public function logView(iRequest $request, array $args = []): iResponse
{
if (null === ($filename = ag($args, 'filename'))) {
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
return api_error('Invalid value for filename path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$path = realpath(fixPath(Config::get('tmpDir') . '/logs'));

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\API\Plex;
use App\Backends\Plex\PlexClient;
use App\Libs\Attributes\Route\Post;
use App\Libs\DataUtil;
use App\Libs\HTTP_STATUS;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
final class Discover
{
public const string URL = '%{api.prefix}/plex/discover';
public function __construct(private iHttp $http)
{
}
#[Post(self::URL . '[/]', name: 'plex.discover')]
public function plexDiscover(iRequest $request): iResponse
{
$data = DataUtil::fromArray($request->getParsedBody());
if (null === ($token = $data->get('token'))) {
return api_error('No token was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$list = PlexClient::discover($this->http, $token);
} catch (\Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
return api_response(HTTP_STATUS::HTTP_OK, ag($list, 'list', []));
}
}

View File

@@ -22,11 +22,11 @@ final class Env
'WS_CACHE_URL'
];
private EnvFile $envfile;
private EnvFile $envFile;
public function __construct()
{
$this->envfile = (new EnvFile(file: Config::get('path') . '/config/.env', create: true));
$this->envFile = (new EnvFile(file: Config::get('path') . '/config/.env', create: true));
}
#[Get(self::URL . '[/]', name: 'system.env')]
@@ -35,12 +35,9 @@ final class Env
$response = [
'data' => [],
'file' => Config::get('path') . '/config/.env',
'links' => [
'self' => (string)$request->getUri()->withHost('')->withPort(0)->withScheme(''),
],
];
foreach ($this->envfile->getAll() as $key => $val) {
foreach ($this->envFile->getAll() as $key => $val) {
if (false === str_starts_with($key, 'WS_')) {
continue;
}
@@ -49,9 +46,6 @@ final class Env
'key' => $key,
'value' => $val,
'mask' => in_array($key, self::MASK),
'urls' => [
'self' => (string)$request->getUri()->withPath(parseConfigValue(self::URL . '/' . $key)),
],
];
}
@@ -70,13 +64,13 @@ final class Env
return api_error(r("Invalid key '{key}' was given.", ['key' => $key]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === $this->envfile->has($key)) {
if (false === $this->envFile->has($key)) {
return api_error(r("Key '{key}' is not set.", ['key' => $key]), HTTP_STATUS::HTTP_NOT_FOUND);
}
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $key,
'value' => $this->envfile->get($key),
'value' => $this->envFile->get($key),
]);
}
@@ -94,7 +88,7 @@ final class Env
}
if ('DELETE' === $request->getMethod()) {
$this->envfile->remove($key);
$this->envFile->remove($key);
} else {
$params = DataUtil::fromRequest($request);
if (null === ($value = $params->get('value', null))) {
@@ -103,14 +97,14 @@ final class Env
]), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$this->envfile->set($key, $value);
$this->envFile->set($key, $value);
}
$this->envfile->persist();
$this->envFile->persist();
return api_response(HTTP_STATUS::HTTP_OK, [
'key' => $key,
'value' => env($key, fn() => $this->envfile->get($key)),
'value' => $this->envFile->get($key, fn() => env($key)),
]);
}
}

View File

@@ -17,8 +17,6 @@ final class Supported
#[Get(self::URL . '[/]', name: 'system.supported')]
public function __invoke(iRequest $request): iResponse
{
return api_response(HTTP_STATUS::HTTP_OK, [
'supported' => array_keys(Config::get('supported')),
]);
return api_response(HTTP_STATUS::HTTP_OK, ['supported' => array_keys(Config::get('supported'))]);
}
}

View File

@@ -8,6 +8,7 @@ use App\Commands\System\TasksCommand;
use App\Libs\Attributes\Route\Get;
use App\Libs\Attributes\Route\Route;
use App\Libs\HTTP_STATUS;
use Cron\CronExpression;
use DateInterval;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
@@ -28,29 +29,15 @@ final class Index
#[Get(self::URL . '[/]', name: 'tasks.index')]
public function tasksIndex(iRequest $request): iResponse
{
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$urlPath = rtrim($request->getUri()->getPath(), '/');
$queuedTasks = $this->cache->get('queued_tasks', []);
$response = [
'tasks' => [],
'queued' => $queuedTasks,
'links' => [
'self' => (string)$apiUrl,
],
];
foreach (TasksCommand::getTasks() as $task) {
$task = self::formatTask($task);
$task['links'] = [
'self' => (string)$apiUrl->withPath($urlPath . '/' . ag($task, 'name')),
'queue' => (string)$apiUrl->withPath($urlPath . '/' . ag($task, 'name') . '/queue'),
];
$task['queued'] = in_array(ag($task, 'name'), $queuedTasks);
$response['tasks'][] = $task;
}
@@ -87,17 +74,9 @@ final class Index
return api_response(HTTP_STATUS::HTTP_OK, ['queue' => $queuedTasks]);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('')->withUserInfo('');
$urlPath = parseConfigValue(Index::URL);
return api_response(HTTP_STATUS::HTTP_OK, [
'task' => $id,
'is_queued' => in_array($id, $queuedTasks),
'links' => [
'self' => (string)$apiUrl,
'task' => (string)$apiUrl->withPath($urlPath . '/' . $id),
'tasks' => (string)$apiUrl->withPath($urlPath),
],
]);
}
@@ -108,34 +87,27 @@ final class Index
return api_error('No id was given.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$apiUrl = $request->getUri()->withHost('')->withPort(0)->withScheme('');
$task = TasksCommand::getTasks($id);
if (empty($task)) {
return api_error('Task not found.', HTTP_STATUS::HTTP_NOT_FOUND);
}
$response = [
...Index::formatTask($task),
'links' => [
'self' => (string)$apiUrl,
'list' => (string)$apiUrl->withPath(parseConfigValue(Index::URL)),
],
];
return api_response(HTTP_STATUS::HTTP_OK, ['task' => $response]);
return api_response(HTTP_STATUS::HTTP_OK, Index::formatTask($task));
}
private function formatTask(array $task): array
{
$isEnabled = (bool)ag($task, 'enabled', false);
$timer = ag($task, 'timer');
assert($timer instanceof CronExpression);
$item = [
'name' => ag($task, 'name'),
'description' => ag($task, 'description'),
'enabled' => true === $isEnabled,
'timer' => ag($task, 'timer')->getexpression(),
'timer' => $timer->getExpression(),
'next_run' => null,
'prev_run' => null,
'command' => ag($task, 'command'),
@@ -147,8 +119,13 @@ final class Index
}
if (true === $isEnabled) {
$item['next_run'] = makeDate(ag($task, 'timer')->getNextRunDate());
$item['prev_run'] = makeDate(ag($task, 'timer')->getPreviousRunDate());
try {
$item['next_run'] = makeDate($timer->getNextRunDate());
$item['prev_run'] = makeDate($timer->getPreviousRunDate());
} catch (\Exception) {
$item['next_run'] = null;
$item['prev_run'] = null;
}
}
return $item;

View File

@@ -41,6 +41,13 @@ interface ClientInterface
*/
public function getName(): string;
/**
* Get The Backend type.
*
* @return string Backend type.
*/
public function getType(): string;
/**
* Inject logger.
*

View File

@@ -163,6 +163,11 @@ class EmbyClient implements iClient
return $this->context?->backendName ?? static::CLIENT_NAME;
}
public function getType(): string
{
return static::CLIENT_NAME;
}
/**
* @inheritdoc
*/

View File

@@ -180,6 +180,11 @@ class JellyfinClient implements iClient
return $this->context?->backendName ?? static::CLIENT_NAME;
}
public function getType(): string
{
return static::CLIENT_NAME;
}
/**
* @inheritdoc
*/

View File

@@ -11,6 +11,7 @@ use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Libs\Container;
use App\Libs\Options;
use DateInterval;
use JsonException;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
@@ -40,7 +41,7 @@ final class GetUsersList
{
return $this->tryResponse(
context: $context,
fn: fn() => $this->getUsers($context, $opts),
fn: fn() => $this->action($context, $opts),
action: $this->action
);
}
@@ -51,7 +52,26 @@ final class GetUsersList
* @throws ExceptionInterface
* @throws JsonException
*/
private function getUsers(Context $context, array $opts = []): Response
private function action(Context $context, array $opts = []): Response
{
$cls = fn() => $this->real_request($context, $opts);
return true === (bool)ag($opts, Options::NO_CACHE) ? $cls() : $this->tryCache(
$context,
'users_' . md5(json_encode($opts)),
$cls,
new DateInterval('PT5M'),
$this->logger
);
}
/**
* Get Users list.
*
* @throws ExceptionInterface
* @throws JsonException
*/
private function real_request(Context $context, array $opts = []): Response
{
$url = Container::getNew(UriInterface::class)->withPort(443)->withScheme('https')->withHost('plex.tv')
->withPath('/api/v2/home/users/');
@@ -77,6 +97,7 @@ final class GetUsersList
context: [
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
'body' => $response->getContent(),
],
level: Levels::ERROR
),
@@ -137,4 +158,5 @@ final class GetUsersList
return new Response(status: true, response: $list);
}
}

View File

@@ -180,6 +180,11 @@ class PlexClient implements iClient
return $this->context?->backendName ?? static::CLIENT_NAME;
}
public function getType(): string
{
return static::CLIENT_NAME;
}
/**
* @inheritdoc
*/
@@ -558,12 +563,16 @@ class PlexClient implements iClient
{
$params = DataUtil::fromArray($request->getParsedBody());
if (null !== ($uuid = $params->get('options.' . Options::PLEX_USER_UUID))) {
$config = ag_set($config, 'options.' . Options::PLEX_USER_UUID, $uuid);
if (null !== ($val = $params->get('options.' . Options::PLEX_USER_UUID))) {
$config = ag_set($config, 'options.' . Options::PLEX_USER_UUID, $val);
}
if (null !== ($adminToken = $params->get('options.' . Options::ADMIN_TOKEN))) {
$config = ag_set($config, 'options.' . Options::ADMIN_TOKEN, $adminToken);
if (null !== ($val = $params->get('options.' . Options::ADMIN_TOKEN))) {
$config = ag_set($config, 'options.' . Options::ADMIN_TOKEN, $val);
}
if (null !== ($val = $params->get('options.' . Options::PLEX_USE_OLD_PROGRESS_ENDPOINT))) {
$config = ag_set($config, 'options.' . Options::PLEX_USE_OLD_PROGRESS_ENDPOINT, (bool)$val);
}
if (null !== ($userId = ag($config, 'user')) && !is_int($userId)) {

View File

@@ -25,21 +25,22 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
#[Cli(command: self::ROUTE)]
final class LogsCommand extends Command
{
public const ROUTE = 'system:logs';
public const string ROUTE = 'system:logs';
/**
* @var array Constant array containing names of supported log files.
*/
private const LOG_FILES = [
private const array LOG_FILES = [
'app',
'access',
'task'
'task',
'webhook',
];
/**
* @var int The default limit of how many lines to show.
*/
public const DEFAULT_LIMIT = 50;
public const int DEFAULT_LIMIT = 50;
/**
* Configure the command.

View File

@@ -22,9 +22,9 @@ use Symfony\Component\Console\Output\OutputInterface;
#[Cli(command: self::ROUTE)]
final class PruneCommand extends Command
{
public const ROUTE = 'system:prune';
public const string ROUTE = 'system:prune';
public const TASK_NAME = 'prune';
public const string TASK_NAME = 'prune';
/**
* Class Constructor.

View File

@@ -477,13 +477,12 @@ final class Initializer
{
$inContainer = inContainer();
if (null !== ($logfile = Config::get('webhook.logfile'))) {
$level = Config::get('webhook.debug') ? Level::Debug : Level::Info;
if (null !== ($logfile = Config::get('api.logfile'))) {
$this->accessLog = $logger->withName(name: 'http')
->pushHandler(new StreamHandler($logfile, $level, true));
->pushHandler(new StreamHandler($logfile, Level::Info, true));
if (true === $inContainer) {
$this->accessLog->pushHandler(new StreamHandler('php://stderr', $level, true));
$this->accessLog->pushHandler(new StreamHandler('php://stderr', Level::Info, true));
}
}

View File

@@ -19,6 +19,7 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface
private const array OPEN_ROUTES = [
HealthCheck::URL,
'/webhook'
];
/**
@@ -30,9 +31,11 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface
return $handler->handle($request);
}
$requestPath = rtrim($request->getUri()->getPath(), '/');
foreach (self::OPEN_ROUTES as $route) {
$route = parseConfigValue($route);
if (true === str_starts_with($request->getUri()->getPath(), parseConfigValue($route))) {
$route = rtrim(parseConfigValue($route), '/');
if (true === str_starts_with($requestPath, $route) || true === str_ends_with($requestPath, $route)) {
return $handler->handle($request);
}
}

View File

@@ -4,10 +4,17 @@ declare(strict_types=1);
namespace App\Libs\Traits;
use App\Backends\Common\Cache as BackendCache;
use App\Backends\Common\ClientInterface;
use App\Backends\Common\ClientInterface as iClient;
use App\Backends\Common\Context;
use App\Libs\Config;
use App\Libs\ConfigFile;
use App\Libs\Container;
use App\Libs\DataUtil;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Uri;
trait APITraits
{
@@ -66,4 +73,42 @@ trait APITraits
return $backends;
}
/**
* Create basic client to inquiry about the backend.
*
* @param string $type Backend type.
* @param DataUtil $data The request data.
*
* @return iClient The client instance.
* @throws InvalidArgumentException If url, token is missing or type is incorrect.
*/
protected function getBasicClient(string $type, DataUtil $data): iClient
{
if (null === ($class = Config::get("supported.{$type}", null))) {
throw new InvalidArgumentException(r("Unexpected client type '{type}' was given.", ['type' => $type]));
}
if (null === $data->get('url')) {
throw new InvalidArgumentException('No URL was given.');
}
if (null === $data->get('token')) {
throw new InvalidArgumentException('No token was given.');
}
$instance = Container::getNew($class);
assert($instance instanceof ClientInterface, new InvalidArgumentException('Invalid client class.'));
return $instance->withContext(
new Context(
clientName: $type,
backendName: 'basic_' . $type,
backendUrl: new Uri($data->get('url')),
cache: Container::get(BackendCache::class),
backendId: $data->get('uuid'),
backendToken: $data->get('token'),
backendUser: $data->get('user'),
)
);
}
}

View File

@@ -401,7 +401,7 @@ if (!function_exists('api_response')) {
return (new Response(
status: $status->value,
headers: $headers,
body: $body ? json_encode(
body: null !== $body ? json_encode(
$body,
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES
) : null,
@@ -1253,3 +1253,27 @@ if (!function_exists('addCors')) {
return $response;
}
}
if (!function_exists('deepArrayMerge')) {
function deepArrayMerge(array $arrays, $preserve_integer_keys = false)
{
$result = [];
foreach ($arrays as $array) {
foreach ($array as $key => $value) {
// Renumber integer keys as array_merge_recursive() does unless
// $preserve_integer_keys is set to TRUE. Note that PHP automatically
// converts array keys that are integer strings (e.g., '1') to integers.
if (is_int($key) && !$preserve_integer_keys) {
$result[] = $value;
} // Recurse when both values are arrays.
elseif (isset($result[$key]) && is_array($result[$key]) && is_array($value)) {
$result[$key] = deepArrayMerge([$result[$key], $value], $preserve_integer_keys);
} // Otherwise, use the latter value, overriding any previous value.
else {
$result[$key] = $value;
}
}
}
return $result;
}
}