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