diff --git a/FAQ.md b/FAQ.md index 7b8c8a83..6e381dda 100644 --- a/FAQ.md +++ b/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) diff --git a/src/API/Webhooks/Index.php b/src/API/Webhooks/Index.php new file mode 100644 index 00000000..52f58043 --- /dev/null +++ b/src/API/Webhooks/Index.php @@ -0,0 +1,267 @@ +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)); + } + } +} diff --git a/src/Libs/Extends/LogMessageProcessor.php b/src/Libs/Extends/LogMessageProcessor.php index b1d95e45..8becb7c8 100644 --- a/src/Libs/Extends/LogMessageProcessor.php +++ b/src/Libs/Extends/LogMessageProcessor.php @@ -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); } diff --git a/src/Libs/Initializer.php b/src/Libs/Initializer.php index c9305813..80f74696 100644 --- a/src/Libs/Initializer.php +++ b/src/Libs/Initializer.php @@ -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 = '-';