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

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