Initial commit: CloudOps infrastructure platform
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1,49 @@
|
||||
Mautic.sendHookTest = function() {
|
||||
|
||||
var url = mQuery('#webhook_webhookUrl').val();
|
||||
var secret = mQuery('#webhook_secret').val();
|
||||
var eventTypes = mQuery("#event-types input[type='checkbox']");
|
||||
var selectedTypes = [];
|
||||
|
||||
eventTypes.each(function() {
|
||||
var item = mQuery(this);
|
||||
if (item.is(':checked')) {
|
||||
selectedTypes.push(item.val());
|
||||
}
|
||||
});
|
||||
|
||||
var data = {
|
||||
action: 'webhook:sendHookTest',
|
||||
url: url,
|
||||
secret: secret,
|
||||
types: selectedTypes
|
||||
};
|
||||
|
||||
var spinner = mQuery('#spinner');
|
||||
|
||||
// show the spinner
|
||||
spinner.removeClass('hide');
|
||||
|
||||
mQuery.ajax({
|
||||
url: mauticAjaxUrl,
|
||||
data: data,
|
||||
type: 'POST',
|
||||
dataType: "json",
|
||||
success: function(response) {
|
||||
if (response.html) {
|
||||
mQuery('#tester').html(response.html);
|
||||
}
|
||||
},
|
||||
error: function (response, textStatus, errorThrown) {
|
||||
console.log(response.responseJSON);
|
||||
if (response.responseJSON.html) {
|
||||
mQuery('#tester').html(response.responseJSON.html);
|
||||
} else {
|
||||
Mautic.processAjaxError(response, textStatus, errorThrown);
|
||||
}
|
||||
},
|
||||
complete: function(response) {
|
||||
spinner.addClass('hide');
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Command;
|
||||
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* Retains a rolling number of log records.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: DeleteWebhookLogsCommand::COMMAND_NAME,
|
||||
description: 'Retains a rolling number of log records.'
|
||||
)]
|
||||
class DeleteWebhookLogsCommand extends Command
|
||||
{
|
||||
public const COMMAND_NAME = 'mautic:webhooks:delete_logs';
|
||||
|
||||
private \Mautic\WebhookBundle\Entity\LogRepository $logRepository;
|
||||
|
||||
public function __construct(
|
||||
WebhookModel $webhookModel,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
) {
|
||||
$this->logRepository = $webhookModel->getLogRepository();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$logMaxLimit = (int) $this->coreParametersHelper->get('webhook_log_max', WebhookModel::WEBHOOK_LOG_MAX);
|
||||
$webHookIds = $this->logRepository->getWebhooksBasedOnLogLimit($logMaxLimit);
|
||||
$webhookCount = count($webHookIds);
|
||||
$output->writeln("<info>There is {$webhookCount} webhooks with logs more than defined limit.</info>");
|
||||
|
||||
foreach ($webHookIds as $webHookId) {
|
||||
$deletedLogCount = $this->logRepository->removeLimitExceedLogs($webHookId, $logMaxLimit);
|
||||
$output->writeln(sprintf('<info>%s logs deleted successfully for webhook id - %s</info>', $deletedLogCount, $webHookId));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Command;
|
||||
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use Mautic\WebhookBundle\Service\WebhookService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* CLI Command to process queued webhook payloads.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: ProcessWebhookQueuesCommand::COMMAND_NAME,
|
||||
description: 'Process queued webhook payloads'
|
||||
)]
|
||||
class ProcessWebhookQueuesCommand extends Command
|
||||
{
|
||||
public const COMMAND_NAME = 'mautic:webhooks:process';
|
||||
|
||||
public function __construct(private WebhookModel $webhookModel,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
private WebhookService $webhookService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption(
|
||||
'--webhook-id',
|
||||
'-i',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Process payload for a specific webhook. If not specified, all webhooks will be processed.',
|
||||
null
|
||||
)
|
||||
->addOption(
|
||||
'--min-id',
|
||||
null,
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Sets the minimum webhook queue ID to process (so called range mode).',
|
||||
null
|
||||
)
|
||||
->addOption(
|
||||
'--max-id',
|
||||
null,
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Sets the maximum webhook queue ID to process (so called range mode).',
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// check to make sure we are in queue mode
|
||||
if ($this->coreParametersHelper->get('queue_mode') != $this->webhookModel::COMMAND_PROCESS) {
|
||||
$output->writeLn('Webhook Bundle is in immediate process mode. To use the command function change to command mode.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$id = $input->getOption('webhook-id');
|
||||
$minId = (int) $input->getOption('min-id');
|
||||
$maxId = (int) $input->getOption('max-id');
|
||||
|
||||
$queueRangeMode = false;
|
||||
|
||||
$healthyWebhookTime = $this->webhookService->getHealthyWebhookTime();
|
||||
if ($id) {
|
||||
$webhook = $this->webhookModel->getEntity($id);
|
||||
$webhooks = (null !== $webhook && $webhook->isPublished()
|
||||
&& $this->webhookService->isWebhookHealthy($webhook)) ? [$id => $webhook] : [];
|
||||
$queueRangeMode = $minId && $maxId;
|
||||
} else {
|
||||
// make sure we only get published / healthy webhook entities
|
||||
$webhooks = $this->webhookModel->getEntities(
|
||||
[
|
||||
'filter' => [
|
||||
'where' => [
|
||||
[
|
||||
'expr' => 'andX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => 'e.isPublished',
|
||||
'expr' => 'eq',
|
||||
'value' => 1,
|
||||
],
|
||||
[
|
||||
'expr' => 'orX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => 'e.markedUnhealthyAt',
|
||||
'expr' => 'lt',
|
||||
'value' => $healthyWebhookTime->format(DateTimeHelper::FORMAT_DB),
|
||||
],
|
||||
[
|
||||
'column' => 'e.markedUnhealthyAt',
|
||||
'expr' => 'isNull',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (!count($webhooks)) {
|
||||
$output->writeln('<error>No published/Healthy webhooks found. Try again later.</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeLn('<info>Processing Webhooks</info>');
|
||||
|
||||
try {
|
||||
if ($queueRangeMode) {
|
||||
$webhookLimit = $this->webhookModel->getWebhookLimit();
|
||||
|
||||
if (1 > $webhookLimit) {
|
||||
throw new \InvalidArgumentException('`webhook limit` parameter must be greater than zero.');
|
||||
}
|
||||
|
||||
for (; $minId <= $maxId; $minId += $webhookLimit) {
|
||||
$this->webhookModel
|
||||
->setMinQueueId($minId)
|
||||
->setMaxQueueId(min($minId + $webhookLimit - 1, $maxId));
|
||||
$this->webhookModel->processWebhook(current($webhooks));
|
||||
}
|
||||
} else {
|
||||
$this->webhookModel->processWebhooks($webhooks);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$output->writeLn('<error>'.$e->getMessage().'</error>');
|
||||
$output->writeLn('<error>'.$e->getTraceAsString().'</error>');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeLn('<info>Webhook Processing Complete</info>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'routes' => [
|
||||
'main' => [
|
||||
'mautic_webhook_index' => [
|
||||
'path' => '/webhooks/{page}',
|
||||
'controller' => 'Mautic\WebhookBundle\Controller\WebhookController::indexAction',
|
||||
],
|
||||
'mautic_webhook_action' => [
|
||||
'path' => '/webhooks/{objectAction}/{objectId}',
|
||||
'controller' => 'Mautic\WebhookBundle\Controller\WebhookController::executeAction',
|
||||
],
|
||||
],
|
||||
'api' => [
|
||||
'mautic_api_webhookstandard' => [
|
||||
'standard_entity' => true,
|
||||
'name' => 'hooks',
|
||||
'path' => '/hooks',
|
||||
'controller' => Mautic\WebhookBundle\Controller\Api\WebhookApiController::class,
|
||||
],
|
||||
'mautic_api_webhookevents' => [
|
||||
'path' => '/hooks/triggers',
|
||||
'controller' => 'Mautic\WebhookBundle\Controller\Api\WebhookApiController::getTriggersAction',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'menu' => [
|
||||
'admin' => [
|
||||
'items' => [
|
||||
'mautic.webhook.webhooks' => [
|
||||
'id' => 'mautic_webhook_root',
|
||||
'access' => ['webhook:webhooks:viewown', 'webhook:webhooks:viewother'],
|
||||
'route' => 'mautic_webhook_index',
|
||||
'parent' => 'mautic.core.integrations',
|
||||
'iconClass' => 'ri-webhook-fill',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'services' => [
|
||||
'others' => [
|
||||
'mautic.webhook.campaign.helper' => [
|
||||
'class' => Mautic\WebhookBundle\Helper\CampaignHelper::class,
|
||||
'arguments' => [
|
||||
'mautic.http.client',
|
||||
'mautic.lead.model.company',
|
||||
'event_dispatcher',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'parameters' => [
|
||||
'webhook_limit' => 10, // How many entities can be sent in one webhook
|
||||
'webhook_time_limit' => 600, // How long the webhook processing can run in seconds
|
||||
'webhook_log_max' => 1000, // How many recent logs to keep
|
||||
'webhook_health_check_time' => 300, // Retry webhook after this time once it marked it as unhealthy in seconds.
|
||||
'webhook_retry_delay' => 3600, // Retry webhook_queue entry after given time after it is failed in seconds.
|
||||
'clean_webhook_logs_in_background' => false,
|
||||
'webhook_disable_limit' => 100, // How many times the webhook response can fail until the webhook will be unpublished
|
||||
'webhook_timeout' => 15, // How long the CURL request can wait for response before Mautic hangs up. In seconds
|
||||
'queue_mode' => Mautic\WebhookBundle\Model\WebhookModel::IMMEDIATE_PROCESS, // Trigger the webhook immediately or queue it for faster response times
|
||||
'events_orderby_dir' => Doctrine\Common\Collections\Order::Ascending->value, // Order the queued events chronologically or the other way around
|
||||
'webhook_email_details' => true, // If enabled, email related webhooks send detailed data
|
||||
'disable_auto_unpublish' => false, // If enabled, webhooks will not be automatically unpublished on errors
|
||||
'first_webhook_failure_notification_time' => 3600, // 1 hour
|
||||
'webhook_failure_notification_interval' => 86400, // 1 day
|
||||
'webhook_allowed_private_addresses' => [],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
|
||||
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
|
||||
|
||||
return function (ContainerConfigurator $configurator): void {
|
||||
$services = $configurator->services()
|
||||
->defaults()
|
||||
->autowire()
|
||||
->autoconfigure()
|
||||
->public();
|
||||
|
||||
$excludes = [
|
||||
'Form/DataTransformer/EventsToArrayTransformer.php',
|
||||
];
|
||||
|
||||
$services->load('Mautic\\WebhookBundle\\', '../')
|
||||
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
|
||||
|
||||
$services->load('Mautic\\WebhookBundle\\Entity\\', '../Entity/*Repository.php')
|
||||
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
|
||||
|
||||
$services->alias('mautic.webhook.model.webhook', Mautic\WebhookBundle\Model\WebhookModel::class);
|
||||
$services->alias('mautic.webhook.repository.queue', Mautic\WebhookBundle\Entity\WebhookQueueRepository::class);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Controller;
|
||||
|
||||
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
|
||||
use Mautic\CoreBundle\Helper\InputHelper;
|
||||
use Mautic\CoreBundle\Helper\PathsHelper;
|
||||
use Mautic\WebhookBundle\Exception\PrivateAddressException;
|
||||
use Mautic\WebhookBundle\Http\Client;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AjaxController extends CommonAjaxController
|
||||
{
|
||||
public function sendHookTestAction(Request $request, Client $client, PathsHelper $pathsHelper): JsonResponse
|
||||
{
|
||||
try {
|
||||
return $this->processWebhookTest($request, $client, $pathsHelper);
|
||||
} catch (PrivateAddressException) {
|
||||
return $this->createErrorResponse(
|
||||
'mautic.webhook.error.private_address'
|
||||
);
|
||||
} catch (\Exception) {
|
||||
return $this->createErrorResponse(
|
||||
'mautic.webhook.label.warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function processWebhookTest(Request $request, Client $client, PathsHelper $pathsHelper): JsonResponse
|
||||
{
|
||||
$url = $this->validateUrl($request);
|
||||
if (!$url) {
|
||||
return $this->createErrorResponse('mautic.webhook.label.no.url');
|
||||
}
|
||||
|
||||
$selectedTypes = InputHelper::cleanArray($request->request->all()['types']) ?? [];
|
||||
$payloadPaths = $this->getPayloadPaths($selectedTypes, $pathsHelper);
|
||||
$payload = $this->loadPayloads($payloadPaths);
|
||||
$payload['timestamp'] = (new \DateTimeImmutable())->format('c');
|
||||
$secret = InputHelper::string($request->request->get('secret'));
|
||||
|
||||
$response = $client->post($url, $payload, $secret);
|
||||
|
||||
return $this->createResponseFromStatusCode($response->getStatusCode());
|
||||
}
|
||||
|
||||
private function validateUrl(Request $request): ?string
|
||||
{
|
||||
$url = InputHelper::url($request->request->get('url'));
|
||||
|
||||
return '' !== $url ? $url : null;
|
||||
}
|
||||
|
||||
private function createResponseFromStatusCode(int $statusCode): JsonResponse
|
||||
{
|
||||
$isSuccess = str_starts_with((string) $statusCode, '2');
|
||||
$message = $isSuccess
|
||||
? 'mautic.webhook.label.success'
|
||||
: 'mautic.webhook.label.warning';
|
||||
|
||||
$cssClass = $isSuccess ? 'has-success' : 'has-error';
|
||||
|
||||
return $this->createJsonResponse($message, $cssClass);
|
||||
}
|
||||
|
||||
private function createErrorResponse(string $message): JsonResponse
|
||||
{
|
||||
return $this->createJsonResponse($message, 'has-error', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
private function createJsonResponse(
|
||||
string $message,
|
||||
string $cssClass,
|
||||
int $status = Response::HTTP_OK,
|
||||
): JsonResponse {
|
||||
$html = sprintf(
|
||||
'<div class="%s"><span class="help-block">%s</span></div>',
|
||||
$cssClass,
|
||||
$this->translator->trans($message)
|
||||
);
|
||||
|
||||
return $this->sendJsonResponse(
|
||||
['html' => $html],
|
||||
$status
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get an array of all the payload paths we need to load
|
||||
*
|
||||
* @param $types array
|
||||
* @return array
|
||||
*/
|
||||
/**
|
||||
* @return non-falsy-string[]
|
||||
*/
|
||||
public function getPayloadPaths($types, PathsHelper $pathsHelper): array
|
||||
{
|
||||
$payloadPaths = [];
|
||||
|
||||
foreach ($types as $type) {
|
||||
// takes an input like mautic.lead_on_something
|
||||
// converts to array pieces using _
|
||||
$typePath = explode('_', $type);
|
||||
|
||||
// pull the prefix into its own variable
|
||||
$prefix = $typePath[0];
|
||||
|
||||
// now that we have the remove it from the array
|
||||
unset($typePath[0]);
|
||||
|
||||
// build the event name by putting the pieces back together
|
||||
$eventName = implode('_', $typePath);
|
||||
|
||||
// default the path to core
|
||||
$payloadPath = $pathsHelper->getSystemPath('bundles', true);
|
||||
|
||||
// if plugin is in first part of the string this is an addon
|
||||
// input is plugin.bundlename or mautic.bundlename
|
||||
if (strpos('plugin.', $prefix)) {
|
||||
$payloadPath = $pathsHelper->getSystemPath('plugins', true);
|
||||
}
|
||||
|
||||
$prefixParts = explode('.', $prefix);
|
||||
|
||||
$bundleName = array_pop($prefixParts);
|
||||
|
||||
$payloadPath .= '/'.ucfirst($bundleName).'Bundle/Assets/WebhookPayload/'.$bundleName.'_'.$eventName.'.json';
|
||||
|
||||
$payloadPaths[$type] = $payloadPath;
|
||||
}
|
||||
|
||||
return $payloadPaths;
|
||||
}
|
||||
|
||||
/*
|
||||
* Iterate through the paths and get the json payloads
|
||||
*
|
||||
* @param $paths array
|
||||
* @return $payload array
|
||||
*/
|
||||
/**
|
||||
* @return mixed[]
|
||||
*/
|
||||
public function loadPayloads($paths): array
|
||||
{
|
||||
$payloads = [];
|
||||
|
||||
foreach ($paths as $key => $path) {
|
||||
if (file_exists($path)) {
|
||||
$payloads[$key] = json_decode(file_get_contents($path), true);
|
||||
}
|
||||
}
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Controller\Api;
|
||||
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Mautic\ApiBundle\Controller\CommonApiController;
|
||||
use Mautic\ApiBundle\Helper\EntityResultHelper;
|
||||
use Mautic\CoreBundle\Factory\ModelFactory;
|
||||
use Mautic\CoreBundle\Helper\AppVersion;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\CoreBundle\Translation\Translator;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
|
||||
/**
|
||||
* @extends CommonApiController<Webhook>
|
||||
*/
|
||||
class WebhookApiController extends CommonApiController
|
||||
{
|
||||
/**
|
||||
* @var WebhookModel|null
|
||||
*/
|
||||
protected $model;
|
||||
|
||||
public function __construct(
|
||||
CorePermissions $security,
|
||||
Translator $translator,
|
||||
EntityResultHelper $entityResultHelper,
|
||||
RouterInterface $router,
|
||||
FormFactoryInterface $formFactory,
|
||||
AppVersion $appVersion,
|
||||
private RequestStack $requestStack,
|
||||
ManagerRegistry $doctrine,
|
||||
ModelFactory $modelFactory,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
CoreParametersHelper $coreParametersHelper,
|
||||
) {
|
||||
$webhookModel = $modelFactory->getModel('webhook');
|
||||
\assert($webhookModel instanceof WebhookModel);
|
||||
|
||||
$this->model = $webhookModel;
|
||||
$this->entityClass = Webhook::class;
|
||||
$this->entityNameOne = 'hook';
|
||||
$this->entityNameMulti = 'hooks';
|
||||
$this->serializerGroups = ['hookDetails', 'categoryList', 'publishDetails'];
|
||||
|
||||
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives child controllers opportunity to analyze and do whatever to an entity before going through serializer.
|
||||
*/
|
||||
protected function preSerializeEntity(object $entity, string $action = 'view'): void
|
||||
{
|
||||
// We have to use this hack to have a simple array instead of the one the serializer gives us
|
||||
$entity->buildTriggers();
|
||||
}
|
||||
|
||||
protected function preSaveEntity(&$entity, $form, $parameters, $action = 'edit')
|
||||
{
|
||||
$eventsToKeep = [];
|
||||
|
||||
// Build webhook events from the triggers
|
||||
if (isset($parameters['triggers']) && is_array($parameters['triggers'])) {
|
||||
$entity->setTriggers($parameters['triggers']);
|
||||
$eventsToKeep = $parameters['triggers'];
|
||||
}
|
||||
|
||||
// Remove events missing in the PUT request
|
||||
if ('PUT' === $this->requestStack->getCurrentRequest()->getMethod()) {
|
||||
foreach ($entity->getEvents() as $event) {
|
||||
if (!in_array($event->getEventType(), $eventsToKeep)) {
|
||||
$entity->removeEvent($event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getTriggersAction()
|
||||
{
|
||||
return $this->handleView(
|
||||
$this->view(
|
||||
[
|
||||
'triggers' => $this->model->getEvents(),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Controller;
|
||||
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Mautic\CoreBundle\Controller\FormController;
|
||||
use Mautic\CoreBundle\Factory\ModelFactory;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\UserHelper;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\CoreBundle\Service\FlashBag;
|
||||
use Mautic\CoreBundle\Translation\Translator;
|
||||
use Mautic\FormBundle\Helper\FormFieldHelper;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
class WebhookController extends FormController
|
||||
{
|
||||
public function __construct(FormFactoryInterface $formFactory, FormFieldHelper $fieldHelper, ManagerRegistry $doctrine, ModelFactory $modelFactory, UserHelper $userHelper, CoreParametersHelper $coreParametersHelper, EventDispatcherInterface $dispatcher, Translator $translator, FlashBag $flashBag, RequestStack $requestStack, CorePermissions $security)
|
||||
{
|
||||
$this->setStandardParameters(
|
||||
'webhook.webhook', // model name
|
||||
'webhook:webhooks', // permission base
|
||||
'mautic_webhook', // route base
|
||||
'mautic_webhook', // session base
|
||||
'mautic.webhook', // lang string base
|
||||
'@MauticWebhook/Webhook', // template base
|
||||
'mautic_webhook', // activeLink
|
||||
'mauticWebhook' // mauticContent
|
||||
);
|
||||
|
||||
parent::__construct($formFactory, $fieldHelper, $doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $page
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
|
||||
*/
|
||||
public function indexAction(Request $request, $page = 1): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
return parent::indexStandard($request, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates new form and processes post data.
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse
|
||||
*/
|
||||
public function newAction(Request $request)
|
||||
{
|
||||
return parent::newStandard($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates edit form and processes post data.
|
||||
*
|
||||
* @param int $objectId
|
||||
* @param bool $ignorePost
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse
|
||||
*/
|
||||
public function editAction(Request $request, $objectId, $ignorePost = false)
|
||||
{
|
||||
return parent::editStandard($request, $objectId, $ignorePost);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays details on a Focus.
|
||||
*
|
||||
* @return array|\Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
|
||||
*/
|
||||
public function viewAction(Request $request, $objectId)
|
||||
{
|
||||
return $this->viewStandard($request, $objectId, 'webhook', 'webhook', null, 'item');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone an entity.
|
||||
*
|
||||
* @param int $objectId
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
|
||||
*/
|
||||
public function cloneAction(Request $request, $objectId)
|
||||
{
|
||||
return parent::cloneStandard($request, $objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the entity.
|
||||
*
|
||||
* @param int $objectId
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
|
||||
*/
|
||||
public function deleteAction(Request $request, $objectId)
|
||||
{
|
||||
return parent::deleteStandard($request, $objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a group of entities.
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
|
||||
*/
|
||||
public function batchDeleteAction(Request $request)
|
||||
{
|
||||
return parent::batchDeleteStandard($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\Extension;
|
||||
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
|
||||
|
||||
class MauticWebhookExtension extends Extension
|
||||
{
|
||||
/**
|
||||
* @param mixed[] $configs
|
||||
*/
|
||||
public function load(array $configs, ContainerBuilder $container): void
|
||||
{
|
||||
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
|
||||
$loader->load('services.php');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
|
||||
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
|
||||
|
||||
class Event
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
private $webhook;
|
||||
|
||||
/**
|
||||
* @var ArrayCollection<int, WebhookQueue>
|
||||
*/
|
||||
private $queues;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $eventType;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->queues = new ArrayCollection();
|
||||
}
|
||||
|
||||
public static function loadMetadata(ORM\ClassMetadata $metadata): void
|
||||
{
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
$builder->setTable('webhook_events')
|
||||
->setCustomRepositoryClass(EventRepository::class);
|
||||
|
||||
$builder->addId();
|
||||
|
||||
$builder->createManyToOne('webhook', 'Webhook')
|
||||
->inversedBy('events')
|
||||
->cascadeDetach()
|
||||
->cascadeMerge()
|
||||
->addJoinColumn('webhook_id', 'id', false, false, 'CASCADE')
|
||||
->build();
|
||||
|
||||
$builder->createOneToMany('queues', 'WebhookQueue')
|
||||
->mappedBy('event')
|
||||
->cascadeDetach()
|
||||
->cascadeMerge()
|
||||
->fetchExtraLazy()
|
||||
->build();
|
||||
|
||||
$builder->createField('eventType', 'string')
|
||||
->columnName('event_type')
|
||||
->length(50)
|
||||
->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the metadata for API usage.
|
||||
*/
|
||||
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
|
||||
{
|
||||
$metadata->setGroupPrefix('event')
|
||||
->addListProperties(
|
||||
[
|
||||
'eventType',
|
||||
]
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Webhook
|
||||
*/
|
||||
public function getWebhook()
|
||||
{
|
||||
return $this->webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function setWebhook(Webhook $webhook)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function getEventType()
|
||||
{
|
||||
return $this->eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $eventType
|
||||
*/
|
||||
public function setEventType($eventType)
|
||||
{
|
||||
$this->eventType = $eventType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection $queues
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function setQueues($queues)
|
||||
{
|
||||
$this->queues = $queues;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ArrayCollection
|
||||
*/
|
||||
public function getQueues()
|
||||
{
|
||||
return $this->queues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use Mautic\CoreBundle\Entity\CommonRepository;
|
||||
|
||||
/**
|
||||
* @extends CommonRepository<Event>
|
||||
*/
|
||||
class EventRepository extends CommonRepository
|
||||
{
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getEntitiesByEventType($type)
|
||||
{
|
||||
$alias = $this->getTableAlias();
|
||||
$q = $this->createQueryBuilder($alias)
|
||||
->leftJoin($alias.'.webhook', 'u');
|
||||
|
||||
$q->where(
|
||||
$q->expr()->eq($alias.'.eventType', ':type')
|
||||
)->setParameter('type', $type);
|
||||
|
||||
// only find published webhooks
|
||||
$q->andWhere($q->expr()->eq('u.isPublished', ':published'))
|
||||
->setParameter('published', 1);
|
||||
|
||||
return $q->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
|
||||
|
||||
class Log
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
private $webhook;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $statusCode;
|
||||
|
||||
/**
|
||||
* @var \DateTimeInterface
|
||||
*/
|
||||
private $dateAdded;
|
||||
|
||||
/**
|
||||
* @var float|null
|
||||
*/
|
||||
private $runtime;
|
||||
|
||||
private ?string $note = null;
|
||||
|
||||
public static function loadMetadata(ClassMetadata $metadata): void
|
||||
{
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
$builder->setTable('webhook_logs')
|
||||
->setCustomRepositoryClass(LogRepository::class)
|
||||
->addIndex(['webhook_id', 'date_added'], 'webhook_id_date_added')
|
||||
->addId();
|
||||
|
||||
$builder->createManyToOne('webhook', 'Webhook')
|
||||
->inversedBy('logs')
|
||||
->addJoinColumn('webhook_id', 'id', false, false, 'CASCADE')
|
||||
->build();
|
||||
|
||||
$builder->createField('statusCode', Types::STRING)
|
||||
->columnName('status_code')
|
||||
->length(50)
|
||||
->build();
|
||||
|
||||
$builder->addNullableField('dateAdded', Types::DATETIME_MUTABLE, 'date_added');
|
||||
$builder->addNullableField('note', Types::STRING);
|
||||
$builder->addNullableField('runtime', Types::FLOAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Webhook
|
||||
*/
|
||||
public function getWebhook()
|
||||
{
|
||||
return $this->webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Log
|
||||
*/
|
||||
public function setWebhook(Webhook $webhook)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function getStatusCode()
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $statusCode
|
||||
*
|
||||
* @return Log
|
||||
*/
|
||||
public function setStatusCode($statusCode)
|
||||
{
|
||||
$this->statusCode = $statusCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*/
|
||||
public function getDateAdded()
|
||||
{
|
||||
return $this->dateAdded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Log
|
||||
*/
|
||||
public function setDateAdded(\DateTime $dateAdded)
|
||||
{
|
||||
$this->dateAdded = $dateAdded;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNote(): ?string
|
||||
{
|
||||
return $this->note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips tags and keeps first 191 characters so it would fit in the varchar 191 limit.
|
||||
*/
|
||||
public function setNote(?string $note): self
|
||||
{
|
||||
$this->note = $note ? substr(strip_tags(iconv('UTF-8', 'UTF-8//IGNORE', $note)), 0, 190) : $note;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return float
|
||||
*/
|
||||
public function getRuntime()
|
||||
{
|
||||
return $this->runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float $runtime
|
||||
*
|
||||
* @return Log
|
||||
*/
|
||||
public function setRuntime($runtime)
|
||||
{
|
||||
$this->runtime = round($runtime, 2);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Mautic\CoreBundle\Entity\CommonRepository;
|
||||
|
||||
/**
|
||||
* @extends CommonRepository<Log>
|
||||
*/
|
||||
class LogRepository extends CommonRepository
|
||||
{
|
||||
private const LOG_DELETE_BATCH_SIZE = 5000;
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function getWebhooksBasedOnLogLimit(int $logMaxLimit): array
|
||||
{
|
||||
$qb = $this->_em->getConnection()->createQueryBuilder();
|
||||
$qb->select('webhook_id')
|
||||
->from(MAUTIC_TABLE_PREFIX.'webhook_logs', $this->getTableAlias())
|
||||
->groupBy('webhook_id')
|
||||
->having('count(id) > :logMaxLimit')
|
||||
->setParameter('logMaxLimit', $logMaxLimit);
|
||||
|
||||
return array_map(
|
||||
static fn ($row): int => (int) $row['webhook_id'],
|
||||
$qb->executeQuery()->fetchAllAssociative()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retains a rolling number of log records for a webhook id.
|
||||
*/
|
||||
public function removeLimitExceedLogs(int $webHookId, int $logMax): int
|
||||
{
|
||||
$deletedLogs = 0;
|
||||
$table_name = $this->getTableName();
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
|
||||
$id = $conn->createQueryBuilder()
|
||||
->select('id')
|
||||
->from($table_name)
|
||||
->where('webhook_id = '.$webHookId)
|
||||
->orderBy('id', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->setFirstResult($logMax) // if log max limit is 1000 then it will fetch id of 1001'th record from last and we will delete all log which have id less than or equal to this id.
|
||||
->executeQuery()->fetchOne();
|
||||
|
||||
if ($id) {
|
||||
$sql = "DELETE FROM {$table_name} WHERE webhook_id = (?) and id <= (?) LIMIT ".self::LOG_DELETE_BATCH_SIZE;
|
||||
while ($rows = $conn->executeStatement($sql, [$webHookId, $id], [ParameterType::INTEGER, ParameterType::INTEGER])) {
|
||||
$deletedLogs += $rows;
|
||||
}
|
||||
}
|
||||
|
||||
return $deletedLogs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets assume that all HTTP status codes 2** are a success.
|
||||
* This method will count the latest success codes until the $limit
|
||||
* and divide them with the all requests until the limit.
|
||||
*
|
||||
* 0 = 100% responses failed
|
||||
* 1 = 100% responses are successful
|
||||
* null = no log rows yet
|
||||
*
|
||||
* @param int $webhookId
|
||||
* @param int $limit
|
||||
*
|
||||
* @return float|null
|
||||
*/
|
||||
public function getSuccessVsErrorStatusCodeRatio($webhookId, $limit)
|
||||
{
|
||||
// Generate query to select last X = $limit rows
|
||||
$selectqb = $this->_em->getConnection()->createQueryBuilder();
|
||||
$selectqb->select('*')
|
||||
->from(MAUTIC_TABLE_PREFIX.'webhook_logs', $this->getTableAlias())
|
||||
->where($this->getTableAlias().'.webhook_id = :webhookId')
|
||||
->setFirstResult(0)
|
||||
->setMaxResults($limit)
|
||||
->orderBy($this->getTableAlias().'.date_added', 'DESC');
|
||||
|
||||
// Count all responses
|
||||
$countAllQb = $this->_em->getConnection()->createQueryBuilder();
|
||||
$countAllQb->select('COUNT('.$this->getTableAlias().'.id) AS thecount')
|
||||
->from(sprintf('(%s)', $selectqb->getSQL()), $this->getTableAlias())
|
||||
->setParameter('webhookId', $webhookId);
|
||||
|
||||
$result = $countAllQb->executeQuery()->fetchAssociative();
|
||||
|
||||
if (isset($result['thecount'])) {
|
||||
$allCount = (int) $result['thecount'];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Count successful responses
|
||||
$countSuccessQb = $this->_em->getConnection()->createQueryBuilder();
|
||||
$countSuccessQb->select('COUNT('.$this->getTableAlias().'.id) AS thecount')
|
||||
->from(sprintf('(%s)', $selectqb->getSQL()), $this->getTableAlias())
|
||||
->andWhere($countSuccessQb->expr()->gte($this->getTableAlias().'.status_code', 200))
|
||||
->andWhere($countSuccessQb->expr()->lt($this->getTableAlias().'.status_code', 300))
|
||||
->setParameter('webhookId', $webhookId);
|
||||
|
||||
$result = $countSuccessQb->executeQuery()->fetchAssociative();
|
||||
|
||||
if (isset($result['thecount'])) {
|
||||
$successCount = (int) $result['thecount'];
|
||||
}
|
||||
|
||||
if (!empty($allCount) && isset($successCount)) {
|
||||
return $successCount / $allCount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use Doctrine\Common\Collections\Order;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
|
||||
use Mautic\CategoryBundle\Entity\Category;
|
||||
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
|
||||
use Mautic\CoreBundle\Entity\FormEntity;
|
||||
use Mautic\CoreBundle\Entity\SkipModifiedInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Mapping\ClassMetadata;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Webhooks',
|
||||
operations: [
|
||||
new GetCollection(uriTemplate: '/webhooks', security: "is_granted('webhook:webhooks:viewown')"),
|
||||
new Post(uriTemplate: '/webhooks', security: "is_granted('webhook:webhooks:create')"),
|
||||
new Get(uriTemplate: '/webhooks/{id}', security: "is_granted('webhook:webhooks:viewown')"),
|
||||
new Put(uriTemplate: '/webhooks/{id}', security: "is_granted('webhook:webhooks:editown')"),
|
||||
new Patch(uriTemplate: '/webhooks/{id}', security: "is_granted('webhook:webhooks:editother')"),
|
||||
new Delete(uriTemplate: '/webhooks/{id}', security: "is_granted('webhook:webhooks:deleteown')"),
|
||||
],
|
||||
normalizationContext: [
|
||||
'groups' => ['webhook:read'],
|
||||
'swagger_definition_name' => 'Read',
|
||||
'api_included' => ['category'],
|
||||
],
|
||||
denormalizationContext: [
|
||||
'groups' => ['webhook:write'],
|
||||
'swagger_definition_name' => 'Write',
|
||||
]
|
||||
)]
|
||||
class Webhook extends FormEntity implements SkipModifiedInterface
|
||||
{
|
||||
public const LOGS_DISPLAY_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* @var ?int
|
||||
*/
|
||||
#[Groups(['webhook:read'])]
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $name;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $description;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $webhookUrl;
|
||||
|
||||
/**
|
||||
* @var ?string
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $secret;
|
||||
|
||||
/**
|
||||
* @var Category|null
|
||||
**/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $category;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Event>
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $events;
|
||||
|
||||
/**
|
||||
* @var ArrayCollection<int, Log>
|
||||
*/
|
||||
private $logs;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $removedEvents = [];
|
||||
|
||||
/**
|
||||
* @var mixed[]
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $payload;
|
||||
|
||||
/**
|
||||
* Holds a simplified array of events, just an array of event types.
|
||||
* It's used for API serializaiton.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $triggers = [];
|
||||
|
||||
/**
|
||||
* ASC or DESC order for fetching order of the events when queue mode is on.
|
||||
* Null means use the global default.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
#[Groups(['webhook:read', 'webhook:write'])]
|
||||
private $eventsOrderbyDir;
|
||||
|
||||
private ?\DateTimeImmutable $markedUnhealthyAt = null;
|
||||
private ?\DateTimeImmutable $unHealthySince = null;
|
||||
private ?\DateTimeImmutable $lastNotificationSentAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->events = new ArrayCollection();
|
||||
$this->logs = new ArrayCollection();
|
||||
}
|
||||
|
||||
public static function loadMetadata(ORM\ClassMetadata $metadata): void
|
||||
{
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
$builder->setTable('webhooks')
|
||||
->setCustomRepositoryClass(WebhookRepository::class);
|
||||
|
||||
$builder->addIdColumns();
|
||||
|
||||
$builder->addCategory();
|
||||
|
||||
$builder->createOneToMany('events', 'Event')
|
||||
->orphanRemoval()
|
||||
->setIndexBy('eventType')
|
||||
->mappedBy('webhook')
|
||||
->cascadePersist()
|
||||
->cascadeMerge()
|
||||
->cascadeDetach()
|
||||
->build();
|
||||
|
||||
$builder->createOneToMany('logs', 'Log')->setOrderBy(['dateAdded' => Order::Descending->value])
|
||||
->fetchExtraLazy()
|
||||
->mappedBy('webhook')
|
||||
->cascadePersist()
|
||||
->cascadeMerge()
|
||||
->cascadeDetach()
|
||||
->build();
|
||||
|
||||
$builder->addNamedField('webhookUrl', Types::TEXT, 'webhook_url');
|
||||
$builder->addField('secret', Types::STRING);
|
||||
$builder->addNullableField('eventsOrderbyDir', Types::STRING, 'events_orderby_dir');
|
||||
$builder->addNullableField('markedUnhealthyAt', Types::DATETIME_IMMUTABLE, 'marked_unhealthy_at');
|
||||
$builder->addNullableField('unHealthySince', Types::DATETIME_IMMUTABLE, 'unhealthy_since');
|
||||
$builder->addNullableField('lastNotificationSentAt', Types::DATETIME_IMMUTABLE, 'last_notification_sent_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the metadata for API usage.
|
||||
*/
|
||||
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
|
||||
{
|
||||
$metadata->setGroupPrefix('hook')
|
||||
->addListProperties(
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'webhookUrl',
|
||||
'secret',
|
||||
'eventsOrderbyDir',
|
||||
'category',
|
||||
'triggers',
|
||||
]
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
public static function loadValidatorMetadata(ClassMetadata $metadata): void
|
||||
{
|
||||
$metadata->addPropertyConstraint(
|
||||
'name',
|
||||
new NotBlank(
|
||||
[
|
||||
'message' => 'mautic.core.name.required',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$metadata->addPropertyConstraint(
|
||||
'webhookUrl',
|
||||
new Assert\Url(
|
||||
[
|
||||
'message' => 'mautic.core.valid_url_required',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$metadata->addPropertyConstraint(
|
||||
'webhookUrl',
|
||||
new NotBlank(
|
||||
[
|
||||
'message' => 'mautic.core.valid_url_required',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$metadata->addPropertyConstraint(
|
||||
'eventsOrderbyDir',
|
||||
new Assert\Choice(
|
||||
[
|
||||
null,
|
||||
Order::Ascending->value,
|
||||
Order::Descending->value,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
*
|
||||
* @return Webhook
|
||||
*/
|
||||
public function setName($name)
|
||||
{
|
||||
$this->isChanged('name', $name);
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $description
|
||||
*
|
||||
* @return Webhook
|
||||
*/
|
||||
public function setDescription($description)
|
||||
{
|
||||
$this->isChanged('description', $description);
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getDescription()
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $webhookUrl
|
||||
*
|
||||
* @return Webhook
|
||||
*/
|
||||
public function setWebhookUrl($webhookUrl)
|
||||
{
|
||||
$this->isChanged('webhookUrl', $webhookUrl);
|
||||
$this->webhookUrl = $webhookUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getWebhookUrl()
|
||||
{
|
||||
return $this->webhookUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $secret
|
||||
*
|
||||
* @return Webhook
|
||||
*/
|
||||
public function setSecret($secret)
|
||||
{
|
||||
$this->isChanged('secret', $secret);
|
||||
$this->secret = $secret;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?string
|
||||
*/
|
||||
public function getSecret()
|
||||
{
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Webhook
|
||||
*/
|
||||
public function setCategory(?Category $category = null)
|
||||
{
|
||||
$this->isChanged('category', $category);
|
||||
$this->category = $category;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Category
|
||||
*/
|
||||
public function getCategory()
|
||||
{
|
||||
return $this->category;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Event>
|
||||
*/
|
||||
public function getEvents()
|
||||
{
|
||||
return $this->events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Event> $events
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setEvents($events)
|
||||
{
|
||||
$this->isChanged('events', $events);
|
||||
|
||||
$this->events = $events;
|
||||
|
||||
foreach ($events as $event) {
|
||||
$event->setWebhook($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This builds a simple array with subscribed events.
|
||||
*/
|
||||
public function buildTriggers(): void
|
||||
{
|
||||
foreach ($this->events as $event) {
|
||||
$this->triggers[] = $event->getEventType();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the array of triggers and builds events from them if they don't exist already.
|
||||
*/
|
||||
public function setTriggers(array $triggers): void
|
||||
{
|
||||
foreach ($triggers as $key) {
|
||||
$this->addTrigger($key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a trigger (event type) and builds the Event object form it if it doesn't exist already.
|
||||
*
|
||||
* @param string $key
|
||||
*/
|
||||
public function addTrigger($key): bool
|
||||
{
|
||||
if ($this->eventExists($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$event = new Event();
|
||||
$event->setEventType($key);
|
||||
$event->setWebhook($this);
|
||||
$this->addEvent($event);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event exists comared to its type.
|
||||
*
|
||||
* @param string $key
|
||||
*/
|
||||
public function eventExists($key): bool
|
||||
{
|
||||
foreach ($this->events as $event) {
|
||||
if ($event->getEventType() === $key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addEvent(Event $event)
|
||||
{
|
||||
$this->isChanged('events', $event);
|
||||
|
||||
$this->events[] = $event;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function removeEvent(Event $event)
|
||||
{
|
||||
$this->isChanged('events', $event);
|
||||
$this->removedEvents[] = $event;
|
||||
$this->events->removeElement($event);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $eventsOrderbyDir
|
||||
*/
|
||||
public function setEventsOrderbyDir($eventsOrderbyDir)
|
||||
{
|
||||
$this->isChanged('eventsOrderbyDir', $eventsOrderbyDir);
|
||||
$this->eventsOrderbyDir = $eventsOrderbyDir;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getEventsOrderbyDir()
|
||||
{
|
||||
return $this->eventsOrderbyDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get log entities.
|
||||
*
|
||||
* @return ArrayCollection<int,Log>
|
||||
*/
|
||||
public function getLogs()
|
||||
{
|
||||
return $this->logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int,Log>
|
||||
*/
|
||||
public function getLimitedLogs(): Collection
|
||||
{
|
||||
$criteria = Criteria::create()
|
||||
->setMaxResults(self::LOGS_DISPLAY_LIMIT);
|
||||
|
||||
return $this->logs->matching($criteria);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection<int,Log> $logs
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addLogs($logs)
|
||||
{
|
||||
$this->logs = $logs;
|
||||
|
||||
/** @var Log $log */
|
||||
foreach ($logs as $log) {
|
||||
$log->setWebhook($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function addLog(Log $log)
|
||||
{
|
||||
$this->logs[] = $log;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function removeLog(Log $log)
|
||||
{
|
||||
$this->logs->removeElement($log);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getPayload()
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Webhook
|
||||
*/
|
||||
public function setPayload($payload)
|
||||
{
|
||||
$this->payload = $payload;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function wasModifiedRecently(): bool
|
||||
{
|
||||
$dateModified = $this->getDateModified();
|
||||
|
||||
if (null === $dateModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$aWhileBack = (new \DateTime())->modify('-2 days');
|
||||
|
||||
if ($dateModified < $aWhileBack) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $prop
|
||||
*/
|
||||
protected function isChanged($prop, $val)
|
||||
{
|
||||
$getter = 'get'.ucfirst($prop);
|
||||
$current = $this->$getter();
|
||||
if ('category' == $prop) {
|
||||
$currentId = ($current) ? $current->getId() : '';
|
||||
$newId = ($val) ? $val->getId() : null;
|
||||
if ($currentId != $newId) {
|
||||
$this->changes[$prop] = [$currentId, $newId];
|
||||
}
|
||||
} elseif ('events' == $prop) {
|
||||
$this->changes[$prop] = [];
|
||||
} elseif ($current != $val) {
|
||||
$this->changes[$prop] = [$current, $val];
|
||||
} else {
|
||||
parent::isChanged($prop, $val);
|
||||
}
|
||||
}
|
||||
|
||||
public function getMarkedUnhealthyAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->markedUnhealthyAt;
|
||||
}
|
||||
|
||||
public function setMarkedUnhealthyAt(?\DateTimeImmutable $markedUnhealthyAt): Webhook
|
||||
{
|
||||
$this->isChanged('markedUnhealthyAt', $markedUnhealthyAt);
|
||||
$this->markedUnhealthyAt = $markedUnhealthyAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUnHealthySince(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->unHealthySince;
|
||||
}
|
||||
|
||||
public function setUnHealthySince(?\DateTimeImmutable $unHealthySince): self
|
||||
{
|
||||
$this->unHealthySince = $unHealthySince;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastNotificationSentAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->lastNotificationSentAt;
|
||||
}
|
||||
|
||||
public function setLastNotificationSentAt(?\DateTimeImmutable $lastNotificationSentAt): self
|
||||
{
|
||||
$this->lastNotificationSentAt = $lastNotificationSentAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not update modified_by and date_modified fields if only DNC or manipulator was changed.
|
||||
* Avoid unnecessary update queries.
|
||||
*/
|
||||
public function shouldSkipSettingModifiedProperties(): bool
|
||||
{
|
||||
$changes = $this->changes;
|
||||
|
||||
unset($changes['markedUnhealthyAt']);
|
||||
unset($changes['unHealthySince']);
|
||||
unset($changes['lastNotificationSentAt']);
|
||||
|
||||
return 0 === count($changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
|
||||
|
||||
class WebhookQueue
|
||||
{
|
||||
public const TABLE_NAME = 'webhook_queue';
|
||||
|
||||
private ?string $id = null;
|
||||
|
||||
private ?Webhook $webhook;
|
||||
|
||||
private ?\DateTime $dateAdded;
|
||||
|
||||
private ?\DateTimeImmutable $dateModified; // @phpstan-ignore-line (BC: plain payload is fetched by ORM)
|
||||
|
||||
/**
|
||||
* @var string|resource|null
|
||||
*/
|
||||
private $payloadCompressed;
|
||||
|
||||
private ?Event $event;
|
||||
|
||||
private int $retries = 0;
|
||||
|
||||
public static function loadMetadata(ORM\ClassMetadata $metadata): void
|
||||
{
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
$builder->setTable(WebhookQueue::TABLE_NAME)
|
||||
->setCustomRepositoryClass(WebhookQueueRepository::class);
|
||||
$builder->addBigIntIdField();
|
||||
$builder->createManyToOne('webhook', 'Webhook')
|
||||
->addJoinColumn('webhook_id', 'id', false, false, 'CASCADE')
|
||||
->build();
|
||||
$builder->addNullableField('dateAdded', Types::DATETIME_MUTABLE, 'date_added');
|
||||
$builder->addNullableField('dateModified', Types::DATETIME_IMMUTABLE, 'date_modified');
|
||||
$builder->createField('payloadCompressed', Types::BLOB)
|
||||
->columnName('payload_compressed')
|
||||
->nullable()
|
||||
->length(MySQLPlatform::LENGTH_LIMIT_MEDIUMBLOB)
|
||||
->build();
|
||||
$builder->createManyToOne('event', 'Event')
|
||||
->inversedBy('queues')
|
||||
->addJoinColumn('event_id', 'id', false, false, 'CASCADE')
|
||||
->build();
|
||||
$builder->createField('retries', Types::SMALLINT)
|
||||
->columnName('retries')
|
||||
->option('unsigned', true)
|
||||
->option('default', 0)
|
||||
->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Webhook|null
|
||||
*/
|
||||
public function getWebhook()
|
||||
{
|
||||
return $this->webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Webhook|null $webhook
|
||||
*
|
||||
* @return WebhookQueue
|
||||
*/
|
||||
public function setWebhook($webhook)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface|null
|
||||
*/
|
||||
public function getDateAdded()
|
||||
{
|
||||
return $this->dateAdded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime|null $dateAdded
|
||||
*
|
||||
* @return WebhookQueue
|
||||
*/
|
||||
public function setDateAdded($dateAdded)
|
||||
{
|
||||
$this->dateAdded = $dateAdded;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getPayload()
|
||||
{
|
||||
if (null === $this->payloadCompressed) {
|
||||
// no payload is set
|
||||
return null;
|
||||
}
|
||||
|
||||
$payloadCompressed = $this->payloadCompressed;
|
||||
|
||||
if (is_resource($payloadCompressed)) {
|
||||
// compressed payload is fetched by ORM
|
||||
$payloadCompressed = stream_get_contents($this->payloadCompressed);
|
||||
}
|
||||
|
||||
return gzuncompress($payloadCompressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $payload
|
||||
*
|
||||
* @return WebhookQueue
|
||||
*/
|
||||
public function setPayload($payload)
|
||||
{
|
||||
$this->payloadCompressed = gzcompress($payload, 9);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Event|null
|
||||
*/
|
||||
public function getEvent()
|
||||
{
|
||||
return $this->event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Event|null $event
|
||||
*
|
||||
* @return WebhookQueue
|
||||
*/
|
||||
public function setEvent($event)
|
||||
{
|
||||
$this->event = $event;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRetries(): int
|
||||
{
|
||||
return $this->retries;
|
||||
}
|
||||
|
||||
public function setRetries(int $retries): WebhookQueue
|
||||
{
|
||||
$this->retries = $retries;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDateModified(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->dateModified;
|
||||
}
|
||||
|
||||
public function setDateModified(?\DateTimeImmutable $dateModified): WebhookQueue
|
||||
{
|
||||
$this->dateModified = $dateModified;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Mautic\CoreBundle\Entity\CommonRepository;
|
||||
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
||||
|
||||
/**
|
||||
* @extends CommonRepository<WebhookQueue>
|
||||
*/
|
||||
class WebhookQueueRepository extends CommonRepository
|
||||
{
|
||||
/**
|
||||
* Deletes all the webhook queues by ID.
|
||||
*
|
||||
* @param $idList array of webhookqueue IDs
|
||||
*/
|
||||
public function deleteQueuesById(array $idList): void
|
||||
{
|
||||
// don't process the list if there are no items in it
|
||||
if (!count($idList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb = $this->_em->getConnection()->createQueryBuilder();
|
||||
$qb->delete(MAUTIC_TABLE_PREFIX.'webhook_queue')
|
||||
->where(
|
||||
$qb->expr()->in('id', $idList)
|
||||
)
|
||||
->executeStatement();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $idList
|
||||
*/
|
||||
public function incrementRetryCount(array $idList): void
|
||||
{
|
||||
if (!count($idList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb = $this->_em->getConnection()->createQueryBuilder();
|
||||
$qb->update(MAUTIC_TABLE_PREFIX.'webhook_queue')
|
||||
->where(
|
||||
$qb->expr()->in('id', ':ids')
|
||||
)
|
||||
->set('retries', 'retries + 1')
|
||||
->set('date_modified', ':date_modified')
|
||||
->setParameter('ids', $idList, ArrayParameterType::INTEGER)
|
||||
->setParameter('date_modified', (new \DateTimeImmutable())->format(DateTimeHelper::FORMAT_DB))
|
||||
->executeStatement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is webhook to process.
|
||||
*/
|
||||
public function exists(int $id): bool
|
||||
{
|
||||
$qb = $this->_em->getConnection()->createQueryBuilder();
|
||||
$result = $qb->select($this->getTableAlias().'.id')
|
||||
->from(MAUTIC_TABLE_PREFIX.'webhook_queue', $this->getTableAlias())
|
||||
->where($this->getTableAlias().'.webhook_id = :id')
|
||||
->setParameter('id', $id)
|
||||
->setMaxResults(1)
|
||||
->executeQuery()
|
||||
->fetchOne();
|
||||
|
||||
return (bool) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Entity;
|
||||
|
||||
use Mautic\CoreBundle\Entity\CommonRepository;
|
||||
|
||||
/**
|
||||
* @extends CommonRepository<Webhook>
|
||||
*/
|
||||
class WebhookRepository extends CommonRepository
|
||||
{
|
||||
/**
|
||||
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
|
||||
*/
|
||||
protected function addCatchAllWhereClause($q, $filter): array
|
||||
{
|
||||
return $this->addStandardCatchAllWhereClause($q, $filter, ['e.name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
|
||||
*/
|
||||
protected function addSearchCommandWhereClause($q, $filter): array
|
||||
{
|
||||
return $this->addStandardSearchCommandWhereClause($q, $filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getSearchCommands(): array
|
||||
{
|
||||
return $this->getStandardSearchCommands();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array<string>>
|
||||
*/
|
||||
protected function getDefaultOrder(): array
|
||||
{
|
||||
return [
|
||||
[$this->getTableAlias().'.name', 'ASC'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Event;
|
||||
|
||||
use Symfony\Component\Process\Exception\InvalidArgumentException;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class WebhookBuilderEvent extends Event
|
||||
{
|
||||
private array $events = [];
|
||||
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event for the event list.
|
||||
*
|
||||
* @param string $key - a unique identifier; it is recommended that it be namespaced i.e. lead.mytrigger
|
||||
* @param array $event - can contain the following keys:
|
||||
* 'label' => (required) what to display in the list
|
||||
* 'description' => (optional) short description of event
|
||||
*/
|
||||
public function addEvent($key, array $event): void
|
||||
{
|
||||
if (array_key_exists($key, $this->events)) {
|
||||
throw new InvalidArgumentException("The key, '$key' is already used by another webhook event. Please use a different key.");
|
||||
}
|
||||
|
||||
$event['label'] = $this->translator->trans($event['label']);
|
||||
$event['description'] = (isset($event['description'])) ? $this->translator->trans($event['description']) : '';
|
||||
|
||||
$this->events[$key] = $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook events.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getEvents()
|
||||
{
|
||||
static $sorted = false;
|
||||
|
||||
if (empty($sorted)) {
|
||||
uasort($this->events, fn ($a, $b): int => strnatcasecmp(
|
||||
$a['label'], $b['label']));
|
||||
$sorted = true;
|
||||
}
|
||||
|
||||
return $this->events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Event;
|
||||
|
||||
use Mautic\CoreBundle\Event\CommonEvent;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
|
||||
class WebhookEvent extends CommonEvent
|
||||
{
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
protected $entity;
|
||||
|
||||
/**
|
||||
* @param bool $isNew
|
||||
* @param string $reason
|
||||
*/
|
||||
public function __construct(
|
||||
Webhook $webhook,
|
||||
protected $isNew = false,
|
||||
private $reason = '',
|
||||
) {
|
||||
$this->entity = $webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Webhook entity.
|
||||
*
|
||||
* @return Webhook
|
||||
*/
|
||||
public function getWebhook()
|
||||
{
|
||||
return $this->entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Webhook entity.
|
||||
*/
|
||||
public function setWebhook(Webhook $webhook): void
|
||||
{
|
||||
$this->entity = $webhook;
|
||||
}
|
||||
|
||||
public function setReason($reason): void
|
||||
{
|
||||
$this->reason = $reason;
|
||||
}
|
||||
|
||||
public function getReason(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Event;
|
||||
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class WebhookNotificationEvent extends Event
|
||||
{
|
||||
private bool $canSend = true;
|
||||
|
||||
public function __construct(private Webhook $webhook)
|
||||
{
|
||||
}
|
||||
|
||||
public function getWebhook(): Webhook
|
||||
{
|
||||
return $this->webhook;
|
||||
}
|
||||
|
||||
public function setCanSend(bool $canSend): void
|
||||
{
|
||||
$this->canSend = $canSend;
|
||||
}
|
||||
|
||||
public function canSend(): bool
|
||||
{
|
||||
return $this->canSend;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Event;
|
||||
|
||||
use Mautic\CoreBundle\Event\CommonEvent;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueue;
|
||||
|
||||
class WebhookQueueEvent extends CommonEvent
|
||||
{
|
||||
/**
|
||||
* @param bool $isNew
|
||||
*/
|
||||
public function __construct(
|
||||
WebhookQueue $webhookQueue,
|
||||
protected Webhook $webhook,
|
||||
$isNew = false,
|
||||
) {
|
||||
$this->entity = $webhookQueue;
|
||||
$this->isNew = $isNew;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebhookQueue entity.
|
||||
*
|
||||
* @return WebhookQueue
|
||||
*/
|
||||
public function getWebhookQueue()
|
||||
{
|
||||
return $this->entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the WebhookQueue entity.
|
||||
*/
|
||||
public function setWebhookQueue(WebhookQueue $webhookQueue): void
|
||||
{
|
||||
$this->entity = $webhookQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Webhook entity.
|
||||
*
|
||||
* @return Webhook
|
||||
*/
|
||||
public function getWebhook()
|
||||
{
|
||||
return $this->webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Webhook entity.
|
||||
*/
|
||||
public function setWebhook(Webhook $webhook): void
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Event;
|
||||
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class WebhookRequestEvent extends Event
|
||||
{
|
||||
public function __construct(
|
||||
private Lead $contact,
|
||||
private string $url,
|
||||
private array $headers,
|
||||
private array $payload,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
public function setUrl(string $url): void
|
||||
{
|
||||
$this->url = $url;
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
public function setHeaders(array $headers): void
|
||||
{
|
||||
$this->headers = $headers;
|
||||
}
|
||||
|
||||
public function getPayload(): array
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
public function setPayload(array $payload): void
|
||||
{
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
public function getContact(): Lead
|
||||
{
|
||||
return $this->contact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\EventListener;
|
||||
|
||||
use Mautic\CampaignBundle\CampaignEvents;
|
||||
use Mautic\CampaignBundle\Event as Events;
|
||||
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
|
||||
use Mautic\WebhookBundle\Form\Type\CampaignEventSendWebhookType;
|
||||
use Mautic\WebhookBundle\Helper\CampaignHelper;
|
||||
use Mautic\WebhookBundle\WebhookEvents;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
class CampaignSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CampaignHelper $campaignHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
|
||||
WebhookEvents::ON_CAMPAIGN_TRIGGER_ACTION => ['onCampaignTriggerAction', 0],
|
||||
];
|
||||
}
|
||||
|
||||
public function onCampaignTriggerAction(CampaignExecutionEvent $event): void
|
||||
{
|
||||
if ($event->checkContext('campaign.sendwebhook')) {
|
||||
try {
|
||||
$this->campaignHelper->fireWebhook($event->getConfig(), $event->getLead());
|
||||
$event->setResult(true);
|
||||
} catch (\Exception $e) {
|
||||
$event->setFailed($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event triggers and actions.
|
||||
*/
|
||||
public function onCampaignBuild(Events\CampaignBuilderEvent $event): void
|
||||
{
|
||||
$sendWebhookAction = [
|
||||
'label' => 'mautic.webhook.event.sendwebhook',
|
||||
'description' => 'mautic.webhook.event.sendwebhook_desc',
|
||||
'formType' => CampaignEventSendWebhookType::class,
|
||||
'formTypeCleanMasks' => 'clean',
|
||||
'eventName' => WebhookEvents::ON_CAMPAIGN_TRIGGER_ACTION,
|
||||
];
|
||||
$event->addAction('campaign.sendwebhook', $sendWebhookAction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\EventListener;
|
||||
|
||||
use Mautic\ConfigBundle\ConfigEvents;
|
||||
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
|
||||
use Mautic\WebhookBundle\Form\Type\ConfigType;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
class ConfigSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
ConfigEvents::CONFIG_ON_GENERATE => ['onConfigGenerate', 0],
|
||||
];
|
||||
}
|
||||
|
||||
public function onConfigGenerate(ConfigBuilderEvent $event): void
|
||||
{
|
||||
$event->addForm([
|
||||
'bundle' => 'WebhookBundle',
|
||||
'formAlias' => 'webhookconfig',
|
||||
'formType' => ConfigType::class,
|
||||
'formTheme' => '@MauticWebhook/FormTheme/Config/_config_webhookconfig_widget.html.twig',
|
||||
'parameters' => $event->getParametersFromConfig('MauticWebhookBundle'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\EventListener;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Mautic\CoreBundle\EventListener\CommonStatsSubscriber;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
|
||||
class StatsSubscriber extends CommonStatsSubscriber
|
||||
{
|
||||
public function __construct(CorePermissions $security, EntityManager $entityManager)
|
||||
{
|
||||
parent::__construct($security, $entityManager);
|
||||
$this->addRestrictedRepostories([Log::class], ['webhook' => 'webhook:webhooks']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\EventListener;
|
||||
|
||||
use Mautic\CoreBundle\Helper\IpLookupHelper;
|
||||
use Mautic\CoreBundle\Model\AuditLogModel;
|
||||
use Mautic\WebhookBundle\Event\WebhookEvent;
|
||||
use Mautic\WebhookBundle\Notificator\WebhookKillNotificator;
|
||||
use Mautic\WebhookBundle\WebhookEvents;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
class WebhookSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private IpLookupHelper $ipLookupHelper,
|
||||
private AuditLogModel $auditLogModel,
|
||||
private WebhookKillNotificator $webhookKillNotificator,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
WebhookEvents::WEBHOOK_POST_SAVE => ['onWebhookSave', 0],
|
||||
WebhookEvents::WEBHOOK_POST_DELETE => ['onWebhookDelete', 0],
|
||||
WebhookEvents::WEBHOOK_KILL => ['onWebhookKill', 0],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry to the audit log.
|
||||
*/
|
||||
public function onWebhookSave(WebhookEvent $event): void
|
||||
{
|
||||
$webhook = $event->getWebhook();
|
||||
|
||||
if ($details = $event->getChanges()) {
|
||||
$log = [
|
||||
'bundle' => 'webhook',
|
||||
'object' => 'webhook',
|
||||
'objectId' => $webhook->getId(),
|
||||
'action' => ($event->isNew()) ? 'create' : 'update',
|
||||
'details' => $details,
|
||||
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
|
||||
];
|
||||
$this->auditLogModel->writeToLog($log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a delete entry to the audit log.
|
||||
*/
|
||||
public function onWebhookDelete(WebhookEvent $event): void
|
||||
{
|
||||
$webhook = $event->getWebhook();
|
||||
$log = [
|
||||
'bundle' => 'webhook',
|
||||
'object' => 'webhook',
|
||||
'objectId' => $event->getWebhook()->deletedId,
|
||||
'action' => 'delete',
|
||||
'details' => ['name' => $webhook->getName()],
|
||||
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
|
||||
];
|
||||
$this->auditLogModel->writeToLog($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification about killed webhook.
|
||||
*/
|
||||
public function onWebhookKill(WebhookEvent $event): void
|
||||
{
|
||||
$this->webhookKillNotificator->send($event->getWebhook(), $event->getReason());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Exception;
|
||||
|
||||
class PrivateAddressException extends \Exception
|
||||
{
|
||||
private const DEFAULT_MESSAGE = 'Access to private addresses is not allowed.';
|
||||
|
||||
public function __construct(string $message = self::DEFAULT_MESSAGE, int $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Form\DataTransformer;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Symfony\Component\Form\DataTransformerInterface;
|
||||
|
||||
/**
|
||||
* @implements DataTransformerInterface<Collection<int, Event>, array<int, string>>
|
||||
*/
|
||||
class EventsToArrayTransformer implements DataTransformerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Webhook $webhook,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from the PersistentCollection of Event entities to a simple array.
|
||||
*
|
||||
* @param $events Collection<int, Event>
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function transform(mixed $events): mixed
|
||||
{
|
||||
$eventArray = [];
|
||||
foreach ($events as $event) {
|
||||
$eventArray[] = $event->getEventType();
|
||||
}
|
||||
|
||||
return $eventArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a simple array into a PersistentCollection of Event entities.
|
||||
*
|
||||
* @param $submittedArray array<int, string>
|
||||
*
|
||||
* @return Collection<int, Event>
|
||||
*/
|
||||
public function reverseTransform(mixed $submittedArray): mixed
|
||||
{
|
||||
// Get a list of existing events and types
|
||||
|
||||
$events = $this->webhook->getEvents();
|
||||
$eventTypes = $events->getKeys();
|
||||
|
||||
// Check to see what events have been removed
|
||||
$removed = array_diff($eventTypes, $submittedArray);
|
||||
foreach ($removed as $type) {
|
||||
$this->webhook->removeEvent($events[$type]);
|
||||
}
|
||||
|
||||
// Now check to see what events have been added
|
||||
$added = array_diff($submittedArray, $eventTypes);
|
||||
foreach ($added as $type) {
|
||||
// Create a new entity
|
||||
$event = new Event();
|
||||
$event->setWebhook($this->webhook)->setEventType($type);
|
||||
$events[] = $event;
|
||||
}
|
||||
|
||||
$this->webhook->setEvents($events);
|
||||
|
||||
return $events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Form\Type;
|
||||
|
||||
use Mautic\CoreBundle\Form\Type\SortableListType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<array<mixed>>
|
||||
*/
|
||||
class CampaignEventSendWebhookType extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add(
|
||||
'url',
|
||||
UrlType::class,
|
||||
[
|
||||
'label' => 'mautic.webhook.event.sendwebhook.url',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => ['class' => 'form-control'],
|
||||
'required' => true,
|
||||
'constraints' => [
|
||||
new NotBlank(
|
||||
[
|
||||
'message' => 'mautic.core.value.required',
|
||||
]
|
||||
),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'method',
|
||||
ChoiceType::class,
|
||||
[
|
||||
'choices' => [
|
||||
'GET' => 'get',
|
||||
'POST' => 'post',
|
||||
'PUT' => 'put',
|
||||
'PATCH' => 'patch',
|
||||
'DELETE' => 'delete',
|
||||
],
|
||||
'multiple' => false,
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'label' => 'mautic.webhook.event.sendwebhook.method',
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
],
|
||||
'placeholder' => false,
|
||||
'required' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'headers',
|
||||
SortableListType::class,
|
||||
[
|
||||
'required' => false,
|
||||
'label' => 'mautic.webhook.event.sendwebhook.headers',
|
||||
'option_required' => false,
|
||||
'with_labels' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'additional_data',
|
||||
SortableListType::class,
|
||||
[
|
||||
'required' => false,
|
||||
'label' => 'mautic.webhook.event.sendwebhook.data',
|
||||
'option_required' => false,
|
||||
'with_labels' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'timeout',
|
||||
NumberType::class,
|
||||
[
|
||||
'label' => 'mautic.webhook.event.sendwebhook.timeout',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
'postaddon_text' => $this->translator->trans('mautic.core.time.seconds'),
|
||||
],
|
||||
'data' => !empty($options['data']['timeout']) ? $options['data']['timeout'] : 10,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getBlockPrefix(): string
|
||||
{
|
||||
return 'campaignevent_sendwebhook';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Form\Type;
|
||||
|
||||
use Doctrine\Common\Collections\Order;
|
||||
use Mautic\CoreBundle\Form\DataTransformer\ArrayLinebreakTransformer;
|
||||
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<array<mixed>>
|
||||
*/
|
||||
class ConfigType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('queue_mode', ChoiceType::class, [
|
||||
'choices' => [
|
||||
'mautic.webhook.config.immediate_process' => 'immediate_process',
|
||||
'mautic.webhook.config.cron_process' => 'command_process',
|
||||
],
|
||||
'label' => 'mautic.webhook.config.form.queue.mode',
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
'tooltip' => 'mautic.webhook.config.form.queue.mode.tooltip',
|
||||
],
|
||||
'placeholder' => false,
|
||||
'constraints' => [
|
||||
new NotBlank(
|
||||
[
|
||||
'message' => 'mautic.core.value.required',
|
||||
]
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
$builder->add('events_orderby_dir', ChoiceType::class, [
|
||||
'choices' => [
|
||||
'mautic.webhook.config.event.orderby.chronological' => Order::Ascending->value,
|
||||
'mautic.webhook.config.event.orderby.reverse.chronological' => Order::Descending->value,
|
||||
],
|
||||
'label' => 'mautic.webhook.config.event.orderby',
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
'tooltip' => 'mautic.webhook.config.event.orderby.tooltip',
|
||||
],
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add(
|
||||
'webhook_email_details',
|
||||
YesNoButtonGroupType::class,
|
||||
[
|
||||
'label' => 'mautic.webhook.config.email.details',
|
||||
'data' => (bool) ($options['data']['webhook_email_details'] ?? null),
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
'tooltip' => 'mautic.webhook.config.email.details.tooltip',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
$builder->create(
|
||||
'webhook_allowed_private_addresses',
|
||||
TextareaType::class,
|
||||
[
|
||||
'label' => 'mautic.webhook.config.allowed_private_addresses',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
'tooltip' => 'mautic.webhook.config.allowed_private_addresses.tooltip',
|
||||
'rows' => 8,
|
||||
],
|
||||
'required' => false,
|
||||
]
|
||||
)->addViewTransformer(new ArrayLinebreakTransformer())
|
||||
);
|
||||
}
|
||||
|
||||
public function getBlockPrefix(): string
|
||||
{
|
||||
return 'webhookconfig';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Form\Type;
|
||||
|
||||
use Doctrine\Common\Collections\Order;
|
||||
use Mautic\CategoryBundle\Form\Type\CategoryListType;
|
||||
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
|
||||
use Mautic\CoreBundle\Form\Type\FormButtonsType;
|
||||
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
|
||||
use Mautic\CoreBundle\Helper\EncryptionHelper;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Form\DataTransformer\EventsToArrayTransformer;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ButtonType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\UrlType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<Webhook>
|
||||
*/
|
||||
class WebhookType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addEventSubscriber(new CleanFormSubscriber(['description' => 'strict_html']));
|
||||
|
||||
/** @var Webhook $webhook */
|
||||
$webhook = $builder->getData();
|
||||
|
||||
$builder->add(
|
||||
'name',
|
||||
TextType::class,
|
||||
[
|
||||
'label' => 'mautic.core.name',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => ['class' => 'form-control'],
|
||||
'required' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'description',
|
||||
TextareaType::class,
|
||||
[
|
||||
'label' => 'mautic.webhook.form.description',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'webhookUrl',
|
||||
UrlType::class,
|
||||
[
|
||||
'label' => 'mautic.webhook.form.webhook_url',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => ['class' => 'form-control'],
|
||||
'required' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'secret',
|
||||
TextType::class,
|
||||
[
|
||||
'label' => 'mautic.webhook.form.secret',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
'tooltip' => 'mautic.webhook.secret.tooltip',
|
||||
],
|
||||
'data' => $webhook->getSecret() ?? EncryptionHelper::generateKey(),
|
||||
'required' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$events = $options['events'];
|
||||
|
||||
$choices = [];
|
||||
foreach ($events as $type => $event) {
|
||||
$choices[$event['label']] = $type;
|
||||
}
|
||||
|
||||
$builder->add(
|
||||
'events',
|
||||
ChoiceType::class,
|
||||
[
|
||||
'choices' => $choices,
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'label' => 'mautic.webhook.form.webhook.events',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => ['class' => ''],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->get('events')->addModelTransformer(new EventsToArrayTransformer($options['data']));
|
||||
|
||||
$builder->add('buttons', FormButtonsType::class);
|
||||
|
||||
$builder->add(
|
||||
'sendTest',
|
||||
ButtonType::class,
|
||||
[
|
||||
'attr' => ['class' => 'btn btn-tertiary', 'onclick' => 'Mautic.sendHookTest(this)'],
|
||||
'label' => 'mautic.webhook.send.test.payload',
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'category',
|
||||
CategoryListType::class,
|
||||
[
|
||||
'bundle' => 'Webhook',
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add('isPublished', YesNoButtonGroupType::class);
|
||||
|
||||
$builder->add(
|
||||
'eventsOrderbyDir',
|
||||
ChoiceType::class,
|
||||
[
|
||||
'choices' => [
|
||||
'mautic.webhook.config.event.orderby.chronological' => Order::Ascending->value,
|
||||
'mautic.webhook.config.event.orderby.reverse.chronological' => Order::Descending->value,
|
||||
],
|
||||
'label' => 'mautic.webhook.config.event.orderby',
|
||||
'attr' => [
|
||||
'class' => 'form-control',
|
||||
'tooltip' => 'mautic.webhook.config.event.orderby.tooltip',
|
||||
],
|
||||
'placeholder' => 'mautic.core.form.default',
|
||||
'required' => false,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults(
|
||||
[
|
||||
'data_class' => Webhook::class,
|
||||
]
|
||||
);
|
||||
|
||||
$resolver->setDefined(['events']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Helper;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use GuzzleHttp\Client;
|
||||
use Mautic\CoreBundle\Helper\AbstractFormFieldHelper;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Helper\TokenHelper;
|
||||
use Mautic\LeadBundle\Model\CompanyModel;
|
||||
use Mautic\WebhookBundle\Event\WebhookRequestEvent;
|
||||
use Mautic\WebhookBundle\WebhookEvents;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class CampaignHelper
|
||||
{
|
||||
/**
|
||||
* Cached contact values in format [contact_id => [key1 => val1, key2 => val1]].
|
||||
*/
|
||||
private array $contactsValues = [];
|
||||
|
||||
public function __construct(
|
||||
protected Client $client,
|
||||
protected CompanyModel $companyModel,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the neccessary data transformations and then makes the HTTP request.
|
||||
*/
|
||||
public function fireWebhook(array $config, Lead $contact): void
|
||||
{
|
||||
$payload = $this->getPayload($config, $contact);
|
||||
$headers = $this->getHeaders($config, $contact);
|
||||
$url = rawurldecode(TokenHelper::findLeadTokens($config['url'], $this->getContactValues($contact), true));
|
||||
|
||||
$webhookRequestEvent = new WebhookRequestEvent($contact, $url, $headers, $payload);
|
||||
$this->dispatcher->dispatch($webhookRequestEvent, WebhookEvents::WEBHOOK_ON_REQUEST);
|
||||
|
||||
$this->makeRequest(
|
||||
$webhookRequestEvent->getUrl(),
|
||||
$config['method'],
|
||||
$config['timeout'],
|
||||
$webhookRequestEvent->getHeaders(),
|
||||
$webhookRequestEvent->getPayload()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the payload fields from the config and if there are tokens it translates them to contact values.
|
||||
*/
|
||||
private function getPayload(array $config, Lead $contact): array
|
||||
{
|
||||
$payload = !empty($config['additional_data']['list']) ? $config['additional_data']['list'] : '';
|
||||
$payload = array_flip(AbstractFormFieldHelper::parseList($payload));
|
||||
|
||||
return $this->getTokenValues($payload, $contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the payload fields from the config and if there are tokens it translates them to contact values.
|
||||
*/
|
||||
private function getHeaders(array $config, Lead $contact): array
|
||||
{
|
||||
$headers = !empty($config['headers']['list']) ? $config['headers']['list'] : '';
|
||||
$headers = array_flip(AbstractFormFieldHelper::parseList($headers));
|
||||
|
||||
return $this->getTokenValues($headers, $contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
* @param string $method
|
||||
* @param int $timeout
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws \OutOfRangeException
|
||||
*/
|
||||
private function makeRequest($url, $method, $timeout, array $headers, array $payload): void
|
||||
{
|
||||
switch ($method) {
|
||||
case 'get':
|
||||
$payload = $url.(parse_url($url, PHP_URL_QUERY) ? '&' : '?').http_build_query($payload);
|
||||
$response = $this->client->get($payload, [
|
||||
\GuzzleHttp\RequestOptions::HEADERS => $headers,
|
||||
\GuzzleHttp\RequestOptions::TIMEOUT => $timeout,
|
||||
]);
|
||||
break;
|
||||
case 'post':
|
||||
case 'put':
|
||||
case 'patch':
|
||||
$headers = array_change_key_case($headers);
|
||||
$options = [
|
||||
\GuzzleHttp\RequestOptions::HEADERS => $headers,
|
||||
\GuzzleHttp\RequestOptions::TIMEOUT => $timeout,
|
||||
];
|
||||
if (array_key_exists('content-type', $headers) && 'application/json' == strtolower($headers['content-type'])) {
|
||||
$options[\GuzzleHttp\RequestOptions::BODY] = json_encode($payload);
|
||||
} else {
|
||||
$options[\GuzzleHttp\RequestOptions::FORM_PARAMS] = $payload;
|
||||
}
|
||||
$response = $this->client->request($method, $url, $options);
|
||||
break;
|
||||
case 'delete':
|
||||
$response = $this->client->delete($url, [
|
||||
\GuzzleHttp\RequestOptions::HEADERS => $headers,
|
||||
\GuzzleHttp\RequestOptions::TIMEOUT => $timeout,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
throw new \InvalidArgumentException('HTTP method "'.$method.' is not supported."');
|
||||
}
|
||||
|
||||
if (!in_array($response->getStatusCode(), [200, 201])) {
|
||||
throw new \OutOfRangeException('Campaign webhook response returned error code: '.$response->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates tokens to values.
|
||||
*/
|
||||
private function getTokenValues(array $rawTokens, Lead $contact): array
|
||||
{
|
||||
$values = [];
|
||||
$contactValues = $this->getContactValues($contact);
|
||||
|
||||
foreach ($rawTokens as $key => $value) {
|
||||
$values[$key] = rawurldecode(TokenHelper::findLeadTokens($value, $contactValues, true));
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets array of contact values.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function getContactValues(Lead $contact)
|
||||
{
|
||||
if (empty($this->contactsValues[$contact->getId()])) {
|
||||
$this->contactsValues[$contact->getId()] = $contact->getProfileFields();
|
||||
$this->contactsValues[$contact->getId()]['ipAddress'] = $this->ipAddressesToCsv($contact->getIpAddresses());
|
||||
$this->contactsValues[$contact->getId()]['companies'] = $this->companyModel->getRepository()->getCompaniesByLeadId($contact->getId());
|
||||
}
|
||||
|
||||
return $this->contactsValues[$contact->getId()];
|
||||
}
|
||||
|
||||
private function ipAddressesToCsv(Collection $ipAddresses): string
|
||||
{
|
||||
$addresses = [];
|
||||
foreach ($ipAddresses as $ipAddress) {
|
||||
$addresses[] = $ipAddress->getIpAddress();
|
||||
}
|
||||
|
||||
return implode(',', $addresses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Http;
|
||||
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\PrivateAddressChecker;
|
||||
use Mautic\WebhookBundle\Exception\PrivateAddressException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class Client
|
||||
{
|
||||
public function __construct(
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
private GuzzleClient $httpClient,
|
||||
private PrivateAddressChecker $privateAddressChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Http\Client\Exception
|
||||
*/
|
||||
public function post($url, array $payload, ?string $secret = null): ResponseInterface
|
||||
{
|
||||
$jsonPayload = json_encode($payload);
|
||||
$signature = null === $secret ? null : base64_encode(hash_hmac('sha256', $jsonPayload, $secret, true));
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Origin-Base-URL' => $this->coreParametersHelper->get('site_url'),
|
||||
'Webhook-Signature' => $signature,
|
||||
];
|
||||
|
||||
$allowedPrivateAddresses = $this->coreParametersHelper->get('webhook_allowed_private_addresses');
|
||||
$this->privateAddressChecker->setAllowedPrivateAddresses($allowedPrivateAddresses);
|
||||
|
||||
if (!$this->privateAddressChecker->isAllowedUrl($url)) {
|
||||
throw new PrivateAddressException();
|
||||
}
|
||||
|
||||
return $this->httpClient->sendRequest(new Request('POST', $url, $headers, $jsonPayload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class MauticWebhookBundle extends Bundle
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Model;
|
||||
|
||||
use Doctrine\Common\Collections\Order;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use JMS\Serializer\SerializationContext;
|
||||
use JMS\Serializer\SerializerInterface;
|
||||
use Mautic\ApiBundle\Serializer\Exclusion\PublishDetailsExclusionStrategy;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
||||
use Mautic\CoreBundle\Helper\EncryptionHelper;
|
||||
use Mautic\CoreBundle\Helper\UserHelper;
|
||||
use Mautic\CoreBundle\Model\FormModel;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\CoreBundle\Translation\Translator;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\EventRepository;
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
use Mautic\WebhookBundle\Entity\LogRepository;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueue;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueueRepository;
|
||||
use Mautic\WebhookBundle\Entity\WebhookRepository;
|
||||
use Mautic\WebhookBundle\Event as Events;
|
||||
use Mautic\WebhookBundle\Event\WebhookEvent;
|
||||
use Mautic\WebhookBundle\Form\Type\WebhookType;
|
||||
use Mautic\WebhookBundle\Http\Client;
|
||||
use Mautic\WebhookBundle\Service\WebhookService;
|
||||
use Mautic\WebhookBundle\WebhookEvents;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\EventDispatcher\Event as SymfonyEvent;
|
||||
|
||||
/**
|
||||
* @extends FormModel<Webhook>
|
||||
*/
|
||||
class WebhookModel extends FormModel
|
||||
{
|
||||
/**
|
||||
* 2 possible types of the processing of the webhooks.
|
||||
*/
|
||||
public const COMMAND_PROCESS = 'command_process';
|
||||
|
||||
public const IMMEDIATE_PROCESS = 'immediate_process';
|
||||
|
||||
private const DELETE_BATCH_LIMIT = 5000;
|
||||
|
||||
public const WEBHOOK_LOG_MAX = 1000;
|
||||
|
||||
/**
|
||||
* Whet queue mode is turned on.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $queueMode;
|
||||
|
||||
/**
|
||||
* How many entities to add into one queued webhook.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $webhookLimit;
|
||||
|
||||
/**
|
||||
* Sets min webhook queue ID to get/process.
|
||||
*/
|
||||
protected ?int $minQueueId = null;
|
||||
|
||||
/**
|
||||
* Sets max webhook queue ID to get/process.
|
||||
*/
|
||||
protected ?int $maxQueueId = null;
|
||||
|
||||
/**
|
||||
* How long the webhook processing can run in seconds.
|
||||
*/
|
||||
private int $webhookTimeLimit;
|
||||
|
||||
/**
|
||||
* How many responses in 1 row can fail until the webhook disables itself.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $disableLimit;
|
||||
|
||||
/**
|
||||
* How many seconds will we wait for the response.
|
||||
*
|
||||
* @var int in seconds
|
||||
*/
|
||||
protected $webhookTimeout;
|
||||
|
||||
/**
|
||||
* The key is queue ID, the value is the WebhookQueue object.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $webhookQueueIdList = [];
|
||||
|
||||
/**
|
||||
* How many recent log records should be kept.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $logMax;
|
||||
|
||||
/**
|
||||
* Queued events default order by dir
|
||||
* Possible values: ['ASC', 'DESC'].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $eventsOrderByDir;
|
||||
|
||||
/**
|
||||
* Timestamp when the webhook processing starts.
|
||||
*/
|
||||
private ?float $startTime = null;
|
||||
|
||||
private bool $disableAutoUnpublish;
|
||||
private int $webhookRetryDelay;
|
||||
|
||||
public function __construct(
|
||||
CoreParametersHelper $coreParametersHelper,
|
||||
protected SerializerInterface $serializer,
|
||||
private Client $httpClient,
|
||||
EntityManager $em,
|
||||
CorePermissions $security,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
UrlGeneratorInterface $router,
|
||||
Translator $translator,
|
||||
UserHelper $userHelper,
|
||||
LoggerInterface $mauticLogger,
|
||||
private WebhookService $webhookService,
|
||||
) {
|
||||
$this->setConfigProps($coreParametersHelper);
|
||||
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Webhook $entity
|
||||
*/
|
||||
public function saveEntity($entity, $unlock = true): void
|
||||
{
|
||||
if (null === $entity->getSecret()) {
|
||||
$entity->setSecret(EncryptionHelper::generateKey());
|
||||
}
|
||||
|
||||
parent::saveEntity($entity, $unlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Webhook $entity
|
||||
* @param array<mixed> $options
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
*/
|
||||
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
|
||||
{
|
||||
if (!$entity instanceof Webhook) {
|
||||
throw new MethodNotAllowedHttpException(['Webhook']);
|
||||
}
|
||||
|
||||
if (!empty($action)) {
|
||||
$options['action'] = $action;
|
||||
}
|
||||
|
||||
$options['events'] = $this->getEvents();
|
||||
|
||||
return $formFactory->create(WebhookType::class, $entity, $options);
|
||||
}
|
||||
|
||||
public function getEntity($id = null): ?Webhook
|
||||
{
|
||||
if (null === $id) {
|
||||
return new Webhook();
|
||||
}
|
||||
|
||||
return parent::getEntity($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WebhookRepository
|
||||
*/
|
||||
public function getRepository()
|
||||
{
|
||||
return $this->em->getRepository(Webhook::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets array of custom events from bundles subscribed MauticWehbhookBundle::WEBHOOK_ON_BUILD.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getEvents()
|
||||
{
|
||||
static $events;
|
||||
|
||||
if (empty($events)) {
|
||||
// build them
|
||||
$events = [];
|
||||
$event = new Events\WebhookBuilderEvent($this->translator);
|
||||
$this->dispatcher->dispatch($event, WebhookEvents::WEBHOOK_ON_BUILD);
|
||||
$events = $event->getEvents();
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of webhooks by matching events.
|
||||
*
|
||||
* @param string $type string of event type
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getEventWebooksByType($type)
|
||||
{
|
||||
return $this->getEventRepository()->getEntitiesByEventType($type);
|
||||
}
|
||||
|
||||
public function queueWebhooksByType($type, $payload, array $groups = []): void
|
||||
{
|
||||
$this->queueWebhooks(
|
||||
$this->getEventWebooksByType($type),
|
||||
$payload,
|
||||
$groups
|
||||
);
|
||||
}
|
||||
|
||||
public function queueWebhooks($webhookEvents, $payload, array $serializationGroups = []): void
|
||||
{
|
||||
if (!count($webhookEvents) || !is_array($webhookEvents)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Event $event */
|
||||
foreach ($webhookEvents as $event) {
|
||||
$webhook = $event->getWebhook();
|
||||
$queue = $this->queueWebhook($webhook, $event, $payload, $serializationGroups);
|
||||
|
||||
if (self::COMMAND_PROCESS === $this->queueMode) {
|
||||
// Queue to the database to process later
|
||||
$this->getQueueRepository()->saveEntity($queue);
|
||||
} else {
|
||||
// Immediately process
|
||||
$this->processWebhook($webhook, $queue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebhookQueue entity, sets the date and returns the created entity.
|
||||
*/
|
||||
public function queueWebhook(Webhook $webhook, $event, $payload, array $serializationGroups = []): WebhookQueue
|
||||
{
|
||||
$serializedPayload = $this->serializeData($payload, $serializationGroups);
|
||||
|
||||
$queue = new WebhookQueue();
|
||||
$queue->setWebhook($webhook);
|
||||
$queue->setDateAdded(new \DateTime());
|
||||
$queue->setEvent($event);
|
||||
$queue->setPayload($serializedPayload);
|
||||
|
||||
// fire events for when the queues are created
|
||||
if ($this->dispatcher->hasListeners(WebhookEvents::WEBHOOK_QUEUE_ON_ADD)) {
|
||||
$webhookQueueEvent = $event = new Events\WebhookQueueEvent($queue, $webhook, true);
|
||||
$this->dispatcher->dispatch($webhookQueueEvent, WebhookEvents::WEBHOOK_QUEUE_ON_ADD);
|
||||
}
|
||||
|
||||
return $queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a list of webhooks to their specified endpoints.
|
||||
*
|
||||
* @param array|\Doctrine\ORM\Tools\Pagination\Paginator $webhooks
|
||||
*/
|
||||
public function processWebhooks($webhooks): void
|
||||
{
|
||||
$this->startTime = microtime(true);
|
||||
|
||||
foreach ($webhooks as $webhook) {
|
||||
$this->processWebhook($webhook);
|
||||
}
|
||||
}
|
||||
|
||||
public function processWebhook(Webhook $webhook, ?WebhookQueue $queue = null): bool
|
||||
{
|
||||
// get the webhook payload
|
||||
$payload = $this->getWebhookPayload($webhook, $queue);
|
||||
|
||||
// if there wasn't a payload we can stop here.
|
||||
if (empty($payload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
$logged = false;
|
||||
$webhookQueueRepo = $this->getQueueRepository();
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->post($webhook->getWebhookUrl(), $payload, $webhook->getSecret());
|
||||
// remove successfully processed queues from the Webhook object so they won't get stored again
|
||||
$queueIds = array_keys($this->webhookQueueIdList);
|
||||
$chunkedQueueIds = array_chunk($queueIds, self::DELETE_BATCH_LIMIT);
|
||||
|
||||
$responseStatusCode = $response->getStatusCode();
|
||||
if ($responseStatusCode >= 300 || $responseStatusCode < 200) {
|
||||
foreach ($chunkedQueueIds as $queueIds) {
|
||||
$webhookQueueRepo->incrementRetryCount($queueIds);
|
||||
}
|
||||
} else {
|
||||
foreach ($chunkedQueueIds as $queueIds) {
|
||||
$webhookQueueRepo->deleteQueuesById($queueIds);
|
||||
}
|
||||
$this->markWebhookHealthy($webhook);
|
||||
}
|
||||
|
||||
$responseBody = $response->getBody()->getContents();
|
||||
if (!$responseBody) {
|
||||
$responseBody = null; // Save null value to database
|
||||
}
|
||||
|
||||
$this->addLog($webhook, $responseStatusCode, microtime(true) - $start, $responseBody);
|
||||
$logged = true;
|
||||
|
||||
// throw an error exception if we don't get a 200 back
|
||||
if ($responseStatusCode >= 300 || $responseStatusCode < 200) {
|
||||
// The receiver of the webhook is telling us to stop bothering him with our requests by code 410
|
||||
if (410 === $responseStatusCode) {
|
||||
$this->killWebhook($webhook, 'mautic.webhook.stopped.reason.410');
|
||||
}
|
||||
|
||||
throw new \ErrorException($webhook->getWebhookUrl().' returned '.$responseStatusCode.' with payload: '.json_encode($payload));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$message = $e->getMessage();
|
||||
if ($this->isSick($webhook)) {
|
||||
if (!$this->disableAutoUnpublish && !$webhook->wasModifiedRecently()) {
|
||||
$this->killWebhook($webhook);
|
||||
$message .= ' '.$this->translator->trans('mautic.webhook.killed', ['%limit%' => $this->disableLimit]);
|
||||
}
|
||||
$this->markWebhookUnHealthy($webhook, $e->getMessage());
|
||||
} else {
|
||||
$this->markWebhookHealthy($webhook);
|
||||
}
|
||||
|
||||
// log any errors but allow the script to keep running
|
||||
$this->logger->error($message);
|
||||
|
||||
if (!$logged) {
|
||||
// log that the request failed to display it to the user
|
||||
$this->addLog($webhook, 'N/A', microtime(true) - $start, $message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run this on command as well as immediate send because if switched from queue to immediate
|
||||
// it can have some rows in the queue which will be send in every webhook forever
|
||||
if (!empty($this->webhookQueueIdList)) {
|
||||
// delete all the queued items we just processed
|
||||
$webhookQueueRepo->deleteQueuesById(array_keys($this->webhookQueueIdList));
|
||||
$nextWebhookExists = $webhookQueueRepo->exists($webhook->getId());
|
||||
|
||||
// reset the array to blank so none of the IDs are repeated
|
||||
$this->webhookQueueIdList = [];
|
||||
|
||||
// if there are still items in the queue after processing we re-process
|
||||
// WARNING: this is recursive
|
||||
if ($nextWebhookExists && !$this->isProcessingExpired()) {
|
||||
$this->processWebhook($webhook);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look into the history and check if all the responses we care about had failed.
|
||||
* But let it run for a while after the user modified it. Lets not aggravate the user.
|
||||
*/
|
||||
public function isSick(Webhook $webhook): bool
|
||||
{
|
||||
$successRadio = $this->getLogRepository()->getSuccessVsErrorStatusCodeRatio($webhook->getId(), $this->disableLimit);
|
||||
|
||||
// If there are no log rows yet, consider it healthy
|
||||
if (null === $successRadio) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$successRadio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish the webhook so it will stop emit the requests
|
||||
* and notify user about it.
|
||||
*
|
||||
* @param string $reason
|
||||
*/
|
||||
public function killWebhook(Webhook $webhook, $reason = 'mautic.webhook.stopped.reason'): void
|
||||
{
|
||||
$webhook->setIsPublished(false);
|
||||
$this->saveEntity($webhook);
|
||||
$event = new WebhookEvent($webhook, false, $reason);
|
||||
$this->dispatcher->dispatch($event, WebhookEvents::WEBHOOK_KILL);
|
||||
}
|
||||
|
||||
public function markWebhookUnHealthy(Webhook $webhook, string $reason): void
|
||||
{
|
||||
$webhook->setMarkedUnhealthyAt(new \DateTimeImmutable());
|
||||
$webhook->getUnHealthySince() ?: $webhook->setUnHealthySince(new \DateTimeImmutable());
|
||||
if ($this->webhookService->sendWebhookFailureNotification($webhook, $reason)) {
|
||||
$webhook->setLastNotificationSentAt(new \DateTimeImmutable());
|
||||
}
|
||||
$this->saveEntity($webhook);
|
||||
}
|
||||
|
||||
public function markWebhookHealthy(Webhook $webhook): void
|
||||
{
|
||||
if (null === $webhook->getMarkedUnhealthyAt()) {
|
||||
return;
|
||||
}
|
||||
$webhook->setMarkedUnhealthyAt(null);
|
||||
$webhook->setUnHealthySince(null);
|
||||
$webhook->setLastNotificationSentAt(null);
|
||||
$this->saveEntity($webhook);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log for the webhook response HTTP status and save it.
|
||||
*
|
||||
* @param int $statusCode
|
||||
* @param float $runtime in seconds
|
||||
* @param string $note
|
||||
* $runtime variable unit is in seconds
|
||||
*/
|
||||
public function addLog(Webhook $webhook, $statusCode, $runtime, $note = null): void
|
||||
{
|
||||
if (!$webhook->getId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->coreParametersHelper->get('clean_webhook_logs_in_background')) {
|
||||
$this->getLogRepository()->removeLimitExceedLogs($webhook->getId(), $this->logMax);
|
||||
}
|
||||
|
||||
$log = new Log();
|
||||
$log->setWebhook($webhook);
|
||||
$log->setNote($note);
|
||||
$log->setRuntime($runtime);
|
||||
$log->setStatusCode($statusCode);
|
||||
$log->setDateAdded(new \DateTime());
|
||||
$webhook->addLog($log);
|
||||
$this->saveEntity($webhook);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return WebhookQueueRepository
|
||||
*/
|
||||
public function getQueueRepository()
|
||||
{
|
||||
return $this->em->getRepository(WebhookQueue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EventRepository
|
||||
*/
|
||||
public function getEventRepository()
|
||||
{
|
||||
return $this->em->getRepository(Event::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LogRepository
|
||||
*/
|
||||
public function getLogRepository()
|
||||
{
|
||||
return $this->em->getRepository(Log::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the payload from the webhook.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getWebhookPayload(Webhook $webhook, ?WebhookQueue $queue = null)
|
||||
{
|
||||
if ($payload = $webhook->getPayload()) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
|
||||
if (self::COMMAND_PROCESS === $this->queueMode) {
|
||||
$queuesArray = $this->getWebhookQueues($webhook);
|
||||
} else {
|
||||
$queuesArray = null !== $queue ? [$queue] : [];
|
||||
}
|
||||
$this->webhookQueueIdList = [];
|
||||
/* @var WebhookQueue $queueItem */
|
||||
foreach ($queuesArray as $queueItem) {
|
||||
/** @var Event $event */
|
||||
$event = $queueItem->getEvent();
|
||||
$type = $event->getEventType();
|
||||
|
||||
// create new array level for each unique event type
|
||||
if (!isset($payload[$type])) {
|
||||
$payload[$type] = [];
|
||||
}
|
||||
|
||||
$queuePayload = json_decode($queueItem->getPayload(), true);
|
||||
$queuePayload['timestamp'] = $queueItem->getDateAdded()->format('c');
|
||||
|
||||
// its important to decode the payload form the DB as we re-encode it with the
|
||||
$payload[$type][] = $queuePayload;
|
||||
|
||||
// Add to the webhookQueueIdList only if ID exists.
|
||||
// That means if it was stored to DB and not sent via immediate send.
|
||||
if ($queueItem->getId()) {
|
||||
$this->webhookQueueIdList[$queueItem->getId()] = $queueItem;
|
||||
|
||||
// Clear the WebhookQueue entity from memory
|
||||
$this->em->detach($queueItem);
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the queues and order by date so we get events.
|
||||
*
|
||||
* @return iterable<object>
|
||||
*/
|
||||
public function getWebhookQueues(Webhook $webhook)
|
||||
{
|
||||
/** @var WebhookQueueRepository $queueRepo */
|
||||
$queueRepo = $this->getQueueRepository();
|
||||
|
||||
$webhookRetryTime = (new \DateTimeImmutable())
|
||||
->modify(sprintf('-%d seconds', $this->webhookRetryDelay))
|
||||
->format(DateTimeHelper::FORMAT_DB);
|
||||
$parameters = [
|
||||
'iterable_mode' => true,
|
||||
'start' => 0,
|
||||
'limit' => $this->webhookLimit,
|
||||
'orderBy' => $queueRepo->getTableAlias().'.retries,'.$queueRepo->getTableAlias().'.id',
|
||||
'orderByDir' => $this->getEventsOrderbyDir($webhook),
|
||||
'filter' => [
|
||||
'force' => [
|
||||
[
|
||||
'column' => 'IDENTITY('.$queueRepo->getTableAlias().'.webhook)',
|
||||
'expr' => 'eq',
|
||||
'value' => $webhook->getId(),
|
||||
],
|
||||
],
|
||||
'where' => [
|
||||
[
|
||||
'expr' => 'andX',
|
||||
'val' => [
|
||||
[
|
||||
'expr' => 'orX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => $queueRepo->getTableAlias().'.retries',
|
||||
'expr' => 'eq',
|
||||
'value' => 0,
|
||||
],
|
||||
[
|
||||
'expr' => 'andX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => $queueRepo->getTableAlias().'.retries',
|
||||
'expr' => 'gt',
|
||||
'value' => 0,
|
||||
],
|
||||
[
|
||||
'column' => $queueRepo->getTableAlias().'.dateModified',
|
||||
'expr' => 'lt',
|
||||
'value' => $webhookRetryTime,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->minQueueId && $this->maxQueueId) {
|
||||
unset($parameters['start']);
|
||||
unset($parameters['limit']);
|
||||
|
||||
$parameters['filter']['where'][0]['val'][] = [
|
||||
'column' => $queueRepo->getTableAlias().'.id',
|
||||
'expr' => 'gte',
|
||||
'value' => $this->minQueueId,
|
||||
];
|
||||
|
||||
$parameters['filter']['where'][0]['val'][] = [
|
||||
'column' => $queueRepo->getTableAlias().'.id',
|
||||
'expr' => 'lte',
|
||||
'value' => $this->maxQueueId,
|
||||
];
|
||||
}
|
||||
|
||||
return $queueRepo->getEntities($parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either Webhook's orderbyDir or the value from configuration as default.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getEventsOrderbyDir(?Webhook $webhook = null)
|
||||
{
|
||||
// Try to get the value from Webhook
|
||||
if ($webhook && $orderByDir = $webhook->getEventsOrderbyDir()) {
|
||||
return $orderByDir;
|
||||
}
|
||||
|
||||
// Use the global config value if it's not set in the Webhook
|
||||
return $this->eventsOrderByDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws MethodNotAllowedHttpException
|
||||
*/
|
||||
protected function dispatchEvent($action, &$entity, $isNew = false, ?SymfonyEvent $event = null): ?SymfonyEvent
|
||||
{
|
||||
if (!$entity instanceof Webhook) {
|
||||
throw new MethodNotAllowedHttpException(['Webhook'], 'Entity must be of class Webhook()');
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'pre_save':
|
||||
$name = WebhookEvents::WEBHOOK_PRE_SAVE;
|
||||
break;
|
||||
case 'post_save':
|
||||
$name = WebhookEvents::WEBHOOK_POST_SAVE;
|
||||
break;
|
||||
case 'pre_delete':
|
||||
$name = WebhookEvents::WEBHOOK_PRE_DELETE;
|
||||
break;
|
||||
case 'post_delete':
|
||||
$name = WebhookEvents::WEBHOOK_POST_DELETE;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->dispatcher->hasListeners($name)) {
|
||||
if (empty($event)) {
|
||||
$event = new WebhookEvent($entity, $isNew);
|
||||
$event->setEntityManager($this->em);
|
||||
}
|
||||
$this->dispatcher->dispatch($event, $name);
|
||||
|
||||
return $event;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $groups
|
||||
*/
|
||||
public function serializeData($payload, $groups = [], array $customExclusionStrategies = []): string
|
||||
{
|
||||
$context = SerializationContext::create();
|
||||
if (!empty($groups)) {
|
||||
$context->setGroups($groups);
|
||||
}
|
||||
|
||||
// Only include FormEntity properties for the top level entity and not the associated entities
|
||||
$context->addExclusionStrategy(
|
||||
new PublishDetailsExclusionStrategy()
|
||||
);
|
||||
|
||||
foreach ($customExclusionStrategies as $exclusionStrategy) {
|
||||
$context->addExclusionStrategy($exclusionStrategy);
|
||||
}
|
||||
|
||||
// include null values
|
||||
$context->setSerializeNull(true);
|
||||
|
||||
// serialize the data and send it as a payload
|
||||
return $this->serializer->serialize($payload, 'json', $context);
|
||||
}
|
||||
|
||||
public function getPermissionBase(): string
|
||||
{
|
||||
return 'webhook:webhooks';
|
||||
}
|
||||
|
||||
public function getWebhookLimit(): int
|
||||
{
|
||||
return $this->webhookLimit;
|
||||
}
|
||||
|
||||
public function setMinQueueId(int $minQueueId): self
|
||||
{
|
||||
$this->minQueueId = $minQueueId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setMaxQueueId(int $maxQueueId): self
|
||||
{
|
||||
$this->maxQueueId = $maxQueueId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function isProcessingExpired(): bool
|
||||
{
|
||||
$currentTime = microtime(true);
|
||||
$runTime = $currentTime - $this->startTime;
|
||||
|
||||
return $runTime >= $this->webhookTimeLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets all class properties from CoreParametersHelper.
|
||||
*/
|
||||
private function setConfigProps(CoreParametersHelper $coreParametersHelper): void
|
||||
{
|
||||
$this->webhookLimit = (int) $coreParametersHelper->get('webhook_limit', 10);
|
||||
$this->webhookTimeLimit = (int) $coreParametersHelper->get('webhook_time_limit', 600);
|
||||
$this->disableLimit = (int) $coreParametersHelper->get('webhook_disable_limit', 100);
|
||||
$this->webhookTimeout = (int) $coreParametersHelper->get('webhook_timeout', 15);
|
||||
$this->logMax = (int) $coreParametersHelper->get('webhook_log_max', self::WEBHOOK_LOG_MAX);
|
||||
$this->queueMode = $coreParametersHelper->get('queue_mode');
|
||||
$this->eventsOrderByDir = $coreParametersHelper->get('events_orderby_dir', Order::Ascending);
|
||||
$this->disableAutoUnpublish = (bool) $coreParametersHelper->get('disable_auto_unpublish');
|
||||
$this->webhookRetryDelay = (int) $coreParametersHelper->get('webhook_retry_delay', 3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Notificator;
|
||||
|
||||
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class WebhookFailureNotificator
|
||||
{
|
||||
public function __construct(
|
||||
private WebhookNotificationSender $sender,
|
||||
private TranslatorInterface $translator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $reason Translatable key
|
||||
*/
|
||||
public function send(Webhook $webhook, string $reason): void
|
||||
{
|
||||
$subject = $this->translator->trans('mautic.webhook.failing', [
|
||||
'%webhook%' => $webhook->getName(),
|
||||
]);
|
||||
$reason = $this->translator->trans($reason);
|
||||
$details = [
|
||||
'reason' => $reason,
|
||||
'webhook' => $webhook,
|
||||
'failing_since' => $webhook->getUnHealthySince()->format(DateTimeHelper::FORMAT_DB),
|
||||
'signature_from_name' => $this->sender->getFromNameForSignature(),
|
||||
];
|
||||
|
||||
$this->sender->send($webhook, $subject, '@MauticWebhook/Notifications/webhook-failing.html.twig', $details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Notificator;
|
||||
|
||||
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class WebhookKillNotificator
|
||||
{
|
||||
public function __construct(
|
||||
private WebhookNotificationSender $sender,
|
||||
private TranslatorInterface $translator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $reason Translatable key
|
||||
*/
|
||||
public function send(Webhook $webhook, string $reason): void
|
||||
{
|
||||
$subject = $this->translator->trans('mautic.webhook.stopped');
|
||||
$reason = $this->translator->trans($reason);
|
||||
$details = [
|
||||
'reason' => $reason,
|
||||
'webhook' => $webhook,
|
||||
'failing_since' => $webhook->getUnHealthySince()->format(DateTimeHelper::FORMAT_DB),
|
||||
'signature_from_name' => $this->sender->getFromNameForSignature(),
|
||||
];
|
||||
|
||||
$this->sender->send($webhook, $subject, '@MauticWebhook/Notifications/webhook-killed.html.twig', $details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Notificator;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Exception\MissingIdentifierField;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Model\NotificationModel;
|
||||
use Mautic\EmailBundle\Helper\MailHelper;
|
||||
use Mautic\UserBundle\Entity\User;
|
||||
use Mautic\UserBundle\Entity\UserRepository;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Event\WebhookNotificationEvent;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
class WebhookNotificationSender
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private NotificationModel $notificationModel,
|
||||
private EntityManager $entityManager,
|
||||
private MailHelper $mailer,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
private UserRepository $userRepository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $templateParameters
|
||||
*/
|
||||
public function send(Webhook $webhook, string $subject, string $templateName, array $templateParameters): void
|
||||
{
|
||||
$notificationEvent = $this->dispatcher->dispatch(new WebhookNotificationEvent($webhook));
|
||||
if (!$notificationEvent->canSend()) {
|
||||
return;
|
||||
}
|
||||
$users = $this->getToAndCCUsers($webhook);
|
||||
$toUsers = $users['toUsers'];
|
||||
$ccToUser = $users['ccUser'];
|
||||
|
||||
$details = $this->twig->render($templateName, $templateParameters);
|
||||
|
||||
foreach ($toUsers as $user) {
|
||||
// Send notification
|
||||
$this->notificationModel->addNotification(
|
||||
$details,
|
||||
'error',
|
||||
false,
|
||||
$subject,
|
||||
null,
|
||||
null,
|
||||
$user
|
||||
);
|
||||
}
|
||||
|
||||
$this->sendEmail($toUsers, $ccToUser, $subject, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*
|
||||
* @throws \Doctrine\ORM\Exception\ORMException
|
||||
*/
|
||||
private function getToAndCCUsers(Webhook $webhook): array
|
||||
{
|
||||
try {
|
||||
$owner = $toUser = $this->entityManager->getReference(User::class, $webhook->getCreatedBy());
|
||||
} catch (MissingIdentifierField) {
|
||||
$owner = $toUser = null;
|
||||
}
|
||||
|
||||
$ccToUser = null;
|
||||
|
||||
if (null !== $webhook->getModifiedBy() && $webhook->getCreatedBy() !== $webhook->getModifiedBy()) {
|
||||
$modifiedBy = $this->entityManager->getReference(User::class, $webhook->getModifiedBy());
|
||||
|
||||
$toUser = $modifiedBy; // Send notification to modifier
|
||||
$ccToUser = $owner; // And cc e-mail to owner
|
||||
}
|
||||
|
||||
$toUsers = [$toUser];
|
||||
if (!$toUser) {
|
||||
$toUsers = $this->userRepository->getAllAdminUsers();
|
||||
}
|
||||
|
||||
return [
|
||||
'toUsers' => $toUsers,
|
||||
'ccUser' => $ccToUser,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<User|null> $toUsers
|
||||
*/
|
||||
private function sendEmail(array $toUsers, ?User $ccToUser, string $subject, string $details): void
|
||||
{
|
||||
$emailsArr = [];
|
||||
foreach ($toUsers as $user) {
|
||||
$emailsArr[] = $user->getEmail();
|
||||
}
|
||||
|
||||
$sendToAuthor = $this->coreParametersHelper->get('webhook_send_notification_to_author', 1);
|
||||
if ($sendToAuthor) {
|
||||
$this->mailer->setTo($emailsArr);
|
||||
if ($ccToUser) {
|
||||
$this->mailer->setCc([$ccToUser->getEmail()]);
|
||||
}
|
||||
} else {
|
||||
$emailAddresses = array_map('trim', explode(',', $this->coreParametersHelper->get('webhook_notification_email_addresses')));
|
||||
$this->mailer->setTo($emailAddresses);
|
||||
}
|
||||
|
||||
$this->mailer->setSubject($subject);
|
||||
$this->mailer->setBody($details);
|
||||
$this->mailer->send(true);
|
||||
}
|
||||
|
||||
public function getFromNameForSignature(): string
|
||||
{
|
||||
return $this->coreParametersHelper->get('mailer_from_name');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% block _config_webhookconfig_widget %}
|
||||
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.config.tab.webhookconfig'|trans }}</h4>
|
||||
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.webhookconfig.description'|trans }}</div>
|
||||
<div class="row">
|
||||
<div class="panel panel-default mb-md">
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
{% for f in form.children %}
|
||||
<div class="col-xs-12">
|
||||
{{ form_row(f) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% if code >= 200 and code < 300 %}
|
||||
<span class="label label-success">
|
||||
{{ code }}
|
||||
</span>
|
||||
{% elseif code >= 300 and code < 500 %}
|
||||
<span class="label label-warning">
|
||||
{{ code }}
|
||||
</span>
|
||||
{% elseif code >= 500 %}
|
||||
<span class="label label-warning">
|
||||
{{ code }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span size="sm" class="label label-gray">
|
||||
{{ code }}
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="col-lg-12">
|
||||
<h3 class="mt-80 mb-md">{{ 'mautic.webhook.card.interesting'|trans }}</h3>
|
||||
{% include '@MauticCore/Components/card.html.twig' with {
|
||||
'type': 'feature--large',
|
||||
'aspectRatio': '2:1',
|
||||
'ctaType': 'local',
|
||||
'image': {'src': 'app/bundles/WebhookBundle/Assets/images/google-deepmind-erunoELfh50-unsplash.jpg', 'alt': 'mautic.webhook.card.image.alt'|trans},
|
||||
'eyebrow': 'mautic.webhook.webhooks'|trans,
|
||||
'heading': 'mautic.webhook.card.heading'|trans,
|
||||
'copy': 'mautic.webhook.card.copy'|trans,
|
||||
'href': path('mautic_webhook_index'),
|
||||
'attributes': {
|
||||
'data-toggle': 'ajax',
|
||||
}
|
||||
} %}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<p>Dear User,</p>
|
||||
<p>We wanted to bring to your attention that we've detected a failure in one of your configured webhooks. This issue has been ongoing for a significant period and requires your immediate attention to prevent potential disruptions.</p>
|
||||
|
||||
<h2>Details of the Issue:</h2>
|
||||
<div class="content">
|
||||
<p><strong>Webhook Name/ID:</strong>
|
||||
<a href="{{ path('mautic_webhook_action', {'objectAction': 'view', 'objectId': webhook.id}) }}">
|
||||
{{ webhook.name }}
|
||||
</a>
|
||||
</p>
|
||||
<p><strong>Failure Started At:</strong> {{ failing_since }} (UTC)</p>
|
||||
<p><strong>Current Status:</strong> Failing</p>
|
||||
</div>
|
||||
|
||||
<h2>Recommended Action:</h2>
|
||||
<div class="content">
|
||||
<ul>
|
||||
<li>Review the webhook configuration and ensure that the endpoint URL is correct and reachable.</li>
|
||||
<li>Check any related server logs for error messages that might indicate the cause of the failure.</li>
|
||||
<li>Please verify the authentication credentials, if required by the webhook endpoint.</li>
|
||||
<li>Consider temporarily disabling the webhook to prevent further alerts while you resolve the issue.</li>
|
||||
</ul>
|
||||
<p>Our technical support team is available to assist you if necessary.</p>
|
||||
<p>By resolving this promptly, you can prevent further complications from arising.</p>
|
||||
</div>
|
||||
|
||||
<p class="footer">Thank you for your attention to this matter.</p>
|
||||
|
||||
<p>Best Regards,</p>
|
||||
<p>{{ signature_from_name }}</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{# Twig template for webhook killed notification #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<p>Dear User,</p>
|
||||
<p>We wanted to bring to your attention that we've detected a failure in one of your configured webhook so we unpublished it automatically. This issue has been ongoing for a significant period and requires your immediate attention to prevent potential disruptions.</p>
|
||||
|
||||
<h2>Details of the Issue:</h2>
|
||||
<div class="content">
|
||||
<p><strong>Webhook Name/ID:</strong>
|
||||
<a href="{{ path('mautic_webhook_action', {'objectAction': 'view', 'objectId': webhook.id}) }}">
|
||||
{{ webhook.name }}
|
||||
</a>
|
||||
</p>
|
||||
<p><strong>Failure Started At:</strong> {{ failing_since }} (UTC)</p>
|
||||
<p><strong>Current Status:</strong> Failing</p>
|
||||
</div>
|
||||
|
||||
<h2>Recommended Action:</h2>
|
||||
<div class="content">
|
||||
<ul>
|
||||
<li>Review the webhook configuration and ensure that the endpoint URL is correct and reachable.</li>
|
||||
<li>Check any related server logs for error messages that might indicate the cause of the failure.</li>
|
||||
<li>Please verify the authentication credentials, if required by the webhook endpoint.</li>
|
||||
</ul>
|
||||
<p>Our technical support team is available to assist you if necessary.</p>
|
||||
<p>By resolving this promptly, you can prevent further complications from arising.</p>
|
||||
</div>
|
||||
|
||||
<p class="footer">Thank you for your attention to this matter.</p>
|
||||
|
||||
<p>Best Regards,</p>
|
||||
<p>{{ signature_from_name }}</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,153 @@
|
||||
{% extends '@MauticCore/Default/content.html.twig' %}
|
||||
|
||||
{% block preHeader %}
|
||||
{{- include(
|
||||
'@MauticCore/Helper/page_actions.html.twig', {
|
||||
item: item,
|
||||
templateButtons: {
|
||||
'close': securityHasEntityAccess(
|
||||
permissions['webhook:webhooks:viewown'],
|
||||
permissions['webhook:webhooks:viewother'],
|
||||
item.getCreatedBy()
|
||||
),
|
||||
},
|
||||
routeBase: 'webhook',
|
||||
'targetLabel': 'mautic.webhook.webhooks'|trans
|
||||
}) -}}
|
||||
{{ include('@MauticCore/Modules/category--inline.html.twig', {'category': item.category}) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block headerTitle %}{{ item.getName() }}{% endblock %}
|
||||
{% block mauticContent %}mauticWebhook{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
{{- include(
|
||||
'@MauticCore/Helper/page_actions.html.twig', {
|
||||
item: item,
|
||||
templateButtons: {
|
||||
'edit': securityHasEntityAccess(
|
||||
permissions['webhook:webhooks:editown'],
|
||||
permissions['webhook:webhooks:editother'],
|
||||
item.getCreatedBy()
|
||||
),
|
||||
'clone': permissions['webhook:webhooks:create'],
|
||||
'delete': securityHasEntityAccess(
|
||||
permissions['webhook:webhooks:deleteown'],
|
||||
permissions['webhook:webhooks:deleteother'],
|
||||
item.getCreatedBy()
|
||||
)
|
||||
},
|
||||
routeBase: 'webhook'
|
||||
}) -}}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- start: box layout -->
|
||||
<div class="box-layout">
|
||||
<!-- left section -->
|
||||
<div class="col-md-9 height-auto">
|
||||
{% include '@MauticCore/Helper/description--expanded.html.twig' with {'description': item.description} %}
|
||||
<div class="pa-md">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% set hookLog = item.getLimitedLogs() %}
|
||||
{% if hookLog is empty %}
|
||||
<div class="alert alert-warning col-md-6 col-md-offset-3 mt-md" style="white-space: normal;">
|
||||
<h4>
|
||||
{% trans %}mautic.webhook.no.logs{% endtrans %}
|
||||
</h4>
|
||||
<p>
|
||||
{% trans %}mautic.webhook.no.logs_desc{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<table class="table table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{% trans %}mautic.core.id{% endtrans %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans %}mautic.webhook.status{% endtrans %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans %}mautic.webhook.note{% endtrans %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans %}mautic.webhook.runtime{% endtrans %}
|
||||
</th>
|
||||
<th>
|
||||
{% trans %}mautic.core.date.added{% endtrans %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in hookLog %}
|
||||
<tr>
|
||||
<td>{{ log.getId() }}</td>
|
||||
<td>{{- include(
|
||||
'@MauticWebhook/Helper/labelcode.html.twig', {
|
||||
code: log.getStatusCode()
|
||||
}
|
||||
) }}</td>
|
||||
<td>{{ log.getNote() is not empty ? log.getNote() : 'mautic.webhook.webhook.logs.empty.response'|trans }}</td>
|
||||
<td>{{ log.getRuntime() }} s</td>
|
||||
<td>{{ dateToFull(log.getDateAdded()) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="alert alert-info col-md-6 col-md-offset-3 mt-md">
|
||||
<h4>
|
||||
{% trans %}mautic.webhook.webhook.logs.title{% endtrans %}
|
||||
</h4>
|
||||
<p>
|
||||
{% trans %}mautic.webhook.webhook.logs.desc{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- right section -->
|
||||
<div class="col-md-3 bdr-l height-auto">
|
||||
<!-- preview URL -->
|
||||
<div class="panel shd-none bdr-rds-0 bdr-w-0 mt-sm mb-0">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-title">{% trans %}mautic.webhook.webhook_url{% endtrans %}</div>
|
||||
</div>
|
||||
<div class="panel-body pt-xs">
|
||||
<div class="input-group">
|
||||
<input onclick="this.setSelectionRange(0, this.value.length);" type="text" class="form-control" readonly
|
||||
value="{{ item.getWebhookUrl()|escape('html_attr') }}" />
|
||||
<span class="input-group-btn">
|
||||
{% include '@MauticCore/Helper/button.html.twig' with {
|
||||
buttons: [
|
||||
{
|
||||
label: 'mautic.core.open_link',
|
||||
variant: 'ghost',
|
||||
icon_only: true,
|
||||
icon: 'ri-external-link-line',
|
||||
onclick: 'window.open("' ~ item.getWebhookUrl()|escape('html_attr') ~ '", "_blank");',
|
||||
}
|
||||
]
|
||||
} %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="hr-w-2" style="width:50%">
|
||||
|
||||
<!-- recent activity -->
|
||||
{{- include(
|
||||
'@MauticCore/Helper/recentactivity.html.twig', {
|
||||
logs: logs
|
||||
}
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<!--/ right section -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends '@MauticCore/Default/content.html.twig' %}
|
||||
|
||||
{% block headerTitle %}{{ entity.getId() ?
|
||||
'mautic.webhook.webhook.header.edit'|trans({'%name%': entity.getName()|trans}) :
|
||||
'mautic.webhook.webhook.header.new'|trans }}{% endblock %}
|
||||
{% block mauticContent %}mauticWebhook{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form) }}
|
||||
<!-- start: box layout -->
|
||||
<div class="box-layout">
|
||||
<!-- container -->
|
||||
<div class="col-md-9 height-auto">
|
||||
<div class="pa-md">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.name) }}
|
||||
{{ form_row(form.description) }}
|
||||
{{ form_row(form.secret) }}
|
||||
{{ form_row(form.webhookUrl) }}
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
{{ form_row(form.sendTest) }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<span id="spinner" class="ri-loader-3-line ri-spin hide"></span>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div id="tester" class="text-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-md-6" id="event-types">
|
||||
{{ form_row(form.events) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 height-auto bdr-l">
|
||||
<div class="pr-lg pl-lg pt-md pb-md">
|
||||
{{ form_row(form.category) }}
|
||||
{{ form_row(form.eventsOrderbyDir) }}
|
||||
{{ form_row(form.isPublished) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,192 @@
|
||||
{# Override tmpl to "list" because otherwise AJAX calls render the whole content instead of just the raw output #}
|
||||
{% set isIndex = tmpl == 'index' ? true : false %}
|
||||
{% set tmpl = 'list' %}
|
||||
|
||||
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
|
||||
|
||||
{% block headerTitle %}{% trans %}mautic.webhook.webhooks{% endtrans %}{% endblock %}
|
||||
{% block mauticContent %}mauticWebhook{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
{{- include(
|
||||
'@MauticCore/Helper/page_actions.html.twig', {
|
||||
templateButtons: {
|
||||
'new': permissions['webhook:webhooks:create']
|
||||
},
|
||||
routeBase: 'webhook'
|
||||
}) -}}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if isIndex %}
|
||||
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %}panel-default">
|
||||
{{ include('@MauticCore/Helper/list_toolbar.html.twig', {
|
||||
'searchValue': searchValue,
|
||||
'action': currentRoute,
|
||||
'bulk_actions': {
|
||||
'routeBase': 'webhook',
|
||||
'templateButtons': {
|
||||
'delete': (permissions['webhook:webhooks:deleteown'] or permissions['webhook:webhooks:deleteother'])
|
||||
}
|
||||
},
|
||||
'quickFilters': [
|
||||
{
|
||||
'search': 'mautic.core.searchcommand.ispublished',
|
||||
'label': 'mautic.core.form.active',
|
||||
'tooltip': 'mautic.core.searchcommand.ispublished.description',
|
||||
'icon': 'ri-check-line'
|
||||
},
|
||||
{
|
||||
'search': 'mautic.core.searchcommand.isunpublished',
|
||||
'label': 'mautic.core.form.inactive',
|
||||
'tooltip': 'mautic.core.searchcommand.isunpublished.description',
|
||||
'icon': 'ri-close-line'
|
||||
},
|
||||
{
|
||||
'search': 'mautic.core.searchcommand.ismine',
|
||||
'label': 'mautic.core.searchcommand.ismine.label',
|
||||
'tooltip': 'mautic.core.searchcommand.ismine.description',
|
||||
'icon': 'ri-user-line'
|
||||
}
|
||||
]
|
||||
}) }}
|
||||
<div class="page-list">
|
||||
{{ block('listResults') }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ block('listResults') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block listResults %}
|
||||
{% if items is defined and items is not empty %}
|
||||
<div class="table-responsive panel-collapse pull out webhook-list">
|
||||
<table class="table table-hover webhook-list" id="webhookTable">
|
||||
<thead>
|
||||
<tr>
|
||||
{{- include(
|
||||
'@MauticCore/Helper/tableheader.html.twig', {
|
||||
checkall: 'true',
|
||||
target: '#webhookTable'
|
||||
}) -}}
|
||||
{{- include(
|
||||
'@MauticCore/Helper/tableheader.html.twig', {
|
||||
sessionVar: 'mautic_webhook',
|
||||
orderBy: 'e.name',
|
||||
text: 'mautic.core.name',
|
||||
class: 'col-webhook-name',
|
||||
default: true
|
||||
}) -}}
|
||||
{{- include(
|
||||
'@MauticCore/Helper/tableheader.html.twig', {
|
||||
sessionVar: 'mautic_webhook',
|
||||
orderBy: 'c.title',
|
||||
text: 'mautic.core.category',
|
||||
class: 'visible-md visible-lg col-webhook-category',
|
||||
}) -}}
|
||||
{{- include(
|
||||
'@MauticCore/Helper/tableheader.html.twig', {
|
||||
sessionVar: 'mautic_webhook',
|
||||
orderBy: 'e.webhookUrl',
|
||||
text: 'mautic.webhook.webhook_url',
|
||||
class: 'col-webhook-id visible-md visible-lg'
|
||||
}) -}}
|
||||
{{- include(
|
||||
'@MauticCore/Helper/tableheader.html.twig', {
|
||||
sessionVar: 'mautic_webhook',
|
||||
orderBy: 'e.id',
|
||||
text: 'mautic.core.id',
|
||||
class: 'col-webhook-id visible-md visible-lg'
|
||||
}) -}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
{{- include(
|
||||
'@MauticCore/Helper/list_actions.html.twig', {
|
||||
item: item,
|
||||
templateButtons: {
|
||||
edit: securityHasEntityAccess(
|
||||
permissions['webhook:webhooks:editown'],
|
||||
permissions['webhook:webhooks:editother'],
|
||||
item.getCreatedBy()
|
||||
),
|
||||
clone: permissions['webhook:webhooks:create'],
|
||||
delete: securityHasEntityAccess(
|
||||
permissions['webhook:webhooks:deleteown'],
|
||||
permissions['webhook:webhooks:deleteother'],
|
||||
item.getCreatedBy()
|
||||
)
|
||||
},
|
||||
routeBase: 'webhook'
|
||||
}) -}}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
{{- include(
|
||||
'@MauticCore/Helper/publishstatus_icon.html.twig', {
|
||||
item: item,
|
||||
model: 'webhook'
|
||||
}
|
||||
) }}
|
||||
<a data-toggle="ajax" href="{{ path(
|
||||
'mautic_webhook_action',
|
||||
{ objectId: item.getId(), objectAction: 'view' }
|
||||
) }}">
|
||||
{{ item.getName() }}
|
||||
</a>
|
||||
{{ include('@MauticCore/Helper/description--inline.html.twig', {
|
||||
'description': item.getDescription()
|
||||
}) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="visible-md visible-lg">
|
||||
{{ include('@MauticCore/Modules/category--expanded.html.twig', {'category': item.getCategory()}) }}
|
||||
</td>
|
||||
<td class="visible-md visible-lg">{{ item.getWebhookUrl() }}</td>
|
||||
<td class="visible-md visible-lg">{{ item.getId() }} </td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
{{- include(
|
||||
'@MauticCore/Helper/pagination.html.twig', {
|
||||
totalItems: items|length,
|
||||
page: page,
|
||||
limit: limit,
|
||||
menuLinkId: 'mautic_webhook_index',
|
||||
baseUrl: path('mautic_webhook_index'),
|
||||
sessionVar: 'mautic_webhook'
|
||||
}
|
||||
) }}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if searchValue is not empty %}
|
||||
{{- include('@MauticCore/Helper/noresults.html.twig') -}}
|
||||
{% else %}
|
||||
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
|
||||
{% set childContainer %}
|
||||
<div class="mt-32 mb-md">
|
||||
{% include '@MauticCore/Components/pictogram.html.twig' with {
|
||||
'pictogram': 'event--streams',
|
||||
'size': '80'
|
||||
} %}
|
||||
</div>
|
||||
{% endset %}
|
||||
|
||||
{{ include('@MauticCore/Components/content-block.html.twig', {
|
||||
heading: 'mautic.webhook.contentblock.heading',
|
||||
subheading: 'mautic.webhook.contentblock.subheading',
|
||||
copy: 'mautic.webhook.contentblock.copy',
|
||||
childContainer: childContainer,
|
||||
}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Security\Permissions;
|
||||
|
||||
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
class WebhookPermissions extends AbstractPermissions
|
||||
{
|
||||
public function __construct($params)
|
||||
{
|
||||
parent::__construct($params);
|
||||
$this->addExtendedPermissions('webhooks');
|
||||
$this->addStandardPermissions('categories');
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'webhook';
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
|
||||
{
|
||||
$this->addStandardFormFields('webhook', 'categories', $builder, $data);
|
||||
$this->addExtendedFormFields('webhook', 'webhooks', $builder, $data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Service;
|
||||
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Notificator\WebhookFailureNotificator;
|
||||
|
||||
class WebhookService
|
||||
{
|
||||
public function __construct(private CoreParametersHelper $coreParametersHelper,
|
||||
private WebhookFailureNotificator $webhookFailureNotificator)
|
||||
{
|
||||
}
|
||||
|
||||
public function getHealthyWebhookTime(): \DateTimeImmutable
|
||||
{
|
||||
$webHookHealthCheckTime = $this->coreParametersHelper->get('webhook_health_check_time', 300);
|
||||
|
||||
return (new \DateTimeImmutable())->modify(sprintf('-%d seconds', $webHookHealthCheckTime));
|
||||
}
|
||||
|
||||
public function isWebhookHealthy(Webhook $webhook): bool
|
||||
{
|
||||
$healthyWebhookTime = $this->getHealthyWebhookTime();
|
||||
|
||||
return null === $webhook->getMarkedUnhealthyAt() || ($webhook->getMarkedUnhealthyAt() < $healthyWebhookTime);
|
||||
}
|
||||
|
||||
public function sendWebhookFailureNotification(Webhook $webhook, string $reason): bool
|
||||
{
|
||||
if ($this->shouldSendFailureNotification($webhook)) {
|
||||
$this->webhookFailureNotificator->send($webhook, $reason);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function shouldSendFailureNotification(Webhook $webhook): bool
|
||||
{
|
||||
return $this->isFailingMoreThanThresholdTime($webhook) && $this->shouldSendNotificationNow($webhook);
|
||||
}
|
||||
|
||||
private function isFailingMoreThanThresholdTime(Webhook $webhook): bool
|
||||
{
|
||||
if (null === $webhook->getUnHealthySince()) {
|
||||
return false;
|
||||
}
|
||||
$webhookFailureNotificationTime = $this->coreParametersHelper->get('first_webhook_failure_notification_time', 3600);
|
||||
$healthyWebhookTime = (new \DateTimeImmutable())->modify(sprintf('-%d seconds', $webhookFailureNotificationTime));
|
||||
|
||||
return $webhook->getUnHealthySince() < $healthyWebhookTime;
|
||||
}
|
||||
|
||||
private function shouldSendNotificationNow(Webhook $webhook): bool
|
||||
{
|
||||
if (null === $webhook->getLastNotificationSentAt()) {
|
||||
return true;
|
||||
}
|
||||
$webhookFailureNotificationInterval = $this->coreParametersHelper->get('webhook_failure_notification_interval', 86400);
|
||||
$healthyWebhookTime = (new \DateTimeImmutable())->modify(sprintf('-%d seconds', $webhookFailureNotificationInterval));
|
||||
|
||||
return $webhook->getLastNotificationSentAt() < $healthyWebhookTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Form\Type;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
class ConfigTypeFunctionalTest extends MauticMysqlTestCase
|
||||
{
|
||||
public function testSendEmailDetailsToggleIsOnByDefault(): void
|
||||
{
|
||||
$crawler = $this->client->request('GET', '/s/config/edit');
|
||||
|
||||
// Updated CSS selector based on the new ID
|
||||
$yesSpan = $crawler->filter('#config_webhookconfig_webhook_email_details_label > div > span');
|
||||
|
||||
// Assert that exactly one such span exists
|
||||
Assert::assertCount(1, $yesSpan, 'The "Yes" span for "Send email details" toggle should exist.');
|
||||
|
||||
// Assert that the text within the span is "Yes"
|
||||
Assert::assertSame('Yes', $yesSpan->text(), 'The "Send email details" toggle should be set to "Yes" by default.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Functional\Command;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\WebhookBundle\Command\DeleteWebhookLogsCommand;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
final class DeleteWebhookLogsCommandTest extends MauticMysqlTestCase
|
||||
{
|
||||
/**
|
||||
* @var WebhookModel
|
||||
*/
|
||||
private $webhookModel;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->configParams['clean_webhook_logs_in_background'] = 'testRemoveLogUsingCleanUpJob' === $this->name();
|
||||
$this->configParams['webhook_log_max'] = 5;
|
||||
parent::setUp();
|
||||
|
||||
$this->webhookModel = static::getContainer()->get('mautic.webhook.model.webhook');
|
||||
}
|
||||
|
||||
public function testRemoveLogInstantly(): void
|
||||
{
|
||||
$webhook = $this->createWebhook('test', 'http://domain.tld', 'secret');
|
||||
$this->createWebhookEvent($webhook, 'Type');
|
||||
$logIds = [];
|
||||
for ($log = 1; $log <= 6; ++$log) {
|
||||
$addedLog = $this->createWebhookLog($webhook, 'test', 200);
|
||||
array_push($logIds, $addedLog->getId());
|
||||
}
|
||||
|
||||
$this->assertLogs($webhook, 6, $logIds);
|
||||
|
||||
$this->webhookModel->addLog($webhook, 200, 15);
|
||||
|
||||
array_shift($logIds);
|
||||
array_push($logIds, end($logIds) + 1);
|
||||
$this->assertLogs($webhook, 6, $logIds);
|
||||
}
|
||||
|
||||
public function testRemoveLogUsingCleanUpJob(): void
|
||||
{
|
||||
$webhook = $this->createWebhook('test', 'http://domain.tld', 'secret');
|
||||
$this->createWebhookEvent($webhook, 'Type');
|
||||
$logIds = [];
|
||||
for ($log = 1; $log <= 6; ++$log) {
|
||||
$addedLog = $this->createWebhookLog($webhook, 'test', 200);
|
||||
array_push($logIds, $addedLog->getId());
|
||||
}
|
||||
|
||||
$this->assertLogs($webhook, 6, $logIds);
|
||||
|
||||
$this->webhookModel->addLog($webhook, 200, 15);
|
||||
|
||||
array_push($logIds, end($logIds) + 1);
|
||||
$this->assertLogs($webhook, 7, $logIds);
|
||||
}
|
||||
|
||||
public function testRemoveLogCommand(): void
|
||||
{
|
||||
$webhook = $this->createWebhook('test', 'http://domain.tld', 'secret');
|
||||
$this->createWebhookEvent($webhook, 'Type');
|
||||
$logIds = [];
|
||||
for ($log = 1; $log <= 7; ++$log) {
|
||||
$addedLog = $this->createWebhookLog($webhook, 'test', 200);
|
||||
array_push($logIds, $addedLog->getId());
|
||||
}
|
||||
|
||||
$output = $this->testSymfonyCommand(DeleteWebhookLogsCommand::COMMAND_NAME);
|
||||
Assert::assertStringContainsString('2 logs deleted successfully for webhook id - '.$webhook->getId(), $output->getDisplay());
|
||||
array_shift($logIds);
|
||||
array_shift($logIds);
|
||||
$this->assertLogs($webhook, 5, $logIds);
|
||||
}
|
||||
|
||||
public function testRemoveLogCommandForNoWebhook(): void
|
||||
{
|
||||
$output = $this->testSymfonyCommand(DeleteWebhookLogsCommand::COMMAND_NAME);
|
||||
Assert::assertStringContainsString('There is 0 webhooks with logs more than defined limit.', $output->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $expectedIds
|
||||
*/
|
||||
private function assertLogs(Webhook $webhook, int $expectedCount, array $expectedIds): void
|
||||
{
|
||||
$logs = $this->em->getRepository(Log::class)->findBy(['webhook' => $webhook]);
|
||||
$logIds = array_map(fn (Log $log) => $log->getId(), $logs);
|
||||
|
||||
Assert::assertCount($expectedCount, $logs);
|
||||
Assert::assertSame($expectedIds, $logIds);
|
||||
}
|
||||
|
||||
private function createWebhook(string $name, string $url, string $secret): Webhook
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$webhook->setName($name);
|
||||
$webhook->setWebhookUrl($url);
|
||||
$webhook->setSecret($secret);
|
||||
$this->em->persist($webhook);
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
private function createWebhookEvent(Webhook $webhook, string $type): Event
|
||||
{
|
||||
$event = new Event();
|
||||
$event->setWebhook($webhook);
|
||||
$event->setEventType($type);
|
||||
$this->em->persist($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function createWebhookLog(Webhook $webhook, string $note, int $statusCode): Log
|
||||
{
|
||||
$log = new Log();
|
||||
$log->setWebhook($webhook);
|
||||
$log->setNote($note);
|
||||
$log->setStatusCode($statusCode);
|
||||
$this->em->persist($log);
|
||||
$this->em->flush();
|
||||
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Functional\Command;
|
||||
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Mautic\CoreBundle\Test\Guzzle\ClientMockTrait;
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\WebhookBundle\Command\ProcessWebhookQueuesCommand;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueue;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
final class ProcessWebhookQueuesCommandTest extends MauticMysqlTestCase
|
||||
{
|
||||
use ClientMockTrait;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->configParams['queue_mode'] = WebhookModel::COMMAND_PROCESS;
|
||||
$this->configParams['webhook_limit'] = 3;
|
||||
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
public function testCommand(): void
|
||||
{
|
||||
$webhook = $this->createWebhook('test', 'https://httpbin.org/post', 'secret');
|
||||
$event = $this->createWebhookEvent($webhook, 'Type');
|
||||
$handlerStack = $this->getClientMockHandler();
|
||||
$queueIds = [];
|
||||
|
||||
// Generate 10 queue records.
|
||||
for ($i = 1; $i <= 10; ++$i) {
|
||||
$addedLog = $this->createWebhookQueue($webhook, $event, "Some payload {$i}");
|
||||
array_push($queueIds, $addedLog->getId());
|
||||
|
||||
$handlerStack->append(
|
||||
function (RequestInterface $request) {
|
||||
Assert::assertSame('POST', $request->getMethod());
|
||||
Assert::assertSame('https://httpbin.org/post', $request->getUri()->__toString());
|
||||
|
||||
return new Response(SymfonyResponse::HTTP_OK);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Process queue records from 4 to 9 including. 6 in total.
|
||||
$output = $this->testSymfonyCommand(
|
||||
ProcessWebhookQueuesCommand::COMMAND_NAME,
|
||||
['--webhook-id' => $webhook->getId(), '--min-id' => $queueIds[3], '--max-id' => $queueIds[8]]
|
||||
);
|
||||
Assert::assertStringContainsString('Webhook Processing Complete', $output->getDisplay());
|
||||
|
||||
// There will be 2 batches of webhook events sent. We've set we want to send 3 events per batch.
|
||||
Assert::assertCount(2, $this->em->getRepository(Log::class)->findBy(['webhook' => $webhook]));
|
||||
|
||||
// And 4 out of 10 queue records will be left alone as they did not fit the ID range.
|
||||
Assert::assertCount(4, $this->em->getRepository(WebhookQueue::class)->findBy(['webhook' => $webhook]));
|
||||
}
|
||||
|
||||
private function createWebhook(string $name, string $url, string $secret): Webhook
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$webhook->setName($name);
|
||||
$webhook->setWebhookUrl($url);
|
||||
$webhook->setSecret($secret);
|
||||
$this->em->persist($webhook);
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
private function createWebhookEvent(Webhook $webhook, string $type): Event
|
||||
{
|
||||
$event = new Event();
|
||||
$event->setWebhook($webhook);
|
||||
$event->setEventType($type);
|
||||
$this->em->persist($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function createWebhookQueue(Webhook $webhook, Event $event, string $payload): WebhookQueue
|
||||
{
|
||||
$record = new WebhookQueue();
|
||||
$record->setWebhook($webhook);
|
||||
$record->setEvent($event);
|
||||
$record->setPayload($payload);
|
||||
$record->setDateAdded(new \DateTime());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Controller;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class WebhookControllerTest extends MauticMysqlTestCase
|
||||
{
|
||||
public function testViewWebhookDetail(): void
|
||||
{
|
||||
$webhook = $this->createWebhook('test', 'http://domain.tld', 'secret');
|
||||
$this->createWebhookEvent($webhook, 'Type');
|
||||
for ($log = 1; $log <= 105; ++$log) {
|
||||
$this->createWebhookLog($webhook, 'test', 200);
|
||||
}
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/s/webhooks/view/'.$webhook->getId());
|
||||
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
|
||||
|
||||
$logList = $crawler->filter('.table.table-responsive > tbody > tr')->count();
|
||||
Assert::assertSame(Webhook::LOGS_DISPLAY_LIMIT, $logList);
|
||||
}
|
||||
|
||||
private function createWebhook(string $name, string $url, string $secret): Webhook
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$webhook->setName($name);
|
||||
$webhook->setWebhookUrl($url);
|
||||
$webhook->setSecret($secret);
|
||||
$this->em->persist($webhook);
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
private function createWebhookEvent(Webhook $webhook, string $type): Event
|
||||
{
|
||||
$event = new Event();
|
||||
$event->setWebhook($webhook);
|
||||
$event->setEventType($type);
|
||||
$this->em->persist($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function createWebhookLog(Webhook $webhook, string $note, int $statusCode): Log
|
||||
{
|
||||
$log = new Log();
|
||||
$log->setWebhook($webhook);
|
||||
$log->setNote($note);
|
||||
$log->setStatusCode($statusCode);
|
||||
$this->em->persist($log);
|
||||
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Entity;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueue;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
class WebhookQueueFunctionalTest extends MauticMysqlTestCase
|
||||
{
|
||||
public function testPayloadCompressed(): void
|
||||
{
|
||||
$webhookQueue = $this->createWebhookQueue();
|
||||
|
||||
$payload = 'Compressed payload';
|
||||
$webhookQueue->setPayload($payload);
|
||||
|
||||
Assert::assertSame($payload, $webhookQueue->getPayload());
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$payloadDbValues = $this->fetchPayloadDbValues($webhookQueue);
|
||||
Assert::assertSame($payload, gzuncompress($payloadDbValues['payload_compressed']));
|
||||
|
||||
$this->em->clear();
|
||||
$webhookQueue = $this->em->getRepository(WebhookQueue::class)
|
||||
->find($webhookQueue->getId());
|
||||
|
||||
Assert::assertSame($payload, $webhookQueue->getPayload());
|
||||
}
|
||||
|
||||
private function createWebhookQueue(): WebhookQueue
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$webhook->setName('Test');
|
||||
$webhook->setWebhookUrl('http://domain.tld');
|
||||
$webhook->setSecret('secret');
|
||||
$this->em->persist($webhook);
|
||||
|
||||
$even = new Event();
|
||||
$even->setWebhook($webhook);
|
||||
$even->setEventType('Type');
|
||||
$this->em->persist($even);
|
||||
|
||||
$webhookQueue = new WebhookQueue();
|
||||
$webhookQueue->setWebhook($webhook);
|
||||
$webhookQueue->setEvent($even);
|
||||
$this->em->persist($webhookQueue);
|
||||
|
||||
return $webhookQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed[]
|
||||
*/
|
||||
private function fetchPayloadDbValues(WebhookQueue $webhookQueue): array
|
||||
{
|
||||
$prefix = static::getContainer()->getParameter('mautic.db_table_prefix');
|
||||
$query = sprintf('SELECT payload_compressed FROM %swebhook_queue WHERE id = ?', $prefix);
|
||||
|
||||
return $this->connection->executeQuery($query, [$webhookQueue->getId()])
|
||||
->fetchAssociative();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Functional\Model;
|
||||
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\UserBundle\Entity\User;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
final class WebhookModelProcessFailureTest extends MauticMysqlTestCase
|
||||
{
|
||||
/**
|
||||
* @var WebhookModel
|
||||
*/
|
||||
private $webhookModel;
|
||||
|
||||
/**
|
||||
* @var MockHandler
|
||||
*/
|
||||
private $clientMockHandler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->configParams['queue_mode'] = WebhookModel::IMMEDIATE_PROCESS;
|
||||
$this->configParams['disable_auto_unpublish'] = 'testDisableAutoUnpublishIsEnabled' === $this->name();
|
||||
parent::setUp();
|
||||
|
||||
$this->webhookModel = self::$kernel->getContainer()->get('mautic.webhook.model.webhook');
|
||||
$this->clientMockHandler = new MockHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $logStatusCodes
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('dataFailureWithPreviousLogs')]
|
||||
public function testFailureWithPreviousLogs(array $logStatusCodes, bool $expectedIsPublished, int $expectedNumberOfLogs): void
|
||||
{
|
||||
$this->clientMockHandler->append(new Response(401));
|
||||
$webhook = $this->createWebhook();
|
||||
$webhook->setUnHealthySince(new \DateTimeImmutable());
|
||||
foreach ($logStatusCodes as $logStatusCode) {
|
||||
$this->createWebhookLog($webhook, $logStatusCode);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->processWebhook($webhook);
|
||||
|
||||
Assert::assertSame($expectedIsPublished, $webhook->getIsPublished());
|
||||
$this->assertNumberOfLogs($expectedNumberOfLogs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<mixed>
|
||||
*/
|
||||
public static function dataFailureWithPreviousLogs(): iterable
|
||||
{
|
||||
yield 'no previous logs' => [[], true, 1];
|
||||
yield 'at least one successful previous log' => [[200, 403], true, 3];
|
||||
yield 'all failed previous logs' => [[401, 403], false, 3];
|
||||
}
|
||||
|
||||
public function test404DoesNotProduceRedundantLog(): void
|
||||
{
|
||||
$this->clientMockHandler->append(new Response(404));
|
||||
|
||||
$webhook = $this->createWebhook();
|
||||
$webhook->setUnHealthySince(new \DateTimeImmutable());
|
||||
$this->createWebhookLog($webhook, 401);
|
||||
|
||||
$this->em->flush();
|
||||
$this->processWebhook($webhook);
|
||||
|
||||
Assert::assertFalse($webhook->getIsPublished());
|
||||
$this->assertNumberOfLogs(2);
|
||||
}
|
||||
|
||||
public function testWebhookIsNotUnpublishedIfModifiedRecently(): void
|
||||
{
|
||||
$webhook = $this->createWebhook();
|
||||
$webhook->setDateModified(new \DateTime('-1 day'));
|
||||
$this->createWebhookLog($webhook, 401);
|
||||
|
||||
$this->em->flush();
|
||||
$this->processWebhook($webhook);
|
||||
|
||||
Assert::assertTrue($webhook->getIsPublished());
|
||||
$this->assertNumberOfLogs(2);
|
||||
}
|
||||
|
||||
public function testWebhookIsUnpublishedIfNotModifiedRecently(): void
|
||||
{
|
||||
$webhook = $this->createWebhook();
|
||||
$webhook->setUnHealthySince(new \DateTimeImmutable());
|
||||
$this->createWebhookLog($webhook, 401);
|
||||
|
||||
$this->em->flush();
|
||||
$this->processWebhook($webhook);
|
||||
|
||||
Assert::assertFalse($webhook->getIsPublished());
|
||||
$this->assertNumberOfLogs(2);
|
||||
}
|
||||
|
||||
public function testDisableAutoUnpublishIsEnabled(): void
|
||||
{
|
||||
$webhook = $this->createWebhook();
|
||||
$this->createWebhookLog($webhook, 401);
|
||||
|
||||
$this->em->flush();
|
||||
$this->processWebhook($webhook);
|
||||
|
||||
Assert::assertTrue($webhook->getIsPublished());
|
||||
$this->assertNumberOfLogs(2);
|
||||
}
|
||||
|
||||
private function createWebhook(): Webhook
|
||||
{
|
||||
$user = $this->em->getRepository(User::class)->findOneBy([]);
|
||||
|
||||
$webhook = new Webhook();
|
||||
$webhook->setCreatedBy($user);
|
||||
$webhook->setName('Test');
|
||||
$webhook->setWebhookUrl('https://domain.tld');
|
||||
$webhook->setSecret('secret');
|
||||
$webhook->setDateModified(new \DateTime('-1 week'));
|
||||
$this->em->persist($webhook);
|
||||
$this->em->flush();
|
||||
$webhook->setChanges([]);
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
private function createWebhookEvent(Webhook $webhook): Event
|
||||
{
|
||||
$event = new Event();
|
||||
$event->setWebhook($webhook);
|
||||
$event->setEventType('type');
|
||||
$this->em->persist($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function createWebhookLog(Webhook $webhook, int $statusCode): void
|
||||
{
|
||||
$log = new Log();
|
||||
$log->setWebhook($webhook);
|
||||
$log->setStatusCode($statusCode);
|
||||
$this->em->persist($log);
|
||||
}
|
||||
|
||||
private function processWebhook(Webhook $webhook): void
|
||||
{
|
||||
$event = $this->createWebhookEvent($webhook);
|
||||
$queue = $this->webhookModel->queueWebhook($webhook, $event, []);
|
||||
$this->webhookModel->processWebhook($webhook, $queue);
|
||||
}
|
||||
|
||||
private function assertNumberOfLogs(int $expectedNumberOfLogs): void
|
||||
{
|
||||
Assert::assertSame($expectedNumberOfLogs, $this->em->getRepository(Log::class)->count([]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Functional\Model;
|
||||
|
||||
use Doctrine\Common\Collections\Order;
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueue;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
final class WebhookModelTest extends MauticMysqlTestCase
|
||||
{
|
||||
protected $useCleanupRollback = false;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Cleanup from previous tests
|
||||
$this->connection->executeStatement('DELETE FROM '.MAUTIC_TABLE_PREFIX.'webhook_queue');
|
||||
$this->connection->executeStatement('ALTER TABLE '.MAUTIC_TABLE_PREFIX.'webhook_queue AUTO_INCREMENT = 1');
|
||||
}
|
||||
|
||||
public function testEventsOrderByDirAsc(): void
|
||||
{
|
||||
$webhookModel = $this->getWebhookModel(Order::Ascending->value);
|
||||
$webhook = $this->createWebhookAndQueue();
|
||||
$queueArray = $webhookModel->getWebhookQueues($webhook);
|
||||
|
||||
// Order should be 1 to 10
|
||||
$counter = 1;
|
||||
|
||||
foreach ($queueArray as $queuedEvent) {
|
||||
Assert::assertSame((string) $counter, $queuedEvent->getId());
|
||||
|
||||
$payload = json_decode($queuedEvent->getPayload(), true);
|
||||
Assert::assertSame($counter, $payload['spoof']);
|
||||
|
||||
++$counter;
|
||||
}
|
||||
|
||||
Assert::assertSame(11, $counter);
|
||||
}
|
||||
|
||||
public function testEventsOrderByDirDesc(): void
|
||||
{
|
||||
$webhookModel = $this->getWebhookModel(Order::Descending->value);
|
||||
$webhook = $this->createWebhookAndQueue();
|
||||
$queueArray = $webhookModel->getWebhookQueues($webhook);
|
||||
|
||||
// Order should be 10 to 1
|
||||
$counter = 10;
|
||||
foreach ($queueArray as $queuedEvent) {
|
||||
Assert::assertSame((string) $counter, $queuedEvent->getId());
|
||||
|
||||
$payload = json_decode($queuedEvent->getPayload(), true);
|
||||
Assert::assertSame($counter, $payload['spoof']);
|
||||
|
||||
--$counter;
|
||||
}
|
||||
|
||||
Assert::assertSame(0, $counter);
|
||||
}
|
||||
|
||||
private function createWebhookAndQueue(): Webhook
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
|
||||
$webhook->setName('Test Webhook');
|
||||
$webhook->setWebhookUrl('https://localhost');
|
||||
$webhook->setSecret('abc13');
|
||||
$this->em->persist($webhook);
|
||||
$this->em->flush();
|
||||
|
||||
$event = new Event();
|
||||
$event->setWebhook($webhook);
|
||||
$event->setEventType('mautic.email_on_send');
|
||||
$this->em->persist($event);
|
||||
$this->em->flush();
|
||||
|
||||
$counter = 1;
|
||||
while ($counter <= 10) {
|
||||
$this->createWebhookQueue($webhook, $event, ['spoof' => $counter]);
|
||||
|
||||
++$counter;
|
||||
}
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $payload
|
||||
*/
|
||||
private function createWebhookQueue(Webhook $webhook, Event $event, array $payload): void
|
||||
{
|
||||
$queue = new WebhookQueue();
|
||||
$queue->setDateAdded(new \DateTime());
|
||||
$queue->setEvent($event);
|
||||
$queue->setWebhook($webhook);
|
||||
$queue->setPayload(json_encode($payload));
|
||||
$this->em->persist($queue);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function getWebhookModel(string $direction): WebhookModel
|
||||
{
|
||||
$webhookParams = [
|
||||
'queue_mode' => WebhookModel::COMMAND_PROCESS,
|
||||
'events_orderby_dir' => $direction,
|
||||
];
|
||||
|
||||
$this->setUpSymfony($webhookParams);
|
||||
|
||||
return static::getContainer()->get('mautic.webhook.model.webhook');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Functional;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator;
|
||||
use GuzzleHttp\Psr7\Response as GuzzleResponse;
|
||||
use Mautic\CoreBundle\Entity\Notification;
|
||||
use Mautic\CoreBundle\Entity\NotificationRepository;
|
||||
use Mautic\CoreBundle\Test\Guzzle\ClientMockTrait;
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\WebhookBundle\Command\ProcessWebhookQueuesCommand;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueue;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueueRepository;
|
||||
use Mautic\WebhookBundle\Entity\WebhookRepository;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WebhookFunctionalTest extends MauticMysqlTestCase
|
||||
{
|
||||
use ClientMockTrait;
|
||||
|
||||
protected $useCleanupRollback = false;
|
||||
|
||||
/**
|
||||
* @var WebhookQueueRepository
|
||||
*/
|
||||
private $webhookQueueRepository;
|
||||
|
||||
/**
|
||||
* @var NotificationRepository
|
||||
*/
|
||||
private $notificationRepository;
|
||||
|
||||
/**
|
||||
* @var WebhookRepository|EntityRepository<Webhook>
|
||||
*/
|
||||
private $webhhokRepository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->authenticateApi = true;
|
||||
parent::setUp();
|
||||
|
||||
$this->setUpSymfony(
|
||||
$this->configParams +
|
||||
[
|
||||
'queue_mode' => WebhookModel::COMMAND_PROCESS,
|
||||
'webhook_limit' => 2,
|
||||
]
|
||||
);
|
||||
|
||||
$this->truncateTables('leads', 'webhooks', 'webhook_queue', 'webhook_events');
|
||||
|
||||
$this->webhookQueueRepository = $this->em->getRepository(WebhookQueue::class);
|
||||
$this->notificationRepository = $this->em->getRepository(Notification::class);
|
||||
$this->webhhokRepository = $this->em->getRepository(Webhook::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up after the tests.
|
||||
*/
|
||||
protected function beforeTearDown(): void
|
||||
{
|
||||
$this->truncateTables('leads', 'webhooks', 'webhook_queue', 'webhook_events');
|
||||
}
|
||||
|
||||
public function testWebhookWorkflowWithCommandProcess(): void
|
||||
{
|
||||
$webhookQueueRepository = $this->em->getRepository(WebhookQueue::class);
|
||||
\assert($webhookQueueRepository instanceof WebhookQueueRepository);
|
||||
$this->mockSuccessfulWebhookResponse(2);
|
||||
$webhook = $this->createWebhook();
|
||||
// Ensure we have a clean slate. There should be no rows waiting to be processed at this point.
|
||||
Assert::assertSame(0, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
|
||||
$this->createContacts();
|
||||
|
||||
// At this point there should be 3 events waiting to be processed.
|
||||
Assert::assertSame(3, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME, ['--webhook-id' => $webhook->getId()]);
|
||||
|
||||
// The queue should be processed now.
|
||||
Assert::assertSame(0, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
}
|
||||
|
||||
public function testWebhookWorkflowWithCommandProcessInQueueRange(): void
|
||||
{
|
||||
$this->mockSuccessfulWebhookResponse(2);
|
||||
$webhook = $this->createWebhook();
|
||||
$contacts = $this->createContacts();
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME, [
|
||||
'--webhook-id' => $webhook->getId(),
|
||||
'--min-id' => $contacts[0],
|
||||
'--max-id' => $contacts[2],
|
||||
]);
|
||||
|
||||
// The queue should be processed now.
|
||||
Assert::assertSame(0, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
}
|
||||
|
||||
public function testWebhookWorkflowWithCommandProcessWithoutPassingWebhookID(): void
|
||||
{
|
||||
$this->mockSuccessfulWebhookResponse(2);
|
||||
$webhook = $this->createWebhook();
|
||||
$this->createContacts();
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME);
|
||||
|
||||
// The queue should be processed now.
|
||||
Assert::assertSame(0, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<mixed>
|
||||
*/
|
||||
public static function dataNotificationToUser(): iterable
|
||||
{
|
||||
yield 'Support User' => [null, 1];
|
||||
yield 'Actual user' => [1, 1];
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('dataNotificationToUser')]
|
||||
public function testWebhookFailureNotificationSent(?int $createdByUserId, ?int $expectedUserId): void
|
||||
{
|
||||
$this->mockFailedWebhookResponse(2);
|
||||
$webhook = $this->createWebhook();
|
||||
$webhook->setCreatedBy();
|
||||
$webhook->setModifiedBy();
|
||||
$this->em->persist($webhook);
|
||||
$this->em->flush();
|
||||
$this->createContacts();
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME, ['--webhook-id' => $webhook->getId()]);
|
||||
|
||||
Assert::assertSame(3, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
|
||||
$webhookQueues = $this->getWebhookQueue($webhook->getId());
|
||||
foreach ($webhookQueues as $webhookQueue) {
|
||||
$webhookQueue->setRetries(2);
|
||||
$webhookQueue->setDateModified((new \DateTimeImmutable())->modify('-3601 seconds'));
|
||||
$this->em->persist($webhookQueue);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$webhook->setCreatedBy($createdByUserId);
|
||||
$webhook->setModifiedBy($createdByUserId);
|
||||
$webhook->setUnHealthySince((new \DateTimeImmutable())->modify('-3601 seconds'));
|
||||
$webhook->setMarkedUnhealthyAt((new \DateTimeImmutable())->modify('-3601 seconds'));
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME, ['--webhook-id' => $webhook->getId()]);
|
||||
|
||||
Assert::assertCount(1, $this->notificationRepository->getNotifications($expectedUserId));
|
||||
Assert::assertSame(3, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME);
|
||||
|
||||
$webhook = $this->webhhokRepository->find($webhook->getId());
|
||||
Assert::assertNotNull($webhook->getMarkedUnhealthyAt());
|
||||
Assert::assertNotNull($webhook->getUnHealthySince());
|
||||
Assert::assertNotNull($webhook->getLastNotificationSentAt());
|
||||
}
|
||||
|
||||
public function testWebhookQueueNotProcessedIfMarkedUnhealthy(): void
|
||||
{
|
||||
$this->mockSuccessfulWebhookResponse();
|
||||
$webhook = $this->createWebhook();
|
||||
$webhook->setMarkedUnhealthyAt(new \DateTimeImmutable());
|
||||
$this->em->persist($webhook);
|
||||
$this->em->flush();
|
||||
$this->createContacts();
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME);
|
||||
|
||||
// The queue should not be processed.
|
||||
Assert::assertSame(3, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
}
|
||||
|
||||
public function testWebhookQueueProcessedWhenUnhealthyTimePassed(): void
|
||||
{
|
||||
$this->mockSuccessfulWebhookResponse(2);
|
||||
$webhook = $this->createWebhook();
|
||||
$webhook->setMarkedUnhealthyAt((new \DateTimeImmutable())->modify('-301 seconds'));
|
||||
$this->em->persist($webhook);
|
||||
$this->em->flush();
|
||||
$this->createContacts();
|
||||
|
||||
$this->testSymfonyCommand(ProcessWebhookQueuesCommand::COMMAND_NAME);
|
||||
|
||||
$webhook = $this->webhhokRepository->find($webhook->getId());
|
||||
Assert::assertNull($webhook->getMarkedUnhealthyAt());
|
||||
Assert::assertNull($webhook->getUnHealthySince());
|
||||
Assert::assertNull($webhook->getLastNotificationSentAt());
|
||||
|
||||
// The queue should be processed.
|
||||
Assert::assertSame(0, $this->getQueueCountByWebhookId($webhook->getId()));
|
||||
}
|
||||
|
||||
private function createWebhook(): Webhook
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$event = new Event();
|
||||
|
||||
$event->setEventType('mautic.lead_post_save_new');
|
||||
$event->setWebhook($webhook);
|
||||
|
||||
$webhook->addEvent($event);
|
||||
$webhook->setName('Webhook from a functional test');
|
||||
$webhook->setWebhookUrl('https://httpbin.org/post');
|
||||
$webhook->setSecret('any_secret_will_do');
|
||||
$webhook->isPublished(true);
|
||||
$webhook->setCreatedBy(1);
|
||||
|
||||
$this->em->persist($event);
|
||||
$this->em->persist($webhook);
|
||||
$this->em->flush();
|
||||
|
||||
return $webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating some contacts via API so all the listeners are triggered.
|
||||
* It's closer to a real world contact creation.
|
||||
*/
|
||||
private function createContacts(): array
|
||||
{
|
||||
$contacts = [
|
||||
[
|
||||
'email' => sprintf('contact1%s@email.com', mt_rand(99999, 999999)),
|
||||
'firstname' => 'Contact',
|
||||
'lastname' => 'One',
|
||||
'points' => 4,
|
||||
'city' => 'Houston',
|
||||
'state' => 'Texas',
|
||||
'country' => 'United States',
|
||||
],
|
||||
[
|
||||
'email' => sprintf('contact2%s@email.com', mt_rand(99999, 999999)),
|
||||
'firstname' => 'Contact',
|
||||
'lastname' => 'Two',
|
||||
'city' => 'Boston',
|
||||
'state' => 'Massachusetts',
|
||||
'country' => 'United States',
|
||||
'timezone' => 'America/New_York',
|
||||
],
|
||||
[
|
||||
'email' => sprintf('contact3%s@email.com', mt_rand(99999, 999999)),
|
||||
'firstname' => 'contact',
|
||||
'lastname' => 'Three',
|
||||
],
|
||||
];
|
||||
|
||||
$this->client->request(Request::METHOD_POST, '/api/contacts/batch/new', $contacts);
|
||||
$clientResponse = $this->client->getResponse();
|
||||
$response = json_decode($clientResponse->getContent(), true);
|
||||
|
||||
Assert::assertEquals(Response::HTTP_CREATED, $clientResponse->getStatusCode(), $clientResponse->getContent());
|
||||
Assert::assertEquals(Response::HTTP_CREATED, $response['statusCodes'][0], $clientResponse->getContent());
|
||||
Assert::assertEquals(Response::HTTP_CREATED, $response['statusCodes'][1], $clientResponse->getContent());
|
||||
Assert::assertEquals(Response::HTTP_CREATED, $response['statusCodes'][2], $clientResponse->getContent());
|
||||
|
||||
return [
|
||||
$response['contacts'][0]['id'],
|
||||
$response['contacts'][1]['id'],
|
||||
$response['contacts'][2]['id'],
|
||||
];
|
||||
}
|
||||
|
||||
private function mockSuccessfulWebhookResponse(int $expectedToBeCalled = 0): void
|
||||
{
|
||||
$handlerStack = $this->getClientMockHandler();
|
||||
for (; $expectedToBeCalled > 0; --$expectedToBeCalled) {
|
||||
$handlerStack->append(
|
||||
function (RequestInterface $request) use (&$sendRequestCounter) {
|
||||
Assert::assertSame('/post', $request->getUri()->getPath());
|
||||
$jsonPayload = json_decode($request->getBody()->getContents(), true);
|
||||
Assert::assertNotEmpty($request->getHeader('Webhook-Signature'));
|
||||
|
||||
++$sendRequestCounter;
|
||||
|
||||
return new GuzzleResponse(Response::HTTP_OK);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function mockFailedWebhookResponse(int $expectedToBeCalled = 0): void
|
||||
{
|
||||
$handlerStack = $this->getClientMockHandler();
|
||||
for (; $expectedToBeCalled > 0; --$expectedToBeCalled) {
|
||||
$handlerStack->append(
|
||||
function (RequestInterface $request) use (&$sendRequestCounter) {
|
||||
Assert::assertSame('/post', $request->getUri()->getPath());
|
||||
$jsonPayload = json_decode($request->getBody()->getContents(), true);
|
||||
Assert::assertNotEmpty($request->getHeader('Webhook-Signature'));
|
||||
|
||||
++$sendRequestCounter;
|
||||
|
||||
return new GuzzleResponse(Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function getWebhookQueue(int $webhookId): Paginator
|
||||
{
|
||||
return $this->webhookQueueRepository->getEntities([
|
||||
'webhook_id' => $webhookId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function getQueueCountByWebhookId(int $webhookId): int
|
||||
{
|
||||
return $this->webhookQueueRepository->count([
|
||||
'webhook' => $webhookId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Entity;
|
||||
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
|
||||
class LogTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
public function testSetNote(): void
|
||||
{
|
||||
$log = new Log();
|
||||
$log->setNote("\x6d\x61\x75\x74\x69\x63");
|
||||
$this->assertSame('mautic', $log->getNote());
|
||||
|
||||
$log->setNote("\x57\xfc\x72\x74\x74\x65\x6d\x62\x65\x72\x67"); // original string is W<>rttemberg, in this '<27>' is invaliad char so it should be removed
|
||||
$this->assertSame('Wrttemberg', $log->getNote());
|
||||
|
||||
$log->setNote('mautic');
|
||||
$this->assertSame('mautic', $log->getNote());
|
||||
|
||||
$log->setNote('ěščřžýá');
|
||||
$this->assertSame('ěščřžýá', $log->getNote());
|
||||
|
||||
$log->setNote('†º5¶2KfNœã');
|
||||
$this->assertSame('†º5¶2KfNœã', $log->getNote());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Entity;
|
||||
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
class WebhookTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
public function testWasModifiedRecentlyWithNotModifiedWebhook(): void
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$this->assertNull($webhook->getDateModified());
|
||||
$this->assertFalse($webhook->wasModifiedRecently());
|
||||
}
|
||||
|
||||
public function testWasModifiedRecentlyWithWebhookModifiedAWhileBack(): void
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$webhook->setDateModified((new \DateTime())->modify('-20 days'));
|
||||
$this->assertFalse($webhook->wasModifiedRecently());
|
||||
}
|
||||
|
||||
public function testWasModifiedRecentlyWithWebhookModifiedRecently(): void
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$webhook->setDateModified((new \DateTime())->modify('-2 hours'));
|
||||
$this->assertTrue($webhook->wasModifiedRecently());
|
||||
}
|
||||
|
||||
public function testTriggersFromApiAreStoredAsEvents(): void
|
||||
{
|
||||
$webhook = new Webhook();
|
||||
$triggers = [
|
||||
'mautic.company_post_save',
|
||||
'mautic.company_post_delete',
|
||||
'mautic.lead_channel_subscription_changed',
|
||||
];
|
||||
|
||||
$webhook->setTriggers($triggers);
|
||||
|
||||
$events = $webhook->getEvents();
|
||||
Assert::assertCount(3, $events);
|
||||
|
||||
foreach ($events as $key => $event) {
|
||||
Assert::assertEquals($event->getEventType(), $triggers[$key]);
|
||||
Assert::assertSame($webhook, $event->getWebhook());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\EventListener;
|
||||
|
||||
use Mautic\CoreBundle\Helper\IpLookupHelper;
|
||||
use Mautic\CoreBundle\Model\AuditLogModel;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Event\WebhookEvent;
|
||||
use Mautic\WebhookBundle\EventListener\WebhookSubscriber;
|
||||
use Mautic\WebhookBundle\Notificator\WebhookKillNotificator;
|
||||
use Mautic\WebhookBundle\WebhookEvents;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
|
||||
class WebhookSubscriberTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
/**
|
||||
* @var MockObject&IpLookupHelper
|
||||
*/
|
||||
private MockObject $ipLookupHelper;
|
||||
|
||||
/**
|
||||
* @var MockObject&AuditLogModel
|
||||
*/
|
||||
private MockObject $auditLogModel;
|
||||
|
||||
/**
|
||||
* @var MockObject&WebhookKillNotificator
|
||||
*/
|
||||
private MockObject $webhookKillNotificator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->ipLookupHelper = $this->createMock(IpLookupHelper::class);
|
||||
$this->auditLogModel = $this->createMock(AuditLogModel::class);
|
||||
$this->webhookKillNotificator = $this->createMock(WebhookKillNotificator::class);
|
||||
}
|
||||
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
[
|
||||
WebhookEvents::WEBHOOK_POST_SAVE => ['onWebhookSave', 0],
|
||||
WebhookEvents::WEBHOOK_POST_DELETE => ['onWebhookDelete', 0],
|
||||
WebhookEvents::WEBHOOK_KILL => ['onWebhookKill', 0],
|
||||
],
|
||||
WebhookSubscriber::getSubscribedEvents()
|
||||
);
|
||||
}
|
||||
|
||||
public function testOnWebhookKill(): void
|
||||
{
|
||||
$webhookMock = $this->createMock(Webhook::class);
|
||||
$reason = 'reason';
|
||||
|
||||
$eventMock = $this->createMock(WebhookEvent::class);
|
||||
$eventMock
|
||||
->expects($this->once())
|
||||
->method('getWebhook')
|
||||
->willReturn($webhookMock);
|
||||
$eventMock
|
||||
->expects($this->once())
|
||||
->method('getReason')
|
||||
->willReturn($reason);
|
||||
|
||||
$this->webhookKillNotificator
|
||||
->expects($this->once())
|
||||
->method('send')
|
||||
->with($webhookMock, $reason);
|
||||
|
||||
$subscriber = new WebhookSubscriber($this->ipLookupHelper, $this->auditLogModel, $this->webhookKillNotificator);
|
||||
$subscriber->onWebhookKill($eventMock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Helper;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Mautic\CoreBundle\Entity\IpAddress;
|
||||
use Mautic\LeadBundle\Entity\Company;
|
||||
use Mautic\LeadBundle\Entity\CompanyRepository;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Model\CompanyModel;
|
||||
use Mautic\WebhookBundle\Helper\CampaignHelper;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class CampaignHelperTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
/**
|
||||
* @var MockObject&Lead
|
||||
*/
|
||||
private MockObject $contact;
|
||||
|
||||
/**
|
||||
* @var MockObject|Client
|
||||
*/
|
||||
private MockObject $client;
|
||||
|
||||
/**
|
||||
* @var MockObject|CompanyModel
|
||||
*/
|
||||
private MockObject $companyModel;
|
||||
|
||||
/**
|
||||
* @var MockObject|CompanyRepository
|
||||
*/
|
||||
private MockObject $companyRepository;
|
||||
|
||||
/**
|
||||
* @var ArrayCollection<int,IpAddress>
|
||||
*/
|
||||
private ArrayCollection $ipCollection;
|
||||
|
||||
private CampaignHelper $campaignHelper;
|
||||
|
||||
/**
|
||||
* @var MockObject|EventDispatcherInterface
|
||||
*/
|
||||
private MockObject $dispatcher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->contact = $this->createMock(Lead::class);
|
||||
$this->client = $this->createMock(Client::class);
|
||||
$this->companyModel = $this->createMock(CompanyModel::class);
|
||||
$this->dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$this->ipCollection = new ArrayCollection();
|
||||
$this->companyRepository = $this->getMockBuilder(CompanyRepository::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['getCompaniesByLeadId'])
|
||||
->getMock();
|
||||
|
||||
$this->companyRepository->method('getCompaniesByLeadId')->willReturn([new Company()]);
|
||||
|
||||
$this->companyModel->method('getRepository')->willReturn($this->companyRepository);
|
||||
|
||||
$this->campaignHelper = new CampaignHelper($this->client, $this->companyModel, $this->dispatcher);
|
||||
|
||||
$this->ipCollection->add((new IpAddress())->setIpAddress('127.0.0.1'));
|
||||
$this->ipCollection->add((new IpAddress())->setIpAddress('127.0.0.2'));
|
||||
|
||||
$this->contact->expects($this->once())
|
||||
->method('getProfileFields')
|
||||
->willReturn(['email' => 'john@doe.email', 'company' => 'Mautic']);
|
||||
|
||||
$this->contact->expects($this->once())
|
||||
->method('getIpAddresses')
|
||||
->willReturn($this->ipCollection);
|
||||
}
|
||||
|
||||
public function testFireWebhookWithGet(): void
|
||||
{
|
||||
$expectedUrl = 'https://mautic.org?test=tee&email=john%40doe.email&IP=127.0.0.1%2C127.0.0.2';
|
||||
|
||||
$this->client->expects($this->once())
|
||||
->method('get')
|
||||
->with($expectedUrl, [
|
||||
\GuzzleHttp\RequestOptions::HEADERS => ['test' => 'tee', 'company' => 'Mautic'],
|
||||
\GuzzleHttp\RequestOptions::TIMEOUT => 10,
|
||||
])
|
||||
->willReturn(new Response(200));
|
||||
|
||||
$this->campaignHelper->fireWebhook($this->provideSampleConfig(), $this->contact);
|
||||
}
|
||||
|
||||
public function testFireWebhookWithPost(): void
|
||||
{
|
||||
$config = $this->provideSampleConfig('post');
|
||||
|
||||
$this->client->expects($this->once())
|
||||
->method('request')
|
||||
->with('post', 'https://mautic.org', [
|
||||
\GuzzleHttp\RequestOptions::FORM_PARAMS => ['test' => 'tee', 'email' => 'john@doe.email', 'IP' => '127.0.0.1,127.0.0.2'],
|
||||
\GuzzleHttp\RequestOptions::HEADERS => ['test' => 'tee', 'company' => 'Mautic'],
|
||||
\GuzzleHttp\RequestOptions::TIMEOUT => 10,
|
||||
])
|
||||
->willReturn(new Response(200));
|
||||
|
||||
$this->campaignHelper->fireWebhook($config, $this->contact);
|
||||
}
|
||||
|
||||
public function testFireWebhookWithPostJson(): void
|
||||
{
|
||||
$config = $this->provideSampleConfig('post', 'application/json');
|
||||
$this->client->expects($this->once())
|
||||
->method('request')
|
||||
->with('post', 'https://mautic.org', [
|
||||
\GuzzleHttp\RequestOptions::HEADERS => [
|
||||
'test' => 'tee',
|
||||
'company' => 'Mautic',
|
||||
'content-type' => 'application/json',
|
||||
],
|
||||
\GuzzleHttp\RequestOptions::TIMEOUT => 10,
|
||||
\GuzzleHttp\RequestOptions::BODY => json_encode(
|
||||
['test' => 'tee', 'email' => 'john@doe.email', 'IP' => '127.0.0.1,127.0.0.2']
|
||||
),
|
||||
])
|
||||
->willReturn(new Response(200));
|
||||
|
||||
$this->campaignHelper->fireWebhook($config, $this->contact);
|
||||
}
|
||||
|
||||
public function testFireWebhookWhenReturningNotFound(): void
|
||||
{
|
||||
$this->client->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn(new Response(404));
|
||||
|
||||
$this->expectException(\OutOfRangeException::class);
|
||||
|
||||
$this->campaignHelper->fireWebhook($this->provideSampleConfig(), $this->contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function provideSampleConfig(string $method = 'get', string $type = 'application/x-www-form-urlencoded'): array
|
||||
{
|
||||
$sample = [
|
||||
'url' => 'https://mautic.org',
|
||||
'method' => $method,
|
||||
'timeout' => 10,
|
||||
'additional_data' => [
|
||||
'list' => [
|
||||
[
|
||||
'label' => 'test',
|
||||
'value' => 'tee',
|
||||
],
|
||||
[
|
||||
'label' => 'email',
|
||||
'value' => '{contactfield=email}',
|
||||
],
|
||||
[
|
||||
'label' => 'IP',
|
||||
'value' => '{contactfield=ipAddress}',
|
||||
],
|
||||
],
|
||||
],
|
||||
'headers' => [
|
||||
'list' => [
|
||||
[
|
||||
'label' => 'test',
|
||||
'value' => 'tee',
|
||||
],
|
||||
[
|
||||
'label' => 'company',
|
||||
'value' => '{contactfield=company}',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
if ('application/json' == $type) {
|
||||
array_push($sample['headers']['list'],
|
||||
[
|
||||
'label' => 'content-type',
|
||||
'value' => 'application/json',
|
||||
]);
|
||||
}
|
||||
|
||||
return $sample;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Http;
|
||||
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\PrivateAddressChecker;
|
||||
use Mautic\WebhookBundle\Http\Client;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ClientTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var MockObject&CoreParametersHelper
|
||||
*/
|
||||
private MockObject $parametersMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&GuzzleClient
|
||||
*/
|
||||
private MockObject $httpClientMock;
|
||||
|
||||
private Client $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->parametersMock = $this->createMock(CoreParametersHelper::class);
|
||||
$this->httpClientMock = $this->createMock(GuzzleClient::class);
|
||||
$this->client = new Client($this->parametersMock, $this->httpClientMock, new PrivateAddressChecker());
|
||||
}
|
||||
|
||||
public function testPost(): void
|
||||
{
|
||||
$method = 'POST';
|
||||
$url = 'https://8.8.8.8';
|
||||
$payload = ['payload'];
|
||||
$secret = 'secret123';
|
||||
$siteUrl = 'siteUrl';
|
||||
|
||||
// Calculate the expected signature the same way as the Client class
|
||||
$jsonPayload = json_encode($payload);
|
||||
$expectedSignature = base64_encode(hash_hmac('sha256', $jsonPayload, $secret, true));
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Origin-Base-URL' => $siteUrl,
|
||||
'Webhook-Signature' => $expectedSignature,
|
||||
];
|
||||
|
||||
$response = new Response();
|
||||
|
||||
$matcher = $this->exactly(2);
|
||||
$this->parametersMock->expects($matcher)
|
||||
->method('get')
|
||||
->willReturnCallback(function (string $parameter) use ($matcher, $siteUrl) {
|
||||
if (1 === $matcher->numberOfInvocations()) {
|
||||
$this->assertSame('site_url', $parameter);
|
||||
|
||||
return $siteUrl;
|
||||
}
|
||||
if (2 === $matcher->numberOfInvocations()) {
|
||||
$this->assertSame('webhook_allowed_private_addresses', $parameter);
|
||||
|
||||
return [];
|
||||
}
|
||||
throw new \RuntimeException('Unexpected method call');
|
||||
});
|
||||
|
||||
$this->httpClientMock->expects($this->once())
|
||||
->method('sendRequest')
|
||||
->with($this->callback(function (Request $request) use ($method, $url, $headers, $payload) {
|
||||
$this->assertSame($method, $request->getMethod());
|
||||
$this->assertSame($url, (string) $request->getUri());
|
||||
|
||||
foreach ($headers as $headerName => $headerValue) {
|
||||
$header = $request->getHeader($headerName);
|
||||
$this->assertSame($headerValue, $header[0]);
|
||||
}
|
||||
|
||||
$this->assertSame(json_encode($payload), (string) $request->getBody());
|
||||
|
||||
return true;
|
||||
}))
|
||||
->willReturn($response);
|
||||
|
||||
$this->assertEquals($response, $this->client->post($url, $payload, $secret));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Model;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use JMS\Serializer\SerializerInterface;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
||||
use Mautic\CoreBundle\Helper\UserHelper;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\CoreBundle\Translation\Translator;
|
||||
use Mautic\WebhookBundle\Entity\Event;
|
||||
use Mautic\WebhookBundle\Entity\Log;
|
||||
use Mautic\WebhookBundle\Entity\LogRepository;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueue;
|
||||
use Mautic\WebhookBundle\Entity\WebhookQueueRepository;
|
||||
use Mautic\WebhookBundle\Entity\WebhookRepository;
|
||||
use Mautic\WebhookBundle\Http\Client;
|
||||
use Mautic\WebhookBundle\Model\WebhookModel;
|
||||
use Mautic\WebhookBundle\Service\WebhookService;
|
||||
use Monolog\Logger;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGenerator;
|
||||
|
||||
class WebhookModelTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var MockObject&CoreParametersHelper
|
||||
*/
|
||||
private MockObject $parametersHelperMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&SerializerInterface
|
||||
*/
|
||||
private MockObject $serializerMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&EntityManager
|
||||
*/
|
||||
private MockObject $entityManagerMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&WebhookRepository
|
||||
*/
|
||||
private MockObject $webhookRepository;
|
||||
|
||||
/**
|
||||
* @var MockObject&WebhookQueueRepository
|
||||
*/
|
||||
private $webhookQueueRepository;
|
||||
|
||||
/**
|
||||
* @var MockObject&UserHelper
|
||||
*/
|
||||
private MockObject $userHelper;
|
||||
|
||||
/**
|
||||
* @var MockObject&EventDispatcherInterface
|
||||
*/
|
||||
private MockObject $eventDispatcherMock;
|
||||
|
||||
private WebhookModel $model;
|
||||
|
||||
/**
|
||||
* @var MockObject&Client
|
||||
*/
|
||||
private MockObject $httpClientMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parametersHelperMock = $this->createMock(CoreParametersHelper::class);
|
||||
$this->serializerMock = $this->createMock(SerializerInterface::class);
|
||||
$this->entityManagerMock = $this->createMock(EntityManager::class);
|
||||
$this->userHelper = $this->createMock(UserHelper::class);
|
||||
$this->webhookRepository = $this->createMock(WebhookRepository::class);
|
||||
$this->webhookQueueRepository = $this->createMock(WebhookQueueRepository::class);
|
||||
$this->httpClientMock = $this->createMock(Client::class);
|
||||
$this->eventDispatcherMock = $this->createMock(EventDispatcher::class);
|
||||
|
||||
$this->model = $this->initModel();
|
||||
}
|
||||
|
||||
public function testSaveEntity(): void
|
||||
{
|
||||
$entity = new Webhook();
|
||||
|
||||
// The secret hash is null at first.
|
||||
$this->assertNull($entity->getSecret());
|
||||
|
||||
$this->entityManagerMock->expects($this->once())
|
||||
->method('getRepository')
|
||||
->with(Webhook::class)
|
||||
->willReturn($this->webhookRepository);
|
||||
|
||||
$this->webhookRepository->expects($this->once())
|
||||
->method('saveEntity')
|
||||
->with($this->callback(function (Webhook $entity) {
|
||||
// The secret hash is not empty on save.
|
||||
$this->assertNotEmpty($entity->getSecret());
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
$this->model->saveEntity($entity);
|
||||
}
|
||||
|
||||
public function testGetEventsOrderbyDirWhenSetInWebhook(): void
|
||||
{
|
||||
$webhook = (new Webhook())->setEventsOrderbyDir('DESC');
|
||||
$this->assertEquals('DESC', $this->model->getEventsOrderbyDir($webhook));
|
||||
}
|
||||
|
||||
public function testGetEventsOrderbyDirWhenNotSetInWebhook(): void
|
||||
{
|
||||
$this->parametersHelperMock->method('get')->willReturn('DESC');
|
||||
$this->assertEquals('DESC', $this->initModel()->getEventsOrderbyDir());
|
||||
}
|
||||
|
||||
public function testGetEventsOrderbyDirWhenWebhookNotProvided(): void
|
||||
{
|
||||
$this->parametersHelperMock->method('get')->willReturn('DESC');
|
||||
$this->assertEquals('DESC', $this->initModel()->getEventsOrderbyDir());
|
||||
}
|
||||
|
||||
public function testGetWebhookPayloadForPayloadInWebhook(): void
|
||||
{
|
||||
$payload = ['the' => 'payload'];
|
||||
$webhook = new Webhook();
|
||||
$webhook->setPayload($payload);
|
||||
|
||||
$this->assertEquals($payload, $this->model->getWebhookPayload($webhook));
|
||||
}
|
||||
|
||||
public function testGetWebhookPayloadForQueueLoadedFromDatabase(): void
|
||||
{
|
||||
$queueMock = $this->createMock(WebhookQueue::class);
|
||||
$webhook = new Webhook();
|
||||
$event = new Event();
|
||||
$event->setEventType('leads');
|
||||
$queueMock->method('getPayload')->willReturn('{"the": "payload"}');
|
||||
$queueMock->method('getEvent')->willReturn($event);
|
||||
$queueMock->method('getDateAdded')->willReturn(new \DateTime('2018-04-10T15:04:57+00:00'));
|
||||
$queueMock->method('getId')->willReturn(12);
|
||||
|
||||
$queueRepositoryMock = $this->createMock(WebhookQueueRepository::class);
|
||||
|
||||
$this->parametersHelperMock->method('get')
|
||||
->willReturnCallback(function ($param) {
|
||||
if ('queue_mode' === $param) {
|
||||
return WebhookModel::COMMAND_PROCESS;
|
||||
}
|
||||
if ('webhook_retry_delay' === $param) {
|
||||
return 3600;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->entityManagerMock->expects($this->once())
|
||||
->method('getRepository')
|
||||
->with(WebhookQueue::class)
|
||||
->willReturn($queueRepositoryMock);
|
||||
|
||||
$this->entityManagerMock->expects($this->once())
|
||||
->method('detach')
|
||||
->with($queueMock);
|
||||
|
||||
$queueRepositoryMock->expects($this->once())
|
||||
->method('getEntities')
|
||||
->willReturn([$queueMock]);
|
||||
|
||||
$expectedPayload = [
|
||||
'leads' => [
|
||||
[
|
||||
'the' => 'payload',
|
||||
'timestamp' => '2018-04-10T15:04:57+00:00',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertEquals($expectedPayload, $this->initModel()->getWebhookPayload($webhook));
|
||||
}
|
||||
|
||||
public function testGetWebhookPayloadForQueueInWebhook(): void
|
||||
{
|
||||
$queue = new WebhookQueue();
|
||||
$webhook = new Webhook();
|
||||
$event = new Event();
|
||||
$event->setEventType('leads');
|
||||
$queue->setPayload('{"the": "payload"}');
|
||||
$queue->setEvent($event);
|
||||
$queue->setDateAdded(new \DateTime('2018-04-10T15:04:57+00:00'));
|
||||
|
||||
$this->parametersHelperMock->method('get')
|
||||
->willReturnCallback(function ($param) {
|
||||
if ('queue_mode' === $param) {
|
||||
return WebhookModel::IMMEDIATE_PROCESS;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$expectedPayload = [
|
||||
'leads' => [
|
||||
[
|
||||
'the' => 'payload',
|
||||
'timestamp' => '2018-04-10T15:04:57+00:00',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertEquals($expectedPayload, $this->initModel()->getWebhookPayload($webhook, $queue));
|
||||
}
|
||||
|
||||
public function testProcessWebhook(): void
|
||||
{
|
||||
$webhook = new class extends Webhook {
|
||||
public function getId(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
$webhook->setWebhookUrl('test-webhook.com');
|
||||
|
||||
$event = new Event();
|
||||
$event->setEventType('mautic.email_on_send');
|
||||
|
||||
$queue = new class extends WebhookQueue {
|
||||
public function getId(): string
|
||||
{
|
||||
return '1';
|
||||
}
|
||||
};
|
||||
$queue->setPayload('{"payload": "some data"}');
|
||||
$queue->setEvent($event);
|
||||
$queue->setDateAdded(new \DateTime('2021-04-01T16:00:00+00:00'));
|
||||
|
||||
$webhookQueueRepoMock = $this->createMock(WebhookQueueRepository::class);
|
||||
$webhookLogRepoMock = $this->createMock(LogRepository::class);
|
||||
$webhookRepoMock = $this->createMock(WebhookRepository::class);
|
||||
|
||||
$this->entityManagerMock->method('getRepository')
|
||||
->willReturnMap([
|
||||
[WebhookQueue::class, $webhookQueueRepoMock],
|
||||
[Log::class, $webhookLogRepoMock],
|
||||
[Webhook::class, $webhookRepoMock],
|
||||
]);
|
||||
|
||||
$webhookQueueRepoMock
|
||||
->method('deleteQueuesById')
|
||||
->with([1]);
|
||||
|
||||
$responsePayload = [
|
||||
'mautic.email_on_send' => [
|
||||
[
|
||||
'payload' => 'some data',
|
||||
'timestamp' => '2021-04-01T16:00:00+00:00',
|
||||
],
|
||||
],
|
||||
];
|
||||
$this->httpClientMock
|
||||
->method('post')
|
||||
->with('test-webhook.com', $responsePayload)
|
||||
->willReturn(new Response(200, [], 'Success'));
|
||||
|
||||
self::assertTrue($this->model->processWebhook($webhook, $queue));
|
||||
}
|
||||
|
||||
public function testMinAndMaxQueueIdWhenNoneIsSet(): void
|
||||
{
|
||||
$webhook = new class extends Webhook {
|
||||
public function getId(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
$webhook->setEventsOrderbyDir('ASC');
|
||||
|
||||
$this->entityManagerMock->expects($this->once())
|
||||
->method('getRepository')
|
||||
->with(WebhookQueue::class)
|
||||
->willReturn($this->webhookQueueRepository);
|
||||
|
||||
$this->webhookQueueRepository->method('getTableAlias')->willReturn('w');
|
||||
|
||||
$webhookRetryTime = (new \DateTimeImmutable())
|
||||
->format(DateTimeHelper::FORMAT_DB);
|
||||
$this->webhookQueueRepository->expects($this->once())
|
||||
->method('getEntities')
|
||||
->with(
|
||||
[
|
||||
'filter' => [
|
||||
'force' => [
|
||||
[
|
||||
'column' => 'IDENTITY(w.webhook)',
|
||||
'expr' => 'eq',
|
||||
'value' => 1,
|
||||
],
|
||||
],
|
||||
'where' => [
|
||||
[
|
||||
'expr' => 'andX',
|
||||
'val' => [
|
||||
[
|
||||
'expr' => 'orX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => 'w.retries',
|
||||
'expr' => 'eq',
|
||||
'value' => 0,
|
||||
],
|
||||
[
|
||||
'expr' => 'andX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => 'w.retries',
|
||||
'expr' => 'gt',
|
||||
'value' => 0,
|
||||
],
|
||||
[
|
||||
'column' => 'w.dateModified',
|
||||
'expr' => 'lt',
|
||||
'value' => $webhookRetryTime,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'limit' => 0,
|
||||
'iterable_mode' => true,
|
||||
'start' => 0,
|
||||
'orderBy' => 'w.retries,w.id',
|
||||
'orderByDir' => 'ASC',
|
||||
]
|
||||
);
|
||||
$this->initModel()->getWebhookQueues($webhook);
|
||||
}
|
||||
|
||||
public function testMinAndMaxQueueIdWhenBothSet(): void
|
||||
{
|
||||
$webhook = new class extends Webhook {
|
||||
public function getId(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
$webhook->setEventsOrderbyDir('ASC');
|
||||
|
||||
$this->entityManagerMock->expects($this->once())
|
||||
->method('getRepository')
|
||||
->with(WebhookQueue::class)
|
||||
->willReturn($this->webhookQueueRepository);
|
||||
|
||||
$this->webhookQueueRepository->method('getTableAlias')->willReturn('w');
|
||||
$webhookRetryTime = (new \DateTimeImmutable())
|
||||
->format(DateTimeHelper::FORMAT_DB);
|
||||
$expected = [
|
||||
'iterable_mode' => true,
|
||||
'orderBy' => 'w.retries,w.id',
|
||||
'orderByDir' => 'ASC',
|
||||
'filter' => [
|
||||
'force' => [
|
||||
[
|
||||
'column' => 'IDENTITY(w.webhook)',
|
||||
'expr' => 'eq',
|
||||
'value' => 1,
|
||||
],
|
||||
],
|
||||
'where' => [
|
||||
[
|
||||
'expr' => 'andX',
|
||||
'val' => [
|
||||
[
|
||||
'expr' => 'orX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => 'w.retries',
|
||||
'expr' => 'eq',
|
||||
'value' => 0,
|
||||
],
|
||||
[
|
||||
'expr' => 'andX',
|
||||
'val' => [
|
||||
[
|
||||
'column' => 'w.retries',
|
||||
'expr' => 'gt',
|
||||
'value' => 0,
|
||||
],
|
||||
[
|
||||
'column' => 'w.dateModified',
|
||||
'expr' => 'lt',
|
||||
'value' => $webhookRetryTime,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'column' => 'w.id',
|
||||
'expr' => 'gte',
|
||||
'value' => 20,
|
||||
],
|
||||
[
|
||||
'column' => 'w.id',
|
||||
'expr' => 'lte',
|
||||
'value' => 30,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
$this->webhookQueueRepository->expects($this->once())
|
||||
->method('getEntities')
|
||||
->with($expected);
|
||||
|
||||
$model = $this->initModel();
|
||||
$model->setMinQueueId(20);
|
||||
$model->setMaxQueueId(30);
|
||||
$model->getWebhookQueues($webhook);
|
||||
}
|
||||
|
||||
private function initModel(): WebhookModel
|
||||
{
|
||||
$webhookServiceMock = $this->createMock(WebhookService::class);
|
||||
|
||||
// create anew webhook model instance using mocks
|
||||
$model = new WebhookModel(
|
||||
$this->parametersHelperMock,
|
||||
$this->serializerMock,
|
||||
$this->httpClientMock,
|
||||
$this->entityManagerMock,
|
||||
$this->createMock(CorePermissions::class),
|
||||
$this->eventDispatcherMock,
|
||||
$this->createMock(UrlGenerator::class),
|
||||
$this->createMock(Translator::class),
|
||||
$this->userHelper,
|
||||
$this->createMock(Logger::class),
|
||||
$webhookServiceMock
|
||||
);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\WebhookBundle\Tests\Unit\Notificator;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Model\NotificationModel;
|
||||
use Mautic\EmailBundle\Helper\MailHelper;
|
||||
use Mautic\UserBundle\Entity\User;
|
||||
use Mautic\UserBundle\Entity\UserRepository;
|
||||
use Mautic\WebhookBundle\Entity\Webhook;
|
||||
use Mautic\WebhookBundle\Event\WebhookNotificationEvent;
|
||||
use Mautic\WebhookBundle\Notificator\WebhookKillNotificator;
|
||||
use Mautic\WebhookBundle\Notificator\WebhookNotificationSender;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
final class WebhookKillNotificatorTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
/**
|
||||
* @var MockObject&TranslatorInterface
|
||||
*/
|
||||
private MockObject $translatorMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&NotificationModel
|
||||
*/
|
||||
private MockObject $notificationModelMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&EntityManager
|
||||
*/
|
||||
private MockObject $entityManagerMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&MailHelper
|
||||
*/
|
||||
private MockObject $mailHelperMock;
|
||||
|
||||
/**
|
||||
* @var MockObject&Webhook
|
||||
*/
|
||||
private MockObject $webhook;
|
||||
|
||||
/**
|
||||
* @var MockObject&CoreParametersHelper
|
||||
*/
|
||||
private MockObject $coreParamHelperMock;
|
||||
|
||||
private WebhookKillNotificator $webhookKillNotificator;
|
||||
|
||||
private string $subject = 'subject';
|
||||
|
||||
private string $reason = 'reason';
|
||||
|
||||
private string $webhookName = 'Webhook name';
|
||||
|
||||
private string $generatedRoute = 'generatedRoute';
|
||||
|
||||
private string $details = 'details';
|
||||
|
||||
private string $createdBy = 'createdBy';
|
||||
|
||||
private MockObject&User $owner;
|
||||
|
||||
private string $ownerEmail = 'toEmail';
|
||||
|
||||
private ?string $modifiedBy = null;
|
||||
|
||||
/**
|
||||
* @var MockObject|UserRepository
|
||||
*/
|
||||
private $userRepositoryMock;
|
||||
|
||||
private WebhookNotificationSender $webhookNotificationSender;
|
||||
|
||||
private EventDispatcherInterface $eventDispatcher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->translatorMock = $this->createMock(TranslatorInterface::class);
|
||||
$this->notificationModelMock = $this->createMock(NotificationModel::class);
|
||||
$this->entityManagerMock = $this->createMock(EntityManager::class);
|
||||
$this->mailHelperMock = $this->createMock(MailHelper::class);
|
||||
$this->coreParamHelperMock = $this->createMock(CoreParametersHelper::class);
|
||||
$this->webhook = $this->createMock(Webhook::class);
|
||||
$this->userRepositoryMock = $this->createMock(UserRepository::class);
|
||||
$twig = $this->createMock(Environment::class);
|
||||
$this->eventDispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
|
||||
$webhookNotificationEventMock = $this->createMock(WebhookNotificationEvent::class);
|
||||
$webhookNotificationEventMock->method('canSend')->willReturn(true);
|
||||
|
||||
$twig->expects(self::once())
|
||||
->method('render')
|
||||
->willReturn($this->details);
|
||||
|
||||
$this->eventDispatcher->method('dispatch')
|
||||
->willReturn(
|
||||
$webhookNotificationEventMock
|
||||
);
|
||||
$this->webhookNotificationSender =new WebhookNotificationSender(
|
||||
$twig,
|
||||
$this->notificationModelMock,
|
||||
$this->entityManagerMock,
|
||||
$this->mailHelperMock,
|
||||
$this->coreParamHelperMock,
|
||||
$this->userRepositoryMock,
|
||||
$this->eventDispatcher
|
||||
);
|
||||
}
|
||||
|
||||
public function testSendToOwner(): void
|
||||
{
|
||||
$this->mockCommonMethods(1);
|
||||
$this->webhook
|
||||
->expects($this->once())
|
||||
->method('getCreatedBy')
|
||||
->willReturn($this->createdBy);
|
||||
|
||||
$this->webhook
|
||||
->expects($this->once())
|
||||
->method('getModifiedBy')
|
||||
->willReturn($this->modifiedBy);
|
||||
|
||||
$this->entityManagerMock
|
||||
->expects($this->once())
|
||||
->method('getReference')
|
||||
->with(User::class, $this->createdBy)
|
||||
->willReturn($this->owner);
|
||||
|
||||
$this->notificationModelMock
|
||||
->expects($this->once())
|
||||
->method('addNotification')
|
||||
->with(
|
||||
$this->details,
|
||||
'error',
|
||||
false,
|
||||
$this->subject,
|
||||
null,
|
||||
false,
|
||||
$this->owner
|
||||
);
|
||||
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setTo')
|
||||
->with([$this->ownerEmail]);
|
||||
|
||||
$this->webhookKillNotificator->send($this->webhook, $this->reason);
|
||||
}
|
||||
|
||||
public function testSendToModifier(): void
|
||||
{
|
||||
$this->ownerEmail = 'ownerEmail';
|
||||
$this->modifiedBy = 'modifiedBy';
|
||||
$modifier = $this->createMock(User::class);
|
||||
$modifierEmail = 'modifierEmail';
|
||||
|
||||
$this->mockCommonMethods(1);
|
||||
$this->webhook
|
||||
->expects($this->exactly(2))
|
||||
->method('getCreatedBy')
|
||||
->willReturn($this->createdBy);
|
||||
$this->webhook
|
||||
->expects($this->exactly(3))
|
||||
->method('getModifiedBy')
|
||||
->willReturn($this->modifiedBy);
|
||||
$matcher = $this->exactly(2);
|
||||
|
||||
$this->entityManagerMock->expects($matcher)
|
||||
->method('getReference')->willReturnCallback(function (string $entityClass, string|int $entityId) use ($matcher, $modifier) {
|
||||
$this->assertSame(User::class, $entityClass);
|
||||
if (1 === $matcher->numberOfInvocations()) {
|
||||
$this->assertSame($this->createdBy, $entityId);
|
||||
|
||||
return $this->owner;
|
||||
}
|
||||
if (2 === $matcher->numberOfInvocations()) {
|
||||
$this->assertSame($this->modifiedBy, $entityId);
|
||||
|
||||
return $modifier;
|
||||
}
|
||||
});
|
||||
|
||||
$this->notificationModelMock
|
||||
->expects($this->once())
|
||||
->method('addNotification')
|
||||
->with(
|
||||
$this->details,
|
||||
'error',
|
||||
false,
|
||||
$this->subject,
|
||||
null,
|
||||
false,
|
||||
$modifier
|
||||
);
|
||||
|
||||
$modifier
|
||||
->expects($this->once())
|
||||
->method('getEmail')
|
||||
->willReturn($modifierEmail);
|
||||
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setTo')
|
||||
->with([$modifierEmail]);
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setCc')
|
||||
->with([$this->ownerEmail], null);
|
||||
|
||||
$this->webhookKillNotificator->send($this->webhook, $this->reason);
|
||||
}
|
||||
|
||||
private function mockCommonMethods(int $sentToAuthor, ?string $emailToSend = null): void
|
||||
{
|
||||
$this->coreParamHelperMock->expects($this->any())
|
||||
->method('get')
|
||||
->willReturnOnConsecutiveCalls('from_name', $sentToAuthor, $emailToSend);
|
||||
|
||||
$this->webhookKillNotificator = new WebhookKillNotificator(
|
||||
$this->webhookNotificationSender,
|
||||
$this->translatorMock
|
||||
);
|
||||
$this->owner = $this->createMock(User::class);
|
||||
|
||||
$htmlUrl = '<a href="'.$this->generatedRoute.'" data-toggle="ajax">'.$this->webhookName.'</a>';
|
||||
$matcher = $this->exactly(2);
|
||||
$this->translatorMock->expects($matcher)
|
||||
->method('trans')->willReturnCallback(function (...$parameters) use ($matcher, $htmlUrl) {
|
||||
if (1 === $matcher->numberOfInvocations()) {
|
||||
$this->assertSame('mautic.webhook.stopped', $parameters[0]);
|
||||
|
||||
return $this->subject;
|
||||
}
|
||||
if (2 === $matcher->numberOfInvocations()) {
|
||||
$this->assertSame($this->reason, $parameters[0]);
|
||||
|
||||
return $this->reason;
|
||||
}
|
||||
if (3 === $matcher->numberOfInvocations()) {
|
||||
$this->assertSame('mautic.webhook.stopped.details', $parameters[0]);
|
||||
$this->assertSame(['%reason%' => $this->reason, '%webhook%' => $htmlUrl], $parameters[1]);
|
||||
|
||||
return $this->details;
|
||||
}
|
||||
});
|
||||
|
||||
$this->webhook->expects($this->once())
|
||||
->method('getUnHealthySince')
|
||||
->willReturn(new \DateTimeImmutable());
|
||||
|
||||
if ($sentToAuthor) {
|
||||
$this->owner
|
||||
->expects($this->once())
|
||||
->method('getEmail')
|
||||
->willReturn($this->ownerEmail);
|
||||
}
|
||||
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setSubject')
|
||||
->with($this->subject);
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setBody')
|
||||
->with($this->details);
|
||||
}
|
||||
|
||||
public function testSendToAuthorWithCC(): void
|
||||
{
|
||||
$subject = 'subject';
|
||||
$reason = 'reason';
|
||||
$webhookName = 'Webhook name';
|
||||
$generatedRoute = 'generatedRoute';
|
||||
$details = 'details';
|
||||
$createdById = 1;
|
||||
$owner = $this->createMock(User::class);
|
||||
$ownerEmail = 'owner-email@email.com';
|
||||
$modifiedById = 2;
|
||||
$modifiedBy = $this->createMock(User::class);
|
||||
$modifiedByEmail = 'modified-by@email.com';
|
||||
$htmlUrl = '<a href="'.$generatedRoute.'" data-toggle="ajax">'.$webhookName.'</a>';
|
||||
|
||||
$this->translatorMock
|
||||
->method('trans')
|
||||
->willReturnMap([
|
||||
['mautic.webhook.stopped', [], null, null, $subject],
|
||||
[$reason, [], null, null, $reason],
|
||||
[
|
||||
'mautic.webhook.stopped.details',
|
||||
[
|
||||
'%reason%' => $reason,
|
||||
'%webhook%' => $htmlUrl,
|
||||
],
|
||||
null,
|
||||
null,
|
||||
$details,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->webhook->expects($this->once())
|
||||
->method('getUnHealthySince')
|
||||
->willReturn(new \DateTimeImmutable());
|
||||
|
||||
$this->webhook
|
||||
->expects($this->exactly(2))
|
||||
->method('getCreatedBy')
|
||||
->willReturn($createdById);
|
||||
$this->webhook
|
||||
->expects($this->exactly(3))
|
||||
->method('getModifiedBy')
|
||||
->willReturn($modifiedById);
|
||||
|
||||
$this->entityManagerMock
|
||||
->method('getReference')
|
||||
->willReturnMap([
|
||||
[User::class, $createdById, $owner],
|
||||
[User::class, $modifiedById, $modifiedBy],
|
||||
]);
|
||||
|
||||
$this->notificationModelMock
|
||||
->expects($this->once())
|
||||
->method('addNotification')
|
||||
->with(
|
||||
$details,
|
||||
'error',
|
||||
false,
|
||||
$subject,
|
||||
null,
|
||||
null,
|
||||
$modifiedBy
|
||||
);
|
||||
|
||||
$modifiedBy->expects(self::atLeastOnce())->method('getEmail')->willReturn($modifiedByEmail);
|
||||
$owner->expects(self::atLeastOnce())->method('getEmail')->willReturn($ownerEmail);
|
||||
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setTo')
|
||||
->with([$modifiedByEmail], null);
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setCc')
|
||||
->with([$ownerEmail], null);
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setSubject')
|
||||
->with($subject);
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setBody')
|
||||
->with($details);
|
||||
|
||||
$this->coreParamHelperMock->expects(self::atLeastOnce())
|
||||
->method('get')
|
||||
->willReturnMap([
|
||||
['webhook_send_notification_to_author', 1, true],
|
||||
['mailer_from_name', null, 'from_name'],
|
||||
]);
|
||||
|
||||
$webhookKillNotificator = new WebhookKillNotificator(
|
||||
$this->webhookNotificationSender,
|
||||
$this->translatorMock
|
||||
);
|
||||
$webhookKillNotificator->send($this->webhook, $reason);
|
||||
}
|
||||
|
||||
public function testSendToWebHookNotificationEmail(): void
|
||||
{
|
||||
$subject = 'subject';
|
||||
$reason = 'reason';
|
||||
$webhookName = 'Webhook name';
|
||||
$generatedRoute = 'generatedRoute';
|
||||
$details = 'details';
|
||||
$createdById = 1;
|
||||
$owner = $this->createMock(User::class);
|
||||
$ownerEmail = 'owner@email.com';
|
||||
$modifiedBy = null;
|
||||
$htmlUrl = '<a href="'.$generatedRoute.'" data-toggle="ajax">'.$webhookName.'</a>';
|
||||
|
||||
$this->translatorMock
|
||||
->method('trans')
|
||||
->willReturnMap([
|
||||
['mautic.webhook.stopped', [], null, null, $subject],
|
||||
[$reason, [], null, null, $reason],
|
||||
[
|
||||
'mautic.webhook.stopped.details',
|
||||
[
|
||||
'%reason%' => $reason,
|
||||
'%webhook%' => $htmlUrl,
|
||||
],
|
||||
null,
|
||||
null,
|
||||
$details,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->webhook->expects($this->once())
|
||||
->method('getUnHealthySince')
|
||||
->willReturn(new \DateTimeImmutable());
|
||||
|
||||
$this->webhook
|
||||
->expects($this->once())
|
||||
->method('getCreatedBy')
|
||||
->willReturn($createdById);
|
||||
$this->webhook
|
||||
->expects($this->once())
|
||||
->method('getModifiedBy')
|
||||
->willReturn($modifiedBy);
|
||||
|
||||
$this->entityManagerMock
|
||||
->expects($this->once())
|
||||
->method('getReference')
|
||||
->with(User::class, $createdById)
|
||||
->willReturn($owner);
|
||||
|
||||
$this->notificationModelMock
|
||||
->expects($this->once())
|
||||
->method('addNotification')
|
||||
->with(
|
||||
$details,
|
||||
'error',
|
||||
false,
|
||||
$subject,
|
||||
null,
|
||||
null,
|
||||
$owner
|
||||
);
|
||||
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setTo')
|
||||
->with([$ownerEmail], null);
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setSubject')
|
||||
->with($subject);
|
||||
$this->mailHelperMock
|
||||
->expects($this->once())
|
||||
->method('setBody')
|
||||
->with($details);
|
||||
|
||||
$this->coreParamHelperMock->expects(self::atLeastOnce())
|
||||
->method('get')
|
||||
->willReturnMap([
|
||||
['webhook_send_notification_to_author', 1, false],
|
||||
['webhook_notification_email_addresses', null, $ownerEmail],
|
||||
['mailer_from_name', null, 'from_name'],
|
||||
]);
|
||||
|
||||
$webhookKillNotificator = new WebhookKillNotificator(
|
||||
$this->webhookNotificationSender,
|
||||
$this->translatorMock
|
||||
);
|
||||
$webhookKillNotificator->send($this->webhook, $reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
mautic.webhook.error.notfound="Webhook Not Found"
|
||||
@@ -0,0 +1,64 @@
|
||||
mautic.config.tab.webhookconfig="Webhook Settings"
|
||||
mautic.core.config.header.webhookconfig.description="Configure event processing and delivery options for webhooks."
|
||||
mautic.webhook.config.cron_process="Queue Events Only - Process Via CLI Command"
|
||||
mautic.webhook.config.form.queue.mode.tooltip="Select how to process webhook events. Process immediately will execute the webhook event. Queue mode will only add the event the queue and it must be processed by a cron command."
|
||||
mautic.webhook.config.form.queue.mode="Queue Mode"
|
||||
mautic.webhook.config.immediate_process="Process Events Immediately"
|
||||
mautic.webhook.error.notfound="Webhook Not Found"
|
||||
mautic.webhook.error.private_address="The URL you provided for the webhook is in a private IP address range, which is not allowed by default. Please provide a URL that points to a publicly accessible server, or add the address to the whitelist in the system settings if you need to use a private IP."
|
||||
mautic.webhook.form.confirmbatchdelete="Delete the selected Webhooks?"
|
||||
mautic.webhook.form.confirmdelete="Delete the Webhook, %name%?"
|
||||
mautic.webhook.form.description="Webhook Description"
|
||||
mautic.webhook.form.webhook.events="Webhook Events"
|
||||
mautic.webhook.form.webhook_url="Webhook POST Url"
|
||||
mautic.webhook.form.secret="Secret"
|
||||
mautic.webhook.secret.tooltip="This field will autogenerate if no value is provided. Use this secret hash to verify authenticity of the webhook payload on the target server."
|
||||
mautic.webhook.label.success="Success!"
|
||||
mautic.webhook.label.warning="Error Detected!"
|
||||
mautic.webhook.label.no.url="No URL specified"
|
||||
mautic.webhook.no.logs="No Webhooks Logs"
|
||||
mautic.webhook.no.logs_desc="This webhook hasn't been executed yet so there aren't any logs available"
|
||||
mautic.webhook.permissions.header="Webhook Permissions"
|
||||
mautic.webhook.permissions.webhooks="Webhooks - User has access to"
|
||||
mautic.webhook.send.test.payload="Send Test Payload"
|
||||
mautic.webhook.status="Response"
|
||||
mautic.webhook.webhook.header.edit="Edit Webhook"
|
||||
mautic.webhook.webhook.header.new="Create New Webhook"
|
||||
mautic.webhook.webhook.logs.desc="These are the logs for this hook. Each time an event triggers this hook to be activated we record the HTTP response header. A successful response should be a 200 header. All other responses are considered an error."
|
||||
mautic.webhook.webhook.logs.title="Webhook Response Log"
|
||||
mautic.webhook.webhook.logs.empty.response="Empty response body received"
|
||||
mautic.webhook.webhook.menu.index="Webhooks"
|
||||
mautic.webhook.webhook_url="Webhook Url"
|
||||
mautic.webhook.webhooks="Webhooks"
|
||||
mautic.webhook.stopped="Webhook has been stopped"
|
||||
mautic.webhook.stopped.details="%webhook% has been stopped because %reason%"
|
||||
mautic.webhook.stopped.reason="the responses were not successful for some time"
|
||||
mautic.webhook.stopped.reason.410="the response HTTP code was 410, which means the receiver doesn't want us to send more requests."
|
||||
mautic.webhook.failing="Webhook %webhook% is failing"
|
||||
mautic.webhook.failing.details="%webhook% is failing because %reason%"
|
||||
mautic.webhook.note="Note"
|
||||
mautic.webhook.runtime="Request Runtime"
|
||||
mautic.webhook.killed="Webhook was automatically deactivated because the response has errored out %limit% times in a row."
|
||||
mautic.webhook.config.event.orderby="Order of the queued events"
|
||||
mautic.webhook.config.event.orderby.tooltip="One webhook request can contain several events if queue is used. The default value is defined in settings."
|
||||
mautic.webhook.config.event.orderby.chronological="Chronological"
|
||||
mautic.webhook.config.event.orderby.reverse.chronological="Reverse Chronological"
|
||||
mautic.webhook.event.sendwebhook="Send a webhook"
|
||||
mautic.webhook.event.sendwebhook_desc="Send a webhook (only for experienced users)"
|
||||
mautic.campaign.campaign.sendwebhook="Send a webhook"
|
||||
mautic.webhook.event.sendwebhook.url="Url"
|
||||
mautic.webhook.event.sendwebhook.method="Method"
|
||||
mautic.webhook.event.sendwebhook.data="Data"
|
||||
mautic.webhook.event.sendwebhook.headers="Headers"
|
||||
mautic.webhook.event.sendwebhook.timeout="Timeout"
|
||||
mautic.webhook.contentblock.heading="Trigger external actions from Mautic events"
|
||||
mautic.webhook.contentblock.subheading="Webhooks send real-time data to other applications when specific events occur, acting as instant notifications to your external systems."
|
||||
mautic.webhook.contentblock.copy="When a contact is created, submits a form, opens an email, or triggers another event, Mautic instantly pushes relevant data to a URL you define, enabling seamless, event-driven integrations."
|
||||
mautic.webhook.config.email.details="Send email details"
|
||||
mautic.webhook.config.email.details.tooltip="If enabled, email related webhooks send detailed data."
|
||||
mautic.webhook.card.interesting="You may also find interesting"
|
||||
mautic.webhook.card.image.alt="A conceptual image symbolizing webhooks, featuring interconnected blue and purple crystalline structures that represent data packets being automatically sent between applications."
|
||||
mautic.webhook.card.heading="Send data from your account into another system or database"
|
||||
mautic.webhook.card.copy="Event driven webhooks allow you to update 3rd party applications with contact and campaign activity as they happen, using an API that can listen for webhooks."
|
||||
mautic.webhook.config.allowed_private_addresses="Allowed private addresses (one per line)"
|
||||
mautic.webhook.config.allowed_private_addresses.tooltip="Specify a list of private IPs or domains that are permitted for webhooks. This allows webhooks to interact with internal services securely."
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<p>Dear User,</p>
|
||||
<p>We wanted to bring to your attention that we've detected a failure in one of your configured webhooks. This issue has been ongoing for a significant period and requires your immediate attention to prevent potential disruptions.</p>
|
||||
|
||||
<h2>Details of the Issue:</h2>
|
||||
<div class="content">
|
||||
<p><strong>Webhook Name/ID:</strong>
|
||||
<a href="<?php echo $view['router']->url('mautic_webhook_action', ['objectAction' => 'view', 'objectId' => $webhook->getId()]); ?>">
|
||||
<?php echo $webhook->getName(); ?>
|
||||
</a>
|
||||
</p>
|
||||
<p><strong>Failure Started At:</strong> <?php echo $failing_since.' (UTC)'; ?></p>
|
||||
<p><strong>Current Status:</strong> Failing</p>
|
||||
</div>
|
||||
|
||||
<h2>Recommended Action:</h2>
|
||||
<div class="content">
|
||||
<ul>
|
||||
<li>Review the webhook configuration and ensure that the endpoint URL is correct and reachable.</li>
|
||||
<li>Check any related server logs for error messages that might indicate the cause of the failure.</li>
|
||||
<li>Please verify the authentication credentials, if required by the webhook endpoint.</li>
|
||||
<li>Consider temporarily disabling the webhook to prevent further alerts while you resolve the issue.</li>
|
||||
</ul>
|
||||
<p>Our technical support team is available to assist you if necessary.</p>
|
||||
<p>By resolving this promptly, you can prevent further complications from arising.</p>
|
||||
</div>
|
||||
|
||||
<p class="footer">Thank you for your attention to this matter.</p>
|
||||
|
||||
<p>Best Regards,</p>
|
||||
<p><?php echo $signature_from_name; ?></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<p>Dear User,</p>
|
||||
<p>We wanted to bring to your attention that we've detected a failure in one of your configured webhook so we unpublished it automatically. This issue has been ongoing for a significant period and requires your immediate attention to prevent potential disruptions.</p>
|
||||
|
||||
<h2>Details of the Issue:</h2>
|
||||
<div class="content">
|
||||
<p><strong>Webhook Name/ID:</strong>
|
||||
<a href="<?php echo $view['router']->url('mautic_webhook_action', ['objectAction' => 'view', 'objectId' => $webhook->getId()]); ?>">
|
||||
<?php echo $webhook->getName(); ?>
|
||||
</a>
|
||||
</p>
|
||||
<p><strong>Failure Started At:</strong> <?php echo $failing_since.' (UTC)'; ?></p>
|
||||
<p><strong>Current Status:</strong> Failing</p>
|
||||
</div>
|
||||
|
||||
<h2>Recommended Action:</h2>
|
||||
<div class="content">
|
||||
<ul>
|
||||
<li>Review the webhook configuration and ensure that the endpoint URL is correct and reachable.</li>
|
||||
<li>Check any related server logs for error messages that might indicate the cause of the failure.</li>
|
||||
<li>Please verify the authentication credentials, if required by the webhook endpoint.</li>
|
||||
</ul>
|
||||
<p>Our technical support team is available to assist you if necessary.</p>
|
||||
<p>By resolving this promptly, you can prevent further complications from arising.</p>
|
||||
</div>
|
||||
|
||||
<p class="footer">Thank you for your attention to this matter.</p>
|
||||
|
||||
<p>Best Regards,</p>
|
||||
<p><?php echo $signature_from_name; ?></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\WebhookBundle;
|
||||
|
||||
/**
|
||||
* Events available for MauticWebhookBundle.
|
||||
*/
|
||||
final class WebhookEvents
|
||||
{
|
||||
/**
|
||||
* The mautic.webhook_pre_save event is thrown right before a form is persisted.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookBundleEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_PRE_SAVE = 'mautic.webhook_pre_save';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_post_save event is thrown right after a form is persisted.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookBundleEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_POST_SAVE = 'mautic.webhook_post_save';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_pre_delete event is thrown before a form is deleted.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookBundleEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_PRE_DELETE = 'mautic.webhook_pre_delete';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_post_delete event is thrown after a form is deleted.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookBundleEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_POST_DELETE = 'mautic.webhook_post_delete';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_kill event is thrown when target is not available.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_KILL = 'mautic.webhook_kill';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_queue_on_add event is thrown as the queue entity is created, before it is persisted to the database.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookQueueEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_QUEUE_ON_ADD = 'mautic.webhook_queue_on_add';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_pre_execute event is thrown right before a webhook URL is executed.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookExecuteEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_PRE_EXECUTE = 'mautic.webhook_pre_execute';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_post_execute event is thrown right after a webhook URL is executed.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookExecuteEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_POST_EXECUTE = 'mautic.webhook_post_execute';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_on_build event is as the webhook form is built.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookBuild instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_ON_BUILD = 'mautic.webhook_on_build';
|
||||
|
||||
/**
|
||||
* The mautic.webhook.campaign_on_trigger event is dispatched from the mautic:campaign:trigger command.
|
||||
*
|
||||
* The event listener receives a
|
||||
* Mautic\CampaignBundle\Event\CampaignTriggerEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const ON_CAMPAIGN_TRIGGER_ACTION = 'mautic.webhook.campaign_on_trigger_action';
|
||||
|
||||
/**
|
||||
* The mautic.webhook_on_request event is fired before request is processed.
|
||||
*
|
||||
* The event listener receives a Mautic\WebhookBundle\Event\WebhookRequestEvent instance.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const WEBHOOK_ON_REQUEST = 'mautic.webhook_on_request';
|
||||
}
|
||||
Reference in New Issue
Block a user