- <?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);
-     }
- }
-