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

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