Added new API endpoint for processing webhooks requests.
This commit is contained in:
10
FAQ.md
10
FAQ.md
@@ -341,11 +341,7 @@ $ docker exec -ti watchstate console system:tasks
|
||||
### How to add webhooks?
|
||||
|
||||
To add webhook for your backend the URL will be dependent on how you exposed webhook frontend, but typically it will be
|
||||
like this:
|
||||
|
||||
Directly to container: `http://localhost:8080/?apikey=[WEBHOOK_TOKEN]`
|
||||
|
||||
Via reverse proxy : `https://watchstate.domain.example/?apikey=[WEBHOOK_TOKEN]`.
|
||||
like this: `http://localhost:8080/?apikey=[WEBHOOK_TOKEN]`, or via reverse proxy `https://watchstate.domain.example/?apikey=[WEBHOOK_TOKEN]`.
|
||||
|
||||
If your media backend support sending headers then remove query parameter `?apikey=[WEBHOOK_TOKEN]`, and add this header
|
||||
|
||||
@@ -399,6 +395,10 @@ Go to your Manage Emby Server > Server > Webhooks > (Click Add Webhook)
|
||||
|
||||
Click `Add Webhook`
|
||||
|
||||
> [!NOTE]
|
||||
> Emby version 4.9 replaced webhooks with Notification system, the system is somewhat similar to webhooks,
|
||||
> There is an extra option called `Request content type` you should set it to `application/json`.
|
||||
|
||||
-----
|
||||
|
||||
#### Plex (You need `Plex Pass` to use webhooks)
|
||||
|
||||
267
src/API/Webhooks/Index.php
Normal file
267
src/API/Webhooks/Index.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\Webhooks;
|
||||
|
||||
use App\Libs\Attributes\Route\Route;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Exceptions\RuntimeException;
|
||||
use App\Libs\Extends\LogMessageProcessor;
|
||||
use App\Libs\HTTP_STATUS;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use App\Libs\Uri;
|
||||
use DateInterval;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Level;
|
||||
use Monolog\Logger;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
|
||||
final class Index
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
public const string URL = '%{api.prefix}/webhooks';
|
||||
|
||||
private iLogger $accesslog;
|
||||
|
||||
public function __construct(private CacheInterface $cache)
|
||||
{
|
||||
$this->accesslog = new Logger(name: 'http', processors: [new LogMessageProcessor()]);
|
||||
|
||||
$level = Config::get('webhook.debug') ? Level::Debug : Level::Info;
|
||||
|
||||
if (null !== ($logfile = Config::get('webhook.logfile'))) {
|
||||
$this->accesslog = $this->accesslog->pushHandler(new StreamHandler($logfile, $level, true));
|
||||
}
|
||||
|
||||
if (true === inContainer()) {
|
||||
$this->accesslog->pushHandler(new StreamHandler('php://stderr', $level, true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive a webhook request from a backend.
|
||||
*
|
||||
* @param iRequest $request The incoming request object.
|
||||
* @param array $args The request path arguments.
|
||||
*
|
||||
* @return iResponse The response object.
|
||||
* @throws InvalidArgumentException if cache key is invalid.
|
||||
*/
|
||||
#[Route(['POST', 'PUT'], self::URL . '/{name:backend}[/]', name: 'webhooks.receive')]
|
||||
public function __invoke(iRequest $request, array $args = []): iResponse
|
||||
{
|
||||
if (null === ($name = ag($args, 'name'))) {
|
||||
return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$backend = $this->getBackends(name: $name);
|
||||
if (empty($backend)) {
|
||||
throw new RuntimeException(r("Backend '{backend}' not found.", ['backend ' => $name]));
|
||||
}
|
||||
|
||||
$backend = array_pop($backend);
|
||||
|
||||
$client = $this->getClient(name: $name);
|
||||
} catch (RuntimeException $e) {
|
||||
return api_error($e->getMessage(), HTTP_STATUS::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (true === Config::get('webhook.dumpRequest')) {
|
||||
saveRequestPayload(clone $request);
|
||||
}
|
||||
|
||||
$request = $client->processRequest($request);
|
||||
$attr = $request->getAttributes();
|
||||
|
||||
if (null !== ($userId = ag($backend, 'user', null)) && true === (bool)ag($backend, 'webhook.match.user')) {
|
||||
if (null === ($requestUser = ag($attr, 'user.id'))) {
|
||||
$message = "Request payload didn't contain a user id. Backend requires a user check.";
|
||||
$this->write($request, Level::Info, $message);
|
||||
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
|
||||
if (false === hash_equals((string)$userId, (string)$requestUser)) {
|
||||
$message = r('Request user id [{req_user}] does not match configured value [{config_user}]', [
|
||||
'req_user' => $requestUser ?? 'NOT SET',
|
||||
'config_user' => $userId,
|
||||
]);
|
||||
$this->write($request, Level::Info, $message);
|
||||
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($uuid = ag($backend, 'uuid', null)) && true === (bool)ag($backend, 'webhook.match.uuid')) {
|
||||
if (null === ($requestBackendId = ag($attr, 'backend.id'))) {
|
||||
$message = "Request payload didn't contain the backend unique id.";
|
||||
$this->write($request, Level::Info, $message);
|
||||
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
|
||||
if (false === hash_equals((string)$uuid, (string)$requestBackendId)) {
|
||||
$message = r('Request backend unique id [{req_uid}] does not match backend uuid [{config_uid}].', [
|
||||
'req_uid' => $requestBackendId ?? 'NOT SET',
|
||||
'config_uid' => $uuid,
|
||||
]);
|
||||
$this->write($request, Level::Info, $message);
|
||||
return api_error($message, HTTP_STATUS::HTTP_BAD_REQUEST)->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
}
|
||||
|
||||
if (true === (bool)ag($backend, 'import.enabled')) {
|
||||
if (true === ag_exists($backend, 'options.' . Options::IMPORT_METADATA_ONLY)) {
|
||||
$backend = ag_delete($backend, 'options.' . Options::IMPORT_METADATA_ONLY);
|
||||
}
|
||||
}
|
||||
|
||||
$metadataOnly = true === (bool)ag($backend, 'options.' . Options::IMPORT_METADATA_ONLY);
|
||||
|
||||
if (true !== $metadataOnly && true !== (bool)ag($backend, 'import.enabled')) {
|
||||
$response = api_response(HTTP_STATUS::HTTP_NOT_ACCEPTABLE);
|
||||
$this->write($request, Level::Error, r('Import are disabled for [{backend}].', [
|
||||
'backend' => $client->getName(),
|
||||
]), forceContext: true);
|
||||
|
||||
return $response->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
|
||||
$entity = $client->parseWebhook($request);
|
||||
|
||||
if (true === (bool)ag($backend, 'options.' . Options::DUMP_PAYLOAD)) {
|
||||
saveWebhookPayload($entity, $request);
|
||||
}
|
||||
|
||||
if (!$entity->hasGuids() && !$entity->hasRelativeGuid()) {
|
||||
$this->write(
|
||||
$request,
|
||||
Level::Info,
|
||||
'Ignoring [{backend}] {item.type} [{item.title}]. No valid/supported external ids.',
|
||||
[
|
||||
'backend' => $entity->via,
|
||||
'item' => [
|
||||
'title' => $entity->getName(),
|
||||
'type' => $entity->type,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED)->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
|
||||
if ((0 === (int)$entity->episode || null === $entity->season) && $entity->isEpisode()) {
|
||||
$this->write(
|
||||
$request,
|
||||
Level::Notice,
|
||||
'Ignoring [{backend}] {item.type} [{item.title}]. No episode/season number present.',
|
||||
[
|
||||
'backend' => $entity->via,
|
||||
'item' => [
|
||||
'title' => $entity->getName(),
|
||||
'type' => $entity->type,
|
||||
'season' => (string)($entity->season ?? 'None'),
|
||||
'episode' => (string)($entity->episode ?? 'None'),
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
return api_response(HTTP_STATUS::HTTP_NOT_MODIFIED)->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
|
||||
$items = $this->cache->get('requests', []);
|
||||
|
||||
$itemId = r('{type}://{id}:{tainted}@{backend}', [
|
||||
'type' => $entity->type,
|
||||
'backend' => $entity->via,
|
||||
'tainted' => $entity->isTainted() ? 'tainted' : 'untainted',
|
||||
'id' => ag($entity->getMetadata($entity->via), iState::COLUMN_ID, '??'),
|
||||
]);
|
||||
|
||||
$items[$itemId] = [
|
||||
'options' => [
|
||||
Options::IMPORT_METADATA_ONLY => $metadataOnly,
|
||||
],
|
||||
'entity' => $entity,
|
||||
];
|
||||
|
||||
$this->cache->set('requests', $items, new DateInterval('P3D'));
|
||||
|
||||
if (false === $metadataOnly && true === $entity->hasPlayProgress()) {
|
||||
$progress = $this->cache->get('progress', []);
|
||||
$progress[$itemId] = $entity;
|
||||
$this->cache->set('progress', $progress, new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
$this->write($request, Level::Info, 'Queued [{backend}: {event}] {item.type} [{item.title}].', [
|
||||
'backend' => $entity->via,
|
||||
'event' => ag($entity->getExtra($entity->via), iState::COLUMN_EXTRA_EVENT),
|
||||
'has_progress' => $entity->hasPlayProgress() ? 'Yes' : 'No',
|
||||
'item' => [
|
||||
'title' => $entity->getName(),
|
||||
'type' => $entity->type,
|
||||
'played' => $entity->isWatched() ? 'Yes' : 'No',
|
||||
'queue_id' => $itemId,
|
||||
'progress' => $entity->hasPlayProgress() ? $entity->getPlayProgress() : null,
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
return api_response(HTTP_STATUS::HTTP_OK)->withHeader('X-Log-Response', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry to the access log.
|
||||
*
|
||||
* @param iRequest $request The incoming request object.
|
||||
* @param int|string|Level $level The log level or priority.
|
||||
* @param string $message The log message.
|
||||
* @param array $context Additional data/context for the log entry.
|
||||
*/
|
||||
private function write(
|
||||
iRequest $request,
|
||||
int|string|Level $level,
|
||||
string $message,
|
||||
array $context = [],
|
||||
bool $forceContext = false
|
||||
): void {
|
||||
$params = $request->getServerParams();
|
||||
|
||||
$uri = new Uri((string)ag($params, 'REQUEST_URI', '/'));
|
||||
|
||||
if (false === empty($uri->getQuery())) {
|
||||
$query = [];
|
||||
parse_str($uri->getQuery(), $query);
|
||||
if (true === ag_exists($query, 'apikey')) {
|
||||
$query['apikey'] = 'api_key_removed';
|
||||
$uri = $uri->withQuery(http_build_query($query));
|
||||
}
|
||||
}
|
||||
|
||||
$context = array_replace_recursive([
|
||||
'request' => [
|
||||
'method' => $request->getMethod(),
|
||||
'id' => ag($params, 'X_REQUEST_ID'),
|
||||
'ip' => getClientIp($request),
|
||||
'agent' => ag($params, 'HTTP_USER_AGENT'),
|
||||
'uri' => (string)$uri,
|
||||
],
|
||||
], $context);
|
||||
|
||||
if (($attributes = $request->getAttributes()) && count($attributes) >= 1) {
|
||||
$context['attributes'] = $attributes;
|
||||
}
|
||||
|
||||
if (true === (Config::get('logs.context') || $forceContext)) {
|
||||
$this->accesslog->log($level, $message, $context);
|
||||
} else {
|
||||
$this->accesslog->log($level, r($message, $context));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,9 @@ class LogMessageProcessor implements ProcessorInterface
|
||||
return $record;
|
||||
}
|
||||
|
||||
$repl = r_array(
|
||||
text: $record->message,
|
||||
context: $record->context,
|
||||
opts: [
|
||||
'log_behavior' => true
|
||||
]
|
||||
);
|
||||
$repl = r_array(text: $record->message, context: $record->context, opts: [
|
||||
'log_behavior' => true
|
||||
]);
|
||||
|
||||
return $record->with(...$repl);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use Monolog\Logger;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Nyholm\Psr7\Response;
|
||||
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
@@ -182,8 +182,8 @@ final class Initializer
|
||||
* Run the application in HTTP Context.
|
||||
*
|
||||
* @param iRequest|null $request If null, the request will be created from globals.
|
||||
* @param callable(ResponseInterface):void|null $emitter If null, the emitter will be created from globals.
|
||||
* @param null|Closure(iRequest): ResponseInterface $fn If null, the default HTTP server will be used.
|
||||
* @param callable(iResponse):void|null $emitter If null, the emitter will be created from globals.
|
||||
* @param null|Closure(iRequest): iResponse $fn If null, the default HTTP server will be used.
|
||||
*/
|
||||
public function http(iRequest|null $request = null, callable|null $emitter = null, Closure|null $fn = null): void
|
||||
{
|
||||
@@ -200,12 +200,12 @@ final class Initializer
|
||||
$response = $response->withAddedHeader('X-Application-Version', getAppVersion());
|
||||
}
|
||||
|
||||
if ($response->hasHeader('X-Log-Response')) {
|
||||
if (true === (bool)$response->getHeaderLine('X-Log-Response')) {
|
||||
$this->write($request, Level::Info, $this->formatLog($request, $response));
|
||||
$response = $response->withoutHeader('X-Log-Response');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$httpException = (true === ($e instanceof HttpException));
|
||||
$httpException = true === ($e instanceof HttpException);
|
||||
|
||||
if (false === $httpException || $e->getCode() !== 200) {
|
||||
Container::get(LoggerInterface::class)->error(
|
||||
@@ -233,11 +233,11 @@ final class Initializer
|
||||
*
|
||||
* @param iRequest $realRequest The incoming HTTP request.
|
||||
*
|
||||
* @return ResponseInterface The HTTP response.
|
||||
* @return iResponse The HTTP response.
|
||||
*
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException If an error occurs.
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException If cache key is illegal.
|
||||
*/
|
||||
private function defaultHttpServer(iRequest $realRequest): ResponseInterface
|
||||
private function defaultHttpServer(iRequest $realRequest): iResponse
|
||||
{
|
||||
$log = $backend = [];
|
||||
$class = null;
|
||||
@@ -506,11 +506,11 @@ final class Initializer
|
||||
*
|
||||
* @param iRequest $realRequest The incoming HTTP request.
|
||||
*
|
||||
* @return ResponseInterface The HTTP response.
|
||||
* @return iResponse The HTTP response.
|
||||
*
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException If an error occurs.
|
||||
*/
|
||||
private function defaultAPIServer(iRequest $realRequest): ResponseInterface
|
||||
private function defaultAPIServer(iRequest $realRequest): iResponse
|
||||
{
|
||||
$router = new APIRouter();
|
||||
foreach (Config::get('api.pattern_match', []) as $_key => $_value) {
|
||||
@@ -566,7 +566,11 @@ final class Initializer
|
||||
})();
|
||||
|
||||
try {
|
||||
return $router->dispatch($realRequest)->withHeader('X-Log-Response', '1');
|
||||
$response = $router->dispatch($realRequest);
|
||||
if (!$response->hasHeader('X-Log-Response')) {
|
||||
$response = $response->withHeader('X-Log-Response', '1');
|
||||
}
|
||||
return $response;
|
||||
} /** @noinspection PhpRedundantCatchClauseInspection */
|
||||
catch (RouterHttpException $e) {
|
||||
throw new HttpException($e->getMessage(), $e->getStatusCode());
|
||||
@@ -769,7 +773,7 @@ final class Initializer
|
||||
}
|
||||
}
|
||||
|
||||
private function formatLog(iRequest $request, ResponseInterface $response, string|null $message = null): string
|
||||
private function formatLog(iRequest $request, iResponse $response, string|null $message = null): string
|
||||
{
|
||||
$refer = '-';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user