vendor/shopware/core/Framework/Webhook/WebhookDispatcher.php line 94

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\Framework\Webhook;
  3. use Doctrine\DBAL\Connection;
  4. use GuzzleHttp\Client;
  5. use GuzzleHttp\Pool;
  6. use GuzzleHttp\Psr7\Request;
  7. use Shopware\Core\Framework\App\AppLocaleProvider;
  8. use Shopware\Core\Framework\App\Event\AppChangedEvent;
  9. use Shopware\Core\Framework\App\Event\AppDeletedEvent;
  10. use Shopware\Core\Framework\App\Exception\AppUrlChangeDetectedException;
  11. use Shopware\Core\Framework\App\Hmac\Guzzle\AuthMiddleware;
  12. use Shopware\Core\Framework\App\Hmac\RequestSigner;
  13. use Shopware\Core\Framework\App\ShopId\ShopIdProvider;
  14. use Shopware\Core\Framework\Context;
  15. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
  16. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
  17. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  18. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  19. use Shopware\Core\Framework\Event\BusinessEventInterface;
  20. use Shopware\Core\Framework\Event\FlowEventAware;
  21. use Shopware\Core\Framework\Feature;
  22. use Shopware\Core\Framework\Uuid\Uuid;
  23. use Shopware\Core\Framework\Webhook\EventLog\WebhookEventLogDefinition;
  24. use Shopware\Core\Framework\Webhook\Hookable\HookableEventFactory;
  25. use Shopware\Core\Framework\Webhook\Message\WebhookEventMessage;
  26. use Symfony\Component\DependencyInjection\ContainerInterface;
  27. use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
  28. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  29. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  30. use Symfony\Component\Messenger\MessageBusInterface;
  31. class WebhookDispatcher implements EventDispatcherInterface
  32. {
  33.     private EventDispatcherInterface $dispatcher;
  34.     private Connection $connection;
  35.     private ?WebhookCollection $webhooks null;
  36.     private Client $guzzle;
  37.     private string $shopUrl;
  38.     private ContainerInterface $container;
  39.     private array $privileges = [];
  40.     private HookableEventFactory $eventFactory;
  41.     private string $shopwareVersion;
  42.     private MessageBusInterface $bus;
  43.     private bool $isAdminWorkerEnabled;
  44.     /**
  45.      * @psalm-suppress ContainerDependency
  46.      */
  47.     public function __construct(
  48.         EventDispatcherInterface $dispatcher,
  49.         Connection $connection,
  50.         Client $guzzle,
  51.         string $shopUrl,
  52.         ContainerInterface $container,
  53.         HookableEventFactory $eventFactory,
  54.         string $shopwareVersion,
  55.         MessageBusInterface $bus,
  56.         bool $isAdminWorkerEnabled
  57.     ) {
  58.         $this->dispatcher $dispatcher;
  59.         $this->connection $connection;
  60.         $this->guzzle $guzzle;
  61.         $this->shopUrl $shopUrl;
  62.         // inject container, so we can later get the ShopIdProvider and the webhook repository
  63.         // ShopIdProvider, AppLocaleProvider and webhook repository can not be injected directly as it would lead to a circular reference
  64.         $this->container $container;
  65.         $this->eventFactory $eventFactory;
  66.         $this->shopwareVersion $shopwareVersion;
  67.         $this->bus $bus;
  68.         $this->isAdminWorkerEnabled $isAdminWorkerEnabled;
  69.     }
  70.     /**
  71.      * @template TEvent of object
  72.      *
  73.      * @param TEvent $event
  74.      *
  75.      * @return TEvent
  76.      */
  77.     public function dispatch($event, ?string $eventName null): object
  78.     {
  79.         $event $this->dispatcher->dispatch($event$eventName);
  80.         foreach ($this->eventFactory->createHookablesFor($event) as $hookable) {
  81.             $context Context::createDefaultContext();
  82.             if (Feature::isActive('FEATURE_NEXT_17858')) {
  83.                 if ($event instanceof FlowEventAware || $event instanceof AppChangedEvent || $event instanceof EntityWrittenContainerEvent) {
  84.                     $context $event->getContext();
  85.                 }
  86.             } else {
  87.                 if ($event instanceof BusinessEventInterface || $event instanceof AppChangedEvent || $event instanceof EntityWrittenContainerEvent) {
  88.                     $context $event->getContext();
  89.                 }
  90.             }
  91.             $this->callWebhooks($hookable$context);
  92.         }
  93.         // always return the original event and never our wrapped events
  94.         // this would lead to problems in the `BusinessEventDispatcher` from core
  95.         return $event;
  96.     }
  97.     /**
  98.      * @param string   $eventName
  99.      * @param callable $listener
  100.      * @param int      $priority
  101.      */
  102.     public function addListener($eventName$listener$priority 0): void
  103.     {
  104.         $this->dispatcher->addListener($eventName$listener$priority);
  105.     }
  106.     public function addSubscriber(EventSubscriberInterface $subscriber): void
  107.     {
  108.         $this->dispatcher->addSubscriber($subscriber);
  109.     }
  110.     /**
  111.      * @param string   $eventName
  112.      * @param callable $listener
  113.      */
  114.     public function removeListener($eventName$listener): void
  115.     {
  116.         $this->dispatcher->removeListener($eventName$listener);
  117.     }
  118.     public function removeSubscriber(EventSubscriberInterface $subscriber): void
  119.     {
  120.         $this->dispatcher->removeSubscriber($subscriber);
  121.     }
  122.     /**
  123.      * @param string|null $eventName
  124.      *
  125.      * @return array<array-key, array<array-key, callable>|callable>
  126.      */
  127.     public function getListeners($eventName null): array
  128.     {
  129.         return $this->dispatcher->getListeners($eventName);
  130.     }
  131.     /**
  132.      * @param string   $eventName
  133.      * @param callable $listener
  134.      */
  135.     public function getListenerPriority($eventName$listener): ?int
  136.     {
  137.         return $this->dispatcher->getListenerPriority($eventName$listener);
  138.     }
  139.     /**
  140.      * @param string|null $eventName
  141.      */
  142.     public function hasListeners($eventName null): bool
  143.     {
  144.         return $this->dispatcher->hasListeners($eventName);
  145.     }
  146.     public function clearInternalWebhookCache(): void
  147.     {
  148.         $this->webhooks null;
  149.     }
  150.     public function clearInternalPrivilegesCache(): void
  151.     {
  152.         $this->privileges = [];
  153.     }
  154.     private function callWebhooks(Hookable $eventContext $context): void
  155.     {
  156.         /** @var WebhookCollection $webhooksForEvent */
  157.         $webhooksForEvent $this->getWebhooks()->filterForEvent($event->getName());
  158.         if ($webhooksForEvent->count() === 0) {
  159.             return;
  160.         }
  161.         $affectedRoleIds $webhooksForEvent->getAclRoleIdsAsBinary();
  162.         $languageId $context->getLanguageId();
  163.         $userLocale $this->getAppLocaleProvider()->getLocaleFromContext($context);
  164.         if ($this->isAdminWorkerEnabled) {
  165.             $this->callWebhooksSynchronous($webhooksForEvent$event$affectedRoleIds$languageId$userLocale);
  166.             return;
  167.         }
  168.         $this->dispatchWebhooksToQueue($webhooksForEvent$event$affectedRoleIds$languageId$userLocale);
  169.     }
  170.     private function getWebhooks(): WebhookCollection
  171.     {
  172.         if ($this->webhooks) {
  173.             return $this->webhooks;
  174.         }
  175.         $criteria = new Criteria();
  176.         $criteria->addFilter(new EqualsFilter('active'true));
  177.         $criteria->addAssociation('app');
  178.         if (!$this->container->has('webhook.repository')) {
  179.             throw new ServiceNotFoundException('webhook.repository');
  180.         }
  181.         /** @var WebhookCollection $webhooks */
  182.         $webhooks $this->container->get('webhook.repository')->search($criteriaContext::createDefaultContext())->getEntities();
  183.         return $this->webhooks $webhooks;
  184.     }
  185.     private function isEventDispatchingAllowed(WebhookEntity $webhookHookable $event, array $affectedRoles): bool
  186.     {
  187.         $app $webhook->getApp();
  188.         if ($app === null) {
  189.             return true;
  190.         }
  191.         // Only app lifecycle hooks can be received if app is deactivated
  192.         if (!$app->isActive() && !($event instanceof AppChangedEvent || $event instanceof AppDeletedEvent)) {
  193.             return false;
  194.         }
  195.         if (!($this->privileges[$event->getName()] ?? null)) {
  196.             $this->loadPrivileges($event->getName(), $affectedRoles);
  197.         }
  198.         $privileges $this->privileges[$event->getName()][$app->getAclRoleId()]
  199.             ?? new AclPrivilegeCollection([]);
  200.         if (!$event->isAllowed($app->getId(), $privileges)) {
  201.             return false;
  202.         }
  203.         return true;
  204.     }
  205.     /**
  206.      * @param string[] $affectedRoleIds
  207.      */
  208.     private function callWebhooksSynchronous(
  209.         WebhookCollection $webhooksForEvent,
  210.         Hookable $event,
  211.         array $affectedRoleIds,
  212.         string $languageId,
  213.         string $userLocale
  214.     ): void {
  215.         $requests = [];
  216.         foreach ($webhooksForEvent as $webhook) {
  217.             if (!$this->isEventDispatchingAllowed($webhook$event$affectedRoleIds)) {
  218.                 continue;
  219.             }
  220.             try {
  221.                 $webhookData $this->getPayloadForWebhook($webhook$event);
  222.             } catch (AppUrlChangeDetectedException $e) {
  223.                 // don't dispatch webhooks for apps if url changed
  224.                 continue;
  225.             }
  226.             $timestamp time();
  227.             $webhookData['timestamp'] = $timestamp;
  228.             /** @var string $jsonPayload */
  229.             $jsonPayload json_encode($webhookData);
  230.             $request = new Request(
  231.                 'POST',
  232.                 $webhook->getUrl(),
  233.                 [
  234.                     'Content-Type' => 'application/json',
  235.                     'sw-version' => $this->shopwareVersion,
  236.                     AuthMiddleware::SHOPWARE_CONTEXT_LANGUAGE => $languageId,
  237.                     AuthMiddleware::SHOPWARE_USER_LANGUAGE => $userLocale,
  238.                 ],
  239.                 $jsonPayload
  240.             );
  241.             if ($webhook->getApp() !== null && $webhook->getApp()->getAppSecret() !== null) {
  242.                 $request $request->withHeader(
  243.                     RequestSigner::SHOPWARE_SHOP_SIGNATURE,
  244.                     (new RequestSigner())->signPayload($jsonPayload$webhook->getApp()->getAppSecret())
  245.                 );
  246.             }
  247.             $requests[] = $request;
  248.         }
  249.         if (\count($requests) > 0) {
  250.             $pool = new Pool($this->guzzle$requests);
  251.             $pool->promise()->wait();
  252.         }
  253.     }
  254.     /**
  255.      * @param string[] $affectedRoleIds
  256.      */
  257.     private function dispatchWebhooksToQueue(
  258.         WebhookCollection $webhooksForEvent,
  259.         Hookable $event,
  260.         array $affectedRoleIds,
  261.         string $languageId,
  262.         string $userLocale
  263.     ): void {
  264.         foreach ($webhooksForEvent as $webhook) {
  265.             if (!$this->isEventDispatchingAllowed($webhook$event$affectedRoleIds)) {
  266.                 continue;
  267.             }
  268.             try {
  269.                 $webhookData $this->getPayloadForWebhook($webhook$event);
  270.             } catch (AppUrlChangeDetectedException $e) {
  271.                 // don't dispatch webhooks for apps if url changed
  272.                 continue;
  273.             }
  274.             $webhookEventId Uuid::randomHex();
  275.             $appId $webhook->getApp() !== null $webhook->getApp()->getId() : null;
  276.             $secret $webhook->getApp() !== null $webhook->getApp()->getAppSecret() : null;
  277.             $webhookEventMessage = new WebhookEventMessage(
  278.                 $webhookEventId,
  279.                 $webhookData,
  280.                 $appId,
  281.                 $webhook->getId(),
  282.                 $this->shopwareVersion,
  283.                 $webhook->getUrl(),
  284.                 $secret,
  285.                 $languageId,
  286.                 $userLocale
  287.             );
  288.             $this->logWebhookWithEvent($webhook$webhookEventMessage);
  289.             $this->bus->dispatch($webhookEventMessage);
  290.         }
  291.     }
  292.     private function getPayloadForWebhook(WebhookEntity $webhookHookable $event): array
  293.     {
  294.         $data = [
  295.             'payload' => $event->getWebhookPayload(),
  296.             'event' => $event->getName(),
  297.         ];
  298.         $source = [
  299.             'url' => $this->shopUrl,
  300.         ];
  301.         if ($webhook->getApp() !== null) {
  302.             $shopIdProvider $this->getShopIdProvider();
  303.             $source['appVersion'] = $webhook->getApp()->getVersion();
  304.             $source['shopId'] = $shopIdProvider->getShopId();
  305.         }
  306.         return [
  307.             'data' => $data,
  308.             'source' => $source,
  309.         ];
  310.     }
  311.     private function logWebhookWithEvent(WebhookEntity $webhookWebhookEventMessage $webhookEventMessage): void
  312.     {
  313.         if (!$this->container->has('webhook_event_log.repository')) {
  314.             throw new ServiceNotFoundException('webhook_event_log.repository');
  315.         }
  316.         /** @var EntityRepositoryInterface $webhookEventLogRepository */
  317.         $webhookEventLogRepository $this->container->get('webhook_event_log.repository');
  318.         $webhookEventLogRepository->create([
  319.             [
  320.                 'id' => $webhookEventMessage->getWebhookEventId(),
  321.                 'appName' => $webhook->getApp() !== null $webhook->getApp()->getName() : null,
  322.                 'deliveryStatus' => WebhookEventLogDefinition::STATUS_QUEUED,
  323.                 'webhookName' => $webhook->getName(),
  324.                 'eventName' => $webhook->getEventName(),
  325.                 'appVersion' => $webhook->getApp() !== null $webhook->getApp()->getVersion() : null,
  326.                 'url' => $webhook->getUrl(),
  327.                 'serializedWebhookMessage' => serialize($webhookEventMessage),
  328.             ],
  329.         ], Context::createDefaultContext());
  330.     }
  331.     private function loadPrivileges(string $eventName, array $affectedRoleIds): void
  332.     {
  333.         $roles $this->connection->fetchAll('
  334.             SELECT `id`, `privileges`
  335.             FROM `acl_role`
  336.             WHERE `id` IN (:aclRoleIds)
  337.         ', ['aclRoleIds' => $affectedRoleIds], ['aclRoleIds' => Connection::PARAM_STR_ARRAY]);
  338.         if (!$roles) {
  339.             $this->privileges[$eventName] = [];
  340.         }
  341.         foreach ($roles as $privilege) {
  342.             $this->privileges[$eventName][Uuid::fromBytesToHex($privilege['id'])]
  343.                 = new AclPrivilegeCollection(json_decode($privilege['privileges'], true));
  344.         }
  345.     }
  346.     private function getShopIdProvider(): ShopIdProvider
  347.     {
  348.         if (!$this->container->has(ShopIdProvider::class)) {
  349.             throw new ServiceNotFoundException(ShopIdProvider::class);
  350.         }
  351.         return $this->container->get(ShopIdProvider::class);
  352.     }
  353.     private function getAppLocaleProvider(): AppLocaleProvider
  354.     {
  355.         if (!$this->container->has(AppLocaleProvider::class)) {
  356.             throw new ServiceNotFoundException(AppLocaleProvider::class);
  357.         }
  358.         return $this->container->get(AppLocaleProvider::class);
  359.     }
  360. }