Experimental support for limited access tokens for emby & jellyfin.
This commit is contained in:
@@ -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.',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
55
src/API/Backends/AccessToken.php
Normal file
55
src/API/Backends/AccessToken.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
10
src/Backends/Emby/Action/GenerateAccessToken.php
Normal file
10
src/Backends/Emby/Action/GenerateAccessToken.php
Normal 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';
|
||||
}
|
||||
10
src/Backends/Emby/Action/GetUser.php
Normal file
10
src/Backends/Emby/Action/GetUser.php
Normal 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';
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
152
src/Backends/Jellyfin/Action/GenerateAccessToken.php
Normal file
152
src/Backends/Jellyfin/Action/GenerateAccessToken.php
Normal 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);
|
||||
}
|
||||
}
|
||||
151
src/Backends/Jellyfin/Action/GetUser.php
Normal file
151
src/Backends/Jellyfin/Action/GetUser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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'),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user