Page MenuHomePhabricator

D19045.id.diff
No OneTemporary

D19045.id.diff

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

Mime Type
text/plain
Expires
Sat, Oct 19, 2:11 PM (4 w, 1 d ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/mf/4r/2ouknq5lxz3ugbsd
Default Alt Text
D19045.id.diff (62 KB)

Event Timeline