diff --git a/scripts/setup/manage_garbage.php b/scripts/setup/manage_garbage.php index ba727eab60..326730375e 100755 --- a/scripts/setup/manage_garbage.php +++ b/scripts/setup/manage_garbage.php @@ -1,21 +1,21 @@ #!/usr/bin/env php setTagline(pht('manage garbage colletors')); +$args->setTagline(pht('manage garbage collectors')); $args->setSynopsis(<<parseStandardArguments(); $workflows = id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorGarbageCollectorManagementWorkflow') ->execute(); $workflows[] = new PhutilHelpArgumentWorkflow(); $args->parseWorkflows($workflows); diff --git a/src/aphront/httpparametertype/AphrontHTTPParameterType.php b/src/aphront/httpparametertype/AphrontHTTPParameterType.php index 995556a6b9..78a62a663c 100644 --- a/src/aphront/httpparametertype/AphrontHTTPParameterType.php +++ b/src/aphront/httpparametertype/AphrontHTTPParameterType.php @@ -1,309 +1,309 @@ viewer = $viewer; return $this; } /** * Get the current viewer. * * @return PhabricatorUser Current viewer. * @task read */ final public function getViewer() { if (!$this->viewer) { throw new PhutilInvalidStateException('setViewer'); } return $this->viewer; } /** * Test if a value is present in a request. * * @param AphrontRequest The incoming request. * @param string The key to examine. * @return bool True if a readable value is present in the request. * @task read */ final public function getExists(AphrontRequest $request, $key) { return $this->getParameterExists($request, $key); } /** * Read a value from a request. * * If the value is not present, a default value is returned (usually `null`). * Use @{method:getExists} to test if a value is present. * * @param AphrontRequest The incoming request. * @param string The key to examine. * @return wild Value, or default if value is not present. * @task read */ final public function getValue(AphrontRequest $request, $key) { if (!$this->getExists($request, $key)) { return $this->getParameterDefault(); } return $this->getParameterValue($request, $key); } /** * Get the default value for this parameter type. * * @return wild Default value for this type. * @task read */ final public function getDefaultValue() { return $this->getParameterDefault(); } /* -( Information About the Type )----------------------------------------- */ /** * Get a short name for this type, like `string` or `list`. * * @return string Short type name. * @task info */ final public function getTypeName() { return $this->getParameterTypeName(); } /** * Get a list of human-readable descriptions of acceptable formats for this * type. * * For example, a type might return strings like these: * * > Any positive integer. * > A comma-separated list of PHIDs. * * This is used to explain to users how to specify a type when generating * documentation. * * @return list Human-readable list of acceptable formats. * @task info */ final public function getFormatDescriptions() { return $this->getParameterFormatDescriptions(); } /** * Get a list of human-readable examples of how to format this type as an * HTTP GET parameter. * * For example, a type might return strings like these: * * > v=123 * > v[]=1&v[]=2 * * This is used to show users how to specify parameters of this type in * generated documentation. * * @return list Human-readable list of format examples. * @task info */ final public function getExamples() { return $this->getParameterExamples(); } /* -( Utilities )---------------------------------------------------------- */ /** * Call another type's existence check. * - * This method allows a type to reuse the exitence behavior of a different + * This method allows a type to reuse the existence behavior of a different * type. For example, a "list of users" type may have the same basic * existence check that a simpler "list of strings" type has, and can just * call the simpler type to reuse its behavior. * * @param AphrontHTTPParameterType The other type. * @param AphrontRequest Incoming request. * @param string Key to examine. * @return bool True if the parameter exists. * @task util */ final protected function getExistsWithType( AphrontHTTPParameterType $type, AphrontRequest $request, $key) { $type->setViewer($this->getViewer()); return $type->getParameterExists($request, $key); } /** * Call another type's value parser. * * This method allows a type to reuse the parsing behavior of a different * type. For example, a "list of users" type may start by running the same * basic parsing that a simpler "list of strings" type does. * * @param AphrontHTTPParameterType The other type. * @param AphrontRequest Incoming request. * @param string Key to examine. * @return wild Parsed value. * @task util */ final protected function getValueWithType( AphrontHTTPParameterType $type, AphrontRequest $request, $key) { $type->setViewer($this->getViewer()); return $type->getValue($request, $key); } /** * Get a list of all available parameter types. * * @return list List of all available types. * @task util */ final public static function getAllTypes() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getTypeName') ->setSortMethod('getTypeName') ->execute(); } /* -( Implementation )----------------------------------------------------- */ /** * Test if a parameter exists in a request. * * See @{method:getExists}. By default, this method tests if the key is * present in the request. * * To call another type's behavior in order to perform this check, use * @{method:getExistsWithType}. * * @param AphrontRequest The incoming request. * @param string The key to examine. * @return bool True if a readable value is present in the request. * @task impl */ protected function getParameterExists(AphrontRequest $request, $key) { return $request->getExists($key); } /** * Parse a value from a request. * * See @{method:getValue}. This method will //only// be called if this type * has already asserted that the value exists with * @{method:getParameterExists}. * * To call another type's behavior in order to parse a value, use * @{method:getValueWithType}. * * @param AphrontRequest The incoming request. * @param string The key to examine. * @return wild Parsed value. * @task impl */ abstract protected function getParameterValue(AphrontRequest $request, $key); /** * Return a simple type name string, like "string" or "list". * * See @{method:getTypeName}. * * @return string Short type name. * @task impl */ abstract protected function getParameterTypeName(); /** * Return a human-readable list of format descriptions. * * See @{method:getFormatDescriptions}. * * @return list Human-readable list of acceptable formats. * @task impl */ abstract protected function getParameterFormatDescriptions(); /** * Return a human-readable list of examples. * * See @{method:getExamples}. * * @return list Human-readable list of format examples. * @task impl */ abstract protected function getParameterExamples(); /** * Return the default value for this parameter type. * * See @{method:getDefaultValue}. If unspecified, the default is `null`. * * @return wild Default value. * @task impl */ protected function getParameterDefault() { return null; } } diff --git a/src/applications/almanac/editor/AlmanacDeviceEditor.php b/src/applications/almanac/editor/AlmanacDeviceEditor.php index 64b8de7bad..8e110954a3 100644 --- a/src/applications/almanac/editor/AlmanacDeviceEditor.php +++ b/src/applications/almanac/editor/AlmanacDeviceEditor.php @@ -1,335 +1,335 @@ getTransactionType()) { case AlmanacDeviceTransaction::TYPE_NAME: return $object->getName(); } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case AlmanacDeviceTransaction::TYPE_NAME: case AlmanacDeviceTransaction::TYPE_INTERFACE: return $xaction->getNewValue(); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case AlmanacDeviceTransaction::TYPE_NAME: $object->setName($xaction->getNewValue()); return; case AlmanacDeviceTransaction::TYPE_INTERFACE: return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case AlmanacDeviceTransaction::TYPE_NAME: return; case AlmanacDeviceTransaction::TYPE_INTERFACE: $old = $xaction->getOldValue(); if ($old) { $interface = id(new AlmanacInterfaceQuery()) ->setViewer($this->requireActor()) ->withIDs(array($old['id'])) ->executeOne(); if (!$interface) { throw new Exception(pht('Unable to load interface!')); } } else { $interface = AlmanacInterface::initializeNewInterface() ->setDevicePHID($object->getPHID()); } $new = $xaction->getNewValue(); if ($new) { $interface ->setNetworkPHID($new['networkPHID']) ->setAddress($new['address']) ->setPort((int)$new['port']); if (idx($new, 'phid')) { $interface->setPHID($new['phid']); } $interface->save(); } else { $interface->delete(); } return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case AlmanacDeviceTransaction::TYPE_NAME: $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Device name is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } else { foreach ($xactions as $xaction) { $message = null; $name = $xaction->getNewValue(); try { AlmanacNames::validateName($name); } catch (Exception $ex) { $message = $ex->getMessage(); } if ($message !== null) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), $message, $xaction); $errors[] = $error; continue; } $other = id(new AlmanacDeviceQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withNames(array($name)) ->executeOne(); if ($other && ($other->getID() != $object->getID())) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Not Unique'), pht('Almanac devices must have unique names.'), $xaction); $errors[] = $error; continue; } if ($name === $object->getName()) { continue; } $namespace = AlmanacNamespace::loadRestrictedNamespace( $this->getActor(), $name); if ($namespace) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Restricted'), pht( 'You do not have permission to create Almanac devices '. 'within the "%s" namespace.', $namespace->getName()), $xaction); $errors[] = $error; continue; } } } break; case AlmanacDeviceTransaction::TYPE_INTERFACE: // We want to make sure that all the affected networks are visible to // the actor, any edited interfaces exist, and that the actual address // components are valid. $network_phids = array(); foreach ($xactions as $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); if ($old) { $network_phids[] = $old['networkPHID']; } if ($new) { $network_phids[] = $new['networkPHID']; $address = $new['address']; if (!strlen($address)) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('Interfaces must have an address.'), $xaction); $errors[] = $error; } else { - // TODO: Validate addresses, but IPv6 addresses are not trival + // TODO: Validate addresses, but IPv6 addresses are not trivial // to validate. } $port = $new['port']; if (!strlen($port)) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('Interfaces must have a port.'), $xaction); $errors[] = $error; } else if ((int)$port < 1 || (int)$port > 65535) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Port numbers must be between 1 and 65535, inclusive.'), $xaction); $errors[] = $error; } $phid = idx($new, 'phid'); if ($phid) { $interface_phid_type = AlmanacInterfacePHIDType::TYPECONST; if (phid_get_type($phid) !== $interface_phid_type) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Precomputed interface PHIDs must be of type '. 'AlmanacInterfacePHIDType.'), $xaction); $errors[] = $error; } } } } if ($network_phids) { $networks = id(new AlmanacNetworkQuery()) ->setViewer($this->requireActor()) ->withPHIDs($network_phids) ->execute(); $networks = mpull($networks, null, 'getPHID'); } else { $networks = array(); } $addresses = array(); foreach ($xactions as $xaction) { $old = $xaction->getOldValue(); if ($old) { $network = idx($networks, $old['networkPHID']); if (!$network) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can not edit an interface which belongs to a '. 'nonexistent or restricted network.'), $xaction); $errors[] = $error; } $addresses[] = $old['id']; } $new = $xaction->getNewValue(); if ($new) { $network = idx($networks, $new['networkPHID']); if (!$network) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can not add an interface on a nonexistent or '. 'restricted network.'), $xaction); $errors[] = $error; } } } if ($addresses) { $interfaces = id(new AlmanacInterfaceQuery()) ->setViewer($this->requireActor()) ->withDevicePHIDs(array($object->getPHID())) ->withIDs($addresses) ->execute(); $interfaces = mpull($interfaces, null, 'getID'); } else { $interfaces = array(); } foreach ($xactions as $xaction) { $old = $xaction->getOldValue(); if ($old) { $interface = idx($interfaces, $old['id']); if (!$interface) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('You can not edit an invalid or restricted interface.'), $xaction); $errors[] = $error; continue; } $new = $xaction->getNewValue(); if (!$new) { if ($interface->loadIsInUse()) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('In Use'), pht('You can not delete an interface which is still in use.'), $xaction); $errors[] = $error; } } } } break; } return $errors; } } diff --git a/src/applications/audit/view/PhabricatorAuditListView.php b/src/applications/audit/view/PhabricatorAuditListView.php index f9399e66ab..cb9fecac3a 100644 --- a/src/applications/audit/view/PhabricatorAuditListView.php +++ b/src/applications/audit/view/PhabricatorAuditListView.php @@ -1,182 +1,182 @@ noDataString = $no_data_string; return $this; } public function getNoDataString() { return $this->noDataString; } public function setHeader($header) { $this->header = $header; return $this; } public function getHeader() { return $this->header; } public function setShowDrafts($show_drafts) { $this->showDrafts = $show_drafts; return $this; } public function getShowDrafts() { return $this->showDrafts; } /** * These commits should have both commit data and audit requests attached. */ public function setCommits(array $commits) { assert_instances_of($commits, 'PhabricatorRepositoryCommit'); $this->commits = mpull($commits, null, 'getPHID'); return $this; } public function getCommits() { return $this->commits; } private function getCommitDescription($phid) { if ($this->commits === null) { return pht('(Unknown Commit)'); } $commit = idx($this->commits, $phid); if (!$commit) { return pht('(Unknown Commit)'); } $summary = $commit->getCommitData()->getSummary(); if (strlen($summary)) { return $summary; } - // No summary, so either this is still impoting or just has an empty + // No summary, so either this is still importing or just has an empty // commit message. if (!$commit->isImported()) { return pht('(Importing Commit...)'); } else { return pht('(Untitled Commit)'); } } public function render() { $list = $this->buildList(); $list->setFlush(true); return $list->render(); } public function buildList() { $viewer = $this->getViewer(); $rowc = array(); $phids = array(); foreach ($this->getCommits() as $commit) { $phids[] = $commit->getPHID(); foreach ($commit->getAudits() as $audit) { $phids[] = $audit->getAuditorPHID(); } $author_phid = $commit->getAuthorPHID(); if ($author_phid) { $phids[] = $author_phid; } } $handles = $viewer->loadHandles($phids); $show_drafts = $this->getShowDrafts(); $draft_icon = id(new PHUIIconView()) ->setIcon('fa-comment yellow') ->addSigil('has-tooltip') ->setMetadata( array( 'tip' => pht('Unsubmitted Comments'), )); $list = new PHUIObjectItemListView(); foreach ($this->commits as $commit) { $commit_phid = $commit->getPHID(); $commit_handle = $handles[$commit_phid]; $committed = null; $commit_name = $commit_handle->getName(); $commit_link = $commit_handle->getURI(); $commit_desc = $this->getCommitDescription($commit_phid); $committed = phabricator_datetime($commit->getEpoch(), $viewer); $status = $commit->getAuditStatus(); $status_text = PhabricatorAuditCommitStatusConstants::getStatusName($status); $status_color = PhabricatorAuditCommitStatusConstants::getStatusColor($status); $status_icon = PhabricatorAuditCommitStatusConstants::getStatusIcon($status); $author_phid = $commit->getAuthorPHID(); if ($author_phid) { $author_name = $handles[$author_phid]->renderLink(); } else { $author_name = $commit->getCommitData()->getAuthorName(); } $item = id(new PHUIObjectItemView()) ->setObjectName($commit_name) ->setHeader($commit_desc) ->setHref($commit_link) ->setDisabled($commit->isUnreachable()) ->addByline(pht('Author: %s', $author_name)) ->addIcon('none', $committed); if ($show_drafts) { if ($commit->getHasDraft($viewer)) { $item->addAttribute($draft_icon); } } $audits = $commit->getAudits(); $auditor_phids = mpull($audits, 'getAuditorPHID'); if ($auditor_phids) { $auditor_list = $handles->newSublist($auditor_phids) ->renderList() ->setAsInline(true); } else { $auditor_list = phutil_tag('em', array(), pht('None')); } $item->addAttribute(pht('Auditors: %s', $auditor_list)); if ($status_color) { $item->setStatusIcon($status_icon.' '.$status_color, $status_text); } $list->addItem($item); } if ($this->noDataString) { $list->setNoDataString($this->noDataString); } if ($this->header) { $list->setHeader($this->header); } return $list; } } diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php index c5b3c1ce99..0ed86e3056 100644 --- a/src/applications/auth/controller/PhabricatorAuthController.php +++ b/src/applications/auth/controller/PhabricatorAuthController.php @@ -1,295 +1,295 @@ setTitle($title); $view->setErrors($messages); return $this->newPage() ->setTitle($title) ->appendChild($view); } /** * Returns true if this install is newly setup (i.e., there are no user * accounts yet). In this case, we enter a special mode to permit creation * of the first account form the web UI. */ protected function isFirstTimeSetup() { // If there are any auth providers, this isn't first time setup, even if // we don't have accounts. if (PhabricatorAuthProvider::getAllEnabledProviders()) { return false; } // Otherwise, check if there are any user accounts. If not, we're in first // time setup. $any_users = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setLimit(1) ->execute(); return !$any_users; } /** * Log a user into a web session and return an @{class:AphrontResponse} which * corresponds to continuing the login process. * * Normally, this is a redirect to the validation controller which makes sure * the user's cookies are set. However, event listeners can intercept this * event and do something else if they prefer. * * @param PhabricatorUser User to log the viewer in as. * @return AphrontResponse Response which continues the login process. */ protected function loginUser(PhabricatorUser $user) { $response = $this->buildLoginValidateResponse($user); $session_type = PhabricatorAuthSession::TYPE_WEB; $event_type = PhabricatorEventType::TYPE_AUTH_WILLLOGINUSER; $event_data = array( 'user' => $user, 'type' => $session_type, 'response' => $response, 'shouldLogin' => true, ); $event = id(new PhabricatorEvent($event_type, $event_data)) ->setUser($user); PhutilEventEngine::dispatchEvent($event); $should_login = $event->getValue('shouldLogin'); if ($should_login) { $session_key = id(new PhabricatorAuthSessionEngine()) ->establishSession($session_type, $user->getPHID(), $partial = true); // NOTE: We allow disabled users to login and roadblock them later, so // there's no check for users being disabled here. $request = $this->getRequest(); $request->setCookie( PhabricatorCookies::COOKIE_USERNAME, $user->getUsername()); $request->setCookie( PhabricatorCookies::COOKIE_SESSION, $session_key); $this->clearRegistrationCookies(); } return $event->getValue('response'); } protected function clearRegistrationCookies() { $request = $this->getRequest(); // Clear the registration key. $request->clearCookie(PhabricatorCookies::COOKIE_REGISTRATION); // Clear the client ID / OAuth state key. $request->clearCookie(PhabricatorCookies::COOKIE_CLIENTID); // Clear the invite cookie. $request->clearCookie(PhabricatorCookies::COOKIE_INVITE); } private function buildLoginValidateResponse(PhabricatorUser $user) { $validate_uri = new PhutilURI($this->getApplicationURI('validate/')); $validate_uri->setQueryParam('expect', $user->getUsername()); return id(new AphrontRedirectResponse())->setURI((string)$validate_uri); } protected function renderError($message) { return $this->renderErrorPage( pht('Authentication Error'), array( $message, )); } protected function loadAccountForRegistrationOrLinking($account_key) { $request = $this->getRequest(); $viewer = $request->getUser(); $account = null; $provider = null; $response = null; if (!$account_key) { $response = $this->renderError( pht('Request did not include account key.')); return array($account, $provider, $response); } // NOTE: We're using the omnipotent user because the actual user may not // be logged in yet, and because we want to tailor an error message to // distinguish between "not usable" and "does not exist". We do explicit // checks later on to make sure this account is valid for the intended // operation. This requires edit permission for completeness and consistency // but it won't actually be meaningfully checked because we're using the - // ominpotent user. + // omnipotent user. $account = id(new PhabricatorExternalAccountQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAccountSecrets(array($account_key)) ->needImages(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$account) { $response = $this->renderError(pht('No valid linkable account.')); return array($account, $provider, $response); } if ($account->getUserPHID()) { if ($account->getUserPHID() != $viewer->getPHID()) { $response = $this->renderError( pht( 'The account you are attempting to register or link is already '. 'linked to another user.')); } else { $response = $this->renderError( pht( 'The account you are attempting to link is already linked '. 'to your account.')); } return array($account, $provider, $response); } $registration_key = $request->getCookie( PhabricatorCookies::COOKIE_REGISTRATION); // NOTE: This registration key check is not strictly necessary, because // we're only creating new accounts, not linking existing accounts. It // might be more hassle than it is worth, especially for email. // // The attack this prevents is getting to the registration screen, then // copy/pasting the URL and getting someone else to click it and complete // the process. They end up with an account bound to credentials you // control. This doesn't really let you do anything meaningful, though, // since you could have simply completed the process yourself. if (!$registration_key) { $response = $this->renderError( pht( 'Your browser did not submit a registration key with the request. '. 'You must use the same browser to begin and complete registration. '. 'Check that cookies are enabled and try again.')); return array($account, $provider, $response); } // We store the digest of the key rather than the key itself to prevent a // theoretical attacker with read-only access to the database from // hijacking registration sessions. $actual = $account->getProperty('registrationKey'); $expect = PhabricatorHash::weakDigest($registration_key); if (!phutil_hashes_are_identical($actual, $expect)) { $response = $this->renderError( pht( 'Your browser submitted a different registration key than the one '. 'associated with this account. You may need to clear your cookies.')); return array($account, $provider, $response); } $other_account = id(new PhabricatorExternalAccount())->loadAllWhere( 'accountType = %s AND accountDomain = %s AND accountID = %s AND id != %d', $account->getAccountType(), $account->getAccountDomain(), $account->getAccountID(), $account->getID()); if ($other_account) { $response = $this->renderError( pht( 'The account you are attempting to register with already belongs '. 'to another user.')); return array($account, $provider, $response); } $provider = PhabricatorAuthProvider::getEnabledProviderByKey( $account->getProviderKey()); if (!$provider) { $response = $this->renderError( pht( 'The account you are attempting to register with uses a nonexistent '. 'or disabled authentication provider (with key "%s"). An '. 'administrator may have recently disabled this provider.', $account->getProviderKey())); return array($account, $provider, $response); } return array($account, $provider, null); } protected function loadInvite() { $invite_cookie = PhabricatorCookies::COOKIE_INVITE; $invite_code = $this->getRequest()->getCookie($invite_cookie); if (!$invite_code) { return null; } $engine = id(new PhabricatorAuthInviteEngine()) ->setViewer($this->getViewer()) ->setUserHasConfirmedVerify(true); try { return $engine->processInviteCode($invite_code); } catch (Exception $ex) { // If this fails for any reason, just drop the invite. In normal // circumstances, we gave them a detailed explanation of any error // before they jumped into this workflow. return null; } } protected function renderInviteHeader(PhabricatorAuthInvite $invite) { $viewer = $this->getViewer(); // Since the user hasn't registered yet, they may not be able to see other // user accounts. Load the inviting user with the omnipotent viewer. $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $invite_author = id(new PhabricatorPeopleQuery()) ->setViewer($omnipotent_viewer) ->withPHIDs(array($invite->getAuthorPHID())) ->needProfileImage(true) ->executeOne(); // If we can't load the author for some reason, just drop this message. // We lose the value of contextualizing things without author details. if (!$invite_author) { return null; } $invite_item = id(new PHUIObjectItemView()) ->setHeader(pht('Welcome to Phabricator!')) ->setImageURI($invite_author->getProfileImageURI()) ->addAttribute( pht( '%s has invited you to join Phabricator.', $invite_author->getFullName())); $invite_list = id(new PHUIObjectItemListView()) ->addItem($invite_item) ->setFlush(true); return id(new PHUIBoxView()) ->addMargin(PHUI::MARGIN_LARGE) ->appendChild($invite_list); } } diff --git a/src/applications/auth/controller/PhabricatorAuthRegisterController.php b/src/applications/auth/controller/PhabricatorAuthRegisterController.php index a68e12c801..5a792dc864 100644 --- a/src/applications/auth/controller/PhabricatorAuthRegisterController.php +++ b/src/applications/auth/controller/PhabricatorAuthRegisterController.php @@ -1,721 +1,721 @@ getViewer(); $account_key = $request->getURIData('akey'); if ($request->getUser()->isLoggedIn()) { return id(new AphrontRedirectResponse())->setURI('/'); } $is_setup = false; if (strlen($account_key)) { $result = $this->loadAccountForRegistrationOrLinking($account_key); list($account, $provider, $response) = $result; $is_default = false; } else if ($this->isFirstTimeSetup()) { list($account, $provider, $response) = $this->loadSetupAccount(); $is_default = true; $is_setup = true; } else { list($account, $provider, $response) = $this->loadDefaultAccount(); $is_default = true; } if ($response) { return $response; } $invite = $this->loadInvite(); if (!$provider->shouldAllowRegistration()) { if ($invite) { // If the user has an invite, we allow them to register with any // provider, even a login-only provider. } else { // TODO: This is a routine error if you click "Login" on an external // auth source which doesn't allow registration. The error should be // more tailored. return $this->renderError( pht( 'The account you are attempting to register with uses an '. 'authentication provider ("%s") which does not allow '. 'registration. An administrator may have recently disabled '. 'registration with this provider.', $provider->getProviderName())); } } $errors = array(); $user = new PhabricatorUser(); $default_username = $account->getUsername(); $default_realname = $account->getRealName(); $default_email = $account->getEmail(); if ($invite) { $default_email = $invite->getEmailAddress(); } if ($default_email !== null) { if (!PhabricatorUserEmail::isValidAddress($default_email)) { $errors[] = pht( 'The email address associated with this external account ("%s") is '. 'not a valid email address and can not be used to register a '. 'Phabricator account. Choose a different, valid address.', phutil_tag('strong', array(), $default_email)); $default_email = null; } } if ($default_email !== null) { - // We should bypass policy here becase e.g. limiting an application use + // We should bypass policy here because e.g. limiting an application use // to a subset of users should not allow the others to overwrite // configured application emails. $application_email = id(new PhabricatorMetaMTAApplicationEmailQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAddresses(array($default_email)) ->executeOne(); if ($application_email) { $errors[] = pht( 'The email address associated with this account ("%s") is '. 'already in use by an application and can not be used to '. 'register a new Phabricator account. Choose a different, valid '. 'address.', phutil_tag('strong', array(), $default_email)); $default_email = null; } } $show_existing = null; if ($default_email !== null) { // If the account source provided an email, but it's not allowed by // the configuration, roadblock the user. Previously, we let the user // pick a valid email address instead, but this does not align well with // user expectation and it's not clear the cases it enables are valuable. // See discussion in T3472. if (!PhabricatorUserEmail::isAllowedAddress($default_email)) { $debug_email = new PHUIInvisibleCharacterView($default_email); return $this->renderError( array( pht( 'The account you are attempting to register with has an invalid '. 'email address (%s). This Phabricator install only allows '. 'registration with specific email addresses:', $debug_email), phutil_tag('br'), phutil_tag('br'), PhabricatorUserEmail::describeAllowedAddresses(), )); } // If the account source provided an email, but another account already // has that email, just pretend we didn't get an email. if ($default_email !== null) { $same_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $default_email); if ($same_email) { if ($invite) { // We're allowing this to continue. The fact that we loaded the // invite means that the address is nonprimary and unverified and // we're OK to steal it. } else { $show_existing = $default_email; $default_email = null; } } } } if ($show_existing !== null) { if (!$request->getInt('phase')) { return $this->newDialog() ->setTitle(pht('Email Address Already in Use')) ->addHiddenInput('phase', 1) ->appendParagraph( pht( 'You are creating a new Phabricator account linked to an '. 'existing external account from outside Phabricator.')) ->appendParagraph( pht( 'The email address ("%s") associated with the external account '. 'is already in use by an existing Phabricator account. Multiple '. 'Phabricator accounts may not have the same email address, so '. 'you can not use this email address to register a new '. 'Phabricator account.', phutil_tag('strong', array(), $show_existing))) ->appendParagraph( pht( 'If you want to register a new account, continue with this '. 'registration workflow and choose a new, unique email address '. 'for the new account.')) ->appendParagraph( pht( 'If you want to link an existing Phabricator account to this '. 'external account, do not continue. Instead: log in to your '. 'existing account, then go to "Settings" and link the account '. 'in the "External Accounts" panel.')) ->appendParagraph( pht( 'If you continue, you will create a new account. You will not '. 'be able to link this external account to an existing account.')) ->addCancelButton('/auth/login/', pht('Cancel')) ->addSubmitButton(pht('Create New Account')); } else { $errors[] = pht( 'The external account you are registering with has an email address '. 'that is already in use ("%s") by an existing Phabricator account. '. 'Choose a new, valid email address to register a new Phabricator '. 'account.', phutil_tag('strong', array(), $show_existing)); } } $profile = id(new PhabricatorRegistrationProfile()) ->setDefaultUsername($default_username) ->setDefaultEmail($default_email) ->setDefaultRealName($default_realname) ->setCanEditUsername(true) ->setCanEditEmail(($default_email === null)) ->setCanEditRealName(true) ->setShouldVerifyEmail(false); $event_type = PhabricatorEventType::TYPE_AUTH_WILLREGISTERUSER; $event_data = array( 'account' => $account, 'profile' => $profile, ); $event = id(new PhabricatorEvent($event_type, $event_data)) ->setUser($user); PhutilEventEngine::dispatchEvent($event); $default_username = $profile->getDefaultUsername(); $default_email = $profile->getDefaultEmail(); $default_realname = $profile->getDefaultRealName(); $can_edit_username = $profile->getCanEditUsername(); $can_edit_email = $profile->getCanEditEmail(); $can_edit_realname = $profile->getCanEditRealName(); $must_set_password = $provider->shouldRequireRegistrationPassword(); $can_edit_anything = $profile->getCanEditAnything() || $must_set_password; $force_verify = $profile->getShouldVerifyEmail(); // Automatically verify the administrator's email address during first-time // setup. if ($is_setup) { $force_verify = true; } $value_username = $default_username; $value_realname = $default_realname; $value_email = $default_email; $value_password = null; $require_real_name = PhabricatorEnv::getEnvConfig('user.require-real-name'); $e_username = strlen($value_username) ? null : true; $e_realname = $require_real_name ? true : null; $e_email = strlen($value_email) ? null : true; $e_password = true; $e_captcha = true; $skip_captcha = false; if ($invite) { // If the user is accepting an invite, assume they're trustworthy enough // that we don't need to CAPTCHA them. $skip_captcha = true; } $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; $from_invite = $request->getStr('invite'); if ($from_invite && $can_edit_username) { $value_username = $request->getStr('username'); $e_username = null; } $try_register = ($request->isFormPost() || !$can_edit_anything) && !$from_invite && ($request->getInt('phase') != 1); if ($try_register) { $errors = array(); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); if ($must_set_password && !$skip_captcha) { $e_captcha = pht('Again'); $captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request); if (!$captcha_ok) { $errors[] = pht('Captcha response is incorrect, try again.'); $e_captcha = pht('Invalid'); } } if ($can_edit_username) { $value_username = $request->getStr('username'); if (!strlen($value_username)) { $e_username = pht('Required'); $errors[] = pht('Username is required.'); } else if (!PhabricatorUser::validateUsername($value_username)) { $e_username = pht('Invalid'); $errors[] = PhabricatorUser::describeValidUsername(); } else { $e_username = null; } } if ($must_set_password) { $value_password = $request->getStr('password'); $value_confirm = $request->getStr('confirm'); if (!strlen($value_password)) { $e_password = pht('Required'); $errors[] = pht('You must choose a password.'); } else if ($value_password !== $value_confirm) { $e_password = pht('No Match'); $errors[] = pht('Password and confirmation must match.'); } else if (strlen($value_password) < $min_len) { $e_password = pht('Too Short'); $errors[] = pht( 'Password is too short (must be at least %d characters long).', $min_len); } else if ( PhabricatorCommonPasswords::isCommonPassword($value_password)) { $e_password = pht('Very Weak'); $errors[] = pht( 'Password is pathologically weak. This password is one of the '. 'most common passwords in use, and is extremely easy for '. 'attackers to guess. You must choose a stronger password.'); } else { $e_password = null; } } if ($can_edit_email) { $value_email = $request->getStr('email'); if (!strlen($value_email)) { $e_email = pht('Required'); $errors[] = pht('Email is required.'); } else if (!PhabricatorUserEmail::isValidAddress($value_email)) { $e_email = pht('Invalid'); $errors[] = PhabricatorUserEmail::describeValidAddresses(); } else if (!PhabricatorUserEmail::isAllowedAddress($value_email)) { $e_email = pht('Disallowed'); $errors[] = PhabricatorUserEmail::describeAllowedAddresses(); } else { $e_email = null; } } if ($can_edit_realname) { $value_realname = $request->getStr('realName'); if (!strlen($value_realname) && $require_real_name) { $e_realname = pht('Required'); $errors[] = pht('Real name is required.'); } else { $e_realname = null; } } if (!$errors) { $image = $this->loadProfilePicture($account); if ($image) { $user->setProfileImagePHID($image->getPHID()); } try { $verify_email = false; if ($force_verify) { $verify_email = true; } if ($value_email === $default_email) { if ($account->getEmailVerified()) { $verify_email = true; } if ($provider->shouldTrustEmails()) { $verify_email = true; } if ($invite) { $verify_email = true; } } $email_obj = null; if ($invite) { // If we have a valid invite, this email may exist but be // nonprimary and unverified, so we'll reassign it. $email_obj = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $value_email); } if (!$email_obj) { $email_obj = id(new PhabricatorUserEmail()) ->setAddress($value_email); } $email_obj->setIsVerified((int)$verify_email); $user->setUsername($value_username); $user->setRealname($value_realname); if ($is_setup) { $must_approve = false; } else if ($invite) { $must_approve = false; } else { $must_approve = PhabricatorEnv::getEnvConfig( 'auth.require-approval'); } if ($must_approve) { $user->setIsApproved(0); } else { $user->setIsApproved(1); } if ($invite) { $allow_reassign_email = true; } else { $allow_reassign_email = false; } $user->openTransaction(); $editor = id(new PhabricatorUserEditor()) ->setActor($user); $editor->createNewUser($user, $email_obj, $allow_reassign_email); if ($must_set_password) { $envelope = new PhutilOpaqueEnvelope($value_password); $editor->changePassword($user, $envelope); } if ($is_setup) { $editor->makeAdminUser($user, true); } $account->setUserPHID($user->getPHID()); $provider->willRegisterAccount($account); $account->save(); $user->saveTransaction(); if (!$email_obj->getIsVerified()) { $email_obj->sendVerificationEmail($user); } if ($must_approve) { $this->sendWaitingForApprovalEmail($user); } if ($invite) { $invite->setAcceptedByPHID($user->getPHID())->save(); } return $this->loginUser($user); } catch (AphrontDuplicateKeyQueryException $exception) { $same_username = id(new PhabricatorUser())->loadOneWhere( 'userName = %s', $user->getUserName()); $same_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $value_email); if ($same_username) { $e_username = pht('Duplicate'); $errors[] = pht('Another user already has that username.'); } if ($same_email) { // TODO: See T3340. $e_email = pht('Duplicate'); $errors[] = pht('Another user already has that email.'); } if (!$same_username && !$same_email) { throw $exception; } } } unset($unguarded); } $form = id(new AphrontFormView()) ->setUser($request->getUser()) ->addHiddenInput('phase', 2); if (!$is_default) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('External Account')) ->setValue( id(new PhabricatorAuthAccountView()) ->setUser($request->getUser()) ->setExternalAccount($account) ->setAuthProvider($provider))); } if ($can_edit_username) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Username')) ->setName('username') ->setValue($value_username) ->setError($e_username)); } else { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Username')) ->setValue($value_username) ->setError($e_username)); } if ($can_edit_realname) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Real Name')) ->setName('realName') ->setValue($value_realname) ->setError($e_realname)); } if ($must_set_password) { $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Password')) ->setName('password') ->setError($e_password)); $form->appendChild( id(new AphrontFormPasswordControl()) ->setLabel(pht('Confirm Password')) ->setName('confirm') ->setError($e_password) ->setCaption( $min_len ? pht('Minimum length of %d characters.', $min_len) : null)); } if ($can_edit_email) { $form->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Email')) ->setName('email') ->setValue($value_email) ->setCaption(PhabricatorUserEmail::describeAllowedAddresses()) ->setError($e_email)); } if ($must_set_password && !$skip_captcha) { $form->appendChild( id(new AphrontFormRecaptchaControl()) ->setLabel(pht('Captcha')) ->setError($e_captcha)); } $submit = id(new AphrontFormSubmitControl()); if ($is_setup) { $submit ->setValue(pht('Create Admin Account')); } else { $submit ->addCancelButton($this->getApplicationURI('start/')) ->setValue(pht('Register Account')); } $form->appendChild($submit); $crumbs = $this->buildApplicationCrumbs(); if ($is_setup) { $crumbs->addTextCrumb(pht('Setup Admin Account')); $title = pht('Welcome to Phabricator'); } else { $crumbs->addTextCrumb(pht('Register')); $crumbs->addTextCrumb($provider->getProviderName()); $title = pht('Create a New Account'); } $crumbs->setBorder(true); $welcome_view = null; if ($is_setup) { $welcome_view = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) ->setTitle(pht('Welcome to Phabricator')) ->appendChild( pht( 'Installation is complete. Register your administrator account '. 'below to log in. You will be able to configure options and add '. 'other authentication mechanisms (like LDAP or OAuth) later on.')); } $object_box = id(new PHUIObjectBoxView()) ->setForm($form) ->setFormErrors($errors); $invite_header = null; if ($invite) { $invite_header = $this->renderInviteHeader($invite); } $header = id(new PHUIHeaderView()) ->setHeader($title); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter(array( $welcome_view, $invite_header, $object_box, )); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($view); } private function loadDefaultAccount() { $providers = PhabricatorAuthProvider::getAllEnabledProviders(); $account = null; $provider = null; $response = null; foreach ($providers as $key => $candidate_provider) { if (!$candidate_provider->shouldAllowRegistration()) { unset($providers[$key]); continue; } if (!$candidate_provider->isDefaultRegistrationProvider()) { unset($providers[$key]); } } if (!$providers) { $response = $this->renderError( pht( 'There are no configured default registration providers.')); return array($account, $provider, $response); } else if (count($providers) > 1) { $response = $this->renderError( pht('There are too many configured default registration providers.')); return array($account, $provider, $response); } $provider = head($providers); $account = $provider->getDefaultExternalAccount(); return array($account, $provider, $response); } private function loadSetupAccount() { $provider = new PhabricatorPasswordAuthProvider(); $provider->attachProviderConfig( id(new PhabricatorAuthProviderConfig()) ->setShouldAllowRegistration(1) ->setShouldAllowLogin(1) ->setIsEnabled(true)); $account = $provider->getDefaultExternalAccount(); $response = null; return array($account, $provider, $response); } private function loadProfilePicture(PhabricatorExternalAccount $account) { $phid = $account->getProfileImagePHID(); if (!$phid) { return null; } // NOTE: Use of omnipotent user is okay here because the registering user // can not control the field value, and we can't use their user object to // do meaningful policy checks anyway since they have not registered yet. // Reaching this means the user holds the account secret key and the // registration secret key, and thus has permission to view the image. $file = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($phid)) ->executeOne(); if (!$file) { return null; } $xform = PhabricatorFileTransform::getTransformByKey( PhabricatorFileThumbnailTransform::TRANSFORM_PROFILE); return $xform->executeTransform($file); } protected function renderError($message) { return $this->renderErrorPage( pht('Registration Failed'), array($message)); } private function sendWaitingForApprovalEmail(PhabricatorUser $user) { $title = '[Phabricator] '.pht( 'New User "%s" Awaiting Approval', $user->getUsername()); $body = new PhabricatorMetaMTAMailBody(); $body->addRawSection( pht( 'Newly registered user "%s" is awaiting account approval by an '. 'administrator.', $user->getUsername())); $body->addLinkSection( pht('APPROVAL QUEUE'), PhabricatorEnv::getProductionURI( '/people/query/approval/')); $body->addLinkSection( pht('DISABLE APPROVAL QUEUE'), PhabricatorEnv::getProductionURI( '/config/edit/auth.require-approval/')); $admins = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withIsAdmin(true) ->execute(); if (!$admins) { return; } $mail = id(new PhabricatorMetaMTAMail()) ->addTos(mpull($admins, 'getPHID')) ->setSubject($title) ->setBody($body->render()) ->saveAndSend(); } } diff --git a/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php b/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php index 0962b7b56a..1c40af5795 100644 --- a/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php +++ b/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php @@ -1,256 +1,256 @@ getTransactionType()) { case PhabricatorAuthSSHKeyTransaction::TYPE_NAME: return $object->getName(); case PhabricatorAuthSSHKeyTransaction::TYPE_KEY: return $object->getEntireKey(); case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE: return !$object->getIsActive(); } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuthSSHKeyTransaction::TYPE_NAME: case PhabricatorAuthSSHKeyTransaction::TYPE_KEY: return $xaction->getNewValue(); case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE: return (bool)$xaction->getNewValue(); } } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $value = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case PhabricatorAuthSSHKeyTransaction::TYPE_NAME: $object->setName($value); return; case PhabricatorAuthSSHKeyTransaction::TYPE_KEY: $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($value); $type = $public_key->getType(); $body = $public_key->getBody(); $comment = $public_key->getComment(); $object->setKeyType($type); $object->setKeyBody($body); $object->setKeyComment($comment); return; case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE: if ($value) { $new = null; } else { $new = 1; } $object->setIsActive($new); return; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return; } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case PhabricatorAuthSSHKeyTransaction::TYPE_NAME: $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('SSH key name is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } break; case PhabricatorAuthSSHKeyTransaction::TYPE_KEY; $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('SSH key material is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } else { foreach ($xactions as $xaction) { $new = $xaction->getNewValue(); try { $public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($new); } catch (Exception $ex) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), $ex->getMessage(), $xaction); } } } break; case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE: foreach ($xactions as $xaction) { if (!$xaction->getNewValue()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('SSH keys can not be reactivated.'), $xaction); } } break; } return $errors; } protected function didCatchDuplicateKeyException( PhabricatorLiskDAO $object, array $xactions, Exception $ex) { $errors = array(); $errors[] = new PhabricatorApplicationTransactionValidationError( PhabricatorAuthSSHKeyTransaction::TYPE_KEY, pht('Duplicate'), pht( 'This public key is already associated with another user or device. '. 'Each key must unambiguously identify a single unique owner.'), null); throw new PhabricatorApplicationTransactionValidationException($errors); } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailSubjectPrefix() { return pht('[SSH Key]'); } protected function getMailThreadID(PhabricatorLiskDAO $object) { return 'ssh-key-'.$object->getPHID(); } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // After making any change to an SSH key, drop the authfile cache so it // is regenerated the next time anyone authenticates. PhabricatorAuthSSHKeyQuery::deleteSSHKeyCache(); return $xactions; } protected function getMailTo(PhabricatorLiskDAO $object) { return $object->getObject()->getSSHKeyNotifyPHIDs(); } protected function getMailCC(PhabricatorLiskDAO $object) { return array(); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new PhabricatorAuthSSHKeyReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $name = $object->getName(); $phid = $object->getPHID(); $mail = id(new PhabricatorMetaMTAMail()) ->setSubject(pht('SSH Key %d: %s', $id, $name)) ->addHeader('Thread-Topic', $phid); // The primary value of this mail is alerting users to account compromises, - // so force delivery. In particular, this mail should still be delievered + // so force delivery. In particular, this mail should still be delivered // even if "self mail" is disabled. $mail->setForceDelivery(true); return $mail; } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); $body->addTextSection( pht('SECURITY WARNING'), pht( 'If you do not recognize this change, it may indicate your account '. 'has been compromised.')); $detail_uri = $object->getURI(); $detail_uri = PhabricatorEnv::getProductionURI($detail_uri); $body->addLinkSection(pht('SSH KEY DETAIL'), $detail_uri); return $body; } } diff --git a/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php b/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php index 3906c5eabe..ce9151bcef 100644 --- a/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php +++ b/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php @@ -1,374 +1,374 @@ true, ); } /** * Test that invalid invites can not be accepted. */ public function testInvalidInvite() { $viewer = $this->generateUser(); $engine = $this->generateEngine($viewer); $caught = null; try { $engine->processInviteCode('asdf1234'); } catch (PhabricatorAuthInviteInvalidException $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); } /** * Test that invites can be accepted exactly once. */ public function testDuplicateInvite() { $author = $this->generateUser(); $viewer = $this->generateUser(); $address = Filesystem::readRandomCharacters(16).'@example.com'; $invite = id(new PhabricatorAuthInvite()) ->setAuthorPHID($author->getPHID()) ->setEmailAddress($address) ->save(); $engine = $this->generateEngine($viewer); $engine->setUserHasConfirmedVerify(true); $caught = null; try { $result = $engine->processInviteCode($invite->getVerificationCode()); } catch (Exception $ex) { $caught = $ex; } - // This first time should accept the invite and verify the addresss. + // This first time should accept the invite and verify the address. $this->assertTrue( ($caught instanceof PhabricatorAuthInviteRegisteredException)); try { $result = $engine->processInviteCode($invite->getVerificationCode()); } catch (Exception $ex) { $caught = $ex; } // The second time through, the invite should not be acceptable. $this->assertTrue( ($caught instanceof PhabricatorAuthInviteInvalidException)); } /** * Test easy invite cases, where the email is not anywhere in the system. */ public function testInviteWithNewEmail() { $expect_map = array( 'out' => array( null, null, ), 'in' => array( 'PhabricatorAuthInviteVerifyException', 'PhabricatorAuthInviteRegisteredException', ), ); $author = $this->generateUser(); $logged_in = $this->generateUser(); $logged_out = new PhabricatorUser(); foreach (array('out', 'in') as $is_logged_in) { foreach (array(0, 1) as $should_verify) { $address = Filesystem::readRandomCharacters(16).'@example.com'; $invite = id(new PhabricatorAuthInvite()) ->setAuthorPHID($author->getPHID()) ->setEmailAddress($address) ->save(); switch ($is_logged_in) { case 'out': $viewer = $logged_out; break; case 'in': $viewer = $logged_in; break; } $engine = $this->generateEngine($viewer); $engine->setUserHasConfirmedVerify($should_verify); $caught = null; try { $result = $engine->processInviteCode($invite->getVerificationCode()); } catch (Exception $ex) { $caught = $ex; } $expect = $expect_map[$is_logged_in]; $expect = $expect[$should_verify]; $this->assertEqual( ($expect !== null), ($caught instanceof Exception), pht( 'user=%s, should_verify=%s', $is_logged_in, $should_verify)); if ($expect === null) { $this->assertEqual($invite->getPHID(), $result->getPHID()); } else { $this->assertEqual( $expect, get_class($caught), pht('Actual exception: %s', $caught->getMessage())); } } } } /** * Test hard invite cases, where the email is already known and attached * to some user account. */ public function testInviteWithKnownEmail() { // This tests all permutations of: // // - Is the user logged out, logged in with a different account, or // logged in with the correct account? // - Is the address verified, or unverified? // - Is the address primary, or nonprimary? // - Has the user confirmed that they want to verify the address? $expect_map = array( 'out' => array( array( array( // For example, this corresponds to a logged out user trying to // follow an invite with an unverified, nonprimary address, and // they haven't clicked the "Verify" button yet. We ask them to // verify that they want to register a new account. 'PhabricatorAuthInviteVerifyException', // In this case, they have clicked the verify button. The engine // continues the workflow. null, ), array( // And so on. All of the rest of these cases cover the other // permutations. 'PhabricatorAuthInviteLoginException', 'PhabricatorAuthInviteLoginException', ), ), array( array( 'PhabricatorAuthInviteLoginException', 'PhabricatorAuthInviteLoginException', ), array( 'PhabricatorAuthInviteLoginException', 'PhabricatorAuthInviteLoginException', ), ), ), 'in' => array( array( array( 'PhabricatorAuthInviteVerifyException', array(true, 'PhabricatorAuthInviteRegisteredException'), ), array( 'PhabricatorAuthInviteAccountException', 'PhabricatorAuthInviteAccountException', ), ), array( array( 'PhabricatorAuthInviteAccountException', 'PhabricatorAuthInviteAccountException', ), array( 'PhabricatorAuthInviteAccountException', 'PhabricatorAuthInviteAccountException', ), ), ), 'same' => array( array( array( 'PhabricatorAuthInviteVerifyException', array(true, 'PhabricatorAuthInviteRegisteredException'), ), array( 'PhabricatorAuthInviteVerifyException', array(true, 'PhabricatorAuthInviteRegisteredException'), ), ), array( array( 'PhabricatorAuthInviteRegisteredException', 'PhabricatorAuthInviteRegisteredException', ), array( 'PhabricatorAuthInviteRegisteredException', 'PhabricatorAuthInviteRegisteredException', ), ), ), ); $author = $this->generateUser(); $logged_in = $this->generateUser(); $logged_out = new PhabricatorUser(); foreach (array('out', 'in', 'same') as $is_logged_in) { foreach (array(0, 1) as $is_verified) { foreach (array(0, 1) as $is_primary) { foreach (array(0, 1) as $should_verify) { $other = $this->generateUser(); switch ($is_logged_in) { case 'out': $viewer = $logged_out; break; case 'in'; $viewer = $logged_in; break; case 'same': $viewer = clone $other; break; } $email = $this->generateEmail($other, $is_verified, $is_primary); $invite = id(new PhabricatorAuthInvite()) ->setAuthorPHID($author->getPHID()) ->setEmailAddress($email->getAddress()) ->save(); $code = $invite->getVerificationCode(); $engine = $this->generateEngine($viewer); $engine->setUserHasConfirmedVerify($should_verify); $caught = null; try { $result = $engine->processInviteCode($code); } catch (Exception $ex) { $caught = $ex; } $expect = $expect_map[$is_logged_in]; $expect = $expect[$is_verified]; $expect = $expect[$is_primary]; $expect = $expect[$should_verify]; if (is_array($expect)) { list($expect_reassign, $expect_exception) = $expect; } else { $expect_reassign = false; $expect_exception = $expect; } $case_info = pht( 'user=%s, verified=%s, primary=%s, should_verify=%s', $is_logged_in, $is_verified, $is_primary, $should_verify); $this->assertEqual( ($expect_exception !== null), ($caught instanceof Exception), $case_info); if ($expect_exception === null) { $this->assertEqual($invite->getPHID(), $result->getPHID()); } else { $this->assertEqual( $expect_exception, get_class($caught), pht('%s, exception=%s', $case_info, $caught->getMessage())); } if ($expect_reassign) { $email->reload(); $this->assertEqual( $viewer->getPHID(), $email->getUserPHID(), pht( 'Expected email address reassignment (%s).', $case_info)); } switch ($expect_exception) { case 'PhabricatorAuthInviteRegisteredException': $invite->reload(); $this->assertEqual( $viewer->getPHID(), $invite->getAcceptedByPHID(), pht( 'Expected invite accepted (%s).', $case_info)); break; } } } } } } private function generateUser() { return $this->generateNewTestUser(); } private function generateEngine(PhabricatorUser $viewer) { return id(new PhabricatorAuthInviteEngine()) ->setViewer($viewer); } private function generateEmail( PhabricatorUser $user, $is_verified, $is_primary) { // NOTE: We're being a little bit sneaky here because UserEditor will not // let you make an unverified address a primary account address, and // the test user will already have a verified primary address. $email = id(new PhabricatorUserEmail()) ->setAddress(Filesystem::readRandomCharacters(16).'@example.com') ->setIsVerified((int)($is_verified || $is_primary)) ->setIsPrimary(0); $editor = id(new PhabricatorUserEditor()) ->setActor($user); $editor->addEmail($user, $email); if ($is_primary) { $editor->changePrimaryEmail($user, $email); } $email->setIsVerified((int)$is_verified); $email->save(); return $email; } } diff --git a/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php index bb94cfca62..758761b2ca 100644 --- a/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php +++ b/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php @@ -1,140 +1,140 @@ setName('revoke') ->setExamples( "**revoke** --type __type__ --from __user__\n". "**revoke** --everything --everywhere") ->setSynopsis( pht( 'Revoke credentials which may have been leaked or disclosed.')) ->setArguments( array( array( 'name' => 'from', 'param' => 'user', 'help' => pht( 'Revoke credentials for the specified user.'), ), array( 'name' => 'type', 'param' => 'type', 'help' => pht( 'Revoke credentials of the given type.'), ), array( 'name' => 'everything', 'help' => pht('Revoke all credentials types.'), ), array( 'name' => 'everywhere', 'help' => pht('Revoke from all credential owners.'), ), )); } public function execute(PhutilArgumentParser $args) { $viewer = PhabricatorUser::getOmnipotentUser(); $all_types = PhabricatorAuthRevoker::getAllRevokers(); $type = $args->getArg('type'); $is_everything = $args->getArg('everything'); if (!strlen($type) && !$is_everything) { throw new PhutilArgumentUsageException( pht( 'Specify the credential type to revoke with "--type" or specify '. '"--everything".')); } else if (strlen($type) && $is_everything) { throw new PhutilArgumentUsageException( pht( 'Specify the credential type to revoke with "--type" or '. '"--everything", but not both.')); } else if ($is_everything) { $types = $all_types; } else { if (empty($all_types[$type])) { throw new PhutilArgumentUsageException( pht( 'Credential type "%s" is not valid. Valid credential types '. 'are: %s.', $type, implode(', ', array_keys($all_types)))); } $types = array($all_types[$type]); } $is_everywhere = $args->getArg('everywhere'); $from = $args->getArg('from'); $target = null; if (!strlen($from) && !$is_everywhere) { throw new PhutilArgumentUsageException( pht( - 'Specify the target to revoke credentals from with "--from" or '. + 'Specify the target to revoke credentials from with "--from" or '. 'specify "--everywhere".')); } else if (strlen($from) && $is_everywhere) { throw new PhutilArgumentUsageException( pht( 'Specify the target to revoke credentials from with "--from" or '. 'specify "--everywhere", but not both.')); } else if ($is_everywhere) { // Just carry the flag through. } else { $target = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withNames(array($from)) ->executeOne(); if (!$target) { throw new PhutilArgumentUsageException( pht( 'Target "%s" is not a valid target to revoke credentials from. '. 'Usually, revoke from "@username".', $from)); } } if ($is_everywhere) { echo id(new PhutilConsoleBlock()) ->addParagraph( pht( 'You are destroying an entire class of credentials. This may be '. 'very disruptive to users. You should normally do this only if '. 'you suspect there has been a widespread compromise which may '. 'have impacted everyone.')) ->drawConsoleString(); $prompt = pht('Really destroy credentials everywhere?'); if (!phutil_console_confirm($prompt)) { throw new PhutilArgumentUsageException( pht('Aborted workflow.')); } } foreach ($types as $type) { $type->setViewer($viewer); if ($is_everywhere) { $count = $type->revokeAllCredentials(); } else { $count = $type->revokeCredentialsFrom($target); } echo tsprintf( "%s\n", pht( 'Destroyed %s credential(s) of type "%s".', new PhutilNumber($count), $type->getRevokerKey())); } echo tsprintf( "%s\n", pht('Done.')); return 0; } } diff --git a/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php b/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php index 530bf30583..c62983de2f 100644 --- a/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php +++ b/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php @@ -1,280 +1,280 @@ getProviderConfig(); $adapter->setConsumerKey($config->getProperty(self::PROPERTY_CONSUMER_KEY)); $secret = $config->getProperty(self::PROPERTY_CONSUMER_SECRET); if (strlen($secret)) { $adapter->setConsumerSecret(new PhutilOpaqueEnvelope($secret)); } $adapter->setCallbackURI(PhabricatorEnv::getURI($this->getLoginURI())); return $adapter; } protected function renderLoginForm(AphrontRequest $request, $mode) { $attributes = array( 'method' => 'POST', 'uri' => $this->getLoginURI(), ); return $this->renderStandardLoginButton($request, $mode, $attributes); } public function processLoginRequest( PhabricatorAuthLoginController $controller) { $request = $controller->getRequest(); $adapter = $this->getAdapter(); $account = null; $response = null; if ($request->isHTTPPost()) { // Add a CSRF code to the callback URI, which we'll verify when // performing the login. $client_code = $this->getAuthCSRFCode($request); $callback_uri = $adapter->getCallbackURI(); $callback_uri = $callback_uri.$client_code.'/'; $adapter->setCallbackURI($callback_uri); $uri = $adapter->getClientRedirectURI(); $this->saveHandshakeTokenSecret( $client_code, $adapter->getTokenSecret()); $response = id(new AphrontRedirectResponse()) ->setIsExternal(true) ->setURI($uri); return array($account, $response); } $denied = $request->getStr('denied'); if (strlen($denied)) { // Twitter indicates that the user cancelled the login attempt by // returning "denied" as a parameter. throw new PhutilAuthUserAbortedException(); } // NOTE: You can get here via GET, this should probably be a bit more // user friendly. $this->verifyAuthCSRFCode($request, $controller->getExtraURIData()); $token = $request->getStr('oauth_token'); $verifier = $request->getStr('oauth_verifier'); if (!$token) { throw new Exception(pht("Expected '%s' in request!", 'oauth_token')); } if (!$verifier) { throw new Exception(pht("Expected '%s' in request!", 'oauth_verifier')); } $adapter->setToken($token); $adapter->setVerifier($verifier); $client_code = $this->getAuthCSRFCode($request); $token_secret = $this->loadHandshakeTokenSecret($client_code); $adapter->setTokenSecret($token_secret); // NOTE: As a side effect, this will cause the OAuth adapter to request // an access token. try { $account_id = $adapter->getAccountID(); } catch (Exception $ex) { // TODO: Handle this in a more user-friendly way. throw $ex; } if (!strlen($account_id)) { $response = $controller->buildProviderErrorResponse( $this, pht( 'The OAuth provider failed to retrieve an account ID.')); return array($account, $response); } return array($this->loadOrCreateAccount($account_id), $response); } public function processEditForm( AphrontRequest $request, array $values) { $key_ckey = self::PROPERTY_CONSUMER_KEY; $key_csecret = self::PROPERTY_CONSUMER_SECRET; return $this->processOAuthEditForm( $request, $values, pht('Consumer key is required.'), pht('Consumer secret is required.')); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { return $this->extendOAuthEditForm( $request, $form, $values, $issues, pht('OAuth Consumer Key'), pht('OAuth Consumer Secret')); } public function renderConfigPropertyTransactionTitle( PhabricatorAuthProviderConfigTransaction $xaction) { $author_phid = $xaction->getAuthorPHID(); $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $key = $xaction->getMetadataValue( PhabricatorAuthProviderConfigTransaction::PROPERTY_KEY); switch ($key) { case self::PROPERTY_CONSUMER_KEY: if (strlen($old)) { return pht( '%s updated the OAuth consumer key for this provider from '. '"%s" to "%s".', $xaction->renderHandleLink($author_phid), $old, $new); } else { return pht( '%s set the OAuth consumer key for this provider to '. '"%s".', $xaction->renderHandleLink($author_phid), $new); } case self::PROPERTY_CONSUMER_SECRET: if (strlen($old)) { return pht( '%s updated the OAuth consumer secret for this provider.', $xaction->renderHandleLink($author_phid)); } else { return pht( '%s set the OAuth consumer secret for this provider.', $xaction->renderHandleLink($author_phid)); } } return parent::renderConfigPropertyTransactionTitle($xaction); } protected function synchronizeOAuthAccount( PhabricatorExternalAccount $account) { $adapter = $this->getAdapter(); $oauth_token = $adapter->getToken(); $oauth_token_secret = $adapter->getTokenSecret(); $account->setProperty('oauth1.token', $oauth_token); $account->setProperty('oauth1.token.secret', $oauth_token_secret); } public function willRenderLinkedAccount( PhabricatorUser $viewer, PHUIObjectItemView $item, PhabricatorExternalAccount $account) { $item->addAttribute(pht('OAuth1 Account')); parent::willRenderLinkedAccount($viewer, $item, $account); } /* -( Temporary Secrets )-------------------------------------------------- */ private function saveHandshakeTokenSecret($client_code, $secret) { $secret_type = PhabricatorOAuth1SecretTemporaryTokenType::TOKENTYPE; $key = $this->getHandshakeTokenKeyFromClientCode($client_code); $type = $this->getTemporaryTokenType($secret_type); // Wipe out an existing token, if one exists. $token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTokenResources(array($key)) ->withTokenTypes(array($type)) ->executeOne(); if ($token) { $token->delete(); } // Save the new secret. id(new PhabricatorAuthTemporaryToken()) ->setTokenResource($key) ->setTokenType($type) ->setTokenExpires(time() + phutil_units('1 hour in seconds')) ->setTokenCode($secret) ->save(); } private function loadHandshakeTokenSecret($client_code) { $secret_type = PhabricatorOAuth1SecretTemporaryTokenType::TOKENTYPE; $key = $this->getHandshakeTokenKeyFromClientCode($client_code); $type = $this->getTemporaryTokenType($secret_type); $token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTokenResources(array($key)) ->withTokenTypes(array($type)) ->withExpired(false) ->executeOne(); if (!$token) { throw new Exception( pht( 'Unable to load your OAuth1 token secret from storage. It may '. 'have expired. Try authenticating again.')); } return $token->getTokenCode(); } private function getTemporaryTokenType($core_type) { // Namespace the type so that multiple providers don't step on each // others' toes if a user starts Mediawiki and Bitbucket auth at the // same time. // TODO: This isn't really a proper use of the table and should get // cleaned up some day: the type should be constant. return $core_type.':'.$this->getProviderConfig()->getID(); } private function getHandshakeTokenKeyFromClientCode($client_code) { - // NOTE: This is very slightly coersive since the TemporaryToken table + // NOTE: This is very slightly coercive since the TemporaryToken table // expects an "objectPHID" as an identifier, but nothing about the storage // is bound to PHIDs. return 'oauth1:secret/'.$client_code; } } diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php index 72b577771f..c25612408c 100644 --- a/src/applications/base/PhabricatorApplication.php +++ b/src/applications/base/PhabricatorApplication.php @@ -1,666 +1,666 @@ pht('Core Applications'), self::GROUP_UTILITIES => pht('Utilities'), self::GROUP_ADMIN => pht('Administration'), self::GROUP_DEVELOPER => pht('Developer Tools'), ); } final public function getApplicationName() { return 'application'; } final public function getTableName() { return 'application_application'; } final protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, ) + parent::getConfiguration(); } final public function generatePHID() { return $this->getPHID(); } final public function save() { // When "save()" is called on applications, we just return without // actually writing anything to the database. return $this; } /* -( Application Information )-------------------------------------------- */ abstract public function getName(); public function getMenuName() { return $this->getName(); } public function getShortDescription() { return pht('%s Application', $this->getName()); } final public function isInstalled() { if (!$this->canUninstall()) { return true; } $prototypes = PhabricatorEnv::getEnvConfig('phabricator.show-prototypes'); if (!$prototypes && $this->isPrototype()) { return false; } $uninstalled = PhabricatorEnv::getEnvConfig( 'phabricator.uninstalled-applications'); return empty($uninstalled[get_class($this)]); } public function isPrototype() { return false; } /** * Return `true` if this application should never appear in application lists * in the UI. Primarily intended for unit test applications or other * pseudo-applications. * * Few applications should be unlisted. For most applications, use * @{method:isLaunchable} to hide them from main launch views instead. * * @return bool True to remove application from UI lists. */ public function isUnlisted() { return false; } /** * Return `true` if this application is a normal application with a base * URI and a web interface. * * Launchable applications can be pinned to the home page, and show up in the * "Launcher" view of the Applications application. Making an application - * unlauncahble prevents pinning and hides it from this view. + * unlaunchable prevents pinning and hides it from this view. * * Usually, an application should be marked unlaunchable if: * * - it is available on every page anyway (like search); or * - it does not have a web interface (like subscriptions); or * - it is still pre-release and being intentionally buried. * * To hide applications more completely, use @{method:isUnlisted}. * * @return bool True if the application is launchable. */ public function isLaunchable() { return true; } /** * Return `true` if this application should be pinned by default. * * Users who have not yet set preferences see a default list of applications. * * @param PhabricatorUser User viewing the pinned application list. * @return bool True if this application should be pinned by default. */ public function isPinnedByDefault(PhabricatorUser $viewer) { return false; } /** * Returns true if an application is first-party (developed by Phacility) * and false otherwise. * * @return bool True if this application is developed by Phacility. */ final public function isFirstParty() { $where = id(new ReflectionClass($this))->getFileName(); $root = phutil_get_library_root('phabricator'); if (!Filesystem::isDescendant($where, $root)) { return false; } if (Filesystem::isDescendant($where, $root.'/extensions')) { return false; } return true; } public function canUninstall() { return true; } final public function getPHID() { return 'PHID-APPS-'.get_class($this); } public function getTypeaheadURI() { return $this->isLaunchable() ? $this->getBaseURI() : null; } public function getBaseURI() { return null; } final public function getApplicationURI($path = '') { return $this->getBaseURI().ltrim($path, '/'); } public function getIcon() { return 'fa-puzzle-piece'; } public function getApplicationOrder() { return PHP_INT_MAX; } public function getApplicationGroup() { return self::GROUP_CORE; } public function getTitleGlyph() { return null; } final public function getHelpMenuItems(PhabricatorUser $viewer) { $items = array(); $articles = $this->getHelpDocumentationArticles($viewer); if ($articles) { foreach ($articles as $article) { $item = id(new PhabricatorActionView()) ->setName($article['name']) ->setHref($article['href']) ->addSigil('help-item') ->setOpenInNewWindow(true); $items[] = $item; } } $command_specs = $this->getMailCommandObjects(); if ($command_specs) { foreach ($command_specs as $key => $spec) { $object = $spec['object']; $class = get_class($this); $href = '/applications/mailcommands/'.$class.'/'.$key.'/'; $item = id(new PhabricatorActionView()) ->setName($spec['name']) ->setHref($href) ->addSigil('help-item') ->setOpenInNewWindow(true); $items[] = $item; } } if ($items) { $divider = id(new PhabricatorActionView()) ->addSigil('help-item') ->setType(PhabricatorActionView::TYPE_DIVIDER); array_unshift($items, $divider); } return array_values($items); } public function getHelpDocumentationArticles(PhabricatorUser $viewer) { return array(); } public function getOverview() { return null; } public function getEventListeners() { return array(); } public function getRemarkupRules() { return array(); } public function getQuicksandURIPatternBlacklist() { return array(); } public function getMailCommandObjects() { return array(); } /* -( URI Routing )-------------------------------------------------------- */ public function getRoutes() { return array(); } public function getResourceRoutes() { return array(); } /* -( Email Integration )-------------------------------------------------- */ public function supportsEmailIntegration() { return false; } final protected function getInboundEmailSupportLink() { return PhabricatorEnv::getDoclink('Configuring Inbound Email'); } public function getAppEmailBlurb() { throw new PhutilMethodNotImplementedException(); } /* -( Fact Integration )--------------------------------------------------- */ public function getFactObjectsForAnalysis() { return array(); } /* -( UI Integration )----------------------------------------------------- */ /** * You can provide an optional piece of flavor text for the application. This * is currently rendered in application launch views if the application has no * status elements. * * @return string|null Flavor text. * @task ui */ public function getFlavorText() { return null; } /** * Build items for the main menu. * * @param PhabricatorUser The viewing user. * @param AphrontController The current controller. May be null for special * pages like 404, exception handlers, etc. * @return list List of menu items. * @task ui */ public function buildMainMenuItems( PhabricatorUser $user, PhabricatorController $controller = null) { return array(); } /* -( Application Management )--------------------------------------------- */ final public static function getByClass($class_name) { $selected = null; $applications = self::getAllApplications(); foreach ($applications as $application) { if (get_class($application) == $class_name) { $selected = $application; break; } } if (!$selected) { throw new Exception(pht("No application '%s'!", $class_name)); } return $selected; } final public static function getAllApplications() { static $applications; if ($applications === null) { $apps = id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setSortMethod('getApplicationOrder') ->execute(); // Reorder the applications into "application order". Notably, this // ensures their event handlers register in application order. $apps = mgroup($apps, 'getApplicationGroup'); $group_order = array_keys(self::getApplicationGroups()); $apps = array_select_keys($apps, $group_order) + $apps; $apps = array_mergev($apps); $applications = $apps; } return $applications; } final public static function getAllInstalledApplications() { $all_applications = self::getAllApplications(); $apps = array(); foreach ($all_applications as $app) { if (!$app->isInstalled()) { continue; } $apps[] = $app; } return $apps; } /** * Determine if an application is installed, by application class name. * * To check if an application is installed //and// available to a particular * viewer, user @{method:isClassInstalledForViewer}. * * @param string Application class name. * @return bool True if the class is installed. * @task meta */ final public static function isClassInstalled($class) { return self::getByClass($class)->isInstalled(); } /** * Determine if an application is installed and available to a viewer, by * application class name. * * To check if an application is installed at all, use * @{method:isClassInstalled}. * * @param string Application class name. * @param PhabricatorUser Viewing user. * @return bool True if the class is installed for the viewer. * @task meta */ final public static function isClassInstalledForViewer( $class, PhabricatorUser $viewer) { if ($viewer->isOmnipotent()) { return true; } $cache = PhabricatorCaches::getRequestCache(); $viewer_fragment = $viewer->getCacheFragment(); $key = 'app.'.$class.'.installed.'.$viewer_fragment; $result = $cache->getKey($key); if ($result === null) { if (!self::isClassInstalled($class)) { $result = false; } else { $application = self::getByClass($class); if (!$application->canUninstall()) { // If the application can not be uninstalled, always allow viewers // to see it. In particular, this allows logged-out viewers to see // Settings and load global default settings even if the install // does not allow public viewers. $result = true; } else { $result = PhabricatorPolicyFilter::hasCapability( $viewer, self::getByClass($class), PhabricatorPolicyCapability::CAN_VIEW); } } $cache->setKey($key, $result); } return $result; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array_merge( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ), array_keys($this->getCustomCapabilities())); } public function getPolicy($capability) { $default = $this->getCustomPolicySetting($capability); if ($default) { return $default; } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_ADMIN; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'default', PhabricatorPolicies::POLICY_USER); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( Policies )----------------------------------------------------------- */ protected function getCustomCapabilities() { return array(); } final private function getCustomPolicySetting($capability) { if (!$this->isCapabilityEditable($capability)) { return null; } $policy_locked = PhabricatorEnv::getEnvConfig('policy.locked'); if (isset($policy_locked[$capability])) { return $policy_locked[$capability]; } $config = PhabricatorEnv::getEnvConfig('phabricator.application-settings'); $app = idx($config, $this->getPHID()); if (!$app) { return null; } $policy = idx($app, 'policy'); if (!$policy) { return null; } return idx($policy, $capability); } final private function getCustomCapabilitySpecification($capability) { $custom = $this->getCustomCapabilities(); if (!isset($custom[$capability])) { throw new Exception(pht("Unknown capability '%s'!", $capability)); } return $custom[$capability]; } final public function getCapabilityLabel($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht('Can Use Application'); case PhabricatorPolicyCapability::CAN_EDIT: return pht('Can Configure Application'); } $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); if ($capobj) { return $capobj->getCapabilityName(); } return null; } final public function isCapabilityEditable($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->canUninstall(); case PhabricatorPolicyCapability::CAN_EDIT: return false; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'edit', true); } } final public function getCapabilityCaption($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if (!$this->canUninstall()) { return pht( 'This application is required for Phabricator to operate, so all '. 'users must have access to it.'); } else { return null; } case PhabricatorPolicyCapability::CAN_EDIT: return null; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'caption'); } } final public function getCapabilityTemplatePHIDType($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: case PhabricatorPolicyCapability::CAN_EDIT: return null; } $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'template'); } final public function getDefaultObjectTypePolicyMap() { $map = array(); foreach ($this->getCustomCapabilities() as $capability => $spec) { if (empty($spec['template'])) { continue; } if (empty($spec['capability'])) { continue; } $default = $this->getPolicy($capability); $map[$spec['template']][$spec['capability']] = $default; } return $map; } public function getApplicationSearchDocumentTypes() { return array(); } protected function getEditRoutePattern($base = null) { return $base.'(?:'. '(?P[0-9]\d*)/)?'. '(?:'. '(?:'. '(?Pparameters|nodefault|nocreate|nomanage|comment)/'. '|'. '(?:form/(?P[^/]+)/)?(?:page/(?P[^/]+)/)?'. ')'. ')?'; } protected function getQueryRoutePattern($base = null) { return $base.'(?:query/(?P[^/]+)/)?'; } protected function getProfileMenuRouting($controller) { $edit_route = $this->getEditRoutePattern(); $mode_route = '(?Pglobal|custom)/'; return array( '(?Pview)/(?P[^/]+)/' => $controller, '(?Phide)/(?P[^/]+)/' => $controller, '(?Pdefault)/(?P[^/]+)/' => $controller, '(?Pconfigure)/' => $controller, '(?Pconfigure)/'.$mode_route => $controller, '(?Preorder)/'.$mode_route => $controller, '(?Pedit)/'.$edit_route => $controller, '(?Pnew)/'.$mode_route.'(?[^/]+)/'.$edit_route => $controller, '(?Pbuiltin)/(?[^/]+)/'.$edit_route => $controller, ); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorApplicationEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorApplicationApplicationTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } } diff --git a/src/applications/base/controller/PhabricatorController.php b/src/applications/base/controller/PhabricatorController.php index 1ecfdaf557..f923eb7b73 100644 --- a/src/applications/base/controller/PhabricatorController.php +++ b/src/applications/base/controller/PhabricatorController.php @@ -1,582 +1,582 @@ shouldRequireLogin()) { return false; } if (!$this->shouldRequireEnabledUser()) { return false; } if ($this->shouldAllowPartialSessions()) { return false; } $user = $this->getRequest()->getUser(); if (!$user->getIsStandardUser()) { return false; } return PhabricatorEnv::getEnvConfig('security.require-multi-factor-auth'); } public function shouldAllowLegallyNonCompliantUsers() { return false; } public function isGlobalDragAndDropUploadEnabled() { return false; } public function willBeginExecution() { $request = $this->getRequest(); if ($request->getUser()) { // NOTE: Unit tests can set a user explicitly. Normal requests are not // permitted to do this. PhabricatorTestCase::assertExecutingUnitTests(); $user = $request->getUser(); } else { $user = new PhabricatorUser(); $session_engine = new PhabricatorAuthSessionEngine(); $phsid = $request->getCookie(PhabricatorCookies::COOKIE_SESSION); if (strlen($phsid)) { $session_user = $session_engine->loadUserForSession( PhabricatorAuthSession::TYPE_WEB, $phsid); if ($session_user) { $user = $session_user; } } else { // If the client doesn't have a session token, generate an anonymous // session. This is used to provide CSRF protection to logged-out users. $phsid = $session_engine->establishSession( PhabricatorAuthSession::TYPE_WEB, null, $partial = false); // This may be a resource request, in which case we just don't set // the cookie. if ($request->canSetCookies()) { $request->setCookie(PhabricatorCookies::COOKIE_SESSION, $phsid); } } if (!$user->isLoggedIn()) { $user->attachAlternateCSRFString(PhabricatorHash::weakDigest($phsid)); } $request->setUser($user); } id(new PhabricatorAuthSessionEngine()) ->willServeRequestForUser($user); if (PhabricatorEnv::getEnvConfig('darkconsole.enabled')) { $dark_console = PhabricatorDarkConsoleSetting::SETTINGKEY; if ($user->getUserSetting($dark_console) || PhabricatorEnv::getEnvConfig('darkconsole.always-on')) { $console = new DarkConsoleCore(); $request->getApplicationConfiguration()->setConsole($console); } } // NOTE: We want to set up the user first so we can render a real page // here, but fire this before any real logic. $restricted = array( 'code', ); foreach ($restricted as $parameter) { if ($request->getExists($parameter)) { if (!$this->shouldAllowRestrictedParameter($parameter)) { throw new Exception( pht( 'Request includes restricted parameter "%s", but this '. 'controller ("%s") does not whitelist it. Refusing to '. 'serve this request because it might be part of a redirection '. 'attack.', $parameter, get_class($this))); } } } if ($this->shouldRequireEnabledUser()) { if ($user->isLoggedIn() && !$user->getIsApproved()) { $controller = new PhabricatorAuthNeedsApprovalController(); return $this->delegateToController($controller); } if ($user->getIsDisabled()) { $controller = new PhabricatorDisabledUserController(); return $this->delegateToController($controller); } } $auth_class = 'PhabricatorAuthApplication'; $auth_application = PhabricatorApplication::getByClass($auth_class); // Require partial sessions to finish login before doing anything. if (!$this->shouldAllowPartialSessions()) { if ($user->hasSession() && $user->getSession()->getIsPartial()) { $login_controller = new PhabricatorAuthFinishController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($login_controller); } } // Check if the user needs to configure MFA. $need_mfa = $this->shouldRequireMultiFactorEnrollment(); $have_mfa = $user->getIsEnrolledInMultiFactor(); if ($need_mfa && !$have_mfa) { // Check if the cache is just out of date. Otherwise, roadblock the user // and require MFA enrollment. $user->updateMultiFactorEnrollment(); if (!$user->getIsEnrolledInMultiFactor()) { $mfa_controller = new PhabricatorAuthNeedsMultiFactorController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($mfa_controller); } } if ($this->shouldRequireLogin()) { // This actually means we need either: // - a valid user, or a public controller; and // - permission to see the application; and // - permission to see at least one Space if spaces are configured. $allow_public = $this->shouldAllowPublic() && PhabricatorEnv::getEnvConfig('policy.allow-public'); // If this controller isn't public, and the user isn't logged in, require // login. if (!$allow_public && !$user->isLoggedIn()) { $login_controller = new PhabricatorAuthStartController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($login_controller); } if ($user->isLoggedIn()) { if ($this->shouldRequireEmailVerification()) { if (!$user->getIsEmailVerified()) { $controller = new PhabricatorMustVerifyEmailController(); $this->setCurrentApplication($auth_application); return $this->delegateToController($controller); } } } // If Spaces are configured, require that the user have access to at // least one. If we don't do this, they'll get confusing error messages // later on. $spaces = PhabricatorSpacesNamespaceQuery::getSpacesExist(); if ($spaces) { $viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces( $user); if (!$viewer_spaces) { $controller = new PhabricatorSpacesNoAccessController(); return $this->delegateToController($controller); } } // If the user doesn't have access to the application, don't let them use // any of its controllers. We query the application in order to generate // a policy exception if the viewer doesn't have permission. $application = $this->getCurrentApplication(); if ($application) { id(new PhabricatorApplicationQuery()) ->setViewer($user) ->withPHIDs(array($application->getPHID())) ->executeOne(); } } if (!$this->shouldAllowLegallyNonCompliantUsers()) { $legalpad_class = 'PhabricatorLegalpadApplication'; $legalpad = id(new PhabricatorApplicationQuery()) ->setViewer($user) ->withClasses(array($legalpad_class)) ->withInstalled(true) ->execute(); $legalpad = head($legalpad); $doc_query = id(new LegalpadDocumentQuery()) ->setViewer($user) ->withSignatureRequired(1) ->needViewerSignatures(true); if ($user->hasSession() && !$user->getSession()->getIsPartial() && !$user->getSession()->getSignedLegalpadDocuments() && $user->isLoggedIn() && $legalpad) { $sign_docs = $doc_query->execute(); $must_sign_docs = array(); foreach ($sign_docs as $sign_doc) { if (!$sign_doc->getUserSignature($user->getPHID())) { $must_sign_docs[] = $sign_doc; } } if ($must_sign_docs) { $controller = new LegalpadDocumentSignController(); $this->getRequest()->setURIMap(array( 'id' => head($must_sign_docs)->getID(), )); $this->setCurrentApplication($legalpad); return $this->delegateToController($controller); } else { $engine = id(new PhabricatorAuthSessionEngine()) ->signLegalpadDocuments($user, $sign_docs); } } } // NOTE: We do this last so that users get a login page instead of a 403 // if they need to login. if ($this->shouldRequireAdmin() && !$user->getIsAdmin()) { return new Aphront403Response(); } } public function getApplicationURI($path = '') { if (!$this->getCurrentApplication()) { throw new Exception(pht('No application!')); } return $this->getCurrentApplication()->getApplicationURI($path); } public function willSendResponse(AphrontResponse $response) { $request = $this->getRequest(); if ($response instanceof AphrontDialogResponse) { if (!$request->isAjax() && !$request->isQuicksand()) { $dialog = $response->getDialog(); $title = $dialog->getTitle(); $short = $dialog->getShortTitle(); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(coalesce($short, $title)); $page_content = array( $crumbs, $response->buildResponseString(), ); $view = id(new PhabricatorStandardPageView()) ->setRequest($request) ->setController($this) ->setDeviceReady(true) ->setTitle($title) ->appendChild($page_content); $response = id(new AphrontWebpageResponse()) ->setContent($view->render()) ->setHTTPResponseCode($response->getHTTPResponseCode()); } else { $response->getDialog()->setIsStandalone(true); return id(new AphrontAjaxResponse()) ->setContent(array( 'dialog' => $response->buildResponseString(), )); } } else if ($response instanceof AphrontRedirectResponse) { if ($request->isAjax() || $request->isQuicksand()) { return id(new AphrontAjaxResponse()) ->setContent( array( 'redirect' => $response->getURI(), )); } } return $response; } /** * WARNING: Do not call this in new code. * * @deprecated See "Handles Technical Documentation". */ protected function loadViewerHandles(array $phids) { return id(new PhabricatorHandleQuery()) ->setViewer($this->getRequest()->getUser()) ->withPHIDs($phids) ->execute(); } public function buildApplicationMenu() { return null; } protected function buildApplicationCrumbs() { $crumbs = array(); $application = $this->getCurrentApplication(); if ($application) { $icon = $application->getIcon(); if (!$icon) { $icon = 'fa-puzzle'; } $crumbs[] = id(new PHUICrumbView()) ->setHref($this->getApplicationURI()) ->setName($application->getName()) ->setIcon($icon); } $view = new PHUICrumbsView(); foreach ($crumbs as $crumb) { $view->addCrumb($crumb); } return $view; } protected function hasApplicationCapability($capability) { return PhabricatorPolicyFilter::hasCapability( $this->getRequest()->getUser(), $this->getCurrentApplication(), $capability); } protected function requireApplicationCapability($capability) { PhabricatorPolicyFilter::requireCapability( $this->getRequest()->getUser(), $this->getCurrentApplication(), $capability); } protected function explainApplicationCapability( $capability, $positive_message, $negative_message) { $can_act = $this->hasApplicationCapability($capability); if ($can_act) { $message = $positive_message; $icon_name = 'fa-play-circle-o lightgreytext'; } else { $message = $negative_message; $icon_name = 'fa-lock'; } $icon = id(new PHUIIconView()) ->setIcon($icon_name); require_celerity_resource('policy-css'); $phid = $this->getCurrentApplication()->getPHID(); $explain_uri = "/policy/explain/{$phid}/{$capability}/"; $message = phutil_tag( 'div', array( 'class' => 'policy-capability-explanation', ), array( $icon, javelin_tag( 'a', array( 'href' => $explain_uri, 'sigil' => 'workflow', ), $message), )); return array($can_act, $message); } public function getDefaultResourceSource() { return 'phabricator'; } /** * Create a new @{class:AphrontDialogView} with defaults filled in. * * @return AphrontDialogView New dialog. */ public function newDialog() { $submit_uri = new PhutilURI($this->getRequest()->getRequestURI()); $submit_uri = $submit_uri->getPath(); return id(new AphrontDialogView()) ->setUser($this->getRequest()->getUser()) ->setSubmitURI($submit_uri); } public function newPage() { $page = id(new PhabricatorStandardPageView()) ->setRequest($this->getRequest()) ->setController($this) ->setDeviceReady(true); $application = $this->getCurrentApplication(); if ($application) { $page->setApplicationName($application->getName()); if ($application->getTitleGlyph()) { $page->setGlyph($application->getTitleGlyph()); } } $viewer = $this->getRequest()->getUser(); if ($viewer) { $page->setUser($viewer); } return $page; } public function newApplicationMenu() { return id(new PHUIApplicationMenuView()) ->setViewer($this->getViewer()); } public function newCurtainView($object = null) { $viewer = $this->getViewer(); $action_id = celerity_generate_unique_node_id(); $action_list = id(new PhabricatorActionListView()) ->setViewer($viewer) ->setID($action_id); // NOTE: Applications (objects of class PhabricatorApplication) can't // currently be set here, although they don't need any of the extensions // anyway. This should probably work differently than it does, though. if ($object) { if ($object instanceof PhabricatorLiskDAO) { $action_list->setObject($object); } } $curtain = id(new PHUICurtainView()) ->setViewer($viewer) ->setActionList($action_list); if ($object) { $panels = PHUICurtainExtension::buildExtensionPanels($viewer, $object); foreach ($panels as $panel) { $curtain->addPanel($panel); } } return $curtain; } protected function buildTransactionTimeline( PhabricatorApplicationTransactionInterface $object, PhabricatorApplicationTransactionQuery $query, PhabricatorMarkupEngine $engine = null, $render_data = array()) { $viewer = $this->getRequest()->getUser(); $xaction = $object->getApplicationTransactionTemplate(); $view = $xaction->getApplicationTransactionViewObject(); $pager = id(new AphrontCursorPagerView()) ->readFromRequest($this->getRequest()) ->setURI(new PhutilURI( '/transactions/showolder/'.$object->getPHID().'/')); $xactions = $query ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())) ->needComments(true) ->executeWithCursorPager($pager); $xactions = array_reverse($xactions); if ($engine) { foreach ($xactions as $xaction) { if ($xaction->getComment()) { $engine->addObject( $xaction->getComment(), PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT); } } $engine->process(); $view->setMarkupEngine($engine); } $timeline = $view ->setUser($viewer) ->setObjectPHID($object->getPHID()) ->setTransactions($xactions) ->setPager($pager) ->setRenderData($render_data) ->setQuoteTargetID($this->getRequest()->getStr('quoteTargetID')) ->setQuoteRef($this->getRequest()->getStr('quoteRef')); $object->willRenderTimeline($timeline, $this->getRequest()); return $timeline; } public function buildApplicationCrumbsForEditEngine() { - // TODO: This is kind of gross, I'm bascially just making this public so + // TODO: This is kind of gross, I'm basically just making this public so // I can use it in EditEngine. We could do this without making it public // by using controller delegation, or make it properly public. return $this->buildApplicationCrumbs(); } /* -( Deprecated )--------------------------------------------------------- */ /** * DEPRECATED. Use @{method:newPage}. */ public function buildStandardPageView() { return $this->newPage(); } /** * DEPRECATED. Use @{method:newPage}. */ public function buildStandardPageResponse($view, array $data) { $page = $this->buildStandardPageView(); $page->appendChild($view); return $page->produceAphrontResponse(); } } diff --git a/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php b/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php index c3dc29bb51..90b4962ca9 100644 --- a/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php +++ b/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php @@ -1,155 +1,155 @@ importEngine = $engine; return $this; } public function getImportEngine() { return $this->importEngine; } public function getEngineName() { return pht('Calendar Imports'); } public function isEngineConfigurable() { return false; } public function getSummaryHeader() { return pht('Configure Calendar Import Forms'); } public function getSummaryText() { return pht('Configure how users create and edit imports.'); } public function getEngineApplicationClass() { return 'PhabricatorCalendarApplication'; } protected function newEditableObject() { $viewer = $this->getViewer(); $engine = $this->getImportEngine(); return PhabricatorCalendarImport::initializeNewCalendarImport( $viewer, $engine); } protected function newObjectQuery() { return new PhabricatorCalendarImportQuery(); } protected function getObjectCreateTitleText($object) { return pht('Create New Import'); } protected function getObjectEditTitleText($object) { return pht('Edit Import: %s', $object->getDisplayName()); } protected function getObjectEditShortText($object) { return pht('Import %d', $object->getID()); } protected function getObjectCreateShortText() { return pht('Create Import'); } protected function getObjectName() { return pht('Import'); } protected function getObjectViewURI($object) { return $object->getURI(); } protected function getEditorURI() { return $this->getApplication()->getApplicationURI('import/edit/'); } protected function buildCustomEditFields($object) { $viewer = $this->getViewer(); $engine = $object->getEngine(); $can_trigger = $engine->supportsTriggers($object); $fields = array( id(new PhabricatorTextEditField()) ->setKey('name') ->setLabel(pht('Name')) ->setDescription(pht('Name of the import.')) ->setTransactionType( PhabricatorCalendarImportNameTransaction::TRANSACTIONTYPE) ->setConduitDescription(pht('Rename the import.')) ->setConduitTypeDescription(pht('New import name.')) ->setPlaceholder($object->getDisplayName()) ->setValue($object->getName()), id(new PhabricatorBoolEditField()) ->setKey('disabled') ->setOptions(pht('Active'), pht('Disabled')) ->setLabel(pht('Disabled')) ->setDescription(pht('Disable the import.')) ->setTransactionType( PhabricatorCalendarImportDisableTransaction::TRANSACTIONTYPE) ->setIsConduitOnly(true) ->setConduitDescription(pht('Disable or restore the import.')) ->setConduitTypeDescription(pht('True to cancel the import.')) ->setValue($object->getIsDisabled()), id(new PhabricatorBoolEditField()) ->setKey('delete') ->setLabel(pht('Delete Imported Events')) ->setDescription(pht('Delete all events from this source.')) ->setTransactionType( PhabricatorCalendarImportDisableTransaction::TRANSACTIONTYPE) ->setIsConduitOnly(true) ->setConduitDescription(pht('Disable or restore the import.')) ->setConduitTypeDescription(pht('True to delete imported events.')) ->setValue(false), id(new PhabricatorBoolEditField()) ->setKey('reload') ->setLabel(pht('Reload Import')) ->setDescription(pht('Reload events imported from this source.')) ->setTransactionType( PhabricatorCalendarImportDisableTransaction::TRANSACTIONTYPE) ->setIsConduitOnly(true) ->setConduitDescription(pht('Disable or restore the import.')) ->setConduitTypeDescription(pht('True to reload the import.')) ->setValue(false), ); if ($can_trigger) { $frequency_map = PhabricatorCalendarImport::getTriggerFrequencyMap(); $frequency_options = ipull($frequency_map, 'name'); $fields[] = id(new PhabricatorSelectEditField()) ->setKey('frequency') ->setLabel(pht('Update Automatically')) - ->setDescription(pht('Configure an automatic update frequncy.')) + ->setDescription(pht('Configure an automatic update frequency.')) ->setTransactionType( PhabricatorCalendarImportFrequencyTransaction::TRANSACTIONTYPE) ->setConduitDescription(pht('Set the automatic update frequency.')) ->setConduitTypeDescription(pht('Update frequency constant.')) ->setValue($object->getTriggerFrequency()) ->setOptions($frequency_options); } $import_engine = $object->getEngine(); foreach ($import_engine->newEditEngineFields($this, $object) as $field) { $fields[] = $field; } return $fields; } } diff --git a/src/applications/calendar/icon/PhabricatorCalendarIconSet.php b/src/applications/calendar/icon/PhabricatorCalendarIconSet.php index 22d5b0e4f5..ab6d2126ed 100644 --- a/src/applications/calendar/icon/PhabricatorCalendarIconSet.php +++ b/src/applications/calendar/icon/PhabricatorCalendarIconSet.php @@ -1,45 +1,45 @@ pht('Default'), 'fa-glass' => pht('Party'), 'fa-plane' => pht('Travel'), 'fa-plus-square' => pht('Health / Appointment'), - 'fa-rocket' => pht('Sabatical / Leave'), + 'fa-rocket' => pht('Sabbatical / Leave'), 'fa-home' => pht('Working From Home'), 'fa-tree' => pht('Holiday'), 'fa-gamepad' => pht('Staycation'), 'fa-coffee' => pht('Coffee Meeting'), 'fa-film' => pht('Movie'), 'fa-users' => pht('Meeting'), 'fa-cutlery' => pht('Meal'), 'fa-paw' => pht('Pet Activity'), 'fa-institution' => pht('Official Business'), 'fa-bus' => pht('Field Trip'), 'fa-microphone' => pht('Conference'), ); $icons = array(); foreach ($map as $key => $label) { $icons[] = id(new PhabricatorIconSetIcon()) ->setKey($key) ->setLabel($label); } return $icons; } } diff --git a/src/applications/calendar/util/CalendarTimeUtil.php b/src/applications/calendar/util/CalendarTimeUtil.php index 781f6adf7a..d3276654e4 100644 --- a/src/applications/calendar/util/CalendarTimeUtil.php +++ b/src/applications/calendar/util/CalendarTimeUtil.php @@ -1,90 +1,90 @@ Saturday list, whilest the profile view shows a more simple + * a Sunday -> Saturday list, whilst the profile view shows a more simple * seven day rolling list of events. */ final class CalendarTimeUtil extends Phobject { public static function getCalendarEventEpochs( PhabricatorUser $user, $start_day_str = 'Sunday', $days = 9) { $objects = self::getStartDateTimeObjects($user, $start_day_str); $start_day = $objects['start_day']; $end_day = clone $start_day; $end_day->modify('+'.$days.' days'); return array( 'start_epoch' => $start_day->format('U'), 'end_epoch' => $end_day->format('U'), ); } public static function getCalendarWeekTimestamps( PhabricatorUser $user) { return self::getTimestamps($user, 'Today', 7); } public static function getCalendarWidgetTimestamps( PhabricatorUser $user) { return self::getTimestamps($user, 'Sunday', 9); } /** * Public for testing purposes only. You should probably use one of the * functions above. */ public static function getTimestamps( PhabricatorUser $user, $start_day_str, $days) { $objects = self::getStartDateTimeObjects($user, $start_day_str); $start_day = $objects['start_day']; $timestamps = array(); for ($day = 0; $day < $days; $day++) { $timestamp = clone $start_day; $timestamp->modify(sprintf('+%d days', $day)); $timestamps[] = $timestamp; } return array( 'today' => $objects['today'], 'epoch_stamps' => $timestamps, ); } private static function getStartDateTimeObjects( PhabricatorUser $user, $start_day_str) { $timezone = new DateTimeZone($user->getTimezoneIdentifier()); $today_epoch = PhabricatorTime::parseLocalTime('today', $user); $today = new DateTime('@'.$today_epoch); $today->setTimezone($timezone); if (strtolower($start_day_str) == 'today' || $today->format('l') == $start_day_str) { $start_day = clone $today; } else { $start_epoch = PhabricatorTime::parseLocalTime( 'last '.$start_day_str, $user); $start_day = new DateTime('@'.$start_epoch); $start_day->setTimezone($timezone); } return array( 'today' => $today, 'start_day' => $start_day, ); } } diff --git a/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php b/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php index 6fb8ae8f31..69f2e9e8a6 100644 --- a/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php +++ b/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php @@ -1,141 +1,141 @@ newRecurrenceRule(); if (!$rrule) { return null; } return $rrule->getFrequency(); } public function applyInternalEffects($object, $value) { $rrule = id(new PhutilCalendarRecurrenceRule()) ->setFrequency($value); // If the user creates a monthly event on the 29th, 30th or 31st of a // month, it means "the 30th of every month" as far as the RRULE is // concerned. Such an event will not occur on months with fewer days. - // This is surprising, and proably not what the user wants. Instead, + // This is surprising, and probably not what the user wants. Instead, // schedule these events relative to the end of the month: on the "-1st", // "-2nd" or "-3rd" day of the month. For example, a monthly event on // the 31st of a 31-day month translates to "every month, on the last // day of the month". if ($value == PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY) { $start_datetime = $object->newStartDateTime(); $y = $start_datetime->getYear(); $m = $start_datetime->getMonth(); $d = $start_datetime->getDay(); if ($d >= 29) { $year_map = PhutilCalendarRecurrenceRule::getYearMap( $y, PhutilCalendarRecurrenceRule::WEEKDAY_MONDAY); $month_days = $year_map['monthDays'][$m]; $schedule_on = -(($month_days + 1) - $d); $rrule->setByMonthDay(array($schedule_on)); } } $object->setRecurrenceRule($rrule); } public function validateTransactions($object, array $xactions) { $errors = array(); $valid = array( PhutilCalendarRecurrenceRule::FREQUENCY_DAILY, PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY, PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY, PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY, ); $valid = array_fuse($valid); foreach ($xactions as $xaction) { $value = $xaction->getNewValue(); if (!isset($valid[$value])) { $errors[] = $this->newInvalidError( pht( - 'Event frequency "%s" is not valid. Valid frequences are: %s.', + 'Event frequency "%s" is not valid. Valid frequencies are: %s.', $value, implode(', ', $valid)), $xaction); } } return $errors; } public function getTitle() { $frequency = $this->getFrequency($this->getNewValue()); switch ($frequency) { case PhutilCalendarRecurrenceRule::FREQUENCY_DAILY: return pht( '%s set this event to repeat daily.', $this->renderAuthor()); case PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY: return pht( '%s set this event to repeat weekly.', $this->renderAuthor()); case PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY: return pht( '%s set this event to repeat monthly.', $this->renderAuthor()); case PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY: return pht( '%s set this event to repeat yearly.', $this->renderAuthor()); } } public function getTitleForFeed() { $frequency = $this->getFrequency($this->getNewValue()); switch ($frequency) { case PhutilCalendarRecurrenceRule::FREQUENCY_DAILY: return pht( '%s set %s to repeat daily.', $this->renderAuthor(), $this->renderObject()); case PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY: return pht( '%s set %s to repeat weekly.', $this->renderAuthor(), $this->renderObject()); case PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY: return pht( '%s set %s to repeat monthly.', $this->renderAuthor(), $this->renderObject()); case PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY: return pht( '%s set %s to repeat yearly.', $this->renderAuthor(), $this->renderObject()); } } private function getFrequency($value) { // NOTE: This is normalizing three generations of these transactions // to use RRULE constants. It would be vaguely nice to migrate them // for consistency. if (is_array($value)) { $value = idx($value, 'rule'); } else { $value = $value; } return phutil_utf8_strtoupper($value); } } diff --git a/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php b/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php index f1da2c1d77..841a066320 100644 --- a/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php +++ b/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php @@ -1,184 +1,184 @@ getInvitees(); foreach ($invitees as $key => $invitee) { if ($invitee->getStatus() == $status_uninvited) { unset($invitees[$key]); } } return array_values(mpull($invitees, 'getInviteePHID')); } private function generateChangeMap($object, $new_value) { $status_invited = PhabricatorCalendarEventInvitee::STATUS_INVITED; $status_uninvited = PhabricatorCalendarEventInvitee::STATUS_UNINVITED; $status_attending = PhabricatorCalendarEventInvitee::STATUS_ATTENDING; $old = $this->generateOldValue($object); $add = array_diff($new_value, $old); $rem = array_diff($old, $new_value); $map = array(); foreach ($add as $phid) { $map[$phid] = $status_invited; } foreach ($rem as $phid) { $map[$phid] = $status_uninvited; } // If we're creating this event and the actor is inviting themselves, // mark them as attending. if ($this->isNewObject()) { $acting_phid = $this->getActingAsPHID(); if (isset($map[$acting_phid])) { $map[$acting_phid] = $status_attending; } } return $map; } public function applyExternalEffects($object, $value) { $map = $this->generateChangeMap($object, $value); $invitees = $object->getInvitees(); $invitees = mpull($invitees, null, 'getInviteePHID'); foreach ($map as $phid => $status) { $invitee = idx($invitees, $phid); if (!$invitee) { $invitee = id(new PhabricatorCalendarEventInvitee()) ->setEventPHID($object->getPHID()) ->setInviteePHID($phid) ->setInviterPHID($this->getActingAsPHID()); $invitees[] = $invitee; } $invitee->setStatus($status) ->save(); } $object->attachInvitees($invitees); } public function validateTransactions($object, array $xactions) { $actor = $this->getActor(); $errors = array(); $old = $object->getInvitees(); $old = mpull($old, null, 'getInviteePHID'); foreach ($xactions as $xaction) { $new = $xaction->getNewValue(); $new = array_fuse($new); $add = array_diff_key($new, $old); if (!$add) { continue; } // In the UI, we only allow you to invite mailable objects, but there // is no definitive marker for "invitable object" today. Just allow // any valid object to be invited. $objects = id(new PhabricatorObjectQuery()) ->setViewer($actor) ->withPHIDs($add) ->execute(); $objects = mpull($objects, null, 'getPHID'); foreach ($add as $phid) { if (isset($objects[$phid])) { continue; } $errors[] = $this->newInvalidError( pht( 'Invitee "%s" identifies an object that does not exist or '. 'which you do not have permission to view.', $phid), $xaction); } } return $errors; } public function getIcon() { return 'fa-user-plus'; } public function getTitle() { list($add, $rem) = $this->getChanges(); if ($add && !$rem) { return pht( '%s invited %s attendee(s): %s.', $this->renderAuthor(), phutil_count($add), $this->renderHandleList($add)); } else if (!$add && $rem) { return pht( '%s uninvited %s attendee(s): %s.', $this->renderAuthor(), phutil_count($rem), $this->renderHandleList($rem)); } else { return pht( - '%s invited %s attendee(s): %s; uninvinted %s attendee(s): %s.', + '%s invited %s attendee(s): %s; uninvited %s attendee(s): %s.', $this->renderAuthor(), phutil_count($add), $this->renderHandleList($add), phutil_count($rem), $this->renderHandleList($rem)); } } public function getTitleForFeed() { list($add, $rem) = $this->getChanges(); if ($add && !$rem) { return pht( '%s invited %s attendee(s) to %s: %s.', $this->renderAuthor(), phutil_count($add), $this->renderObject(), $this->renderHandleList($add)); } else if (!$add && $rem) { return pht( '%s uninvited %s attendee(s) to %s: %s.', $this->renderAuthor(), phutil_count($rem), $this->renderObject(), $this->renderHandleList($rem)); } else { return pht( '%s updated the invite list for %s, invited %s: %s; '. - 'uninvinted %s: %s.', + 'uninvited %s: %s.', $this->renderAuthor(), $this->renderObject(), phutil_count($add), $this->renderHandleList($add), phutil_count($rem), $this->renderHandleList($rem)); } } private function getChanges() { $old = $this->getOldValue(); $new = $this->getNewValue(); $add = array_diff($new, $old); $rem = array_diff($old, $new); return array(array_fuse($add), array_fuse($rem)); } } diff --git a/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php b/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php index 177adbbf0b..9fc68a3497 100644 --- a/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php +++ b/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php @@ -1,45 +1,45 @@ getTriggerFrequency(); } public function applyInternalEffects($object, $value) { $object->setTriggerFrequency($value); } public function getTitle() { return pht( '%s changed the automatic update frequency for this import.', $this->renderAuthor()); } public function validateTransactions($object, array $xactions) { $errors = array(); $frequency_map = PhabricatorCalendarImport::getTriggerFrequencyMap(); $valid = array_keys($frequency_map); $valid = array_fuse($valid); foreach ($xactions as $xaction) { $value = $xaction->getNewValue(); if (!isset($valid[$value])) { $errors[] = $this->newInvalidError( pht( - 'Import frequency "%s" is not valid. Valid frequences are: %s.', + 'Import frequency "%s" is not valid. Valid frequencies are: %s.', $value, implode(', ', $valid)), $xaction); } } return $errors; } } diff --git a/src/applications/config/check/PhabricatorStorageSetupCheck.php b/src/applications/config/check/PhabricatorStorageSetupCheck.php index 09cecfe6d0..cc74cce2ea 100644 --- a/src/applications/config/check/PhabricatorStorageSetupCheck.php +++ b/src/applications/config/check/PhabricatorStorageSetupCheck.php @@ -1,196 +1,196 @@ checkS3(); if (!$chunk_engine_active) { $doc_href = PhabricatorEnv::getDoclink('Configuring File Storage'); $message = pht( 'Large file storage has not been configured, which will limit '. 'the maximum size of file uploads. See %s for '. 'instructions on configuring uploads and storage.', phutil_tag( 'a', array( 'href' => $doc_href, 'target' => '_blank', ), pht('Configuring File Storage'))); $this ->newIssue('large-files') ->setShortName(pht('Large Files')) ->setName(pht('Large File Storage Not Configured')) ->setMessage($message); } $post_max_size = ini_get('post_max_size'); if ($post_max_size && ((int)$post_max_size > 0)) { $post_max_bytes = phutil_parse_bytes($post_max_size); $post_max_need = (32 * 1024 * 1024); if ($post_max_need > $post_max_bytes) { $summary = pht( 'Set %s in your PHP configuration to at least 32MB '. 'to support large file uploads.', phutil_tag('tt', array(), 'post_max_size')); $message = pht( 'Adjust %s in your PHP configuration to at least 32MB. When '. 'set to smaller value, large file uploads may not work properly.', phutil_tag('tt', array(), 'post_max_size')); $this ->newIssue('php.post_max_size') ->setName(pht('PHP post_max_size Not Configured')) ->setSummary($summary) ->setMessage($message) ->setGroup(self::GROUP_PHP) ->addPHPConfig('post_max_size'); } } // This is somewhat arbitrary, but make sure we have enough headroom to // upload a default file at the chunk threshold (8MB), which may be // base64 encoded, then JSON encoded in the request, and may need to be // held in memory in the raw and as a query string. $need_bytes = (64 * 1024 * 1024); $memory_limit = PhabricatorStartup::getOldMemoryLimit(); if ($memory_limit && ((int)$memory_limit > 0)) { $memory_limit_bytes = phutil_parse_bytes($memory_limit); $memory_usage_bytes = memory_get_usage(); $available_bytes = ($memory_limit_bytes - $memory_usage_bytes); if ($need_bytes > $available_bytes) { $summary = pht( 'Your PHP memory limit is configured in a way that may prevent '. 'you from uploading large files or handling large requests.'); $message = pht( 'When you upload a file via drag-and-drop or the API, chunks must '. 'be buffered into memory before being written to permanent '. 'storage. Phabricator needs memory available to store these '. 'chunks while they are uploaded, but PHP is currently configured '. - 'to severly limit the available memory.'. + 'to severely limit the available memory.'. "\n\n". 'PHP processes currently have very little free memory available '. '(%s). To work well, processes should have at least %s.'. "\n\n". '(Note that the application itself must also fit in available '. 'memory, so not all of the memory under the memory limit is '. 'available for running workloads.)'. "\n\n". "The easiest way to resolve this issue is to set %s to %s in your ". "PHP configuration, to disable the memory limit. There is ". "usually little or no value to using this option to limit ". "Phabricator process memory.". "\n\n". "You can also increase the limit or ignore this issue and accept ". "that you may encounter problems uploading large files and ". "processing large requests.", phutil_format_bytes($available_bytes), phutil_format_bytes($need_bytes), phutil_tag('tt', array(), 'memory_limit'), phutil_tag('tt', array(), '-1')); $this ->newIssue('php.memory_limit.upload') ->setName(pht('Memory Limit Restricts File Uploads')) ->setSummary($summary) ->setMessage($message) ->setGroup(self::GROUP_PHP) ->addPHPConfig('memory_limit') ->addPHPConfigOriginalValue('memory_limit', $memory_limit); } } $local_path = PhabricatorEnv::getEnvConfig('storage.local-disk.path'); if (!$local_path) { return; } if (!Filesystem::pathExists($local_path) || !is_readable($local_path) || !is_writable($local_path)) { $message = pht( 'Configured location for storing uploaded files on disk ("%s") does '. 'not exist, or is not readable or writable. Verify the directory '. 'exists and is readable and writable by the webserver.', $local_path); $this ->newIssue('config.storage.local-disk.path') ->setShortName(pht('Local Disk Storage')) ->setName(pht('Local Disk Storage Not Readable/Writable')) ->setMessage($message) ->addPhabricatorConfig('storage.local-disk.path'); } } private function checkS3() { $access_key = PhabricatorEnv::getEnvConfig('amazon-s3.access-key'); $secret_key = PhabricatorEnv::getEnvConfig('amazon-s3.secret-key'); $region = PhabricatorEnv::getEnvConfig('amazon-s3.region'); $endpoint = PhabricatorEnv::getEnvConfig('amazon-s3.endpoint'); $how_many = 0; if (strlen($access_key)) { $how_many++; } if (strlen($secret_key)) { $how_many++; } if (strlen($region)) { $how_many++; } if (strlen($endpoint)) { $how_many++; } // Nothing configured, no issues here. if ($how_many === 0) { return; } // Everything configured, no issues here. if ($how_many === 4) { return; } $message = pht( 'File storage in Amazon S3 has been partially configured, but you are '. 'missing some required settings. S3 will not be available to store '. 'files until you complete the configuration. Either configure S3 fully '. 'or remove the partial configuration.'); $this->newIssue('storage.s3.partial-config') ->setShortName(pht('S3 Partially Configured')) ->setName(pht('Amazon S3 is Only Partially Configured')) ->setMessage($message) ->addPhabricatorConfig('amazon-s3.access-key') ->addPhabricatorConfig('amazon-s3.secret-key') ->addPhabricatorConfig('amazon-s3.region') ->addPhabricatorConfig('amazon-s3.endpoint'); } } diff --git a/src/applications/conpherence/query/ConpherenceThreadQuery.php b/src/applications/conpherence/query/ConpherenceThreadQuery.php index 1bb96537dc..5cd6489d65 100644 --- a/src/applications/conpherence/query/ConpherenceThreadQuery.php +++ b/src/applications/conpherence/query/ConpherenceThreadQuery.php @@ -1,326 +1,326 @@ needParticipants = $need; return $this; } public function needProfileImage($need) { $this->needProfileImage = $need; return $this; } public function needTransactions($need_transactions) { $this->needTransactions = $need_transactions; return $this; } public function withIDs(array $ids) { $this->ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withParticipantPHIDs(array $phids) { $this->participantPHIDs = $phids; return $this; } public function setAfterTransactionID($id) { $this->afterTransactionID = $id; return $this; } public function setBeforeTransactionID($id) { $this->beforeTransactionID = $id; return $this; } public function setTransactionLimit($transaction_limit) { $this->transactionLimit = $transaction_limit; return $this; } public function getTransactionLimit() { return $this->transactionLimit; } public function withFulltext($query) { $this->fulltext = $query; return $this; } public function withTitleNgrams($ngrams) { return $this->withNgramsConstraint( id(new ConpherenceThreadTitleNgrams()), $ngrams); } protected function loadPage() { $table = new ConpherenceThread(); $conn_r = $table->establishConnection('r'); $data = queryfx_all( $conn_r, 'SELECT thread.* FROM %T thread %Q %Q %Q %Q %Q', $table->getTableName(), $this->buildJoinClause($conn_r), $this->buildWhereClause($conn_r), $this->buildGroupClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); $conpherences = $table->loadAllFromArray($data); if ($conpherences) { $conpherences = mpull($conpherences, null, 'getPHID'); $this->loadParticipantsAndInitHandles($conpherences); if ($this->needParticipants) { $this->loadCoreHandles($conpherences, 'getParticipantPHIDs'); } if ($this->needTransactions) { $this->loadTransactionsAndHandles($conpherences); } if ($this->needProfileImage) { $default = null; $file_phids = mpull($conpherences, 'getProfileImagePHID'); $file_phids = array_filter($file_phids); if ($file_phids) { $files = id(new PhabricatorFileQuery()) ->setParentQuery($this) ->setViewer($this->getViewer()) ->withPHIDs($file_phids) ->execute(); $files = mpull($files, null, 'getPHID'); } else { $files = array(); } foreach ($conpherences as $conpherence) { $file = idx($files, $conpherence->getProfileImagePHID()); if (!$file) { if (!$default) { $default = PhabricatorFile::loadBuiltin( $this->getViewer(), 'conpherence.png'); } $file = $default; } $conpherence->attachProfileImageFile($file); } } } return $conpherences; } protected function buildGroupClause(AphrontDatabaseConnection $conn_r) { if ($this->participantPHIDs !== null || strlen($this->fulltext)) { return 'GROUP BY thread.id'; } else { return $this->buildApplicationSearchGroupClause($conn_r); } } protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $joins = parent::buildJoinClauseParts($conn); if ($this->participantPHIDs !== null) { $joins[] = qsprintf( $conn, 'JOIN %T p ON p.conpherencePHID = thread.phid', id(new ConpherenceParticipant())->getTableName()); } if (strlen($this->fulltext)) { $joins[] = qsprintf( $conn, 'JOIN %T idx ON idx.threadPHID = thread.phid', id(new ConpherenceIndex())->getTableName()); } // See note in buildWhereClauseParts() about this optimization. $viewer = $this->getViewer(); if (!$viewer->isOmnipotent() && $viewer->isLoggedIn()) { $joins[] = qsprintf( $conn, 'LEFT JOIN %T vp ON vp.conpherencePHID = thread.phid AND vp.participantPHID = %s', id(new ConpherenceParticipant())->getTableName(), $viewer->getPHID()); } return $joins; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); // Optimize policy filtering of private rooms. If we are not looking for // particular rooms by ID or PHID, we can just skip over any rooms with // "View Policy: Room Participants" if the viewer isn't a participant: we // know they won't be able to see the room. // This avoids overheating browse/search queries, since it's common for // a large number of rooms to be private and have this view policy. $viewer = $this->getViewer(); $can_optimize = !$viewer->isOmnipotent() && ($this->ids === null) && ($this->phids === null); if ($can_optimize) { $members_policy = id(new ConpherenceThreadMembersPolicyRule()) ->getObjectPolicyFullKey(); if ($viewer->isLoggedIn()) { $where[] = qsprintf( $conn, 'thread.viewPolicy != %s OR vp.participantPHID = %s', $members_policy, $viewer->getPHID()); } else { $where[] = qsprintf( $conn, 'thread.viewPolicy != %s', $members_policy); } } if ($this->ids !== null) { $where[] = qsprintf( $conn, 'thread.id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'thread.phid IN (%Ls)', $this->phids); } if ($this->participantPHIDs !== null) { $where[] = qsprintf( $conn, 'p.participantPHID IN (%Ls)', $this->participantPHIDs); } if (strlen($this->fulltext)) { $where[] = qsprintf( $conn, 'MATCH(idx.corpus) AGAINST (%s IN BOOLEAN MODE)', $this->fulltext); } return $where; } private function loadParticipantsAndInitHandles(array $conpherences) { $participants = id(new ConpherenceParticipant()) ->loadAllWhere('conpherencePHID IN (%Ls)', array_keys($conpherences)); $map = mgroup($participants, 'getConpherencePHID'); foreach ($conpherences as $current_conpherence) { $conpherence_phid = $current_conpherence->getPHID(); $conpherence_participants = idx( $map, $conpherence_phid, array()); $conpherence_participants = mpull( $conpherence_participants, null, 'getParticipantPHID'); $current_conpherence->attachParticipants($conpherence_participants); $current_conpherence->attachHandles(array()); } return $this; } private function loadCoreHandles( array $conpherences, $method) { $handle_phids = array(); foreach ($conpherences as $conpherence) { $handle_phids[$conpherence->getPHID()] = $conpherence->$method(); } $flat_phids = array_mergev($handle_phids); $viewer = $this->getViewer(); $handles = $viewer->loadHandles($flat_phids); $handles = iterator_to_array($handles); foreach ($handle_phids as $conpherence_phid => $phids) { $conpherence = $conpherences[$conpherence_phid]; $conpherence->attachHandles( $conpherence->getHandles() + array_select_keys($handles, $phids)); } return $this; } private function loadTransactionsAndHandles(array $conpherences) { $query = id(new ConpherenceTransactionQuery()) ->setViewer($this->getViewer()) ->withObjectPHIDs(array_keys($conpherences)) ->needHandles(true); - // We have to flip these for the underyling query class. The semantics of + // We have to flip these for the underlying query class. The semantics of // paging are tricky business. if ($this->afterTransactionID) { $query->setBeforeID($this->afterTransactionID); } else if ($this->beforeTransactionID) { $query->setAfterID($this->beforeTransactionID); } if ($this->getTransactionLimit()) { // fetch an extra for "show older" scenarios $query->setLimit($this->getTransactionLimit() + 1); } $transactions = $query->execute(); $transactions = mgroup($transactions, 'getObjectPHID'); foreach ($conpherences as $phid => $conpherence) { $current_transactions = idx($transactions, $phid, array()); $handles = array(); foreach ($current_transactions as $transaction) { $handles += $transaction->getHandles(); } $conpherence->attachHandles($conpherence->getHandles() + $handles); $conpherence->attachTransactions($current_transactions); } return $this; } public function getQueryApplicationClass() { return 'PhabricatorConpherenceApplication'; } protected function getPrimaryTableAlias() { return 'thread'; } } diff --git a/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php b/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php index 96566db54a..07bbb67ae2 100644 --- a/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php +++ b/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php @@ -1,114 +1,114 @@ getArr($this->getFieldKey().'_name'); $panel_ids = $request->getArr($this->getFieldKey().'_panelID'); $panels = array(); foreach ($panel_ids as $panel_id) { $panels[] = $panel_id[0]; } foreach ($names as $idx => $name) { $panel_id = idx($panels, $idx); if (strlen($name) && $panel_id) { $value[] = array( 'name' => $name, 'panelID' => $panel_id, ); } } $this->setFieldValue($value); } public function getApplicationTransactionTitle( PhabricatorApplicationTransaction $xaction) { $author_phid = $xaction->getAuthorPHID(); $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $new_tabs = array(); if ($new) { foreach ($new as $new_tab) { $new_tabs[] = $new_tab['name']; } $new_tabs = implode(' | ', $new_tabs); } $old_tabs = array(); if ($old) { foreach ($old as $old_tab) { $old_tabs[] = $old_tab['name']; } $old_tabs = implode(' | ', $old_tabs); } if (!$old) { // In case someone makes a tab panel with no tabs. if ($new) { return pht( '%s set the tabs to "%s".', $xaction->renderHandleLink($author_phid), $new_tabs); } } else if (!$new) { return pht( '%s removed tabs.', $xaction->renderHandleLink($author_phid)); } else { return pht( '%s changed the tabs from "%s" to "%s".', $xaction->renderHandleLink($author_phid), $old_tabs, $new_tabs); } } public function renderEditControl(array $handles) { // NOTE: This includes archived panels so we don't mutate the tabs - // when saving a tab panel that includes archied panels. This whole UI is + // when saving a tab panel that includes archived panels. This whole UI is // hopefully temporary anyway. $value = $this->getFieldValue(); if (!is_array($value)) { $value = array(); } $out = array(); for ($ii = 1; $ii <= 6; $ii++) { $tab = idx($value, ($ii - 1), array()); $panel = idx($tab, 'panelID', null); $panel_id = array(); if ($panel) { $panel_id[] = $panel; } $out[] = id(new AphrontFormTextControl()) ->setName($this->getFieldKey().'_name[]') ->setValue(idx($tab, 'name')) ->setLabel(pht('Tab %d Name', $ii)); $out[] = id(new AphrontFormTokenizerControl()) ->setUser($this->getViewer()) ->setDatasource(new PhabricatorDashboardPanelDatasource()) ->setName($this->getFieldKey().'_panelID[]') ->setValue($panel_id) ->setLimit(1) ->setLabel(pht('Tab %d Panel', $ii)); } return $out; } } diff --git a/src/applications/differential/controller/DifferentialController.php b/src/applications/differential/controller/DifferentialController.php index fb8a6249a7..1860a07745 100644 --- a/src/applications/differential/controller/DifferentialController.php +++ b/src/applications/differential/controller/DifferentialController.php @@ -1,276 +1,276 @@ getRequest()->getUser(); $nav = new AphrontSideNavFilterView(); $nav->setBaseURI(new PhutilURI($this->getApplicationURI())); id(new DifferentialRevisionSearchEngine()) ->setViewer($viewer) ->addNavigationItems($nav->getMenu()); $nav->selectFilter(null); return $nav; } public function buildApplicationMenu() { return $this->buildSideNavView(true)->getMenu(); } protected function buildPackageMaps(array $changesets) { assert_instances_of($changesets, 'DifferentialChangeset'); $this->packageChangesetMap = array(); $this->pathPackageMap = array(); $this->authorityPackages = array(); if (!$changesets) { return; } $viewer = $this->getViewer(); $have_owners = PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorOwnersApplication', $viewer); if (!$have_owners) { return; } $changeset = head($changesets); $diff = $changeset->getDiff(); $repository_phid = $diff->getRepositoryPHID(); if (!$repository_phid) { return; } if ($viewer->getPHID()) { $packages = id(new PhabricatorOwnersPackageQuery()) ->setViewer($viewer) ->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE)) ->withAuthorityPHIDs(array($viewer->getPHID())) ->execute(); $this->authorityPackages = $packages; } $paths = mpull($changesets, 'getOwnersFilename'); $control_query = id(new PhabricatorOwnersPackageQuery()) ->setViewer($viewer) ->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE)) ->withControl($repository_phid, $paths); $control_query->execute(); foreach ($changesets as $changeset) { $changeset_path = $changeset->getOwnersFilename(); $packages = $control_query->getControllingPackagesForPath( $repository_phid, $changeset_path); $this->pathPackageMap[$changeset_path] = $packages; foreach ($packages as $package) { $this->packageChangesetMap[$package->getPHID()][] = $changeset; } } } protected function getAuthorityPackages() { if ($this->authorityPackages === null) { throw new PhutilInvalidStateException('buildPackageMaps'); } return $this->authorityPackages; } protected function getChangesetPackages(DifferentialChangeset $changeset) { if ($this->pathPackageMap === null) { throw new PhutilInvalidStateException('buildPackageMaps'); } $path = $changeset->getOwnersFilename(); return idx($this->pathPackageMap, $path, array()); } protected function getPackageChangesets($package_phid) { if ($this->packageChangesetMap === null) { throw new PhutilInvalidStateException('buildPackageMaps'); } return idx($this->packageChangesetMap, $package_phid, array()); } protected function buildTableOfContents( array $changesets, array $visible_changesets, array $coverage) { $viewer = $this->getViewer(); $toc_view = id(new PHUIDiffTableOfContentsListView()) ->setViewer($viewer) ->setBare(true) ->setAuthorityPackages($this->getAuthorityPackages()); foreach ($changesets as $changeset_id => $changeset) { $is_visible = isset($visible_changesets[$changeset_id]); $anchor = $changeset->getAnchorName(); $filename = $changeset->getFilename(); $coverage_id = 'differential-mcoverage-'.md5($filename); $item = id(new PHUIDiffTableOfContentsItemView()) ->setChangeset($changeset) ->setIsVisible($is_visible) ->setAnchor($anchor) ->setCoverage(idx($coverage, $filename)) ->setCoverageID($coverage_id); $packages = $this->getChangesetPackages($changeset); $item->setPackages($packages); $toc_view->addItem($item); } return $toc_view; } protected function loadDiffProperties(array $diffs) { $diffs = mpull($diffs, null, 'getID'); $properties = id(new DifferentialDiffProperty())->loadAllWhere( 'diffID IN (%Ld)', array_keys($diffs)); $properties = mgroup($properties, 'getDiffID'); foreach ($diffs as $id => $diff) { $values = idx($properties, $id, array()); $values = mpull($values, 'getData', 'getName'); $diff->attachDiffProperties($values); } } protected function loadHarbormasterData(array $diffs) { $viewer = $this->getViewer(); $diffs = mpull($diffs, null, 'getPHID'); $buildables = id(new HarbormasterBuildableQuery()) ->setViewer($viewer) ->withBuildablePHIDs(array_keys($diffs)) ->withManualBuildables(false) ->needBuilds(true) ->needTargets(true) ->execute(); $buildables = mpull($buildables, null, 'getBuildablePHID'); foreach ($diffs as $phid => $diff) { $diff->attachBuildable(idx($buildables, $phid)); } $target_map = array(); foreach ($diffs as $phid => $diff) { $target_map[$phid] = $diff->getBuildTargetPHIDs(); } $all_target_phids = array_mergev($target_map); if ($all_target_phids) { $unit_messages = id(new HarbormasterBuildUnitMessage())->loadAllWhere( 'buildTargetPHID IN (%Ls)', $all_target_phids); $unit_messages = mgroup($unit_messages, 'getBuildTargetPHID'); } else { $unit_messages = array(); } foreach ($diffs as $phid => $diff) { $target_phids = idx($target_map, $phid, array()); $messages = array_select_keys($unit_messages, $target_phids); $messages = array_mergev($messages); $diff->attachUnitMessages($messages); } // For diffs with no messages, look for legacy unit messages stored on the // diff itself. foreach ($diffs as $phid => $diff) { if ($diff->getUnitMessages()) { continue; } if (!$diff->hasDiffProperty('arc:unit')) { continue; } $legacy_messages = $diff->getProperty('arc:unit'); if (!$legacy_messages) { continue; } // Show the top 100 legacy lint messages. Previously, we showed some // by default and let the user toggle the rest. With modern messages, // we can send the user to the Harbormaster detail page. Just show // "a lot" of messages in legacy cases to try to strike a balance - // between implementation simplicitly and compatibility. + // between implementation simplicity and compatibility. $legacy_messages = array_slice($legacy_messages, 0, 100); $messages = array(); foreach ($legacy_messages as $message) { $messages[] = HarbormasterBuildUnitMessage::newFromDictionary( new HarbormasterBuildTarget(), $this->getModernUnitMessageDictionary($message)); } $diff->attachUnitMessages($messages); } } private function getModernUnitMessageDictionary(array $map) { // Strip out `null` values to satisfy stricter typechecks. foreach ($map as $key => $value) { if ($value === null) { unset($map[$key]); } } // Cast duration to a float since it used to be a string in some // cases. if (isset($map['duration'])) { $map['duration'] = (double)$map['duration']; } return $map; } protected function getDiffTabLabels(array $diffs) { // Make sure we're only going to render unique diffs. $diffs = mpull($diffs, null, 'getID'); $labels = array(pht('Left'), pht('Right')); $results = array(); foreach ($diffs as $diff) { if (count($diffs) == 2) { $label = array_shift($labels); $label = pht('%s (Diff %d)', $label, $diff->getID()); } else { $label = pht('Diff %d', $diff->getID()); } $results[] = array( $label, $diff, ); } return $results; } } diff --git a/src/applications/differential/customfield/DifferentialHarbormasterField.php b/src/applications/differential/customfield/DifferentialHarbormasterField.php index c9b573bda2..cc13be3aa7 100644 --- a/src/applications/differential/customfield/DifferentialHarbormasterField.php +++ b/src/applications/differential/customfield/DifferentialHarbormasterField.php @@ -1,98 +1,98 @@ getDiffPropertyKeys(); $properties = id(new DifferentialDiffProperty())->loadAllWhere( 'diffID = %d AND name IN (%Ls)', $diff->getID(), $keys); $properties = mpull($properties, 'getData', 'getName'); foreach ($keys as $key) { $diff->attachProperty($key, idx($properties, $key)); } $target_phids = $diff->getBuildTargetPHIDs(); if ($target_phids) { $messages = $this->loadHarbormasterTargetMessages($target_phids); } else { $messages = array(); } if (!$messages) { // No Harbormaster messages, so look for legacy messages and make them // look like modern messages. $legacy_messages = $diff->getProperty($this->getLegacyProperty()); if ($legacy_messages) { // Show the top 100 legacy lint messages. Previously, we showed some // by default and let the user toggle the rest. With modern messages, // we can send the user to the Harbormaster detail page. Just show // "a lot" of messages in legacy cases to try to strike a balance - // between implementation simplicitly and compatibility. + // between implementation simplicity and compatibility. $legacy_messages = array_slice($legacy_messages, 0, 100); foreach ($legacy_messages as $message) { try { $modern = $this->newModernMessage($message); $messages[] = $modern; } catch (Exception $ex) { // Ignore any poorly formatted messages. } } } } $status = $this->renderHarbormasterStatus($diff, $messages); if ($messages) { $path_map = mpull($diff->loadChangesets(), 'getID', 'getFilename'); foreach ($path_map as $path => $id) { $href = '#C'.$id.'NL'; // TODO: When the diff is not the right-hand-size diff, we should // ideally adjust this URI to be absolute. $path_map[$path] = $href; } $view = $this->newHarbormasterMessageView($messages); if ($view) { $view->setPathURIMap($path_map); } } else { $view = null; } if ($view) { $view = phutil_tag( 'div', array( 'class' => 'differential-harbormaster-table-view', ), $view); } return array( $status, $view, ); } } diff --git a/src/applications/differential/editor/DifferentialDiffEditor.php b/src/applications/differential/editor/DifferentialDiffEditor.php index 0058485e63..261f6f1598 100644 --- a/src/applications/differential/editor/DifferentialDiffEditor.php +++ b/src/applications/differential/editor/DifferentialDiffEditor.php @@ -1,238 +1,238 @@ lookupRepository = $bool; return $this; } public function getEditorApplicationClass() { return 'PhabricatorDifferentialApplication'; } public function getEditorObjectsDescription() { return pht('Differential Diffs'); } public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = DifferentialDiffTransaction::TYPE_DIFF_CREATE; return $types; } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialDiffTransaction::TYPE_DIFF_CREATE: return null; } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialDiffTransaction::TYPE_DIFF_CREATE: $this->diffDataDict = $xaction->getNewValue(); return true; } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialDiffTransaction::TYPE_DIFF_CREATE: $dict = $this->diffDataDict; $this->updateDiffFromDict($object, $dict); return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialDiffTransaction::TYPE_DIFF_CREATE: return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // If we didn't get an explicit `repositoryPHID` (which means the client // is old, or couldn't figure out which repository the working copy // belongs to), apply heuristics to try to figure it out. if ($this->lookupRepository && !$object->getRepositoryPHID()) { $repository = id(new DifferentialRepositoryLookup()) ->setDiff($object) ->setViewer($this->getActor()) ->lookupRepository(); if ($repository) { $object->setRepositoryPHID($repository->getPHID()); $object->setRepositoryUUID($repository->getUUID()); $object->save(); } } return $xactions; } /** * We run Herald as part of transaction validation because Herald can * block diff creation for Differential diffs. Its important to do this * separately so no Herald logs are saved; these logs could expose - * information the Herald rules are inteneded to block. + * information the Herald rules are intended to block. */ protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); foreach ($xactions as $xaction) { switch ($type) { case DifferentialDiffTransaction::TYPE_DIFF_CREATE: $diff = clone $object; $diff = $this->updateDiffFromDict($diff, $xaction->getNewValue()); $adapter = $this->buildHeraldAdapter($diff, $xactions); $adapter->setContentSource($this->getContentSource()); $adapter->setIsNewObject($this->getIsNewObject()); $engine = new HeraldEngine(); $rules = $engine->loadRulesForAdapter($adapter); $rules = mpull($rules, null, 'getID'); $effects = $engine->applyRules($rules, $adapter); $action_block = DifferentialBlockHeraldAction::ACTIONCONST; $blocking_effect = null; foreach ($effects as $effect) { if ($effect->getAction() == $action_block) { $blocking_effect = $effect; break; } } if ($blocking_effect) { $rule = $blocking_effect->getRule(); $message = $effect->getTarget(); if (!strlen($message)) { $message = pht('(None.)'); } $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Rejected by Herald'), pht( "Creation of this diff was rejected by Herald rule %s.\n". " Rule: %s\n". "Reason: %s", $rule->getMonogram(), $rule->getName(), $message)); } break; } } return $errors; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function supportsSearch() { return false; } /* -( Herald Integration )------------------------------------------------- */ /** * See @{method:validateTransaction}. The only Herald action is to block * the creation of Diffs. We thus have to be careful not to save any * data and do this validation very early. */ protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { $adapter = id(new HeraldDifferentialDiffAdapter()) ->setDiff($object); return $adapter; } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { $xactions = array(); return $xactions; } private function updateDiffFromDict(DifferentialDiff $diff, $dict) { $diff ->setSourcePath(idx($dict, 'sourcePath')) ->setSourceMachine(idx($dict, 'sourceMachine')) ->setBranch(idx($dict, 'branch')) ->setCreationMethod(idx($dict, 'creationMethod')) ->setAuthorPHID(idx($dict, 'authorPHID', $this->getActor())) ->setBookmark(idx($dict, 'bookmark')) ->setRepositoryPHID(idx($dict, 'repositoryPHID')) ->setRepositoryUUID(idx($dict, 'repositoryUUID')) ->setSourceControlSystem(idx($dict, 'sourceControlSystem')) ->setSourceControlPath(idx($dict, 'sourceControlPath')) ->setSourceControlBaseRevision(idx($dict, 'sourceControlBaseRevision')) ->setLintStatus(idx($dict, 'lintStatus')) ->setUnitStatus(idx($dict, 'unitStatus')); return $diff; } } diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php index 88303cf420..1eeb4c8452 100644 --- a/src/applications/differential/parser/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/DifferentialChangesetParser.php @@ -1,1638 +1,1638 @@ rangeStart = $start; $this->rangeEnd = $end; return $this; } public function setMask(array $mask) { $this->mask = $mask; return $this; } public function renderChangeset() { return $this->render($this->rangeStart, $this->rangeEnd, $this->mask); } public function setShowEditAndReplyLinks($bool) { $this->showEditAndReplyLinks = $bool; return $this; } public function getShowEditAndReplyLinks() { return $this->showEditAndReplyLinks; } public function setHighlightAs($highlight_as) { $this->highlightAs = $highlight_as; return $this; } public function getHighlightAs() { return $this->highlightAs; } public function setCharacterEncoding($character_encoding) { $this->characterEncoding = $character_encoding; return $this; } public function getCharacterEncoding() { return $this->characterEncoding; } public function setRenderer(DifferentialChangesetRenderer $renderer) { $this->renderer = $renderer; return $this; } public function getRenderer() { if (!$this->renderer) { return new DifferentialChangesetTwoUpRenderer(); } return $this->renderer; } public function setDisableCache($disable_cache) { $this->disableCache = $disable_cache; return $this; } public function getDisableCache() { return $this->disableCache; } public function setCanMarkDone($can_mark_done) { $this->canMarkDone = $can_mark_done; return $this; } public function getCanMarkDone() { return $this->canMarkDone; } public function setObjectOwnerPHID($phid) { $this->objectOwnerPHID = $phid; return $this; } public function getObjectOwnerPHID() { return $this->objectOwnerPHID; } public function setOffsetMode($offset_mode) { $this->offsetMode = $offset_mode; return $this; } public function getOffsetMode() { return $this->offsetMode; } public static function getDefaultRendererForViewer(PhabricatorUser $viewer) { $is_unified = $viewer->compareUserSetting( PhabricatorUnifiedDiffsSetting::SETTINGKEY, PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED); if ($is_unified) { return '1up'; } return null; } public function readParametersFromRequest(AphrontRequest $request) { $this->setWhitespaceMode($request->getStr('whitespace')); $this->setCharacterEncoding($request->getStr('encoding')); $this->setHighlightAs($request->getStr('highlight')); $renderer = null; // If the viewer prefers unified diffs, always set the renderer to unified. // Otherwise, we leave it unspecified and the client will choose a // renderer based on the screen size. if ($request->getStr('renderer')) { $renderer = $request->getStr('renderer'); } else { $renderer = self::getDefaultRendererForViewer($request->getViewer()); } switch ($renderer) { case '1up': $this->setRenderer(new DifferentialChangesetOneUpRenderer()); break; default: $this->setRenderer(new DifferentialChangesetTwoUpRenderer()); break; } return $this; } const CACHE_VERSION = 11; const CACHE_MAX_SIZE = 8e6; const ATTR_GENERATED = 'attr:generated'; const ATTR_DELETED = 'attr:deleted'; const ATTR_UNCHANGED = 'attr:unchanged'; const ATTR_WHITELINES = 'attr:white'; const ATTR_MOVEAWAY = 'attr:moveaway'; const WHITESPACE_SHOW_ALL = 'show-all'; const WHITESPACE_IGNORE_TRAILING = 'ignore-trailing'; const WHITESPACE_IGNORE_MOST = 'ignore-most'; const WHITESPACE_IGNORE_ALL = 'ignore-all'; public function setOldLines(array $lines) { $this->old = $lines; return $this; } public function setNewLines(array $lines) { $this->new = $lines; return $this; } public function setSpecialAttributes(array $attributes) { $this->specialAttributes = $attributes; return $this; } public function setIntraLineDiffs(array $diffs) { $this->intra = $diffs; return $this; } public function setVisibileLinesMask(array $mask) { $this->visible = $mask; return $this; } public function setLinesOfContext($lines_of_context) { $this->linesOfContext = $lines_of_context; return $this; } public function getLinesOfContext() { return $this->linesOfContext; } /** * Configure which Changeset comments added to the right side of the visible * diff will be attached to. The ID must be the ID of a real Differential * Changeset. * * The complexity here is that we may show an arbitrary side of an arbitrary * changeset as either the left or right part of a diff. This method allows * the left and right halves of the displayed diff to be correctly mapped to * storage changesets. * * @param id The Differential Changeset ID that comments added to the right * side of the visible diff should be attached to. * @param bool If true, attach new comments to the right side of the storage * changeset. Note that this may be false, if the left side of * some storage changeset is being shown as the right side of * a display diff. * @return this */ public function setRightSideCommentMapping($id, $is_new) { $this->rightSideChangesetID = $id; $this->rightSideAttachesToNewFile = $is_new; return $this; } /** * See setRightSideCommentMapping(), but this sets information for the left * side of the display diff. */ public function setLeftSideCommentMapping($id, $is_new) { $this->leftSideChangesetID = $id; $this->leftSideAttachesToNewFile = $is_new; return $this; } public function setOriginals( DifferentialChangeset $left, DifferentialChangeset $right) { $this->originalLeft = $left; $this->originalRight = $right; return $this; } public function diffOriginals() { $engine = new PhabricatorDifferenceEngine(); $changeset = $engine->generateChangesetFromFileContent( implode('', mpull($this->originalLeft->getHunks(), 'getChanges')), implode('', mpull($this->originalRight->getHunks(), 'getChanges'))); $parser = new DifferentialHunkParser(); return $parser->parseHunksForHighlightMasks( $changeset->getHunks(), $this->originalLeft->getHunks(), $this->originalRight->getHunks()); } /** * Set a key for identifying this changeset in the render cache. If set, the * parser will attempt to use the changeset render cache, which can improve * performance for frequently-viewed changesets. * * By default, there is no render cache key and parsers do not use the cache. * This is appropriate for rarely-viewed changesets. * * NOTE: Currently, this key must be a valid Differential Changeset ID. * * @param string Key for identifying this changeset in the render cache. * @return this */ public function setRenderCacheKey($key) { $this->renderCacheKey = $key; return $this; } private function getRenderCacheKey() { return $this->renderCacheKey; } public function setChangeset(DifferentialChangeset $changeset) { $this->changeset = $changeset; $this->setFilename($changeset->getFilename()); return $this; } public function setWhitespaceMode($whitespace_mode) { $this->whitespaceMode = $whitespace_mode; return $this; } public function setRenderingReference($ref) { $this->renderingReference = $ref; return $this; } private function getRenderingReference() { return $this->renderingReference; } public function getChangeset() { return $this->changeset; } public function setFilename($filename) { $this->filename = $filename; return $this; } public function setHandles(array $handles) { assert_instances_of($handles, 'PhabricatorObjectHandle'); $this->handles = $handles; return $this; } public function setMarkupEngine(PhabricatorMarkupEngine $engine) { $this->markupEngine = $engine; return $this; } public function setUser(PhabricatorUser $user) { $this->user = $user; return $this; } public function getUser() { return $this->user; } public function setCoverage($coverage) { $this->coverage = $coverage; return $this; } private function getCoverage() { return $this->coverage; } public function parseInlineComment( PhabricatorInlineCommentInterface $comment) { // Parse only comments which are actually visible. if ($this->isCommentVisibleOnRenderedDiff($comment)) { $this->comments[] = $comment; } return $this; } private function loadCache() { $render_cache_key = $this->getRenderCacheKey(); if (!$render_cache_key) { return false; } $data = null; $changeset = new DifferentialChangeset(); $conn_r = $changeset->establishConnection('r'); $data = queryfx_one( $conn_r, 'SELECT * FROM %T WHERE id = %d', $changeset->getTableName().'_parse_cache', $render_cache_key); if (!$data) { return false; } if ($data['cache'][0] == '{') { // This is likely an old-style JSON cache which we will not be able to // deserialize. return false; } $data = unserialize($data['cache']); if (!is_array($data) || !$data) { return false; } foreach (self::getCacheableProperties() as $cache_key) { if (!array_key_exists($cache_key, $data)) { // If we're missing a cache key, assume we're looking at an old cache // and ignore it. return false; } } if ($data['cacheVersion'] !== self::CACHE_VERSION) { return false; } // Someone displays contents of a partially cached shielded file. if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) { return false; } unset($data['cacheVersion'], $data['cacheHost']); $cache_prop = array_select_keys($data, self::getCacheableProperties()); foreach ($cache_prop as $cache_key => $v) { $this->$cache_key = $v; } return true; } protected static function getCacheableProperties() { return array( 'visible', 'new', 'old', 'intra', 'newRender', 'oldRender', 'specialAttributes', 'hunkStartLines', 'cacheVersion', 'cacheHost', 'highlightingDisabled', ); } public function saveCache() { if (PhabricatorEnv::isReadOnly()) { return false; } if ($this->highlightErrors) { return false; } $render_cache_key = $this->getRenderCacheKey(); if (!$render_cache_key) { return false; } $cache = array(); foreach (self::getCacheableProperties() as $cache_key) { switch ($cache_key) { case 'cacheVersion': $cache[$cache_key] = self::CACHE_VERSION; break; case 'cacheHost': $cache[$cache_key] = php_uname('n'); break; default: $cache[$cache_key] = $this->$cache_key; break; } } $cache = serialize($cache); // We don't want to waste too much space by a single changeset. if (strlen($cache) > self::CACHE_MAX_SIZE) { return; } $changeset = new DifferentialChangeset(); $conn_w = $changeset->establishConnection('w'); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { queryfx( $conn_w, 'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %B, %d) ON DUPLICATE KEY UPDATE cache = VALUES(cache)', DifferentialChangeset::TABLE_CACHE, $render_cache_key, $cache, time()); } catch (AphrontQueryException $ex) { // Ignore these exceptions. A common cause is that the cache is // larger than 'max_allowed_packet', in which case we're better off // not writing it. // TODO: It would be nice to tailor this more narrowly. } unset($unguarded); } private function markGenerated($new_corpus_block = '') { $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false); if (!$generated_guess) { $generated_path_regexps = PhabricatorEnv::getEnvConfig( 'differential.generated-paths'); foreach ($generated_path_regexps as $regexp) { if (preg_match($regexp, $this->changeset->getFilename())) { $generated_guess = true; break; } } } $event = new PhabricatorEvent( PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED, array( 'corpus' => $new_corpus_block, 'is_generated' => $generated_guess, ) ); PhutilEventEngine::dispatchEvent($event); $generated = $event->getValue('is_generated'); $this->specialAttributes[self::ATTR_GENERATED] = $generated; } public function isGenerated() { return idx($this->specialAttributes, self::ATTR_GENERATED, false); } public function isDeleted() { return idx($this->specialAttributes, self::ATTR_DELETED, false); } public function isUnchanged() { return idx($this->specialAttributes, self::ATTR_UNCHANGED, false); } public function isWhitespaceOnly() { return idx($this->specialAttributes, self::ATTR_WHITELINES, false); } public function isMoveAway() { return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false); } private function applyIntraline(&$render, $intra, $corpus) { foreach ($render as $key => $text) { if (isset($intra[$key])) { $render[$key] = ArcanistDiffUtils::applyIntralineDiff( $text, $intra[$key]); } } } private function getHighlightFuture($corpus) { $language = $this->highlightAs; if (!$language) { $language = $this->highlightEngine->getLanguageFromFilename( $this->filename); if (($language != 'txt') && (strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) { $this->highlightingDisabled = true; $language = 'txt'; } } return $this->highlightEngine->getHighlightFuture( $language, $corpus); } protected function processHighlightedSource($data, $result) { $result_lines = phutil_split_lines($result); foreach ($data as $key => $info) { if (!$info) { unset($result_lines[$key]); } } return $result_lines; } private function tryCacheStuff() { $whitespace_mode = $this->whitespaceMode; switch ($whitespace_mode) { case self::WHITESPACE_SHOW_ALL: case self::WHITESPACE_IGNORE_TRAILING: case self::WHITESPACE_IGNORE_ALL: break; default: $whitespace_mode = self::WHITESPACE_IGNORE_MOST; break; } $skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_MOST); if ($this->disableCache) { $skip_cache = true; } if ($this->characterEncoding) { $skip_cache = true; } if ($this->highlightAs) { $skip_cache = true; } $this->whitespaceMode = $whitespace_mode; $changeset = $this->changeset; if ($changeset->getFileType() != DifferentialChangeType::FILE_TEXT && $changeset->getFileType() != DifferentialChangeType::FILE_SYMLINK) { $this->markGenerated(); } else { if ($skip_cache || !$this->loadCache()) { $this->process(); if (!$skip_cache) { $this->saveCache(); } } } } private function process() { $whitespace_mode = $this->whitespaceMode; $changeset = $this->changeset; $ignore_all = (($whitespace_mode == self::WHITESPACE_IGNORE_MOST) || ($whitespace_mode == self::WHITESPACE_IGNORE_ALL)); $force_ignore = ($whitespace_mode == self::WHITESPACE_IGNORE_ALL); if (!$force_ignore) { if ($ignore_all && $changeset->getWhitespaceMatters()) { $ignore_all = false; } } // The "ignore all whitespace" algorithm depends on rediffing the // files, and we currently need complete representations of both // files to do anything reasonable. If we only have parts of the files, // don't use the "ignore all" algorithm. if ($ignore_all) { $hunks = $changeset->getHunks(); if (count($hunks) !== 1) { $ignore_all = false; } else { $first_hunk = reset($hunks); if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) { $ignore_all = false; } } } if ($ignore_all) { $old_file = $changeset->makeOldFile(); $new_file = $changeset->makeNewFile(); if ($old_file == $new_file) { // If the old and new files are exactly identical, the synthetic // diff below will give us nonsense and whitespace modes are // irrelevant anyway. This occurs when you, e.g., copy a file onto // itself in Subversion (see T271). $ignore_all = false; } } $hunk_parser = new DifferentialHunkParser(); $hunk_parser->setWhitespaceMode($whitespace_mode); $hunk_parser->parseHunksForLineData($changeset->getHunks()); // Depending on the whitespace mode, we may need to compute a different - // set of changes than the set of changes in the hunk data (specificaly, + // set of changes than the set of changes in the hunk data (specifically, // we might want to consider changed lines which have only whitespace // changes as unchanged). if ($ignore_all) { $engine = new PhabricatorDifferenceEngine(); $engine->setIgnoreWhitespace(true); $no_whitespace_changeset = $engine->generateChangesetFromFileContent( $old_file, $new_file); $type_parser = new DifferentialHunkParser(); $type_parser->parseHunksForLineData($no_whitespace_changeset->getHunks()); $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap()); $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap()); } $hunk_parser->reparseHunksForSpecialAttributes(); $unchanged = false; if (!$hunk_parser->getHasAnyChanges()) { $filetype = $this->changeset->getFileType(); if ($filetype == DifferentialChangeType::FILE_TEXT || $filetype == DifferentialChangeType::FILE_SYMLINK) { $unchanged = true; } } $moveaway = false; $changetype = $this->changeset->getChangeType(); if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) { $moveaway = true; } $this->setSpecialAttributes(array( self::ATTR_UNCHANGED => $unchanged, self::ATTR_DELETED => $hunk_parser->getIsDeleted(), self::ATTR_WHITELINES => !$hunk_parser->getHasTextChanges(), self::ATTR_MOVEAWAY => $moveaway, )); $lines_context = $this->getLinesOfContext(); $hunk_parser->generateIntraLineDiffs(); $hunk_parser->generateVisibileLinesMask($lines_context); $this->setOldLines($hunk_parser->getOldLines()); $this->setNewLines($hunk_parser->getNewLines()); $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs()); $this->setVisibileLinesMask($hunk_parser->getVisibleLinesMask()); $this->hunkStartLines = $hunk_parser->getHunkStartLines( $changeset->getHunks()); $new_corpus = $hunk_parser->getNewCorpus(); $new_corpus_block = implode('', $new_corpus); $this->markGenerated($new_corpus_block); if ($this->isTopLevel && !$this->comments && ($this->isGenerated() || $this->isUnchanged() || $this->isDeleted())) { return; } $old_corpus = $hunk_parser->getOldCorpus(); $old_corpus_block = implode('', $old_corpus); $old_future = $this->getHighlightFuture($old_corpus_block); $new_future = $this->getHighlightFuture($new_corpus_block); $futures = array( 'old' => $old_future, 'new' => $new_future, ); $corpus_blocks = array( 'old' => $old_corpus_block, 'new' => $new_corpus_block, ); $this->highlightErrors = false; foreach (new FutureIterator($futures) as $key => $future) { try { try { $highlighted = $future->resolve(); } catch (PhutilSyntaxHighlighterException $ex) { $this->highlightErrors = true; $highlighted = id(new PhutilDefaultSyntaxHighlighter()) ->getHighlightFuture($corpus_blocks[$key]) ->resolve(); } switch ($key) { case 'old': $this->oldRender = $this->processHighlightedSource( $this->old, $highlighted); break; case 'new': $this->newRender = $this->processHighlightedSource( $this->new, $highlighted); break; } } catch (Exception $ex) { phlog($ex); throw $ex; } } $this->applyIntraline( $this->oldRender, ipull($this->intra, 0), $old_corpus); $this->applyIntraline( $this->newRender, ipull($this->intra, 1), $new_corpus); } private function shouldRenderPropertyChangeHeader($changeset) { if (!$this->isTopLevel) { // We render properties only at top level; otherwise we get multiple // copies of them when a user clicks "Show More". return false; } return true; } public function render( $range_start = null, $range_len = null, $mask_force = array()) { // "Top level" renders are initial requests for the whole file, versus // requests for a specific range generated by clicking "show more". We // generate property changes and "shield" UI elements only for toplevel // requests. $this->isTopLevel = (($range_start === null) && ($range_len === null)); $this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine(); $encoding = null; if ($this->characterEncoding) { // We are forcing this changeset to be interpreted with a specific // character encoding, so force all the hunks into that encoding and // propagate it to the renderer. $encoding = $this->characterEncoding; foreach ($this->changeset->getHunks() as $hunk) { $hunk->forceEncoding($this->characterEncoding); } } else { // We're just using the default, so tell the renderer what that is // (by reading the encoding from the first hunk). foreach ($this->changeset->getHunks() as $hunk) { $encoding = $hunk->getDataEncoding(); break; } } $this->tryCacheStuff(); // If we're rendering in an offset mode, treat the range numbers as line // numbers instead of rendering offsets. $offset_mode = $this->getOffsetMode(); if ($offset_mode) { if ($offset_mode == 'new') { $offset_map = $this->new; } else { $offset_map = $this->old; } $range_end = $this->getOffset($offset_map, $range_start + $range_len); $range_start = $this->getOffset($offset_map, $range_start); $range_len = ($range_end - $range_start); } $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset); $rows = max( count($this->old), count($this->new)); $renderer = $this->getRenderer() ->setUser($this->getUser()) ->setChangeset($this->changeset) ->setRenderPropertyChangeHeader($render_pch) ->setIsTopLevel($this->isTopLevel) ->setOldRender($this->oldRender) ->setNewRender($this->newRender) ->setHunkStartLines($this->hunkStartLines) ->setOldChangesetID($this->leftSideChangesetID) ->setNewChangesetID($this->rightSideChangesetID) ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile) ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile) ->setCodeCoverage($this->getCoverage()) ->setRenderingReference($this->getRenderingReference()) ->setMarkupEngine($this->markupEngine) ->setHandles($this->handles) ->setOldLines($this->old) ->setNewLines($this->new) ->setOriginalCharacterEncoding($encoding) ->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks()) ->setCanMarkDone($this->getCanMarkDone()) ->setObjectOwnerPHID($this->getObjectOwnerPHID()) ->setHighlightingDisabled($this->highlightingDisabled); $shield = null; if ($this->isTopLevel && !$this->comments) { if ($this->isGenerated()) { $shield = $renderer->renderShield( pht( 'This file contains generated code, which does not normally '. 'need to be reviewed.')); } else if ($this->isMoveAway()) { // We put an empty shield on these files. Normally, they do not have // any diff content anyway. However, if they come through `arc`, they // may have content. We don't want to show it (it's not useful) and // we bailed out of fully processing it earlier anyway. // We could show a message like "this file was moved", but we show // that as a change header anyway, so it would be redundant. Instead, // just render an empty shield to skip rendering the diff body. $shield = ''; } else if ($this->isUnchanged()) { $type = 'text'; if (!$rows) { // NOTE: Normally, diffs which don't change files do not include // file content (for example, if you "chmod +x" a file and then // run "git show", the file content is not available). Similarly, // if you move a file from A to B without changing it, diffs normally // do not show the file content. In some cases `arc` is able to // synthetically generate content for these diffs, but for raw diffs // we'll never have it so we need to be prepared to not render a link. $type = 'none'; } $type_add = DifferentialChangeType::TYPE_ADD; if ($this->changeset->getChangeType() == $type_add) { // Although the generic message is sort of accurate in a technical // sense, this more-tailored message is less confusing. $shield = $renderer->renderShield( pht('This is an empty file.'), $type); } else { $shield = $renderer->renderShield( pht('The contents of this file were not changed.'), $type); } } else if ($this->isWhitespaceOnly()) { $shield = $renderer->renderShield( pht('This file was changed only by adding or removing whitespace.'), 'whitespace'); } else if ($this->isDeleted()) { $shield = $renderer->renderShield( pht('This file was completely deleted.')); } else if ($this->changeset->getAffectedLineCount() > 2500) { $shield = $renderer->renderShield( pht( 'This file has a very large number of changes (%s lines).', new PhutilNumber($this->changeset->getAffectedLineCount()))); } } if ($shield !== null) { return $renderer->renderChangesetTable($shield); } // This request should render the "undershield" headers if it's a top-level // request which made it this far (indicating the changeset has no shield) // or it's a request with no mask information (indicating it's the request // that removes the rendering shield). Possibly, this second class of // request might need to be made more explicit. $is_undershield = (empty($mask_force) || $this->isTopLevel); $renderer->setIsUndershield($is_undershield); $old_comments = array(); $new_comments = array(); $old_mask = array(); $new_mask = array(); $feedback_mask = array(); $lines_context = $this->getLinesOfContext(); if ($this->comments) { // If there are any comments which appear in sections of the file which // we don't have, we're going to move them backwards to the closest // earlier line. Two cases where this may happen are: // // - Porting ghost comments forward into a file which was mostly // deleted. // - Porting ghost comments forward from a full-context diff to a // partial-context diff. list($old_backmap, $new_backmap) = $this->buildLineBackmaps(); foreach ($this->comments as $comment) { $new_side = $this->isCommentOnRightSideWhenDisplayed($comment); $line = $comment->getLineNumber(); if ($new_side) { $back_line = $new_backmap[$line]; } else { $back_line = $old_backmap[$line]; } if ($back_line != $line) { // TODO: This should probably be cleaner, but just be simple and // obvious for now. $ghost = $comment->getIsGhost(); if ($ghost) { $moved = pht( 'This comment originally appeared on line %s, but that line '. 'does not exist in this version of the diff. It has been '. 'moved backward to the nearest line.', new PhutilNumber($line)); $ghost['reason'] = $ghost['reason']."\n\n".$moved; $comment->setIsGhost($ghost); } $comment->setLineNumber($back_line); $comment->setLineLength(0); } $start = max($comment->getLineNumber() - $lines_context, 0); $end = $comment->getLineNumber() + $comment->getLineLength() + $lines_context; for ($ii = $start; $ii <= $end; $ii++) { if ($new_side) { $new_mask[$ii] = true; } else { $old_mask[$ii] = true; } } } foreach ($this->old as $ii => $old) { if (isset($old['line']) && isset($old_mask[$old['line']])) { $feedback_mask[$ii] = true; } } foreach ($this->new as $ii => $new) { if (isset($new['line']) && isset($new_mask[$new['line']])) { $feedback_mask[$ii] = true; } } $this->comments = id(new PHUIDiffInlineThreader()) ->reorderAndThreadCommments($this->comments); foreach ($this->comments as $comment) { $final = $comment->getLineNumber() + $comment->getLineLength(); $final = max(1, $final); if ($this->isCommentOnRightSideWhenDisplayed($comment)) { $new_comments[$final][] = $comment; } else { $old_comments[$final][] = $comment; } } } $renderer ->setOldComments($old_comments) ->setNewComments($new_comments); switch ($this->changeset->getFileType()) { case DifferentialChangeType::FILE_IMAGE: $old = null; $new = null; // TODO: Improve the architectural issue as discussed in D955 // https://secure.phabricator.com/D955 $reference = $this->getRenderingReference(); $parts = explode('/', $reference); if (count($parts) == 2) { list($id, $vs) = $parts; } else { $id = $parts[0]; $vs = 0; } $id = (int)$id; $vs = (int)$vs; if (!$vs) { $metadata = $this->changeset->getMetadata(); $data = idx($metadata, 'attachment-data'); $old_phid = idx($metadata, 'old:binary-phid'); $new_phid = idx($metadata, 'new:binary-phid'); } else { $vs_changeset = id(new DifferentialChangeset())->load($vs); $old_phid = null; $new_phid = null; // TODO: This is spooky, see D6851 if ($vs_changeset) { $vs_metadata = $vs_changeset->getMetadata(); $old_phid = idx($vs_metadata, 'new:binary-phid'); } $changeset = id(new DifferentialChangeset())->load($id); if ($changeset) { $metadata = $changeset->getMetadata(); $new_phid = idx($metadata, 'new:binary-phid'); } } if ($old_phid || $new_phid) { // grab the files, (micro) optimization for 1 query not 2 $file_phids = array(); if ($old_phid) { $file_phids[] = $old_phid; } if ($new_phid) { $file_phids[] = $new_phid; } $files = id(new PhabricatorFileQuery()) ->setViewer($this->getUser()) ->withPHIDs($file_phids) ->execute(); foreach ($files as $file) { if (empty($file)) { continue; } if ($file->getPHID() == $old_phid) { $old = $file; } else if ($file->getPHID() == $new_phid) { $new = $file; } } } $renderer->attachOldFile($old); $renderer->attachNewFile($new); return $renderer->renderFileChange($old, $new, $id, $vs); case DifferentialChangeType::FILE_DIRECTORY: case DifferentialChangeType::FILE_BINARY: $output = $renderer->renderChangesetTable(null); return $output; } if ($this->originalLeft && $this->originalRight) { list($highlight_old, $highlight_new) = $this->diffOriginals(); $highlight_old = array_flip($highlight_old); $highlight_new = array_flip($highlight_new); $renderer ->setHighlightOld($highlight_old) ->setHighlightNew($highlight_new); } $renderer ->setOriginalOld($this->originalLeft) ->setOriginalNew($this->originalRight); if ($range_start === null) { $range_start = 0; } if ($range_len === null) { $range_len = $rows; } $range_len = min($range_len, $rows - $range_start); list($gaps, $mask, $depths) = $this->calculateGapsMaskAndDepths( $mask_force, $feedback_mask, $range_start, $range_len); $renderer ->setGaps($gaps) ->setMask($mask) ->setDepths($depths); $html = $renderer->renderTextChange( $range_start, $range_len, $rows); return $renderer->renderChangesetTable($html); } /** * This function calculates a lot of stuff we need to know to display * the diff: * * Gaps - compute gaps in the visible display diff, where we will render * "Show more context" spacers. If a gap is smaller than the context size, * we just display it. Otherwise, we record it into $gaps and will render a * "show more context" element instead of diff text below. A given $gap * is a tuple of $gap_line_number_start and $gap_length. * * Mask - compute the actual lines that need to be shown (because they * are near changes lines, near inline comments, or the request has * explicitly asked for them, i.e. resulting from the user clicking - * "show more"). The $mask returned is a sparesely populated dictionary + * "show more"). The $mask returned is a sparsely populated dictionary * of $visible_line_number => true. * * Depths - compute how indented any given line is. The $depths returned - * is a sparesely populated dictionary of $visible_line_number => $depth. + * is a sparsely populated dictionary of $visible_line_number => $depth. * * This function also has the side effect of modifying member variable * new such that tabs are normalized to spaces for each line of the diff. * * @return array($gaps, $mask, $depths) */ private function calculateGapsMaskAndDepths( $mask_force, $feedback_mask, $range_start, $range_len) { $lines_context = $this->getLinesOfContext(); // Calculate gaps and mask first $gaps = array(); $gap_start = 0; $in_gap = false; $base_mask = $this->visible + $mask_force + $feedback_mask; $base_mask[$range_start + $range_len] = true; for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) { if (isset($base_mask[$ii])) { if ($in_gap) { $gap_length = $ii - $gap_start; if ($gap_length <= $lines_context) { for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) { $base_mask[$jj] = true; } } else { $gaps[] = array($gap_start, $gap_length); } $in_gap = false; } } else { if (!$in_gap) { $gap_start = $ii; $in_gap = true; } } } $gaps = array_reverse($gaps); $mask = $base_mask; // Time to calculate depth. // We need to go backwards to properly indent whitespace in this code: // // 0: class C { // 1: // 1: function f() { // 2: // 2: return; // 1: // 1: } // 0: // 0: } // $depths = array(); $last_depth = 0; $range_end = $range_start + $range_len; if (!isset($this->new[$range_end])) { $range_end--; } for ($ii = $range_end; $ii >= $range_start; $ii--) { // We need to expand tabs to process mixed indenting and to round // correctly later. $line = str_replace("\t", ' ', $this->new[$ii]['text']); $trimmed = ltrim($line); if ($trimmed != '') { // We round down to flatten "/**" and " *". $last_depth = floor((strlen($line) - strlen($trimmed)) / 2); } $depths[$ii] = $last_depth; } return array($gaps, $mask, $depths); } /** * Determine if an inline comment will appear on the rendered diff, * taking into consideration which halves of which changesets will actually * be shown. * * @param PhabricatorInlineCommentInterface Comment to test for visibility. * @return bool True if the comment is visible on the rendered diff. */ private function isCommentVisibleOnRenderedDiff( PhabricatorInlineCommentInterface $comment) { $changeset_id = $comment->getChangesetID(); $is_new = $comment->getIsNewFile(); if ($changeset_id == $this->rightSideChangesetID && $is_new == $this->rightSideAttachesToNewFile) { return true; } if ($changeset_id == $this->leftSideChangesetID && $is_new == $this->leftSideAttachesToNewFile) { return true; } return false; } /** * Determine if a comment will appear on the right side of the display diff. * Note that the comment must appear somewhere on the rendered changeset, as * per isCommentVisibleOnRenderedDiff(). * * @param PhabricatorInlineCommentInterface Comment to test for display * location. * @return bool True for right, false for left. */ private function isCommentOnRightSideWhenDisplayed( PhabricatorInlineCommentInterface $comment) { if (!$this->isCommentVisibleOnRenderedDiff($comment)) { throw new Exception(pht('Comment is not visible on changeset!')); } $changeset_id = $comment->getChangesetID(); $is_new = $comment->getIsNewFile(); if ($changeset_id == $this->rightSideChangesetID && $is_new == $this->rightSideAttachesToNewFile) { return true; } return false; } /** * Parse the 'range' specification that this class and the client-side JS * emit to indicate that a user clicked "Show more..." on a diff. Generally, * use is something like this: * * $spec = $request->getStr('range'); * $parsed = DifferentialChangesetParser::parseRangeSpecification($spec); * list($start, $end, $mask) = $parsed; * $parser->render($start, $end, $mask); * * @param string Range specification, indicating the range of the diff that * should be rendered. * @return tuple List of suitable for passing to * @{method:render}. */ public static function parseRangeSpecification($spec) { $range_s = null; $range_e = null; $mask = array(); if ($spec) { $match = null; if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) { $range_s = (int)$match[1]; $range_e = (int)$match[2]; if (count($match) > 3) { $start = (int)$match[3]; $len = (int)$match[4]; for ($ii = $start; $ii < $start + $len; $ii++) { $mask[$ii] = true; } } } } return array($range_s, $range_e, $mask); } /** * Render "modified coverage" information; test coverage on modified lines. * This synthesizes diff information with unit test information into a useful * indicator of how well tested a change is. */ public function renderModifiedCoverage() { $na = phutil_tag('em', array(), '-'); $coverage = $this->getCoverage(); if (!$coverage) { return $na; } $covered = 0; $not_covered = 0; foreach ($this->new as $k => $new) { if (!$new['line']) { continue; } if (!$new['type']) { continue; } if (empty($coverage[$new['line'] - 1])) { continue; } switch ($coverage[$new['line'] - 1]) { case 'C': $covered++; break; case 'U': $not_covered++; break; } } if (!$covered && !$not_covered) { return $na; } return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered))); } public function detectCopiedCode( array $changesets, $min_width = 30, $min_lines = 3) { assert_instances_of($changesets, 'DifferentialChangeset'); $map = array(); $files = array(); $types = array(); foreach ($changesets as $changeset) { $file = $changeset->getFilename(); foreach ($changeset->getHunks() as $hunk) { $lines = $hunk->getStructuredOldFile(); foreach ($lines as $line => $info) { $type = $info['type']; if ($type == '\\') { continue; } $types[$file][$line] = $type; $text = $info['text']; $text = trim($text); $files[$file][$line] = $text; if (strlen($text) >= $min_width) { $map[$text][] = array($file, $line); } } } } foreach ($changesets as $changeset) { $copies = array(); foreach ($changeset->getHunks() as $hunk) { $added = $hunk->getStructuredNewFile(); $atype = array(); foreach ($added as $line => $info) { $atype[$line] = $info['type']; $added[$line] = trim($info['text']); } $skip_lines = 0; foreach ($added as $line => $code) { if ($skip_lines) { // We're skipping lines that we already processed because we // extended a block above them downward to include them. $skip_lines--; continue; } if ($atype[$line] !== '+') { // This line hasn't been changed in the new file, so don't try // to figure out where it came from. continue; } if (empty($map[$code])) { // This line was too short to trigger copy/move detection. continue; } if (count($map[$code]) > 16) { // If there are a large number of identical lines in this diff, // don't try to figure out where this block came from: the analysis // is O(N^2), since we need to compare every line against every // other line. Even if we arrive at a result, it is unlikely to be // meaningful. See T5041. continue; } $best_length = 0; // Explore all candidates. foreach ($map[$code] as $val) { list($file, $orig_line) = $val; $length = 1; // Search backward and forward to find all of the adjacent lines // which match. foreach (array(-1, 1) as $direction) { $offset = $direction; while (true) { if (isset($copies[$line + $offset])) { // If we run into a block above us which we've already // attributed to a move or copy from elsewhere, stop // looking. break; } if (!isset($added[$line + $offset])) { // If we've run off the beginning or end of the new file, // stop looking. break; } if (!isset($files[$file][$orig_line + $offset])) { // If we've run off the beginning or end of the original // file, we also stop looking. break; } $old = $files[$file][$orig_line + $offset]; $new = $added[$line + $offset]; if ($old !== $new) { // If the old line doesn't match the new line, stop // looking. break; } $length++; $offset += $direction; } } if ($length < $best_length) { // If we already know of a better source (more matching lines) // for this move/copy, stick with that one. We prefer long // copies/moves which match a lot of context over short ones. continue; } if ($length == $best_length) { if (idx($types[$file], $orig_line) != '-') { // If we already know of an equally good source (same number // of matching lines) and this isn't a move, stick with the // other one. We prefer moves over copies. continue; } } $best_length = $length; // ($offset - 1) contains number of forward matching lines. $best_offset = $offset - 1; $best_file = $file; $best_line = $orig_line; } $file = ($best_file == $changeset->getFilename() ? '' : $best_file); for ($i = $best_length; $i--; ) { $type = idx($types[$best_file], $best_line + $best_offset - $i); $copies[$line + $best_offset - $i] = ($best_length < $min_lines ? array() // Ignore short blocks. : array($file, $best_line + $best_offset - $i, $type)); } $skip_lines = $best_offset; } } $copies = array_filter($copies); if ($copies) { $metadata = $changeset->getMetadata(); $metadata['copy:lines'] = $copies; $changeset->setMetadata($metadata); } } return $changesets; } /** * Build maps from lines comments appear on to actual lines. */ private function buildLineBackmaps() { $old_back = array(); $new_back = array(); foreach ($this->old as $ii => $old) { $old_back[$old['line']] = $old['line']; } foreach ($this->new as $ii => $new) { $new_back[$new['line']] = $new['line']; } $max_old_line = 0; $max_new_line = 0; foreach ($this->comments as $comment) { if ($this->isCommentOnRightSideWhenDisplayed($comment)) { $max_new_line = max($max_new_line, $comment->getLineNumber()); } else { $max_old_line = max($max_old_line, $comment->getLineNumber()); } } $cursor = 1; for ($ii = 1; $ii <= $max_old_line; $ii++) { if (empty($old_back[$ii])) { $old_back[$ii] = $cursor; } else { $cursor = $old_back[$ii]; } } $cursor = 1; for ($ii = 1; $ii <= $max_new_line; $ii++) { if (empty($new_back[$ii])) { $new_back[$ii] = $cursor; } else { $cursor = $new_back[$ii]; } } return array($old_back, $new_back); } private function getOffset(array $map, $line) { if (!$map) { return null; } $line = (int)$line; foreach ($map as $key => $spec) { if ($spec && isset($spec['line'])) { if ((int)$spec['line'] >= $line) { return $key; } } } return $key; } } diff --git a/src/applications/differential/storage/DifferentialModernHunk.php b/src/applications/differential/storage/DifferentialModernHunk.php index cde6f29329..c3675c8adf 100644 --- a/src/applications/differential/storage/DifferentialModernHunk.php +++ b/src/applications/differential/storage/DifferentialModernHunk.php @@ -1,249 +1,249 @@ array( 'data' => true, ), self::CONFIG_COLUMN_SCHEMA => array( 'dataType' => 'bytes4', 'dataEncoding' => 'text16?', 'dataFormat' => 'bytes4', 'oldOffset' => 'uint32', 'oldLen' => 'uint32', 'newOffset' => 'uint32', 'newLen' => 'uint32', ), self::CONFIG_KEY_SCHEMA => array( 'key_changeset' => array( 'columns' => array('changesetID'), ), 'key_created' => array( 'columns' => array('dateCreated'), ), ), ) + parent::getConfiguration(); } public function setChanges($text) { $this->rawData = $text; $this->dataEncoding = $this->detectEncodingForStorage($text); $this->dataType = self::DATATYPE_TEXT; list($format, $data) = $this->formatDataForStorage($text); $this->dataFormat = $format; $this->data = $data; return $this; } public function getChanges() { return $this->getUTF8StringFromStorage( $this->getRawData(), nonempty($this->forcedEncoding, $this->getDataEncoding())); } public function forceEncoding($encoding) { $this->forcedEncoding = $encoding; return $this; } private function formatDataForStorage($data) { $deflated = PhabricatorCaches::maybeDeflateData($data); if ($deflated !== null) { return array(self::DATAFORMAT_DEFLATED, $deflated); } return array(self::DATAFORMAT_RAW, $data); } public function saveAsText() { $old_type = $this->getDataType(); $old_data = $this->getData(); if ($old_type == self::DATATYPE_TEXT) { return $this; } $raw_data = $this->getRawData(); $this->setDataType(self::DATATYPE_TEXT); list($format, $data) = $this->formatDataForStorage($raw_data); $this->setDataFormat($format); $this->setData($data); $result = $this->save(); $this->destroyData($old_type, $old_data); return $result; } public function saveAsFile() { $old_type = $this->getDataType(); $old_data = $this->getData(); if ($old_type == self::DATATYPE_FILE) { return $this; } $raw_data = $this->getRawData(); list($format, $data) = $this->formatDataForStorage($raw_data); $this->setDataFormat($format); $file = PhabricatorFile::newFromFileData( $data, array( 'name' => 'differential-hunk', 'mime-type' => 'application/octet-stream', 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE, )); $this->setDataType(self::DATATYPE_FILE); $this->setData($file->getPHID()); // NOTE: Because hunks don't have a PHID and we just load hunk data with - // the ominipotent viewer, we do not need to attach the file to anything. + // the omnipotent viewer, we do not need to attach the file to anything. $result = $this->save(); $this->destroyData($old_type, $old_data); return $result; } private function getRawData() { if ($this->rawData === null) { $type = $this->getDataType(); $data = $this->getData(); switch ($type) { case self::DATATYPE_TEXT: // In this storage type, the changes are stored on the object. $data = $data; break; case self::DATATYPE_FILE: $data = $this->loadFileData(); break; default: throw new Exception( pht('Hunk has unsupported data type "%s"!', $type)); } $format = $this->getDataFormat(); switch ($format) { case self::DATAFORMAT_RAW: // In this format, the changes are stored as-is. $data = $data; break; case self::DATAFORMAT_DEFLATED: $data = PhabricatorCaches::inflateData($data); break; default: throw new Exception( pht('Hunk has unsupported data encoding "%s"!', $type)); } $this->rawData = $data; } return $this->rawData; } private function loadFileData() { if ($this->fileData === null) { $type = $this->getDataType(); if ($type !== self::DATATYPE_FILE) { throw new Exception( pht( 'Unable to load file data for hunk with wrong data type ("%s").', $type)); } $file_phid = $this->getData(); $file = $this->loadRawFile($file_phid); $data = $file->loadFileData(); $this->fileData = $data; } return $this->fileData; } private function loadRawFile($file_phid) { $viewer = PhabricatorUser::getOmnipotentUser(); $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($file_phid)) ->execute(); if (!$files) { throw new Exception( pht( 'Failed to load file ("%s") with hunk data.', $file_phid)); } $file = head($files); return $file; } public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $type = $this->getDataType(); $data = $this->getData(); $this->destroyData($type, $data, $engine); return parent::destroyObjectPermanently($engine); } private function destroyData( $type, $data, PhabricatorDestructionEngine $engine = null) { if (!$engine) { $engine = new PhabricatorDestructionEngine(); } switch ($type) { case self::DATATYPE_FILE: $file = $this->loadRawFile($data); $engine->destroyObject($file); break; } } } diff --git a/src/applications/differential/storage/DifferentialRevision.php b/src/applications/differential/storage/DifferentialRevision.php index 7bcd178a41..e29db36b14 100644 --- a/src/applications/differential/storage/DifferentialRevision.php +++ b/src/applications/differential/storage/DifferentialRevision.php @@ -1,975 +1,975 @@ setViewer($actor) ->withClasses(array('PhabricatorDifferentialApplication')) ->executeOne(); $view_policy = $app->getPolicy( DifferentialDefaultViewCapability::CAPABILITY); return id(new DifferentialRevision()) ->setViewPolicy($view_policy) ->setAuthorPHID($actor->getPHID()) ->attachRepository(null) ->attachActiveDiff(null) ->attachReviewers(array()) ->setModernRevisionStatus(DifferentialRevisionStatus::NEEDS_REVIEW); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'attached' => self::SERIALIZATION_JSON, 'unsubscribed' => self::SERIALIZATION_JSON, 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'title' => 'text255', 'originalTitle' => 'text255', 'status' => 'text32', 'summary' => 'text', 'testPlan' => 'text', 'authorPHID' => 'phid?', 'lastReviewerPHID' => 'phid?', 'lineCount' => 'uint32?', 'mailKey' => 'bytes40', 'branchName' => 'text255?', 'repositoryPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'authorPHID' => array( 'columns' => array('authorPHID', 'status'), ), 'repositoryPHID' => array( 'columns' => array('repositoryPHID'), ), // If you (or a project you are a member of) is reviewing a significant // fraction of the revisions on an install, the result set of open // revisions may be smaller than the result set of revisions where you // are a reviewer. In these cases, this key is better than keys on the // edge table. 'key_status' => array( 'columns' => array('status', 'phid'), ), ), ) + parent::getConfiguration(); } public function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function getProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function hasRevisionProperty($key) { return array_key_exists($key, $this->properties); } public function getMonogram() { $id = $this->getID(); return "D{$id}"; } public function getURI() { return '/'.$this->getMonogram(); } public function setTitle($title) { $this->title = $title; if (!$this->getID()) { $this->originalTitle = $title; } return $this; } public function loadIDsByCommitPHIDs($phids) { if (!$phids) { return array(); } $revision_ids = queryfx_all( $this->establishConnection('r'), 'SELECT * FROM %T WHERE commitPHID IN (%Ls)', self::TABLE_COMMIT, $phids); return ipull($revision_ids, 'revisionID', 'commitPHID'); } public function loadCommitPHIDs() { if (!$this->getID()) { return ($this->commits = array()); } $commits = queryfx_all( $this->establishConnection('r'), 'SELECT commitPHID FROM %T WHERE revisionID = %d', self::TABLE_COMMIT, $this->getID()); $commits = ipull($commits, 'commitPHID'); return ($this->commits = $commits); } public function getCommitPHIDs() { return $this->assertAttached($this->commits); } public function getActiveDiff() { // TODO: Because it's currently technically possible to create a revision // without an associated diff, we allow an attached-but-null active diff. // It would be good to get rid of this once we make diff-attaching // transactional. return $this->assertAttached($this->activeDiff); } public function attachActiveDiff($diff) { $this->activeDiff = $diff; return $this; } public function getDiffIDs() { return $this->assertAttached($this->diffIDs); } public function attachDiffIDs(array $ids) { rsort($ids); $this->diffIDs = array_values($ids); return $this; } public function attachCommitPHIDs(array $phids) { $this->commits = array_values($phids); return $this; } public function getAttachedPHIDs($type) { return array_keys(idx($this->attached, $type, array())); } public function setAttachedPHIDs($type, array $phids) { $this->attached[$type] = array_fill_keys($phids, array()); return $this; } public function generatePHID() { return PhabricatorPHID::generateNewPHID( DifferentialRevisionPHIDType::TYPECONST); } public function loadActiveDiff() { return id(new DifferentialDiff())->loadOneWhere( 'revisionID = %d ORDER BY id DESC LIMIT 1', $this->getID()); } public function save() { if (!$this->getMailKey()) { $this->mailKey = Filesystem::readRandomCharacters(40); } return parent::save(); } public function getHashes() { return $this->assertAttached($this->hashes); } public function attachHashes(array $hashes) { $this->hashes = $hashes; return $this; } public function canReviewerForceAccept( PhabricatorUser $viewer, DifferentialReviewer $reviewer) { if (!$reviewer->isPackage()) { return false; } $map = $this->getReviewerForceAcceptMap($viewer); if (!$map) { return false; } if (isset($map[$reviewer->getReviewerPHID()])) { return true; } return false; } private function getReviewerForceAcceptMap(PhabricatorUser $viewer) { $fragment = $viewer->getCacheFragment(); if (!array_key_exists($fragment, $this->forceMap)) { $map = $this->newReviewerForceAcceptMap($viewer); $this->forceMap[$fragment] = $map; } return $this->forceMap[$fragment]; } private function newReviewerForceAcceptMap(PhabricatorUser $viewer) { $diff = $this->getActiveDiff(); if (!$diff) { return null; } $repository_phid = $diff->getRepositoryPHID(); if (!$repository_phid) { return null; } $paths = array(); try { $changesets = $diff->getChangesets(); } catch (Exception $ex) { $changesets = id(new DifferentialChangesetQuery()) ->setViewer($viewer) ->withDiffs(array($diff)) ->execute(); } foreach ($changesets as $changeset) { $paths[] = $changeset->getOwnersFilename(); } if (!$paths) { return null; } $reviewer_phids = array(); foreach ($this->getReviewers() as $reviewer) { if (!$reviewer->isPackage()) { continue; } $reviewer_phids[] = $reviewer->getReviewerPHID(); } if (!$reviewer_phids) { return null; } // Load all the reviewing packages which have control over some of the // paths in the change. These are packages which the actor may be able // to force-accept on behalf of. $control_query = id(new PhabricatorOwnersPackageQuery()) ->setViewer($viewer) ->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE)) ->withPHIDs($reviewer_phids) ->withControl($repository_phid, $paths); $control_packages = $control_query->execute(); if (!$control_packages) { return null; } // Load all the packages which have potential control over some of the // paths in the change and are owned by the actor. These are packages // which the actor may be able to use their authority over to gain the // ability to force-accept for other packages. This query doesn't apply // dominion rules yet, and we'll bypass those rules later on. $authority_query = id(new PhabricatorOwnersPackageQuery()) ->setViewer($viewer) ->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE)) ->withAuthorityPHIDs(array($viewer->getPHID())) ->withControl($repository_phid, $paths); $authority_packages = $authority_query->execute(); if (!$authority_packages) { return null; } $authority_packages = mpull($authority_packages, null, 'getPHID'); // Build a map from each path in the revision to the reviewer packages // which control it. $control_map = array(); foreach ($paths as $path) { $control_packages = $control_query->getControllingPackagesForPath( $repository_phid, $path); // Remove packages which the viewer has authority over. We don't need // to check these for force-accept because they can just accept them // normally. $control_packages = mpull($control_packages, null, 'getPHID'); foreach ($control_packages as $phid => $control_package) { if (isset($authority_packages[$phid])) { unset($control_packages[$phid]); } } if (!$control_packages) { continue; } $control_map[$path] = $control_packages; } if (!$control_map) { return null; } // From here on out, we only care about paths which we have at least one // controlling package for. $paths = array_keys($control_map); // Now, build a map from each path to the packages which would control it // if there were no dominion rules. $authority_map = array(); foreach ($paths as $path) { $authority_packages = $authority_query->getControllingPackagesForPath( $repository_phid, $path, $ignore_dominion = true); $authority_map[$path] = mpull($authority_packages, null, 'getPHID'); } // For each path, find the most general package that the viewer has // authority over. For example, we'll prefer a package that owns "/" to a // package that owns "/src/". $force_map = array(); foreach ($authority_map as $path => $package_map) { $path_fragments = PhabricatorOwnersPackage::splitPath($path); $fragment_count = count($path_fragments); // Find the package that we have authority over which has the most // general match for this path. $best_match = null; $best_package = null; foreach ($package_map as $package_phid => $package) { $package_paths = $package->getPathsForRepository($repository_phid); foreach ($package_paths as $package_path) { // NOTE: A strength of 0 means "no match". A strength of 1 means // that we matched "/", so we can not possibly find another stronger // match. $strength = $package_path->getPathMatchStrength( $path_fragments, $fragment_count); if (!$strength) { continue; } if ($strength < $best_match || !$best_package) { $best_match = $strength; $best_package = $package; if ($strength == 1) { break 2; } } } } if ($best_package) { $force_map[$path] = array( 'strength' => $best_match, 'package' => $best_package, ); } } // For each path which the viewer owns a package for, find other packages // which that authority can be used to force-accept. Once we find a way to - // force-accept a package, we don't need to keep loooking. + // force-accept a package, we don't need to keep looking. $has_control = array(); foreach ($force_map as $path => $spec) { $path_fragments = PhabricatorOwnersPackage::splitPath($path); $fragment_count = count($path_fragments); $authority_strength = $spec['strength']; $control_packages = $control_map[$path]; foreach ($control_packages as $control_phid => $control_package) { if (isset($has_control[$control_phid])) { continue; } $control_paths = $control_package->getPathsForRepository( $repository_phid); foreach ($control_paths as $control_path) { $strength = $control_path->getPathMatchStrength( $path_fragments, $fragment_count); if (!$strength) { continue; } if ($strength > $authority_strength) { $authority = $spec['package']; $has_control[$control_phid] = array( 'authority' => $authority, 'phid' => $authority->getPHID(), ); break; } } } } // Return a map from packages which may be force accepted to the packages // which permit that forced acceptance. return ipull($has_control, 'phid'); } /* -( 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 $user) { // A revision's author (which effectively means "owner" after we added // commandeering) can always view and edit it. $author_phid = $this->getAuthorPHID(); if ($author_phid) { if ($user->getPHID() == $author_phid) { return true; } } return false; } public function describeAutomaticCapability($capability) { $description = array( pht('The owner of a revision can always view and edit it.'), ); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $description[] = pht( 'If a revision belongs to a repository, other users must be able '. 'to view the repository in order to view the revision.'); break; } return $description; } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $repository_phid = $this->getRepositoryPHID(); $repository = $this->getRepository(); // Try to use the object if we have it, since it will save us some // data fetching later on. In some cases, we might not have it. $repository_ref = nonempty($repository, $repository_phid); if ($repository_ref) { $extended[] = array( $repository_ref, PhabricatorPolicyCapability::CAN_VIEW, ); } break; } return $extended; } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array( $this->getAuthorPHID(), ); } public function getReviewers() { return $this->assertAttached($this->reviewerStatus); } public function attachReviewers(array $reviewers) { assert_instances_of($reviewers, 'DifferentialReviewer'); $reviewers = mpull($reviewers, null, 'getReviewerPHID'); $this->reviewerStatus = $reviewers; return $this; } public function getReviewerPHIDs() { $reviewers = $this->getReviewers(); return mpull($reviewers, 'getReviewerPHID'); } public function getReviewerPHIDsForEdit() { $reviewers = $this->getReviewers(); $status_blocking = DifferentialReviewerStatus::STATUS_BLOCKING; $value = array(); foreach ($reviewers as $reviewer) { $phid = $reviewer->getReviewerPHID(); if ($reviewer->getReviewerStatus() == $status_blocking) { $value[] = 'blocking('.$phid.')'; } else { $value[] = $phid; } } return $value; } public function getRepository() { return $this->assertAttached($this->repository); } public function attachRepository(PhabricatorRepository $repository = null) { $this->repository = $repository; return $this; } public function setModernRevisionStatus($status) { return $this->setStatus($status); } public function getModernRevisionStatus() { return $this->getStatus(); } public function getLegacyRevisionStatus() { return $this->getStatusObject()->getLegacyKey(); } public function isClosed() { return $this->getStatusObject()->isClosedStatus(); } public function isAbandoned() { return $this->getStatusObject()->isAbandoned(); } public function isAccepted() { return $this->getStatusObject()->isAccepted(); } public function isNeedsReview() { return $this->getStatusObject()->isNeedsReview(); } public function isNeedsRevision() { return $this->getStatusObject()->isNeedsRevision(); } public function isChangePlanned() { return $this->getStatusObject()->isChangePlanned(); } public function isPublished() { return $this->getStatusObject()->isPublished(); } public function isDraft() { return $this->getStatusObject()->isDraft(); } public function getStatusIcon() { return $this->getStatusObject()->getIcon(); } public function getStatusDisplayName() { return $this->getStatusObject()->getDisplayName(); } public function getStatusIconColor() { return $this->getStatusObject()->getIconColor(); } public function getStatusObject() { $status = $this->getStatus(); return DifferentialRevisionStatus::newForStatus($status); } public function getFlag(PhabricatorUser $viewer) { return $this->assertAttachedKey($this->flags, $viewer->getPHID()); } public function attachFlag( PhabricatorUser $viewer, PhabricatorFlag $flag = null) { $this->flags[$viewer->getPHID()] = $flag; return $this; } public function getHasDraft(PhabricatorUser $viewer) { return $this->assertAttachedKey($this->drafts, $viewer->getCacheFragment()); } public function attachHasDraft(PhabricatorUser $viewer, $has_draft) { $this->drafts[$viewer->getCacheFragment()] = $has_draft; return $this; } public function shouldBroadcast() { if (!$this->isDraft()) { return true; } return false; } /* -( HarbormasterBuildableInterface )------------------------------------- */ public function getHarbormasterBuildableDisplayPHID() { return $this->getHarbormasterContainerPHID(); } public function getHarbormasterBuildablePHID() { return $this->loadActiveDiff()->getPHID(); } public function getHarbormasterContainerPHID() { return $this->getPHID(); } public function getHarbormasterPublishablePHID() { return $this->getPHID(); } public function getBuildVariables() { return array(); } public function getAvailableBuildVariables() { return array(); } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { if ($phid == $this->getAuthorPHID()) { return true; } // TODO: This only happens when adding or removing CCs, and is safe from a // policy perspective, but the subscription pathway should have some // opportunity to load this data properly. For now, this is the only case // where implicit subscription is not an intrinsic property of the object. if ($this->reviewerStatus == self::ATTACHABLE) { $reviewers = id(new DifferentialRevisionQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($this->getPHID())) ->needReviewers(true) ->executeOne() ->getReviewers(); } else { $reviewers = $this->getReviewers(); } foreach ($reviewers as $reviewer) { if ($reviewer->getReviewerPHID() == $phid) { return true; } } return false; } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('differential.fields'); } public function getCustomFieldBaseClass() { return 'DifferentialCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new DifferentialTransactionEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new DifferentialTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { $viewer = $request->getViewer(); $render_data = $timeline->getRenderData(); $left = $request->getInt('left', idx($render_data, 'left')); $right = $request->getInt('right', idx($render_data, 'right')); $diffs = id(new DifferentialDiffQuery()) ->setViewer($request->getUser()) ->withIDs(array($left, $right)) ->execute(); $diffs = mpull($diffs, null, 'getID'); $left_diff = $diffs[$left]; $right_diff = $diffs[$right]; $old_ids = $request->getStr('old', idx($render_data, 'old')); $new_ids = $request->getStr('new', idx($render_data, 'new')); $old_ids = array_filter(explode(',', $old_ids)); $new_ids = array_filter(explode(',', $new_ids)); $type_inline = DifferentialTransaction::TYPE_INLINE; $changeset_ids = array_merge($old_ids, $new_ids); $inlines = array(); foreach ($timeline->getTransactions() as $xaction) { if ($xaction->getTransactionType() == $type_inline) { $inlines[] = $xaction->getComment(); $changeset_ids[] = $xaction->getComment()->getChangesetID(); } } if ($changeset_ids) { $changesets = id(new DifferentialChangesetQuery()) ->setViewer($request->getUser()) ->withIDs($changeset_ids) ->execute(); $changesets = mpull($changesets, null, 'getID'); } else { $changesets = array(); } foreach ($inlines as $key => $inline) { $inlines[$key] = DifferentialInlineComment::newFromModernComment( $inline); } $query = id(new DifferentialInlineCommentQuery()) ->needHidden(true) ->setViewer($viewer); // NOTE: This is a bit sketchy: this method adjusts the inlines as a // side effect, which means it will ultimately adjust the transaction // comments and affect timeline rendering. $query->adjustInlinesForChangesets( $inlines, array_select_keys($changesets, $old_ids), array_select_keys($changesets, $new_ids), $this); return $timeline ->setChangesets($changesets) ->setRevision($this) ->setLeftDiff($left_diff) ->setRightDiff($right_diff); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $diffs = id(new DifferentialDiffQuery()) ->setViewer($engine->getViewer()) ->withRevisionIDs(array($this->getID())) ->execute(); foreach ($diffs as $diff) { $engine->destroyObject($diff); } $conn_w = $this->establishConnection('w'); queryfx( $conn_w, 'DELETE FROM %T WHERE revisionID = %d', self::TABLE_COMMIT, $this->getID()); - // we have to do paths a little differentally as they do not have + // we have to do paths a little differently as they do not have // an id or phid column for delete() to act on $dummy_path = new DifferentialAffectedPath(); queryfx( $conn_w, 'DELETE FROM %T WHERE revisionID = %d', $dummy_path->getTableName(), $this->getID()); $this->delete(); $this->saveTransaction(); } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new DifferentialRevisionFulltextEngine(); } /* -( PhabricatorFerretInterface )----------------------------------------- */ public function newFerretEngine() { return new DifferentialRevisionFerretEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('title') ->setType('string') ->setDescription(pht('The revision title.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('authorPHID') ->setType('phid') ->setDescription(pht('Revision author PHID.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('status') ->setType('map') ->setDescription(pht('Information about revision status.')), ); } public function getFieldValuesForConduit() { $status = $this->getStatusObject(); $status_info = array( 'value' => $status->getKey(), 'name' => $status->getDisplayName(), 'closed' => $status->isClosedStatus(), 'color.ansi' => $status->getANSIColor(), ); return array( 'title' => $this->getTitle(), 'authorPHID' => $this->getAuthorPHID(), 'status' => $status_info, ); } public function getConduitSearchAttachments() { return array( id(new DifferentialReviewersSearchEngineAttachment()) ->setAttachmentKey('reviewers'), ); } /* -( PhabricatorDraftInterface )------------------------------------------ */ public function newDraftEngine() { return new DifferentialRevisionDraftEngine(); } } diff --git a/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php b/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php index 51eb2beb9a..d23ecff47d 100644 --- a/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php +++ b/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php @@ -1,77 +1,77 @@ array( 'name' => pht('Current Viewer'), 'summary' => pht('Use the current viewing user.'), 'description' => pht( 'Show revisions the current viewer is responsible for. This '. - 'function inclues revisions the viewer is responsible for through '. + 'function includes revisions the viewer is responsible for through '. 'membership in projects and packages.'), ), ); } public function loadResults() { if ($this->getViewer()->getPHID()) { $results = array($this->renderViewerFunctionToken()); } else { $results = array(); } return $this->filterResultsAgainstTokens($results); } protected function canEvaluateFunction($function) { if (!$this->getViewer()->getPHID()) { return false; } return parent::canEvaluateFunction($function); } protected function evaluateFunction($function, array $argv_list) { $results = array(); foreach ($argv_list as $argv) { $results[] = $this->getViewer()->getPHID(); } return DifferentialResponsibleDatasource::expandResponsibleUsers( $this->getViewer(), $results); } public function renderFunctionTokens($function, array $argv_list) { $tokens = array(); foreach ($argv_list as $argv) { $tokens[] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult( $this->renderViewerFunctionToken()); } return $tokens; } private function renderViewerFunctionToken() { return $this->newFunctionResult() ->setName(pht('Current Viewer')) ->setPHID('viewer()') ->setIcon('fa-user') ->setUnique(true); } } diff --git a/src/applications/diffusion/controller/DiffusionCommitController.php b/src/applications/diffusion/controller/DiffusionCommitController.php index 38c94e8a92..d7734856f0 100644 --- a/src/applications/diffusion/controller/DiffusionCommitController.php +++ b/src/applications/diffusion/controller/DiffusionCommitController.php @@ -1,1130 +1,1130 @@ loadDiffusionContext(); if ($response) { return $response; } $drequest = $this->getDiffusionRequest(); $viewer = $request->getUser(); $repository = $drequest->getRepository(); $commit_identifier = $drequest->getCommit(); // If this page is being accessed via "/source/xyz/commit/...", redirect // to the canonical URI. $has_callsign = strlen($request->getURIData('repositoryCallsign')); $has_id = strlen($request->getURIData('repositoryID')); if (!$has_callsign && !$has_id) { $canonical_uri = $repository->getCommitURI($commit_identifier); return id(new AphrontRedirectResponse()) ->setURI($canonical_uri); } if ($request->getStr('diff')) { return $this->buildRawDiffResponse($drequest); } $commit = id(new DiffusionCommitQuery()) ->setViewer($viewer) ->withRepository($repository) ->withIdentifiers(array($commit_identifier)) ->needCommitData(true) ->needAuditRequests(true) ->executeOne(); $crumbs = $this->buildCrumbs(array( 'commit' => true, )); $crumbs->setBorder(true); if (!$commit) { if (!$this->getCommitExists()) { return new Aphront404Response(); } $error = id(new PHUIInfoView()) ->setTitle(pht('Commit Still Parsing')) ->appendChild( pht( 'Failed to load the commit because the commit has not been '. 'parsed yet.')); $title = pht('Commit Still Parsing'); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild($error); } $audit_requests = $commit->getAudits(); $commit->loadAndAttachAuditAuthority($viewer); $commit_data = $commit->getCommitData(); $is_foreign = $commit_data->getCommitDetail('foreign-svn-stub'); $error_panel = null; $hard_limit = 1000; if ($commit->isImported()) { $change_query = DiffusionPathChangeQuery::newFromDiffusionRequest( $drequest); $change_query->setLimit($hard_limit + 1); $changes = $change_query->loadChanges(); } else { $changes = array(); } $was_limited = (count($changes) > $hard_limit); if ($was_limited) { $changes = array_slice($changes, 0, $hard_limit); } $count = count($changes); $is_unreadable = false; $hint = null; if (!$count || $commit->isUnreachable()) { $hint = id(new DiffusionCommitHintQuery()) ->setViewer($viewer) ->withRepositoryPHIDs(array($repository->getPHID())) ->withOldCommitIdentifiers(array($commit->getCommitIdentifier())) ->executeOne(); if ($hint) { $is_unreadable = $hint->isUnreadable(); } } if ($is_foreign) { $subpath = $commit_data->getCommitDetail('svn-subpath'); $error_panel = new PHUIInfoView(); $error_panel->setTitle(pht('Commit Not Tracked')); $error_panel->setSeverity(PHUIInfoView::SEVERITY_WARNING); $error_panel->appendChild( pht( "This Diffusion repository is configured to track only one ". "subdirectory of the entire Subversion repository, and this commit ". "didn't affect the tracked subdirectory ('%s'), so no ". "information is available.", $subpath)); } else { $engine = PhabricatorMarkupEngine::newDifferentialMarkupEngine(); $engine->setConfig('viewer', $viewer); $commit_tag = $this->renderCommitHashTag($drequest); $header = id(new PHUIHeaderView()) ->setHeader(nonempty($commit->getSummary(), pht('Commit Detail'))) ->setHeaderIcon('fa-code-fork') ->addTag($commit_tag); if ($commit->getAuditStatus()) { $icon = PhabricatorAuditCommitStatusConstants::getStatusIcon( $commit->getAuditStatus()); $color = PhabricatorAuditCommitStatusConstants::getStatusColor( $commit->getAuditStatus()); $status = PhabricatorAuditCommitStatusConstants::getStatusName( $commit->getAuditStatus()); $header->setStatus($icon, $color, $status); } $curtain = $this->buildCurtain($commit, $repository); $subheader = $this->buildSubheaderView($commit, $commit_data); $details = $this->buildPropertyListView( $commit, $commit_data, $audit_requests); $message = $commit_data->getCommitMessage(); $revision = $commit->getCommitIdentifier(); $message = $this->linkBugtraq($message); $message = $engine->markupText($message); $detail_list = new PHUIPropertyListView(); $detail_list->addTextContent( phutil_tag( 'div', array( 'class' => 'diffusion-commit-message phabricator-remarkup', ), $message)); if ($commit->isUnreachable()) { $did_rewrite = false; if ($hint) { if ($hint->isRewritten()) { $rewritten = id(new DiffusionCommitQuery()) ->setViewer($viewer) ->withRepository($repository) ->withIdentifiers(array($hint->getNewCommitIdentifier())) ->executeOne(); if ($rewritten) { $did_rewrite = true; $rewritten_uri = $rewritten->getURI(); $rewritten_name = $rewritten->getLocalName(); $rewritten_link = phutil_tag( 'a', array( 'href' => $rewritten_uri, ), $rewritten_name); $this->commitErrors[] = pht( 'This commit was rewritten after it was published, which '. 'changed the commit hash. This old version of the commit is '. 'no longer reachable from any branch, tag or ref. The new '. 'version of this commit is %s.', $rewritten_link); } } } if (!$did_rewrite) { $this->commitErrors[] = pht( 'This commit has been deleted in the repository: it is no longer '. 'reachable from any branch, tag, or ref.'); } } if ($this->getCommitErrors()) { $error_panel = id(new PHUIInfoView()) ->appendChild($this->getCommitErrors()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING); } } $timeline = $this->buildComments($commit); $merge_table = $this->buildMergesTable($commit); $show_changesets = false; $info_panel = null; $change_list = null; $change_table = null; if ($is_unreadable) { $info_panel = $this->renderStatusMessage( pht('Unreadable Commit'), pht( 'This commit has been marked as unreadable by an administrator. '. 'It may have been corrupted or created improperly by an external '. 'tool.')); } else if ($is_foreign) { // Don't render anything else. } else if (!$commit->isImported()) { $info_panel = $this->renderStatusMessage( pht('Still Importing...'), pht( 'This commit is still importing. Changes will be visible once '. 'the import finishes.')); } else if (!count($changes)) { $info_panel = $this->renderStatusMessage( pht('Empty Commit'), pht( 'This commit is empty and does not affect any paths.')); } else if ($was_limited) { $info_panel = $this->renderStatusMessage( pht('Enormous Commit'), pht( 'This commit is enormous, and affects more than %d files. '. 'Changes are not shown.', $hard_limit)); } else if (!$this->getCommitExists()) { $info_panel = $this->renderStatusMessage( pht('Commit No Longer Exists'), pht('This commit no longer exists in the repository.')); } else { $show_changesets = true; // The user has clicked "Show All Changes", and we should show all the // changes inline even if there are more than the soft limit. $show_all_details = $request->getBool('show_all'); $change_header = id(new PHUIHeaderView()) ->setHeader(pht('Changes (%s)', new PhutilNumber($count))); $warning_view = null; if ($count > self::CHANGES_LIMIT && !$show_all_details) { $button = id(new PHUIButtonView()) ->setText(pht('Show All Changes')) ->setHref('?show_all=true') ->setTag('a') ->setIcon('fa-files-o'); $warning_view = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setTitle(pht('Very Large Commit')) ->appendChild( pht('This commit is very large. Load each file individually.')); $change_header->addActionLink($button); } $changesets = DiffusionPathChange::convertToDifferentialChangesets( $viewer, $changes); // TODO: This table and panel shouldn't really be separate, but we need // to clean up the "Load All Files" interaction first. $change_table = $this->buildTableOfContents( $changesets, $change_header, $warning_view); $vcs = $repository->getVersionControlSystem(); switch ($vcs) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $vcs_supports_directory_changes = true; break; case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $vcs_supports_directory_changes = false; break; default: throw new Exception(pht('Unknown VCS.')); } $references = array(); foreach ($changesets as $key => $changeset) { $file_type = $changeset->getFileType(); if ($file_type == DifferentialChangeType::FILE_DIRECTORY) { if (!$vcs_supports_directory_changes) { unset($changesets[$key]); continue; } } $references[$key] = $drequest->generateURI( array( 'action' => 'rendering-ref', 'path' => $changeset->getFilename(), )); } // TODO: Some parts of the views still rely on properties of the // DifferentialChangeset. Make the objects ephemeral to make sure we don't // accidentally save them, and then set their ID to the appropriate ID for // this application (the path IDs). $path_ids = array_flip(mpull($changes, 'getPath')); foreach ($changesets as $changeset) { $changeset->makeEphemeral(); $changeset->setID($path_ids[$changeset->getFilename()]); } if ($count <= self::CHANGES_LIMIT || $show_all_details) { $visible_changesets = $changesets; } else { $visible_changesets = array(); $inlines = PhabricatorAuditInlineComment::loadDraftAndPublishedComments( $viewer, $commit->getPHID()); $path_ids = mpull($inlines, null, 'getPathID'); foreach ($changesets as $key => $changeset) { if (array_key_exists($changeset->getID(), $path_ids)) { $visible_changesets[$key] = $changeset; } } } $change_list_title = $commit->getDisplayName(); $change_list = new DifferentialChangesetListView(); $change_list->setTitle($change_list_title); $change_list->setChangesets($changesets); $change_list->setVisibleChangesets($visible_changesets); $change_list->setRenderingReferences($references); $change_list->setRenderURI($repository->getPathURI('diff/')); $change_list->setRepository($repository); $change_list->setUser($viewer); $change_list->setBackground(PHUIObjectBoxView::BLUE_PROPERTY); // TODO: Try to setBranch() to something reasonable here? $change_list->setStandaloneURI( $repository->getPathURI('diff/')); $change_list->setRawFileURIs( // TODO: Implement this, somewhat tricky if there's an octopus merge // or whatever? null, $repository->getPathURI('diff/?view=r')); $change_list->setInlineCommentControllerURI( '/diffusion/inline/edit/'.phutil_escape_uri($commit->getPHID()).'/'); } $add_comment = $this->renderAddCommentPanel( $commit, $timeline); $filetree_on = $viewer->compareUserSetting( PhabricatorShowFiletreeSetting::SETTINGKEY, PhabricatorShowFiletreeSetting::VALUE_ENABLE_FILETREE); $pref_collapse = PhabricatorFiletreeVisibleSetting::SETTINGKEY; $collapsed = $viewer->getUserSetting($pref_collapse); $nav = null; if ($show_changesets && $filetree_on) { $nav = id(new DifferentialChangesetFileTreeSideNavBuilder()) ->setTitle($commit->getDisplayName()) ->setBaseURI(new PhutilURI($commit->getURI())) ->build($changesets) ->setCrumbs($crumbs) ->setCollapsed((bool)$collapsed); } $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setSubheader($subheader) ->setMainColumn(array( $error_panel, $timeline, $merge_table, $info_panel, )) ->setFooter(array( $change_table, $change_list, $add_comment, )) ->addPropertySection(pht('Description'), $detail_list) ->addPropertySection(pht('Details'), $details) ->setCurtain($curtain); $page = $this->newPage() ->setTitle($commit->getDisplayName()) ->setCrumbs($crumbs) ->setPageObjectPHIDS(array($commit->getPHID())) ->appendChild( array( $view, )); if ($nav) { $page->setNavigation($nav); } return $page; } private function buildPropertyListView( PhabricatorRepositoryCommit $commit, PhabricatorRepositoryCommitData $data, array $audit_requests) { $viewer = $this->getViewer(); $commit_phid = $commit->getPHID(); $drequest = $this->getDiffusionRequest(); $repository = $drequest->getRepository(); $view = id(new PHUIPropertyListView()) ->setUser($this->getRequest()->getUser()); $edge_query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($commit_phid)) ->withEdgeTypes(array( DiffusionCommitHasTaskEdgeType::EDGECONST, DiffusionCommitHasRevisionEdgeType::EDGECONST, DiffusionCommitRevertsCommitEdgeType::EDGECONST, DiffusionCommitRevertedByCommitEdgeType::EDGECONST, )); $edges = $edge_query->execute(); $task_phids = array_keys( $edges[$commit_phid][DiffusionCommitHasTaskEdgeType::EDGECONST]); $revision_phid = key( $edges[$commit_phid][DiffusionCommitHasRevisionEdgeType::EDGECONST]); $reverts_phids = array_keys( $edges[$commit_phid][DiffusionCommitRevertsCommitEdgeType::EDGECONST]); $reverted_by_phids = array_keys( $edges[$commit_phid][DiffusionCommitRevertedByCommitEdgeType::EDGECONST]); $phids = $edge_query->getDestinationPHIDs(array($commit_phid)); if ($data->getCommitDetail('authorPHID')) { $phids[] = $data->getCommitDetail('authorPHID'); } if ($data->getCommitDetail('reviewerPHID')) { $phids[] = $data->getCommitDetail('reviewerPHID'); } if ($data->getCommitDetail('committerPHID')) { $phids[] = $data->getCommitDetail('committerPHID'); } // NOTE: We should never normally have more than a single push log, but // it can occur naturally if a commit is pushed, then the branch it was // on is deleted, then the commit is pushed again (or through other similar // chains of events). This should be rare, but does not indicate a bug // or data issue. - // NOTE: We never query push logs in SVN because the commiter is always + // NOTE: We never query push logs in SVN because the committer is always // the pusher and the commit time is always the push time; the push log // is redundant and we save a query by skipping it. $push_logs = array(); if ($repository->isHosted() && !$repository->isSVN()) { $push_logs = id(new PhabricatorRepositoryPushLogQuery()) ->setViewer($viewer) ->withRepositoryPHIDs(array($repository->getPHID())) ->withNewRefs(array($commit->getCommitIdentifier())) ->withRefTypes(array(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)) ->execute(); foreach ($push_logs as $log) { $phids[] = $log->getPusherPHID(); } } $handles = array(); if ($phids) { $handles = $this->loadViewerHandles($phids); } $props = array(); if ($audit_requests) { $user_requests = array(); $other_requests = array(); foreach ($audit_requests as $audit_request) { if (!$audit_request->isInteresting()) { continue; } if ($audit_request->isUser()) { $user_requests[] = $audit_request; } else { $other_requests[] = $audit_request; } } if ($user_requests) { $view->addProperty( pht('Auditors'), $this->renderAuditStatusView($commit, $user_requests)); } if ($other_requests) { $view->addProperty( pht('Group Auditors'), $this->renderAuditStatusView($commit, $other_requests)); } } $author_phid = $data->getCommitDetail('authorPHID'); $author_name = $data->getAuthorName(); $author_epoch = $data->getCommitDetail('authorEpoch'); $committed_info = id(new PHUIStatusItemView()) ->setNote(phabricator_datetime($commit->getEpoch(), $viewer)); $committer_phid = $data->getCommitDetail('committerPHID'); $committer_name = $data->getCommitDetail('committer'); if ($committer_phid) { $committed_info->setTarget($handles[$committer_phid]->renderLink()); } else if (strlen($committer_name)) { $committed_info->setTarget($committer_name); } else if ($author_phid) { $committed_info->setTarget($handles[$author_phid]->renderLink()); } else if (strlen($author_name)) { $committed_info->setTarget($author_name); } $committed_list = new PHUIStatusListView(); $committed_list->addItem($committed_info); $view->addProperty( pht('Committed'), $committed_list); if ($push_logs) { $pushed_list = new PHUIStatusListView(); foreach ($push_logs as $push_log) { $pushed_item = id(new PHUIStatusItemView()) ->setTarget($handles[$push_log->getPusherPHID()]->renderLink()) ->setNote(phabricator_datetime($push_log->getEpoch(), $viewer)); $pushed_list->addItem($pushed_item); } $view->addProperty( pht('Pushed'), $pushed_list); } $reviewer_phid = $data->getCommitDetail('reviewerPHID'); if ($reviewer_phid) { $view->addProperty( pht('Reviewer'), $handles[$reviewer_phid]->renderLink()); } if ($revision_phid) { $view->addProperty( pht('Differential Revision'), $handles[$revision_phid]->renderLink()); } $parents = $this->getCommitParents(); if ($parents) { $view->addProperty( pht('Parents'), $viewer->renderHandleList(mpull($parents, 'getPHID'))); } if ($this->getCommitExists()) { $view->addProperty( pht('Branches'), phutil_tag( 'span', array( 'id' => 'commit-branches', ), pht('Unknown'))); $view->addProperty( pht('Tags'), phutil_tag( 'span', array( 'id' => 'commit-tags', ), pht('Unknown'))); $identifier = $commit->getCommitIdentifier(); $root = $repository->getPathURI("commit/{$identifier}"); Javelin::initBehavior( 'diffusion-commit-branches', array( $root.'/branches/' => 'commit-branches', $root.'/tags/' => 'commit-tags', )); } $refs = $this->getCommitRefs(); if ($refs) { $ref_links = array(); foreach ($refs as $ref_data) { $ref_links[] = phutil_tag( 'a', array( 'href' => $ref_data['href'], ), $ref_data['ref']); } $view->addProperty( pht('References'), phutil_implode_html(', ', $ref_links)); } if ($reverts_phids) { $view->addProperty( pht('Reverts'), $viewer->renderHandleList($reverts_phids)); } if ($reverted_by_phids) { $view->addProperty( pht('Reverted By'), $viewer->renderHandleList($reverted_by_phids)); } if ($task_phids) { $task_list = array(); foreach ($task_phids as $phid) { $task_list[] = $handles[$phid]->renderLink(); } $task_list = phutil_implode_html(phutil_tag('br'), $task_list); $view->addProperty( pht('Tasks'), $task_list); } return $view; } private function buildSubheaderView( PhabricatorRepositoryCommit $commit, PhabricatorRepositoryCommitData $data) { $viewer = $this->getViewer(); $drequest = $this->getDiffusionRequest(); $repository = $drequest->getRepository(); if ($repository->isSVN()) { return null; } $author_phid = $data->getCommitDetail('authorPHID'); $author_name = $data->getAuthorName(); $author_epoch = $data->getCommitDetail('authorEpoch'); $date = null; if ($author_epoch !== null) { $date = phabricator_datetime($author_epoch, $viewer); } if ($author_phid) { $handles = $viewer->loadHandles(array($author_phid)); $image_uri = $handles[$author_phid]->getImageURI(); $image_href = $handles[$author_phid]->getURI(); $author = $handles[$author_phid]->renderLink(); } else if (strlen($author_name)) { $author = $author_name; $image_uri = null; $image_href = null; } else { return null; } $author = phutil_tag('strong', array(), $author); if ($date) { $content = pht('Authored by %s on %s.', $author, $date); } else { $content = pht('Authored by %s.', $author); } return id(new PHUIHeadThingView()) ->setImage($image_uri) ->setImageHref($image_href) ->setContent($content); } private function buildComments(PhabricatorRepositoryCommit $commit) { $timeline = $this->buildTransactionTimeline( $commit, new PhabricatorAuditTransactionQuery()); $commit->willRenderTimeline($timeline, $this->getRequest()); $timeline->setQuoteRef($commit->getMonogram()); return $timeline; } private function renderAddCommentPanel( PhabricatorRepositoryCommit $commit, $timeline) { $request = $this->getRequest(); $viewer = $request->getUser(); // TODO: This is pretty awkward, unify the CSS between Diffusion and // Differential better. require_celerity_resource('differential-core-view-css'); $comment_view = id(new DiffusionCommitEditEngine()) ->setViewer($viewer) ->buildEditEngineCommentView($commit); $comment_view->setTransactionTimeline($timeline); return $comment_view; } private function buildMergesTable(PhabricatorRepositoryCommit $commit) { $viewer = $this->getViewer(); $drequest = $this->getDiffusionRequest(); $repository = $drequest->getRepository(); $merges = $this->getCommitMerges(); if (!$merges) { return null; } $limit = $this->getMergeDisplayLimit(); $caption = null; if (count($merges) > $limit) { $merges = array_slice($merges, 0, $limit); $caption = new PHUIInfoView(); $caption->setSeverity(PHUIInfoView::SEVERITY_NOTICE); $caption->appendChild( pht( 'This commit merges a very large number of changes. '. 'Only the first %s are shown.', new PhutilNumber($limit))); } $history_table = id(new DiffusionHistoryTableView()) ->setUser($viewer) ->setDiffusionRequest($drequest) ->setHistory($merges); $history_table->loadRevisions(); $panel = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Merged Changes')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($history_table); if ($caption) { $panel->setInfoView($caption); } return $panel; } private function buildCurtain( PhabricatorRepositoryCommit $commit, PhabricatorRepository $repository) { $request = $this->getRequest(); $viewer = $this->getViewer(); $curtain = $this->newCurtainView($commit); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $commit, PhabricatorPolicyCapability::CAN_EDIT); $id = $commit->getID(); $edit_uri = $this->getApplicationURI("/commit/edit/{$id}/"); $action = id(new PhabricatorActionView()) ->setName(pht('Edit Commit')) ->setHref($edit_uri) ->setIcon('fa-pencil') ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit); $curtain->addAction($action); $action = id(new PhabricatorActionView()) ->setName(pht('Download Raw Diff')) ->setHref($request->getRequestURI()->alter('diff', true)) ->setIcon('fa-download'); $curtain->addAction($action); $relationship_list = PhabricatorObjectRelationshipList::newForObject( $viewer, $commit); $relationship_submenu = $relationship_list->newActionMenu(); if ($relationship_submenu) { $curtain->addAction($relationship_submenu); } return $curtain; } private function buildRawDiffResponse(DiffusionRequest $drequest) { $diff_info = $this->callConduitWithDiffusionRequest( 'diffusion.rawdiffquery', array( 'commit' => $drequest->getCommit(), 'path' => $drequest->getPath(), )); $file_phid = $diff_info['filePHID']; $file = id(new PhabricatorFileQuery()) ->setViewer($this->getViewer()) ->withPHIDs(array($file_phid)) ->executeOne(); if (!$file) { throw new Exception( pht( 'Failed to load file ("%s") returned by "%s".', $file_phid, 'diffusion.rawdiffquery')); } return $file->getRedirectResponse(); } private function renderAuditStatusView( PhabricatorRepositoryCommit $commit, array $audit_requests) { assert_instances_of($audit_requests, 'PhabricatorRepositoryAuditRequest'); $viewer = $this->getViewer(); $view = new PHUIStatusListView(); foreach ($audit_requests as $request) { $code = $request->getAuditStatus(); $item = new PHUIStatusItemView(); $item->setIcon( PhabricatorAuditStatusConstants::getStatusIcon($code), PhabricatorAuditStatusConstants::getStatusColor($code), PhabricatorAuditStatusConstants::getStatusName($code)); $auditor_phid = $request->getAuditorPHID(); $target = $viewer->renderHandle($auditor_phid); $item->setTarget($target); if ($commit->hasAuditAuthority($viewer, $request)) { $item->setHighlighted(true); } $view->addItem($item); } return $view; } private function linkBugtraq($corpus) { $url = PhabricatorEnv::getEnvConfig('bugtraq.url'); if (!strlen($url)) { return $corpus; } $regexes = PhabricatorEnv::getEnvConfig('bugtraq.logregex'); if (!$regexes) { return $corpus; } $parser = id(new PhutilBugtraqParser()) ->setBugtraqPattern("[[ {$url} | %BUGID% ]]") ->setBugtraqCaptureExpression(array_shift($regexes)); $select = array_shift($regexes); if ($select) { $parser->setBugtraqSelectExpression($select); } return $parser->processCorpus($corpus); } private function buildTableOfContents( array $changesets, $header, $info_view) { $drequest = $this->getDiffusionRequest(); $viewer = $this->getViewer(); $toc_view = id(new PHUIDiffTableOfContentsListView()) ->setUser($viewer) ->setHeader($header) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY); if ($info_view) { $toc_view->setInfoView($info_view); } // TODO: This is hacky, we just want access to the linkX() methods on // DiffusionView. $diffusion_view = id(new DiffusionEmptyResultView()) ->setDiffusionRequest($drequest); $have_owners = PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorOwnersApplication', $viewer); if (!$changesets) { $have_owners = false; } if ($have_owners) { if ($viewer->getPHID()) { $packages = id(new PhabricatorOwnersPackageQuery()) ->setViewer($viewer) ->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE)) ->withAuthorityPHIDs(array($viewer->getPHID())) ->execute(); $toc_view->setAuthorityPackages($packages); } $repository = $drequest->getRepository(); $repository_phid = $repository->getPHID(); $control_query = id(new PhabricatorOwnersPackageQuery()) ->setViewer($viewer) ->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE)) ->withControl($repository_phid, mpull($changesets, 'getFilename')); $control_query->execute(); } foreach ($changesets as $changeset_id => $changeset) { $path = $changeset->getFilename(); $anchor = $changeset->getAnchorName(); $history_link = $diffusion_view->linkHistory($path); $browse_link = $diffusion_view->linkBrowse( $path, array( 'type' => $changeset->getFileType(), )); $item = id(new PHUIDiffTableOfContentsItemView()) ->setChangeset($changeset) ->setAnchor($anchor) ->setContext( array( $history_link, ' ', $browse_link, )); if ($have_owners) { $packages = $control_query->getControllingPackagesForPath( $repository_phid, $changeset->getFilename()); $item->setPackages($packages); } $toc_view->addItem($item); } return $toc_view; } private function loadCommitState() { $viewer = $this->getViewer(); $drequest = $this->getDiffusionRequest(); $repository = $drequest->getRepository(); $commit = $drequest->getCommit(); // TODO: We could use futures here and resolve these calls in parallel. $exceptions = array(); try { $parent_refs = $this->callConduitWithDiffusionRequest( 'diffusion.commitparentsquery', array( 'commit' => $commit, )); if ($parent_refs) { $parents = id(new DiffusionCommitQuery()) ->setViewer($viewer) ->withRepository($repository) ->withIdentifiers($parent_refs) ->execute(); } else { $parents = array(); } $this->commitParents = $parents; } catch (Exception $ex) { $this->commitParents = false; $exceptions[] = $ex; } $merge_limit = $this->getMergeDisplayLimit(); try { if ($repository->isSVN()) { $this->commitMerges = array(); } else { $merges = $this->callConduitWithDiffusionRequest( 'diffusion.mergedcommitsquery', array( 'commit' => $commit, 'limit' => $merge_limit + 1, )); $this->commitMerges = DiffusionPathChange::newFromConduit($merges); } } catch (Exception $ex) { $this->commitMerges = false; $exceptions[] = $ex; } try { if ($repository->isGit()) { $refs = $this->callConduitWithDiffusionRequest( 'diffusion.refsquery', array( 'commit' => $commit, )); } else { $refs = array(); } $this->commitRefs = $refs; } catch (Exception $ex) { $this->commitRefs = false; $exceptions[] = $ex; } if ($exceptions) { $exists = $this->callConduitWithDiffusionRequest( 'diffusion.existsquery', array( 'commit' => $commit, )); if ($exists) { $this->commitExists = true; foreach ($exceptions as $exception) { $this->commitErrors[] = $exception->getMessage(); } } else { $this->commitExists = false; $this->commitErrors[] = pht( 'This commit no longer exists in the repository. It may have '. 'been part of a branch which was deleted.'); } } else { $this->commitExists = true; $this->commitErrors = array(); } } private function getMergeDisplayLimit() { return 50; } private function getCommitExists() { if ($this->commitExists === null) { $this->loadCommitState(); } return $this->commitExists; } private function getCommitParents() { if ($this->commitParents === null) { $this->loadCommitState(); } return $this->commitParents; } private function getCommitRefs() { if ($this->commitRefs === null) { $this->loadCommitState(); } return $this->commitRefs; } private function getCommitMerges() { if ($this->commitMerges === null) { $this->loadCommitState(); } return $this->commitMerges; } private function getCommitErrors() { if ($this->commitErrors === null) { $this->loadCommitState(); } return $this->commitErrors; } } diff --git a/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php b/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php index 2459c8c559..b3baafbd06 100644 --- a/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php +++ b/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php @@ -1,455 +1,455 @@ versionControlSystem = $version_control_system; return $this; } public function getVersionControlSystem() { return $this->versionControlSystem; } public function isEngineConfigurable() { return false; } public function isDefaultQuickCreateEngine() { return true; } public function getQuickCreateOrderVector() { return id(new PhutilSortVector())->addInt(300); } public function getEngineName() { return pht('Repositories'); } public function getSummaryHeader() { return pht('Edit Repositories'); } public function getSummaryText() { return pht('Creates and edits repositories.'); } public function getEngineApplicationClass() { return 'PhabricatorDiffusionApplication'; } protected function newEditableObject() { $viewer = $this->getViewer(); $repository = PhabricatorRepository::initializeNewRepository($viewer); $repository->setDetail('newly-initialized', true); $vcs = $this->getVersionControlSystem(); if ($vcs) { $repository->setVersionControlSystem($vcs); } // Pick a random open service to allocate this repository on, if any exist. // If there are no services, we aren't in cluster mode and will allocate // locally. If there are services but none permit allocations, we fail. // Eventually we can make this more flexible, but this rule is a reasonable // starting point as we begin to deploy cluster services. $services = id(new AlmanacServiceQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withServiceTypes( array( AlmanacClusterRepositoryServiceType::SERVICETYPE, )) ->needProperties(true) ->execute(); if ($services) { // Filter out services which do not permit new allocations. foreach ($services as $key => $possible_service) { if ($possible_service->getAlmanacPropertyValue('closed')) { unset($services[$key]); } } if (!$services) { throw new Exception( pht( 'This install is configured in cluster mode, but all available '. 'repository cluster services are closed to new allocations. '. 'At least one service must be open to allow new allocations to '. 'take place.')); } shuffle($services); $service = head($services); $repository->setAlmanacServicePHID($service->getPHID()); } return $repository; } protected function newObjectQuery() { return new PhabricatorRepositoryQuery(); } protected function getObjectCreateTitleText($object) { return pht('Create Repository'); } protected function getObjectCreateButtonText($object) { return pht('Create Repository'); } protected function getObjectEditTitleText($object) { return pht('Edit Repository: %s', $object->getName()); } protected function getObjectEditShortText($object) { return $object->getDisplayName(); } protected function getObjectCreateShortText() { return pht('Create Repository'); } protected function getObjectName() { return pht('Repository'); } protected function getObjectViewURI($object) { return $object->getPathURI('manage/'); } protected function getCreateNewObjectPolicy() { return $this->getApplication()->getPolicy( DiffusionCreateRepositoriesCapability::CAPABILITY); } protected function newPages($object) { $panels = DiffusionRepositoryManagementPanel::getAllPanels(); $pages = array(); $uris = array(); foreach ($panels as $panel_key => $panel) { $panel->setRepository($object); $uris[$panel_key] = $panel->getPanelURI(); $page = $panel->newEditEnginePage(); if (!$page) { continue; } $pages[] = $page; } $basics_key = DiffusionRepositoryBasicsManagementPanel::PANELKEY; $basics_uri = $uris[$basics_key]; $more_pages = array( id(new PhabricatorEditPage()) ->setKey('encoding') ->setLabel(pht('Text Encoding')) ->setViewURI($basics_uri) ->setFieldKeys( array( 'encoding', )), id(new PhabricatorEditPage()) ->setKey('extensions') ->setLabel(pht('Extensions')) ->setIsDefault(true), ); foreach ($more_pages as $page) { $pages[] = $page; } return $pages; } protected function willConfigureFields($object, array $fields) { // Change the default field order so related fields are adjacent. $after = array( 'policy.edit' => array('policy.push'), ); $result = array(); foreach ($fields as $key => $value) { $result[$key] = $value; if (!isset($after[$key])) { continue; } foreach ($after[$key] as $next_key) { if (!isset($fields[$next_key])) { continue; } unset($result[$next_key]); $result[$next_key] = $fields[$next_key]; unset($fields[$next_key]); } } return $result; } protected function buildCustomEditFields($object) { $viewer = $this->getViewer(); $policies = id(new PhabricatorPolicyQuery()) ->setViewer($viewer) ->setObject($object) ->execute(); $track_value = $object->getDetail('branch-filter', array()); $track_value = array_keys($track_value); $autoclose_value = $object->getDetail('close-commits-filter', array()); $autoclose_value = array_keys($autoclose_value); $automation_instructions = pht( "Configure **Repository Automation** to allow Phabricator to ". "write to this repository.". "\n\n". "IMPORTANT: This feature is new, experimental, and not supported. ". "Use it at your own risk."); $staging_instructions = pht( "To make it easier to run integration tests and builds on code ". "under review, you can configure a **Staging Area**. When `arc` ". "creates a diff, it will push a copy of the changes to the ". "configured staging area with a corresponding tag.". "\n\n". "IMPORTANT: This feature is new, experimental, and not supported. ". "Use it at your own risk."); $subpath_instructions = pht( 'If you want to import only part of a repository, like `trunk/`, '. 'you can set a path in **Import Only**. Phabricator will ignore '. 'commits which do not affect this path.'); return array( id(new PhabricatorSelectEditField()) ->setKey('vcs') ->setLabel(pht('Version Control System')) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_VCS) ->setIsConduitOnly(true) ->setIsCopyable(true) ->setOptions(PhabricatorRepositoryType::getAllRepositoryTypes()) ->setDescription(pht('Underlying repository version control system.')) ->setConduitDescription( pht( 'Choose which version control system to use when creating a '. 'repository.')) ->setConduitTypeDescription(pht('Version control system selection.')) ->setValue($object->getVersionControlSystem()), id(new PhabricatorTextEditField()) ->setKey('name') ->setLabel(pht('Name')) ->setIsRequired(true) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_NAME) ->setDescription(pht('The repository name.')) ->setConduitDescription(pht('Rename the repository.')) ->setConduitTypeDescription(pht('New repository name.')) ->setValue($object->getName()), id(new PhabricatorTextEditField()) ->setKey('callsign') ->setLabel(pht('Callsign')) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_CALLSIGN) ->setDescription(pht('The repository callsign.')) ->setConduitDescription(pht('Change the repository callsign.')) ->setConduitTypeDescription(pht('New repository callsign.')) ->setValue($object->getCallsign()), id(new PhabricatorTextEditField()) ->setKey('shortName') ->setLabel(pht('Short Name')) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_SLUG) ->setDescription(pht('Short, unique repository name.')) ->setConduitDescription(pht('Change the repository short name.')) ->setConduitTypeDescription(pht('New short name for the repository.')) ->setValue($object->getRepositorySlug()), id(new PhabricatorRemarkupEditField()) ->setKey('description') ->setLabel(pht('Description')) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_DESCRIPTION) ->setDescription(pht('Repository description.')) ->setConduitDescription(pht('Change the repository description.')) ->setConduitTypeDescription(pht('New repository description.')) ->setValue($object->getDetail('description')), id(new PhabricatorTextEditField()) ->setKey('encoding') ->setLabel(pht('Text Encoding')) ->setIsCopyable(true) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_ENCODING) ->setDescription(pht('Default text encoding.')) ->setConduitDescription(pht('Change the default text encoding.')) ->setConduitTypeDescription(pht('New text encoding.')) ->setValue($object->getDetail('encoding')), id(new PhabricatorBoolEditField()) ->setKey('allowDangerousChanges') ->setLabel(pht('Allow Dangerous Changes')) ->setIsCopyable(true) ->setIsConduitOnly(true) ->setOptions( pht('Prevent Dangerous Changes'), pht('Allow Dangerous Changes')) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_DANGEROUS) ->setDescription(pht('Permit dangerous changes to be made.')) ->setConduitDescription(pht('Allow or prevent dangerous changes.')) ->setConduitTypeDescription(pht('New protection setting.')) ->setValue($object->shouldAllowDangerousChanges()), id(new PhabricatorSelectEditField()) ->setKey('status') ->setLabel(pht('Status')) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_ACTIVATE) ->setIsConduitOnly(true) ->setOptions(PhabricatorRepository::getStatusNameMap()) ->setDescription(pht('Active or inactive status.')) ->setConduitDescription(pht('Active or deactivate the repository.')) ->setConduitTypeDescription(pht('New repository status.')) ->setValue($object->getStatus()), id(new PhabricatorTextEditField()) ->setKey('defaultBranch') ->setLabel(pht('Default Branch')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_DEFAULT_BRANCH) ->setIsCopyable(true) ->setDescription(pht('Default branch name.')) ->setConduitDescription(pht('Set the default branch name.')) ->setConduitTypeDescription(pht('New default branch name.')) ->setValue($object->getDetail('default-branch')), id(new PhabricatorTextAreaEditField()) ->setIsStringList(true) ->setKey('trackOnly') ->setLabel(pht('Track Only')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_TRACK_ONLY) ->setIsCopyable(true) ->setDescription(pht('Track only these branches.')) ->setConduitDescription(pht('Set the tracked branches.')) - ->setConduitTypeDescription(pht('New tracked branchs.')) + ->setConduitTypeDescription(pht('New tracked branches.')) ->setValue($track_value), id(new PhabricatorTextAreaEditField()) ->setIsStringList(true) ->setKey('autocloseOnly') ->setLabel(pht('Autoclose Only')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_AUTOCLOSE_ONLY) ->setIsCopyable(true) ->setDescription(pht('Autoclose commits on only these branches.')) ->setConduitDescription(pht('Set the autoclose branches.')) - ->setConduitTypeDescription(pht('New default tracked branchs.')) + ->setConduitTypeDescription(pht('New default tracked branches.')) ->setValue($autoclose_value), id(new PhabricatorTextEditField()) ->setKey('importOnly') ->setLabel(pht('Import Only')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_SVN_SUBPATH) ->setIsCopyable(true) ->setDescription(pht('Subpath to selectively import.')) ->setConduitDescription(pht('Set the subpath to import.')) ->setConduitTypeDescription(pht('New subpath to import.')) ->setValue($object->getDetail('svn-subpath')) ->setControlInstructions($subpath_instructions), id(new PhabricatorTextEditField()) ->setKey('stagingAreaURI') ->setLabel(pht('Staging Area URI')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_STAGING_URI) ->setIsCopyable(true) ->setDescription(pht('Staging area URI.')) ->setConduitDescription(pht('Set the staging area URI.')) ->setConduitTypeDescription(pht('New staging area URI.')) ->setValue($object->getStagingURI()) ->setControlInstructions($staging_instructions), id(new PhabricatorDatasourceEditField()) ->setKey('automationBlueprintPHIDs') ->setLabel(pht('Use Blueprints')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_AUTOMATION_BLUEPRINTS) ->setIsCopyable(true) ->setDatasource(new DrydockBlueprintDatasource()) ->setDescription(pht('Automation blueprints.')) ->setConduitDescription(pht('Change automation blueprints.')) ->setConduitTypeDescription(pht('New blueprint PHIDs.')) ->setValue($object->getAutomationBlueprintPHIDs()) ->setControlInstructions($automation_instructions), id(new PhabricatorStringListEditField()) ->setKey('symbolLanguages') ->setLabel(pht('Languages')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_SYMBOLS_LANGUAGE) ->setIsCopyable(true) ->setDescription( pht('Languages which define symbols in this repository.')) ->setConduitDescription( pht('Change symbol languages for this repository.')) ->setConduitTypeDescription( - pht('New symbol langauges.')) + pht('New symbol languages.')) ->setValue($object->getSymbolLanguages()), id(new PhabricatorDatasourceEditField()) ->setKey('symbolRepositoryPHIDs') ->setLabel(pht('Uses Symbols From')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_SYMBOLS_SOURCES) ->setIsCopyable(true) ->setDatasource(new DiffusionRepositoryDatasource()) ->setDescription(pht('Repositories to link symbols from.')) ->setConduitDescription(pht('Change symbol source repositories.')) ->setConduitTypeDescription(pht('New symbol repositories.')) ->setValue($object->getSymbolSources()), id(new PhabricatorBoolEditField()) ->setKey('publish') ->setLabel(pht('Publish/Notify')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_NOTIFY) ->setIsCopyable(true) ->setOptions( pht('Disable Notifications, Feed, and Herald'), pht('Enable Notifications, Feed, and Herald')) ->setDescription(pht('Configure how changes are published.')) ->setConduitDescription(pht('Change publishing options.')) ->setConduitTypeDescription(pht('New notification setting.')) ->setValue(!$object->getDetail('herald-disabled')), id(new PhabricatorBoolEditField()) ->setKey('autoclose') ->setLabel(pht('Autoclose')) ->setTransactionType( PhabricatorRepositoryTransaction::TYPE_AUTOCLOSE) ->setIsCopyable(true) ->setOptions( pht('Disable Autoclose'), pht('Enable Autoclose')) ->setDescription(pht('Stop or resume autoclosing in this repository.')) ->setConduitDescription(pht('Change autoclose setting.')) ->setConduitTypeDescription(pht('New autoclose setting.')) ->setValue(!$object->getDetail('disable-autoclose')), id(new PhabricatorPolicyEditField()) ->setKey('policy.push') ->setLabel(pht('Push Policy')) ->setAliases(array('push')) ->setIsCopyable(true) ->setCapability(DiffusionPushCapability::CAPABILITY) ->setPolicies($policies) ->setTransactionType(PhabricatorRepositoryTransaction::TYPE_PUSH_POLICY) ->setDescription( pht('Controls who can push changes to the repository.')) ->setConduitDescription( pht('Change the push policy of the repository.')) ->setConduitTypeDescription(pht('New policy PHID or constant.')) ->setValue($object->getPolicy(DiffusionPushCapability::CAPABILITY)), ); } } diff --git a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php index 21df37d87d..7fe45834b3 100644 --- a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php +++ b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php @@ -1,1264 +1,1264 @@ remoteProtocol = $remote_protocol; return $this; } public function getRemoteProtocol() { return $this->remoteProtocol; } public function setRemoteAddress($remote_address) { $this->remoteAddress = $remote_address; return $this; } public function getRemoteAddress() { return $this->remoteAddress; } public function setSubversionTransactionInfo($transaction, $repository) { $this->subversionTransaction = $transaction; $this->subversionRepository = $repository; return $this; } public function setStdin($stdin) { $this->stdin = $stdin; return $this; } public function getStdin() { return $this->stdin; } public function setOriginalArgv(array $original_argv) { $this->originalArgv = $original_argv; return $this; } public function getOriginalArgv() { return $this->originalArgv; } public function setRepository(PhabricatorRepository $repository) { $this->repository = $repository; return $this; } public function getRepository() { return $this->repository; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setMercurialHook($mercurial_hook) { $this->mercurialHook = $mercurial_hook; return $this; } public function getMercurialHook() { return $this->mercurialHook; } /* -( Hook Execution )----------------------------------------------------- */ public function execute() { $ref_updates = $this->findRefUpdates(); $all_updates = $ref_updates; $caught = null; try { try { $this->rejectDangerousChanges($ref_updates); } catch (DiffusionCommitHookRejectException $ex) { // If we're rejecting dangerous changes, flag everything that we've // seen as rejected so it's clear that none of it was accepted. $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_DANGEROUS; throw $ex; } $this->applyHeraldRefRules($ref_updates, $all_updates); $content_updates = $this->findContentUpdates($ref_updates); $all_updates = array_merge($all_updates, $content_updates); $this->applyHeraldContentRules($content_updates, $all_updates); // Run custom scripts in `hook.d/` directories. $this->applyCustomHooks($all_updates); // If we make it this far, we're accepting these changes. Mark all the // logs as accepted. $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ACCEPT; } catch (Exception $ex) { // We'll throw this again in a minute, but we want to save all the logs // first. $caught = $ex; } // Save all the logs no matter what the outcome was. $event = $this->newPushEvent(); $event->setRejectCode($this->rejectCode); $event->setRejectDetails($this->rejectDetails); $event->openTransaction(); $event->save(); foreach ($all_updates as $update) { $update->setPushEventPHID($event->getPHID()); $update->save(); } $event->saveTransaction(); if ($caught) { throw $caught; } // If this went through cleanly, detect pushes which are actually imports // of an existing repository rather than an addition of new commits. If // this push is importing a bunch of stuff, set the importing flag on // the repository. It will be cleared once we fully process everything. if ($this->isInitialImport($all_updates)) { $repository = $this->getRepository(); $repository->markImporting(); } if ($this->emailPHIDs) { // If Herald rules triggered email to users, queue a worker to send the // mail. We do this out-of-process so that we block pushes as briefly // as possible. // (We do need to pull some commit info here because the commit objects // may not exist yet when this worker runs, which could be immediately.) PhabricatorWorker::scheduleTask( 'PhabricatorRepositoryPushMailWorker', array( 'eventPHID' => $event->getPHID(), 'emailPHIDs' => array_values($this->emailPHIDs), 'info' => $this->loadCommitInfoForWorker($all_updates), ), array( 'priority' => PhabricatorWorker::PRIORITY_ALERTS, )); } return 0; } private function findRefUpdates() { $type = $this->getRepository()->getVersionControlSystem(); switch ($type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: return $this->findGitRefUpdates(); case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: return $this->findMercurialRefUpdates(); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: return $this->findSubversionRefUpdates(); default: throw new Exception(pht('Unsupported repository type "%s"!', $type)); } } private function rejectDangerousChanges(array $ref_updates) { assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); $repository = $this->getRepository(); if ($repository->shouldAllowDangerousChanges()) { return; } $flag_dangerous = PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; foreach ($ref_updates as $ref_update) { if (!$ref_update->hasChangeFlags($flag_dangerous)) { // This is not a dangerous change. continue; } // We either have a branch deletion or a non fast-forward branch update. // Format a message and reject the push. $message = pht( "DANGEROUS CHANGE: %s\n". "Dangerous change protection is enabled for this repository.\n". "Edit the repository configuration before making dangerous changes.", $ref_update->getDangerousChangeDescription()); throw new DiffusionCommitHookRejectException($message); } } private function findContentUpdates(array $ref_updates) { assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); $type = $this->getRepository()->getVersionControlSystem(); switch ($type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: return $this->findGitContentUpdates($ref_updates); case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: return $this->findMercurialContentUpdates($ref_updates); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: return $this->findSubversionContentUpdates($ref_updates); default: throw new Exception(pht('Unsupported repository type "%s"!', $type)); } } /* -( Herald )------------------------------------------------------------- */ private function applyHeraldRefRules( array $ref_updates, array $all_updates) { $this->applyHeraldRules( $ref_updates, new HeraldPreCommitRefAdapter(), $all_updates); } private function applyHeraldContentRules( array $content_updates, array $all_updates) { $this->applyHeraldRules( $content_updates, new HeraldPreCommitContentAdapter(), $all_updates); } private function applyHeraldRules( array $updates, HeraldAdapter $adapter_template, array $all_updates) { if (!$updates) { return; } $adapter_template->setHookEngine($this); $engine = new HeraldEngine(); $rules = null; $blocking_effect = null; $blocked_update = null; $blocking_xscript = null; foreach ($updates as $update) { $adapter = id(clone $adapter_template) ->setPushLog($update); if ($rules === null) { $rules = $engine->loadRulesForAdapter($adapter); } $effects = $engine->applyRules($rules, $adapter); $engine->applyEffects($effects, $adapter, $rules); $xscript = $engine->getTranscript(); // Store any PHIDs we want to send email to for later. foreach ($adapter->getEmailPHIDs() as $email_phid) { $this->emailPHIDs[$email_phid] = $email_phid; } $block_action = DiffusionBlockHeraldAction::ACTIONCONST; if ($blocking_effect === null) { foreach ($effects as $effect) { if ($effect->getAction() == $block_action) { $blocking_effect = $effect; $blocked_update = $update; $blocking_xscript = $xscript; break; } } } } if ($blocking_effect) { $rule = $blocking_effect->getRule(); $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_HERALD; $this->rejectDetails = $rule->getPHID(); $message = $blocking_effect->getTarget(); if (!strlen($message)) { $message = pht('(None.)'); } $blocked_ref_name = coalesce( $blocked_update->getRefName(), $blocked_update->getRefNewShort()); $blocked_name = $blocked_update->getRefType().'/'.$blocked_ref_name; throw new DiffusionCommitHookRejectException( pht( "This push was rejected by Herald push rule %s.\n". " Change: %s\n". " Rule: %s\n". " Reason: %s\n". "Transcript: %s", $rule->getMonogram(), $blocked_name, $rule->getName(), $message, PhabricatorEnv::getProductionURI( '/herald/transcript/'.$blocking_xscript->getID().'/'))); } } public function loadViewerProjectPHIDsForHerald() { // This just caches the viewer's projects so we don't need to load them // over and over again when applying Herald rules. if ($this->heraldViewerProjects === null) { $this->heraldViewerProjects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->withMemberPHIDs(array($this->getViewer()->getPHID())) ->execute(); } return mpull($this->heraldViewerProjects, 'getPHID'); } /* -( Git )---------------------------------------------------------------- */ private function findGitRefUpdates() { $ref_updates = array(); // First, parse stdin, which lists all the ref changes. The input looks // like this: // // $stdin = $this->getStdin(); $lines = phutil_split_lines($stdin, $retain_endings = false); foreach ($lines as $line) { $parts = explode(' ', $line, 3); if (count($parts) != 3) { throw new Exception(pht('Expected "old new ref", got "%s".', $line)); } $ref_old = $parts[0]; $ref_new = $parts[1]; $ref_raw = $parts[2]; if (preg_match('(^refs/heads/)', $ref_raw)) { $ref_type = PhabricatorRepositoryPushLog::REFTYPE_BRANCH; $ref_raw = substr($ref_raw, strlen('refs/heads/')); } else if (preg_match('(^refs/tags/)', $ref_raw)) { $ref_type = PhabricatorRepositoryPushLog::REFTYPE_TAG; $ref_raw = substr($ref_raw, strlen('refs/tags/')); } else { throw new Exception( pht( "Unable to identify the reftype of '%s'. Rejecting push.", $ref_raw)); } $ref_update = $this->newPushLog() ->setRefType($ref_type) ->setRefName($ref_raw) ->setRefOld($ref_old) ->setRefNew($ref_new); $ref_updates[] = $ref_update; } $this->findGitMergeBases($ref_updates); $this->findGitChangeFlags($ref_updates); return $ref_updates; } private function findGitMergeBases(array $ref_updates) { assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); $futures = array(); foreach ($ref_updates as $key => $ref_update) { // If the old hash is "00000...", the ref is being created (either a new // branch, or a new tag). If the new hash is "00000...", the ref is being // deleted. If both are nonempty, the ref is being updated. For updates, // we'll figure out the `merge-base` of the old and new objects here. This // lets us reject non-FF changes cheaply; later, we'll figure out exactly // which commits are new. $ref_old = $ref_update->getRefOld(); $ref_new = $ref_update->getRefNew(); if (($ref_old === self::EMPTY_HASH) || ($ref_new === self::EMPTY_HASH)) { continue; } $futures[$key] = $this->getRepository()->getLocalCommandFuture( 'merge-base %s %s', $ref_old, $ref_new); } $futures = id(new FutureIterator($futures)) ->limit(8); foreach ($futures as $key => $future) { // If 'old' and 'new' have no common ancestors (for example, a force push // which completely rewrites a ref), `git merge-base` will exit with // an error and no output. It would be nice to find a positive test // for this instead, but I couldn't immediately come up with one. See // T4224. Assume this means there are no ancestors. list($err, $stdout) = $future->resolve(); if ($err) { $merge_base = null; } else { $merge_base = rtrim($stdout, "\n"); } $ref_update = $ref_updates[$key]; $ref_update->setMergeBase($merge_base); } return $ref_updates; } private function findGitChangeFlags(array $ref_updates) { assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog'); foreach ($ref_updates as $key => $ref_update) { $ref_old = $ref_update->getRefOld(); $ref_new = $ref_update->getRefNew(); $ref_type = $ref_update->getRefType(); $ref_flags = 0; $dangerous = null; if (($ref_old === self::EMPTY_HASH) && ($ref_new === self::EMPTY_HASH)) { // This happens if you try to delete a tag or branch which does not // exist by pushing directly to the ref. Git will warn about it but // allow it. Just call it a delete, without flagging it as dangerous. $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; } else if ($ref_old === self::EMPTY_HASH) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; } else if ($ref_new === self::EMPTY_HASH) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; $dangerous = pht( "The change you're attempting to push deletes the branch '%s'.", $ref_update->getRefName()); } } else { $merge_base = $ref_update->getMergeBase(); if ($merge_base == $ref_old) { // This is a fast-forward update to an existing branch. // These are safe. $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; } else { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE; // For now, we don't consider deleting or moving tags to be a // "dangerous" update. It's way harder to get wrong and should be easy // to recover from once we have better logging. Only add the dangerous // flag if this ref is a branch. if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; $dangerous = pht( "The change you're attempting to push updates the branch '%s' ". "from '%s' to '%s', but this is not a fast-forward. Pushes ". "which rewrite published branch history are dangerous.", $ref_update->getRefName(), $ref_update->getRefOldShort(), $ref_update->getRefNewShort()); } } } $ref_update->setChangeFlags($ref_flags); if ($dangerous !== null) { $ref_update->attachDangerousChangeDescription($dangerous); } } return $ref_updates; } private function findGitContentUpdates(array $ref_updates) { $flag_delete = PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; $futures = array(); foreach ($ref_updates as $key => $ref_update) { if ($ref_update->hasChangeFlags($flag_delete)) { // Deleting a branch or tag can never create any new commits. continue; } // NOTE: This piece of magic finds all new commits, by walking backward // from the new value to the value of *any* existing ref in the // repository. Particularly, this will cover the cases of a new branch, a // completely moved tag, etc. $futures[$key] = $this->getRepository()->getLocalCommandFuture( 'log --format=%s %s --not --all', '%H', $ref_update->getRefNew()); } $content_updates = array(); $futures = id(new FutureIterator($futures)) ->limit(8); foreach ($futures as $key => $future) { list($stdout) = $future->resolvex(); if (!strlen(trim($stdout))) { // This change doesn't have any new commits. One common case of this // is creating a new tag which points at an existing commit. continue; } $commits = phutil_split_lines($stdout, $retain_newlines = false); // If we're looking at a branch, mark all of the new commits as on that // branch. It's only possible for these commits to be on updated branches, // since any other branch heads are necessarily behind them. $branch_name = null; $ref_update = $ref_updates[$key]; $type_branch = PhabricatorRepositoryPushLog::REFTYPE_BRANCH; if ($ref_update->getRefType() == $type_branch) { $branch_name = $ref_update->getRefName(); } foreach ($commits as $commit) { if ($branch_name) { $this->gitCommits[$commit][] = $branch_name; } $content_updates[$commit] = $this->newPushLog() ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT) ->setRefNew($commit) ->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD); } } return $content_updates; } /* -( Custom )------------------------------------------------------------- */ private function applyCustomHooks(array $updates) { $args = $this->getOriginalArgv(); $stdin = $this->getStdin(); $console = PhutilConsole::getConsole(); $env = array( self::ENV_REPOSITORY => $this->getRepository()->getPHID(), self::ENV_USER => $this->getViewer()->getUsername(), self::ENV_REMOTE_PROTOCOL => $this->getRemoteProtocol(), self::ENV_REMOTE_ADDRESS => $this->getRemoteAddress(), ); $repository = $this->getRepository(); $env += $repository->getPassthroughEnvironmentalVariables(); $directories = $repository->getHookDirectories(); foreach ($directories as $directory) { $hooks = $this->getExecutablesInDirectory($directory); sort($hooks); foreach ($hooks as $hook) { // NOTE: We're explicitly running the hooks in sequential order to // make this more predictable. $future = id(new ExecFuture('%s %Ls', $hook, $args)) ->setEnv($env, $wipe_process_env = false) ->write($stdin); list($err, $stdout, $stderr) = $future->resolve(); if (!$err) { // This hook ran OK, but echo its output in case there was something // informative. $console->writeOut('%s', $stdout); $console->writeErr('%s', $stderr); continue; } $this->rejectCode = PhabricatorRepositoryPushLog::REJECT_EXTERNAL; $this->rejectDetails = basename($hook); throw new DiffusionCommitHookRejectException( pht( "This push was rejected by custom hook script '%s':\n\n%s%s", basename($hook), $stdout, $stderr)); } } } private function getExecutablesInDirectory($directory) { $executables = array(); if (!Filesystem::pathExists($directory)) { return $executables; } foreach (Filesystem::listDirectory($directory) as $path) { $full_path = $directory.DIRECTORY_SEPARATOR.$path; if (!is_executable($full_path)) { // Don't include non-executable files. continue; } if (basename($full_path) == 'README') { // Don't include README, even if it is marked as executable. It almost // certainly got caught in the crossfire of a sweeping `chmod`, since // users do this with some frequency. continue; } $executables[] = $full_path; } return $executables; } /* -( Mercurial )---------------------------------------------------------- */ private function findMercurialRefUpdates() { $hook = $this->getMercurialHook(); switch ($hook) { case 'pretxnchangegroup': return $this->findMercurialChangegroupRefUpdates(); case 'prepushkey': return $this->findMercurialPushKeyRefUpdates(); default: throw new Exception(pht('Unrecognized hook "%s"!', $hook)); } } private function findMercurialChangegroupRefUpdates() { $hg_node = getenv('HG_NODE'); if (!$hg_node) { throw new Exception( pht( 'Expected %s in environment!', 'HG_NODE')); } // NOTE: We need to make sure this is passed to subprocesses, or they won't // be able to see new commits. Mercurial uses this as a marker to determine // whether the pending changes are visible or not. $_ENV['HG_PENDING'] = getenv('HG_PENDING'); $repository = $this->getRepository(); $futures = array(); foreach (array('old', 'new') as $key) { $futures[$key] = $repository->getLocalCommandFuture( 'heads --template %s', '{node}\1{branch}\2'); } // Wipe HG_PENDING out of the old environment so we see the pre-commit // state of the repository. $futures['old']->updateEnv('HG_PENDING', null); $futures['commits'] = $repository->getLocalCommandFuture( 'log --rev %s --template %s', hgsprintf('%s:%s', $hg_node, 'tip'), '{node}\1{branch}\2'); // Resolve all of the futures now. We don't need the 'commits' future yet, // but it simplifies the logic to just get it out of the way. foreach (new FutureIterator($futures) as $future) { $future->resolve(); } list($commit_raw) = $futures['commits']->resolvex(); $commit_map = $this->parseMercurialCommits($commit_raw); $this->mercurialCommits = $commit_map; // NOTE: `hg heads` exits with an error code and no output if the repository // has no heads. Most commonly this happens on a new repository. We know // we can run `hg` successfully since the `hg log` above didn't error, so // just ignore the error code. list($err, $old_raw) = $futures['old']->resolve(); $old_refs = $this->parseMercurialHeads($old_raw); list($err, $new_raw) = $futures['new']->resolve(); $new_refs = $this->parseMercurialHeads($new_raw); $all_refs = array_keys($old_refs + $new_refs); $ref_updates = array(); foreach ($all_refs as $ref) { $old_heads = idx($old_refs, $ref, array()); $new_heads = idx($new_refs, $ref, array()); sort($old_heads); sort($new_heads); if (!$old_heads && !$new_heads) { // This should never be possible, as it makes no sense. Explode. throw new Exception( pht( 'Mercurial repository has no new or old heads for branch "%s" '. 'after push. This makes no sense; rejecting change.', $ref)); } if ($old_heads === $new_heads) { // No changes to this branch, so skip it. continue; } $stray_heads = array(); $head_map = array(); if ($old_heads && !$new_heads) { // This is a branch deletion with "--close-branch". foreach ($old_heads as $old_head) { $head_map[$old_head] = array(self::EMPTY_HASH); } } else if (count($old_heads) > 1) { // HORRIBLE: In Mercurial, branches can have multiple heads. If the // old branch had multiple heads, we need to figure out which new // heads descend from which old heads, so we can tell whether you're // actively creating new heads (dangerous) or just working in a // repository that's already full of garbage (strongly discouraged but // not as inherently dangerous). These cases should be very uncommon. // NOTE: We're only looking for heads on the same branch. The old // tip of the branch may be the branchpoint for other branches, but that // is OK. $dfutures = array(); foreach ($old_heads as $old_head) { $dfutures[$old_head] = $repository->getLocalCommandFuture( 'log --branch %s --rev %s --template %s', $ref, hgsprintf('(descendants(%s) and head())', $old_head), '{node}\1'); } foreach (new FutureIterator($dfutures) as $future_head => $dfuture) { list($stdout) = $dfuture->resolvex(); $descendant_heads = array_filter(explode("\1", $stdout)); if ($descendant_heads) { // This old head has at least one descendant in the push. $head_map[$future_head] = $descendant_heads; } else { // This old head has no descendants, so it is being deleted. $head_map[$future_head] = array(self::EMPTY_HASH); } } // Now, find all the new stray heads this push creates, if any. These // are new heads which do not descend from the old heads. $seen = array_fuse(array_mergev($head_map)); foreach ($new_heads as $new_head) { if ($new_head === self::EMPTY_HASH) { // If a branch head is being deleted, don't insert it as an add. continue; } if (empty($seen[$new_head])) { $head_map[self::EMPTY_HASH][] = $new_head; } } } else if ($old_heads) { $head_map[head($old_heads)] = $new_heads; } else { $head_map[self::EMPTY_HASH] = $new_heads; } foreach ($head_map as $old_head => $child_heads) { foreach ($child_heads as $new_head) { if ($new_head === $old_head) { continue; } $ref_flags = 0; $dangerous = null; if ($old_head == self::EMPTY_HASH) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; } else { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; } $deletes_existing_head = ($new_head == self::EMPTY_HASH); $splits_existing_head = (count($child_heads) > 1); $creates_duplicate_head = ($old_head == self::EMPTY_HASH) && (count($head_map) > 1); if ($splits_existing_head || $creates_duplicate_head) { $readable_child_heads = array(); foreach ($child_heads as $child_head) { $readable_child_heads[] = substr($child_head, 0, 12); } $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS; if ($splits_existing_head) { // We're splitting an existing head into two or more heads. // This is dangerous, and a super bad idea. Note that we're only // raising this if you're actively splitting a branch head. If a // head split in the past, we don't consider appends to it // to be dangerous. $dangerous = pht( "The change you're attempting to push splits the head of ". "branch '%s' into multiple heads: %s. This is inadvisable ". "and dangerous.", $ref, implode(', ', $readable_child_heads)); } else { // We're adding a second (or more) head to a branch. The new // head is not a descendant of any old head. $dangerous = pht( "The change you're attempting to push creates new, divergent ". "heads for the branch '%s': %s. This is inadvisable and ". "dangerous.", $ref, implode(', ', $readable_child_heads)); } } if ($deletes_existing_head) { // TODO: Somewhere in here we should be setting CHANGEFLAG_REWRITE // if we are also creating at least one other head to replace // this one. // NOTE: In Git, this is a dangerous change, but it is not dangerous // in Mercurial. Mercurial branches are version controlled, and // Mercurial does not prompt you for any special flags when pushing // a `--close-branch` commit by default. $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; } $ref_update = $this->newPushLog() ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BRANCH) ->setRefName($ref) ->setRefOld($old_head) ->setRefNew($new_head) ->setChangeFlags($ref_flags); if ($dangerous !== null) { $ref_update->attachDangerousChangeDescription($dangerous); } $ref_updates[] = $ref_update; } } } return $ref_updates; } private function findMercurialPushKeyRefUpdates() { $key_namespace = getenv('HG_NAMESPACE'); if ($key_namespace === 'phases') { // Mercurial changes commit phases as part of normal push operations. We // just ignore these, as they don't seem to represent anything // interesting. return array(); } $key_name = getenv('HG_KEY'); $key_old = getenv('HG_OLD'); if (!strlen($key_old)) { $key_old = null; } $key_new = getenv('HG_NEW'); if (!strlen($key_new)) { $key_new = null; } if ($key_namespace !== 'bookmarks') { throw new Exception( pht( "Unknown Mercurial key namespace '%s', with key '%s' (%s -> %s). ". "Rejecting push.", $key_namespace, $key_name, coalesce($key_old, pht('null')), coalesce($key_new, pht('null')))); } if ($key_old === $key_new) { // We get a callback when the bookmark doesn't change. Just ignore this, // as it's a no-op. return array(); } $ref_flags = 0; $merge_base = null; if ($key_old === null) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; } else if ($key_new === null) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE; } else { list($merge_base_raw) = $this->getRepository()->execxLocalCommand( 'log --template %s --rev %s', '{node}', hgsprintf('ancestor(%s, %s)', $key_old, $key_new)); if (strlen(trim($merge_base_raw))) { $merge_base = trim($merge_base_raw); } if ($merge_base && ($merge_base === $key_old)) { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; } else { $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE; } } $ref_update = $this->newPushLog() ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BOOKMARK) ->setRefName($key_name) ->setRefOld(coalesce($key_old, self::EMPTY_HASH)) ->setRefNew(coalesce($key_new, self::EMPTY_HASH)) ->setChangeFlags($ref_flags); return array($ref_update); } private function findMercurialContentUpdates(array $ref_updates) { $content_updates = array(); foreach ($this->mercurialCommits as $commit => $branches) { $content_updates[$commit] = $this->newPushLog() ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT) ->setRefNew($commit) ->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD); } return $content_updates; } private function parseMercurialCommits($raw) { $commits_lines = explode("\2", $raw); $commits_lines = array_filter($commits_lines); $commit_map = array(); foreach ($commits_lines as $commit_line) { list($node, $branch) = explode("\1", $commit_line); $commit_map[$node] = array($branch); } return $commit_map; } private function parseMercurialHeads($raw) { $heads_map = $this->parseMercurialCommits($raw); $heads = array(); foreach ($heads_map as $commit => $branches) { foreach ($branches as $branch) { $heads[$branch][] = $commit; } } return $heads; } /* -( Subversion )--------------------------------------------------------- */ private function findSubversionRefUpdates() { // Subversion doesn't have any kind of mutable ref metadata. return array(); } private function findSubversionContentUpdates(array $ref_updates) { list($youngest) = execx( 'svnlook youngest %s', $this->subversionRepository); $ref_new = (int)$youngest + 1; $ref_flags = 0; $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD; $ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND; $ref_content = $this->newPushLog() ->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT) ->setRefNew($ref_new) ->setChangeFlags($ref_flags); return array($ref_content); } /* -( Internals )---------------------------------------------------------- */ private function newPushLog() { // NOTE: We generate PHIDs up front so the Herald transcripts can pick them // up. $phid = id(new PhabricatorRepositoryPushLog())->generatePHID(); $device = AlmanacKeys::getLiveDevice(); if ($device) { $device_phid = $device->getPHID(); } else { $device_phid = null; } return PhabricatorRepositoryPushLog::initializeNewLog($this->getViewer()) ->setPHID($phid) ->setDevicePHID($device_phid) ->setRepositoryPHID($this->getRepository()->getPHID()) ->attachRepository($this->getRepository()) ->setEpoch(time()); } private function newPushEvent() { $viewer = $this->getViewer(); return PhabricatorRepositoryPushEvent::initializeNewEvent($viewer) ->setRepositoryPHID($this->getRepository()->getPHID()) ->setRemoteAddress($this->getRemoteAddress()) ->setRemoteProtocol($this->getRemoteProtocol()) ->setEpoch(time()); } public function loadChangesetsForCommit($identifier) { $byte_limit = HeraldCommitAdapter::getEnormousByteLimit(); $time_limit = HeraldCommitAdapter::getEnormousTimeLimit(); $vcs = $this->getRepository()->getVersionControlSystem(); switch ($vcs) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: // For git and hg, we can use normal commands. $drequest = DiffusionRequest::newFromDictionary( array( 'repository' => $this->getRepository(), 'user' => $this->getViewer(), 'commit' => $identifier, )); $raw_diff = DiffusionRawDiffQuery::newFromDiffusionRequest($drequest) ->setTimeout($time_limit) ->setByteLimit($byte_limit) ->setLinesOfContext(0) ->executeInline(); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: // TODO: This diff has 3 lines of context, which produces slightly // incorrect "added file content" and "removed file content" results. // This may also choke on binaries, but "svnlook diff" does not support // the "--diff-cmd" flag. // For subversion, we need to use `svnlook`. $future = new ExecFuture( 'svnlook diff -t %s %s', $this->subversionTransaction, $this->subversionRepository); $future->setTimeout($time_limit); $future->setStdoutSizeLimit($byte_limit); $future->setStderrSizeLimit($byte_limit); list($raw_diff) = $future->resolvex(); break; default: throw new Exception(pht("Unknown VCS '%s!'", $vcs)); } if (strlen($raw_diff) >= $byte_limit) { throw new Exception( pht( 'The raw text of this change is enormous (larger than %d '. 'bytes). Herald can not process it.', $byte_limit)); } if (!strlen($raw_diff)) { // If the commit is actually empty, just return no changesets. return array(); } $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($raw_diff); $diff = DifferentialDiff::newEphemeralFromRawChanges( $changes); return $diff->getChangesets(); } public function loadCommitRefForCommit($identifier) { $repository = $this->getRepository(); $vcs = $repository->getVersionControlSystem(); switch ($vcs) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: return id(new DiffusionLowLevelCommitQuery()) ->setRepository($repository) ->withIdentifier($identifier) ->execute(); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: // For subversion, we need to use `svnlook`. list($message) = execx( 'svnlook log -t %s %s', $this->subversionTransaction, $this->subversionRepository); return id(new DiffusionCommitRef()) ->setMessage($message); break; default: throw new Exception(pht("Unknown VCS '%s!'", $vcs)); } } public function loadBranches($identifier) { $repository = $this->getRepository(); $vcs = $repository->getVersionControlSystem(); switch ($vcs) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: return idx($this->gitCommits, $identifier, array()); case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: // NOTE: This will be "the branch the commit was made to", not // "a list of all branch heads which descend from the commit". // This is consistent with Mercurial, but possibly confusing. return idx($this->mercurialCommits, $identifier, array()); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: // Subversion doesn't have branches. return array(); } } private function loadCommitInfoForWorker(array $all_updates) { $type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT; $map = array(); foreach ($all_updates as $update) { if ($update->getRefType() != $type_commit) { continue; } $map[$update->getRefNew()] = array(); } foreach ($map as $identifier => $info) { $ref = $this->loadCommitRefForCommit($identifier); $map[$identifier] += array( 'summary' => $ref->getSummary(), 'branches' => $this->loadBranches($identifier), ); } return $map; } private function isInitialImport(array $all_updates) { $repository = $this->getRepository(); $vcs = $repository->getVersionControlSystem(); switch ($vcs) { case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: // There is no meaningful way to import history into Subversion by // pushing. return false; default: break; } // Now, apply a heuristic to guess whether this is a normal commit or // an initial import. We guess something is an initial import if: // // - the repository is currently empty; and // - it pushes more than 7 commits at once. // // The number "7" is chosen arbitrarily as seeming reasonable. We could // also look at author data (do the commits come from multiple different // authors?) and commit date data (is the oldest commit more than 48 hours // old), but we don't have immediate access to those and this simple - // heruistic might be good enough. + // heuristic might be good enough. $commit_count = 0; $type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT; foreach ($all_updates as $update) { if ($update->getRefType() != $type_commit) { continue; } $commit_count++; } if ($commit_count <= PhabricatorRepository::IMPORT_THRESHOLD) { // If this pushes a very small number of commits, assume it's an // initial commit or stack of a few initial commits. return false; } $any_commits = id(new DiffusionCommitQuery()) ->setViewer($this->getViewer()) ->withRepository($repository) ->setLimit(1) ->execute(); if ($any_commits) { // If the repository already has commits, this isn't an import. return false; } return true; } } diff --git a/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php b/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php index aca7367f8c..41c009455d 100644 --- a/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php +++ b/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php @@ -1,110 +1,110 @@ setName('git-lfs-authenticate'); $this->setArguments( array( array( 'name' => 'argv', 'wildcard' => true, ), )); } protected function identifyRepository() { return $this->loadRepositoryWithPath( $this->getLFSPathArgument(), PhabricatorRepositoryType::REPOSITORY_TYPE_GIT); } private function getLFSPathArgument() { return $this->getLFSArgument(0); } private function getLFSOperationArgument() { return $this->getLFSArgument(1); } private function getLFSArgument($position) { $args = $this->getArgs(); $argv = $args->getArg('argv'); if (!isset($argv[$position])) { throw new Exception( pht( 'Expected `git-lfs-authenticate `, but received '. 'too few arguments.')); } return $argv[$position]; } protected function executeRepositoryOperations() { $operation = $this->getLFSOperationArgument(); // NOTE: We aren't checking write access here, even for "upload". The // HTTP endpoint should be able to do that for us. switch ($operation) { case 'upload': case 'download': break; default: throw new Exception( pht( 'Git LFS operation "%s" is not supported by this server.', $operation)); } $repository = $this->getRepository(); if (!$repository->isGit()) { throw new Exception( pht( 'Repository "%s" is not a Git repository. Git LFS is only '. 'supported for Git repositories.', $repository->getDisplayName())); } if (!$repository->canUseGitLFS()) { throw new Exception( pht('Git LFS is not enabled for this repository.')); } // NOTE: This is usually the same as the default URI (which does not // need to be specified in the response), but the protocol or domain may // differ in some situations. $lfs_uri = $repository->getGitLFSURI('info/lfs'); - // Generate a temporary token to allow the user to acces LFS over HTTP. + // Generate a temporary token to allow the user to access LFS over HTTP. // This works even if normal HTTP repository operations are not available // on this host, and does not require the user to have a VCS password. $user = $this->getUser(); $authorization = DiffusionGitLFSTemporaryTokenType::newHTTPAuthorization( $repository, $user, $operation); $headers = array( 'authorization' => $authorization, ); $result = array( 'header' => $headers, 'href' => $lfs_uri, ); $result = phutil_json_encode($result); $this->writeIO($result); $this->waitForGitClient(); return 0; } } diff --git a/src/applications/diffusion/herald/HeraldCommitAdapter.php b/src/applications/diffusion/herald/HeraldCommitAdapter.php index 4687028418..bfcd8e3ccb 100644 --- a/src/applications/diffusion/herald/HeraldCommitAdapter.php +++ b/src/applications/diffusion/herald/HeraldCommitAdapter.php @@ -1,366 +1,366 @@ setViewer($viewer) ->withPHIDs(array($object->getPHID())) ->needCommitData(true) ->executeOne(); if (!$object) { throw new Exception( pht( 'Failed to reload commit ("%s") to fetch commit data.', $object->getPHID())); } return id(clone $this) ->setObject($object); } protected function initializeNewAdapter() { $this->commit = $this->newObject(); } public function setObject($object) { $this->commit = $object; return $this; } public function getObject() { return $this->commit; } public function getAdapterContentType() { return 'commit'; } public function getAdapterContentName() { return pht('Commits'); } public function getAdapterContentDescription() { return pht( "React to new commits appearing in tracked repositories.\n". "Commit rules can send email, flag commits, trigger audits, ". "and run build plans."); } public function supportsRuleType($rule_type) { switch ($rule_type) { case HeraldRuleTypeConfig::RULE_TYPE_GLOBAL: case HeraldRuleTypeConfig::RULE_TYPE_PERSONAL: case HeraldRuleTypeConfig::RULE_TYPE_OBJECT: return true; default: return false; } } public function canTriggerOnObject($object) { if ($object instanceof PhabricatorRepository) { return true; } if ($object instanceof PhabricatorProject) { return true; } return false; } public function getTriggerObjectPHIDs() { $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; $repository_phid = $this->getRepository()->getPHID(); $commit_phid = $this->getObject()->getPHID(); $phids = array(); $phids[] = $commit_phid; $phids[] = $repository_phid; // NOTE: This is projects for the repository, not for the commit. When - // Herald evalutes, commits normally can not have any project tags yet. + // Herald evaluates, commits normally can not have any project tags yet. $repository_project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $repository_phid, $project_type); foreach ($repository_project_phids as $phid) { $phids[] = $phid; } $phids = array_unique($phids); $phids = array_values($phids); return $phids; } public function explainValidTriggerObjects() { return pht('This rule can trigger for **repositories** and **projects**.'); } public function getHeraldName() { return $this->commit->getMonogram(); } public function loadAffectedPaths() { if ($this->affectedPaths === null) { $result = PhabricatorOwnerPathQuery::loadAffectedPaths( $this->getRepository(), $this->commit, PhabricatorUser::getOmnipotentUser()); $this->affectedPaths = $result; } return $this->affectedPaths; } public function loadAffectedPackages() { if ($this->affectedPackages === null) { $packages = PhabricatorOwnersPackage::loadAffectedPackages( $this->getRepository(), $this->loadAffectedPaths()); $this->affectedPackages = $packages; } return $this->affectedPackages; } public function loadAuditNeededPackages() { if ($this->auditNeededPackages === null) { $status_arr = array( PhabricatorAuditStatusConstants::AUDIT_REQUIRED, PhabricatorAuditStatusConstants::CONCERNED, ); $requests = id(new PhabricatorRepositoryAuditRequest()) ->loadAllWhere( 'commitPHID = %s AND auditStatus IN (%Ls)', $this->commit->getPHID(), $status_arr); $this->auditNeededPackages = $requests; } return $this->auditNeededPackages; } public function loadDifferentialRevision() { if ($this->affectedRevision === null) { $this->affectedRevision = false; $commit = $this->getObject(); $data = $commit->getCommitData(); $revision_id = $data->getCommitDetail('differential.revisionID'); if ($revision_id) { // NOTE: The Herald rule owner might not actually have access to // the revision, and can control which revision a commit is // associated with by putting text in the commit message. However, // the rules they can write against revisions don't actually expose // anything interesting, so it seems reasonable to load unconditionally // here. $revision = id(new DifferentialRevisionQuery()) ->withIDs(array($revision_id)) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->needReviewers(true) ->executeOne(); if ($revision) { $this->affectedRevision = $revision; } } } return $this->affectedRevision; } public static function getEnormousByteLimit() { return 1024 * 1024 * 1024; // 1GB } public static function getEnormousTimeLimit() { return 60 * 15; // 15 Minutes } private function loadCommitDiff() { $viewer = PhabricatorUser::getOmnipotentUser(); $byte_limit = self::getEnormousByteLimit(); $time_limit = self::getEnormousTimeLimit(); $diff_info = $this->callConduit( 'diffusion.rawdiffquery', array( 'commit' => $this->commit->getCommitIdentifier(), 'timeout' => $time_limit, 'byteLimit' => $byte_limit, 'linesOfContext' => 0, )); if ($diff_info['tooHuge']) { throw new Exception( pht( 'The raw text of this change is enormous (larger than %s byte(s)). '. 'Herald can not process it.', new PhutilNumber($byte_limit))); } if ($diff_info['tooSlow']) { throw new Exception( pht( 'The raw text of this change took too long to process (longer '. 'than %s second(s)). Herald can not process it.', new PhutilNumber($time_limit))); } $file_phid = $diff_info['filePHID']; $diff_file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($file_phid)) ->executeOne(); if (!$diff_file) { throw new Exception( pht( 'Failed to load diff ("%s") for this change.', $file_phid)); } $raw = $diff_file->loadFileData(); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($raw); $diff = DifferentialDiff::newEphemeralFromRawChanges( $changes); return $diff; } public function isDiffEnormous() { $this->loadDiffContent('*'); return ($this->commitDiff instanceof Exception); } public function loadDiffContent($type) { if ($this->commitDiff === null) { try { $this->commitDiff = $this->loadCommitDiff(); } catch (Exception $ex) { $this->commitDiff = $ex; phlog($ex); } } if ($this->commitDiff instanceof Exception) { $ex = $this->commitDiff; $ex_class = get_class($ex); $ex_message = pht('Failed to load changes: %s', $ex->getMessage()); return array( '<'.$ex_class.'>' => $ex_message, ); } $changes = $this->commitDiff->getChangesets(); $result = array(); foreach ($changes as $change) { $lines = array(); foreach ($change->getHunks() as $hunk) { switch ($type) { case '-': $lines[] = $hunk->makeOldFile(); break; case '+': $lines[] = $hunk->makeNewFile(); break; case '*': $lines[] = $hunk->makeChanges(); break; default: throw new Exception(pht("Unknown content selection '%s'!", $type)); } } $result[$change->getFilename()] = implode("\n", $lines); } return $result; } public function loadIsMergeCommit() { $parents = $this->callConduit( 'diffusion.commitparentsquery', array( 'commit' => $this->getObject()->getCommitIdentifier(), )); return (count($parents) > 1); } private function callConduit($method, array $params) { $viewer = PhabricatorUser::getOmnipotentUser(); $drequest = DiffusionRequest::newFromDictionary( array( 'user' => $viewer, 'repository' => $this->getRepository(), 'commit' => $this->commit->getCommitIdentifier(), )); return DiffusionQuery::callConduitWithDiffusionRequest( $viewer, $drequest, $method, $params); } private function getRepository() { return $this->getObject()->getRepository(); } /* -( HarbormasterBuildableAdapterInterface )------------------------------ */ public function getHarbormasterBuildablePHID() { return $this->getObject()->getPHID(); } public function getHarbormasterContainerPHID() { return $this->getObject()->getRepository()->getPHID(); } public function getQueuedHarbormasterBuildRequests() { return $this->buildRequests; } public function queueHarbormasterBuildRequest( HarbormasterBuildRequest $request) { $this->buildRequests[] = $request; } } diff --git a/src/applications/diffusion/protocol/DiffusionCommandEngine.php b/src/applications/diffusion/protocol/DiffusionCommandEngine.php index b787745a02..53a086db33 100644 --- a/src/applications/diffusion/protocol/DiffusionCommandEngine.php +++ b/src/applications/diffusion/protocol/DiffusionCommandEngine.php @@ -1,298 +1,298 @@ canBuildForRepository($repository)) { return id(clone $engine) ->setRepository($repository); } } throw new Exception( pht( 'No registered command engine can build commands for this '. 'repository ("%s").', $repository->getDisplayName())); } private static function newCommandEngines() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->execute(); } abstract protected function canBuildForRepository( PhabricatorRepository $repository); abstract protected function newFormattedCommand($pattern, array $argv); abstract protected function newCustomEnvironment(); public function setRepository(PhabricatorRepository $repository) { $this->repository = $repository; return $this; } public function getRepository() { return $this->repository; } public function setURI(PhutilURI $uri) { $this->uri = $uri; $this->setProtocol($uri->getProtocol()); return $this; } public function getURI() { return $this->uri; } public function setProtocol($protocol) { $this->protocol = $protocol; return $this; } public function getProtocol() { return $this->protocol; } public function getDisplayProtocol() { return $this->getProtocol().'://'; } public function setCredentialPHID($credential_phid) { $this->credentialPHID = $credential_phid; return $this; } public function getCredentialPHID() { return $this->credentialPHID; } public function setArgv(array $argv) { $this->argv = $argv; return $this; } public function getArgv() { return $this->argv; } public function setPassthru($passthru) { $this->passthru = $passthru; return $this; } public function getPassthru() { return $this->passthru; } public function setConnectAsDevice($connect_as_device) { $this->connectAsDevice = $connect_as_device; return $this; } public function getConnectAsDevice() { return $this->connectAsDevice; } public function setSudoAsDaemon($sudo_as_daemon) { $this->sudoAsDaemon = $sudo_as_daemon; return $this; } public function getSudoAsDaemon() { return $this->sudoAsDaemon; } public function newFuture() { $argv = $this->newCommandArgv(); $env = $this->newCommandEnvironment(); if ($this->getSudoAsDaemon()) { $command = call_user_func_array('csprintf', $argv); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $argv = array('%C', $command); } if ($this->getPassthru()) { $future = newv('PhutilExecPassthru', $argv); } else { $future = newv('ExecFuture', $argv); } $future->setEnv($env); return $future; } private function newCommandArgv() { $argv = $this->argv; $pattern = $argv[0]; $argv = array_slice($argv, 1); list($pattern, $argv) = $this->newFormattedCommand($pattern, $argv); return array_merge(array($pattern), $argv); } private function newCommandEnvironment() { $env = $this->newCommonEnvironment() + $this->newCustomEnvironment(); foreach ($env as $key => $value) { if ($value === null) { unset($env[$key]); } } return $env; } private function newCommonEnvironment() { $repository = $this->getRepository(); $env = array(); // NOTE: Force the language to "en_US.UTF-8", which overrides locale // settings. This makes stuff print in English instead of, e.g., French, // so we can parse the output of some commands, error messages, etc. $env['LANG'] = 'en_US.UTF-8'; // Propagate PHABRICATOR_ENV explicitly. For discussion, see T4155. $env['PHABRICATOR_ENV'] = PhabricatorEnv::getSelectedEnvironmentName(); $as_device = $this->getConnectAsDevice(); $credential_phid = $this->getCredentialPHID(); if ($as_device) { $device = AlmanacKeys::getLiveDevice(); if (!$device) { throw new Exception( pht( - 'Attempting to build a reposiory command (for repository "%s") '. + 'Attempting to build a repository command (for repository "%s") '. 'as device, but this host ("%s") is not configured as a cluster '. 'device.', $repository->getDisplayName(), php_uname('n'))); } if ($credential_phid) { throw new Exception( pht( 'Attempting to build a repository command (for repository "%s"), '. 'but the CommandEngine is configured to connect as both the '. 'current cluster device ("%s") and with a specific credential '. '("%s"). These options are mutually exclusive. Connections must '. 'authenticate as one or the other, not both.', $repository->getDisplayName(), $device->getName(), $credential_phid)); } } if ($this->isAnySSHProtocol()) { if ($credential_phid) { $env['PHABRICATOR_CREDENTIAL'] = $credential_phid; } if ($as_device) { $env['PHABRICATOR_AS_DEVICE'] = 1; } } $env += $repository->getPassthroughEnvironmentalVariables(); return $env; } public function isSSHProtocol() { return ($this->getProtocol() == 'ssh'); } public function isSVNProtocol() { return ($this->getProtocol() == 'svn'); } public function isSVNSSHProtocol() { return ($this->getProtocol() == 'svn+ssh'); } public function isHTTPProtocol() { return ($this->getProtocol() == 'http'); } public function isHTTPSProtocol() { return ($this->getProtocol() == 'https'); } public function isAnyHTTPProtocol() { return ($this->isHTTPProtocol() || $this->isHTTPSProtocol()); } public function isAnySSHProtocol() { return ($this->isSSHProtocol() || $this->isSVNSSHProtocol()); } public function isCredentialSupported() { return ($this->getPassphraseProvidesCredentialType() !== null); } public function isCredentialOptional() { if ($this->isAnySSHProtocol()) { return false; } return true; } public function getPassphraseCredentialLabel() { if ($this->isAnySSHProtocol()) { return pht('SSH Key'); } if ($this->isAnyHTTPProtocol() || $this->isSVNProtocol()) { return pht('Password'); } return null; } public function getPassphraseDefaultCredentialType() { if ($this->isAnySSHProtocol()) { return PassphraseSSHPrivateKeyTextCredentialType::CREDENTIAL_TYPE; } if ($this->isAnyHTTPProtocol() || $this->isSVNProtocol()) { return PassphrasePasswordCredentialType::CREDENTIAL_TYPE; } return null; } public function getPassphraseProvidesCredentialType() { if ($this->isAnySSHProtocol()) { return PassphraseSSHPrivateKeyCredentialType::PROVIDES_TYPE; } if ($this->isAnyHTTPProtocol() || $this->isSVNProtocol()) { return PassphrasePasswordCredentialType::PROVIDES_TYPE; } return null; } protected function getSSHWrapper() { $root = dirname(phutil_get_library_root('phabricator')); return $root.'/bin/ssh-connect'; } } diff --git a/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php b/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php index 32a73867e5..251935a6dc 100644 --- a/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php +++ b/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php @@ -1,132 +1,132 @@ array('cmds', '*'), 'between' => array('pairs'), 'branchmap' => array(), 'branches' => array('nodes'), 'capabilities' => array(), 'changegroup' => array('roots'), 'changegroupsubset' => array('bases heads'), 'debugwireargs' => array('one two *'), 'getbundle' => array('*'), 'heads' => array(), 'hello' => array(), 'known' => array('nodes', '*'), 'listkeys' => array('namespace'), 'lookup' => array('key'), 'pushkey' => array('namespace', 'key', 'old', 'new'), 'stream_out' => array(''), 'unbundle' => array('heads'), ); if (!isset($commands[$command])) { throw new Exception(pht("Unknown Mercurial command '%s!", $command)); } return $commands[$command]; } public static function isReadOnlyCommand($command) { $read_only = array( 'between' => true, 'branchmap' => true, 'branches' => true, 'capabilities' => true, 'changegroup' => true, 'changegroupsubset' => true, 'debugwireargs' => true, 'getbundle' => true, 'heads' => true, 'hello' => true, 'known' => true, 'listkeys' => true, 'lookup' => true, 'stream_out' => true, ); // Notably, the write commands are "pushkey" and "unbundle". The // "batch" command is theoretically read only, but we require explicit // analysis of the actual commands. return isset($read_only[$command]); } public static function isReadOnlyBatchCommand($cmds) { if (!strlen($cmds)) { // We expect a "batch" command to always have a "cmds" string, so err // on the side of caution and throw if we don't get any data here. This // either indicates a mangled command from the client or a programming // error in our code. throw new Exception(pht("Expected nonempty '%s' specification!", 'cmds')); } // For "batch" we get a "cmds" argument like: // // heads ;known nodes= // // We need to examine the commands (here, "heads" and "known") to make sure // they're all read-only. // NOTE: Mercurial has some code to escape semicolons, but it does not // actually function for command separation. For example, these two batch // commands will produce completely different results (the former will run // the lookup; the latter will fail with a parser error): // // lookup key=a:xb;lookup key=z* 0 // lookup key=a:;b;lookup key=z* 0 // ^ // | // +-- Note semicolon. // // So just split unconditionally. $cmds = explode(';', $cmds); foreach ($cmds as $sub_cmd) { $name = head(explode(' ', $sub_cmd, 2)); if (!self::isReadOnlyCommand($name)) { return false; } } return true; } /** If the server version is running 3.4+ it will respond * with 'bundle2' capability in the format of "bundle2=(url-encoding)". - * Until we maange to properly package up bundles to send back we + * Until we manage to properly package up bundles to send back we * disallow the client from knowing we speak bundle2 by removing it * from the capabilities listing. * * The format of the capabilities string is: "a space separated list * of strings representing what commands the server supports" * @link https://www.mercurial-scm.org/wiki/CommandServer#Protocol * * @param string $capabilities - The string of capabilities to * strip the bundle2 capability from. This is expected to be * the space-separated list of strings resulting from the * querying the 'capabilities' command. * * @return string The resulting space-separated list of capabilities * which no longer contains the 'bundle2' capability. This is meant * to replace the original $body to send back to client. */ public static function filterBundle2Capability($capabilities) { $parts = explode(' ', $capabilities); foreach ($parts as $key => $part) { if (preg_match('/^bundle2=/', $part)) { unset($parts[$key]); break; } } return implode(' ', $parts); } } diff --git a/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php b/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php index e822bb76b0..35bb4d4acf 100644 --- a/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php +++ b/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php @@ -1,766 +1,766 @@ repository = $repository; return $this; } public function getRepository() { return $this->repository; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setLog(DiffusionRepositoryClusterEngineLogInterface $log) { $this->logger = $log; return $this; } /* -( Cluster Synchronization )-------------------------------------------- */ /** * Synchronize repository version information after creating a repository. * * This initializes working copy versions for all currently bound devices to * 0, so that we don't get stuck making an ambiguous choice about which * devices are leaders when we later synchronize before a read. * * @task sync */ public function synchronizeWorkingCopyAfterCreation() { if (!$this->shouldEnableSynchronization(false)) { return; } $repository = $this->getRepository(); $repository_phid = $repository->getPHID(); $service = $repository->loadAlmanacService(); if (!$service) { throw new Exception(pht('Failed to load repository cluster service.')); } $bindings = $service->getActiveBindings(); foreach ($bindings as $binding) { PhabricatorRepositoryWorkingCopyVersion::updateVersion( $repository_phid, $binding->getDevicePHID(), 0); } return $this; } /** * @task sync */ public function synchronizeWorkingCopyAfterHostingChange() { if (!$this->shouldEnableSynchronization(false)) { return; } $repository = $this->getRepository(); $repository_phid = $repository->getPHID(); $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions( $repository_phid); $versions = mpull($versions, null, 'getDevicePHID'); // After converting a hosted repository to observed, or vice versa, we // need to reset version numbers because the clocks for observed and hosted // repositories run on different units. // We identify all the cluster leaders and reset their version to 0. // We identify all the cluster followers and demote them. // This allows the cluster to start over again at version 0 but keep the // same leaders. if ($versions) { $max_version = (int)max(mpull($versions, 'getRepositoryVersion')); foreach ($versions as $version) { $device_phid = $version->getDevicePHID(); if ($version->getRepositoryVersion() == $max_version) { PhabricatorRepositoryWorkingCopyVersion::updateVersion( $repository_phid, $device_phid, 0); } else { PhabricatorRepositoryWorkingCopyVersion::demoteDevice( $repository_phid, $device_phid); } } } return $this; } /** * @task sync */ public function synchronizeWorkingCopyBeforeRead() { if (!$this->shouldEnableSynchronization(true)) { return; } $repository = $this->getRepository(); $repository_phid = $repository->getPHID(); $device = AlmanacKeys::getLiveDevice(); $device_phid = $device->getPHID(); $read_lock = PhabricatorRepositoryWorkingCopyVersion::getReadLock( $repository_phid, $device_phid); $lock_wait = phutil_units('2 minutes in seconds'); $this->logLine( pht( 'Waiting up to %s second(s) for a cluster read lock on "%s"...', new PhutilNumber($lock_wait), $device->getName())); try { $start = PhabricatorTime::getNow(); $read_lock->lock($lock_wait); $waited = (PhabricatorTime::getNow() - $start); if ($waited) { $this->logLine( pht( 'Acquired read lock after %s second(s).', new PhutilNumber($waited))); } else { $this->logLine( pht( 'Acquired read lock immediately.')); } } catch (Exception $ex) { throw new PhutilProxyException( pht( 'Failed to acquire read lock after waiting %s second(s). You '. 'may be able to retry later.', new PhutilNumber($lock_wait)), $ex); } $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions( $repository_phid); $versions = mpull($versions, null, 'getDevicePHID'); $this_version = idx($versions, $device_phid); if ($this_version) { $this_version = (int)$this_version->getRepositoryVersion(); } else { $this_version = -1; } if ($versions) { // This is the normal case, where we have some version information and // can identify which nodes are leaders. If the current node is not a // leader, we want to fetch from a leader and then update our version. $max_version = (int)max(mpull($versions, 'getRepositoryVersion')); if ($max_version > $this_version) { if ($repository->isHosted()) { $fetchable = array(); foreach ($versions as $version) { if ($version->getRepositoryVersion() == $max_version) { $fetchable[] = $version->getDevicePHID(); } } $this->synchronizeWorkingCopyFromDevices($fetchable); } else { $this->synchornizeWorkingCopyFromRemote(); } PhabricatorRepositoryWorkingCopyVersion::updateVersion( $repository_phid, $device_phid, $max_version); } else { $this->logLine( pht( 'Device "%s" is already a cluster leader and does not need '. 'to be synchronized.', $device->getName())); } $result_version = $max_version; } else { // If no version records exist yet, we need to be careful, because we // can not tell which nodes are leaders. // There might be several nodes with arbitrary existing data, and we have // no way to tell which one has the "right" data. If we pick wrong, we // might erase some or all of the data in the repository. - // Since this is dangeorus, we refuse to guess unless there is only one + // Since this is dangerous, we refuse to guess unless there is only one // device. If we're the only device in the group, we obviously must be // a leader. $service = $repository->loadAlmanacService(); if (!$service) { throw new Exception(pht('Failed to load repository cluster service.')); } $bindings = $service->getActiveBindings(); $device_map = array(); foreach ($bindings as $binding) { $device_map[$binding->getDevicePHID()] = true; } if (count($device_map) > 1) { throw new Exception( pht( 'Repository "%s" exists on more than one device, but no device '. 'has any repository version information. Phabricator can not '. 'guess which copy of the existing data is authoritative. Promote '. - 'a device or see "Ambigous Leaders" in the documentation.', + 'a device or see "Ambiguous Leaders" in the documentation.', $repository->getDisplayName())); } if (empty($device_map[$device->getPHID()])) { throw new Exception( pht( 'Repository "%s" is being synchronized on device "%s", but '. 'this device is not bound to the corresponding cluster '. 'service ("%s").', $repository->getDisplayName(), $device->getName(), $service->getName())); } // The current device is the only device in service, so it must be a // leader. We can safely have any future nodes which come online read // from it. PhabricatorRepositoryWorkingCopyVersion::updateVersion( $repository_phid, $device_phid, 0); $result_version = 0; } $read_lock->unlock(); return $result_version; } /** * @task sync */ public function synchronizeWorkingCopyBeforeWrite() { if (!$this->shouldEnableSynchronization(true)) { return; } $repository = $this->getRepository(); $viewer = $this->getViewer(); $repository_phid = $repository->getPHID(); $device = AlmanacKeys::getLiveDevice(); $device_phid = $device->getPHID(); $table = new PhabricatorRepositoryWorkingCopyVersion(); $locked_connection = $table->establishConnection('w'); $write_lock = PhabricatorRepositoryWorkingCopyVersion::getWriteLock( $repository_phid); $write_lock->useSpecificConnection($locked_connection); $lock_wait = phutil_units('2 minutes in seconds'); $this->logLine( pht( 'Waiting up to %s second(s) for a cluster write lock...', new PhutilNumber($lock_wait))); try { $start = PhabricatorTime::getNow(); $write_lock->lock($lock_wait); $waited = (PhabricatorTime::getNow() - $start); if ($waited) { $this->logLine( pht( 'Acquired write lock after %s second(s).', new PhutilNumber($waited))); } else { $this->logLine( pht( 'Acquired write lock immediately.')); } } catch (Exception $ex) { throw new PhutilProxyException( pht( 'Failed to acquire write lock after waiting %s second(s). You '. 'may be able to retry later.', new PhutilNumber($lock_wait)), $ex); } $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions( $repository_phid); foreach ($versions as $version) { if (!$version->getIsWriting()) { continue; } throw new Exception( pht( 'An previous write to this repository was interrupted; refusing '. 'new writes. This issue requires operator intervention to resolve, '. 'see "Write Interruptions" in the "Cluster: Repositories" in the '. 'documentation for instructions.')); } try { $max_version = $this->synchronizeWorkingCopyBeforeRead(); } catch (Exception $ex) { $write_lock->unlock(); throw $ex; } $pid = getmypid(); $hash = Filesystem::readRandomCharacters(12); $this->clusterWriteOwner = "{$pid}.{$hash}"; PhabricatorRepositoryWorkingCopyVersion::willWrite( $locked_connection, $repository_phid, $device_phid, array( 'userPHID' => $viewer->getPHID(), 'epoch' => PhabricatorTime::getNow(), 'devicePHID' => $device_phid, ), $this->clusterWriteOwner); $this->clusterWriteVersion = $max_version; $this->clusterWriteLock = $write_lock; } public function synchronizeWorkingCopyAfterDiscovery($new_version) { if (!$this->shouldEnableSynchronization(true)) { return; } $repository = $this->getRepository(); $repository_phid = $repository->getPHID(); if ($repository->isHosted()) { return; } $viewer = $this->getViewer(); $device = AlmanacKeys::getLiveDevice(); $device_phid = $device->getPHID(); // NOTE: We are not holding a lock here because this method is only called // from PhabricatorRepositoryDiscoveryEngine, which already holds a device // lock. Even if we do race here and record an older version, the // consequences are mild: we only do extra work to correct it later. $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions( $repository_phid); $versions = mpull($versions, null, 'getDevicePHID'); $this_version = idx($versions, $device_phid); if ($this_version) { $this_version = (int)$this_version->getRepositoryVersion(); } else { $this_version = -1; } if ($new_version > $this_version) { PhabricatorRepositoryWorkingCopyVersion::updateVersion( $repository_phid, $device_phid, $new_version); } } /** * @task sync */ public function synchronizeWorkingCopyAfterWrite() { if (!$this->shouldEnableSynchronization(true)) { return; } if (!$this->clusterWriteLock) { throw new Exception( pht( 'Trying to synchronize after write, but not holding a write '. 'lock!')); } $repository = $this->getRepository(); $repository_phid = $repository->getPHID(); $device = AlmanacKeys::getLiveDevice(); $device_phid = $device->getPHID(); // It is possible that we've lost the global lock while receiving the push. // For example, the master database may have been restarted between the // time we acquired the global lock and now, when the push has finished. // We wrote a durable lock while we were holding the the global lock, // essentially upgrading our lock. We can still safely release this upgraded // lock even if we're no longer holding the global lock. // If we fail to release the lock, the repository will be frozen until // an operator can figure out what happened, so we try pretty hard to // reconnect to the database and release the lock. $now = PhabricatorTime::getNow(); $duration = phutil_units('5 minutes in seconds'); $try_until = $now + $duration; $did_release = false; $already_failed = false; while (PhabricatorTime::getNow() <= $try_until) { try { // NOTE: This means we're still bumping the version when pushes fail. We // could select only un-rejected events instead to bump a little less // often. $new_log = id(new PhabricatorRepositoryPushEventQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withRepositoryPHIDs(array($repository_phid)) ->setLimit(1) ->executeOne(); $old_version = $this->clusterWriteVersion; if ($new_log) { $new_version = $new_log->getID(); } else { $new_version = $old_version; } PhabricatorRepositoryWorkingCopyVersion::didWrite( $repository_phid, $device_phid, $this->clusterWriteVersion, $new_version, $this->clusterWriteOwner); $did_release = true; break; } catch (AphrontConnectionQueryException $ex) { $connection_exception = $ex; } catch (AphrontConnectionLostQueryException $ex) { $connection_exception = $ex; } if (!$already_failed) { $already_failed = true; $this->logLine( pht('CRITICAL. Failed to release cluster write lock!')); $this->logLine( pht( 'The connection to the master database was lost while receiving '. 'the write.')); $this->logLine( pht( 'This process will spend %s more second(s) attempting to '. 'recover, then give up.', new PhutilNumber($duration))); } sleep(1); } if ($did_release) { if ($already_failed) { $this->logLine( pht('RECOVERED. Link to master database was restored.')); } $this->logLine(pht('Released cluster write lock.')); } else { throw new Exception( pht( 'Failed to reconnect to master database and release held write '. 'lock ("%s") on device "%s" for repository "%s" after trying '. 'for %s seconds(s). This repository will be frozen.', $this->clusterWriteOwner, $device->getName(), $this->getDisplayName(), new PhutilNumber($duration))); } // We can continue even if we've lost this lock, everything is still // consistent. try { $this->clusterWriteLock->unlock(); } catch (Exception $ex) { // Ignore. } $this->clusterWriteLock = null; $this->clusterWriteOwner = null; } /* -( Internals )---------------------------------------------------------- */ /** * @task internal */ private function shouldEnableSynchronization($require_device) { $repository = $this->getRepository(); $service_phid = $repository->getAlmanacServicePHID(); if (!$service_phid) { return false; } if (!$repository->supportsSynchronization()) { return false; } if ($require_device) { $device = AlmanacKeys::getLiveDevice(); if (!$device) { return false; } } return true; } /** * @task internal */ private function synchornizeWorkingCopyFromRemote() { $repository = $this->getRepository(); $device = AlmanacKeys::getLiveDevice(); $local_path = $repository->getLocalPath(); $fetch_uri = $repository->getRemoteURIEnvelope(); if ($repository->isGit()) { $this->requireWorkingCopy(); $argv = array( 'fetch --prune -- %P %s', $fetch_uri, '+refs/*:refs/*', ); } else { throw new Exception(pht('Remote sync only supported for git!')); } $future = DiffusionCommandEngine::newCommandEngine($repository) ->setArgv($argv) ->setSudoAsDaemon(true) ->setCredentialPHID($repository->getCredentialPHID()) ->setURI($repository->getRemoteURIObject()) ->newFuture(); $future->setCWD($local_path); try { $future->resolvex(); } catch (Exception $ex) { $this->logLine( pht( 'Synchronization of "%s" from remote failed: %s', $device->getName(), $ex->getMessage())); throw $ex; } } /** * @task internal */ private function synchronizeWorkingCopyFromDevices(array $device_phids) { $repository = $this->getRepository(); $service = $repository->loadAlmanacService(); if (!$service) { throw new Exception(pht('Failed to load repository cluster service.')); } $device_map = array_fuse($device_phids); $bindings = $service->getActiveBindings(); $fetchable = array(); foreach ($bindings as $binding) { // We can't fetch from nodes which don't have the newest version. $device_phid = $binding->getDevicePHID(); if (empty($device_map[$device_phid])) { continue; } // TODO: For now, only fetch over SSH. We could support fetching over // HTTP eventually. if ($binding->getAlmanacPropertyValue('protocol') != 'ssh') { continue; } $fetchable[] = $binding; } if (!$fetchable) { throw new Exception( pht( 'Leader lost: no up-to-date nodes in repository cluster are '. 'fetchable.')); } $caught = null; foreach ($fetchable as $binding) { try { $this->synchronizeWorkingCopyFromBinding($binding); $caught = null; break; } catch (Exception $ex) { $caught = $ex; } } if ($caught) { throw $caught; } } /** * @task internal */ private function synchronizeWorkingCopyFromBinding($binding) { $repository = $this->getRepository(); $device = AlmanacKeys::getLiveDevice(); $this->logLine( pht( 'Synchronizing this device ("%s") from cluster leader ("%s") before '. 'read.', $device->getName(), $binding->getDevice()->getName())); $fetch_uri = $repository->getClusterRepositoryURIFromBinding($binding); $local_path = $repository->getLocalPath(); if ($repository->isGit()) { $this->requireWorkingCopy(); $argv = array( 'fetch --prune -- %s %s', $fetch_uri, '+refs/*:refs/*', ); } else { throw new Exception(pht('Binding sync only supported for git!')); } $future = DiffusionCommandEngine::newCommandEngine($repository) ->setArgv($argv) ->setConnectAsDevice(true) ->setSudoAsDaemon(true) ->setURI($fetch_uri) ->newFuture(); $future->setCWD($local_path); try { $future->resolvex(); } catch (Exception $ex) { $this->logLine( pht( 'Synchronization of "%s" from leader "%s" failed: %s', $device->getName(), $binding->getDevice()->getName(), $ex->getMessage())); throw $ex; } } /** * @task internal */ private function logLine($message) { return $this->logText("# {$message}\n"); } /** * @task internal */ private function logText($message) { $log = $this->logger; if ($log) { $log->writeClusterEngineLogMessage($message); } return $this; } private function requireWorkingCopy() { $repository = $this->getRepository(); $local_path = $repository->getLocalPath(); if (!Filesystem::pathExists($local_path)) { $device = AlmanacKeys::getLiveDevice(); throw new Exception( pht( 'Repository "%s" does not have a working copy on this device '. 'yet, so it can not be synchronized. Wait for the daemons to '. 'construct one or run `bin/repository update %s` on this host '. '("%s") to build it explicitly.', $repository->getDisplayName(), $repository->getMonogram(), $device->getName())); } } } diff --git a/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php b/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php index 22ff1d61f0..0217c97d58 100644 --- a/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php +++ b/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php @@ -1,187 +1,187 @@ refs = $refs; return $this; } public function withTypes(array $types) { $this->types = $types; return $this; } protected function executeQuery() { if (!$this->refs) { return array(); } switch ($this->getRepository()->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = $this->resolveGitAndMercurialRefs(); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = $this->resolveSubversionRefs(); break; default: throw new Exception(pht('Unsupported repository type!')); } if ($this->types !== null) { $result = $this->filterRefsByType($result, $this->types); } return $result; } /** * Resolve refs in Git and Mercurial repositories. * * We can resolve commit hashes from the commits table, and branch and tag * names from the refcursor table. */ private function resolveGitAndMercurialRefs() { $repository = $this->getRepository(); $conn_r = $repository->establishConnection('r'); $results = array(); $prefixes = array(); foreach ($this->refs as $ref) { // We require refs to look like hashes and be at least 4 characters // long. This is similar to the behavior of git. if (preg_match('/^[a-f0-9]{4,}$/', $ref)) { $prefixes[] = qsprintf( $conn_r, '(commitIdentifier LIKE %>)', $ref); } } if ($prefixes) { $commits = queryfx_all( $conn_r, 'SELECT commitIdentifier FROM %T WHERE repositoryID = %s AND %Q', id(new PhabricatorRepositoryCommit())->getTableName(), $repository->getID(), implode(' OR ', $prefixes)); foreach ($commits as $commit) { $hash = $commit['commitIdentifier']; foreach ($this->refs as $ref) { if (!strncmp($hash, $ref, strlen($ref))) { $results[$ref][] = array( 'type' => 'commit', 'identifier' => $hash, ); } } } } $name_hashes = array(); foreach ($this->refs as $ref) { $name_hashes[PhabricatorHash::digestForIndex($ref)] = $ref; } $cursors = queryfx_all( $conn_r, 'SELECT c.refNameHash, c.refType, p.commitIdentifier, p.isClosed FROM %T c JOIN %T p ON p.cursorID = c.id WHERE c.repositoryPHID = %s AND c.refNameHash IN (%Ls)', id(new PhabricatorRepositoryRefCursor())->getTableName(), id(new PhabricatorRepositoryRefPosition())->getTableName(), $repository->getPHID(), array_keys($name_hashes)); foreach ($cursors as $cursor) { if (isset($name_hashes[$cursor['refNameHash']])) { $results[$name_hashes[$cursor['refNameHash']]][] = array( 'type' => $cursor['refType'], 'identifier' => $cursor['commitIdentifier'], 'closed' => (bool)$cursor['isClosed'], ); // TODO: In Git, we don't store (and thus don't return) the hash // of the tag itself. It would be vaguely nice to do this. } } return $results; } /** * Resolve refs in Subversion repositories. * * We can resolve all numeric identifiers and the keyword `HEAD`. */ private function resolveSubversionRefs() { $repository = $this->getRepository(); $max_commit = id(new PhabricatorRepositoryCommit()) ->loadOneWhere( 'repositoryID = %d ORDER BY epoch DESC, id DESC LIMIT 1', $repository->getID()); if (!$max_commit) { // This repository is empty or hasn't parsed yet, so none of the refs are // going to resolve. return array(); } $max_commit_id = (int)$max_commit->getCommitIdentifier(); $results = array(); foreach ($this->refs as $ref) { if ($ref == 'HEAD') { // Resolve "HEAD" to mean "the most recent commit". $results[$ref][] = array( 'type' => 'commit', 'identifier' => $max_commit_id, ); continue; } if (!preg_match('/^\d+$/', $ref)) { // This ref is non-numeric, so it doesn't resolve to anything. continue; } // Resolve other commits if we can deduce their existence. // TODO: When we import only part of a repository, we won't necessarily // have all of the smaller commits. Should we fail to resolve them here // for repositories with a subpath? It might let us simplify other things // elsewhere. if ((int)$ref <= $max_commit_id) { $results[$ref][] = array( 'type' => 'commit', 'identifier' => (int)$ref, ); } } return $results; } } diff --git a/src/applications/diffusion/query/DiffusionCommitQuery.php b/src/applications/diffusion/query/DiffusionCommitQuery.php index 1521f5770c..da8937910e 100644 --- a/src/applications/diffusion/query/DiffusionCommitQuery.php +++ b/src/applications/diffusion/query/DiffusionCommitQuery.php @@ -1,646 +1,646 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withAuthorPHIDs(array $phids) { $this->authorPHIDs = $phids; return $this; } /** * Load commits by partial or full identifiers, e.g. "rXab82393", "rX1234", * or "a9caf12". When an identifier matches multiple commits, they will all * be returned; callers should be prepared to deal with more results than * they queried for. */ public function withIdentifiers(array $identifiers) { // Some workflows (like blame lookups) can pass in large numbers of // duplicate identifiers. We only care about unique identifiers, so // get rid of duplicates immediately. $identifiers = array_fuse($identifiers); $this->identifiers = $identifiers; return $this; } /** * Look up commits in a specific repository. This is a shorthand for calling * @{method:withDefaultRepository} and @{method:withRepositoryIDs}. */ public function withRepository(PhabricatorRepository $repository) { $this->withDefaultRepository($repository); $this->withRepositoryIDs(array($repository->getID())); return $this; } /** * Look up commits in a specific repository. Prefer - * @{method:withRepositoryIDs}; the underyling table is keyed by ID such + * @{method:withRepositoryIDs}; the underlying table is keyed by ID such * that this method requires a separate initial query to map PHID to ID. */ public function withRepositoryPHIDs(array $phids) { $this->repositoryPHIDs = $phids; return $this; } /** * If a default repository is provided, ambiguous commit identifiers will * be assumed to belong to the default repository. * * For example, "r123" appearing in a commit message in repository X is * likely to be unambiguously "rX123". Normally the reference would be * considered ambiguous, but if you provide a default repository it will * be correctly resolved. */ public function withDefaultRepository(PhabricatorRepository $repository) { $this->defaultRepository = $repository; return $this; } public function withRepositoryIDs(array $repository_ids) { $this->repositoryIDs = $repository_ids; return $this; } public function needCommitData($need) { $this->needCommitData = $need; return $this; } public function needDrafts($need) { $this->needDrafts = $need; return $this; } public function needAuditRequests($need) { $this->needAuditRequests = $need; return $this; } public function withAuditIDs(array $ids) { $this->auditIDs = $ids; return $this; } public function withAuditorPHIDs(array $auditor_phids) { $this->auditorPHIDs = $auditor_phids; return $this; } public function withResponsiblePHIDs(array $responsible_phids) { $this->responsiblePHIDs = $responsible_phids; return $this; } public function withPackagePHIDs(array $package_phids) { $this->packagePHIDs = $package_phids; return $this; } public function withUnreachable($unreachable) { $this->unreachable = $unreachable; return $this; } public function withStatuses(array $statuses) { $this->statuses = $statuses; return $this; } public function withEpochRange($min, $max) { $this->epochMin = $min; $this->epochMax = $max; return $this; } public function withImporting($importing) { $this->importing = $importing; return $this; } public function getIdentifierMap() { if ($this->identifierMap === null) { throw new Exception( pht( 'You must %s the query before accessing the identifier map.', 'execute()')); } return $this->identifierMap; } protected function getPrimaryTableAlias() { return 'commit'; } protected function willExecute() { if ($this->identifierMap === null) { $this->identifierMap = array(); } } public function newResultObject() { return new PhabricatorRepositoryCommit(); } protected function loadPage() { return $this->loadStandardPage($this->newResultObject()); } protected function willFilterPage(array $commits) { $repository_ids = mpull($commits, 'getRepositoryID', 'getRepositoryID'); $repos = id(new PhabricatorRepositoryQuery()) ->setViewer($this->getViewer()) ->withIDs($repository_ids) ->execute(); $min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH; $result = array(); foreach ($commits as $key => $commit) { $repo = idx($repos, $commit->getRepositoryID()); if ($repo) { $commit->attachRepository($repo); } else { $this->didRejectResult($commit); unset($commits[$key]); continue; } // Build the identifierMap if ($this->identifiers !== null) { $ids = $this->identifiers; $prefixes = array( 'r'.$commit->getRepository()->getCallsign(), 'r'.$commit->getRepository()->getCallsign().':', 'R'.$commit->getRepository()->getID().':', '', // No prefix is valid too and will only match the commitIdentifier ); $suffix = $commit->getCommitIdentifier(); if ($commit->getRepository()->isSVN()) { foreach ($prefixes as $prefix) { if (isset($ids[$prefix.$suffix])) { $result[$prefix.$suffix][] = $commit; } } } else { // This awkward construction is so we can link the commits up in O(N) // time instead of O(N^2). for ($ii = $min_qualified; $ii <= strlen($suffix); $ii++) { $part = substr($suffix, 0, $ii); foreach ($prefixes as $prefix) { if (isset($ids[$prefix.$part])) { $result[$prefix.$part][] = $commit; } } } } } } if ($result) { foreach ($result as $identifier => $matching_commits) { if (count($matching_commits) == 1) { $result[$identifier] = head($matching_commits); } else { // This reference is ambiguous (it matches more than one commit) so // don't link it. unset($result[$identifier]); } } $this->identifierMap += $result; } return $commits; } protected function didFilterPage(array $commits) { $viewer = $this->getViewer(); if ($this->needCommitData) { $data = id(new PhabricatorRepositoryCommitData())->loadAllWhere( 'commitID in (%Ld)', mpull($commits, 'getID')); $data = mpull($data, null, 'getCommitID'); foreach ($commits as $commit) { $commit_data = idx($data, $commit->getID()); if (!$commit_data) { $commit_data = new PhabricatorRepositoryCommitData(); } $commit->attachCommitData($commit_data); } } if ($this->needAuditRequests) { $requests = id(new PhabricatorRepositoryAuditRequest())->loadAllWhere( 'commitPHID IN (%Ls)', mpull($commits, 'getPHID')); $requests = mgroup($requests, 'getCommitPHID'); foreach ($commits as $commit) { $audit_requests = idx($requests, $commit->getPHID(), array()); $commit->attachAudits($audit_requests); foreach ($audit_requests as $audit_request) { $audit_request->attachCommit($commit); } } } if ($this->needDrafts) { PhabricatorDraftEngine::attachDrafts( $viewer, $commits); } return $commits; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->repositoryPHIDs !== null) { $map_repositories = id(new PhabricatorRepositoryQuery()) ->setViewer($this->getViewer()) ->withPHIDs($this->repositoryPHIDs) ->execute(); if (!$map_repositories) { throw new PhabricatorEmptyQueryException(); } $repository_ids = mpull($map_repositories, 'getID'); if ($this->repositoryIDs !== null) { $repository_ids = array_merge($repository_ids, $this->repositoryIDs); } $this->withRepositoryIDs($repository_ids); } if ($this->ids !== null) { $where[] = qsprintf( $conn, 'commit.id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'commit.phid IN (%Ls)', $this->phids); } if ($this->repositoryIDs !== null) { $where[] = qsprintf( $conn, 'commit.repositoryID IN (%Ld)', $this->repositoryIDs); } if ($this->authorPHIDs !== null) { $where[] = qsprintf( $conn, 'commit.authorPHID IN (%Ls)', $this->authorPHIDs); } if ($this->epochMin !== null) { $where[] = qsprintf( $conn, 'commit.epoch >= %d', $this->epochMin); } if ($this->epochMax !== null) { $where[] = qsprintf( $conn, 'commit.epoch <= %d', $this->epochMax); } if ($this->importing !== null) { if ($this->importing) { $where[] = qsprintf( $conn, '(commit.importStatus & %d) != %d', PhabricatorRepositoryCommit::IMPORTED_ALL, PhabricatorRepositoryCommit::IMPORTED_ALL); } else { $where[] = qsprintf( $conn, '(commit.importStatus & %d) = %d', PhabricatorRepositoryCommit::IMPORTED_ALL, PhabricatorRepositoryCommit::IMPORTED_ALL); } } if ($this->identifiers !== null) { $min_unqualified = PhabricatorRepository::MINIMUM_UNQUALIFIED_HASH; $min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH; $refs = array(); $bare = array(); foreach ($this->identifiers as $identifier) { $matches = null; preg_match('/^(?:[rR]([A-Z]+:?|[0-9]+:))?(.*)$/', $identifier, $matches); $repo = nonempty(rtrim($matches[1], ':'), null); $commit_identifier = nonempty($matches[2], null); if ($repo === null) { if ($this->defaultRepository) { $repo = $this->defaultRepository->getPHID(); } } if ($repo === null) { if (strlen($commit_identifier) < $min_unqualified) { continue; } $bare[] = $commit_identifier; } else { $refs[] = array( 'repository' => $repo, 'identifier' => $commit_identifier, ); } } $sql = array(); foreach ($bare as $identifier) { $sql[] = qsprintf( $conn, '(commit.commitIdentifier LIKE %> AND '. 'LENGTH(commit.commitIdentifier) = 40)', $identifier); } if ($refs) { $repositories = ipull($refs, 'repository'); $repos = id(new PhabricatorRepositoryQuery()) ->setViewer($this->getViewer()) ->withIdentifiers($repositories); $repos->execute(); $repos = $repos->getIdentifierMap(); foreach ($refs as $key => $ref) { $repo = idx($repos, $ref['repository']); if (!$repo) { continue; } if ($repo->isSVN()) { if (!ctype_digit((string)$ref['identifier'])) { continue; } $sql[] = qsprintf( $conn, '(commit.repositoryID = %d AND commit.commitIdentifier = %s)', $repo->getID(), // NOTE: Because the 'commitIdentifier' column is a string, MySQL // ignores the index if we hand it an integer. Hand it a string. // See T3377. (int)$ref['identifier']); } else { if (strlen($ref['identifier']) < $min_qualified) { continue; } $identifier = $ref['identifier']; if (strlen($identifier) == 40) { // MySQL seems to do slightly better with this version if the // clause, so issue it if we have a full commit hash. $sql[] = qsprintf( $conn, '(commit.repositoryID = %d AND commit.commitIdentifier = %s)', $repo->getID(), $identifier); } else { $sql[] = qsprintf( $conn, '(commit.repositoryID = %d AND commit.commitIdentifier LIKE %>)', $repo->getID(), $identifier); } } } } if (!$sql) { // If we discarded all possible identifiers (e.g., they all referenced // bogus repositories or were all too short), make sure the query finds // nothing. throw new PhabricatorEmptyQueryException( pht('No commit identifiers.')); } $where[] = '('.implode(' OR ', $sql).')'; } if ($this->auditIDs !== null) { $where[] = qsprintf( $conn, 'auditor.id IN (%Ld)', $this->auditIDs); } if ($this->auditorPHIDs !== null) { $where[] = qsprintf( $conn, 'auditor.auditorPHID IN (%Ls)', $this->auditorPHIDs); } if ($this->responsiblePHIDs !== null) { $where[] = qsprintf( $conn, '(audit.auditorPHID IN (%Ls) OR commit.authorPHID IN (%Ls))', $this->responsiblePHIDs, $this->responsiblePHIDs); } if ($this->statuses !== null) { $where[] = qsprintf( $conn, 'commit.auditStatus IN (%Ls)', $this->statuses); } if ($this->packagePHIDs !== null) { $where[] = qsprintf( $conn, 'package.dst IN (%Ls)', $this->packagePHIDs); } if ($this->unreachable !== null) { if ($this->unreachable) { $where[] = qsprintf( $conn, '(commit.importStatus & %d) = %d', PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE, PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE); } else { $where[] = qsprintf( $conn, '(commit.importStatus & %d) = 0', PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE); } } return $where; } protected function didFilterResults(array $filtered) { if ($this->identifierMap) { foreach ($this->identifierMap as $name => $commit) { if (isset($filtered[$commit->getPHID()])) { unset($this->identifierMap[$name]); } } } } private function shouldJoinAuditor() { return ($this->auditIDs || $this->auditorPHIDs); } private function shouldJoinAudit() { return (bool)$this->responsiblePHIDs; } private function shouldJoinOwners() { return (bool)$this->packagePHIDs; } protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $join = parent::buildJoinClauseParts($conn); $audit_request = new PhabricatorRepositoryAuditRequest(); if ($this->shouldJoinAuditor()) { $join[] = qsprintf( $conn, 'JOIN %T auditor ON commit.phid = auditor.commitPHID', $audit_request->getTableName()); } if ($this->shouldJoinAudit()) { $join[] = qsprintf( $conn, 'LEFT JOIN %T audit ON commit.phid = audit.commitPHID', $audit_request->getTableName()); } if ($this->shouldJoinOwners()) { $join[] = qsprintf( $conn, 'JOIN %T package ON commit.phid = package.src AND package.type = %s', PhabricatorEdgeConfig::TABLE_NAME_EDGE, DiffusionCommitHasPackageEdgeType::EDGECONST); } return $join; } protected function shouldGroupQueryResultRows() { if ($this->shouldJoinAuditor()) { return true; } if ($this->shouldJoinAudit()) { return true; } if ($this->shouldJoinOwners()) { return true; } return parent::shouldGroupQueryResultRows(); } public function getQueryApplicationClass() { return 'PhabricatorDiffusionApplication'; } public function getOrderableColumns() { return parent::getOrderableColumns() + array( 'epoch' => array( 'table' => $this->getPrimaryTableAlias(), 'column' => 'epoch', 'type' => 'int', 'reverse' => false, ), ); } protected function getPagingValueMap($cursor, array $keys) { $commit = $this->loadCursorObject($cursor); return array( 'id' => $commit->getID(), 'epoch' => $commit->getEpoch(), ); } public function getBuiltinOrders() { $parent = parent::getBuiltinOrders(); // Rename the default ID-based orders. $parent['importnew'] = array( 'name' => pht('Import Date (Newest First)'), ) + $parent['newest']; $parent['importold'] = array( 'name' => pht('Import Date (Oldest First)'), ) + $parent['oldest']; return array( 'newest' => array( 'vector' => array('epoch', 'id'), 'name' => pht('Commit Date (Newest First)'), ), 'oldest' => array( 'vector' => array('-epoch', '-id'), 'name' => pht('Commit Date (Oldest First)'), ), ) + $parent; } } diff --git a/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php b/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php index ff1cc9f3c5..30ec8d9f6d 100644 --- a/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php +++ b/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php @@ -1,82 +1,82 @@ setAuditStatus( PhabricatorAuditCommitStatusConstants::CONCERN_RAISED); } public function applyExternalEffects($object, $value) { $status = PhabricatorAuditStatusConstants::CONCERNED; $actor = $this->getActor(); $this->applyAuditorEffect($object, $actor, $value, $status); } protected function validateAction($object, PhabricatorUser $viewer) { if ($this->isViewerCommitAuthor($object, $viewer)) { throw new Exception( pht( 'You can not raise a concern with this commit because you are '. 'the commit author. You can only raise concerns with commits '. 'you did not author.')); } // Even if you've already raised a concern, you can raise again as long - // as the author requsted you verify. + // as the author requested you verify. $state_verify = PhabricatorAuditCommitStatusConstants::NEEDS_VERIFICATION; if ($this->isViewerFullyRejected($object, $viewer)) { if ($object->getAuditStatus() != $state_verify) { throw new Exception( pht( 'You can not raise a concern with this commit because you have '. 'already raised a concern with it.')); } } } public function getTitle() { return pht( '%s raised a concern with this commit.', $this->renderAuthor()); } public function getTitleForFeed() { return pht( '%s raised a concern with %s.', $this->renderAuthor(), $this->renderObject()); } } diff --git a/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php b/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php index f9b3fc5161..a44261f7a4 100644 --- a/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php +++ b/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php @@ -1,73 +1,73 @@ setAuditStatus( PhabricatorAuditCommitStatusConstants::NEEDS_VERIFICATION); } protected function validateAction($object, PhabricatorUser $viewer) { if (!$this->isViewerCommitAuthor($object, $viewer)) { throw new Exception( pht( 'You can not request verification of this commit because you '. 'are not the author.')); } $status = $object->getAuditStatus(); if ($status != PhabricatorAuditCommitStatusConstants::CONCERN_RAISED) { throw new Exception( pht( 'You can not request verification of this commit because no '. - 'auditors have raised conerns with it.')); + 'auditors have raised concerns with it.')); } } public function getTitle() { return pht( '%s requested verification of this commit.', $this->renderAuthor()); } public function getTitleForFeed() { return pht( '%s requested verification of %s.', $this->renderAuthor(), $this->renderObject()); } } diff --git a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php index 76a81d7ef1..a050a859e3 100644 --- a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php +++ b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php @@ -1,489 +1,489 @@ getCustomFieldSpecifications(); if ($this->shouldUseConcurrentResourceLimit()) { $fields += array( 'allocator.limit' => array( 'name' => pht('Limit'), 'caption' => pht( 'Maximum number of resources this blueprint can have active '. 'concurrently.'), 'type' => 'int', ), ); } return $fields; } protected function getCustomFieldSpecifications() { return array(); } public function getViewer() { return PhabricatorUser::getOmnipotentUser(); } /* -( Lease Acquisition )-------------------------------------------------- */ /** * Enforce basic checks on lease/resource compatibility. Allows resources to * reject leases if they are incompatible, even if the resource types match. * * For example, if a resource represents a 32-bit host, this method might * reject leases that need a 64-bit host. The blueprint might also reject * a resource if the lease needs 8GB of RAM and the resource only has 6GB * free. * * This method should not acquire locks or expect anything to be locked. This * is a coarse compatibility check between a lease and a resource. * * @param DrydockBlueprint Concrete blueprint to allocate for. - * @param DrydockResource Candidiate resource to allocate the lease on. + * @param DrydockResource Candidate resource to allocate the lease on. * @param DrydockLease Pending lease that wants to allocate here. * @return bool True if the resource and lease are compatible. * @task lease */ abstract public function canAcquireLeaseOnResource( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease); /** - * Acquire a lease. Allows resources to peform setup as leases are brought + * Acquire a lease. Allows resources to perform setup as leases are brought * online. * * If acquisition fails, throw an exception. * * @param DrydockBlueprint Blueprint which built the resource. * @param DrydockResource Resource to acquire a lease on. * @param DrydockLease Requested lease. * @return void * @task lease */ abstract public function acquireLease( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease); /** * @return void * @task lease */ public function activateLease( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease) { throw new PhutilMethodNotImplementedException(); } /** * React to a lease being released. * * This callback is primarily useful for automatically releasing resources * once all leases are released. * * @param DrydockBlueprint Blueprint which built the resource. * @param DrydockResource Resource a lease was released on. * @param DrydockLease Recently released lease. * @return void * @task lease */ abstract public function didReleaseLease( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease); /** * Destroy any temporary data associated with a lease. * * If a lease creates temporary state while held, destroy it here. * * @param DrydockBlueprint Blueprint which built the resource. * @param DrydockResource Resource the lease is acquired on. * @param DrydockLease The lease being destroyed. * @return void * @task lease */ abstract public function destroyLease( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease); /* -( Resource Allocation )------------------------------------------------ */ /** * Enforce fundamental implementation/lease checks. Allows implementations to * reject a lease which no concrete blueprint can ever satisfy. * * For example, if a lease only builds ARM hosts and the lease needs a * PowerPC host, it may be rejected here. * * This is the earliest rejection phase, and followed by * @{method:canEverAllocateResourceForLease}. * * This method should not actually check if a resource can be allocated * right now, or even if a blueprint which can allocate a suitable resource * really exists, only if some blueprint may conceivably exist which could * plausibly be able to build a suitable resource. * * @param DrydockLease Requested lease. * @return bool True if some concrete blueprint of this implementation's * type might ever be able to build a resource for the lease. * @task resource */ abstract public function canAnyBlueprintEverAllocateResourceForLease( DrydockLease $lease); /** * Enforce basic blueprint/lease checks. Allows blueprints to reject a lease * which they can not build a resource for. * * This is the second rejection phase. It follows * @{method:canAnyBlueprintEverAllocateResourceForLease} and is followed by * @{method:canAllocateResourceForLease}. * * This method should not check if a resource can be built right now, only * if the blueprint as configured may, at some time, be able to build a * suitable resource. * * @param DrydockBlueprint Blueprint which may be asked to allocate a * resource. * @param DrydockLease Requested lease. * @return bool True if this blueprint can eventually build a suitable * resource for the lease, as currently configured. * @task resource */ abstract public function canEverAllocateResourceForLease( DrydockBlueprint $blueprint, DrydockLease $lease); /** * Enforce basic availability limits. Allows blueprints to reject resource * allocation if they are currently overallocated. * * This method should perform basic capacity/limit checks. For example, if * it has a limit of 6 resources and currently has 6 resources allocated, * it might reject new leases. * * This method should not acquire locks or expect locks to be acquired. This * is a coarse check to determine if the operation is likely to succeed * right now without needing to acquire locks. * * It is expected that this method will sometimes return `true` (indicating * that a resource can be allocated) but find that another allocator has * eaten up free capacity by the time it actually tries to build a resource. * This is normal and the allocator will recover from it. * * @param DrydockBlueprint The blueprint which may be asked to allocate a * resource. * @param DrydockLease Requested lease. * @return bool True if this blueprint appears likely to be able to allocate * a suitable resource. * @task resource */ abstract public function canAllocateResourceForLease( DrydockBlueprint $blueprint, DrydockLease $lease); /** * Allocate a suitable resource for a lease. * * This method MUST acquire, hold, and manage locks to prevent multiple * allocations from racing. World state is not locked before this method is * called. Blueprints are entirely responsible for any lock handling they * need to perform. * * @param DrydockBlueprint The blueprint which should allocate a resource. * @param DrydockLease Requested lease. * @return DrydockResource Allocated resource. * @task resource */ abstract public function allocateResource( DrydockBlueprint $blueprint, DrydockLease $lease); /** * @task resource */ public function activateResource( DrydockBlueprint $blueprint, DrydockResource $resource) { throw new PhutilMethodNotImplementedException(); } /** * Destroy any temporary data associated with a resource. * * If a resource creates temporary state when allocated, destroy that state * here. For example, you might shut down a virtual host or destroy a working * copy on disk. * * @param DrydockBlueprint Blueprint which built the resource. * @param DrydockResource Resource being destroyed. * @return void * @task resource */ abstract public function destroyResource( DrydockBlueprint $blueprint, DrydockResource $resource); /** * Get a human readable name for a resource. * * @param DrydockBlueprint Blueprint which built the resource. * @param DrydockResource Resource to get the name of. * @return string Human-readable resource name. * @task resource */ abstract public function getResourceName( DrydockBlueprint $blueprint, DrydockResource $resource); /* -( Resource Interfaces )------------------------------------------------ */ abstract public function getInterface( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease, $type); /* -( Logging )------------------------------------------------------------ */ public static function getAllBlueprintImplementations() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->execute(); } public static function getNamedImplementation($class) { return idx(self::getAllBlueprintImplementations(), $class); } protected function newResourceTemplate(DrydockBlueprint $blueprint) { $resource = id(new DrydockResource()) ->setBlueprintPHID($blueprint->getPHID()) ->attachBlueprint($blueprint) ->setType($this->getType()) ->setStatus(DrydockResourceStatus::STATUS_PENDING); // Pre-allocate the resource PHID. $resource->setPHID($resource->generatePHID()); return $resource; } protected function newLease(DrydockBlueprint $blueprint) { return DrydockLease::initializeNewLease() ->setAuthorizingPHID($blueprint->getPHID()); } protected function requireActiveLease(DrydockLease $lease) { $lease_status = $lease->getStatus(); switch ($lease_status) { case DrydockLeaseStatus::STATUS_PENDING: case DrydockLeaseStatus::STATUS_ACQUIRED: throw new PhabricatorWorkerYieldException(15); case DrydockLeaseStatus::STATUS_ACTIVE: return; default: throw new Exception( pht( 'Lease ("%s") is in bad state ("%s"), expected "%s".', $lease->getPHID(), $lease_status, DrydockLeaseStatus::STATUS_ACTIVE)); } } /** * Does this implementation use concurrent resource limits? * * Implementations can override this method to opt into standard limit * behavior, which provides a simple concurrent resource limit. * * @return bool True to use limits. */ protected function shouldUseConcurrentResourceLimit() { return false; } /** * Get the effective concurrent resource limit for this blueprint. * * @param DrydockBlueprint Blueprint to get the limit for. * @return int|null Limit, or `null` for no limit. */ protected function getConcurrentResourceLimit(DrydockBlueprint $blueprint) { if ($this->shouldUseConcurrentResourceLimit()) { $limit = $blueprint->getFieldValue('allocator.limit'); $limit = (int)$limit; if ($limit > 0) { return $limit; } else { return null; } } return null; } protected function getConcurrentResourceLimitSlotLock( DrydockBlueprint $blueprint) { $limit = $this->getConcurrentResourceLimit($blueprint); if ($limit === null) { return; } $blueprint_phid = $blueprint->getPHID(); // TODO: This logic shouldn't do anything awful, but is a little silly. It // would be nice to unify the "huge limit" and "small limit" cases // eventually but it's a little tricky. // If the limit is huge, just pick a random slot. This is just stopping // us from exploding if someone types a billion zillion into the box. if ($limit > 1024) { $slot = mt_rand(0, $limit - 1); return "allocator({$blueprint_phid}).limit({$slot})"; } // For reasonable limits, actually check for an available slot. $locks = DrydockSlotLock::loadLocks($blueprint_phid); $locks = mpull($locks, null, 'getLockKey'); $slots = range(0, $limit - 1); shuffle($slots); foreach ($slots as $slot) { $slot_lock = "allocator({$blueprint_phid}).limit({$slot})"; if (empty($locks[$slot_lock])) { return $slot_lock; } } // If we found no free slot, just return whatever we checked last (which // is just a random slot). There's a small chance we'll get lucky and the // lock will be free by the time we try to take it, but usually we'll just // fail to grab the lock, throw an appropriate lock exception, and get back // on the right path to retry later. return $slot_lock; } /** * Apply standard limits on resource allocation rate. * * @param DrydockBlueprint The blueprint requesting an allocation. * @return bool True if further allocations should be limited. */ protected function shouldLimitAllocatingPoolSize( DrydockBlueprint $blueprint) { // TODO: If this mechanism sticks around, these values should be // configurable by the blueprint implementation. // Limit on total number of active resources. $total_limit = $this->getConcurrentResourceLimit($blueprint); // Always allow at least this many allocations to be in flight at once. $min_allowed = 1; // Allow this fraction of allocating resources as a fraction of active // resources. $growth_factor = 0.25; $resource = new DrydockResource(); $conn_r = $resource->establishConnection('r'); $counts = queryfx_all( $conn_r, 'SELECT status, COUNT(*) N FROM %T WHERE blueprintPHID = %s AND status != %s GROUP BY status', $resource->getTableName(), $blueprint->getPHID(), DrydockResourceStatus::STATUS_DESTROYED); $counts = ipull($counts, 'N', 'status'); $n_alloc = idx($counts, DrydockResourceStatus::STATUS_PENDING, 0); $n_active = idx($counts, DrydockResourceStatus::STATUS_ACTIVE, 0); $n_broken = idx($counts, DrydockResourceStatus::STATUS_BROKEN, 0); $n_released = idx($counts, DrydockResourceStatus::STATUS_RELEASED, 0); // If we're at the limit on total active resources, limit additional // allocations. if ($total_limit !== null) { $n_total = ($n_alloc + $n_active + $n_broken + $n_released); if ($n_total >= $total_limit) { return true; } } // If the number of in-flight allocations is fewer than the minimum number // of allowed allocations, don't impose a limit. if ($n_alloc < $min_allowed) { return false; } $allowed_alloc = (int)ceil($n_active * $growth_factor); // If the number of in-flight allocation is fewer than the number of // allowed allocations according to the pool growth factor, don't impose // a limit. if ($n_alloc < $allowed_alloc) { return false; } return true; } } diff --git a/src/applications/drydock/storage/DrydockAuthorization.php b/src/applications/drydock/storage/DrydockAuthorization.php index cfd186c9d3..32e694918c 100644 --- a/src/applications/drydock/storage/DrydockAuthorization.php +++ b/src/applications/drydock/storage/DrydockAuthorization.php @@ -1,256 +1,256 @@ true, self::CONFIG_COLUMN_SCHEMA => array( 'blueprintAuthorizationState' => 'text32', 'objectAuthorizationState' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'key_unique' => array( 'columns' => array('objectPHID', 'blueprintPHID'), 'unique' => true, ), 'key_blueprint' => array( 'columns' => array('blueprintPHID', 'blueprintAuthorizationState'), ), 'key_object' => array( 'columns' => array('objectPHID', 'objectAuthorizationState'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( DrydockAuthorizationPHIDType::TYPECONST); } public function attachBlueprint(DrydockBlueprint $blueprint) { $this->blueprint = $blueprint; return $this; } public function getBlueprint() { return $this->assertAttached($this->blueprint); } public function attachObject($object) { $this->object = $object; return $this; } public function getObject() { return $this->assertAttached($this->object); } public static function getBlueprintStateIcon($state) { $map = array( self::BLUEPRINTAUTH_REQUESTED => 'fa-exclamation-circle pink', self::BLUEPRINTAUTH_AUTHORIZED => 'fa-check-circle green', self::BLUEPRINTAUTH_DECLINED => 'fa-times red', ); return idx($map, $state, null); } public static function getBlueprintStateName($state) { $map = array( self::BLUEPRINTAUTH_REQUESTED => pht('Requested'), self::BLUEPRINTAUTH_AUTHORIZED => pht('Authorized'), self::BLUEPRINTAUTH_DECLINED => pht('Declined'), ); return idx($map, $state, pht('', $state)); } public static function getObjectStateName($state) { $map = array( self::OBJECTAUTH_ACTIVE => pht('Active'), self::OBJECTAUTH_INACTIVE => pht('Inactive'), ); return idx($map, $state, pht('', $state)); } public function isAuthorized() { $state = $this->getBlueprintAuthorizationState(); return ($state == self::BLUEPRINTAUTH_AUTHORIZED); } /** - * Apply external authorization effects after a user chagnes the value of a + * Apply external authorization effects after a user changes the value of a * blueprint selector control an object. * * @param PhabricatorUser User applying the change. * @param phid Object PHID change is being applied to. * @param list Old blueprint PHIDs. * @param list New blueprint PHIDs. * @return void */ public static function applyAuthorizationChanges( PhabricatorUser $viewer, $object_phid, array $old, array $new) { $old_phids = array_fuse($old); $new_phids = array_fuse($new); $rem_phids = array_diff_key($old_phids, $new_phids); $add_phids = array_diff_key($new_phids, $old_phids); $altered_phids = $rem_phids + $add_phids; if (!$altered_phids) { return; } $authorizations = id(new DrydockAuthorizationQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withObjectPHIDs(array($object_phid)) ->withBlueprintPHIDs($altered_phids) ->execute(); $authorizations = mpull($authorizations, null, 'getBlueprintPHID'); $state_active = self::OBJECTAUTH_ACTIVE; $state_inactive = self::OBJECTAUTH_INACTIVE; $state_requested = self::BLUEPRINTAUTH_REQUESTED; // Disable the object side of the authorization for any existing // authorizations. foreach ($rem_phids as $rem_phid) { $authorization = idx($authorizations, $rem_phid); if (!$authorization) { continue; } $authorization ->setObjectAuthorizationState($state_inactive) ->save(); } // For new authorizations, either add them or reactivate them depending // on the current state. foreach ($add_phids as $add_phid) { $needs_update = false; $authorization = idx($authorizations, $add_phid); if (!$authorization) { $authorization = id(new DrydockAuthorization()) ->setObjectPHID($object_phid) ->setObjectAuthorizationState($state_active) ->setBlueprintPHID($add_phid) ->setBlueprintAuthorizationState($state_requested); $needs_update = true; } else { $current_state = $authorization->getObjectAuthorizationState(); if ($current_state != $state_active) { $authorization->setObjectAuthorizationState($state_active); $needs_update = true; } } if ($needs_update) { $authorization->save(); } } } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { return $this->getBlueprint()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getBlueprint()->hasAutomaticCapability($capability, $viewer); } public function describeAutomaticCapability($capability) { return pht( 'An authorization inherits the policies of the blueprint it '. 'authorizes access to.'); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('blueprintPHID') ->setType('phid') ->setDescription(pht( 'PHID of the blueprint this request was made for.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('blueprintAuthorizationState') ->setType('map') ->setDescription(pht('Authorization state of this request.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('objectPHID') ->setType('phid') ->setDescription(pht( 'PHID of the object which requested authorization.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('objectAuthorizationState') ->setType('map') ->setDescription(pht('Authorization state of the requesting object.')), ); } public function getFieldValuesForConduit() { $blueprint_state = $this->getBlueprintAuthorizationState(); $object_state = $this->getObjectAuthorizationState(); return array( 'blueprintPHID' => $this->getBlueprintPHID(), 'blueprintAuthorizationState' => array( 'value' => $blueprint_state, 'name' => self::getBlueprintStateName($blueprint_state), ), 'objectPHID' => $this->getObjectPHID(), 'objectAuthorizationState' => array( 'value' => $object_state, 'name' => self::getObjectStateName($object_state), ), ); } public function getConduitSearchAttachments() { return array( ); } } diff --git a/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php index a41e57fbf8..d73506f286 100644 --- a/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php +++ b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php @@ -1,854 +1,854 @@ getTaskDataValue('leasePHID'); $hash = PhabricatorHash::digestForIndex($lease_phid); $lock_key = 'drydock.lease:'.$hash; $lock = PhabricatorGlobalLock::newLock($lock_key) ->lock(1); try { $lease = $this->loadLease($lease_phid); $this->handleUpdate($lease); } catch (Exception $ex) { $lock->unlock(); $this->flushDrydockTaskQueue(); throw $ex; } $lock->unlock(); } /* -( Updating Leases )---------------------------------------------------- */ /** * @task update */ private function handleUpdate(DrydockLease $lease) { try { $this->updateLease($lease); } catch (Exception $ex) { if ($this->isTemporaryException($ex)) { $this->yieldLease($lease, $ex); } else { $this->breakLease($lease, $ex); } } } /** * @task update */ private function updateLease(DrydockLease $lease) { $this->processLeaseCommands($lease); $lease_status = $lease->getStatus(); switch ($lease_status) { case DrydockLeaseStatus::STATUS_PENDING: $this->executeAllocator($lease); break; case DrydockLeaseStatus::STATUS_ACQUIRED: $this->activateLease($lease); break; case DrydockLeaseStatus::STATUS_ACTIVE: // Nothing to do. break; case DrydockLeaseStatus::STATUS_RELEASED: case DrydockLeaseStatus::STATUS_BROKEN: $this->destroyLease($lease); break; case DrydockLeaseStatus::STATUS_DESTROYED: break; } $this->yieldIfExpiringLease($lease); } /** * @task update */ private function yieldLease(DrydockLease $lease, Exception $ex) { $duration = $this->getYieldDurationFromException($ex); $lease->logEvent( DrydockLeaseActivationYieldLogType::LOGCONST, array( 'duration' => $duration, )); throw new PhabricatorWorkerYieldException($duration); } /* -( Processing Commands )------------------------------------------------ */ /** * @task command */ private function processLeaseCommands(DrydockLease $lease) { if (!$lease->canReceiveCommands()) { return; } $this->checkLeaseExpiration($lease); $commands = $this->loadCommands($lease->getPHID()); foreach ($commands as $command) { if (!$lease->canReceiveCommands()) { break; } $this->processLeaseCommand($lease, $command); $command ->setIsConsumed(true) ->save(); } } /** * @task command */ private function processLeaseCommand( DrydockLease $lease, DrydockCommand $command) { switch ($command->getCommand()) { case DrydockCommand::COMMAND_RELEASE: $this->releaseLease($lease); break; } } /* -( Drydock Allocator )-------------------------------------------------- */ /** * Find or build a resource which can satisfy a given lease request, then * acquire the lease. * * @param DrydockLease Requested lease. * @return void * @task allocator */ private function executeAllocator(DrydockLease $lease) { $blueprints = $this->loadBlueprintsForAllocatingLease($lease); // If we get nothing back, that means no blueprint is defined which can // ever build the requested resource. This is a permanent failure, since // we don't expect to succeed no matter how many times we try. if (!$blueprints) { throw new PhabricatorWorkerPermanentFailureException( pht( 'No active Drydock blueprint exists which can ever allocate a '. 'resource for lease "%s".', $lease->getPHID())); } // First, try to find a suitable open resource which we can acquire a new // lease on. $resources = $this->loadResourcesForAllocatingLease($blueprints, $lease); // If no resources exist yet, see if we can build one. if (!$resources) { $usable_blueprints = $this->removeOverallocatedBlueprints( $blueprints, $lease); // If we get nothing back here, some blueprint claims it can eventually // satisfy the lease, just not right now. This is a temporary failure, // and we expect allocation to succeed eventually. if (!$usable_blueprints) { $blueprints = $this->rankBlueprints($blueprints, $lease); // Try to actively reclaim unused resources. If we succeed, jump back // into the queue in an effort to claim it. foreach ($blueprints as $blueprint) { $reclaimed = $this->reclaimResources($blueprint, $lease); if ($reclaimed) { $lease->logEvent( DrydockLeaseReclaimLogType::LOGCONST, array( 'resourcePHIDs' => array($reclaimed->getPHID()), )); throw new PhabricatorWorkerYieldException(15); } } $lease->logEvent( DrydockLeaseWaitingForResourcesLogType::LOGCONST, array( 'blueprintPHIDs' => mpull($blueprints, 'getPHID'), )); throw new PhabricatorWorkerYieldException(15); } $usable_blueprints = $this->rankBlueprints($usable_blueprints, $lease); $exceptions = array(); foreach ($usable_blueprints as $blueprint) { try { $resources[] = $this->allocateResource($blueprint, $lease); // Bail after allocating one resource, we don't need any more than // this. break; } catch (Exception $ex) { $exceptions[] = $ex; } } if (!$resources) { throw new PhutilAggregateException( pht( 'All blueprints failed to allocate a suitable new resource when '. 'trying to allocate lease "%s".', $lease->getPHID()), $exceptions); } $resources = $this->removeUnacquirableResources($resources, $lease); if (!$resources) { // If we make it here, we just built a resource but aren't allowed // to acquire it. We expect this during routine operation if the // resource prevents acquisition until it activates. Yield and wait // for activation. throw new PhabricatorWorkerYieldException(15); } // NOTE: We have not acquired the lease yet, so it is possible that the // resource we just built will be snatched up by some other lease before // we can acquire it. This is not problematic: we'll retry a little later - // and should suceed eventually. + // and should succeed eventually. } $resources = $this->rankResources($resources, $lease); $exceptions = array(); $allocated = false; foreach ($resources as $resource) { try { $this->acquireLease($resource, $lease); $allocated = true; break; } catch (Exception $ex) { $exceptions[] = $ex; } } if (!$allocated) { throw new PhutilAggregateException( pht( - 'Unable to acquire lease "%s" on any resouce.', + 'Unable to acquire lease "%s" on any resource.', $lease->getPHID()), $exceptions); } } /** * Get all the @{class:DrydockBlueprintImplementation}s which can possibly * build a resource to satisfy a lease. * * This method returns blueprints which might, at some time, be able to * build a resource which can satisfy the lease. They may not be able to * build that resource right now. * * @param DrydockLease Requested lease. * @return list List of qualifying blueprint * implementations. * @task allocator */ private function loadBlueprintImplementationsForAllocatingLease( DrydockLease $lease) { $impls = DrydockBlueprintImplementation::getAllBlueprintImplementations(); $keep = array(); foreach ($impls as $key => $impl) { // Don't use disabled blueprint types. if (!$impl->isEnabled()) { continue; } // Don't use blueprint types which can't allocate the correct kind of // resource. if ($impl->getType() != $lease->getResourceType()) { continue; } if (!$impl->canAnyBlueprintEverAllocateResourceForLease($lease)) { continue; } $keep[$key] = $impl; } return $keep; } /** * Get all the concrete @{class:DrydockBlueprint}s which can possibly * build a resource to satisfy a lease. * * @param DrydockLease Requested lease. * @return list List of qualifying blueprints. * @task allocator */ private function loadBlueprintsForAllocatingLease( DrydockLease $lease) { $viewer = $this->getViewer(); $impls = $this->loadBlueprintImplementationsForAllocatingLease($lease); if (!$impls) { return array(); } $blueprint_phids = $lease->getAllowedBlueprintPHIDs(); if (!$blueprint_phids) { $lease->logEvent(DrydockLeaseNoBlueprintsLogType::LOGCONST); return array(); } $query = id(new DrydockBlueprintQuery()) ->setViewer($viewer) ->withPHIDs($blueprint_phids) ->withBlueprintClasses(array_keys($impls)) ->withDisabled(false); // The Drydock application itself is allowed to authorize anything. This // is primarily used for leases generated by CLI administrative tools. $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID(); $authorizing_phid = $lease->getAuthorizingPHID(); if ($authorizing_phid != $drydock_phid) { $blueprints = id(clone $query) ->withAuthorizedPHIDs(array($authorizing_phid)) ->execute(); if (!$blueprints) { // If we didn't hit any blueprints, check if this is an authorization // problem: re-execute the query without the authorization constraint. // If the second query hits blueprints, the overall configuration is // fine but this is an authorization problem. If the second query also // comes up blank, this is some other kind of configuration issue so // we fall through to the default pathway. $all_blueprints = $query->execute(); if ($all_blueprints) { $lease->logEvent( DrydockLeaseNoAuthorizationsLogType::LOGCONST, array( 'authorizingPHID' => $authorizing_phid, )); return array(); } } } else { $blueprints = $query->execute(); } $keep = array(); foreach ($blueprints as $key => $blueprint) { if (!$blueprint->canEverAllocateResourceForLease($lease)) { continue; } $keep[$key] = $blueprint; } return $keep; } /** * Load a list of all resources which a given lease can possibly be * allocated against. * * @param list Blueprints which may produce suitable * resources. * @param DrydockLease Requested lease. * @return list Resources which may be able to allocate * the lease. * @task allocator */ private function loadResourcesForAllocatingLease( array $blueprints, DrydockLease $lease) { assert_instances_of($blueprints, 'DrydockBlueprint'); $viewer = $this->getViewer(); $resources = id(new DrydockResourceQuery()) ->setViewer($viewer) ->withBlueprintPHIDs(mpull($blueprints, 'getPHID')) ->withTypes(array($lease->getResourceType())) ->withStatuses( array( DrydockResourceStatus::STATUS_PENDING, DrydockResourceStatus::STATUS_ACTIVE, )) ->execute(); return $this->removeUnacquirableResources($resources, $lease); } /** * Remove resources which can not be acquired by a given lease from a list. * * @param list Candidate resources. * @param DrydockLease Acquiring lease. * @return list Resources which the lease may be able to * acquire. * @task allocator */ private function removeUnacquirableResources( array $resources, DrydockLease $lease) { $keep = array(); foreach ($resources as $key => $resource) { $blueprint = $resource->getBlueprint(); if (!$blueprint->canAcquireLeaseOnResource($resource, $lease)) { continue; } $keep[$key] = $resource; } return $keep; } /** * Remove blueprints which are too heavily allocated to build a resource for * a lease from a list of blueprints. * * @param list List of blueprints. * @return list List with blueprints that can not allocate * a resource for the lease right now removed. * @task allocator */ private function removeOverallocatedBlueprints( array $blueprints, DrydockLease $lease) { assert_instances_of($blueprints, 'DrydockBlueprint'); $keep = array(); foreach ($blueprints as $key => $blueprint) { if (!$blueprint->canAllocateResourceForLease($lease)) { continue; } $keep[$key] = $blueprint; } return $keep; } /** * Rank blueprints by suitability for building a new resource for a * particular lease. * * @param list List of blueprints. * @param DrydockLease Requested lease. * @return list Ranked list of blueprints. * @task allocator */ private function rankBlueprints(array $blueprints, DrydockLease $lease) { assert_instances_of($blueprints, 'DrydockBlueprint'); // TODO: Implement improvements to this ranking algorithm if they become // available. shuffle($blueprints); return $blueprints; } /** * Rank resources by suitability for allocating a particular lease. * * @param list List of resources. * @param DrydockLease Requested lease. * @return list Ranked list of resources. * @task allocator */ private function rankResources(array $resources, DrydockLease $lease) { assert_instances_of($resources, 'DrydockResource'); // TODO: Implement improvements to this ranking algorithm if they become // available. shuffle($resources); return $resources; } /** * Perform an actual resource allocation with a particular blueprint. * * @param DrydockBlueprint The blueprint to allocate a resource from. * @param DrydockLease Requested lease. * @return DrydockResource Allocated resource. * @task allocator */ private function allocateResource( DrydockBlueprint $blueprint, DrydockLease $lease) { $resource = $blueprint->allocateResource($lease); $this->validateAllocatedResource($blueprint, $resource, $lease); // If this resource was allocated as a pending resource, queue a task to // activate it. if ($resource->getStatus() == DrydockResourceStatus::STATUS_PENDING) { PhabricatorWorker::scheduleTask( 'DrydockResourceUpdateWorker', array( 'resourcePHID' => $resource->getPHID(), ), array( 'objectPHID' => $resource->getPHID(), )); } return $resource; } /** * Check that the resource a blueprint allocated is roughly the sort of * object we expect. * * @param DrydockBlueprint Blueprint which built the resource. * @param wild Thing which the blueprint claims is a valid resource. * @param DrydockLease Lease the resource was allocated for. * @return void * @task allocator */ private function validateAllocatedResource( DrydockBlueprint $blueprint, $resource, DrydockLease $lease) { if (!($resource instanceof DrydockResource)) { throw new Exception( pht( 'Blueprint "%s" (of type "%s") is not properly implemented: %s must '. 'return an object of type %s or throw, but returned something else.', $blueprint->getBlueprintName(), $blueprint->getClassName(), 'allocateResource()', 'DrydockResource')); } if (!$resource->isAllocatedResource()) { throw new Exception( pht( 'Blueprint "%s" (of type "%s") is not properly implemented: %s '. 'must actually allocate the resource it returns.', $blueprint->getBlueprintName(), $blueprint->getClassName(), 'allocateResource()')); } $resource_type = $resource->getType(); $lease_type = $lease->getResourceType(); if ($resource_type !== $lease_type) { throw new Exception( pht( 'Blueprint "%s" (of type "%s") is not properly implemented: it '. 'built a resource of type "%s" to satisfy a lease requesting a '. 'resource of type "%s".', $blueprint->getBlueprintName(), $blueprint->getClassName(), $resource_type, $lease_type)); } } private function reclaimResources( DrydockBlueprint $blueprint, DrydockLease $lease) { $viewer = $this->getViewer(); $resources = id(new DrydockResourceQuery()) ->setViewer($viewer) ->withBlueprintPHIDs(array($blueprint->getPHID())) ->withStatuses( array( DrydockResourceStatus::STATUS_ACTIVE, )) ->execute(); // TODO: We could be much smarter about this and try to release long-unused // resources, resources with many similar copies, old resources, resources // that are cheap to rebuild, etc. shuffle($resources); foreach ($resources as $resource) { if ($this->canReclaimResource($resource)) { $this->reclaimResource($resource, $lease); return $resource; } } return null; } /* -( Acquiring Leases )--------------------------------------------------- */ /** * Perform an actual lease acquisition on a particular resource. * * @param DrydockResource Resource to acquire a lease on. * @param DrydockLease Lease to acquire. * @return void * @task acquire */ private function acquireLease( DrydockResource $resource, DrydockLease $lease) { $blueprint = $resource->getBlueprint(); $blueprint->acquireLease($resource, $lease); $this->validateAcquiredLease($blueprint, $resource, $lease); // If this lease has been acquired but not activated, queue a task to // activate it. if ($lease->getStatus() == DrydockLeaseStatus::STATUS_ACQUIRED) { $this->queueTask( __CLASS__, array( 'leasePHID' => $lease->getPHID(), ), array( 'objectPHID' => $lease->getPHID(), )); } } /** * Make sure that a lease was really acquired properly. * * @param DrydockBlueprint Blueprint which created the resource. * @param DrydockResource Resource which was acquired. * @param DrydockLease The lease which was supposedly acquired. * @return void * @task acquire */ private function validateAcquiredLease( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease) { if (!$lease->isAcquiredLease()) { throw new Exception( pht( 'Blueprint "%s" (of type "%s") is not properly implemented: it '. 'returned from "%s" without acquiring a lease.', $blueprint->getBlueprintName(), $blueprint->getClassName(), 'acquireLease()')); } $lease_phid = $lease->getResourcePHID(); $resource_phid = $resource->getPHID(); if ($lease_phid !== $resource_phid) { throw new Exception( pht( 'Blueprint "%s" (of type "%s") is not properly implemented: it '. 'returned from "%s" with a lease acquired on the wrong resource.', $blueprint->getBlueprintName(), $blueprint->getClassName(), 'acquireLease()')); } } /* -( Activating Leases )-------------------------------------------------- */ /** * @task activate */ private function activateLease(DrydockLease $lease) { $resource = $lease->getResource(); if (!$resource) { throw new Exception( pht('Trying to activate lease with no resource.')); } $resource_status = $resource->getStatus(); if ($resource_status == DrydockResourceStatus::STATUS_PENDING) { throw new PhabricatorWorkerYieldException(15); } if ($resource_status != DrydockResourceStatus::STATUS_ACTIVE) { throw new Exception( pht( 'Trying to activate lease on a dead resource (in status "%s").', $resource_status)); } // NOTE: We can race resource destruction here. Between the time we // performed the read above and now, the resource might have closed, so // we may activate leases on dead resources. At least for now, this seems // fine: a resource dying right before we activate a lease on it should not - // be distinguisahble from a resource dying right after we activate a lease + // be distinguishable from a resource dying right after we activate a lease // on it. We end up with an active lease on a dead resource either way, and // can not prevent resources dying from lightning strikes. $blueprint = $resource->getBlueprint(); $blueprint->activateLease($resource, $lease); $this->validateActivatedLease($blueprint, $resource, $lease); } /** * @task activate */ private function validateActivatedLease( DrydockBlueprint $blueprint, DrydockResource $resource, DrydockLease $lease) { if (!$lease->isActivatedLease()) { throw new Exception( pht( 'Blueprint "%s" (of type "%s") is not properly implemented: it '. 'returned from "%s" without activating a lease.', $blueprint->getBlueprintName(), $blueprint->getClassName(), 'acquireLease()')); } } /* -( Releasing Leases )--------------------------------------------------- */ /** * @task release */ private function releaseLease(DrydockLease $lease) { $lease ->setStatus(DrydockLeaseStatus::STATUS_RELEASED) ->save(); $lease->logEvent(DrydockLeaseReleasedLogType::LOGCONST); $resource = $lease->getResource(); if ($resource) { $blueprint = $resource->getBlueprint(); $blueprint->didReleaseLease($resource, $lease); } $this->destroyLease($lease); } /* -( Breaking Leases )---------------------------------------------------- */ /** * @task break */ protected function breakLease(DrydockLease $lease, Exception $ex) { switch ($lease->getStatus()) { case DrydockLeaseStatus::STATUS_BROKEN: case DrydockLeaseStatus::STATUS_RELEASED: case DrydockLeaseStatus::STATUS_DESTROYED: throw new PhutilProxyException( pht( 'Unexpected failure while destroying lease ("%s").', $lease->getPHID()), $ex); } $lease ->setStatus(DrydockLeaseStatus::STATUS_BROKEN) ->save(); $lease->logEvent( DrydockLeaseActivationFailureLogType::LOGCONST, array( 'class' => get_class($ex), 'message' => $ex->getMessage(), )); $lease->awakenTasks(); $this->queueTask( __CLASS__, array( 'leasePHID' => $lease->getPHID(), ), array( 'objectPHID' => $lease->getPHID(), )); throw new PhabricatorWorkerPermanentFailureException( pht( 'Permanent failure while activating lease ("%s"): %s', $lease->getPHID(), $ex->getMessage())); } /* -( Destroying Leases )-------------------------------------------------- */ /** * @task destroy */ private function destroyLease(DrydockLease $lease) { $resource = $lease->getResource(); if ($resource) { $blueprint = $resource->getBlueprint(); $blueprint->destroyLease($resource, $lease); } DrydockSlotLock::releaseLocks($lease->getPHID()); $lease ->setStatus(DrydockLeaseStatus::STATUS_DESTROYED) ->save(); $lease->logEvent(DrydockLeaseDestroyedLogType::LOGCONST); $lease->awakenTasks(); } } diff --git a/src/applications/drydock/worker/DrydockWorker.php b/src/applications/drydock/worker/DrydockWorker.php index f167fef5d0..443780680d 100644 --- a/src/applications/drydock/worker/DrydockWorker.php +++ b/src/applications/drydock/worker/DrydockWorker.php @@ -1,255 +1,255 @@ getViewer(); $lease = id(new DrydockLeaseQuery()) ->setViewer($viewer) ->withPHIDs(array($lease_phid)) ->executeOne(); if (!$lease) { throw new PhabricatorWorkerPermanentFailureException( pht('No such lease "%s"!', $lease_phid)); } return $lease; } protected function loadResource($resource_phid) { $viewer = $this->getViewer(); $resource = id(new DrydockResourceQuery()) ->setViewer($viewer) ->withPHIDs(array($resource_phid)) ->executeOne(); if (!$resource) { throw new PhabricatorWorkerPermanentFailureException( pht('No such resource "%s"!', $resource_phid)); } return $resource; } protected function loadOperation($operation_phid) { $viewer = $this->getViewer(); $operation = id(new DrydockRepositoryOperationQuery()) ->setViewer($viewer) ->withPHIDs(array($operation_phid)) ->executeOne(); if (!$operation) { throw new PhabricatorWorkerPermanentFailureException( pht('No such operation "%s"!', $operation_phid)); } return $operation; } protected function loadCommands($target_phid) { $viewer = $this->getViewer(); $commands = id(new DrydockCommandQuery()) ->setViewer($viewer) ->withTargetPHIDs(array($target_phid)) ->withConsumed(false) ->execute(); $commands = msort($commands, 'getID'); return $commands; } protected function checkLeaseExpiration(DrydockLease $lease) { $this->checkObjectExpiration($lease); } protected function checkResourceExpiration(DrydockResource $resource) { $this->checkObjectExpiration($resource); } private function checkObjectExpiration($object) { // Check if the resource or lease has expired. If it has, we're going to // send it a release command. // This command is sent from within the update worker so it is handled // immediately, but doing this generates a log and improves consistency. $expires = $object->getUntil(); if (!$expires) { return; } $now = PhabricatorTime::getNow(); if ($expires > $now) { return; } $viewer = $this->getViewer(); $drydock_phid = id(new PhabricatorDrydockApplication())->getPHID(); $command = DrydockCommand::initializeNewCommand($viewer) ->setTargetPHID($object->getPHID()) ->setAuthorPHID($drydock_phid) ->setCommand(DrydockCommand::COMMAND_RELEASE) ->save(); } protected function yieldIfExpiringLease(DrydockLease $lease) { if (!$lease->canReceiveCommands()) { return; } $this->yieldIfExpiring($lease->getUntil()); } protected function yieldIfExpiringResource(DrydockResource $resource) { if (!$resource->canReceiveCommands()) { return; } $this->yieldIfExpiring($resource->getUntil()); } private function yieldIfExpiring($expires) { if (!$expires) { return; } if (!$this->getTaskDataValue('isExpireTask')) { return; } $now = PhabricatorTime::getNow(); throw new PhabricatorWorkerYieldException($expires - $now); } protected function isTemporaryException(Exception $ex) { if ($ex instanceof PhabricatorWorkerYieldException) { return true; } if ($ex instanceof DrydockSlotLockException) { return true; } if ($ex instanceof PhutilAggregateException) { $any_temporary = false; foreach ($ex->getExceptions() as $sub) { if ($this->isTemporaryException($sub)) { $any_temporary = true; break; } } if ($any_temporary) { return true; } } if ($ex instanceof PhutilProxyException) { return $this->isTemporaryException($ex->getPreviousException()); } return false; } protected function getYieldDurationFromException(Exception $ex) { if ($ex instanceof PhabricatorWorkerYieldException) { return $ex->getDuration(); } if ($ex instanceof DrydockSlotLockException) { return 5; } return 15; } protected function flushDrydockTaskQueue() { // NOTE: By default, queued tasks are not scheduled if the current task // fails. This is a good, safe default behavior. For example, it can // protect us from executing side effect tasks too many times, like // sending extra email. // However, it is not the behavior we want in Drydock, because we queue // followup tasks after lease and resource failures and want them to // execute in order to clean things up. // At least for now, we just explicitly flush the queue before exiting // with a failure to make sure tasks get queued up properly. try { $this->flushTaskQueue(); } catch (Exception $ex) { // If this fails, we want to swallow the exception so the caller throws // the original error, since we're more likely to be able to understand // and fix the problem if we have the original error than if we replace // it with this one. phlog($ex); } return $this; } protected function canReclaimResource(DrydockResource $resource) { $viewer = $this->getViewer(); // Don't reclaim a resource if it has been updated recently. If two - // leases are fighting, we don't want them to keep reclaming resources + // leases are fighting, we don't want them to keep reclaiming resources // from one another forever without making progress, so make resources // immune to reclamation for a little while after they activate or update. // TODO: It would be nice to use a more narrow time here, like "last // activation or lease release", but we don't currently store that // anywhere. $updated = $resource->getDateModified(); $now = PhabricatorTime::getNow(); $ago = ($now - $updated); if ($ago < phutil_units('3 minutes in seconds')) { return false; } $statuses = array( DrydockLeaseStatus::STATUS_PENDING, DrydockLeaseStatus::STATUS_ACQUIRED, DrydockLeaseStatus::STATUS_ACTIVE, DrydockLeaseStatus::STATUS_RELEASED, DrydockLeaseStatus::STATUS_BROKEN, ); // Don't reclaim resources that have any active leases. $leases = id(new DrydockLeaseQuery()) ->setViewer($viewer) ->withResourcePHIDs(array($resource->getPHID())) ->withStatuses($statuses) ->setLimit(1) ->execute(); if ($leases) { return false; } return true; } protected function reclaimResource( DrydockResource $resource, DrydockLease $lease) { $viewer = $this->getViewer(); $command = DrydockCommand::initializeNewCommand($viewer) ->setTargetPHID($resource->getPHID()) ->setAuthorPHID($lease->getPHID()) ->setCommand(DrydockCommand::COMMAND_RECLAIM) ->save(); $resource->scheduleUpdate(); return $this; } } diff --git a/src/applications/files/controller/PhabricatorFileDataController.php b/src/applications/files/controller/PhabricatorFileDataController.php index c8bfcc488a..da438730cd 100644 --- a/src/applications/files/controller/PhabricatorFileDataController.php +++ b/src/applications/files/controller/PhabricatorFileDataController.php @@ -1,191 +1,191 @@ getViewer(); $this->phid = $request->getURIData('phid'); $this->key = $request->getURIData('key'); $alt = PhabricatorEnv::getEnvConfig('security.alternate-file-domain'); $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); $alt_uri = new PhutilURI($alt); $alt_domain = $alt_uri->getDomain(); $req_domain = $request->getHost(); $main_domain = id(new PhutilURI($base_uri))->getDomain(); if (!strlen($alt) || $main_domain == $alt_domain) { // No alternate domain. $should_redirect = false; $is_alternate_domain = false; } else if ($req_domain != $alt_domain) { // Alternate domain, but this request is on the main domain. $should_redirect = true; $is_alternate_domain = false; } else { // Alternate domain, and on the alternate domain. $should_redirect = false; $is_alternate_domain = true; } $response = $this->loadFile(); if ($response) { return $response; } $file = $this->getFile(); if ($should_redirect) { return id(new AphrontRedirectResponse()) ->setIsExternal(true) ->setURI($file->getCDNURI()); } $response = new AphrontFileResponse(); $response->setCacheDurationInSeconds(60 * 60 * 24 * 30); $response->setCanCDN($file->getCanCDN()); $begin = null; $end = null; // NOTE: It's important to accept "Range" requests when playing audio. // If we don't, Safari has difficulty figuring out how long sounds are // and glitches when trying to loop them. In particular, Safari sends // an initial request for bytes 0-1 of the audio file, and things go south // if we can't respond with a 206 Partial Content. $range = $request->getHTTPHeader('range'); if (strlen($range)) { list($begin, $end) = $response->parseHTTPRange($range); } $is_viewable = $file->isViewableInBrowser(); $force_download = $request->getExists('download'); $request_type = $request->getHTTPHeader('X-Phabricator-Request-Type'); $is_lfs = ($request_type == 'git-lfs'); if ($is_viewable && !$force_download) { $response->setMimeType($file->getViewableMimeType()); } else { $is_public = !$viewer->isLoggedIn(); $is_post = $request->isHTTPPost(); // NOTE: Require POST to download files from the primary domain if the // request includes credentials. The "Download File" links we generate // in the web UI are forms which use POST to satisfy this requirement. // The intent is to make attacks based on tags like "