src/Core/Framework/Webhook/WebhookDispatcher.php line 52

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