Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F15474493
D19045.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
62 KB
Referenced Files
None
Subscribers
None
D19045.diff
View Options
diff --git a/bin/webhook b/bin/webhook
new file mode 120000
--- /dev/null
+++ b/bin/webhook
@@ -0,0 +1 @@
+../scripts/setup/manage_webhook.php
\ No newline at end of file
diff --git a/resources/sql/autopatches/20180209.hook.01.hook.sql b/resources/sql/autopatches/20180209.hook.01.hook.sql
new file mode 100644
--- /dev/null
+++ b/resources/sql/autopatches/20180209.hook.01.hook.sql
@@ -0,0 +1,12 @@
+CREATE TABLE {$NAMESPACE}_herald.herald_webhook (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ phid VARBINARY(64) NOT NULL,
+ name VARCHAR(128) NOT NULL COLLATE {$COLLATE_TEXT},
+ webhookURI VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT},
+ viewPolicy VARBINARY(64) NOT NULL,
+ editPolicy VARBINARY(64) NOT NULL,
+ status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
+ hmacKey VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
+ dateCreated INT UNSIGNED NOT NULL,
+ dateModified INT UNSIGNED NOT NULL
+) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
diff --git a/resources/sql/autopatches/20180209.hook.02.hookxaction.sql b/resources/sql/autopatches/20180209.hook.02.hookxaction.sql
new file mode 100644
--- /dev/null
+++ b/resources/sql/autopatches/20180209.hook.02.hookxaction.sql
@@ -0,0 +1,19 @@
+CREATE TABLE {$NAMESPACE}_herald.herald_webhooktransaction (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ phid VARBINARY(64) NOT NULL,
+ authorPHID VARBINARY(64) NOT NULL,
+ objectPHID VARBINARY(64) NOT NULL,
+ viewPolicy VARBINARY(64) NOT NULL,
+ editPolicy VARBINARY(64) NOT NULL,
+ commentPHID VARBINARY(64) DEFAULT NULL,
+ commentVersion INT UNSIGNED NOT NULL,
+ transactionType VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL,
+ oldValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
+ newValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
+ contentSource LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
+ metadata LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
+ dateCreated INT UNSIGNED NOT NULL,
+ dateModified INT UNSIGNED NOT NULL,
+ UNIQUE KEY `key_phid` (`phid`),
+ KEY `key_object` (`objectPHID`)
+) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
diff --git a/resources/sql/autopatches/20180209.hook.03.hookrequest.sql b/resources/sql/autopatches/20180209.hook.03.hookrequest.sql
new file mode 100644
--- /dev/null
+++ b/resources/sql/autopatches/20180209.hook.03.hookrequest.sql
@@ -0,0 +1,12 @@
+CREATE TABLE {$NAMESPACE}_herald.herald_webhookrequest (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ phid VARBINARY(64) NOT NULL,
+ webhookPHID VARBINARY(64) NOT NULL,
+ objectPHID VARBINARY(64) NOT NULL,
+ status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
+ properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT},
+ lastRequestResult VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
+ lastRequestEpoch INT UNSIGNED NOT NULL,
+ dateCreated INT UNSIGNED NOT NULL,
+ dateModified INT UNSIGNED NOT NULL
+) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
diff --git a/scripts/setup/manage_webhook.php b/scripts/setup/manage_webhook.php
new file mode 100755
--- /dev/null
+++ b/scripts/setup/manage_webhook.php
@@ -0,0 +1,21 @@
+#!/usr/bin/env php
+<?php
+
+$root = dirname(dirname(dirname(__FILE__)));
+require_once $root.'/scripts/init/init-script.php';
+
+$args = new PhutilArgumentParser($argv);
+$args->setTagline(pht('manage webhooks'));
+$args->setSynopsis(<<<EOSYNOPSIS
+**webhook** __command__ [__options__]
+ Manage webhooks.
+
+EOSYNOPSIS
+ );
+$args->parseStandardArguments();
+
+$workflows = id(new PhutilClassMapQuery())
+ ->setAncestorClass('HeraldWebhookManagementWorkflow')
+ ->execute();
+$workflows[] = new PhutilHelpArgumentWorkflow();
+$args->parseWorkflows($workflows);
diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -1364,6 +1364,7 @@
'HeraldContentSourceField' => 'applications/herald/field/HeraldContentSourceField.php',
'HeraldController' => 'applications/herald/controller/HeraldController.php',
'HeraldCoreStateReasons' => 'applications/herald/state/HeraldCoreStateReasons.php',
+ 'HeraldCreateWebhooksCapability' => 'applications/herald/capability/HeraldCreateWebhooksCapability.php',
'HeraldDAO' => 'applications/herald/storage/HeraldDAO.php',
'HeraldDeprecatedFieldGroup' => 'applications/herald/field/HeraldDeprecatedFieldGroup.php',
'HeraldDifferentialAdapter' => 'applications/differential/herald/HeraldDifferentialAdapter.php',
@@ -1440,6 +1441,30 @@
'HeraldTranscriptSearchEngine' => 'applications/herald/query/HeraldTranscriptSearchEngine.php',
'HeraldTranscriptTestCase' => 'applications/herald/storage/__tests__/HeraldTranscriptTestCase.php',
'HeraldUtilityActionGroup' => 'applications/herald/action/HeraldUtilityActionGroup.php',
+ 'HeraldWebhook' => 'applications/herald/storage/HeraldWebhook.php',
+ 'HeraldWebhookCallManagementWorkflow' => 'applications/herald/management/HeraldWebhookCallManagementWorkflow.php',
+ 'HeraldWebhookController' => 'applications/herald/controller/HeraldWebhookController.php',
+ 'HeraldWebhookEditController' => 'applications/herald/controller/HeraldWebhookEditController.php',
+ 'HeraldWebhookEditEngine' => 'applications/herald/editor/HeraldWebhookEditEngine.php',
+ 'HeraldWebhookEditor' => 'applications/herald/editor/HeraldWebhookEditor.php',
+ 'HeraldWebhookListController' => 'applications/herald/controller/HeraldWebhookListController.php',
+ 'HeraldWebhookManagementWorkflow' => 'applications/herald/management/HeraldWebhookManagementWorkflow.php',
+ 'HeraldWebhookNameTransaction' => 'applications/herald/xaction/HeraldWebhookNameTransaction.php',
+ 'HeraldWebhookPHIDType' => 'applications/herald/phid/HeraldWebhookPHIDType.php',
+ 'HeraldWebhookQuery' => 'applications/herald/query/HeraldWebhookQuery.php',
+ 'HeraldWebhookRequest' => 'applications/herald/storage/HeraldWebhookRequest.php',
+ 'HeraldWebhookRequestListView' => 'applications/herald/view/HeraldWebhookRequestListView.php',
+ 'HeraldWebhookRequestPHIDType' => 'applications/herald/phid/HeraldWebhookRequestPHIDType.php',
+ 'HeraldWebhookRequestQuery' => 'applications/herald/query/HeraldWebhookRequestQuery.php',
+ 'HeraldWebhookSearchEngine' => 'applications/herald/query/HeraldWebhookSearchEngine.php',
+ 'HeraldWebhookStatusTransaction' => 'applications/herald/xaction/HeraldWebhookStatusTransaction.php',
+ 'HeraldWebhookTestController' => 'applications/herald/controller/HeraldWebhookTestController.php',
+ 'HeraldWebhookTransaction' => 'applications/herald/storage/HeraldWebhookTransaction.php',
+ 'HeraldWebhookTransactionQuery' => 'applications/herald/query/HeraldWebhookTransactionQuery.php',
+ 'HeraldWebhookTransactionType' => 'applications/herald/xaction/HeraldWebhookTransactionType.php',
+ 'HeraldWebhookURITransaction' => 'applications/herald/xaction/HeraldWebhookURITransaction.php',
+ 'HeraldWebhookViewController' => 'applications/herald/controller/HeraldWebhookViewController.php',
+ 'HeraldWebhookWorker' => 'applications/herald/worker/HeraldWebhookWorker.php',
'Javelin' => 'infrastructure/javelin/Javelin.php',
'LegalpadController' => 'applications/legalpad/controller/LegalpadController.php',
'LegalpadCreateDocumentsCapability' => 'applications/legalpad/capability/LegalpadCreateDocumentsCapability.php',
@@ -6614,6 +6639,7 @@
'HeraldContentSourceField' => 'HeraldField',
'HeraldController' => 'PhabricatorController',
'HeraldCoreStateReasons' => 'HeraldStateReasons',
+ 'HeraldCreateWebhooksCapability' => 'PhabricatorPolicyCapability',
'HeraldDAO' => 'PhabricatorLiskDAO',
'HeraldDeprecatedFieldGroup' => 'HeraldFieldGroup',
'HeraldDifferentialAdapter' => 'HeraldAdapter',
@@ -6704,6 +6730,39 @@
'HeraldTranscriptSearchEngine' => 'PhabricatorApplicationSearchEngine',
'HeraldTranscriptTestCase' => 'PhabricatorTestCase',
'HeraldUtilityActionGroup' => 'HeraldActionGroup',
+ 'HeraldWebhook' => array(
+ 'HeraldDAO',
+ 'PhabricatorPolicyInterface',
+ 'PhabricatorApplicationTransactionInterface',
+ 'PhabricatorDestructibleInterface',
+ ),
+ 'HeraldWebhookCallManagementWorkflow' => 'HeraldWebhookManagementWorkflow',
+ 'HeraldWebhookController' => 'HeraldController',
+ 'HeraldWebhookEditController' => 'HeraldWebhookController',
+ 'HeraldWebhookEditEngine' => 'PhabricatorEditEngine',
+ 'HeraldWebhookEditor' => 'PhabricatorApplicationTransactionEditor',
+ 'HeraldWebhookListController' => 'HeraldWebhookController',
+ 'HeraldWebhookManagementWorkflow' => 'PhabricatorManagementWorkflow',
+ 'HeraldWebhookNameTransaction' => 'HeraldWebhookTransactionType',
+ 'HeraldWebhookPHIDType' => 'PhabricatorPHIDType',
+ 'HeraldWebhookQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
+ 'HeraldWebhookRequest' => array(
+ 'HeraldDAO',
+ 'PhabricatorPolicyInterface',
+ 'PhabricatorExtendedPolicyInterface',
+ ),
+ 'HeraldWebhookRequestListView' => 'AphrontView',
+ 'HeraldWebhookRequestPHIDType' => 'PhabricatorPHIDType',
+ 'HeraldWebhookRequestQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
+ 'HeraldWebhookSearchEngine' => 'PhabricatorApplicationSearchEngine',
+ 'HeraldWebhookStatusTransaction' => 'HeraldWebhookTransactionType',
+ 'HeraldWebhookTestController' => 'HeraldWebhookController',
+ 'HeraldWebhookTransaction' => 'PhabricatorModularTransaction',
+ 'HeraldWebhookTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
+ 'HeraldWebhookTransactionType' => 'PhabricatorModularTransactionType',
+ 'HeraldWebhookURITransaction' => 'HeraldWebhookTransactionType',
+ 'HeraldWebhookViewController' => 'HeraldWebhookController',
+ 'HeraldWebhookWorker' => 'PhabricatorWorker',
'Javelin' => 'Phobject',
'LegalpadController' => 'PhabricatorController',
'LegalpadCreateDocumentsCapability' => 'PhabricatorPolicyCapability',
diff --git a/src/applications/herald/application/PhabricatorHeraldApplication.php b/src/applications/herald/application/PhabricatorHeraldApplication.php
--- a/src/applications/herald/application/PhabricatorHeraldApplication.php
+++ b/src/applications/herald/application/PhabricatorHeraldApplication.php
@@ -62,6 +62,17 @@
'(?P<id>[1-9]\d*)/'
=> 'HeraldTranscriptController',
),
+ 'webhook/' => array(
+ $this->getQueryRoutePattern() => 'HeraldWebhookListController',
+ 'view/(?P<id>\d+)/(?:request/(?P<requestID>[^/]+)/)?' =>
+ 'HeraldWebhookViewController',
+ $this->getEditRoutePattern('edit/') => 'HeraldWebhookEditController',
+ 'test/(?P<id>\d+)/' => 'HeraldWebhookTestController',
+ 'key/' => array(
+ 'view/(?P<id>\d+)/' => 'HeraldWebhookViewKeyController',
+ 'cycle/(?P<id>\d+)/' => 'HeraldWebhookCycleKeyController',
+ ),
+ ),
),
);
}
@@ -72,6 +83,9 @@
'caption' => pht('Global rules can bypass access controls.'),
'default' => PhabricatorPolicies::POLICY_ADMIN,
),
+ HeraldCreateWebhooksCapability::CAPABILITY => array(
+ 'default' => PhabricatorPolicies::POLICY_ADMIN,
+ ),
);
}
diff --git a/src/applications/herald/capability/HeraldCreateWebhooksCapability.php b/src/applications/herald/capability/HeraldCreateWebhooksCapability.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/capability/HeraldCreateWebhooksCapability.php
@@ -0,0 +1,16 @@
+<?php
+
+final class HeraldCreateWebhooksCapability
+ extends PhabricatorPolicyCapability {
+
+ const CAPABILITY = 'herald.webhooks';
+
+ public function getCapabilityName() {
+ return pht('Can Create Webhooks');
+ }
+
+ public function describeCapabilityRejection() {
+ return pht('You do not have permission to create webhooks.');
+ }
+
+}
diff --git a/src/applications/herald/controller/HeraldController.php b/src/applications/herald/controller/HeraldController.php
--- a/src/applications/herald/controller/HeraldController.php
+++ b/src/applications/herald/controller/HeraldController.php
@@ -6,18 +6,6 @@
return $this->buildSideNavView()->getMenu();
}
- protected function buildApplicationCrumbs() {
- $crumbs = parent::buildApplicationCrumbs();
-
- $crumbs->addAction(
- id(new PHUIListItemView())
- ->setName(pht('Create Herald Rule'))
- ->setHref($this->getApplicationURI('create/'))
- ->setIcon('fa-plus-square'));
-
- return $crumbs;
- }
-
public function buildSideNavView() {
$viewer = $this->getViewer();
@@ -29,8 +17,11 @@
->addNavigationItems($nav->getMenu());
$nav->addLabel(pht('Utilities'))
- ->addFilter('test', pht('Test Console'))
- ->addFilter('transcript', pht('Transcripts'));
+ ->addFilter('test', pht('Test Console'))
+ ->addFilter('transcript', pht('Transcripts'));
+
+ $nav->addLabel(pht('Webhooks'))
+ ->addFilter('webhook', pht('Webhooks'));
$nav->selectFilter(null);
diff --git a/src/applications/herald/controller/HeraldRuleListController.php b/src/applications/herald/controller/HeraldRuleListController.php
--- a/src/applications/herald/controller/HeraldRuleListController.php
+++ b/src/applications/herald/controller/HeraldRuleListController.php
@@ -17,5 +17,16 @@
return $this->delegateToController($controller);
}
+ protected function buildApplicationCrumbs() {
+ $crumbs = parent::buildApplicationCrumbs();
+
+ $crumbs->addAction(
+ id(new PHUIListItemView())
+ ->setName(pht('Create Herald Rule'))
+ ->setHref($this->getApplicationURI('create/'))
+ ->setIcon('fa-plus-square'));
+
+ return $crumbs;
+ }
}
diff --git a/src/applications/herald/controller/HeraldWebhookController.php b/src/applications/herald/controller/HeraldWebhookController.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/controller/HeraldWebhookController.php
@@ -0,0 +1,15 @@
+<?php
+
+abstract class HeraldWebhookController extends HeraldController {
+
+ protected function buildApplicationCrumbs() {
+ $crumbs = parent::buildApplicationCrumbs();
+
+ $crumbs->addTextCrumb(
+ pht('Webhooks'),
+ $this->getApplicationURI('webhook/'));
+
+ return $crumbs;
+ }
+
+}
diff --git a/src/applications/herald/controller/HeraldWebhookEditController.php b/src/applications/herald/controller/HeraldWebhookEditController.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/controller/HeraldWebhookEditController.php
@@ -0,0 +1,12 @@
+<?php
+
+final class HeraldWebhookEditController
+ extends HeraldWebhookController {
+
+ public function handleRequest(AphrontRequest $request) {
+ return id(new HeraldWebhookEditEngine())
+ ->setController($this)
+ ->buildResponse();
+ }
+
+}
diff --git a/src/applications/herald/controller/HeraldWebhookListController.php b/src/applications/herald/controller/HeraldWebhookListController.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/controller/HeraldWebhookListController.php
@@ -0,0 +1,26 @@
+<?php
+
+final class HeraldWebhookListController
+ extends HeraldWebhookController {
+
+ public function shouldAllowPublic() {
+ return true;
+ }
+
+ public function handleRequest(AphrontRequest $request) {
+ return id(new HeraldWebhookSearchEngine())
+ ->setController($this)
+ ->buildResponse();
+ }
+
+ protected function buildApplicationCrumbs() {
+ $crumbs = parent::buildApplicationCrumbs();
+
+ id(new HeraldWebhookEditEngine())
+ ->setViewer($this->getViewer())
+ ->addActionToCrumbs($crumbs);
+
+ return $crumbs;
+ }
+
+}
diff --git a/src/applications/herald/controller/HeraldWebhookTestController.php b/src/applications/herald/controller/HeraldWebhookTestController.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/controller/HeraldWebhookTestController.php
@@ -0,0 +1,45 @@
+<?php
+
+final class HeraldWebhookTestController
+ extends HeraldWebhookController {
+
+ public function handleRequest(AphrontRequest $request) {
+ $viewer = $this->getViewer();
+
+ $hook = id(new HeraldWebhookQuery())
+ ->setViewer($viewer)
+ ->withIDs(array($request->getURIData('id')))
+ ->requireCapabilities(
+ array(
+ PhabricatorPolicyCapability::CAN_VIEW,
+ PhabricatorPolicyCapability::CAN_EDIT,
+ ))
+ ->executeOne();
+ if (!$hook) {
+ return new Aphront404Response();
+ }
+
+ if ($request->isFormPost()) {
+ $object = $hook;
+
+ $request = HeraldWebhookRequest::initializeNewWebhookRequest($hook)
+ ->setObjectPHID($object->getPHID())
+ ->save();
+
+ $request->queueCall();
+
+ $next_uri = $hook->getURI().'request/'.$request->getID().'/';
+
+ return id(new AphrontRedirectResponse())->setURI($next_uri);
+ }
+
+ return $this->newDialog()
+ ->setTitle(pht('New Test Request'))
+ ->appendParagraph(
+ pht('This will make a new test request to the configured URI.'))
+ ->addCancelButton($hook->getURI())
+ ->addSubmitButton(pht('Make Request'));
+ }
+
+
+}
diff --git a/src/applications/herald/controller/HeraldWebhookViewController.php b/src/applications/herald/controller/HeraldWebhookViewController.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/controller/HeraldWebhookViewController.php
@@ -0,0 +1,160 @@
+<?php
+
+final class HeraldWebhookViewController
+ extends HeraldWebhookController {
+
+ public function shouldAllowPublic() {
+ return true;
+ }
+
+ public function handleRequest(AphrontRequest $request) {
+ $viewer = $this->getViewer();
+
+ $hook = id(new HeraldWebhookQuery())
+ ->setViewer($viewer)
+ ->withIDs(array($request->getURIData('id')))
+ ->executeOne();
+ if (!$hook) {
+ return new Aphront404Response();
+ }
+
+ $header = $this->buildHeaderView($hook);
+
+ $warnings = null;
+ if ($hook->isInErrorBackoff($viewer)) {
+ $message = pht(
+ 'Many requests to this webhook have failed recently (at least %s '.
+ 'errors in the last %s seconds). New requests are temporarily paused.',
+ $hook->getErrorBackoffThreshold(),
+ $hook->getErrorBackoffWindow());
+
+ $warnings = id(new PHUIInfoView())
+ ->setSeverity(PHUIInfoView::SEVERITY_WARNING)
+ ->setErrors(
+ array(
+ $message,
+ ));
+ }
+
+ $curtain = $this->buildCurtain($hook);
+ $properties_view = $this->buildPropertiesView($hook);
+
+ $timeline = $this->buildTransactionTimeline(
+ $hook,
+ new HeraldWebhookTransactionQuery());
+ $timeline->setShouldTerminate(true);
+
+ $requests = id(new HeraldWebhookRequestQuery())
+ ->setViewer($viewer)
+ ->withWebhookPHIDs(array($hook->getPHID()))
+ ->setLimit(20)
+ ->execute();
+
+ $requests_table = id(new HeraldWebhookRequestListView())
+ ->setViewer($viewer)
+ ->setRequests($requests)
+ ->setHighlightID($request->getURIData('requestID'));
+
+ $requests_view = id(new PHUIObjectBoxView())
+ ->setHeaderText(pht('Recent Requests'))
+ ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
+ ->setTable($requests_table);
+
+ $hook_view = id(new PHUITwoColumnView())
+ ->setHeader($header)
+ ->setMainColumn(
+ array(
+ $warnings,
+ $properties_view,
+ $requests_view,
+ $timeline,
+ ))
+ ->setCurtain($curtain);
+
+ $crumbs = $this->buildApplicationCrumbs()
+ ->addTextCrumb(pht('Webhook %d', $hook->getID()))
+ ->setBorder(true);
+
+ return $this->newPage()
+ ->setTitle(
+ array(
+ pht('Webhook %d', $hook->getID()),
+ $hook->getName(),
+ ))
+ ->setCrumbs($crumbs)
+ ->setPageObjectPHIDs(
+ array(
+ $hook->getPHID(),
+ ))
+ ->appendChild($hook_view);
+ }
+
+ private function buildHeaderView(HeraldWebhook $hook) {
+ $viewer = $this->getViewer();
+
+ $title = $hook->getName();
+
+ $header = id(new PHUIHeaderView())
+ ->setHeader($title)
+ ->setViewer($viewer)
+ ->setPolicyObject($hook)
+ ->setHeaderIcon('fa-cloud-upload');
+
+ return $header;
+ }
+
+
+ private function buildCurtain(HeraldWebhook $hook) {
+ $viewer = $this->getViewer();
+ $curtain = $this->newCurtainView($hook);
+
+ $can_edit = PhabricatorPolicyFilter::hasCapability(
+ $viewer,
+ $hook,
+ PhabricatorPolicyCapability::CAN_EDIT);
+
+ $id = $hook->getID();
+ $edit_uri = $this->getApplicationURI("webhook/edit/{$id}/");
+ $test_uri = $this->getApplicationURI("webhook/test/{$id}/");
+
+ $curtain->addAction(
+ id(new PhabricatorActionView())
+ ->setName(pht('Edit Webhook'))
+ ->setIcon('fa-pencil')
+ ->setDisabled(!$can_edit)
+ ->setWorkflow(!$can_edit)
+ ->setHref($edit_uri));
+
+ $curtain->addAction(
+ id(new PhabricatorActionView())
+ ->setName(pht('New Test Request'))
+ ->setIcon('fa-cloud-upload')
+ ->setDisabled(!$can_edit)
+ ->setWorkflow(true)
+ ->setHref($test_uri));
+
+ return $curtain;
+ }
+
+
+ private function buildPropertiesView(HeraldWebhook $hook) {
+ $viewer = $this->getViewer();
+
+ $properties = id(new PHUIPropertyListView())
+ ->setViewer($viewer);
+
+ $properties->addProperty(
+ pht('URI'),
+ $hook->getWebhookURI());
+
+ $properties->addProperty(
+ pht('Status'),
+ $hook->getStatusDisplayName());
+
+ return id(new PHUIObjectBoxView())
+ ->setHeaderText(pht('Details'))
+ ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
+ ->appendChild($properties);
+ }
+
+}
diff --git a/src/applications/herald/editor/HeraldWebhookEditEngine.php b/src/applications/herald/editor/HeraldWebhookEditEngine.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/editor/HeraldWebhookEditEngine.php
@@ -0,0 +1,105 @@
+<?php
+
+final class HeraldWebhookEditEngine
+ extends PhabricatorEditEngine {
+
+ const ENGINECONST = 'herald.webhook';
+
+ public function isEngineConfigurable() {
+ return false;
+ }
+
+ public function getEngineName() {
+ return pht('Webhooks');
+ }
+
+ public function getSummaryHeader() {
+ return pht('Edit Webhook Configurations');
+ }
+
+ public function getSummaryText() {
+ return pht('This engine is used to edit webhooks.');
+ }
+
+ public function getEngineApplicationClass() {
+ return 'PhabricatorHeraldApplication';
+ }
+
+ protected function newEditableObject() {
+ $viewer = $this->getViewer();
+ return HeraldWebhook::initializeNewWebhook($viewer);
+ }
+
+ protected function newObjectQuery() {
+ return new HeraldWebhookQuery();
+ }
+
+ protected function getObjectCreateTitleText($object) {
+ return pht('Create Webhook');
+ }
+
+ protected function getObjectCreateButtonText($object) {
+ return pht('Create Webhook');
+ }
+
+ protected function getObjectEditTitleText($object) {
+ return pht('Edit Webhook: %s', $object->getName());
+ }
+
+ protected function getObjectEditShortText($object) {
+ return pht('Edit Webhook');
+ }
+
+ protected function getObjectCreateShortText() {
+ return pht('Create Webhook');
+ }
+
+ protected function getObjectName() {
+ return pht('Webhook');
+ }
+
+ protected function getEditorURI() {
+ return '/herald/webhook/edit/';
+ }
+
+ protected function getObjectCreateCancelURI($object) {
+ return '/herald/webhook/';
+ }
+
+ protected function getObjectViewURI($object) {
+ return $object->getURI();
+ }
+
+ protected function getCreateNewObjectPolicy() {
+ return $this->getApplication()->getPolicy(
+ HeraldCreateWebhooksCapability::CAPABILITY);
+ }
+
+ protected function buildCustomEditFields($object) {
+ return array(
+ id(new PhabricatorTextEditField())
+ ->setKey('name')
+ ->setLabel(pht('Name'))
+ ->setDescription(pht('Name of the webhook.'))
+ ->setTransactionType(HeraldWebhookNameTransaction::TRANSACTIONTYPE)
+ ->setIsRequired(true)
+ ->setValue($object->getName()),
+ id(new PhabricatorTextEditField())
+ ->setKey('uri')
+ ->setLabel(pht('URI'))
+ ->setDescription(pht('URI for the webhook.'))
+ ->setTransactionType(HeraldWebhookURITransaction::TRANSACTIONTYPE)
+ ->setIsRequired(true)
+ ->setValue($object->getWebhookURI()),
+ id(new PhabricatorSelectEditField())
+ ->setKey('status')
+ ->setLabel(pht('Status'))
+ ->setDescription(pht('Status mode for the webhook.'))
+ ->setTransactionType(HeraldWebhookStatusTransaction::TRANSACTIONTYPE)
+ ->setOptions(HeraldWebhook::getStatusDisplayNameMap())
+ ->setValue($object->getStatus()),
+
+ );
+ }
+
+}
diff --git a/src/applications/herald/editor/HeraldWebhookEditor.php b/src/applications/herald/editor/HeraldWebhookEditor.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/editor/HeraldWebhookEditor.php
@@ -0,0 +1,22 @@
+<?php
+
+final class HeraldWebhookEditor
+ extends PhabricatorApplicationTransactionEditor {
+
+ public function getEditorApplicationClass() {
+ return 'PhabricatorHeraldApplication';
+ }
+
+ public function getEditorObjectsDescription() {
+ return pht('Webhooks');
+ }
+
+ public function getCreateObjectTitle($author, $object) {
+ return pht('%s created this webhook.', $author);
+ }
+
+ public function getCreateObjectTitleForFeed($author, $object) {
+ return pht('%s created %s.', $author, $object);
+ }
+
+}
diff --git a/src/applications/herald/management/HeraldWebhookCallManagementWorkflow.php b/src/applications/herald/management/HeraldWebhookCallManagementWorkflow.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/management/HeraldWebhookCallManagementWorkflow.php
@@ -0,0 +1,64 @@
+<?php
+
+final class HeraldWebhookCallManagementWorkflow
+ extends HeraldWebhookManagementWorkflow {
+
+ protected function didConstruct() {
+ $this
+ ->setName('call')
+ ->setExamples('**call** --id __id__')
+ ->setSynopsis(pht('Call a webhook.'))
+ ->setArguments(
+ array(
+ array(
+ 'name' => 'id',
+ 'param' => 'id',
+ 'help' => pht('Webhook ID to call'),
+ ),
+ ));
+ }
+
+ public function execute(PhutilArgumentParser $args) {
+ $viewer = $this->getViewer();
+
+ $id = $args->getArg('id');
+ if (!$id) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Specify a webhook to call with "--id".'));
+ }
+
+ $hook = id(new HeraldWebhookQuery())
+ ->setViewer($viewer)
+ ->withIDs(array($id))
+ ->executeOne();
+ if (!$hook) {
+ throw new PhutilArgumentUsageException(
+ pht(
+ 'Unable to load specified webhook ("%s").',
+ $id));
+ }
+
+ $object = $hook;
+
+ $application_phid = id(new PhabricatorHeraldApplication())->getPHID();
+
+ $request = HeraldWebhookRequest::initializeNewWebhookRequest($hook)
+ ->setObjectPHID($object->getPHID())
+ ->save();
+
+ PhabricatorWorker::setRunAllTasksInProcess(true);
+ $request->queueCall();
+
+ $request->reload();
+
+ echo tsprintf(
+ "%s\n",
+ pht(
+ 'Success, got HTTP %s from webhook.',
+ $request->getErrorCode()));
+
+ return 0;
+ }
+
+}
diff --git a/src/applications/herald/management/HeraldWebhookManagementWorkflow.php b/src/applications/herald/management/HeraldWebhookManagementWorkflow.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/management/HeraldWebhookManagementWorkflow.php
@@ -0,0 +1,4 @@
+<?php
+
+abstract class HeraldWebhookManagementWorkflow
+ extends PhabricatorManagementWorkflow {}
diff --git a/src/applications/herald/phid/HeraldWebhookPHIDType.php b/src/applications/herald/phid/HeraldWebhookPHIDType.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/phid/HeraldWebhookPHIDType.php
@@ -0,0 +1,49 @@
+<?php
+
+final class HeraldWebhookPHIDType extends PhabricatorPHIDType {
+
+ const TYPECONST = 'HWBH';
+
+ public function getTypeName() {
+ return pht('Webhook');
+ }
+
+ public function newObject() {
+ return new HeraldWebhook();
+ }
+
+ public function getPHIDTypeApplicationClass() {
+ return 'PhabricatorHeraldApplication';
+ }
+
+ protected function buildQueryForObjects(
+ PhabricatorObjectQuery $query,
+ array $phids) {
+
+ return id(new HeraldWebhookQuery())
+ ->withPHIDs($phids);
+ }
+
+ public function loadHandles(
+ PhabricatorHandleQuery $query,
+ array $handles,
+ array $objects) {
+
+ foreach ($handles as $phid => $handle) {
+ $hook = $objects[$phid];
+
+ $name = $hook->getName();
+ $id = $hook->getID();
+
+ $handle
+ ->setName($name)
+ ->setURI($hook->getURI())
+ ->setFullName(pht('Webhook %d %s', $id, $name));
+
+ if ($hook->isDisabled()) {
+ $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED);
+ }
+ }
+ }
+
+}
diff --git a/src/applications/herald/phid/HeraldWebhookRequestPHIDType.php b/src/applications/herald/phid/HeraldWebhookRequestPHIDType.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/phid/HeraldWebhookRequestPHIDType.php
@@ -0,0 +1,39 @@
+<?php
+
+final class HeraldWebhookRequestPHIDType extends PhabricatorPHIDType {
+
+ const TYPECONST = 'HWBR';
+
+ public function getTypeName() {
+ return pht('Webhook Request');
+ }
+
+ public function newObject() {
+ return new HeraldWebhook();
+ }
+
+ public function getPHIDTypeApplicationClass() {
+ return 'PhabricatorHeraldApplication';
+ }
+
+ protected function buildQueryForObjects(
+ PhabricatorObjectQuery $query,
+ array $phids) {
+
+ return id(new HeraldWebhookRequestQuery())
+ ->withPHIDs($phids);
+ }
+
+ public function loadHandles(
+ PhabricatorHandleQuery $query,
+ array $handles,
+ array $objects) {
+
+ foreach ($handles as $phid => $handle) {
+ $request = $objects[$phid];
+
+ // TODO: Fill this in.
+ }
+ }
+
+}
diff --git a/src/applications/herald/query/HeraldWebhookQuery.php b/src/applications/herald/query/HeraldWebhookQuery.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/query/HeraldWebhookQuery.php
@@ -0,0 +1,64 @@
+<?php
+
+final class HeraldWebhookQuery
+ extends PhabricatorCursorPagedPolicyAwareQuery {
+
+ private $ids;
+ private $phids;
+ private $statuses;
+
+ public function withIDs(array $ids) {
+ $this->ids = $ids;
+ return $this;
+ }
+
+ public function withPHIDs(array $phids) {
+ $this->phids = $phids;
+ return $this;
+ }
+
+ public function withStatuses(array $statuses) {
+ $this->statuses = $statuses;
+ return $this;
+ }
+
+ public function newResultObject() {
+ return new HeraldWebhook();
+ }
+
+ protected function loadPage() {
+ return $this->loadStandardPage($this->newResultObject());
+ }
+
+ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
+ $where = parent::buildWhereClauseParts($conn);
+
+ if ($this->ids !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'id IN (%Ld)',
+ $this->ids);
+ }
+
+ if ($this->phids !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'phid IN (%Ls)',
+ $this->phids);
+ }
+
+ if ($this->statuses !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'status IN (%Ls)',
+ $this->statuses);
+ }
+
+ return $where;
+ }
+
+ public function getQueryApplicationClass() {
+ return 'PhabricatorHeraldApplication';
+ }
+
+}
diff --git a/src/applications/herald/query/HeraldWebhookRequestQuery.php b/src/applications/herald/query/HeraldWebhookRequestQuery.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/query/HeraldWebhookRequestQuery.php
@@ -0,0 +1,126 @@
+<?php
+
+final class HeraldWebhookRequestQuery
+ extends PhabricatorCursorPagedPolicyAwareQuery {
+
+ private $ids;
+ private $phids;
+ private $webhookPHIDs;
+ private $lastRequestEpochMin;
+ private $lastRequestEpochMax;
+ private $lastRequestResults;
+
+ public function withIDs(array $ids) {
+ $this->ids = $ids;
+ return $this;
+ }
+
+ public function withPHIDs(array $phids) {
+ $this->phids = $phids;
+ return $this;
+ }
+
+ public function withWebhookPHIDs(array $phids) {
+ $this->webhookPHIDs = $phids;
+ return $this;
+ }
+
+ public function newResultObject() {
+ return new HeraldWebhookRequest();
+ }
+
+ protected function loadPage() {
+ return $this->loadStandardPage($this->newResultObject());
+ }
+
+ public function withLastRequestEpochBetween($epoch_min, $epoch_max) {
+ $this->lastRequestEpochMin = $epoch_min;
+ $this->lastRequestEpochMax = $epoch_max;
+ return $this;
+ }
+
+ public function withLastRequestResults(array $results) {
+ $this->lastRequestResults = $results;
+ return $this;
+ }
+
+ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
+ $where = parent::buildWhereClauseParts($conn);
+
+ if ($this->ids !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'id IN (%Ld)',
+ $this->ids);
+ }
+
+ if ($this->phids !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'phid IN (%Ls)',
+ $this->phids);
+ }
+
+ if ($this->webhookPHIDs !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'webhookPHID IN (%Ls)',
+ $this->webhookPHIDs);
+ }
+
+ if ($this->lastRequestEpochMin !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'lastRequestEpoch >= %d',
+ $this->lastRequestEpochMin);
+ }
+
+ if ($this->lastRequestEpochMax !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'lastRequestEpoch <= %d',
+ $this->lastRequestEpochMax);
+ }
+
+ if ($this->lastRequestResults !== null) {
+ $where[] = qsprintf(
+ $conn,
+ 'lastRequestResult IN (%Ls)',
+ $this->lastRequestResults);
+ }
+
+ return $where;
+ }
+
+ protected function willFilterPage(array $requests) {
+ $hook_phids = mpull($requests, 'getWebhookPHID');
+
+ $hooks = id(new HeraldWebhookQuery())
+ ->setViewer($this->getViewer())
+ ->setParentQuery($this)
+ ->withPHIDs($hook_phids)
+ ->execute();
+ $hooks = mpull($hooks, null, 'getPHID');
+
+ foreach ($requests as $key => $request) {
+ $hook_phid = $request->getWebhookPHID();
+ $hook = idx($hooks, $hook_phid);
+
+ if (!$hook) {
+ unset($requests[$key]);
+ $this->didRejectResult($request);
+ continue;
+ }
+
+ $request->attachWebhook($hook);
+ }
+
+ return $requests;
+ }
+
+
+ public function getQueryApplicationClass() {
+ return 'PhabricatorHeraldApplication';
+ }
+
+}
diff --git a/src/applications/herald/query/HeraldWebhookSearchEngine.php b/src/applications/herald/query/HeraldWebhookSearchEngine.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/query/HeraldWebhookSearchEngine.php
@@ -0,0 +1,95 @@
+<?php
+
+final class HeraldWebhookSearchEngine
+ extends PhabricatorApplicationSearchEngine {
+
+ public function getResultTypeDescription() {
+ return pht('Webhooks');
+ }
+
+ public function getApplicationClassName() {
+ return 'PhabricatorHeraldApplication';
+ }
+
+ public function newQuery() {
+ return new HeraldWebhookQuery();
+ }
+
+ protected function buildQueryFromParameters(array $map) {
+ $query = $this->newQuery();
+
+ if ($map['statuses']) {
+ $query->withStatuses($map['statuses']);
+ }
+
+ return $query;
+ }
+
+ protected function buildCustomSearchFields() {
+ return array(
+ id(new PhabricatorSearchCheckboxesField())
+ ->setKey('statuses')
+ ->setLabel(pht('Status'))
+ ->setDescription(
+ pht('Search for archived or active pastes.'))
+ ->setOptions(HeraldWebhook::getStatusDisplayNameMap()),
+ );
+ }
+
+ protected function getURI($path) {
+ return '/herald/webhook/'.$path;
+ }
+
+ protected function getBuiltinQueryNames() {
+ $names = array();
+
+ $names['active'] = pht('Active');
+ $names['all'] = pht('All');
+
+ return $names;
+ }
+
+ public function buildSavedQueryFromBuiltin($query_key) {
+ $query = $this->newSavedQuery();
+ $query->setQueryKey($query_key);
+
+ switch ($query_key) {
+ case 'all':
+ return $query;
+ case 'active':
+ return $query->setParameter(
+ 'statuses',
+ array(
+ HeraldWebhook::HOOKSTATUS_FIREHOSE,
+ HeraldWebhook::HOOKSTATUS_ENABLED,
+ ));
+ }
+
+ return parent::buildSavedQueryFromBuiltin($query_key);
+ }
+
+ protected function renderResultList(
+ array $hooks,
+ PhabricatorSavedQuery $query,
+ array $handles) {
+ assert_instances_of($hooks, 'HeraldWebhook');
+
+ $viewer = $this->requireViewer();
+
+ $list = id(new PHUIObjectItemListView())
+ ->setViewer($viewer);
+ foreach ($hooks as $hook) {
+ $item = id(new PHUIObjectItemView())
+ ->setObjectName(pht('Hook %d', $hook->getID()))
+ ->setHeader($hook->getName())
+ ->setHref($hook->getURI());
+
+ $list->addItem($item);
+ }
+
+ return id(new PhabricatorApplicationSearchResultView())
+ ->setObjectList($list)
+ ->setNoDataString(pht('No webhooks found.'));
+ }
+
+}
diff --git a/src/applications/herald/query/HeraldWebhookTransactionQuery.php b/src/applications/herald/query/HeraldWebhookTransactionQuery.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/query/HeraldWebhookTransactionQuery.php
@@ -0,0 +1,10 @@
+<?php
+
+final class HeraldWebhookTransactionQuery
+ extends PhabricatorApplicationTransactionQuery {
+
+ public function getTemplateApplicationTransaction() {
+ return new HeraldWebhookTransaction();
+ }
+
+}
diff --git a/src/applications/herald/storage/HeraldWebhook.php b/src/applications/herald/storage/HeraldWebhook.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/storage/HeraldWebhook.php
@@ -0,0 +1,177 @@
+<?php
+
+final class HeraldWebhook
+ extends HeraldDAO
+ implements
+ PhabricatorPolicyInterface,
+ PhabricatorApplicationTransactionInterface,
+ PhabricatorDestructibleInterface {
+
+ protected $name;
+ protected $webhookURI;
+ protected $viewPolicy;
+ protected $editPolicy;
+ protected $status;
+ protected $hmacKey;
+
+ const HOOKSTATUS_FIREHOSE = 'firehose';
+ const HOOKSTATUS_ENABLED = 'enabled';
+ const HOOKSTATUS_DISABLED = 'disabled';
+
+ protected function getConfiguration() {
+ return array(
+ self::CONFIG_AUX_PHID => true,
+ self::CONFIG_COLUMN_SCHEMA => array(
+ 'name' => 'text128',
+ 'webhookURI' => 'text255',
+ 'status' => 'text32',
+ 'hmacKey' => 'text32',
+ ),
+ self::CONFIG_KEY_SCHEMA => array(
+ 'key_status' => array(
+ 'columns' => array('status'),
+ ),
+ ),
+ ) + parent::getConfiguration();
+ }
+
+ public function getPHIDType() {
+ return HeraldWebhookPHIDType::TYPECONST;
+ }
+
+ public static function initializeNewWebhook(PhabricatorUser $viewer) {
+ return id(new self())
+ ->setStatus(self::HOOKSTATUS_ENABLED)
+ ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy())
+ ->setEditPolicy($viewer->getPHID())
+ ->setHmacKey(Filesystem::readRandomCharacters(32));
+ }
+
+ public function getURI() {
+ return '/herald/webhook/view/'.$this->getID().'/';
+ }
+
+ public function isDisabled() {
+ return ($this->getStatus() === self::HOOKSTATUS_DISABLED);
+ }
+
+ public static function getStatusDisplayNameMap() {
+ return array(
+ self::HOOKSTATUS_FIREHOSE => pht('Firehose'),
+ self::HOOKSTATUS_ENABLED => pht('Enabled'),
+ self::HOOKSTATUS_DISABLED => pht('Disabled'),
+ );
+ }
+
+ public function getStatusDisplayName() {
+ $status = $this->getStatus();
+ return idx($this->getStatusDisplayNameMap(), $status);
+ }
+
+ public function getErrorBackoffWindow() {
+ return phutil_units('5 minutes in seconds');
+ }
+
+ public function getErrorBackoffThreshold() {
+ return 10;
+ }
+
+ public function isInErrorBackoff(PhabricatorUser $viewer) {
+ $backoff_window = $this->getErrorBackoffWindow();
+ $backoff_threshold = $this->getErrorBackoffThreshold();
+
+ $now = PhabricatorTime::getNow();
+
+ $window_start = ($now - $backoff_window);
+
+ $requests = id(new HeraldWebhookRequestQuery())
+ ->setViewer($viewer)
+ ->withWebhookPHIDs(array($this->getPHID()))
+ ->withLastRequestEpochBetween($window_start, null)
+ ->withLastRequestResults(
+ array(
+ HeraldWebhookRequest::RESULT_FAIL,
+ ))
+ ->execute();
+
+ if (count($requests) >= $backoff_threshold) {
+ return true;
+ }
+
+ return false;
+ }
+
+
+/* -( PhabricatorPolicyInterface )----------------------------------------- */
+
+
+ public function getCapabilities() {
+ return array(
+ PhabricatorPolicyCapability::CAN_VIEW,
+ PhabricatorPolicyCapability::CAN_EDIT,
+ );
+ }
+
+ public function getPolicy($capability) {
+ switch ($capability) {
+ case PhabricatorPolicyCapability::CAN_VIEW:
+ return $this->getViewPolicy();
+ case PhabricatorPolicyCapability::CAN_EDIT:
+ return $this->getEditPolicy();
+ }
+ }
+
+ public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
+ return false;
+ }
+
+
+/* -( PhabricatorApplicationTransactionInterface )------------------------- */
+
+
+ public function getApplicationTransactionEditor() {
+ return new HeraldWebhookEditor();
+ }
+
+ public function getApplicationTransactionObject() {
+ return $this;
+ }
+
+ public function getApplicationTransactionTemplate() {
+ return new HeraldWebhookTransaction();
+ }
+
+ public function willRenderTimeline(
+ PhabricatorApplicationTransactionView $timeline,
+ AphrontRequest $request) {
+ return $timeline;
+ }
+
+
+/* -( PhabricatorDestructibleInterface )----------------------------------- */
+
+
+ public function destroyObjectPermanently(
+ PhabricatorDestructionEngine $engine) {
+
+ while (true) {
+ $requests = id(new HeraldWebhookRequestQuery())
+ ->setViewer($engine->getViewer())
+ ->withWebhookPHIDs(array($this->getPHID()))
+ ->setLimit(100)
+ ->execute();
+
+ if (!$requests) {
+ break;
+ }
+
+ foreach ($requests as $request) {
+ $request->delete();
+ }
+ }
+
+ $this->delete();
+ }
+
+
+}
diff --git a/src/applications/herald/storage/HeraldWebhookRequest.php b/src/applications/herald/storage/HeraldWebhookRequest.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/storage/HeraldWebhookRequest.php
@@ -0,0 +1,191 @@
+<?php
+
+final class HeraldWebhookRequest
+ extends HeraldDAO
+ implements
+ PhabricatorPolicyInterface,
+ PhabricatorExtendedPolicyInterface {
+
+ protected $webhookPHID;
+ protected $objectPHID;
+ protected $status;
+ protected $properties = array();
+ protected $lastRequestResult;
+ protected $lastRequestEpoch;
+
+ private $webhook = self::ATTACHABLE;
+
+ const RETRY_NEVER = 'never';
+ const RETRY_FOREVER = 'forever';
+
+ const STATUS_QUEUED = 'queued';
+ const STATUS_FAILED = 'failed';
+ const STATUS_SENT = 'sent';
+
+ const RESULT_NONE = 'none';
+ const RESULT_OKAY = 'okay';
+ const RESULT_FAIL = 'fail';
+
+ protected function getConfiguration() {
+ return array(
+ self::CONFIG_AUX_PHID => true,
+ self::CONFIG_SERIALIZATION => array(
+ 'properties' => self::SERIALIZATION_JSON,
+ ),
+ self::CONFIG_COLUMN_SCHEMA => array(
+ 'status' => 'text32',
+ 'lastRequestResult' => 'text32',
+ 'lastRequestEpoch' => 'epoch',
+ ),
+ self::CONFIG_KEY_SCHEMA => array(
+ 'key_ratelimit' => array(
+ 'columns' => array(
+ 'webhookPHID',
+ 'lastRequestResult',
+ 'lastRequestEpoch',
+ ),
+ ),
+ 'key_collect' => array(
+ 'columns' => array('dateCreated'),
+ ),
+ ),
+ ) + parent::getConfiguration();
+ }
+
+ public function getPHIDType() {
+ return HeraldWebhookRequestPHIDType::TYPECONST;
+ }
+
+ public static function initializeNewWebhookRequest(HeraldWebhook $hook) {
+ return id(new self())
+ ->setWebhookPHID($hook->getPHID())
+ ->attachWebhook($hook)
+ ->setStatus(self::STATUS_QUEUED)
+ ->setRetryMode(self::RETRY_NEVER)
+ ->setLastRequestResult(self::RESULT_NONE)
+ ->setLastRequestEpoch(0);
+ }
+
+ public function getWebhook() {
+ return $this->assertAttached($this->webhook);
+ }
+
+ public function attachWebhook(HeraldWebhook $hook) {
+ $this->webhook = $hook;
+ return $this;
+ }
+
+ protected function setProperty($key, $value) {
+ $this->properties[$key] = $value;
+ return $this;
+ }
+
+ protected function getProperty($key, $default = null) {
+ return idx($this->properties, $key, $default);
+ }
+
+ public function setRetryMode($mode) {
+ return $this->setProperty('retry', $mode);
+ }
+
+ public function getRetryMode() {
+ return $this->getProperty('retry');
+ }
+
+ public function setErrorType($error_type) {
+ return $this->setProperty('errorType', $error_type);
+ }
+
+ public function getErrorType() {
+ return $this->getProperty('errorType');
+ }
+
+ public function setErrorCode($error_code) {
+ return $this->setProperty('errorCode', $error_code);
+ }
+
+ public function getErrorCode() {
+ return $this->getProperty('errorCode');
+ }
+
+ public function setTransactionPHIDs(array $phids) {
+ return $this->setProperty('transactionPHIDs', $phids);
+ }
+
+ public function getTransactionPHIDs() {
+ return $this->getProperty('transactionPHIDs', array());
+ }
+
+ public function queueCall() {
+ PhabricatorWorker::scheduleTask(
+ 'HeraldWebhookWorker',
+ array(
+ 'webhookRequestPHID' => $this->getPHID(),
+ ),
+ array(
+ 'objectPHID' => $this->getPHID(),
+ ));
+
+ return $this;
+ }
+
+ public function newStatusIcon() {
+ switch ($this->getStatus()) {
+ case self::STATUS_QUEUED:
+ $icon = 'fa-refresh';
+ $color = 'blue';
+ $tooltip = pht('Queued');
+ break;
+ case self::STATUS_SENT:
+ $icon = 'fa-check';
+ $color = 'green';
+ $tooltip = pht('Sent');
+ break;
+ case self::STATUS_FAILED:
+ default:
+ $icon = 'fa-times';
+ $color = 'red';
+ $tooltip = pht('Failed');
+ break;
+
+ }
+
+ return id(new PHUIIconView())
+ ->setIcon($icon, $color)
+ ->setTooltip($tooltip);
+ }
+
+
+/* -( PhabricatorPolicyInterface )----------------------------------------- */
+
+
+ public function getCapabilities() {
+ return array(
+ PhabricatorPolicyCapability::CAN_VIEW,
+ );
+ }
+
+ public function getPolicy($capability) {
+ switch ($capability) {
+ case PhabricatorPolicyCapability::CAN_VIEW:
+ return PhabricatorPolicies::getMostOpenPolicy();
+ }
+ }
+
+ public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
+ return false;
+ }
+
+
+/* -( PhabricatorExtendedPolicyInterface )--------------------------------- */
+
+
+ public function getExtendedPolicy($capability, PhabricatorUser $viewer) {
+ return array(
+ array($this->getWebhook(), PhabricatorPolicyCapability::CAN_VIEW),
+ );
+ }
+
+
+
+}
diff --git a/src/applications/herald/storage/HeraldWebhookTransaction.php b/src/applications/herald/storage/HeraldWebhookTransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/storage/HeraldWebhookTransaction.php
@@ -0,0 +1,22 @@
+<?php
+
+final class HeraldWebhookTransaction
+ extends PhabricatorModularTransaction {
+
+ public function getApplicationName() {
+ return 'herald';
+ }
+
+ public function getApplicationTransactionType() {
+ return HeraldWebhookPHIDType::TYPECONST;
+ }
+
+ public function getApplicationTransactionCommentObject() {
+ return null;
+ }
+
+ public function getBaseTransactionClass() {
+ return 'HeraldWebhookTransactionType';
+ }
+
+}
diff --git a/src/applications/herald/view/HeraldWebhookRequestListView.php b/src/applications/herald/view/HeraldWebhookRequestListView.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/view/HeraldWebhookRequestListView.php
@@ -0,0 +1,78 @@
+<?php
+
+final class HeraldWebhookRequestListView
+ extends AphrontView {
+
+ private $requests;
+ private $highlightID;
+
+ public function setRequests(array $requests) {
+ assert_instances_of($requests, 'HeraldWebhookRequest');
+ $this->requests = $requests;
+ return $this;
+ }
+
+ public function setHighlightID($highlight_id) {
+ $this->highlightID = $highlight_id;
+ return $this;
+ }
+
+ public function getHighlightID() {
+ return $this->highlightID;
+ }
+
+ public function render() {
+ $viewer = $this->getViewer();
+ $requests = $this->requests;
+
+ $handle_phids = array();
+ foreach ($requests as $request) {
+ $handle_phids[] = $request->getObjectPHID();
+ }
+ $handles = $viewer->loadHandles($handle_phids);
+
+ $highlight_id = $this->getHighlightID();
+
+ $rows = array();
+ $rowc = array();
+ foreach ($requests as $request) {
+ $icon = $request->newStatusIcon();
+
+ if ($highlight_id == $request->getID()) {
+ $rowc[] = 'highlighted';
+ } else {
+ $rowc[] = null;
+ }
+
+ $rows[] = array(
+ $request->getID(),
+ $icon,
+ $handles[$request->getObjectPHID()]->renderLink(),
+ $request->getErrorType(),
+ $request->getErrorCode(),
+ );
+ }
+
+ $table = id(new AphrontTableView($rows))
+ ->setRowClasses($rowc)
+ ->setHeaders(
+ array(
+ pht('ID'),
+ '',
+ pht('Object'),
+ pht('Type'),
+ pht('Code'),
+ ))
+ ->setColumnClasses(
+ array(
+ 'n',
+ '',
+ 'wide',
+ '',
+ '',
+ ));
+
+ return $table;
+ }
+
+}
diff --git a/src/applications/herald/worker/HeraldWebhookWorker.php b/src/applications/herald/worker/HeraldWebhookWorker.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/worker/HeraldWebhookWorker.php
@@ -0,0 +1,229 @@
+<?php
+
+final class HeraldWebhookWorker
+ extends PhabricatorWorker {
+
+ protected function doWork() {
+ $viewer = PhabricatorUser::getOmnipotentUser();
+
+ $data = $this->getTaskData();
+ $request_phid = idx($data, 'webhookRequestPHID');
+
+ $request = id(new HeraldWebhookRequestQuery())
+ ->setViewer($viewer)
+ ->withPHIDs(array($request_phid))
+ ->executeOne();
+ if (!$request) {
+ throw new PhabricatorWorkerPermanentFailureException(
+ pht(
+ 'Unable to load webhook request ("%s"). It may have been '.
+ 'garbage collected.',
+ $request_phid));
+ }
+
+ $status = $request->getStatus();
+ if ($status !== HeraldWebhookRequest::STATUS_QUEUED) {
+ throw new PhabricatorWorkerPermanentFailureException(
+ pht(
+ 'Webhook request ("%s") is not in "%s" status (actual '.
+ 'status is "%s"). Declining call to hook.',
+ $request_phid,
+ HeraldWebhookRequest::STATUS_QUEUED,
+ $status));
+ }
+
+ $hook = $request->getWebhook();
+
+ if ($hook->isDisabled()) {
+ $this->failRequest($request, 'hook', 'disabled');
+ throw new PhabricatorWorkerPermanentFailureException(
+ pht(
+ 'Associated hook ("%s") for webhook request ("%s") is disabled.',
+ $hook->getPHID(),
+ $request_phid));
+ }
+
+ $uri = $hook->getWebhookURI();
+ try {
+ PhabricatorEnv::requireValidRemoteURIForFetch(
+ $uri,
+ array(
+ 'http',
+ 'https',
+ ));
+ } catch (Exception $ex) {
+ $this->failRequest($request, 'hook', 'uri');
+ throw new PhabricatorWorkerPermanentFailureException(
+ pht(
+ 'Associated hook ("%s") for webhook request ("%s") has invalid '.
+ 'fetch URI: %s',
+ $hook->getPHID(),
+ $request_phid,
+ $ex->getMessage()));
+ }
+
+ $object_phid = $request->getObjectPHID();
+
+ $object = id(new PhabricatorObjectQuery())
+ ->setViewer($viewer)
+ ->withPHIDs(array($object_phid))
+ ->executeOne();
+ if (!$object) {
+ $this->failRequest($request, 'hook', 'object');
+ throw new PhabricatorWorkerPermanentFailureException(
+ pht(
+ 'Unable to load object ("%s") for webhook request ("%s").',
+ $object_phid,
+ $request_phid));
+ }
+
+ $xaction_query = PhabricatorApplicationTransactionQuery::newQueryForObject(
+ $object);
+ $xaction_phids = $request->getTransactionPHIDs();
+ if ($xaction_phids) {
+ $xactions = $xaction_query
+ ->setViewer($viewer)
+ ->withObjectPHIDs(array($object_phid))
+ ->withPHIDs($xaction_phids)
+ ->execute();
+ $xactions = mpull($xactions, null, 'getPHID');
+ } else {
+ $xactions = array();
+ }
+
+ // To prevent thundering herd issues for high volume webhooks (where
+ // a large number of workers might try to work through a request backlog
+ // simultaneously, before the error backoff can catch up), we never
+ // parallelize requests to a particular webhook.
+
+ $lock_key = 'webhook('.$hook->getPHID().')';
+ $lock = PhabricatorGlobalLock::newLock($lock_key);
+
+ try {
+ $lock->lock();
+ } catch (Exception $ex) {
+ phlog($ex);
+ throw new PhabricatorWorkerYieldException(15);
+ }
+
+ $caught = null;
+ try {
+ $this->callWebhookWithLock($hook, $request, $object, $xactions);
+ } catch (Exception $ex) {
+ $caught = $ex;
+ }
+
+ $lock->unlock();
+
+ if ($caught) {
+ throw $caught;
+ }
+ }
+
+ private function callWebhookWithLock(
+ HeraldWebhook $hook,
+ HeraldWebhookRequest $request,
+ $object,
+ array $xactions) {
+ $viewer = PhabricatorUser::getOmnipotentUser();
+
+ if ($hook->isInErrorBackoff($viewer)) {
+ throw new PhabricatorWorkerYieldException($hook->getErrorBackoffWindow());
+ }
+
+ $xaction_data = array();
+ foreach ($xactions as $xaction) {
+ $xaction_data[] = array(
+ 'phid' => $xaction->getPHID(),
+ );
+ }
+
+ $payload = array(
+ 'triggers' => array(),
+ 'object' => array(
+ 'phid' => $object->getPHID(),
+ ),
+ 'transactions' => $xaction_data,
+ );
+
+ $payload = phutil_json_encode($payload);
+ $key = $hook->getHmacKey();
+ $signature = PhabricatorHash::digestHMACSHA256($payload, $key);
+ $uri = $hook->getWebhookURI();
+
+ $future = id(new HTTPSFuture($uri))
+ ->setMethod('POST')
+ ->addHeader('Content-Type', 'application/json')
+ ->addHeader('X-Phabricator-Webhook-Signature', $signature)
+ ->setTimeout(15)
+ ->setData($payload);
+
+ list($status) = $future->resolve();
+
+ if ($status->isTimeout()) {
+ $error_type = 'timeout';
+ } else {
+ $error_type = 'http';
+ }
+ $error_code = $status->getStatusCode();
+
+ $request
+ ->setErrorType($error_type)
+ ->setErrorCode($error_code)
+ ->setLastRequestEpoch(PhabricatorTime::getNow());
+
+ $retry_forever = HeraldWebhookRequest::RETRY_FOREVER;
+ if ($status->isTimeout() || $status->isError()) {
+ $should_retry = ($request->getRetryMode() === $retry_forever);
+
+ $request
+ ->setLastRequestResult(HeraldWebhookRequest::RESULT_FAIL);
+
+ if ($should_retry) {
+ $request->save();
+
+ throw new Exception(
+ pht(
+ 'Webhook request ("%s", to "%s") failed (%s / %s). The request '.
+ 'will be retried.',
+ $request->getPHID(),
+ $uri,
+ $error_type,
+ $error_code));
+ } else {
+ $request
+ ->setStatus(HeraldWebhookRequest::STATUS_FAILED)
+ ->save();
+
+ throw new PhabricatorWorkerPermanentFailureException(
+ pht(
+ 'Webhook request ("%s", to "%s") failed (%s / %s). The request '.
+ 'will not be retried.',
+ $request->getPHID(),
+ $uri,
+ $error_type,
+ $error_code));
+ }
+ } else {
+ $request
+ ->setLastRequestResult(HeraldWebhookRequest::RESULT_OKAY)
+ ->setStatus(HeraldWebhookRequest::STATUS_SENT)
+ ->save();
+ }
+ }
+
+ private function failRequest(
+ HeraldWebhookRequest $request,
+ $error_type,
+ $error_code) {
+
+ $request
+ ->setStatus(HeraldWebhookRequest::STATUS_FAILED)
+ ->setErrorType($error_type)
+ ->setErrorCode($error_code)
+ ->setLastRequestResult(HeraldWebhookRequest::RESULT_NONE)
+ ->setLastRequestEpoch(0)
+ ->save();
+ }
+
+}
diff --git a/src/applications/herald/xaction/HeraldWebhookNameTransaction.php b/src/applications/herald/xaction/HeraldWebhookNameTransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/xaction/HeraldWebhookNameTransaction.php
@@ -0,0 +1,60 @@
+<?php
+
+final class HeraldWebhookNameTransaction
+ extends HeraldWebhookTransactionType {
+
+ const TRANSACTIONTYPE = 'name';
+
+ public function generateOldValue($object) {
+ return $object->getName();
+ }
+
+ public function applyInternalEffects($object, $value) {
+ $object->setName($value);
+ }
+
+ public function getTitle() {
+ return pht(
+ '%s renamed this webhook from %s to %s.',
+ $this->renderAuthor(),
+ $this->renderOldValue(),
+ $this->renderNewValue());
+ }
+
+ public function getTitleForFeed() {
+ return pht(
+ '%s renamed %s from %s to %s.',
+ $this->renderAuthor(),
+ $this->renderObject(),
+ $this->renderOldValue(),
+ $this->renderNewValue());
+ }
+
+ public function validateTransactions($object, array $xactions) {
+ $errors = array();
+ $viewer = $this->getActor();
+
+ if ($this->isEmptyTextTransaction($object->getName(), $xactions)) {
+ $errors[] = $this->newRequiredError(
+ pht('Webhooks must have a name.'));
+ return $errors;
+ }
+
+ $max_length = $object->getColumnMaximumByteLength('name');
+ foreach ($xactions as $xaction) {
+ $old_value = $this->generateOldValue($object);
+ $new_value = $xaction->getNewValue();
+
+ $new_length = strlen($new_value);
+ if ($new_length > $max_length) {
+ $errors[] = $this->newInvalidError(
+ pht(
+ 'Webhook names can be no longer than %s characters.',
+ new PhutilNumber($max_length)));
+ }
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/applications/herald/xaction/HeraldWebhookStatusTransaction.php b/src/applications/herald/xaction/HeraldWebhookStatusTransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/xaction/HeraldWebhookStatusTransaction.php
@@ -0,0 +1,55 @@
+<?php
+
+final class HeraldWebhookStatusTransaction
+ extends HeraldWebhookTransactionType {
+
+ const TRANSACTIONTYPE = 'status';
+
+ public function generateOldValue($object) {
+ return $object->getStatus();
+ }
+
+ public function applyInternalEffects($object, $value) {
+ $object->setStatus($value);
+ }
+
+ public function getTitle() {
+ return pht(
+ '%s changed hook status from %s to %s.',
+ $this->renderAuthor(),
+ $this->renderOldValue(),
+ $this->renderNewValue());
+ }
+
+ public function getTitleForFeed() {
+ return pht(
+ '%s changed %s from %s to %s.',
+ $this->renderAuthor(),
+ $this->renderObject(),
+ $this->renderOldValue(),
+ $this->renderNewValue());
+ }
+
+ public function validateTransactions($object, array $xactions) {
+ $errors = array();
+ $viewer = $this->getActor();
+
+ $options = HeraldWebhook::getStatusDisplayNameMap();
+
+ foreach ($xactions as $xaction) {
+ $new_value = $xaction->getNewValue();
+
+ if (!isset($options[$new_value])) {
+ $errors[] = $this->newInvalidError(
+ pht(
+ 'Webhook status "%s" is not valid. Valid statuses are: %s.',
+ $new_value,
+ implode(', ', array_keys($options))),
+ $xaction);
+ }
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/applications/herald/xaction/HeraldWebhookTransactionType.php b/src/applications/herald/xaction/HeraldWebhookTransactionType.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/xaction/HeraldWebhookTransactionType.php
@@ -0,0 +1,4 @@
+<?php
+
+abstract class HeraldWebhookTransactionType
+ extends PhabricatorModularTransactionType {}
diff --git a/src/applications/herald/xaction/HeraldWebhookURITransaction.php b/src/applications/herald/xaction/HeraldWebhookURITransaction.php
new file mode 100644
--- /dev/null
+++ b/src/applications/herald/xaction/HeraldWebhookURITransaction.php
@@ -0,0 +1,74 @@
+<?php
+
+final class HeraldWebhookURITransaction
+ extends HeraldWebhookTransactionType {
+
+ const TRANSACTIONTYPE = 'uri';
+
+ public function generateOldValue($object) {
+ return $object->getWebhookURI();
+ }
+
+ public function applyInternalEffects($object, $value) {
+ $object->setWebhookURI($value);
+ }
+
+ public function getTitle() {
+ return pht(
+ '%s changed the URI for this webhook from %s to %s.',
+ $this->renderAuthor(),
+ $this->renderOldValue(),
+ $this->renderNewValue());
+ }
+
+ public function getTitleForFeed() {
+ return pht(
+ '%s changed the URI for %s from %s to %s.',
+ $this->renderAuthor(),
+ $this->renderObject(),
+ $this->renderOldValue(),
+ $this->renderNewValue());
+ }
+
+ public function validateTransactions($object, array $xactions) {
+ $errors = array();
+ $viewer = $this->getActor();
+
+ if ($this->isEmptyTextTransaction($object->getName(), $xactions)) {
+ $errors[] = $this->newRequiredError(
+ pht('Webhooks must have a URI.'));
+ return $errors;
+ }
+
+ $max_length = $object->getColumnMaximumByteLength('webhookURI');
+ foreach ($xactions as $xaction) {
+ $old_value = $this->generateOldValue($object);
+ $new_value = $xaction->getNewValue();
+
+ $new_length = strlen($new_value);
+ if ($new_length > $max_length) {
+ $errors[] = $this->newInvalidError(
+ pht(
+ 'Webhook URIs can be no longer than %s characters.',
+ new PhutilNumber($max_length)),
+ $xaction);
+ }
+
+ try {
+ PhabricatorEnv::requireValidRemoteURIForFetch(
+ $new_value,
+ array(
+ 'http',
+ 'https',
+ ));
+ } catch (Exception $ex) {
+ $errors[] = $this->newInvalidError(
+ $ex->getMessage(),
+ $xaction);
+ }
+ }
+
+ return $errors;
+ }
+
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Apr 7, 9:29 AM (1 w, 4 d ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/qo/ke/4et23e7xwj7yg74o
Default Alt Text
D19045.diff (62 KB)
Attached To
Mode
D19045: Add skeleton code for webhooks
Attached
Detach File
Event Timeline
Log In to Comment