@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Add skeleton code for webhooks

Summary: Ref T11330. Adds general support for webhooks. This is still rough and missing a lot of pieces -- and not yet useful for anything -- but can make HTTP requests.

Test Plan: Used `bin/webhook call ...` to complete requests to a test endpoint.

Maniphest Tasks: T11330

Differential Revision: https://secure.phabricator.com/D19045

+1896 -14
+1
bin/webhook
··· 1 + ../scripts/setup/manage_webhook.php
+12
resources/sql/autopatches/20180209.hook.01.hook.sql
··· 1 + CREATE TABLE {$NAMESPACE}_herald.herald_webhook ( 2 + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 3 + phid VARBINARY(64) NOT NULL, 4 + name VARCHAR(128) NOT NULL COLLATE {$COLLATE_TEXT}, 5 + webhookURI VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT}, 6 + viewPolicy VARBINARY(64) NOT NULL, 7 + editPolicy VARBINARY(64) NOT NULL, 8 + status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT}, 9 + hmacKey VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT}, 10 + dateCreated INT UNSIGNED NOT NULL, 11 + dateModified INT UNSIGNED NOT NULL 12 + ) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
+19
resources/sql/autopatches/20180209.hook.02.hookxaction.sql
··· 1 + CREATE TABLE {$NAMESPACE}_herald.herald_webhooktransaction ( 2 + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 3 + phid VARBINARY(64) NOT NULL, 4 + authorPHID VARBINARY(64) NOT NULL, 5 + objectPHID VARBINARY(64) NOT NULL, 6 + viewPolicy VARBINARY(64) NOT NULL, 7 + editPolicy VARBINARY(64) NOT NULL, 8 + commentPHID VARBINARY(64) DEFAULT NULL, 9 + commentVersion INT UNSIGNED NOT NULL, 10 + transactionType VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL, 11 + oldValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL, 12 + newValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL, 13 + contentSource LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL, 14 + metadata LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL, 15 + dateCreated INT UNSIGNED NOT NULL, 16 + dateModified INT UNSIGNED NOT NULL, 17 + UNIQUE KEY `key_phid` (`phid`), 18 + KEY `key_object` (`objectPHID`) 19 + ) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
+12
resources/sql/autopatches/20180209.hook.03.hookrequest.sql
··· 1 + CREATE TABLE {$NAMESPACE}_herald.herald_webhookrequest ( 2 + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 3 + phid VARBINARY(64) NOT NULL, 4 + webhookPHID VARBINARY(64) NOT NULL, 5 + objectPHID VARBINARY(64) NOT NULL, 6 + status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT}, 7 + properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}, 8 + lastRequestResult VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT}, 9 + lastRequestEpoch INT UNSIGNED NOT NULL, 10 + dateCreated INT UNSIGNED NOT NULL, 11 + dateModified INT UNSIGNED NOT NULL 12 + ) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
+21
scripts/setup/manage_webhook.php
··· 1 + #!/usr/bin/env php 2 + <?php 3 + 4 + $root = dirname(dirname(dirname(__FILE__))); 5 + require_once $root.'/scripts/init/init-script.php'; 6 + 7 + $args = new PhutilArgumentParser($argv); 8 + $args->setTagline(pht('manage webhooks')); 9 + $args->setSynopsis(<<<EOSYNOPSIS 10 + **webhook** __command__ [__options__] 11 + Manage webhooks. 12 + 13 + EOSYNOPSIS 14 + ); 15 + $args->parseStandardArguments(); 16 + 17 + $workflows = id(new PhutilClassMapQuery()) 18 + ->setAncestorClass('HeraldWebhookManagementWorkflow') 19 + ->execute(); 20 + $workflows[] = new PhutilHelpArgumentWorkflow(); 21 + $args->parseWorkflows($workflows);
+59
src/__phutil_library_map__.php
··· 1364 1364 'HeraldContentSourceField' => 'applications/herald/field/HeraldContentSourceField.php', 1365 1365 'HeraldController' => 'applications/herald/controller/HeraldController.php', 1366 1366 'HeraldCoreStateReasons' => 'applications/herald/state/HeraldCoreStateReasons.php', 1367 + 'HeraldCreateWebhooksCapability' => 'applications/herald/capability/HeraldCreateWebhooksCapability.php', 1367 1368 'HeraldDAO' => 'applications/herald/storage/HeraldDAO.php', 1368 1369 'HeraldDeprecatedFieldGroup' => 'applications/herald/field/HeraldDeprecatedFieldGroup.php', 1369 1370 'HeraldDifferentialAdapter' => 'applications/differential/herald/HeraldDifferentialAdapter.php', ··· 1440 1441 'HeraldTranscriptSearchEngine' => 'applications/herald/query/HeraldTranscriptSearchEngine.php', 1441 1442 'HeraldTranscriptTestCase' => 'applications/herald/storage/__tests__/HeraldTranscriptTestCase.php', 1442 1443 'HeraldUtilityActionGroup' => 'applications/herald/action/HeraldUtilityActionGroup.php', 1444 + 'HeraldWebhook' => 'applications/herald/storage/HeraldWebhook.php', 1445 + 'HeraldWebhookCallManagementWorkflow' => 'applications/herald/management/HeraldWebhookCallManagementWorkflow.php', 1446 + 'HeraldWebhookController' => 'applications/herald/controller/HeraldWebhookController.php', 1447 + 'HeraldWebhookEditController' => 'applications/herald/controller/HeraldWebhookEditController.php', 1448 + 'HeraldWebhookEditEngine' => 'applications/herald/editor/HeraldWebhookEditEngine.php', 1449 + 'HeraldWebhookEditor' => 'applications/herald/editor/HeraldWebhookEditor.php', 1450 + 'HeraldWebhookListController' => 'applications/herald/controller/HeraldWebhookListController.php', 1451 + 'HeraldWebhookManagementWorkflow' => 'applications/herald/management/HeraldWebhookManagementWorkflow.php', 1452 + 'HeraldWebhookNameTransaction' => 'applications/herald/xaction/HeraldWebhookNameTransaction.php', 1453 + 'HeraldWebhookPHIDType' => 'applications/herald/phid/HeraldWebhookPHIDType.php', 1454 + 'HeraldWebhookQuery' => 'applications/herald/query/HeraldWebhookQuery.php', 1455 + 'HeraldWebhookRequest' => 'applications/herald/storage/HeraldWebhookRequest.php', 1456 + 'HeraldWebhookRequestListView' => 'applications/herald/view/HeraldWebhookRequestListView.php', 1457 + 'HeraldWebhookRequestPHIDType' => 'applications/herald/phid/HeraldWebhookRequestPHIDType.php', 1458 + 'HeraldWebhookRequestQuery' => 'applications/herald/query/HeraldWebhookRequestQuery.php', 1459 + 'HeraldWebhookSearchEngine' => 'applications/herald/query/HeraldWebhookSearchEngine.php', 1460 + 'HeraldWebhookStatusTransaction' => 'applications/herald/xaction/HeraldWebhookStatusTransaction.php', 1461 + 'HeraldWebhookTestController' => 'applications/herald/controller/HeraldWebhookTestController.php', 1462 + 'HeraldWebhookTransaction' => 'applications/herald/storage/HeraldWebhookTransaction.php', 1463 + 'HeraldWebhookTransactionQuery' => 'applications/herald/query/HeraldWebhookTransactionQuery.php', 1464 + 'HeraldWebhookTransactionType' => 'applications/herald/xaction/HeraldWebhookTransactionType.php', 1465 + 'HeraldWebhookURITransaction' => 'applications/herald/xaction/HeraldWebhookURITransaction.php', 1466 + 'HeraldWebhookViewController' => 'applications/herald/controller/HeraldWebhookViewController.php', 1467 + 'HeraldWebhookWorker' => 'applications/herald/worker/HeraldWebhookWorker.php', 1443 1468 'Javelin' => 'infrastructure/javelin/Javelin.php', 1444 1469 'LegalpadController' => 'applications/legalpad/controller/LegalpadController.php', 1445 1470 'LegalpadCreateDocumentsCapability' => 'applications/legalpad/capability/LegalpadCreateDocumentsCapability.php', ··· 6614 6639 'HeraldContentSourceField' => 'HeraldField', 6615 6640 'HeraldController' => 'PhabricatorController', 6616 6641 'HeraldCoreStateReasons' => 'HeraldStateReasons', 6642 + 'HeraldCreateWebhooksCapability' => 'PhabricatorPolicyCapability', 6617 6643 'HeraldDAO' => 'PhabricatorLiskDAO', 6618 6644 'HeraldDeprecatedFieldGroup' => 'HeraldFieldGroup', 6619 6645 'HeraldDifferentialAdapter' => 'HeraldAdapter', ··· 6704 6730 'HeraldTranscriptSearchEngine' => 'PhabricatorApplicationSearchEngine', 6705 6731 'HeraldTranscriptTestCase' => 'PhabricatorTestCase', 6706 6732 'HeraldUtilityActionGroup' => 'HeraldActionGroup', 6733 + 'HeraldWebhook' => array( 6734 + 'HeraldDAO', 6735 + 'PhabricatorPolicyInterface', 6736 + 'PhabricatorApplicationTransactionInterface', 6737 + 'PhabricatorDestructibleInterface', 6738 + ), 6739 + 'HeraldWebhookCallManagementWorkflow' => 'HeraldWebhookManagementWorkflow', 6740 + 'HeraldWebhookController' => 'HeraldController', 6741 + 'HeraldWebhookEditController' => 'HeraldWebhookController', 6742 + 'HeraldWebhookEditEngine' => 'PhabricatorEditEngine', 6743 + 'HeraldWebhookEditor' => 'PhabricatorApplicationTransactionEditor', 6744 + 'HeraldWebhookListController' => 'HeraldWebhookController', 6745 + 'HeraldWebhookManagementWorkflow' => 'PhabricatorManagementWorkflow', 6746 + 'HeraldWebhookNameTransaction' => 'HeraldWebhookTransactionType', 6747 + 'HeraldWebhookPHIDType' => 'PhabricatorPHIDType', 6748 + 'HeraldWebhookQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 6749 + 'HeraldWebhookRequest' => array( 6750 + 'HeraldDAO', 6751 + 'PhabricatorPolicyInterface', 6752 + 'PhabricatorExtendedPolicyInterface', 6753 + ), 6754 + 'HeraldWebhookRequestListView' => 'AphrontView', 6755 + 'HeraldWebhookRequestPHIDType' => 'PhabricatorPHIDType', 6756 + 'HeraldWebhookRequestQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 6757 + 'HeraldWebhookSearchEngine' => 'PhabricatorApplicationSearchEngine', 6758 + 'HeraldWebhookStatusTransaction' => 'HeraldWebhookTransactionType', 6759 + 'HeraldWebhookTestController' => 'HeraldWebhookController', 6760 + 'HeraldWebhookTransaction' => 'PhabricatorModularTransaction', 6761 + 'HeraldWebhookTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 6762 + 'HeraldWebhookTransactionType' => 'PhabricatorModularTransactionType', 6763 + 'HeraldWebhookURITransaction' => 'HeraldWebhookTransactionType', 6764 + 'HeraldWebhookViewController' => 'HeraldWebhookController', 6765 + 'HeraldWebhookWorker' => 'PhabricatorWorker', 6707 6766 'Javelin' => 'Phobject', 6708 6767 'LegalpadController' => 'PhabricatorController', 6709 6768 'LegalpadCreateDocumentsCapability' => 'PhabricatorPolicyCapability',
+14
src/applications/herald/application/PhabricatorHeraldApplication.php
··· 62 62 '(?P<id>[1-9]\d*)/' 63 63 => 'HeraldTranscriptController', 64 64 ), 65 + 'webhook/' => array( 66 + $this->getQueryRoutePattern() => 'HeraldWebhookListController', 67 + 'view/(?P<id>\d+)/(?:request/(?P<requestID>[^/]+)/)?' => 68 + 'HeraldWebhookViewController', 69 + $this->getEditRoutePattern('edit/') => 'HeraldWebhookEditController', 70 + 'test/(?P<id>\d+)/' => 'HeraldWebhookTestController', 71 + 'key/' => array( 72 + 'view/(?P<id>\d+)/' => 'HeraldWebhookViewKeyController', 73 + 'cycle/(?P<id>\d+)/' => 'HeraldWebhookCycleKeyController', 74 + ), 75 + ), 65 76 ), 66 77 ); 67 78 } ··· 70 81 return array( 71 82 HeraldManageGlobalRulesCapability::CAPABILITY => array( 72 83 'caption' => pht('Global rules can bypass access controls.'), 84 + 'default' => PhabricatorPolicies::POLICY_ADMIN, 85 + ), 86 + HeraldCreateWebhooksCapability::CAPABILITY => array( 73 87 'default' => PhabricatorPolicies::POLICY_ADMIN, 74 88 ), 75 89 );
+16
src/applications/herald/capability/HeraldCreateWebhooksCapability.php
··· 1 + <?php 2 + 3 + final class HeraldCreateWebhooksCapability 4 + extends PhabricatorPolicyCapability { 5 + 6 + const CAPABILITY = 'herald.webhooks'; 7 + 8 + public function getCapabilityName() { 9 + return pht('Can Create Webhooks'); 10 + } 11 + 12 + public function describeCapabilityRejection() { 13 + return pht('You do not have permission to create webhooks.'); 14 + } 15 + 16 + }
+5 -14
src/applications/herald/controller/HeraldController.php
··· 6 6 return $this->buildSideNavView()->getMenu(); 7 7 } 8 8 9 - protected function buildApplicationCrumbs() { 10 - $crumbs = parent::buildApplicationCrumbs(); 11 - 12 - $crumbs->addAction( 13 - id(new PHUIListItemView()) 14 - ->setName(pht('Create Herald Rule')) 15 - ->setHref($this->getApplicationURI('create/')) 16 - ->setIcon('fa-plus-square')); 17 - 18 - return $crumbs; 19 - } 20 - 21 9 public function buildSideNavView() { 22 10 $viewer = $this->getViewer(); 23 11 ··· 29 17 ->addNavigationItems($nav->getMenu()); 30 18 31 19 $nav->addLabel(pht('Utilities')) 32 - ->addFilter('test', pht('Test Console')) 33 - ->addFilter('transcript', pht('Transcripts')); 20 + ->addFilter('test', pht('Test Console')) 21 + ->addFilter('transcript', pht('Transcripts')); 22 + 23 + $nav->addLabel(pht('Webhooks')) 24 + ->addFilter('webhook', pht('Webhooks')); 34 25 35 26 $nav->selectFilter(null); 36 27
+11
src/applications/herald/controller/HeraldRuleListController.php
··· 17 17 return $this->delegateToController($controller); 18 18 } 19 19 20 + protected function buildApplicationCrumbs() { 21 + $crumbs = parent::buildApplicationCrumbs(); 22 + 23 + $crumbs->addAction( 24 + id(new PHUIListItemView()) 25 + ->setName(pht('Create Herald Rule')) 26 + ->setHref($this->getApplicationURI('create/')) 27 + ->setIcon('fa-plus-square')); 28 + 29 + return $crumbs; 30 + } 20 31 21 32 }
+15
src/applications/herald/controller/HeraldWebhookController.php
··· 1 + <?php 2 + 3 + abstract class HeraldWebhookController extends HeraldController { 4 + 5 + protected function buildApplicationCrumbs() { 6 + $crumbs = parent::buildApplicationCrumbs(); 7 + 8 + $crumbs->addTextCrumb( 9 + pht('Webhooks'), 10 + $this->getApplicationURI('webhook/')); 11 + 12 + return $crumbs; 13 + } 14 + 15 + }
+12
src/applications/herald/controller/HeraldWebhookEditController.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookEditController 4 + extends HeraldWebhookController { 5 + 6 + public function handleRequest(AphrontRequest $request) { 7 + return id(new HeraldWebhookEditEngine()) 8 + ->setController($this) 9 + ->buildResponse(); 10 + } 11 + 12 + }
+26
src/applications/herald/controller/HeraldWebhookListController.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookListController 4 + extends HeraldWebhookController { 5 + 6 + public function shouldAllowPublic() { 7 + return true; 8 + } 9 + 10 + public function handleRequest(AphrontRequest $request) { 11 + return id(new HeraldWebhookSearchEngine()) 12 + ->setController($this) 13 + ->buildResponse(); 14 + } 15 + 16 + protected function buildApplicationCrumbs() { 17 + $crumbs = parent::buildApplicationCrumbs(); 18 + 19 + id(new HeraldWebhookEditEngine()) 20 + ->setViewer($this->getViewer()) 21 + ->addActionToCrumbs($crumbs); 22 + 23 + return $crumbs; 24 + } 25 + 26 + }
+45
src/applications/herald/controller/HeraldWebhookTestController.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookTestController 4 + extends HeraldWebhookController { 5 + 6 + public function handleRequest(AphrontRequest $request) { 7 + $viewer = $this->getViewer(); 8 + 9 + $hook = id(new HeraldWebhookQuery()) 10 + ->setViewer($viewer) 11 + ->withIDs(array($request->getURIData('id'))) 12 + ->requireCapabilities( 13 + array( 14 + PhabricatorPolicyCapability::CAN_VIEW, 15 + PhabricatorPolicyCapability::CAN_EDIT, 16 + )) 17 + ->executeOne(); 18 + if (!$hook) { 19 + return new Aphront404Response(); 20 + } 21 + 22 + if ($request->isFormPost()) { 23 + $object = $hook; 24 + 25 + $request = HeraldWebhookRequest::initializeNewWebhookRequest($hook) 26 + ->setObjectPHID($object->getPHID()) 27 + ->save(); 28 + 29 + $request->queueCall(); 30 + 31 + $next_uri = $hook->getURI().'request/'.$request->getID().'/'; 32 + 33 + return id(new AphrontRedirectResponse())->setURI($next_uri); 34 + } 35 + 36 + return $this->newDialog() 37 + ->setTitle(pht('New Test Request')) 38 + ->appendParagraph( 39 + pht('This will make a new test request to the configured URI.')) 40 + ->addCancelButton($hook->getURI()) 41 + ->addSubmitButton(pht('Make Request')); 42 + } 43 + 44 + 45 + }
+160
src/applications/herald/controller/HeraldWebhookViewController.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookViewController 4 + extends HeraldWebhookController { 5 + 6 + public function shouldAllowPublic() { 7 + return true; 8 + } 9 + 10 + public function handleRequest(AphrontRequest $request) { 11 + $viewer = $this->getViewer(); 12 + 13 + $hook = id(new HeraldWebhookQuery()) 14 + ->setViewer($viewer) 15 + ->withIDs(array($request->getURIData('id'))) 16 + ->executeOne(); 17 + if (!$hook) { 18 + return new Aphront404Response(); 19 + } 20 + 21 + $header = $this->buildHeaderView($hook); 22 + 23 + $warnings = null; 24 + if ($hook->isInErrorBackoff($viewer)) { 25 + $message = pht( 26 + 'Many requests to this webhook have failed recently (at least %s '. 27 + 'errors in the last %s seconds). New requests are temporarily paused.', 28 + $hook->getErrorBackoffThreshold(), 29 + $hook->getErrorBackoffWindow()); 30 + 31 + $warnings = id(new PHUIInfoView()) 32 + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) 33 + ->setErrors( 34 + array( 35 + $message, 36 + )); 37 + } 38 + 39 + $curtain = $this->buildCurtain($hook); 40 + $properties_view = $this->buildPropertiesView($hook); 41 + 42 + $timeline = $this->buildTransactionTimeline( 43 + $hook, 44 + new HeraldWebhookTransactionQuery()); 45 + $timeline->setShouldTerminate(true); 46 + 47 + $requests = id(new HeraldWebhookRequestQuery()) 48 + ->setViewer($viewer) 49 + ->withWebhookPHIDs(array($hook->getPHID())) 50 + ->setLimit(20) 51 + ->execute(); 52 + 53 + $requests_table = id(new HeraldWebhookRequestListView()) 54 + ->setViewer($viewer) 55 + ->setRequests($requests) 56 + ->setHighlightID($request->getURIData('requestID')); 57 + 58 + $requests_view = id(new PHUIObjectBoxView()) 59 + ->setHeaderText(pht('Recent Requests')) 60 + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 61 + ->setTable($requests_table); 62 + 63 + $hook_view = id(new PHUITwoColumnView()) 64 + ->setHeader($header) 65 + ->setMainColumn( 66 + array( 67 + $warnings, 68 + $properties_view, 69 + $requests_view, 70 + $timeline, 71 + )) 72 + ->setCurtain($curtain); 73 + 74 + $crumbs = $this->buildApplicationCrumbs() 75 + ->addTextCrumb(pht('Webhook %d', $hook->getID())) 76 + ->setBorder(true); 77 + 78 + return $this->newPage() 79 + ->setTitle( 80 + array( 81 + pht('Webhook %d', $hook->getID()), 82 + $hook->getName(), 83 + )) 84 + ->setCrumbs($crumbs) 85 + ->setPageObjectPHIDs( 86 + array( 87 + $hook->getPHID(), 88 + )) 89 + ->appendChild($hook_view); 90 + } 91 + 92 + private function buildHeaderView(HeraldWebhook $hook) { 93 + $viewer = $this->getViewer(); 94 + 95 + $title = $hook->getName(); 96 + 97 + $header = id(new PHUIHeaderView()) 98 + ->setHeader($title) 99 + ->setViewer($viewer) 100 + ->setPolicyObject($hook) 101 + ->setHeaderIcon('fa-cloud-upload'); 102 + 103 + return $header; 104 + } 105 + 106 + 107 + private function buildCurtain(HeraldWebhook $hook) { 108 + $viewer = $this->getViewer(); 109 + $curtain = $this->newCurtainView($hook); 110 + 111 + $can_edit = PhabricatorPolicyFilter::hasCapability( 112 + $viewer, 113 + $hook, 114 + PhabricatorPolicyCapability::CAN_EDIT); 115 + 116 + $id = $hook->getID(); 117 + $edit_uri = $this->getApplicationURI("webhook/edit/{$id}/"); 118 + $test_uri = $this->getApplicationURI("webhook/test/{$id}/"); 119 + 120 + $curtain->addAction( 121 + id(new PhabricatorActionView()) 122 + ->setName(pht('Edit Webhook')) 123 + ->setIcon('fa-pencil') 124 + ->setDisabled(!$can_edit) 125 + ->setWorkflow(!$can_edit) 126 + ->setHref($edit_uri)); 127 + 128 + $curtain->addAction( 129 + id(new PhabricatorActionView()) 130 + ->setName(pht('New Test Request')) 131 + ->setIcon('fa-cloud-upload') 132 + ->setDisabled(!$can_edit) 133 + ->setWorkflow(true) 134 + ->setHref($test_uri)); 135 + 136 + return $curtain; 137 + } 138 + 139 + 140 + private function buildPropertiesView(HeraldWebhook $hook) { 141 + $viewer = $this->getViewer(); 142 + 143 + $properties = id(new PHUIPropertyListView()) 144 + ->setViewer($viewer); 145 + 146 + $properties->addProperty( 147 + pht('URI'), 148 + $hook->getWebhookURI()); 149 + 150 + $properties->addProperty( 151 + pht('Status'), 152 + $hook->getStatusDisplayName()); 153 + 154 + return id(new PHUIObjectBoxView()) 155 + ->setHeaderText(pht('Details')) 156 + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 157 + ->appendChild($properties); 158 + } 159 + 160 + }
+105
src/applications/herald/editor/HeraldWebhookEditEngine.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookEditEngine 4 + extends PhabricatorEditEngine { 5 + 6 + const ENGINECONST = 'herald.webhook'; 7 + 8 + public function isEngineConfigurable() { 9 + return false; 10 + } 11 + 12 + public function getEngineName() { 13 + return pht('Webhooks'); 14 + } 15 + 16 + public function getSummaryHeader() { 17 + return pht('Edit Webhook Configurations'); 18 + } 19 + 20 + public function getSummaryText() { 21 + return pht('This engine is used to edit webhooks.'); 22 + } 23 + 24 + public function getEngineApplicationClass() { 25 + return 'PhabricatorHeraldApplication'; 26 + } 27 + 28 + protected function newEditableObject() { 29 + $viewer = $this->getViewer(); 30 + return HeraldWebhook::initializeNewWebhook($viewer); 31 + } 32 + 33 + protected function newObjectQuery() { 34 + return new HeraldWebhookQuery(); 35 + } 36 + 37 + protected function getObjectCreateTitleText($object) { 38 + return pht('Create Webhook'); 39 + } 40 + 41 + protected function getObjectCreateButtonText($object) { 42 + return pht('Create Webhook'); 43 + } 44 + 45 + protected function getObjectEditTitleText($object) { 46 + return pht('Edit Webhook: %s', $object->getName()); 47 + } 48 + 49 + protected function getObjectEditShortText($object) { 50 + return pht('Edit Webhook'); 51 + } 52 + 53 + protected function getObjectCreateShortText() { 54 + return pht('Create Webhook'); 55 + } 56 + 57 + protected function getObjectName() { 58 + return pht('Webhook'); 59 + } 60 + 61 + protected function getEditorURI() { 62 + return '/herald/webhook/edit/'; 63 + } 64 + 65 + protected function getObjectCreateCancelURI($object) { 66 + return '/herald/webhook/'; 67 + } 68 + 69 + protected function getObjectViewURI($object) { 70 + return $object->getURI(); 71 + } 72 + 73 + protected function getCreateNewObjectPolicy() { 74 + return $this->getApplication()->getPolicy( 75 + HeraldCreateWebhooksCapability::CAPABILITY); 76 + } 77 + 78 + protected function buildCustomEditFields($object) { 79 + return array( 80 + id(new PhabricatorTextEditField()) 81 + ->setKey('name') 82 + ->setLabel(pht('Name')) 83 + ->setDescription(pht('Name of the webhook.')) 84 + ->setTransactionType(HeraldWebhookNameTransaction::TRANSACTIONTYPE) 85 + ->setIsRequired(true) 86 + ->setValue($object->getName()), 87 + id(new PhabricatorTextEditField()) 88 + ->setKey('uri') 89 + ->setLabel(pht('URI')) 90 + ->setDescription(pht('URI for the webhook.')) 91 + ->setTransactionType(HeraldWebhookURITransaction::TRANSACTIONTYPE) 92 + ->setIsRequired(true) 93 + ->setValue($object->getWebhookURI()), 94 + id(new PhabricatorSelectEditField()) 95 + ->setKey('status') 96 + ->setLabel(pht('Status')) 97 + ->setDescription(pht('Status mode for the webhook.')) 98 + ->setTransactionType(HeraldWebhookStatusTransaction::TRANSACTIONTYPE) 99 + ->setOptions(HeraldWebhook::getStatusDisplayNameMap()) 100 + ->setValue($object->getStatus()), 101 + 102 + ); 103 + } 104 + 105 + }
+22
src/applications/herald/editor/HeraldWebhookEditor.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookEditor 4 + extends PhabricatorApplicationTransactionEditor { 5 + 6 + public function getEditorApplicationClass() { 7 + return 'PhabricatorHeraldApplication'; 8 + } 9 + 10 + public function getEditorObjectsDescription() { 11 + return pht('Webhooks'); 12 + } 13 + 14 + public function getCreateObjectTitle($author, $object) { 15 + return pht('%s created this webhook.', $author); 16 + } 17 + 18 + public function getCreateObjectTitleForFeed($author, $object) { 19 + return pht('%s created %s.', $author, $object); 20 + } 21 + 22 + }
+64
src/applications/herald/management/HeraldWebhookCallManagementWorkflow.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookCallManagementWorkflow 4 + extends HeraldWebhookManagementWorkflow { 5 + 6 + protected function didConstruct() { 7 + $this 8 + ->setName('call') 9 + ->setExamples('**call** --id __id__') 10 + ->setSynopsis(pht('Call a webhook.')) 11 + ->setArguments( 12 + array( 13 + array( 14 + 'name' => 'id', 15 + 'param' => 'id', 16 + 'help' => pht('Webhook ID to call'), 17 + ), 18 + )); 19 + } 20 + 21 + public function execute(PhutilArgumentParser $args) { 22 + $viewer = $this->getViewer(); 23 + 24 + $id = $args->getArg('id'); 25 + if (!$id) { 26 + throw new PhutilArgumentUsageException( 27 + pht( 28 + 'Specify a webhook to call with "--id".')); 29 + } 30 + 31 + $hook = id(new HeraldWebhookQuery()) 32 + ->setViewer($viewer) 33 + ->withIDs(array($id)) 34 + ->executeOne(); 35 + if (!$hook) { 36 + throw new PhutilArgumentUsageException( 37 + pht( 38 + 'Unable to load specified webhook ("%s").', 39 + $id)); 40 + } 41 + 42 + $object = $hook; 43 + 44 + $application_phid = id(new PhabricatorHeraldApplication())->getPHID(); 45 + 46 + $request = HeraldWebhookRequest::initializeNewWebhookRequest($hook) 47 + ->setObjectPHID($object->getPHID()) 48 + ->save(); 49 + 50 + PhabricatorWorker::setRunAllTasksInProcess(true); 51 + $request->queueCall(); 52 + 53 + $request->reload(); 54 + 55 + echo tsprintf( 56 + "%s\n", 57 + pht( 58 + 'Success, got HTTP %s from webhook.', 59 + $request->getErrorCode())); 60 + 61 + return 0; 62 + } 63 + 64 + }
+4
src/applications/herald/management/HeraldWebhookManagementWorkflow.php
··· 1 + <?php 2 + 3 + abstract class HeraldWebhookManagementWorkflow 4 + extends PhabricatorManagementWorkflow {}
+49
src/applications/herald/phid/HeraldWebhookPHIDType.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookPHIDType extends PhabricatorPHIDType { 4 + 5 + const TYPECONST = 'HWBH'; 6 + 7 + public function getTypeName() { 8 + return pht('Webhook'); 9 + } 10 + 11 + public function newObject() { 12 + return new HeraldWebhook(); 13 + } 14 + 15 + public function getPHIDTypeApplicationClass() { 16 + return 'PhabricatorHeraldApplication'; 17 + } 18 + 19 + protected function buildQueryForObjects( 20 + PhabricatorObjectQuery $query, 21 + array $phids) { 22 + 23 + return id(new HeraldWebhookQuery()) 24 + ->withPHIDs($phids); 25 + } 26 + 27 + public function loadHandles( 28 + PhabricatorHandleQuery $query, 29 + array $handles, 30 + array $objects) { 31 + 32 + foreach ($handles as $phid => $handle) { 33 + $hook = $objects[$phid]; 34 + 35 + $name = $hook->getName(); 36 + $id = $hook->getID(); 37 + 38 + $handle 39 + ->setName($name) 40 + ->setURI($hook->getURI()) 41 + ->setFullName(pht('Webhook %d %s', $id, $name)); 42 + 43 + if ($hook->isDisabled()) { 44 + $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); 45 + } 46 + } 47 + } 48 + 49 + }
+39
src/applications/herald/phid/HeraldWebhookRequestPHIDType.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookRequestPHIDType extends PhabricatorPHIDType { 4 + 5 + const TYPECONST = 'HWBR'; 6 + 7 + public function getTypeName() { 8 + return pht('Webhook Request'); 9 + } 10 + 11 + public function newObject() { 12 + return new HeraldWebhook(); 13 + } 14 + 15 + public function getPHIDTypeApplicationClass() { 16 + return 'PhabricatorHeraldApplication'; 17 + } 18 + 19 + protected function buildQueryForObjects( 20 + PhabricatorObjectQuery $query, 21 + array $phids) { 22 + 23 + return id(new HeraldWebhookRequestQuery()) 24 + ->withPHIDs($phids); 25 + } 26 + 27 + public function loadHandles( 28 + PhabricatorHandleQuery $query, 29 + array $handles, 30 + array $objects) { 31 + 32 + foreach ($handles as $phid => $handle) { 33 + $request = $objects[$phid]; 34 + 35 + // TODO: Fill this in. 36 + } 37 + } 38 + 39 + }
+64
src/applications/herald/query/HeraldWebhookQuery.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookQuery 4 + extends PhabricatorCursorPagedPolicyAwareQuery { 5 + 6 + private $ids; 7 + private $phids; 8 + private $statuses; 9 + 10 + public function withIDs(array $ids) { 11 + $this->ids = $ids; 12 + return $this; 13 + } 14 + 15 + public function withPHIDs(array $phids) { 16 + $this->phids = $phids; 17 + return $this; 18 + } 19 + 20 + public function withStatuses(array $statuses) { 21 + $this->statuses = $statuses; 22 + return $this; 23 + } 24 + 25 + public function newResultObject() { 26 + return new HeraldWebhook(); 27 + } 28 + 29 + protected function loadPage() { 30 + return $this->loadStandardPage($this->newResultObject()); 31 + } 32 + 33 + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { 34 + $where = parent::buildWhereClauseParts($conn); 35 + 36 + if ($this->ids !== null) { 37 + $where[] = qsprintf( 38 + $conn, 39 + 'id IN (%Ld)', 40 + $this->ids); 41 + } 42 + 43 + if ($this->phids !== null) { 44 + $where[] = qsprintf( 45 + $conn, 46 + 'phid IN (%Ls)', 47 + $this->phids); 48 + } 49 + 50 + if ($this->statuses !== null) { 51 + $where[] = qsprintf( 52 + $conn, 53 + 'status IN (%Ls)', 54 + $this->statuses); 55 + } 56 + 57 + return $where; 58 + } 59 + 60 + public function getQueryApplicationClass() { 61 + return 'PhabricatorHeraldApplication'; 62 + } 63 + 64 + }
+126
src/applications/herald/query/HeraldWebhookRequestQuery.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookRequestQuery 4 + extends PhabricatorCursorPagedPolicyAwareQuery { 5 + 6 + private $ids; 7 + private $phids; 8 + private $webhookPHIDs; 9 + private $lastRequestEpochMin; 10 + private $lastRequestEpochMax; 11 + private $lastRequestResults; 12 + 13 + public function withIDs(array $ids) { 14 + $this->ids = $ids; 15 + return $this; 16 + } 17 + 18 + public function withPHIDs(array $phids) { 19 + $this->phids = $phids; 20 + return $this; 21 + } 22 + 23 + public function withWebhookPHIDs(array $phids) { 24 + $this->webhookPHIDs = $phids; 25 + return $this; 26 + } 27 + 28 + public function newResultObject() { 29 + return new HeraldWebhookRequest(); 30 + } 31 + 32 + protected function loadPage() { 33 + return $this->loadStandardPage($this->newResultObject()); 34 + } 35 + 36 + public function withLastRequestEpochBetween($epoch_min, $epoch_max) { 37 + $this->lastRequestEpochMin = $epoch_min; 38 + $this->lastRequestEpochMax = $epoch_max; 39 + return $this; 40 + } 41 + 42 + public function withLastRequestResults(array $results) { 43 + $this->lastRequestResults = $results; 44 + return $this; 45 + } 46 + 47 + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { 48 + $where = parent::buildWhereClauseParts($conn); 49 + 50 + if ($this->ids !== null) { 51 + $where[] = qsprintf( 52 + $conn, 53 + 'id IN (%Ld)', 54 + $this->ids); 55 + } 56 + 57 + if ($this->phids !== null) { 58 + $where[] = qsprintf( 59 + $conn, 60 + 'phid IN (%Ls)', 61 + $this->phids); 62 + } 63 + 64 + if ($this->webhookPHIDs !== null) { 65 + $where[] = qsprintf( 66 + $conn, 67 + 'webhookPHID IN (%Ls)', 68 + $this->webhookPHIDs); 69 + } 70 + 71 + if ($this->lastRequestEpochMin !== null) { 72 + $where[] = qsprintf( 73 + $conn, 74 + 'lastRequestEpoch >= %d', 75 + $this->lastRequestEpochMin); 76 + } 77 + 78 + if ($this->lastRequestEpochMax !== null) { 79 + $where[] = qsprintf( 80 + $conn, 81 + 'lastRequestEpoch <= %d', 82 + $this->lastRequestEpochMax); 83 + } 84 + 85 + if ($this->lastRequestResults !== null) { 86 + $where[] = qsprintf( 87 + $conn, 88 + 'lastRequestResult IN (%Ls)', 89 + $this->lastRequestResults); 90 + } 91 + 92 + return $where; 93 + } 94 + 95 + protected function willFilterPage(array $requests) { 96 + $hook_phids = mpull($requests, 'getWebhookPHID'); 97 + 98 + $hooks = id(new HeraldWebhookQuery()) 99 + ->setViewer($this->getViewer()) 100 + ->setParentQuery($this) 101 + ->withPHIDs($hook_phids) 102 + ->execute(); 103 + $hooks = mpull($hooks, null, 'getPHID'); 104 + 105 + foreach ($requests as $key => $request) { 106 + $hook_phid = $request->getWebhookPHID(); 107 + $hook = idx($hooks, $hook_phid); 108 + 109 + if (!$hook) { 110 + unset($requests[$key]); 111 + $this->didRejectResult($request); 112 + continue; 113 + } 114 + 115 + $request->attachWebhook($hook); 116 + } 117 + 118 + return $requests; 119 + } 120 + 121 + 122 + public function getQueryApplicationClass() { 123 + return 'PhabricatorHeraldApplication'; 124 + } 125 + 126 + }
+95
src/applications/herald/query/HeraldWebhookSearchEngine.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookSearchEngine 4 + extends PhabricatorApplicationSearchEngine { 5 + 6 + public function getResultTypeDescription() { 7 + return pht('Webhooks'); 8 + } 9 + 10 + public function getApplicationClassName() { 11 + return 'PhabricatorHeraldApplication'; 12 + } 13 + 14 + public function newQuery() { 15 + return new HeraldWebhookQuery(); 16 + } 17 + 18 + protected function buildQueryFromParameters(array $map) { 19 + $query = $this->newQuery(); 20 + 21 + if ($map['statuses']) { 22 + $query->withStatuses($map['statuses']); 23 + } 24 + 25 + return $query; 26 + } 27 + 28 + protected function buildCustomSearchFields() { 29 + return array( 30 + id(new PhabricatorSearchCheckboxesField()) 31 + ->setKey('statuses') 32 + ->setLabel(pht('Status')) 33 + ->setDescription( 34 + pht('Search for archived or active pastes.')) 35 + ->setOptions(HeraldWebhook::getStatusDisplayNameMap()), 36 + ); 37 + } 38 + 39 + protected function getURI($path) { 40 + return '/herald/webhook/'.$path; 41 + } 42 + 43 + protected function getBuiltinQueryNames() { 44 + $names = array(); 45 + 46 + $names['active'] = pht('Active'); 47 + $names['all'] = pht('All'); 48 + 49 + return $names; 50 + } 51 + 52 + public function buildSavedQueryFromBuiltin($query_key) { 53 + $query = $this->newSavedQuery(); 54 + $query->setQueryKey($query_key); 55 + 56 + switch ($query_key) { 57 + case 'all': 58 + return $query; 59 + case 'active': 60 + return $query->setParameter( 61 + 'statuses', 62 + array( 63 + HeraldWebhook::HOOKSTATUS_FIREHOSE, 64 + HeraldWebhook::HOOKSTATUS_ENABLED, 65 + )); 66 + } 67 + 68 + return parent::buildSavedQueryFromBuiltin($query_key); 69 + } 70 + 71 + protected function renderResultList( 72 + array $hooks, 73 + PhabricatorSavedQuery $query, 74 + array $handles) { 75 + assert_instances_of($hooks, 'HeraldWebhook'); 76 + 77 + $viewer = $this->requireViewer(); 78 + 79 + $list = id(new PHUIObjectItemListView()) 80 + ->setViewer($viewer); 81 + foreach ($hooks as $hook) { 82 + $item = id(new PHUIObjectItemView()) 83 + ->setObjectName(pht('Hook %d', $hook->getID())) 84 + ->setHeader($hook->getName()) 85 + ->setHref($hook->getURI()); 86 + 87 + $list->addItem($item); 88 + } 89 + 90 + return id(new PhabricatorApplicationSearchResultView()) 91 + ->setObjectList($list) 92 + ->setNoDataString(pht('No webhooks found.')); 93 + } 94 + 95 + }
+10
src/applications/herald/query/HeraldWebhookTransactionQuery.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookTransactionQuery 4 + extends PhabricatorApplicationTransactionQuery { 5 + 6 + public function getTemplateApplicationTransaction() { 7 + return new HeraldWebhookTransaction(); 8 + } 9 + 10 + }
+177
src/applications/herald/storage/HeraldWebhook.php
··· 1 + <?php 2 + 3 + final class HeraldWebhook 4 + extends HeraldDAO 5 + implements 6 + PhabricatorPolicyInterface, 7 + PhabricatorApplicationTransactionInterface, 8 + PhabricatorDestructibleInterface { 9 + 10 + protected $name; 11 + protected $webhookURI; 12 + protected $viewPolicy; 13 + protected $editPolicy; 14 + protected $status; 15 + protected $hmacKey; 16 + 17 + const HOOKSTATUS_FIREHOSE = 'firehose'; 18 + const HOOKSTATUS_ENABLED = 'enabled'; 19 + const HOOKSTATUS_DISABLED = 'disabled'; 20 + 21 + protected function getConfiguration() { 22 + return array( 23 + self::CONFIG_AUX_PHID => true, 24 + self::CONFIG_COLUMN_SCHEMA => array( 25 + 'name' => 'text128', 26 + 'webhookURI' => 'text255', 27 + 'status' => 'text32', 28 + 'hmacKey' => 'text32', 29 + ), 30 + self::CONFIG_KEY_SCHEMA => array( 31 + 'key_status' => array( 32 + 'columns' => array('status'), 33 + ), 34 + ), 35 + ) + parent::getConfiguration(); 36 + } 37 + 38 + public function getPHIDType() { 39 + return HeraldWebhookPHIDType::TYPECONST; 40 + } 41 + 42 + public static function initializeNewWebhook(PhabricatorUser $viewer) { 43 + return id(new self()) 44 + ->setStatus(self::HOOKSTATUS_ENABLED) 45 + ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy()) 46 + ->setEditPolicy($viewer->getPHID()) 47 + ->setHmacKey(Filesystem::readRandomCharacters(32)); 48 + } 49 + 50 + public function getURI() { 51 + return '/herald/webhook/view/'.$this->getID().'/'; 52 + } 53 + 54 + public function isDisabled() { 55 + return ($this->getStatus() === self::HOOKSTATUS_DISABLED); 56 + } 57 + 58 + public static function getStatusDisplayNameMap() { 59 + return array( 60 + self::HOOKSTATUS_FIREHOSE => pht('Firehose'), 61 + self::HOOKSTATUS_ENABLED => pht('Enabled'), 62 + self::HOOKSTATUS_DISABLED => pht('Disabled'), 63 + ); 64 + } 65 + 66 + public function getStatusDisplayName() { 67 + $status = $this->getStatus(); 68 + return idx($this->getStatusDisplayNameMap(), $status); 69 + } 70 + 71 + public function getErrorBackoffWindow() { 72 + return phutil_units('5 minutes in seconds'); 73 + } 74 + 75 + public function getErrorBackoffThreshold() { 76 + return 10; 77 + } 78 + 79 + public function isInErrorBackoff(PhabricatorUser $viewer) { 80 + $backoff_window = $this->getErrorBackoffWindow(); 81 + $backoff_threshold = $this->getErrorBackoffThreshold(); 82 + 83 + $now = PhabricatorTime::getNow(); 84 + 85 + $window_start = ($now - $backoff_window); 86 + 87 + $requests = id(new HeraldWebhookRequestQuery()) 88 + ->setViewer($viewer) 89 + ->withWebhookPHIDs(array($this->getPHID())) 90 + ->withLastRequestEpochBetween($window_start, null) 91 + ->withLastRequestResults( 92 + array( 93 + HeraldWebhookRequest::RESULT_FAIL, 94 + )) 95 + ->execute(); 96 + 97 + if (count($requests) >= $backoff_threshold) { 98 + return true; 99 + } 100 + 101 + return false; 102 + } 103 + 104 + 105 + /* -( PhabricatorPolicyInterface )----------------------------------------- */ 106 + 107 + 108 + public function getCapabilities() { 109 + return array( 110 + PhabricatorPolicyCapability::CAN_VIEW, 111 + PhabricatorPolicyCapability::CAN_EDIT, 112 + ); 113 + } 114 + 115 + public function getPolicy($capability) { 116 + switch ($capability) { 117 + case PhabricatorPolicyCapability::CAN_VIEW: 118 + return $this->getViewPolicy(); 119 + case PhabricatorPolicyCapability::CAN_EDIT: 120 + return $this->getEditPolicy(); 121 + } 122 + } 123 + 124 + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { 125 + return false; 126 + } 127 + 128 + 129 + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ 130 + 131 + 132 + public function getApplicationTransactionEditor() { 133 + return new HeraldWebhookEditor(); 134 + } 135 + 136 + public function getApplicationTransactionObject() { 137 + return $this; 138 + } 139 + 140 + public function getApplicationTransactionTemplate() { 141 + return new HeraldWebhookTransaction(); 142 + } 143 + 144 + public function willRenderTimeline( 145 + PhabricatorApplicationTransactionView $timeline, 146 + AphrontRequest $request) { 147 + return $timeline; 148 + } 149 + 150 + 151 + /* -( PhabricatorDestructibleInterface )----------------------------------- */ 152 + 153 + 154 + public function destroyObjectPermanently( 155 + PhabricatorDestructionEngine $engine) { 156 + 157 + while (true) { 158 + $requests = id(new HeraldWebhookRequestQuery()) 159 + ->setViewer($engine->getViewer()) 160 + ->withWebhookPHIDs(array($this->getPHID())) 161 + ->setLimit(100) 162 + ->execute(); 163 + 164 + if (!$requests) { 165 + break; 166 + } 167 + 168 + foreach ($requests as $request) { 169 + $request->delete(); 170 + } 171 + } 172 + 173 + $this->delete(); 174 + } 175 + 176 + 177 + }
+191
src/applications/herald/storage/HeraldWebhookRequest.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookRequest 4 + extends HeraldDAO 5 + implements 6 + PhabricatorPolicyInterface, 7 + PhabricatorExtendedPolicyInterface { 8 + 9 + protected $webhookPHID; 10 + protected $objectPHID; 11 + protected $status; 12 + protected $properties = array(); 13 + protected $lastRequestResult; 14 + protected $lastRequestEpoch; 15 + 16 + private $webhook = self::ATTACHABLE; 17 + 18 + const RETRY_NEVER = 'never'; 19 + const RETRY_FOREVER = 'forever'; 20 + 21 + const STATUS_QUEUED = 'queued'; 22 + const STATUS_FAILED = 'failed'; 23 + const STATUS_SENT = 'sent'; 24 + 25 + const RESULT_NONE = 'none'; 26 + const RESULT_OKAY = 'okay'; 27 + const RESULT_FAIL = 'fail'; 28 + 29 + protected function getConfiguration() { 30 + return array( 31 + self::CONFIG_AUX_PHID => true, 32 + self::CONFIG_SERIALIZATION => array( 33 + 'properties' => self::SERIALIZATION_JSON, 34 + ), 35 + self::CONFIG_COLUMN_SCHEMA => array( 36 + 'status' => 'text32', 37 + 'lastRequestResult' => 'text32', 38 + 'lastRequestEpoch' => 'epoch', 39 + ), 40 + self::CONFIG_KEY_SCHEMA => array( 41 + 'key_ratelimit' => array( 42 + 'columns' => array( 43 + 'webhookPHID', 44 + 'lastRequestResult', 45 + 'lastRequestEpoch', 46 + ), 47 + ), 48 + 'key_collect' => array( 49 + 'columns' => array('dateCreated'), 50 + ), 51 + ), 52 + ) + parent::getConfiguration(); 53 + } 54 + 55 + public function getPHIDType() { 56 + return HeraldWebhookRequestPHIDType::TYPECONST; 57 + } 58 + 59 + public static function initializeNewWebhookRequest(HeraldWebhook $hook) { 60 + return id(new self()) 61 + ->setWebhookPHID($hook->getPHID()) 62 + ->attachWebhook($hook) 63 + ->setStatus(self::STATUS_QUEUED) 64 + ->setRetryMode(self::RETRY_NEVER) 65 + ->setLastRequestResult(self::RESULT_NONE) 66 + ->setLastRequestEpoch(0); 67 + } 68 + 69 + public function getWebhook() { 70 + return $this->assertAttached($this->webhook); 71 + } 72 + 73 + public function attachWebhook(HeraldWebhook $hook) { 74 + $this->webhook = $hook; 75 + return $this; 76 + } 77 + 78 + protected function setProperty($key, $value) { 79 + $this->properties[$key] = $value; 80 + return $this; 81 + } 82 + 83 + protected function getProperty($key, $default = null) { 84 + return idx($this->properties, $key, $default); 85 + } 86 + 87 + public function setRetryMode($mode) { 88 + return $this->setProperty('retry', $mode); 89 + } 90 + 91 + public function getRetryMode() { 92 + return $this->getProperty('retry'); 93 + } 94 + 95 + public function setErrorType($error_type) { 96 + return $this->setProperty('errorType', $error_type); 97 + } 98 + 99 + public function getErrorType() { 100 + return $this->getProperty('errorType'); 101 + } 102 + 103 + public function setErrorCode($error_code) { 104 + return $this->setProperty('errorCode', $error_code); 105 + } 106 + 107 + public function getErrorCode() { 108 + return $this->getProperty('errorCode'); 109 + } 110 + 111 + public function setTransactionPHIDs(array $phids) { 112 + return $this->setProperty('transactionPHIDs', $phids); 113 + } 114 + 115 + public function getTransactionPHIDs() { 116 + return $this->getProperty('transactionPHIDs', array()); 117 + } 118 + 119 + public function queueCall() { 120 + PhabricatorWorker::scheduleTask( 121 + 'HeraldWebhookWorker', 122 + array( 123 + 'webhookRequestPHID' => $this->getPHID(), 124 + ), 125 + array( 126 + 'objectPHID' => $this->getPHID(), 127 + )); 128 + 129 + return $this; 130 + } 131 + 132 + public function newStatusIcon() { 133 + switch ($this->getStatus()) { 134 + case self::STATUS_QUEUED: 135 + $icon = 'fa-refresh'; 136 + $color = 'blue'; 137 + $tooltip = pht('Queued'); 138 + break; 139 + case self::STATUS_SENT: 140 + $icon = 'fa-check'; 141 + $color = 'green'; 142 + $tooltip = pht('Sent'); 143 + break; 144 + case self::STATUS_FAILED: 145 + default: 146 + $icon = 'fa-times'; 147 + $color = 'red'; 148 + $tooltip = pht('Failed'); 149 + break; 150 + 151 + } 152 + 153 + return id(new PHUIIconView()) 154 + ->setIcon($icon, $color) 155 + ->setTooltip($tooltip); 156 + } 157 + 158 + 159 + /* -( PhabricatorPolicyInterface )----------------------------------------- */ 160 + 161 + 162 + public function getCapabilities() { 163 + return array( 164 + PhabricatorPolicyCapability::CAN_VIEW, 165 + ); 166 + } 167 + 168 + public function getPolicy($capability) { 169 + switch ($capability) { 170 + case PhabricatorPolicyCapability::CAN_VIEW: 171 + return PhabricatorPolicies::getMostOpenPolicy(); 172 + } 173 + } 174 + 175 + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { 176 + return false; 177 + } 178 + 179 + 180 + /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ 181 + 182 + 183 + public function getExtendedPolicy($capability, PhabricatorUser $viewer) { 184 + return array( 185 + array($this->getWebhook(), PhabricatorPolicyCapability::CAN_VIEW), 186 + ); 187 + } 188 + 189 + 190 + 191 + }
+22
src/applications/herald/storage/HeraldWebhookTransaction.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookTransaction 4 + extends PhabricatorModularTransaction { 5 + 6 + public function getApplicationName() { 7 + return 'herald'; 8 + } 9 + 10 + public function getApplicationTransactionType() { 11 + return HeraldWebhookPHIDType::TYPECONST; 12 + } 13 + 14 + public function getApplicationTransactionCommentObject() { 15 + return null; 16 + } 17 + 18 + public function getBaseTransactionClass() { 19 + return 'HeraldWebhookTransactionType'; 20 + } 21 + 22 + }
+78
src/applications/herald/view/HeraldWebhookRequestListView.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookRequestListView 4 + extends AphrontView { 5 + 6 + private $requests; 7 + private $highlightID; 8 + 9 + public function setRequests(array $requests) { 10 + assert_instances_of($requests, 'HeraldWebhookRequest'); 11 + $this->requests = $requests; 12 + return $this; 13 + } 14 + 15 + public function setHighlightID($highlight_id) { 16 + $this->highlightID = $highlight_id; 17 + return $this; 18 + } 19 + 20 + public function getHighlightID() { 21 + return $this->highlightID; 22 + } 23 + 24 + public function render() { 25 + $viewer = $this->getViewer(); 26 + $requests = $this->requests; 27 + 28 + $handle_phids = array(); 29 + foreach ($requests as $request) { 30 + $handle_phids[] = $request->getObjectPHID(); 31 + } 32 + $handles = $viewer->loadHandles($handle_phids); 33 + 34 + $highlight_id = $this->getHighlightID(); 35 + 36 + $rows = array(); 37 + $rowc = array(); 38 + foreach ($requests as $request) { 39 + $icon = $request->newStatusIcon(); 40 + 41 + if ($highlight_id == $request->getID()) { 42 + $rowc[] = 'highlighted'; 43 + } else { 44 + $rowc[] = null; 45 + } 46 + 47 + $rows[] = array( 48 + $request->getID(), 49 + $icon, 50 + $handles[$request->getObjectPHID()]->renderLink(), 51 + $request->getErrorType(), 52 + $request->getErrorCode(), 53 + ); 54 + } 55 + 56 + $table = id(new AphrontTableView($rows)) 57 + ->setRowClasses($rowc) 58 + ->setHeaders( 59 + array( 60 + pht('ID'), 61 + '', 62 + pht('Object'), 63 + pht('Type'), 64 + pht('Code'), 65 + )) 66 + ->setColumnClasses( 67 + array( 68 + 'n', 69 + '', 70 + 'wide', 71 + '', 72 + '', 73 + )); 74 + 75 + return $table; 76 + } 77 + 78 + }
+229
src/applications/herald/worker/HeraldWebhookWorker.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookWorker 4 + extends PhabricatorWorker { 5 + 6 + protected function doWork() { 7 + $viewer = PhabricatorUser::getOmnipotentUser(); 8 + 9 + $data = $this->getTaskData(); 10 + $request_phid = idx($data, 'webhookRequestPHID'); 11 + 12 + $request = id(new HeraldWebhookRequestQuery()) 13 + ->setViewer($viewer) 14 + ->withPHIDs(array($request_phid)) 15 + ->executeOne(); 16 + if (!$request) { 17 + throw new PhabricatorWorkerPermanentFailureException( 18 + pht( 19 + 'Unable to load webhook request ("%s"). It may have been '. 20 + 'garbage collected.', 21 + $request_phid)); 22 + } 23 + 24 + $status = $request->getStatus(); 25 + if ($status !== HeraldWebhookRequest::STATUS_QUEUED) { 26 + throw new PhabricatorWorkerPermanentFailureException( 27 + pht( 28 + 'Webhook request ("%s") is not in "%s" status (actual '. 29 + 'status is "%s"). Declining call to hook.', 30 + $request_phid, 31 + HeraldWebhookRequest::STATUS_QUEUED, 32 + $status)); 33 + } 34 + 35 + $hook = $request->getWebhook(); 36 + 37 + if ($hook->isDisabled()) { 38 + $this->failRequest($request, 'hook', 'disabled'); 39 + throw new PhabricatorWorkerPermanentFailureException( 40 + pht( 41 + 'Associated hook ("%s") for webhook request ("%s") is disabled.', 42 + $hook->getPHID(), 43 + $request_phid)); 44 + } 45 + 46 + $uri = $hook->getWebhookURI(); 47 + try { 48 + PhabricatorEnv::requireValidRemoteURIForFetch( 49 + $uri, 50 + array( 51 + 'http', 52 + 'https', 53 + )); 54 + } catch (Exception $ex) { 55 + $this->failRequest($request, 'hook', 'uri'); 56 + throw new PhabricatorWorkerPermanentFailureException( 57 + pht( 58 + 'Associated hook ("%s") for webhook request ("%s") has invalid '. 59 + 'fetch URI: %s', 60 + $hook->getPHID(), 61 + $request_phid, 62 + $ex->getMessage())); 63 + } 64 + 65 + $object_phid = $request->getObjectPHID(); 66 + 67 + $object = id(new PhabricatorObjectQuery()) 68 + ->setViewer($viewer) 69 + ->withPHIDs(array($object_phid)) 70 + ->executeOne(); 71 + if (!$object) { 72 + $this->failRequest($request, 'hook', 'object'); 73 + throw new PhabricatorWorkerPermanentFailureException( 74 + pht( 75 + 'Unable to load object ("%s") for webhook request ("%s").', 76 + $object_phid, 77 + $request_phid)); 78 + } 79 + 80 + $xaction_query = PhabricatorApplicationTransactionQuery::newQueryForObject( 81 + $object); 82 + $xaction_phids = $request->getTransactionPHIDs(); 83 + if ($xaction_phids) { 84 + $xactions = $xaction_query 85 + ->setViewer($viewer) 86 + ->withObjectPHIDs(array($object_phid)) 87 + ->withPHIDs($xaction_phids) 88 + ->execute(); 89 + $xactions = mpull($xactions, null, 'getPHID'); 90 + } else { 91 + $xactions = array(); 92 + } 93 + 94 + // To prevent thundering herd issues for high volume webhooks (where 95 + // a large number of workers might try to work through a request backlog 96 + // simultaneously, before the error backoff can catch up), we never 97 + // parallelize requests to a particular webhook. 98 + 99 + $lock_key = 'webhook('.$hook->getPHID().')'; 100 + $lock = PhabricatorGlobalLock::newLock($lock_key); 101 + 102 + try { 103 + $lock->lock(); 104 + } catch (Exception $ex) { 105 + phlog($ex); 106 + throw new PhabricatorWorkerYieldException(15); 107 + } 108 + 109 + $caught = null; 110 + try { 111 + $this->callWebhookWithLock($hook, $request, $object, $xactions); 112 + } catch (Exception $ex) { 113 + $caught = $ex; 114 + } 115 + 116 + $lock->unlock(); 117 + 118 + if ($caught) { 119 + throw $caught; 120 + } 121 + } 122 + 123 + private function callWebhookWithLock( 124 + HeraldWebhook $hook, 125 + HeraldWebhookRequest $request, 126 + $object, 127 + array $xactions) { 128 + $viewer = PhabricatorUser::getOmnipotentUser(); 129 + 130 + if ($hook->isInErrorBackoff($viewer)) { 131 + throw new PhabricatorWorkerYieldException($hook->getErrorBackoffWindow()); 132 + } 133 + 134 + $xaction_data = array(); 135 + foreach ($xactions as $xaction) { 136 + $xaction_data[] = array( 137 + 'phid' => $xaction->getPHID(), 138 + ); 139 + } 140 + 141 + $payload = array( 142 + 'triggers' => array(), 143 + 'object' => array( 144 + 'phid' => $object->getPHID(), 145 + ), 146 + 'transactions' => $xaction_data, 147 + ); 148 + 149 + $payload = phutil_json_encode($payload); 150 + $key = $hook->getHmacKey(); 151 + $signature = PhabricatorHash::digestHMACSHA256($payload, $key); 152 + $uri = $hook->getWebhookURI(); 153 + 154 + $future = id(new HTTPSFuture($uri)) 155 + ->setMethod('POST') 156 + ->addHeader('Content-Type', 'application/json') 157 + ->addHeader('X-Phabricator-Webhook-Signature', $signature) 158 + ->setTimeout(15) 159 + ->setData($payload); 160 + 161 + list($status) = $future->resolve(); 162 + 163 + if ($status->isTimeout()) { 164 + $error_type = 'timeout'; 165 + } else { 166 + $error_type = 'http'; 167 + } 168 + $error_code = $status->getStatusCode(); 169 + 170 + $request 171 + ->setErrorType($error_type) 172 + ->setErrorCode($error_code) 173 + ->setLastRequestEpoch(PhabricatorTime::getNow()); 174 + 175 + $retry_forever = HeraldWebhookRequest::RETRY_FOREVER; 176 + if ($status->isTimeout() || $status->isError()) { 177 + $should_retry = ($request->getRetryMode() === $retry_forever); 178 + 179 + $request 180 + ->setLastRequestResult(HeraldWebhookRequest::RESULT_FAIL); 181 + 182 + if ($should_retry) { 183 + $request->save(); 184 + 185 + throw new Exception( 186 + pht( 187 + 'Webhook request ("%s", to "%s") failed (%s / %s). The request '. 188 + 'will be retried.', 189 + $request->getPHID(), 190 + $uri, 191 + $error_type, 192 + $error_code)); 193 + } else { 194 + $request 195 + ->setStatus(HeraldWebhookRequest::STATUS_FAILED) 196 + ->save(); 197 + 198 + throw new PhabricatorWorkerPermanentFailureException( 199 + pht( 200 + 'Webhook request ("%s", to "%s") failed (%s / %s). The request '. 201 + 'will not be retried.', 202 + $request->getPHID(), 203 + $uri, 204 + $error_type, 205 + $error_code)); 206 + } 207 + } else { 208 + $request 209 + ->setLastRequestResult(HeraldWebhookRequest::RESULT_OKAY) 210 + ->setStatus(HeraldWebhookRequest::STATUS_SENT) 211 + ->save(); 212 + } 213 + } 214 + 215 + private function failRequest( 216 + HeraldWebhookRequest $request, 217 + $error_type, 218 + $error_code) { 219 + 220 + $request 221 + ->setStatus(HeraldWebhookRequest::STATUS_FAILED) 222 + ->setErrorType($error_type) 223 + ->setErrorCode($error_code) 224 + ->setLastRequestResult(HeraldWebhookRequest::RESULT_NONE) 225 + ->setLastRequestEpoch(0) 226 + ->save(); 227 + } 228 + 229 + }
+60
src/applications/herald/xaction/HeraldWebhookNameTransaction.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookNameTransaction 4 + extends HeraldWebhookTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'name'; 7 + 8 + public function generateOldValue($object) { 9 + return $object->getName(); 10 + } 11 + 12 + public function applyInternalEffects($object, $value) { 13 + $object->setName($value); 14 + } 15 + 16 + public function getTitle() { 17 + return pht( 18 + '%s renamed this webhook from %s to %s.', 19 + $this->renderAuthor(), 20 + $this->renderOldValue(), 21 + $this->renderNewValue()); 22 + } 23 + 24 + public function getTitleForFeed() { 25 + return pht( 26 + '%s renamed %s from %s to %s.', 27 + $this->renderAuthor(), 28 + $this->renderObject(), 29 + $this->renderOldValue(), 30 + $this->renderNewValue()); 31 + } 32 + 33 + public function validateTransactions($object, array $xactions) { 34 + $errors = array(); 35 + $viewer = $this->getActor(); 36 + 37 + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { 38 + $errors[] = $this->newRequiredError( 39 + pht('Webhooks must have a name.')); 40 + return $errors; 41 + } 42 + 43 + $max_length = $object->getColumnMaximumByteLength('name'); 44 + foreach ($xactions as $xaction) { 45 + $old_value = $this->generateOldValue($object); 46 + $new_value = $xaction->getNewValue(); 47 + 48 + $new_length = strlen($new_value); 49 + if ($new_length > $max_length) { 50 + $errors[] = $this->newInvalidError( 51 + pht( 52 + 'Webhook names can be no longer than %s characters.', 53 + new PhutilNumber($max_length))); 54 + } 55 + } 56 + 57 + return $errors; 58 + } 59 + 60 + }
+55
src/applications/herald/xaction/HeraldWebhookStatusTransaction.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookStatusTransaction 4 + extends HeraldWebhookTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'status'; 7 + 8 + public function generateOldValue($object) { 9 + return $object->getStatus(); 10 + } 11 + 12 + public function applyInternalEffects($object, $value) { 13 + $object->setStatus($value); 14 + } 15 + 16 + public function getTitle() { 17 + return pht( 18 + '%s changed hook status from %s to %s.', 19 + $this->renderAuthor(), 20 + $this->renderOldValue(), 21 + $this->renderNewValue()); 22 + } 23 + 24 + public function getTitleForFeed() { 25 + return pht( 26 + '%s changed %s from %s to %s.', 27 + $this->renderAuthor(), 28 + $this->renderObject(), 29 + $this->renderOldValue(), 30 + $this->renderNewValue()); 31 + } 32 + 33 + public function validateTransactions($object, array $xactions) { 34 + $errors = array(); 35 + $viewer = $this->getActor(); 36 + 37 + $options = HeraldWebhook::getStatusDisplayNameMap(); 38 + 39 + foreach ($xactions as $xaction) { 40 + $new_value = $xaction->getNewValue(); 41 + 42 + if (!isset($options[$new_value])) { 43 + $errors[] = $this->newInvalidError( 44 + pht( 45 + 'Webhook status "%s" is not valid. Valid statuses are: %s.', 46 + $new_value, 47 + implode(', ', array_keys($options))), 48 + $xaction); 49 + } 50 + } 51 + 52 + return $errors; 53 + } 54 + 55 + }
+4
src/applications/herald/xaction/HeraldWebhookTransactionType.php
··· 1 + <?php 2 + 3 + abstract class HeraldWebhookTransactionType 4 + extends PhabricatorModularTransactionType {}
+74
src/applications/herald/xaction/HeraldWebhookURITransaction.php
··· 1 + <?php 2 + 3 + final class HeraldWebhookURITransaction 4 + extends HeraldWebhookTransactionType { 5 + 6 + const TRANSACTIONTYPE = 'uri'; 7 + 8 + public function generateOldValue($object) { 9 + return $object->getWebhookURI(); 10 + } 11 + 12 + public function applyInternalEffects($object, $value) { 13 + $object->setWebhookURI($value); 14 + } 15 + 16 + public function getTitle() { 17 + return pht( 18 + '%s changed the URI for this webhook from %s to %s.', 19 + $this->renderAuthor(), 20 + $this->renderOldValue(), 21 + $this->renderNewValue()); 22 + } 23 + 24 + public function getTitleForFeed() { 25 + return pht( 26 + '%s changed the URI for %s from %s to %s.', 27 + $this->renderAuthor(), 28 + $this->renderObject(), 29 + $this->renderOldValue(), 30 + $this->renderNewValue()); 31 + } 32 + 33 + public function validateTransactions($object, array $xactions) { 34 + $errors = array(); 35 + $viewer = $this->getActor(); 36 + 37 + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { 38 + $errors[] = $this->newRequiredError( 39 + pht('Webhooks must have a URI.')); 40 + return $errors; 41 + } 42 + 43 + $max_length = $object->getColumnMaximumByteLength('webhookURI'); 44 + foreach ($xactions as $xaction) { 45 + $old_value = $this->generateOldValue($object); 46 + $new_value = $xaction->getNewValue(); 47 + 48 + $new_length = strlen($new_value); 49 + if ($new_length > $max_length) { 50 + $errors[] = $this->newInvalidError( 51 + pht( 52 + 'Webhook URIs can be no longer than %s characters.', 53 + new PhutilNumber($max_length)), 54 + $xaction); 55 + } 56 + 57 + try { 58 + PhabricatorEnv::requireValidRemoteURIForFetch( 59 + $new_value, 60 + array( 61 + 'http', 62 + 'https', 63 + )); 64 + } catch (Exception $ex) { 65 + $errors[] = $this->newInvalidError( 66 + $ex->getMessage(), 67 + $xaction); 68 + } 69 + } 70 + 71 + return $errors; 72 + } 73 + 74 + }