Merge pull request #7128 from appwrite/fix-limit-failed-webhook-attempts

Limit webhook failure attempts to 10
This commit is contained in:
Eldad A. Fux 2024-01-19 14:01:59 +01:00 committed by GitHub
commit 0206e4ee0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 385 additions and 49 deletions

View file

@ -1486,7 +1486,7 @@ $commonCollections = [
[
'$id' => ID::custom('_key_enabled_type'),
'type' => Database::INDEX_KEY,
'attributes' => ['enabled','type'],
'attributes' => ['enabled', 'type'],
'lengths' => [],
'orders' => [Database::ORDER_ASC],
],
@ -4577,6 +4577,39 @@ $consoleCollections = array_merge([
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('enabled'),
'type' => Database::VAR_BOOLEAN,
'signed' => true,
'size' => 0,
'format' => '',
'filters' => [],
'required' => false,
'default' => true,
'array' => false,
],
[
'$id' => ID::custom('logs'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => 1000000,
'signed' => true,
'required' => false,
'default' => '',
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('attempts'),
'type' => Database::VAR_INTEGER,
'format' => '',
'size' => 0,
'signed' => true,
'required' => false,
'default' => 0,
'array' => false,
'filters' => [],
],
],
'indexes' => [
[

View file

@ -6,6 +6,11 @@
rel="stylesheet"
/>
<style>
@media (max-width:500px) {
.mobile-full-width {
width: 100%;
}
}
.main a {
color: currentColor;
}
@ -169,7 +174,7 @@
</tr>
</table>
<table style="margin-top: 40px">
<table style="margin-top: 32px">
<tr>
<td>{{message}}</td>
</tr>

View file

@ -0,0 +1,20 @@
<p>Hi <strong>{{user}}</strong>,</p>
<p>Your webhook <strong>{{webhook}}</strong> on project <strong>{{project}}</strong> has been paused after {{attempts}} consecutive failures.</p>
<p>Webhook Endpoint: <strong>{{url}}</strong></p>
<p>Error: <strong>{{error}}</strong></p>
<p>To restore your webhook's functionality and reset attempts, we suggest to follow the below steps:</p>
<ol>
<li>Examine the logs of both Appwrite Console and your webhook server to identify the issue.</li>
<li>Investigate potential network issues and use webhook testing tools to verify expected behaviour.</li>
<li>Ensure the webhook endpoint is reachable and configured to accept incoming POST requests.</li>
<li>Confirm that the webhook doesn't return error status codes such as 400 or 500.</li>
</ol>
<p>After the issue is resolved, please make sure to re-enable the webhook directly through the webhook settings.</p>
<table border="0" cellspacing="0" cellpadding="0" style="padding-top: 10px; padding-bottom: 10px; margin-top: 32px">
<tr>
<td style="border-radius: 8px; display: block; width: 100%;">
<a class="mobile-full-width" rel="noopener" target="_blank" href="{{protocol}}://{{hostname}}{{redirect}}" style="font-size: 14px; font-family: Inter; color: #ffffff; text-decoration: none; background-color: #FD366E; border-radius: 8px; padding: 9px 14px; border: 1px solid #FD366E; display: inline-block; text-align:center; box-sizing: border-box;">Webhook settings</a>
</td>
</tr>
</table>

View file

@ -27,6 +27,7 @@ use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\UID;
use Utopia\Domains\Validator\PublicDomain;
use Utopia\Locale\Locale;
use Utopia\Pools\Group;
use Utopia\Registry\Registry;
@ -34,6 +35,7 @@ use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Hostname;
use Utopia\Validator\Integer;
use Utopia\Validator\Multiple;
use Utopia\Validator\Range;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
@ -897,14 +899,15 @@ App::post('/v1/projects/:projectId/webhooks')
->label('sdk.response.model', Response::MODEL_WEBHOOK)
->param('projectId', '', new UID(), 'Project unique ID.')
->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.')
->param('enabled', true, new Boolean(true), 'Enable or disable a webhook.', true)
->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
->param('url', null, new URL(['http', 'https']), 'Webhook URL.')
->param('url', '', fn ($request) => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.', false, ['request'])
->param('security', false, new Boolean(true), 'Certificate verification, false for disabled or true for enabled.')
->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true)
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, string $name, array $events, string $url, bool $security, string $httpUser, string $httpPass, Response $response, Database $dbForConsole) {
->action(function (string $projectId, string $name, bool $enabled, array $events, string $url, bool $security, string $httpUser, string $httpPass, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
@ -930,6 +933,7 @@ App::post('/v1/projects/:projectId/webhooks')
'httpUser' => $httpUser,
'httpPass' => $httpPass,
'signatureKey' => \bin2hex(\random_bytes(64)),
'enabled' => $enabled,
]);
$webhook = $dbForConsole->createDocument('webhooks', $webhook);
@ -1020,14 +1024,15 @@ App::put('/v1/projects/:projectId/webhooks/:webhookId')
->param('projectId', '', new UID(), 'Project unique ID.')
->param('webhookId', '', new UID(), 'Webhook unique ID.')
->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.')
->param('enabled', true, new Boolean(true), 'Enable or disable a webhook.', true)
->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
->param('url', null, new URL(['http', 'https']), 'Webhook URL.')
->param('url', '', fn ($request) => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.', false, ['request'])
->param('security', false, new Boolean(true), 'Certificate verification, false for disabled or true for enabled.')
->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true)
->inject('response')
->inject('dbForConsole')
->action(function (string $projectId, string $webhookId, string $name, array $events, string $url, bool $security, string $httpUser, string $httpPass, Response $response, Database $dbForConsole) {
->action(function (string $projectId, string $webhookId, string $name, bool $enabled, array $events, string $url, bool $security, string $httpUser, string $httpPass, Response $response, Database $dbForConsole) {
$project = $dbForConsole->getDocument('projects', $projectId);
@ -1052,7 +1057,12 @@ App::put('/v1/projects/:projectId/webhooks/:webhookId')
->setAttribute('url', $url)
->setAttribute('security', $security)
->setAttribute('httpUser', $httpUser)
->setAttribute('httpPass', $httpPass);
->setAttribute('httpPass', $httpPass)
->setAttribute('enabled', $enabled);
if ($enabled) {
$webhook->setAttribute('attempts', 0);
}
$dbForConsole->updateDocument('webhooks', $webhook->getId(), $webhook);
$dbForConsole->deleteCachedDocument('projects', $project->getId());

View file

@ -79,6 +79,7 @@ use Utopia\Validator\IP;
use Utopia\Validator\URL;
use Utopia\Validator\WhiteList;
use Utopia\CLI\Console;
use Utopia\Domains\Validator\PublicDomain;
const APP_NAME = 'Appwrite';
const APP_DOMAIN = 'appwrite.io';
@ -230,6 +231,12 @@ $register = new Registry();
App::setMode(App::getEnv('_APP_ENV', App::MODE_TYPE_PRODUCTION));
if (!App::isProduction()) {
// Allow specific domains to skip public domain validation in dev environment
// Useful for existing tests involving webhooks
PublicDomain::allow(['request-catcher']);
}
/*
* ENV vars
*/

View file

@ -240,7 +240,10 @@ $worker
->inject('error')
->inject('logger')
->inject('log')
->action(function (Throwable $error, ?Logger $logger, Log $log) use ($queueName) {
->inject('pools')
->action(function (Throwable $error, ?Logger $logger, Log $log, Group $pools) use ($queueName) {
$pools->reclaim();
$version = App::getEnv('_APP_VERSION', 'UNKNOWN');
if ($error instanceof PDOException) {

View file

@ -50,9 +50,9 @@
"utopia-php/cli": "0.15.*",
"utopia-php/config": "0.2.*",
"utopia-php/database": "0.45.*",
"utopia-php/domains": "0.3.*",
"utopia-php/domains": "0.5.*",
"utopia-php/dsn": "0.1.*",
"utopia-php/framework": "0.31.1",
"utopia-php/framework": "0.32.*",
"utopia-php/image": "0.5.*",
"utopia-php/locale": "0.4.*",
"utopia-php/logger": "0.3.*",

54
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0922b311842c222911fce1ae3e3b352e",
"content-hash": "b493981ce1e062708a4f86b0ceff315c",
"packages": [
{
"name": "adhocore/jwt",
@ -1964,16 +1964,16 @@
},
{
"name": "utopia-php/domains",
"version": "0.3.2",
"version": "0.5.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/domains.git",
"reference": "aaa8c9a96c69ccb397997b1f4f2299c66f77eefb"
"reference": "bf07f60326f8389f378ddf6fcde86217e5cfe18c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/domains/zipball/aaa8c9a96c69ccb397997b1f4f2299c66f77eefb",
"reference": "aaa8c9a96c69ccb397997b1f4f2299c66f77eefb",
"url": "https://api.github.com/repos/utopia-php/domains/zipball/bf07f60326f8389f378ddf6fcde86217e5cfe18c",
"reference": "bf07f60326f8389f378ddf6fcde86217e5cfe18c",
"shasum": ""
},
"require": {
@ -2018,9 +2018,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/domains/issues",
"source": "https://github.com/utopia-php/domains/tree/0.3.2"
"source": "https://github.com/utopia-php/domains/tree/0.5.0"
},
"time": "2023-07-19T16:39:24+00:00"
"time": "2024-01-03T22:04:27+00:00"
},
{
"name": "utopia-php/dsn",
@ -2071,16 +2071,16 @@
},
{
"name": "utopia-php/framework",
"version": "0.31.1",
"version": "0.32.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/http.git",
"reference": "e50d2d16f4bc31319043f3f6d3dbea36c6fd6b68"
"reference": "ad6f7e6d6b38cf5bed4e3af9a1394c59d4bb9225"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/http/zipball/e50d2d16f4bc31319043f3f6d3dbea36c6fd6b68",
"reference": "e50d2d16f4bc31319043f3f6d3dbea36c6fd6b68",
"url": "https://api.github.com/repos/utopia-php/http/zipball/ad6f7e6d6b38cf5bed4e3af9a1394c59d4bb9225",
"reference": "ad6f7e6d6b38cf5bed4e3af9a1394c59d4bb9225",
"shasum": ""
},
"require": {
@ -2110,9 +2110,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/http/issues",
"source": "https://github.com/utopia-php/http/tree/0.31.1"
"source": "https://github.com/utopia-php/http/tree/0.32.0"
},
"time": "2023-12-08T18:47:29+00:00"
"time": "2023-12-26T14:18:36+00:00"
},
{
"name": "utopia-php/image",
@ -3140,16 +3140,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
"version": "0.36.0",
"version": "0.36.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
"reference": "3a10f1f895ed71120442ff71eb6adec3fd6b4e8a"
"reference": "ca4700bfbbb8bcf1c0d5a49fc5efc38da98d0992"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/3a10f1f895ed71120442ff71eb6adec3fd6b4e8a",
"reference": "3a10f1f895ed71120442ff71eb6adec3fd6b4e8a",
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/ca4700bfbbb8bcf1c0d5a49fc5efc38da98d0992",
"reference": "ca4700bfbbb8bcf1c0d5a49fc5efc38da98d0992",
"shasum": ""
},
"require": {
@ -3185,9 +3185,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
"source": "https://github.com/appwrite/sdk-generator/tree/0.36.0"
"source": "https://github.com/appwrite/sdk-generator/tree/0.36.1"
},
"time": "2023-11-20T10:03:06+00:00"
"time": "2024-01-18T06:24:47+00:00"
},
{
"name": "doctrine/deprecations",
@ -5380,16 +5380,16 @@
},
{
"name": "squizlabs/php_codesniffer",
"version": "3.8.0",
"version": "3.8.1",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
"reference": "5805f7a4e4958dbb5e944ef1e6edae0a303765e7"
"reference": "14f5fff1e64118595db5408e946f3a22c75807f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5805f7a4e4958dbb5e944ef1e6edae0a303765e7",
"reference": "5805f7a4e4958dbb5e944ef1e6edae0a303765e7",
"url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/14f5fff1e64118595db5408e946f3a22c75807f7",
"reference": "14f5fff1e64118595db5408e946f3a22c75807f7",
"shasum": ""
},
"require": {
@ -5399,11 +5399,11 @@
"php": ">=5.4.0"
},
"require-dev": {
"phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0"
"phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
},
"bin": [
"bin/phpcs",
"bin/phpcbf"
"bin/phpcbf",
"bin/phpcs"
],
"type": "library",
"extra": {
@ -5456,7 +5456,7 @@
"type": "open_collective"
}
],
"time": "2023-12-08T12:32:31+00:00"
"time": "2024-01-11T20:47:48+00:00"
},
{
"name": "swoole/ide-helper",

View file

@ -192,6 +192,7 @@ services:
- _APP_MESSAGE_SMS_TEST_DSN
- _APP_MESSAGE_EMAIL_TEST_DSN
- _APP_MESSAGE_PUSH_TEST_DSN
appwrite-realtime:
entrypoint: realtime
<<: *x-logging
@ -289,6 +290,11 @@ services:
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
- _APP_SYSTEM_SECURITY_EMAIL_ADDRESS
- _APP_DB_HOST
- _APP_DB_PORT
- _APP_DB_SCHEMA
- _APP_DB_USER
- _APP_DB_PASS
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@ -554,6 +560,8 @@ services:
- _APP_SMTP_PASSWORD
- _APP_LOGGING_PROVIDER
- _APP_LOGGING_CONFIG
- _APP_DOMAIN
- _APP_OPTIONS_FORCE_HTTPS
appwrite-worker-messaging:
entrypoint: worker-messaging

View file

@ -29,7 +29,7 @@ class Mails extends Action
->inject('message')
->inject('register')
->inject('log')
->callback(fn(Message $message, Registry $register, Log $log) => $this->action($message, $register, $log));
->callback(fn (Message $message, Registry $register, Log $log) => $this->action($message, $register, $log));
}
/**
@ -63,6 +63,11 @@ class Mails extends Action
$name = $payload['name'];
$body = $payload['body'];
$protocol = App::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
$hostname = App::getEnv('_APP_DOMAIN');
$body = str_replace(['{{protocol}}', '{{hostname}}'], [$protocol, $hostname], $body);
$bodyTemplate = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-base.tpl');
$bodyTemplate->setParam('{{body}}', $body);
foreach ($variables as $key => $value) {

View file

@ -2,9 +2,13 @@
namespace Appwrite\Platform\Workers;
use Appwrite\Event\Mail;
use Appwrite\Template\Template;
use Exception;
use Utopia\App;
use Utopia\Database\Document;
use Utopia\Database\Database;
use Utopia\Database\Query;
use Utopia\Logger\Log;
use Utopia\Platform\Action;
use Utopia\Queue\Message;
@ -12,6 +16,8 @@ use Utopia\Queue\Message;
class Webhooks extends Action
{
private array $errors = [];
private const MAX_FAILED_ATTEMPTS = 10;
private const MAX_FILE_SIZE = 5242880; // 5 MB
public static function getName(): string
{
@ -26,18 +32,23 @@ class Webhooks extends Action
$this
->desc('Webhooks worker')
->inject('message')
->inject('dbForConsole')
->inject('queueForMails')
->inject('log')
->callback(fn (Message $message, Log $log) => $this->action($message, $log));
->callback(fn (Message $message, Database $dbForConsole, Mail $queueForMails, Log $log) => $this->action($message, $dbForConsole, $queueForMails, $log));
}
/**
* @param Message $message
* @param Database $dbForConsole
* @param Mail $queueForMails
* @param Log $log
* @return void
* @throws Exception
*/
public function action(Message $message, Log $log): void
public function action(Message $message, Database $dbForConsole, Mail $queueForMails, Log $log): void
{
$this->errors = [];
$payload = $message->getPayload() ?? [];
if (empty($payload)) {
@ -53,7 +64,7 @@ class Webhooks extends Action
foreach ($project->getAttribute('webhooks', []) as $webhook) {
if (array_intersect($webhook->getAttribute('events', []), $events)) {
$this->execute($events, $webhookPayload, $webhook, $user, $project);
$this->execute($events, $webhookPayload, $webhook, $user, $project, $dbForConsole, $queueForMails);
}
}
@ -68,10 +79,15 @@ class Webhooks extends Action
* @param Document $webhook
* @param Document $user
* @param Document $project
* @param Database $dbForConsole
* @param Mail $queueForMails
* @return void
*/
private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project): void
private function execute(array $events, string $payload, Document $webhook, Document $user, Document $project, Database $dbForConsole, Mail $queueForMails): void
{
if ($webhook->getAttribute('enabled') !== true) {
return;
}
$url = \rawurldecode($webhook->getAttribute('url'));
$signatureKey = $webhook->getAttribute('signatureKey');
@ -83,9 +99,9 @@ class Webhooks extends Action
\curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
\curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
\curl_setopt($ch, CURLOPT_HEADER, 0);
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, 0);
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
\curl_setopt($ch, CURLOPT_TIMEOUT, 15);
\curl_setopt($ch, CURLOPT_MAXFILESIZE, 5242880);
\curl_setopt($ch, CURLOPT_MAXFILESIZE, self::MAX_FILE_SIZE);
\curl_setopt($ch, CURLOPT_USERAGENT, \sprintf(
APP_USERAGENT,
App::getEnv('_APP_VERSION', 'UNKNOWN'),
@ -117,10 +133,98 @@ class Webhooks extends Action
\curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
}
if (false === \curl_exec($ch)) {
$this->errors[] = \curl_error($ch) . ' in events ' . implode(', ', $events) . ' for webhook ' . $webhook->getAttribute('name');
}
$responseBody = \curl_exec($ch);
$curlError = \curl_error($ch);
$statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
\curl_close($ch);
if (!empty($curlError) || $statusCode >= 400) {
$dbForConsole->increaseDocumentAttribute('webhooks', $webhook->getId(), 'attempts', 1);
$webhook = $dbForConsole->getDocument('webhooks', $webhook->getId());
$attempts = $webhook->getAttribute('attempts');
$logs = '';
$logs .= 'URL: ' . $webhook->getAttribute('url') . "\n";
$logs .= 'Method: ' . 'POST' . "\n";
if (!empty($curlError)) {
$logs .= 'CURL Error: ' . $curlError . "\n";
$logs .= 'Events: ' . implode(', ', $events) . "\n";
} else {
$logs .= 'Status code: ' . $statusCode . "\n";
$logs .= 'Body: ' . "\n" . \mb_strcut($responseBody, 0, 10000) . "\n"; // Limit to 10kb
}
$webhook->setAttribute('logs', $logs);
if ($attempts >= self::MAX_FAILED_ATTEMPTS) {
$webhook->setAttribute('enabled', false);
$this->sendEmailAlert($attempts, $statusCode, $webhook, $project, $dbForConsole, $queueForMails);
}
$dbForConsole->updateDocument('webhooks', $webhook->getId(), $webhook);
$dbForConsole->deleteCachedDocument('projects', $project->getId());
$this->errors[] = $logs;
} else {
$webhook->setAttribute('attempts', 0); // Reset attempts on success
$dbForConsole->updateDocument('webhooks', $webhook->getId(), $webhook);
$dbForConsole->deleteCachedDocument('projects', $project->getId());
}
}
/**
* @param int $attempts
* @param mixed $statusCode
* @param Document $webhook
* @param Document $project
* @param Database $dbForConsole
* @param Mail $queueForMails
* @return void
*/
public function sendEmailAlert(int $attempts, mixed $statusCode, Document $webhook, Document $project, Database $dbForConsole, Mail $queueForMails): void
{
$memberships = $dbForConsole->find('memberships', [
Query::equal('teamInternalId', [$project->getAttribute('teamInternalId')]),
Query::limit(APP_LIMIT_SUBQUERY)
]);
$userIds = array_column(\array_map(fn ($membership) => $membership->getArrayCopy(), $memberships), 'userId');
$users = $dbForConsole->find('users', [
Query::equal('$id', $userIds),
]);
$projectId = $project->getId();
$webhookId = $webhook->getId();
$template = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-webhook-failed.tpl');
$template->setParam('{{webhook}}', $webhook->getAttribute('name'));
$template->setParam('{{project}}', $project->getAttribute('name'));
$template->setParam('{{url}}', $webhook->getAttribute('url'));
$template->setParam('{{error}}', $curlError ?? 'The server returned ' . $statusCode . ' status code');
$template->setParam('{{redirect}}', "/console/project-$projectId/settings/webhooks/$webhookId");
$template->setParam('{{attempts}}', $attempts);
$subject = 'Webhook deliveries have been paused';
$body = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl');
$body
->setParam('{{subject}}', $subject)
->setParam('{{message}}', $template->render())
->setParam('{{year}}', date("Y"));
$queueForMails
->setSubject($subject)
->setBody($body->render());
foreach ($users as $user) {
$queueForMails
->setVariables(['user' => $user->getAttribute('name', '')])
->setName($user->getAttribute('name', ''))
->setRecipient($user->getAttribute('email'))
->trigger();
}
}
}

View file

@ -76,7 +76,24 @@ class Webhook extends Model
'default' => '',
'example' => 'ad3d581ca230e2b7059c545e5a',
])
;
->addRule('enabled', [
'type' => self::TYPE_BOOLEAN,
'description' => 'Indicates if this webhook is enabled.',
'default' => true,
'example' => true,
])
->addRule('logs', [
'type' => self::TYPE_STRING,
'description' => 'Webhook error logs from the most recent failure.',
'default' => '',
'example' => 'Failed to connect to remote server.',
])
->addRule('attempts', [
'type' => self::TYPE_INTEGER,
'description' => 'Number of consecutive failed webhook attempts.',
'default' => 0,
'example' => 10,
]);
}
/**

View file

@ -997,4 +997,128 @@ trait WebhooksBase
$this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['invited']));
$this->assertEquals(('server' === $this->getSide()), $webhook['data']['confirm']);
}
public function testCreateWebhookWithPrivateDomain(): void
{
/**
* Test for FAILURE
*/
$projectId = $this->getProject()['$id'];
$webhook = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/webhooks', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-project' => 'console',
], [
'name' => 'Webhook Test',
'enabled' => true,
'events' => [
'databases.*',
'functions.*',
'buckets.*',
'teams.*',
'users.*'
],
'url' => 'http://localhost/webhook', // private domains not allowed
'security' => false,
]);
$this->assertEquals(400, $webhook['headers']['status-code']);
}
public function testUpdateWebhookWithPrivateDomain(): void
{
/**
* Test for FAILURE
*/
$projectId = $this->getProject()['$id'];
$webhookId = $this->getProject()['webhookId'];
$webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-project' => 'console',
], [
'name' => 'Webhook Test',
'enabled' => true,
'events' => [
'databases.*',
'functions.*',
'buckets.*',
'teams.*',
'users.*'
],
'url' => 'http://localhost/webhook', // private domains not allowed
'security' => false,
]);
$this->assertEquals(400, $webhook['headers']['status-code']);
}
/**
* @depends testCreateCollection
*/
public function testWebhookAutoDisable(array $data): void
{
$projectId = $this->getProject()['$id'];
$webhookId = $this->getProject()['webhookId'];
$databaseId = $data['databaseId'];
$webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-project' => 'console',
], [
'name' => 'Webhook Test',
'enabled' => true,
'events' => [
'databases.*',
'functions.*',
'buckets.*',
'teams.*',
'users.*'
],
'url' => 'http://appwrite-non-existing-domain.com', // set non-existent URL
'security' => false,
]);
$this->assertEquals(200, $webhook['headers']['status-code']);
$this->assertNotEmpty($webhook['body']);
// trigger webhook for failure event 10 times
for ($i = 0; $i < 10; $i++) {
$newCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'newCollection' . $i,
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
],
'documentSecurity' => true,
]);
$this->assertEquals($newCollection['headers']['status-code'], 201);
$this->assertNotEmpty($newCollection['body']['$id']);
}
sleep(10);
$webhook = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/webhooks/' . $webhookId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-project' => 'console',
]));
// assert that the webhook is now disabled after 10 consecutive failures
$this->assertEquals($webhook['body']['enabled'], false);
$this->assertEquals($webhook['body']['attempts'], 10);
}
}