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

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