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 +setTagline(pht('manage webhooks')); +$args->setSynopsis(<<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[1-9]\d*)/' => 'HeraldTranscriptController', ), + 'webhook/' => array( + $this->getQueryRoutePattern() => 'HeraldWebhookListController', + 'view/(?P\d+)/(?:request/(?P[^/]+)/)?' => + 'HeraldWebhookViewController', + $this->getEditRoutePattern('edit/') => 'HeraldWebhookEditController', + 'test/(?P\d+)/' => 'HeraldWebhookTestController', + 'key/' => array( + 'view/(?P\d+)/' => 'HeraldWebhookViewKeyController', + 'cycle/(?P\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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ + 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 @@ + 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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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; + } + +}