Add initial support in webhooks for sub-users.

This commit is contained in:
ArabCoders
2025-02-04 21:42:47 +03:00
parent 1252e71e11
commit a246ca7579
5 changed files with 64 additions and 21 deletions

21
FAQ.md
View File

@@ -479,15 +479,21 @@ $ docker exec -ti watchstate console system:env --list
### How to add webhooks?
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`. Or simply go to the `WebUI > Backends` and click
The Webhook URL is backend specific, the request path is `/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`,
Where `[USER]` is the username for sub user or `main` for main user and `[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/[USER]@[BACKEND_NAME]/webhook`. Or simply go to the `WebUI > Backends` and
click
on `Copy Webhook URL`.
> [!NOTE]
> You will keep seeing the `webhook.token` key, it's being kept for backward compatibility, and will be removed in the
> future. It has no effect except as pointer to the new method.
> [!IMPORTANT]
> As support more sub users expands, it's important to turn on `Webhook match user` for all backends to prevent
> sub users from changing the main user watch state. in case of plex backend.
-----
#### Emby (you need `Emby Premiere` to use webhooks).
@@ -496,9 +502,10 @@ Go to your Manage Emby Server > Server > Webhooks > (Click Add Webhook)
##### Webhook/Notifications URL:
`http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook`
`http://localhost:8080/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`
* Replace `[BACKEND_NAME]` with the name you have chosen for your backend.
* Replace `[USER]` with the `main` for main user or the sub user username.
##### Request content type (Emby v4.9+):
@@ -536,9 +543,10 @@ Go to your Plex Web UI > Settings > Your Account > Webhooks > (Click ADD WEBHOOK
##### URL:
`http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook`
`http://localhost:8080/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`
* Replace `[BACKEND_NAME]` with the name you have chosen for your backend.
* Replace `[USER]` with the `main` for main user or the sub user username.
> [!IMPORTANT]
> If you have enabled `WS_SECURE_API_ENDPOINTS`, you have to add `?apikey=yourapikey` to the end of the URL.
@@ -566,9 +574,10 @@ go back again to dashboard > plugins > webhook. Add `Add Generic Destination`,
##### Webhook Url:
`http://localhost:8080/v1/api/backend/[BACKEND_NAME]/webhook`
`http://localhost:8080/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`
* Replace `[BACKEND_NAME]` with the name you have chosen for your backend.
* Replace `[USER]` with the `main` for main user or the sub user username.
##### Notification Type:

View File

@@ -34,7 +34,8 @@ return (function () {
'secure' => (bool)env('WS_SECURE_API_ENDPOINTS', false),
'auto' => (bool)env('WS_API_AUTO', false),
'pattern_match' => [
'backend' => '[a-zA-Z0-9_-]+',
'backend' => '[a-zA-Z0-9_\-]+',
'ubackend' => '[a-zA-Z0-9_\-\@]+',
],
'logInternal' => (bool)env('WS_API_LOG_INTERNAL', false),
'response' => [

View File

@@ -6,11 +6,13 @@ namespace App\API\Backend;
use App\Libs\Attributes\Route\Route;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Extends\LogMessageProcessor;
use App\Libs\LogSuppressor;
use App\Libs\Mappers\Import\DirectMapper;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use App\Libs\Uri;
@@ -55,7 +57,7 @@ final class Webhooks
*
* @return iResponse The response object.
*/
#[Route(['POST', 'PUT'], Index::URL . '/{name:backend}/webhook[/]', name: 'backend.webhook')]
#[Route(['POST', 'PUT'], Index::URL . '/{name:ubackend}/webhook[/]', name: 'backend.webhook')]
public function __invoke(iRequest $request, array $args = []): iResponse
{
if (null === ($name = ag($args, 'name'))) {
@@ -76,14 +78,35 @@ final class Webhooks
private function process(string $name, iRequest $request): iResponse
{
try {
$backend = $this->getBackends(name: $name);
// -- sub-user
if (true === str_contains($name, '@')) {
[$user, $ubackend] = explode('@', $name, 2);
} else {
$user = 'main';
$ubackend = $name;
}
try {
$userContext = getUserContext(
user: $user,
mapper: Container::get(DirectMapper::class),
logger: Container::get(iLogger::class)
);
} catch (RuntimeException $ex) {
return api_error($ex->getMessage(), Status::NOT_FOUND);
}
$backend = $this->getBackends(name: $ubackend, userContext: $userContext);
if (empty($backend)) {
throw new RuntimeException(r("Backend '{backend}' not found.", ['backend ' => $name]));
throw new RuntimeException(r("Backend '{user}@{backend}' {backends} not found.", [
'user' => $user,
'backend' => $ubackend,
]));
}
$backend = array_pop($backend);
$client = $this->getClient(name: $name);
$client = $this->getClient(name: $ubackend, userContext: $userContext);
} catch (RuntimeException $e) {
return api_error($e->getMessage(), Status::NOT_FOUND);
}
@@ -103,7 +126,7 @@ final class Webhooks
}
if (false === hash_equals((string)$userId, (string)$requestUser)) {
$message = r('Request user id [{req_user}] does not match configured value [{config_user}]', [
$message = r("Request user id '{req_user}' does not match configured value '{config_user}'.", [
'req_user' => $requestUser ?? 'NOT SET',
'config_user' => $userId,
]);
@@ -120,7 +143,7 @@ final class Webhooks
}
if (false === hash_equals((string)$uuid, (string)$requestBackendId)) {
$message = r('Request backend unique id [{req_uid}] does not match backend uuid [{config_uid}].', [
$message = r("Request backend unique id '{req_uid}' does not match backend uuid '{config_uid}'.", [
'req_uid' => $requestBackendId ?? 'NOT SET',
'config_uid' => $uuid,
]);
@@ -139,7 +162,8 @@ final class Webhooks
if (true !== $metadataOnly && true !== (bool)ag($backend, 'import.enabled')) {
$response = api_response(Status::NOT_ACCEPTABLE);
$this->write($request, Level::Error, r('Import are disabled for [{backend}].', [
$this->write($request, Level::Error, r("Import are disabled for '{user}@{backend}'.", [
'user' => $userContext->name,
'backend' => $client->getName(),
]), forceContext: true);
@@ -156,8 +180,9 @@ final class Webhooks
$this->write(
$request,
Level::Info,
'Ignoring [{backend}] {item.type} [{item.title}]. No valid/supported external ids.',
"Ignoring '{user}@{backend}' {item.type} '{item.title}'. No valid/supported external ids.",
[
'user' => $userContext->name,
'backend' => $entity->via,
'item' => [
'title' => $entity->getName(),
@@ -173,8 +198,9 @@ final class Webhooks
$this->write(
$request,
Level::Notice,
'Ignoring [{backend}] {item.type} [{item.title}]. No episode/season number present.',
"Ignoring '{user}@{backend}' {item.type} '{item.title}'. No episode/season number present.",
[
'user' => $userContext->name,
'backend' => $entity->via,
'item' => [
'title' => $entity->getName(),
@@ -188,7 +214,8 @@ final class Webhooks
return api_response(Status::NOT_MODIFIED);
}
$itemId = r('{type}://{id}:{tainted}@{backend}', [
$itemId = r('{type}://{id}:{tainted}@{backend}/{user}', [
'user' => $userContext->name,
'type' => $entity->type,
'backend' => $entity->via,
'tainted' => $entity->isTainted() ? 'tainted' : 'untainted',
@@ -203,13 +230,15 @@ final class Webhooks
Options::IMPORT_METADATA_ONLY => $metadataOnly,
Options::REQUEST_ID => ag($request->getServerParams(), 'X_REQUEST_ID'),
],
Options::CONTEXT_USER => $userContext->name,
]);
$this->write(
$request,
Level::Info,
"Queued {tainted} request '{backend}: {event}' {item.type} '{item.title}' - 'state: {state}, progress: {has_progress}'. request_id '{req}'.",
"Queued {tainted} request '{user}@{backend}: {event}' {item.type} '{item.title}' - 'state: {state}, progress: {has_progress}'. request_id '{req}'.",
[
'user' => $userContext->name,
'backend' => $entity->via,
'event' => ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_EVENT),
'has_progress' => $entity->hasPlayProgress() ? 'Yes' : 'No',

View File

@@ -6,10 +6,12 @@ namespace App\API\Backends;
use App\Libs\Attributes\Route\Get;
use App\Libs\Enums\Http\Status;
use App\Libs\Mappers\ExtendedImportInterface as iEImport;
use App\Libs\Options;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerInterface as iLogger;
final class Index
{
@@ -25,11 +27,12 @@ final class Index
];
#[Get(self::URL . '[/]', name: 'backends')]
public function __invoke(iRequest $request): iResponse
public function __invoke(iRequest $request, iEImport $mapper, iLogger $logger): iResponse
{
$list = [];
$user = $this->getUserContext($request, $mapper, $logger);
foreach ($this->getBackends() as $backend) {
foreach ($this->getBackends(userContext: $user) as $backend) {
$item = array_filter(
$backend,
fn($key) => false === in_array($key, ['options', 'webhook'], true),

View File

@@ -83,7 +83,8 @@ trait APITraits
$backend = ag_set($backend, 'export.lastSync', $export ? makeDate($export) : null);
}
$webhookUrl = parseConfigValue(Index::URL) . "/{$backendName}/webhook";
$user = $userContext?->name ?? 'main';
$webhookUrl = parseConfigValue(Index::URL) . "/{$user}@{$backendName}/webhook";
if (true === (bool)Config::get('api.secure')) {
$webhookUrl .= '?apikey=' . Config::get('api.key');