<?php declare(strict_types=1);namespace Shopware\Core\Framework\Webhook;use Doctrine\DBAL\Connection;use GuzzleHttp\Client;use GuzzleHttp\Pool;use GuzzleHttp\Psr7\Request;use Shopware\Core\DevOps\Environment\EnvironmentHelper;use Shopware\Core\Framework\App\AppLocaleProvider;use Shopware\Core\Framework\App\Event\AppChangedEvent;use Shopware\Core\Framework\App\Event\AppDeletedEvent;use Shopware\Core\Framework\App\Event\AppFlowActionEvent;use Shopware\Core\Framework\App\Exception\AppUrlChangeDetectedException;use Shopware\Core\Framework\App\Hmac\Guzzle\AuthMiddleware;use Shopware\Core\Framework\App\Hmac\RequestSigner;use Shopware\Core\Framework\App\ShopId\ShopIdProvider;use Shopware\Core\Framework\Context;use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;use Shopware\Core\Framework\Event\BusinessEventInterface;use Shopware\Core\Framework\Event\FlowEventAware;use Shopware\Core\Framework\Feature;use Shopware\Core\Framework\Log\Package;use Shopware\Core\Framework\Uuid\Uuid;use Shopware\Core\Framework\Webhook\EventLog\WebhookEventLogDefinition;use Shopware\Core\Framework\Webhook\Hookable\HookableEventFactory;use Shopware\Core\Framework\Webhook\Message\WebhookEventMessage;use Shopware\Core\Profiling\Profiler;use Symfony\Component\DependencyInjection\ContainerInterface;use Symfony\Component\EventDispatcher\EventDispatcherInterface;use Symfony\Component\EventDispatcher\EventSubscriberInterface;use Symfony\Component\Messenger\MessageBusInterface;#[Package('core')]class WebhookDispatcher implements EventDispatcherInterface{ private EventDispatcherInterface $dispatcher; private Connection $connection; private ?WebhookCollection $webhooks = null; private Client $guzzle; private string $shopUrl; private ContainerInterface $container; private array $privileges = []; private HookableEventFactory $eventFactory; private string $shopwareVersion; private MessageBusInterface $bus; private bool $isAdminWorkerEnabled; /** * @internal */ public function __construct( EventDispatcherInterface $dispatcher, Connection $connection, Client $guzzle, string $shopUrl, ContainerInterface $container, HookableEventFactory $eventFactory, string $shopwareVersion, MessageBusInterface $bus, bool $isAdminWorkerEnabled ) { $this->dispatcher = $dispatcher; $this->connection = $connection; $this->guzzle = $guzzle; $this->shopUrl = $shopUrl; // inject container, so we can later get the ShopIdProvider and the webhook repository // ShopIdProvider, AppLocaleProvider and webhook repository can not be injected directly as it would lead to a circular reference $this->container = $container; $this->eventFactory = $eventFactory; $this->shopwareVersion = $shopwareVersion; $this->bus = $bus; $this->isAdminWorkerEnabled = $isAdminWorkerEnabled; } /** * @template TEvent of object * * @param TEvent $event * * @return TEvent */ public function dispatch($event, ?string $eventName = null): object { $event = $this->dispatcher->dispatch($event, $eventName); if (EnvironmentHelper::getVariable('DISABLE_EXTENSIONS', false)) { return $event; } foreach ($this->eventFactory->createHookablesFor($event) as $hookable) { $context = Context::createDefaultContext(); if (Feature::isActive('FEATURE_NEXT_17858')) { if ($event instanceof FlowEventAware || $event instanceof AppChangedEvent || $event instanceof EntityWrittenContainerEvent) { $context = $event->getContext(); } } else { if ($event instanceof BusinessEventInterface || $event instanceof AppChangedEvent || $event instanceof EntityWrittenContainerEvent) { $context = $event->getContext(); } } $this->callWebhooks($hookable, $context); } // always return the original event and never our wrapped events // this would lead to problems in the `BusinessEventDispatcher` from core return $event; } /** * @param string $eventName * @param callable $listener * @param int $priority */ public function addListener($eventName, $listener, $priority = 0): void { $this->dispatcher->addListener($eventName, $listener, $priority); } public function addSubscriber(EventSubscriberInterface $subscriber): void { $this->dispatcher->addSubscriber($subscriber); } /** * @param string $eventName * @param callable $listener */ public function removeListener($eventName, $listener): void { $this->dispatcher->removeListener($eventName, $listener); } public function removeSubscriber(EventSubscriberInterface $subscriber): void { $this->dispatcher->removeSubscriber($subscriber); } /** * @param string|null $eventName * * @return array<array-key, array<array-key, callable>|callable> */ public function getListeners($eventName = null): array { return $this->dispatcher->getListeners($eventName); } /** * @param string $eventName * @param callable $listener */ public function getListenerPriority($eventName, $listener): ?int { return $this->dispatcher->getListenerPriority($eventName, $listener); } /** * @param string|null $eventName */ public function hasListeners($eventName = null): bool { return $this->dispatcher->hasListeners($eventName); } public function clearInternalWebhookCache(): void { $this->webhooks = null; } public function clearInternalPrivilegesCache(): void { $this->privileges = []; } private function callWebhooks(Hookable $event, Context $context): void { /** @var WebhookCollection $webhooksForEvent */ $webhooksForEvent = $this->getWebhooks()->filterForEvent($event->getName()); if ($webhooksForEvent->count() === 0) { return; } $affectedRoleIds = $webhooksForEvent->getAclRoleIdsAsBinary(); $languageId = $context->getLanguageId(); $userLocale = $this->getAppLocaleProvider()->getLocaleFromContext($context); // If the admin worker is enabled we send all events synchronously, as we can't guarantee timely delivery otherwise. // Additionally, all app lifecycle events are sent synchronously as those can lead to nasty race conditions otherwise. if ($this->isAdminWorkerEnabled || $event instanceof AppDeletedEvent || $event instanceof AppChangedEvent) { Profiler::trace('webhook::dispatch-sync', function () use ($userLocale, $languageId, $affectedRoleIds, $event, $webhooksForEvent): void { $this->callWebhooksSynchronous($webhooksForEvent, $event, $affectedRoleIds, $languageId, $userLocale); }); return; } Profiler::trace('webhook::dispatch-async', function () use ($userLocale, $languageId, $affectedRoleIds, $event, $webhooksForEvent): void { $this->dispatchWebhooksToQueue($webhooksForEvent, $event, $affectedRoleIds, $languageId, $userLocale); }); } private function getWebhooks(): WebhookCollection { if ($this->webhooks) { return $this->webhooks; } $criteria = new Criteria(); $criteria->setTitle('apps::webhooks'); $criteria->addFilter(new EqualsFilter('active', true)); $criteria->addAssociation('app'); /** @var WebhookCollection $webhooks */ $webhooks = $this->container->get('webhook.repository')->search($criteria, Context::createDefaultContext())->getEntities(); return $this->webhooks = $webhooks; } private function isEventDispatchingAllowed(WebhookEntity $webhook, Hookable $event, array $affectedRoles): bool { $app = $webhook->getApp(); if ($app === null) { return true; } // Only app lifecycle hooks can be received if app is deactivated if (!$app->isActive() && !($event instanceof AppChangedEvent || $event instanceof AppDeletedEvent)) { return false; } if (!($this->privileges[$event->getName()] ?? null)) { $this->loadPrivileges($event->getName(), $affectedRoles); } $privileges = $this->privileges[$event->getName()][$app->getAclRoleId()] ?? new AclPrivilegeCollection([]); if (!$event->isAllowed($app->getId(), $privileges)) { return false; } return true; } /** * @param array<string> $affectedRoleIds */ private function callWebhooksSynchronous( WebhookCollection $webhooksForEvent, Hookable $event, array $affectedRoleIds, string $languageId, string $userLocale ): void { $requests = []; foreach ($webhooksForEvent as $webhook) { if (!$this->isEventDispatchingAllowed($webhook, $event, $affectedRoleIds)) { continue; } try { $webhookData = $this->getPayloadForWebhook($webhook, $event); } catch (AppUrlChangeDetectedException $e) { // don't dispatch webhooks for apps if url changed continue; } $timestamp = time(); $webhookData['timestamp'] = $timestamp; /** @var string $jsonPayload */ $jsonPayload = json_encode($webhookData); $headers = [ 'Content-Type' => 'application/json', 'sw-version' => $this->shopwareVersion, AuthMiddleware::SHOPWARE_CONTEXT_LANGUAGE => $languageId, AuthMiddleware::SHOPWARE_USER_LANGUAGE => $userLocale, ]; if ($event instanceof AppFlowActionEvent) { $headers = array_merge($headers, $event->getWebhookHeaders()); } $request = new Request( 'POST', $webhook->getUrl(), $headers, $jsonPayload ); if ($webhook->getApp() !== null && $webhook->getApp()->getAppSecret() !== null) { $request = $request->withHeader( RequestSigner::SHOPWARE_SHOP_SIGNATURE, (new RequestSigner())->signPayload($jsonPayload, $webhook->getApp()->getAppSecret()) ); } $requests[] = $request; } if (\count($requests) > 0) { $pool = new Pool($this->guzzle, $requests); $pool->promise()->wait(); } } /** * @param array<string> $affectedRoleIds */ private function dispatchWebhooksToQueue( WebhookCollection $webhooksForEvent, Hookable $event, array $affectedRoleIds, string $languageId, string $userLocale ): void { foreach ($webhooksForEvent as $webhook) { if (!$this->isEventDispatchingAllowed($webhook, $event, $affectedRoleIds)) { continue; } try { $webhookData = $this->getPayloadForWebhook($webhook, $event); } catch (AppUrlChangeDetectedException $e) { // don't dispatch webhooks for apps if url changed continue; } $webhookEventId = $webhookData['source']['eventId']; $appId = $webhook->getApp() !== null ? $webhook->getApp()->getId() : null; $secret = $webhook->getApp() !== null ? $webhook->getApp()->getAppSecret() : null; $webhookEventMessage = new WebhookEventMessage( $webhookEventId, $webhookData, $appId, $webhook->getId(), $this->shopwareVersion, $webhook->getUrl(), $secret, $languageId, $userLocale ); $this->logWebhookWithEvent($webhook, $webhookEventMessage); $this->bus->dispatch($webhookEventMessage); } } private function getPayloadForWebhook(WebhookEntity $webhook, Hookable $event): array { if ($event instanceof AppFlowActionEvent) { return $event->getWebhookPayload(); } $data = [ 'payload' => $event->getWebhookPayload(), 'event' => $event->getName(), ]; $source = [ 'url' => $this->shopUrl, 'eventId' => Uuid::randomHex(), ]; if ($webhook->getApp() !== null) { $shopIdProvider = $this->getShopIdProvider(); $source['appVersion'] = $webhook->getApp()->getVersion(); $source['shopId'] = $shopIdProvider->getShopId(); } return [ 'data' => $data, 'source' => $source, ]; } private function logWebhookWithEvent(WebhookEntity $webhook, WebhookEventMessage $webhookEventMessage): void { /** @var EntityRepositoryInterface $webhookEventLogRepository */ $webhookEventLogRepository = $this->container->get('webhook_event_log.repository'); $webhookEventLogRepository->create([ [ 'id' => $webhookEventMessage->getWebhookEventId(), 'appName' => $webhook->getApp() !== null ? $webhook->getApp()->getName() : null, 'deliveryStatus' => WebhookEventLogDefinition::STATUS_QUEUED, 'webhookName' => $webhook->getName(), 'eventName' => $webhook->getEventName(), 'appVersion' => $webhook->getApp() !== null ? $webhook->getApp()->getVersion() : null, 'url' => $webhook->getUrl(), 'serializedWebhookMessage' => serialize($webhookEventMessage), ], ], Context::createDefaultContext()); } /** * @param array<string> $affectedRoleIds */ private function loadPrivileges(string $eventName, array $affectedRoleIds): void { $roles = $this->connection->fetchAllAssociative(' SELECT `id`, `privileges` FROM `acl_role` WHERE `id` IN (:aclRoleIds) ', ['aclRoleIds' => $affectedRoleIds], ['aclRoleIds' => Connection::PARAM_STR_ARRAY]); if (!$roles) { $this->privileges[$eventName] = []; } foreach ($roles as $privilege) { $this->privileges[$eventName][Uuid::fromBytesToHex($privilege['id'])] = new AclPrivilegeCollection(json_decode($privilege['privileges'], true)); } } private function getShopIdProvider(): ShopIdProvider { return $this->container->get(ShopIdProvider::class); } private function getAppLocaleProvider(): AppLocaleProvider { return $this->container->get(AppLocaleProvider::class); }}