Experimental support for limited access tokens for emby & jellyfin.

This commit is contained in:
Abdulmhsen B. A. A.
2024-07-05 22:12:13 +03:00
parent c912aa39a2
commit e7e5acfe3a
19 changed files with 672 additions and 61 deletions

View File

@@ -167,5 +167,11 @@ return [
'visible' => false,
'description' => 'Whether to use the old progress endpoint for plex progress sync.',
],
[
'key' => 'options.is_limited_token',
'type' => 'bool',
'visible' => false,
'description' => 'Whether the token has limited access.',
],
];

View File

@@ -97,7 +97,11 @@
v-text="'Visit This article for more information.'"/>
</template>
<template v-else>
Generate a new API Key from <code>Dashboard > Settings > API Keys</code>.
Generate a new API Key from <code>Dashboard > Settings > API Keys</code>.<br>
<span class="icon has-text-warning"><i class="fas fa-info-circle"></i></span>
You can use <code>username:password</code> as API key and we will automatically generate limited
token if you are unable to generate API Key. This should be used as last resort. and it's mostly
untested. and things might not work as expected.
</template>
</p>
</div>
@@ -121,7 +125,7 @@
<label class="label">Plex Server URL</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select v-model="backend.url" class="is-capital" @change="stage=1; backend.uuid = ''" required
<select v-model="backend.url" class="is-capital" @change="stage=1; updateIdentifier()" required
:disabled="stage > 1">
<option value="" disabled>Select Server URL</option>
<option v-for="server in servers" :key="'server-'+server.uuid" :value="server.uri">
@@ -149,13 +153,13 @@
</div>
</template>
<div class="field" v-if="stage >= 2">
<div class="field" v-if="stage >= 3">
<label class="label">
Associated User
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select v-model="backend.user" class="is-capitalized" :disabled="stage > 2">
<select v-model="backend.user" class="is-capitalized" :disabled="stage > 3">
<option value="" disabled>Select User</option>
<option v-for="user in users" :key="'uid-'+user.id" :value="user.id">
{{ user.name }}
@@ -168,12 +172,12 @@
</div>
<p class="help">
Which user we should associate this backend with?
<NuxtLink @click="getUsers" v-text="'Retrieve User ids from backend.'" v-if="stage < 3"/>
<NuxtLink @click="getUsers" v-text="'Retrieve User ids from backend.'" v-if="stage < 4"/>
</p>
</div>
</div>
<template v-if="stage >= 3">
<template v-if="stage >= 4">
<div class="field" v-if="backend.import">
<label class="label" for="backend_import">Import data from this backend</label>
<div class="control">
@@ -237,7 +241,7 @@
</div>
<div class="card-footer">
<div class="card-footer-item" v-if="stage < 4">
<div class="card-footer-item" v-if="stage < 5">
<button class="button is-fullwidth is-primary" type="submit" @click="changeStep()">
<span class="icon">
<i class="fas fa-arrow-right"></i>
@@ -259,7 +263,7 @@
<script setup>
import 'assets/css/bulma-switch.css'
import request from '~/utils/request'
import {awaitElement, notification} from '~/utils/index'
import {awaitElement, explode, notification} from '~/utils/index'
const emit = defineEmits(['addBackend'])
@@ -302,9 +306,16 @@ const serversLoading = ref(false)
const exposeToken = ref(false)
const error = ref()
const isLimited = ref(false)
const accessTokenResponse = ref({})
const getUUid = async () => {
const required_values = ['type', 'token', 'url'];
if (true === isLimited.value || Object.keys(accessTokenResponse.value) > 0) {
return
}
if (required_values.some(v => !backend.value[v])) {
notification('error', 'Error', `Please fill all the required fields. ${required_values.join(', ')}.`)
return
@@ -313,14 +324,19 @@ const getUUid = async () => {
try {
error.value = null
uuidLoading.value = true
let data = {
name: backend.value?.name,
token: backend.value.token,
url: backend.value.url
}
if (backend.value.user) {
data.user = backend.value.user
}
const response = await request(`/backends/uuid/${backend.value.type}`, {
method: 'POST',
body: JSON.stringify({
name: backend.value?.name,
token: backend.value.token,
url: backend.value.url
})
body: JSON.stringify(data)
})
const json = await response.json()
@@ -331,6 +347,8 @@ const getUUid = async () => {
}
backend.value.uuid = json.identifier
return backend.value.uuid
} catch (e) {
n_proxy('error', 'Error', `Request error. ${e.message}`, e)
} finally {
@@ -338,6 +356,61 @@ const getUUid = async () => {
}
}
const getAccessToken = async () => {
const required_values = ['type', 'token', 'url'];
if (required_values.some(v => !backend.value[v])) {
notification('error', 'Error', `Please fill all the required fields. ${required_values.join(', ')}.`)
return
}
if (Object.keys(accessTokenResponse.value) > 0) {
return
}
const [username, password] = explode(':', backend.value.token, 2)
if (!username || !password) {
return
}
try {
error.value = null
const response = await request(`/backends/accesstoken/${backend.value.type}`, {
method: 'POST',
body: JSON.stringify({
name: backend.value?.name,
url: backend.value.url,
username: username,
password: password,
})
})
const json = await response.json()
if (200 !== response.status) {
n_proxy('error', 'Error', `${json.error.code}: ${json.error.message}`)
return
}
accessTokenResponse.value = json
backend.value.token = json?.accesstoken
backend.value.user = json?.user
backend.value.uuid = json?.identifier
users.value = [{
id: json?.user,
name: username
}]
isLimited.value = true
return true
} catch (e) {
n_proxy('error', 'Error', `Request error. ${e.message}`, e)
return false
}
}
const getUsers = async (showAlert = true) => {
const required_values = ['type', 'token', 'url', 'uuid']
@@ -378,6 +451,8 @@ const getUsers = async (showAlert = true) => {
}
users.value = json
return users.value
} catch (e) {
n_proxy('error', 'Error', `Request error. ${e.message}`, e)
} finally {
@@ -391,12 +466,14 @@ onMounted(async () => {
backend.value.type = supported.value[0]
})
watch(stage, v => {
console.log(v);
}, {immediate: true})
const changeStep = async () => {
console.log('was called');
stage.value = 0
if (stage.value >= 0) {
let _
if (stage.value <= 0) {
// -- basic validation.
const required = ['name', 'type', 'token']
if (required.some(v => !backend.value[v])) {
@@ -414,13 +491,11 @@ const changeStep = async () => {
}
stage.value = 1
console.log('stage 1')
}
if (stage.value >= 1) {
if (stage.value <= 1) {
if ('plex' === backend.value.type && servers.value.length < 1) {
await getServers()
_ = await getServers()
if (servers.value.length < 1) {
stage.value = 0
return
@@ -431,21 +506,36 @@ const changeStep = async () => {
return
}
if (false === isLimited.value && backend.value.token.includes(':')) {
_ = await getAccessToken()
if (!accessTokenResponse.value) {
stage.value = 0
return
}
}
if (backend.value.token.includes(':')) {
return
}
stage.value = 2
}
if (stage.value <= 2) {
if (!backend.value.uuid) {
await getUUid();
_ = await getUUid();
if (!backend.value.uuid) {
stage.value = 1
return
}
}
stage.value = 2
console.log('stage 2')
stage.value = 3
}
if (stage.value >= 2) {
if (users.value.length < 1) {
await getUsers()
if (stage.value <= 3) {
if (false === isLimited.value && users.value.length < 1) {
_ = await getUsers()
if (users.value.length < 1) {
stage.value = 1
return
@@ -456,13 +546,11 @@ const changeStep = async () => {
return
}
stage.value = 3
console.log('stage 3')
stage.value = 4
}
if (stage.value >= 3) {
stage.value = 4
console.log('stage 4')
if (stage.value <= 4) {
stage.value = 5
}
}
@@ -486,6 +574,10 @@ const addBackend = async () => {
}
}
if (isLimited.value) {
backend.value.options.is_limited_token = true
}
const response = await request(`/backends/`, {
method: 'POST',
headers: {
@@ -538,6 +630,8 @@ const getServers = async () => {
}
servers.value = json
return servers.value
} catch (e) {
n_proxy('error', 'Error', `Request error. ${e.message}`, e)
} finally {

View File

@@ -18,6 +18,21 @@
</div>
</div>
<div class="column is-12" v-if="isLimitedToken">
<Message title="For your information" message_class="has-background-warning-90 has-text-dark"
icon="fas fa-info-circle">
<p>
This backend is using accesstoken instead of API keys, And this method untested and may not work as expected.
Please make sure you know what you are doing. Simple operations like <code>Import</code>, <code>Export</code>
should work fine.
</p>
<p>
How the access token interact with the rest of the API is undefined and untested by us. Please use with
caution. If you notice any issue, please report it to us.
</p>
</Message>
</div>
<div class="column is-12" v-if="isLoading">
<Message message_class="is-background-info-90 has-text-dark" title="Loading"
icon="fas fa-spinner fa-spin" message="Loading backend settings. Please wait..."/>
@@ -125,7 +140,7 @@
<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>
<input class="input" type="text" v-model="backend.uuid" required :disabled="isLimitedToken">
<div class="icon is-left">
<i class="fas fa-cloud" v-if="!uuidLoading"></i>
<i class="fas fa-spinner fa-pulse" v-else></i>
@@ -142,7 +157,7 @@
backend
uniquely. This is used for webhook matching and filtering.
</span>
<a href="javascript:void(0)" @click="getUUid">Get from the backend.</a>
<NuxtLink @click="getUUid" v-if="!isLimitedToken" v-text="'Get from the backend.'"/>
</p>
</div>
</div>
@@ -154,7 +169,7 @@
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth" v-if="users.length>0">
<select v-model="backend.user" class="is-capitalized">
<select v-model="backend.user" class="is-capitalized" :disabled="isLimitedToken">
<option v-for="user in users" :key="'uid-'+user.id" :value="user.id">
{{ user.name }}
</option>
@@ -176,7 +191,7 @@
data we get from the backend. And for webhook matching and filtering.
</span>
This tool is meant for single user use.
<a href="javascript:void(0)" @click="getUsers">
<a href="javascript:void(0)" @click="getUsers" v-if="!isLimitedToken">
Retrieve User ids from backend.
</a>
</p>
@@ -258,6 +273,12 @@
<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>
<p class="help is-unselectable">
<span class="icon has-text-info">
<i class="fas fa-info-circle" :class="{'fa-bounce': newOptions[key]}"></i>
</span>
{{ optionsList.find(v => v.key === key)?.description }}
</p>
</div>
<div class="column is-6">
<input type="text" class="input" v-model="backend.options[key]" required>
@@ -308,7 +329,7 @@
<span class="icon"><i class="fas fa-save"></i></span>
<span>Save Settings</span>
</button>
<NuxtLink class="card-footer-item button is-fullwidth is-danger" :to="`/backend/${backend}`">
<NuxtLink class="card-footer-item button is-fullwidth is-danger" :to="`/backend/${id}`">
<span class="icon"><i class="fas fa-cancel"></i></span>
<span>Cancel changes</span>
</NuxtLink>
@@ -323,6 +344,7 @@
import 'assets/css/bulma-switch.css'
import {notification, ucFirst} from '~/utils/index.js'
import {ref} from "vue";
import Message from "~/components/Message.vue";
const id = useRoute().params.backend
const redirect = useRoute().query?.redirect ?? `/backend/${id}`
@@ -350,6 +372,7 @@ const newOptions = ref({})
const exposeToken = ref(false)
const servers = ref([])
const serversLoading = ref(false)
const isLimitedToken = computed(() => Boolean(backend.value.options?.is_limited_token))
const selectedOptionHelp = computed(() => {
const option = optionsList.value.find(v => v.key === selectedOption.value)
@@ -485,13 +508,19 @@ const getUsers = async (showAlert = true) => {
token: backend.value.token,
url: backend.value.url,
uuid: backend.value.uuid,
user: backend.value.user
};
if (backend.value.options && backend.value.options.ADMIN_TOKEN) {
if (backend.value.options && backend.value.options?.ADMIN_TOKEN) {
data.options = {
ADMIN_TOKEN: backend.value.options.ADMIN_TOKEN
}
}
if (backend.value.options && backend.value.options?.is_limited_token) {
data.options = {
is_limited_token: Boolean(backend.value.options.is_limited_token)
}
}
const response = await request(`/backends/users/${backend.value.type}`, {
method: 'POST',

View File

@@ -423,6 +423,35 @@ const makeSecret = (len = 8) => {
return result;
}
/**
* Explode string by delimiter.
*
* @param {string} delimiter
* @param {string} string
* @param {number} limit
*
* @returns {string[]}
*/
const explode = (delimiter, string, limit = undefined) => {
if ('' === delimiter) {
return [string];
}
const parts = string.split(delimiter);
if (undefined === limit || 0 === limit) {
return parts;
}
if (limit > 0) {
return parts.slice(0, limit - 1).concat(parts.slice(limit - 1).join(delimiter));
}
if (limit < 0) {
return parts.slice(0, limit);
}
}
export {
r,
ag_set,
@@ -442,4 +471,5 @@ export {
makePagination,
TOOLTIP_DATE_FORMAT,
makeSecret,
explode,
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\API\Backends;
use App\Libs\Attributes\Route\Post;
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 AccessToken
{
use APITraits;
public const string URL = Index::URL . '/accesstoken';
#[Post(self::URL . '/{type}[/]', name: 'backends.get.accesstoken')]
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);
}
$params = DataUtil::fromRequest($request);
$username = $params->get('username');
$password = $params->get('password');
if (empty($username) || empty($password)) {
return api_error('Invalid username or password.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
if (false === in_array($type, ['jellyfin', 'emby'])) {
return api_error('Access token endpoint only supported on jellyfin, emby.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$client = $this->getBasicClient($type, $params->with('token', 'accesstoken_request'));
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
}
try {
$info = $client->generateAccessToken($username, $password);
} catch (Throwable $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_INTERNAL_SERVER_ERROR);
}
return api_response(HTTP_STATUS::HTTP_OK, $info);
}
}

View File

@@ -24,14 +24,15 @@ final class Users
return api_error('Invalid value for type path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
}
$params = DataUtil::fromRequest($request, true);
try {
$client = $this->getBasicClient($type, DataUtil::fromRequest($request, true));
$client = $this->getBasicClient($type, $params);
} catch (InvalidArgumentException $e) {
return api_error($e->getMessage(), HTTP_STATUS::HTTP_BAD_REQUEST);
}
$users = $opts = [];
$params = DataUtil::fromRequest($request, true);
if (true === (bool)$params->get('tokens', false)) {
$opts['tokens'] = true;

View File

@@ -295,4 +295,15 @@ interface ClientInterface
*/
public function getVersion(array $opts = []): string;
/**
* Generate Access Token
*
* @param string|int $identifier username.
* @param string $password password.
* @param array $opts options.
*
* @return array
*/
public function generateAccessToken(string|int $identifier, string $password, array $opts = []): array;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Backends\Emby\Action;
final class GenerateAccessToken extends \App\Backends\Jellyfin\Action\GenerateAccessToken
{
protected string $action = 'emby.generateAccessToken';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Backends\Emby\Action;
final class GetUser extends \App\Backends\Jellyfin\Action\GetUsersList
{
protected string $action = 'emby.getUser';
}

View File

@@ -11,6 +11,7 @@ use App\Backends\Common\GuidInterface as iGuid;
use App\Backends\Common\Response;
use App\Backends\Emby\Action\Backup;
use App\Backends\Emby\Action\Export;
use App\Backends\Emby\Action\GenerateAccessToken;
use App\Backends\Emby\Action\GetIdentifier;
use App\Backends\Emby\Action\GetInfo;
use App\Backends\Emby\Action\GetLibrariesList;
@@ -128,7 +129,7 @@ class EmbyClient implements iClient
'token' => $context->backendToken,
'app' => Config::get('name') . '/' . static::CLIENT_NAME,
'os' => PHP_OS,
'id' => md5(Config::get('name') . '/' . static::CLIENT_NAME . $context->backendUser),
'id' => md5(Config::get('name') . '/' . static::CLIENT_NAME),
'version' => getAppVersion(),
'user' => $context->backendUser,
]
@@ -596,6 +597,29 @@ class EmbyClient implements iClient
return Container::get(EmbyValidateContext::class)($context);
}
/**
* @inheritdoc
*/
public function generateAccessToken(string|int $identifier, string $password, array $opts = []): array
{
$response = Container::get(GenerateAccessToken::class)(
context: $this->context,
identifier: $identifier,
password: $password,
opts: $opts
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Context;
use App\Backends\Common\Error;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Libs\Config;
use App\Libs\Options;
use JsonException;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
/**
* Class Generate Access token.
*
* This class is responsible for generating the access token for Jellyfin API.
*/
class GenerateAccessToken
{
use CommonTrait;
/**
* @var string Action name.
*/
protected string $action = 'jellyfin.generateAccessToken';
/**
* Class Constructor.
*
* @param iHttp $http The HTTP client instance.
* @param iLogger $logger The logger instance.
*/
public function __construct(protected iHttp $http, protected iLogger $logger)
{
}
/**
* Generate Access Token.
*
* @param Context $context The context instance.
* @param string|int $identifier
* @param string $password
* @param array $opts Additional options.
*
* @return Response The response received.
*/
public function __invoke(Context $context, string|int $identifier, string $password, array $opts = []): Response
{
return $this->tryResponse(
context: $context,
fn: fn() => $this->generateToken($context, $identifier, $password, $opts),
action: $this->action
);
}
/**
* Generate Access Token.
*
* @param Context $context The context instance.
* @param string|int $identifier
* @param string $password
* @param array $opts Additional options.
*
* @return Response The response received.
* @throws JsonException When the response is not a valid JSON.
* @throws ExceptionInterface When the request fails.
*/
private function generateToken(
Context $context,
string|int $identifier,
string $password,
array $opts = []
): Response {
$url = $context->backendUrl->withPath('/Users/AuthenticateByName');
$this->logger->debug("Requesting '{backend}' to generate access token for '{username}'.", [
'username' => (string)$identifier,
'backend' => $context->backendName,
'url' => (string)$url,
]);
$response = $this->http->request('POST', (string)$url, [
'json' => [
'Username' => (string)$identifier,
'Pw' => $password,
],
'headers' => [
'Accept' => 'application/json',
'Authorization' => r(
'{Agent} Client="{app}", Device="{os}", DeviceId="{id}", Version="{version}"',
[
'Agent' => 'Emby' == $context->clientName ? 'Emby' : 'MediaBrowser',
'app' => Config::get('name') . '/' . $context->clientName,
'os' => PHP_OS,
'id' => md5(Config::get('name') . '/' . $context->clientName),
'version' => getAppVersion(),
]
),
],
]);
if (200 !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: "Request for '{client}: {backend}' to generate access for '{username}' token returned with unexpected '{status_code}' status code. {body}",
context: [
'client' => $context->clientName,
'backend' => $context->backendName,
'username' => (string)$identifier,
'status_code' => $response->getStatusCode(),
'body' => $response->getContent(false),
],
level: Levels::ERROR
),
);
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if ($context->trace) {
$this->logger->debug("Parsing '{backend}' access token response payload.", [
'backend' => $context->backendName,
'url' => (string)$url,
'trace' => $json,
]);
}
$info = [
'user' => ag($json, 'User.Id'),
'identifier' => ag($json, 'ServerId'),
'accesstoken' => ag($json, 'AccessToken'),
'username' => ag($json, 'User.Name'),
];
if (true === ag_exists($opts, Options::RAW_RESPONSE)) {
$info[Options::RAW_RESPONSE] = $json;
}
return new Response(status: true, response: $info);
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Backends\Jellyfin\Action;
use App\Backends\Common\CommonTrait;
use App\Backends\Common\Context;
use App\Backends\Common\Error;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Libs\Options;
use JsonException;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface as iHttp;
/**
* Class GetUsersList
*
* This class is responsible for retrieving the users list from Jellyfin API.
*/
class GetUser
{
use CommonTrait;
/**
* @var string Action name.
*/
protected string $action = 'jellyfin.getUser';
/**
* Class Constructor.
*
* @param iHttp $http The HTTP client instance.
* @param iLogger $logger The logger instance.
*/
public function __construct(protected iHttp $http, protected iLogger $logger)
{
}
/**
* Get Users list.
*
* @param Context $context The context instance.
* @param array $opts Additional options.
*
* @return Response The response received.
*/
public function __invoke(Context $context, array $opts = []): Response
{
return $this->tryResponse(
context: $context,
fn: fn() => $this->getUser($context, $opts),
action: $this->action
);
}
/**
* Fetch the users list from Jellyfin API.
*
* @throws ExceptionInterface When the request fails.
* @throws JsonException When the response is not a valid JSON.
*/
private function getUser(Context $context, array $opts = []): Response
{
if (null === $context->backendUser) {
return new Response(
status: false,
error: new Error(
message: "Request for '{client}: {backend}' user info failed. User not set.",
context: [
'client' => $context->clientName,
'backend' => $context->backendName,
],
level: Levels::ERROR
),
);
}
$url = $context->backendUrl->withPath('/Users/' . $context->backendUser);
$this->logger->debug("Requesting '{client}: {backend}' user '{user}' info.", [
'user' => $context->backendUrl,
'client' => $context->clientName,
'backend' => $context->backendName,
'url' => (string)$url,
]);
$headers = $context->backendHeaders;
if (empty($headers)) {
$headers = [
'headers' => [
'X-MediaBrowser-Token' => $context->backendToken,
],
];
}
$response = $this->http->request('GET', (string)$url, $headers);
if (200 !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: "Request for '{client}: {backend}' user '{user}' info returned with unexpected '{status_code}' status code.",
context: [
'client' => $context->clientName,
'backend' => $context->backendName,
'user' => $context->backendUser,
'status_code' => $response->getStatusCode(),
],
level: Levels::ERROR
),
);
}
$json = json_decode(
json: $response->getContent(),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
);
if ($context->trace) {
$this->logger->debug("Parsing '{client}: {backend}' user '{user}' info payload.", [
'client' => $context->clientName,
'backend' => $context->backendName,
'user' => $context->backendUser,
'url' => (string)$url,
'trace' => $json,
]);
}
$date = ag($json, ['LastActivityDate', 'LastLoginDate'], null);
$data = [
'id' => ag($json, 'Id'),
'name' => ag($json, 'Name'),
'admin' => (bool)ag($json, 'Policy.IsAdministrator'),
'hidden' => (bool)ag($json, 'Policy.IsHidden'),
'disabled' => (bool)ag($json, 'Policy.IsDisabled'),
'updatedAt' => null !== $date ? makeDate($date) : 'Never',
];
if (true === (bool)ag($opts, Options::RAW_RESPONSE)) {
$data[Options::RAW_RESPONSE] = $json;
}
return new Response(status: true, response: $data);
}
}

View File

@@ -9,6 +9,7 @@ use App\Backends\Common\Context;
use App\Backends\Common\Error;
use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Libs\Container;
use App\Libs\Options;
use JsonException;
use Psr\Log\LoggerInterface as iLogger;
@@ -64,9 +65,18 @@ class GetUsersList
*/
private function getUsers(Context $context, array $opts = []): Response
{
if (true === (bool)ag($context->options, Options::IS_LIMITED_TOKEN, false) && null !== $context->backendUser) {
$limited = Container::get(GetUser::class)($context);
if ($limited->isSuccessful()) {
return new Response(status: true, response: [$limited->response]);
}
return $limited;
}
$url = $context->backendUrl->withPath('/Users/');
$this->logger->debug('Requesting [{backend}] Users list.', [
$this->logger->debug("Requesting '{client}: {backend}' users list.", [
'client' => $context->clientName,
'backend' => $context->backendName,
'url' => (string)$url,
]);
@@ -82,13 +92,13 @@ class GetUsersList
}
$response = $this->http->request('GET', (string)$url, $headers);
if (200 !== $response->getStatusCode()) {
return new Response(
status: false,
error: new Error(
message: 'Request for [{backend}] users list returned with unexpected [{status_code}] status code.',
message: "Request for '{client}: {backend}' users list returned with unexpected '{status_code}' status code.",
context: [
'client' => $context->clientName,
'backend' => $context->backendName,
'status_code' => $response->getStatusCode(),
],

View File

@@ -13,6 +13,7 @@ use App\Backends\Common\Levels;
use App\Backends\Common\Response;
use App\Backends\Jellyfin\Action\Backup;
use App\Backends\Jellyfin\Action\Export;
use App\Backends\Jellyfin\Action\GenerateAccessToken;
use App\Backends\Jellyfin\Action\GetIdentifier;
use App\Backends\Jellyfin\Action\GetInfo;
use App\Backends\Jellyfin\Action\GetLibrariesList;
@@ -145,7 +146,7 @@ class JellyfinClient implements iClient
'token' => $context->backendToken,
'app' => Config::get('name') . '/' . static::CLIENT_NAME,
'os' => PHP_OS,
'id' => md5(Config::get('name') . '/' . static::CLIENT_NAME . $context->backendUser),
'id' => md5(Config::get('name') . '/' . static::CLIENT_NAME),
'version' => getAppVersion(),
'user' => $context->backendUser,
]
@@ -629,6 +630,29 @@ class JellyfinClient implements iClient
return Container::get(JellyfinValidateContext::class)($context);
}
/**
* @inheritdoc
*/
public function generateAccessToken(string|int $identifier, string $password, array $opts = []): array
{
$response = Container::get(GenerateAccessToken::class)(
context: $this->context,
identifier: $identifier,
password: $password,
opts: $opts
);
if ($response->hasError()) {
$this->logger->log($response->error->level(), $response->error->message, $response->error->context);
}
if (false === $response->isSuccessful()) {
$this->throwError($response);
}
return $response->response;
}
/**
* @inheritdoc
*/

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Backends\Jellyfin;
use App\Backends\Common\Context;
use App\Backends\Jellyfin\Action\GetUsersList;
use App\Backends\Jellyfin\Action\GetUser;
use App\Libs\Container;
use App\Libs\Exceptions\Backends\InvalidContextException;
use App\Libs\HTTP_STATUS;
@@ -45,29 +45,18 @@ class JellyfinValidateContext
);
}
$action = Container::get(GetUsersList::class)($context);
$action = Container::get(GetUser::class)($context);
if ($action->hasError()) {
throw new InvalidContextException(r('Failed to get user info. {error}', [
'error' => $action->error->format()
]));
}
$found = false;
$list = [];
foreach ($action->response as $user) {
$list[ag($user, 'name')] = ag($user, 'id');
if ((string)ag($user, 'id') === (string)$context->backendUser) {
$found = true;
break;
}
}
if (false === $found) {
if (ag($action->response, 'id') !== $context->backendUser) {
throw new InvalidContextException(
r("User id '{uid}' was not found in list of users. '{user_list}'.", [
r("Expected user id to be '{uid}' but the server responded with '{remote_id}'.", [
'uid' => $context->backendUser,
'user_list' => arrayToString($list),
'remote_id' => ag($action->response, 'id'),
])
);
}

View File

@@ -629,6 +629,14 @@ class PlexClient implements iClient
return Container::get(PlexValidateContext::class)($context);
}
/**
* @inheritdoc
*/
public function generateAccessToken(string|int $identifier, string $password, array $opts = []): array
{
return [];
}
/**
* @inheritdoc
*/

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Libs\Middlewares;
use App\API\Backends\AccessToken;
use App\API\System\AutoConfig;
use App\API\System\HealthCheck;
use App\Libs\Config;
@@ -24,6 +25,7 @@ final class APIKeyRequiredMiddleware implements MiddlewareInterface
private const array PUBLIC_ROUTES = [
HealthCheck::URL,
AutoConfig::URL,
AccessToken::URL,
];
/**

View File

@@ -32,6 +32,7 @@ final class Options
public const string MAX_EPISODE_RANGE = 'MAX_EPISODE_RANGE';
public const string IGNORE = 'ignore';
public const string PLEX_USE_OLD_PROGRESS_ENDPOINT = 'use_old_progress_endpoint';
public const string IS_LIMITED_TOKEN = 'is_limited_token';
public const string TO_ENTITY = 'TO_ENTITY';
private function __construct()

View File

@@ -129,6 +129,10 @@ trait APITraits
$options[Options::ADMIN_TOKEN] = $data->get('options.' . Options::ADMIN_TOKEN);
}
if (null !== $data->get('options.' . Options::IS_LIMITED_TOKEN)) {
$options[Options::IS_LIMITED_TOKEN] = (bool)$data->get('options.' . Options::IS_LIMITED_TOKEN, false);
}
$instance = Container::getNew($class);
assert($instance instanceof ClientInterface, new InvalidArgumentException('Invalid client class.'));
return $instance->withContext(