diff --git a/src/applications/almanac/controller/AlmanacPropertyEditController.php b/src/applications/almanac/controller/AlmanacPropertyEditController.php index ef587e5b05..e42a14df2b 100644 --- a/src/applications/almanac/controller/AlmanacPropertyEditController.php +++ b/src/applications/almanac/controller/AlmanacPropertyEditController.php @@ -1,95 +1,95 @@ <?php final class AlmanacPropertyEditController extends AlmanacPropertyController { public function handleRequest(AphrontRequest $request) { $viewer = $request->getViewer(); $response = $this->loadPropertyObject(); if ($response) { return $response; } $object = $this->getPropertyObject(); $cancel_uri = $object->getURI(); $property_key = $request->getStr('key'); - if (!strlen($property_key)) { + if (!phutil_nonempty_string($property_key)) { return $this->buildPropertyKeyResponse($cancel_uri, null); } else { $error = null; try { AlmanacNames::validateName($property_key); } catch (Exception $ex) { $error = $ex->getMessage(); } // NOTE: If you enter an existing name, we're just treating it as an // edit operation. This might be a little confusing. if ($error !== null) { if ($request->isFormPost()) { // The user is creating a new property and picked a bad name. Give // them an opportunity to fix it. return $this->buildPropertyKeyResponse($cancel_uri, $error); } else { // The user is editing an invalid property. return $this->newDialog() ->setTitle(pht('Invalid Property')) ->appendParagraph( pht( 'The property name "%s" is invalid. This property can not '. 'be edited.', $property_key)) ->appendParagraph($error) ->addCancelButton($cancel_uri); } } } return $object->newAlmanacPropertyEditEngine() ->addContextParameter('objectPHID') ->addContextParameter('key') ->setTargetObject($object) ->setPropertyKey($property_key) ->setController($this) ->buildResponse(); } private function buildPropertyKeyResponse($cancel_uri, $error) { $viewer = $this->getViewer(); $request = $this->getRequest(); $v_key = $request->getStr('key'); if ($error !== null) { $e_key = pht('Invalid'); } else { $e_key = true; } $form = id(new AphrontFormView()) ->setUser($viewer) ->appendChild( id(new AphrontFormTextControl()) ->setName('key') ->setLabel(pht('Name')) ->setValue($v_key) ->setError($e_key)); $errors = array(); if ($error !== null) { $errors[] = $error; } return $this->newDialog() ->setTitle(pht('Add Property')) ->addHiddenInput('objectPHID', $request->getStr('objectPHID')) ->setErrors($errors) ->appendForm($form) ->addSubmitButton(pht('Continue')) ->addCancelButton($cancel_uri); } } diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php index 1dac49bcf9..9b5512db21 100644 --- a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php +++ b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderViewController.php @@ -1,127 +1,127 @@ <?php final class PhabricatorAuthFactorProviderViewController extends PhabricatorAuthFactorProviderController { public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); $this->requireApplicationCapability( AuthManageProvidersCapability::CAPABILITY); $provider = id(new PhabricatorAuthFactorProviderQuery()) ->setViewer($viewer) ->withIDs(array($request->getURIData('id'))) ->executeOne(); if (!$provider) { return new Aphront404Response(); } $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb($provider->getObjectName()) ->setBorder(true); $header = $this->buildHeaderView($provider); $properties = $this->buildPropertiesView($provider); $curtain = $this->buildCurtain($provider); $timeline = $this->buildTransactionTimeline( $provider, new PhabricatorAuthFactorProviderTransactionQuery()); $timeline->setShouldTerminate(true); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setCurtain($curtain) ->setMainColumn( array( $timeline, )) ->addPropertySection(pht('Details'), $properties); return $this->newPage() ->setTitle($provider->getDisplayName()) ->setCrumbs($crumbs) ->setPageObjectPHIDs( array( $provider->getPHID(), )) ->appendChild($view); } private function buildHeaderView(PhabricatorAuthFactorProvider $provider) { $viewer = $this->getViewer(); $view = id(new PHUIHeaderView()) ->setViewer($viewer) ->setHeader($provider->getDisplayName()) ->setPolicyObject($provider); $status = $provider->newStatus(); $header_icon = $status->getStatusHeaderIcon(); $header_color = $status->getStatusHeaderColor(); $header_name = $status->getName(); if ($header_icon !== null) { $view->setStatus($header_icon, $header_color, $header_name); } return $view; } private function buildPropertiesView( PhabricatorAuthFactorProvider $provider) { $viewer = $this->getViewer(); $view = id(new PHUIPropertyListView()) ->setViewer($viewer); $view->addProperty( pht('Factor Type'), $provider->getFactor()->getFactorName()); $custom_enroll = $provider->getEnrollMessage(); - if (strlen($custom_enroll)) { + if ($custom_enroll !== null && strlen($custom_enroll)) { $view->addSectionHeader( pht('Custom Enroll Message'), PHUIPropertyListView::ICON_SUMMARY); $view->addTextContent( new PHUIRemarkupView($viewer, $custom_enroll)); } return $view; } private function buildCurtain(PhabricatorAuthFactorProvider $provider) { $viewer = $this->getViewer(); $id = $provider->getID(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $provider, PhabricatorPolicyCapability::CAN_EDIT); $curtain = $this->newCurtainView($provider); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit MFA Provider')) ->setIcon('fa-pencil') ->setHref($this->getApplicationURI("mfa/edit/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Customize Enroll Message')) ->setIcon('fa-commenting-o') ->setHref($this->getApplicationURI("mfa/message/{$id}/")) ->setDisabled(!$can_edit) ->setWorkflow(true)); return $curtain; } } diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index fefd9b5fd1..1ee861e44c 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -1,626 +1,626 @@ <?php abstract class PhabricatorAuthFactor extends Phobject { abstract public function getFactorName(); abstract public function getFactorShortName(); abstract public function getFactorKey(); abstract public function getFactorCreateHelp(); abstract public function getFactorDescription(); abstract public function processAddFactorForm( PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user); abstract public function renderValidateFactorForm( PhabricatorAuthFactorConfig $config, AphrontFormView $form, PhabricatorUser $viewer, PhabricatorAuthFactorResult $validation_result); public function getParameterName( PhabricatorAuthFactorConfig $config, $name) { return 'authfactor.'.$config->getID().'.'.$name; } public static function getAllFactors() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getFactorKey') ->execute(); } protected function newConfigForUser(PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfig()) ->setUserPHID($user->getPHID()) ->setFactorSecret(''); } protected function newResult() { return new PhabricatorAuthFactorResult(); } public function newIconView() { return id(new PHUIIconView()) ->setIcon('fa-mobile'); } public function canCreateNewProvider() { return true; } public function getProviderCreateDescription() { return null; } public function canCreateNewConfiguration( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return null; } public function getConfigurationListDetails( PhabricatorAuthFactorConfig $config, PhabricatorAuthFactorProvider $provider, PhabricatorUser $viewer) { return null; } public function newEditEngineFields( PhabricatorEditEngine $engine, PhabricatorAuthFactorProvider $provider) { return array(); } public function newChallengeStatusView( PhabricatorAuthFactorConfig $config, PhabricatorAuthFactorProvider $provider, PhabricatorUser $viewer, PhabricatorAuthChallenge $challenge) { return null; } /** * Is this a factor which depends on the user's contact number? * * If a user has a "contact number" factor configured, they can not modify * or switch their primary contact number. * * @return bool True if this factor should lock contact numbers. */ public function isContactNumberFactor() { return false; } abstract public function getEnrollDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user); public function getEnrollButtonText( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return pht('Continue'); } public function getFactorOrder() { return 1000; } final public function newSortVector() { return id(new PhutilSortVector()) ->addInt($this->canCreateNewProvider() ? 0 : 1) ->addInt($this->getFactorOrder()) ->addString($this->getFactorName()); } protected function newChallenge( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer) { $engine = $config->getSessionEngine(); return PhabricatorAuthChallenge::initializeNewChallenge() ->setUserPHID($viewer->getPHID()) ->setSessionPHID($viewer->getSession()->getPHID()) ->setFactorPHID($config->getPHID()) ->setIsNewChallenge(true) ->setWorkflowKey($engine->getWorkflowKey()); } abstract public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $response); final public function getNewIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $now = PhabricatorTime::getNow(); // Factor implementations may need to perform writes in order to issue // challenges, particularly push factors like SMS. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $new_challenges = $this->newIssuedChallenges( $config, $viewer, $challenges); if ($this->isAuthResult($new_challenges)) { unset($unguarded); return $new_challenges; } assert_instances_of($new_challenges, 'PhabricatorAuthChallenge'); foreach ($new_challenges as $new_challenge) { $ttl = $new_challenge->getChallengeTTL(); if (!$ttl) { throw new Exception( pht('Newly issued MFA challenges must have a valid TTL!')); } if ($ttl < $now) { throw new Exception( pht( 'Newly issued MFA challenges must have a future TTL. This '. 'factor issued a bad TTL ("%s"). (Did you use a relative '. 'time instead of an epoch?)', $ttl)); } } foreach ($new_challenges as $challenge) { $challenge->save(); } unset($unguarded); return $new_challenges; } abstract protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromIssuedChallenges( $config, $viewer, $challenges); if ($result === null) { return $result; } if (!$this->isAuthResult($result)) { throw new Exception( pht( 'Expected "newResultFromIssuedChallenges()" to return null or '. 'an object of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } return $result; } final public function getResultForPrompt( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultForPrompt( $config, $viewer, $request, $challenges); if (!$this->isAuthResult($result)) { throw new Exception( pht( 'Expected "newResultForPrompt()" to return an object of class "%s", '. 'but it returned something else ("%s"; in "%s").', 'PhabricatorAuthFactorResult', phutil_describe_type($result), get_class($this))); } return $result; } protected function newResultForPrompt( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { return $this->newResult(); } abstract protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges); final public function getResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { assert_instances_of($challenges, 'PhabricatorAuthChallenge'); $result = $this->newResultFromChallengeResponse( $config, $viewer, $request, $challenges); if (!$this->isAuthResult($result)) { throw new Exception( pht( 'Expected "newResultFromChallengeResponse()" to return an object '. 'of class "%s"; got something else (in "%s").', 'PhabricatorAuthFactorResult', get_class($this))); } return $result; } abstract protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges); final protected function newAutomaticControl( PhabricatorAuthFactorResult $result) { $is_error = $result->getIsError(); if ($is_error) { return $this->newErrorControl($result); } $is_continue = $result->getIsContinue(); if ($is_continue) { return $this->newContinueControl($result); } $is_answered = (bool)$result->getAnsweredChallenge(); if ($is_answered) { return $this->newAnsweredControl($result); } $is_wait = $result->getIsWait(); if ($is_wait) { return $this->newWaitControl($result); } return null; } private function newWaitControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = $result->getIcon(); if (!$icon) { $icon = id(new PHUIIconView()) ->setIcon('fa-clock-o', 'red'); } return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error) ->setError(pht('Wait')); } private function newAnsweredControl( PhabricatorAuthFactorResult $result) { $icon = $result->getIcon(); if (!$icon) { $icon = id(new PHUIIconView()) ->setIcon('fa-check-circle-o', 'green'); } return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild( pht('You responded to this challenge correctly.')); } private function newErrorControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = $result->getIcon(); if (!$icon) { $icon = id(new PHUIIconView()) ->setIcon('fa-times', 'red'); } return id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error) ->setError(pht('Error')); } private function newContinueControl( PhabricatorAuthFactorResult $result) { $error = $result->getErrorMessage(); $icon = $result->getIcon(); if (!$icon) { $icon = id(new PHUIIconView()) ->setIcon('fa-commenting', 'green'); } $control = id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error); $status_challenge = $result->getStatusChallenge(); if ($status_challenge) { $id = $status_challenge->getID(); $uri = "/auth/mfa/challenge/status/{$id}/"; $control->setUpdateURI($uri); } return $control; } /* -( Synchronizing New Factors )------------------------------------------ */ final protected function loadMFASyncToken( PhabricatorAuthFactorProvider $provider, AphrontRequest $request, AphrontFormView $form, PhabricatorUser $user) { // If the form included a synchronization key, load the corresponding // token. The user must synchronize to a key we generated because this // raises the barrier to theoretical attacks where an attacker might // provide a known key for factors like TOTP. // (We store and verify the hash of the key, not the key itself, to limit // how useful the data in the table is to an attacker.) $sync_type = PhabricatorAuthMFASyncTemporaryTokenType::TOKENTYPE; $sync_token = null; $sync_key = $request->getStr($this->getMFASyncTokenFormKey()); - if (strlen($sync_key)) { + if (phutil_nonempty_string($sync_key)) { $sync_key_digest = PhabricatorHash::digestWithNamedKey( $sync_key, PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); $sync_token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($sync_type)) ->withExpired(false) ->withTokenCodes(array($sync_key_digest)) ->executeOne(); } if (!$sync_token) { // Don't generate a new sync token if there are too many outstanding // tokens already. This is mostly relevant for push factors like SMS, // where generating a token has the side effect of sending a user a // message. $outstanding_limit = 10; $outstanding_tokens = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer($user) ->withTokenResources(array($user->getPHID())) ->withTokenTypes(array($sync_type)) ->withExpired(false) ->execute(); if (count($outstanding_tokens) > $outstanding_limit) { throw new Exception( pht( 'Your account has too many outstanding, incomplete MFA '. 'synchronization attempts. Wait an hour and try again.')); } $now = PhabricatorTime::getNow(); $sync_key = Filesystem::readRandomCharacters(32); $sync_key_digest = PhabricatorHash::digestWithNamedKey( $sync_key, PhabricatorAuthMFASyncTemporaryTokenType::DIGEST_KEY); $sync_ttl = $this->getMFASyncTokenTTL(); $sync_token = id(new PhabricatorAuthTemporaryToken()) ->setIsNewTemporaryToken(true) ->setTokenResource($user->getPHID()) ->setTokenType($sync_type) ->setTokenCode($sync_key_digest) ->setTokenExpires($now + $sync_ttl); $properties = $this->newMFASyncTokenProperties( $provider, $user); if ($this->isAuthResult($properties)) { return $properties; } foreach ($properties as $key => $value) { $sync_token->setTemporaryTokenProperty($key, $value); } $sync_token->save(); } $form->addHiddenInput($this->getMFASyncTokenFormKey(), $sync_key); return $sync_token; } protected function newMFASyncTokenProperties( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return array(); } private function getMFASyncTokenFormKey() { return 'sync.key'; } private function getMFASyncTokenTTL() { return phutil_units('1 hour in seconds'); } final protected function getChallengeForCurrentContext( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $session_phid = $viewer->getSession()->getPHID(); $engine = $config->getSessionEngine(); $workflow_key = $engine->getWorkflowKey(); foreach ($challenges as $challenge) { if ($challenge->getSessionPHID() !== $session_phid) { continue; } if ($challenge->getWorkflowKey() !== $workflow_key) { continue; } if ($challenge->getIsCompleted()) { continue; } if ($challenge->getIsReusedChallenge()) { continue; } return $challenge; } return null; } /** * @phutil-external-symbol class QRcode */ final protected function newQRCode($uri) { $root = dirname(phutil_get_library_root('phabricator')); require_once $root.'/externals/phpqrcode/phpqrcode.php'; $lines = QRcode::text($uri); $total_width = 240; $cell_size = floor($total_width / count($lines)); $rows = array(); foreach ($lines as $line) { $cells = array(); for ($ii = 0; $ii < strlen($line); $ii++) { if ($line[$ii] == '1') { $color = '#000'; } else { $color = '#fff'; } $cells[] = phutil_tag( 'td', array( 'width' => $cell_size, 'height' => $cell_size, 'style' => 'background: '.$color, ), ''); } $rows[] = phutil_tag('tr', array(), $cells); } return phutil_tag( 'table', array( 'style' => 'margin: 24px auto;', ), $rows); } final protected function getInstallDisplayName() { $uri = PhabricatorEnv::getURI('/'); $uri = new PhutilURI($uri); return $uri->getDomain(); } final protected function getChallengeResponseParameterName( PhabricatorAuthFactorConfig $config) { return $this->getParameterName($config, 'mfa.response'); } final protected function getChallengeResponseFromRequest( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $name = $this->getChallengeResponseParameterName($config); $value = $request->getStr($name); $value = (string)$value; $value = trim($value); return $value; } final protected function hasCSRF(PhabricatorAuthFactorConfig $config) { $engine = $config->getSessionEngine(); $request = $engine->getRequest(); if (!$request->isHTTPPost()) { return false; } return $request->validateCSRF(); } final protected function loadConfigurationsForProvider( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return id(new PhabricatorAuthFactorConfigQuery()) ->setViewer($user) ->withUserPHIDs(array($user->getPHID())) ->withFactorProviderPHIDs(array($provider->getPHID())) ->execute(); } final protected function isAuthResult($object) { return ($object instanceof PhabricatorAuthFactorResult); } } diff --git a/src/applications/auth/storage/PhabricatorAuthChallenge.php b/src/applications/auth/storage/PhabricatorAuthChallenge.php index 0b740e5fa7..acc05572fc 100644 --- a/src/applications/auth/storage/PhabricatorAuthChallenge.php +++ b/src/applications/auth/storage/PhabricatorAuthChallenge.php @@ -1,272 +1,275 @@ <?php final class PhabricatorAuthChallenge extends PhabricatorAuthDAO implements PhabricatorPolicyInterface { protected $userPHID; protected $factorPHID; protected $sessionPHID; protected $workflowKey; protected $challengeKey; protected $challengeTTL; protected $responseDigest; protected $responseTTL; protected $isCompleted; protected $properties = array(); private $responseToken; private $isNewChallenge; const HTTPKEY = '__hisec.challenges__'; const TOKEN_DIGEST_KEY = 'auth.challenge.token'; public static function initializeNewChallenge() { return id(new self()) ->setIsCompleted(0); } public static function newHTTPParametersFromChallenges(array $challenges) { assert_instances_of($challenges, __CLASS__); $token_list = array(); foreach ($challenges as $challenge) { $token = $challenge->getResponseToken(); if ($token) { $token_list[] = sprintf( '%s:%s', $challenge->getPHID(), $token->openEnvelope()); } } if (!$token_list) { return array(); } $token_list = implode(' ', $token_list); return array( self::HTTPKEY => $token_list, ); } public static function newChallengeResponsesFromRequest( array $challenges, AphrontRequest $request) { assert_instances_of($challenges, __CLASS__); $token_list = $request->getStr(self::HTTPKEY); + if ($token_list === null) { + return; + } $token_list = explode(' ', $token_list); $token_map = array(); foreach ($token_list as $token_element) { $token_element = trim($token_element, ' '); if (!strlen($token_element)) { continue; } // NOTE: This error message is intentionally not printing the token to // avoid disclosing it. As a result, it isn't terribly useful, but no // normal user should ever end up here. if (!preg_match('/^[^:]+:/', $token_element)) { throw new Exception( pht( 'This request included an improperly formatted MFA challenge '. 'token and can not be processed.')); } list($phid, $token) = explode(':', $token_element, 2); if (isset($token_map[$phid])) { throw new Exception( pht( 'This request improperly specifies an MFA challenge token ("%s") '. 'multiple times and can not be processed.', $phid)); } $token_map[$phid] = new PhutilOpaqueEnvelope($token); } $challenges = mpull($challenges, null, 'getPHID'); $now = PhabricatorTime::getNow(); foreach ($challenges as $challenge_phid => $challenge) { // If the response window has expired, don't attach the token. if ($challenge->getResponseTTL() < $now) { continue; } $token = idx($token_map, $challenge_phid); if (!$token) { continue; } $challenge->setResponseToken($token); } } protected function getConfiguration() { return array( self::CONFIG_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'challengeKey' => 'text255', 'challengeTTL' => 'epoch', 'workflowKey' => 'text255', 'responseDigest' => 'text255?', 'responseTTL' => 'epoch?', 'isCompleted' => 'bool', ), self::CONFIG_KEY_SCHEMA => array( 'key_issued' => array( 'columns' => array('userPHID', 'challengeTTL'), ), 'key_collection' => array( 'columns' => array('challengeTTL'), ), ), ) + parent::getConfiguration(); } public function getPHIDType() { return PhabricatorAuthChallengePHIDType::TYPECONST; } public function getIsReusedChallenge() { if ($this->getIsCompleted()) { return true; } if (!$this->getIsAnsweredChallenge()) { return false; } // If the challenge has been answered but the client has provided a token // proving that they answered it, this is still a valid response. if ($this->getResponseToken()) { return false; } return true; } public function getIsAnsweredChallenge() { return (bool)$this->getResponseDigest(); } public function markChallengeAsAnswered($ttl) { $token = Filesystem::readRandomCharacters(32); $token = new PhutilOpaqueEnvelope($token); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $this ->setResponseToken($token) ->setResponseTTL($ttl) ->save(); unset($unguarded); return $this; } public function markChallengeAsCompleted() { return $this ->setIsCompleted(true) ->save(); } public function setResponseToken(PhutilOpaqueEnvelope $token) { if (!$this->getUserPHID()) { throw new PhutilInvalidStateException('setUserPHID'); } if ($this->responseToken) { throw new Exception( pht( 'This challenge already has a response token; you can not '. 'set a new response token.')); } if (preg_match('/ /', $token->openEnvelope())) { throw new Exception( pht( 'The response token for this challenge is invalid: response '. 'tokens may not include spaces.')); } $digest = PhabricatorHash::digestWithNamedKey( $token->openEnvelope(), self::TOKEN_DIGEST_KEY); if ($this->responseDigest !== null) { if (!phutil_hashes_are_identical($digest, $this->responseDigest)) { throw new Exception( pht( 'Invalid response token for this challenge: token digest does '. 'not match stored digest.')); } } else { $this->responseDigest = $digest; } $this->responseToken = $token; return $this; } public function getResponseToken() { return $this->responseToken; } public function setResponseDigest($value) { throw new Exception( pht( 'You can not set the response digest for a challenge directly. '. 'Instead, set a response token. A response digest will be computed '. 'automatically.')); } public function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function getProperty($key, $default = null) { return $this->properties[$key]; } public function setIsNewChallenge($is_new_challenge) { $this->isNewChallenge = $is_new_challenge; return $this; } public function getIsNewChallenge() { return $this->isNewChallenge; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return PhabricatorPolicies::POLICY_NOONE; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return ($viewer->getPHID() === $this->getUserPHID()); } } diff --git a/src/applications/conpherence/query/ConpherenceThreadQuery.php b/src/applications/conpherence/query/ConpherenceThreadQuery.php index 99fd18878e..8358780991 100644 --- a/src/applications/conpherence/query/ConpherenceThreadQuery.php +++ b/src/applications/conpherence/query/ConpherenceThreadQuery.php @@ -1,344 +1,345 @@ <?php final class ConpherenceThreadQuery extends PhabricatorCursorPagedPolicyAwareQuery { const TRANSACTION_LIMIT = 100; private $phids; private $ids; private $participantPHIDs; private $needParticipants; private $needTransactions; private $afterTransactionID; private $beforeTransactionID; private $transactionLimit; private $fulltext; private $needProfileImage; public function needParticipants($need) { $this->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)) { + if ($this->participantPHIDs !== null + || ($this->fulltext !== null && strlen($this->fulltext))) { return qsprintf($conn_r, '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)) { + if ($this->fulltext !== null && 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(); $policies = array( $members_policy, PhabricatorPolicies::POLICY_USER, PhabricatorPolicies::POLICY_ADMIN, PhabricatorPolicies::POLICY_NOONE, ); if ($viewer->isLoggedIn()) { $where[] = qsprintf( $conn, 'thread.viewPolicy NOT IN (%Ls) OR vp.participantPHID = %s', $policies, $viewer->getPHID()); } else { $where[] = qsprintf( $conn, 'thread.viewPolicy NOT IN (%Ls)', $policies); } } 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)) { + if ($this->fulltext !== null && 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) { // NOTE: This is older code which has been modernized to the minimum // standard required by T13266. It probably isn't the best available // approach to the problems it solves. $limit = $this->getTransactionLimit(); if ($limit) { // fetch an extra for "show older" scenarios $limit = $limit + 1; } else { $limit = 0xFFFF; } $pager = id(new AphrontCursorPagerView()) ->setPageSize($limit); // We have to flip these for the underlying query class. The semantics of // paging are tricky business. if ($this->afterTransactionID) { $pager->setBeforeID($this->afterTransactionID); } else if ($this->beforeTransactionID) { $pager->setAfterID($this->beforeTransactionID); } $transactions = id(new ConpherenceTransactionQuery()) ->setViewer($this->getViewer()) ->withObjectPHIDs(array_keys($conpherences)) ->needHandles(true) ->executeWithCursorPager($pager); $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/conpherence/query/ConpherenceThreadSearchEngine.php b/src/applications/conpherence/query/ConpherenceThreadSearchEngine.php index cbaf43b0a9..1bb0d71f7a 100644 --- a/src/applications/conpherence/query/ConpherenceThreadSearchEngine.php +++ b/src/applications/conpherence/query/ConpherenceThreadSearchEngine.php @@ -1,445 +1,445 @@ <?php final class ConpherenceThreadSearchEngine extends PhabricatorApplicationSearchEngine { public function getResultTypeDescription() { return pht('Conpherence Rooms'); } public function getApplicationClassName() { return 'PhabricatorConpherenceApplication'; } public function newQuery() { return id(new ConpherenceThreadQuery()) ->needProfileImage(true); } protected function buildCustomSearchFields() { return array( id(new PhabricatorUsersSearchField()) ->setLabel(pht('Participants')) ->setKey('participants') ->setAliases(array('participant')), id(new PhabricatorSearchDatasourceField()) ->setLabel(pht('Rooms')) ->setKey('phids') ->setDescription(pht('Search by room titles.')) ->setDatasource(id(new ConpherenceThreadDatasource())), id(new PhabricatorSearchTextField()) ->setLabel(pht('Room Contains Words')) ->setKey('fulltext'), ); } protected function getDefaultFieldOrder() { return array( 'participants', '...', ); } protected function shouldShowOrderField() { return false; } protected function buildQueryFromParameters(array $map) { $query = $this->newQuery(); if ($map['participants']) { $query->withParticipantPHIDs($map['participants']); } if ($map['fulltext']) { $query->withFulltext($map['fulltext']); } if ($map['phids']) { $query->withPHIDs($map['phids']); } return $query; } protected function getURI($path) { return '/conpherence/search/'.$path; } protected function getBuiltinQueryNames() { $names = array(); $names['all'] = pht('All Rooms'); if ($this->requireViewer()->isLoggedIn()) { $names['participant'] = pht('Joined Rooms'); } return $names; } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); switch ($query_key) { case 'all': return $query; case 'participant': return $query->setParameter( 'participants', array($this->requireViewer()->getPHID())); } return parent::buildSavedQueryFromBuiltin($query_key); } protected function renderResultList( array $conpherences, PhabricatorSavedQuery $query, array $handles) { assert_instances_of($conpherences, 'ConpherenceThread'); $viewer = $this->requireViewer(); $policy_objects = ConpherenceThread::loadViewPolicyObjects( $viewer, $conpherences); $engines = array(); $fulltext = $query->getParameter('fulltext'); - if (strlen($fulltext) && $conpherences) { + if ($fulltext !== null && strlen($fulltext) && $conpherences) { $context = $this->loadContextMessages($conpherences, $fulltext); $author_phids = array(); foreach ($context as $phid => $messages) { $conpherence = $conpherences[$phid]; $engine = id(new PhabricatorMarkupEngine()) ->setViewer($viewer) ->setContextObject($conpherence); foreach ($messages as $group) { foreach ($group as $message) { $xaction = $message['xaction']; if ($xaction) { $author_phids[] = $xaction->getAuthorPHID(); $engine->addObject( $xaction->getComment(), PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT); } } } $engine->process(); $engines[$phid] = $engine; } $handles = $viewer->loadHandles($author_phids); $handles = iterator_to_array($handles); } else { $context = array(); } $content = array(); $list = new PHUIObjectItemListView(); $list->setUser($viewer); foreach ($conpherences as $conpherence_phid => $conpherence) { $created = phabricator_date($conpherence->getDateCreated(), $viewer); $title = $conpherence->getTitle(); $monogram = $conpherence->getMonogram(); $icon_name = $conpherence->getPolicyIconName($policy_objects); $icon = id(new PHUIIconView()) ->setIcon($icon_name); - if (!strlen($fulltext)) { + if ($fulltext === null || !strlen($fulltext)) { $item = id(new PHUIObjectItemView()) ->setObjectName($conpherence->getMonogram()) ->setHeader($title) ->setHref('/'.$conpherence->getMonogram()) ->setObject($conpherence) ->setImageURI($conpherence->getProfileImageURI()) ->addIcon('none', $created) ->addIcon( 'none', pht('Messages: %d', $conpherence->getMessageCount())) ->addAttribute( array( $icon, ' ', pht( 'Last updated %s', phabricator_datetime($conpherence->getDateModified(), $viewer)), )); $list->addItem($item); } else { $messages = idx($context, $conpherence_phid); $box = array(); $list = null; if ($messages) { foreach ($messages as $group) { $rows = array(); foreach ($group as $message) { $xaction = $message['xaction']; if (!$xaction) { continue; } $view = id(new ConpherenceTransactionView()) ->setUser($viewer) ->setHandles($handles) ->setMarkupEngine($engines[$conpherence_phid]) ->setConpherenceThread($conpherence) ->setConpherenceTransaction($xaction) ->setSearchResult(true) ->addClass('conpherence-fulltext-result'); if ($message['match']) { $view->addClass('conpherence-fulltext-match'); } $rows[] = $view; } $box[] = id(new PHUIBoxView()) ->appendChild($rows) ->addClass('conpherence-fulltext-results'); } } $header = id(new PHUIHeaderView()) ->setHeader($title) ->setHeaderIcon($icon_name) ->setHref('/'.$monogram); $content[] = id(new PHUIObjectBoxView()) ->setHeader($header) ->appendChild($box); } } if ($list) { $content = $list; } else { $content = id(new PHUIBoxView()) ->addClass('conpherence-search-room-results') ->appendChild($content); } $result = new PhabricatorApplicationSearchResultView(); $result->setContent($content); $result->setNoDataString(pht('No results found.')); return $result; } private function loadContextMessages(array $threads, $fulltext) { $phids = mpull($threads, 'getPHID'); // We want to load a few messages for each thread in the result list, to // show some of the actual content hits to help the user find what they // are looking for. // This method is trying to batch this lookup in most cases, so we do // between one and "a handful" of queries instead of one per thread in // most cases. To do this: // // - Load a big block of results for all of the threads. // - If we didn't get a full block back, we have everything that matches // the query. Sort it out and exit. // - Otherwise, some threads had a ton of hits, so we might not be // getting everything we want (we could be getting back 1,000 hits for // the first thread). Remove any threads which we have enough results // for and try again. // - Repeat until we have everything or every thread has enough results. // // In the worst case, we could end up degrading to one query per thread, // but this is incredibly unlikely on real data. // Size of the result blocks we're going to load. $limit = 1000; // Number of messages we want for each thread. $want = 3; $need = $phids; $hits = array(); while ($need) { $rows = id(new ConpherenceFulltextQuery()) ->withThreadPHIDs($need) ->withFulltext($fulltext) ->setLimit($limit) ->execute(); foreach ($rows as $row) { $hits[$row['threadPHID']][] = $row; } if (count($rows) < $limit) { break; } foreach ($need as $key => $phid) { if (count($hits[$phid]) >= $want) { unset($need[$key]); } } } // Now that we have all the fulltext matches, throw away any extras that we // aren't going to render so we don't need to do lookups on them. foreach ($hits as $phid => $rows) { if (count($rows) > $want) { $hits[$phid] = array_slice($rows, 0, $want); } } // For each fulltext match, we want to render a message before and after // the match to give it some context. We already know the transactions // before each match because the rows have a "previousTransactionPHID", // but we need to do one more query to figure out the transactions after // each match. // Collect the transactions we want to find the next transactions for. $after = array(); foreach ($hits as $phid => $rows) { foreach ($rows as $row) { $after[] = $row['transactionPHID']; } } // Look up the next transactions. if ($after) { $after_rows = id(new ConpherenceFulltextQuery()) ->withPreviousTransactionPHIDs($after) ->execute(); } else { $after_rows = array(); } // Build maps from PHIDs to the previous and next PHIDs. $prev_map = array(); $next_map = array(); foreach ($after_rows as $row) { $next_map[$row['previousTransactionPHID']] = $row['transactionPHID']; } foreach ($hits as $phid => $rows) { foreach ($rows as $row) { $prev = $row['previousTransactionPHID']; if ($prev) { $prev_map[$row['transactionPHID']] = $prev; $next_map[$prev] = $row['transactionPHID']; } } } // Now we're going to collect the actual transaction PHIDs, in order, that // we want to show for each thread. $groups = array(); foreach ($hits as $thread_phid => $rows) { $rows = ipull($rows, null, 'transactionPHID'); $done = array(); foreach ($rows as $phid => $row) { if (isset($done[$phid])) { continue; } $done[$phid] = true; $group = array(); // Walk backward, finding all the previous results. We can just keep // going until we run out of results because we've only loaded things // that we want to show. $prev = $phid; while (true) { if (!isset($prev_map[$prev])) { // No previous transaction, so we're done. break; } $prev = $prev_map[$prev]; if (isset($rows[$prev])) { $match = true; $done[$prev] = true; } else { $match = false; } $group[] = array( 'phid' => $prev, 'match' => $match, ); } if (count($group) > 1) { $group = array_reverse($group); } $group[] = array( 'phid' => $phid, 'match' => true, ); $next = $phid; while (true) { if (!isset($next_map[$next])) { break; } $next = $next_map[$next]; if (isset($rows[$next])) { $match = true; $done[$next] = true; } else { $match = false; } $group[] = array( 'phid' => $next, 'match' => $match, ); } $groups[$thread_phid][] = $group; } } // Load all the actual transactions we need. $xaction_phids = array(); foreach ($groups as $thread_phid => $group) { foreach ($group as $list) { foreach ($list as $item) { $xaction_phids[] = $item['phid']; } } } if ($xaction_phids) { $xactions = id(new ConpherenceTransactionQuery()) ->setViewer($this->requireViewer()) ->withPHIDs($xaction_phids) ->needComments(true) ->execute(); $xactions = mpull($xactions, null, 'getPHID'); } else { $xactions = array(); } foreach ($groups as $thread_phid => $group) { foreach ($group as $key => $list) { foreach ($list as $lkey => $item) { $xaction = idx($xactions, $item['phid']); if ($xaction->shouldHide()) { continue; } $groups[$thread_phid][$key][$lkey]['xaction'] = $xaction; } } } // TODO: Sort the groups chronologically? return $groups; } } diff --git a/src/applications/diviner/query/DivinerBookQuery.php b/src/applications/diviner/query/DivinerBookQuery.php index 2d6527ec96..6bd23e8502 100644 --- a/src/applications/diviner/query/DivinerBookQuery.php +++ b/src/applications/diviner/query/DivinerBookQuery.php @@ -1,200 +1,200 @@ <?php final class DivinerBookQuery extends PhabricatorCursorPagedPolicyAwareQuery { private $ids; private $phids; private $names; private $nameLike; private $namePrefix; private $repositoryPHIDs; private $needProjectPHIDs; private $needRepositories; public function withIDs(array $ids) { $this->ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withNameLike($name) { $this->nameLike = $name; return $this; } public function withNames(array $names) { $this->names = $names; return $this; } public function withNamePrefix($prefix) { $this->namePrefix = $prefix; return $this; } public function withRepositoryPHIDs(array $repository_phids) { $this->repositoryPHIDs = $repository_phids; return $this; } public function needProjectPHIDs($need_phids) { $this->needProjectPHIDs = $need_phids; return $this; } public function needRepositories($need_repositories) { $this->needRepositories = $need_repositories; return $this; } protected function loadPage() { $table = new DivinerLiveBook(); $conn_r = $table->establishConnection('r'); $data = queryfx_all( $conn_r, 'SELECT * FROM %T %Q %Q %Q', $table->getTableName(), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); return $table->loadAllFromArray($data); } protected function didFilterPage(array $books) { assert_instances_of($books, 'DivinerLiveBook'); if ($this->needRepositories) { $repositories = id(new PhabricatorRepositoryQuery()) ->setViewer($this->getViewer()) ->withPHIDs(mpull($books, 'getRepositoryPHID')) ->execute(); $repositories = mpull($repositories, null, 'getPHID'); foreach ($books as $key => $book) { if ($book->getRepositoryPHID() === null) { $book->attachRepository(null); continue; } $repository = idx($repositories, $book->getRepositoryPHID()); if (!$repository) { $this->didRejectResult($book); unset($books[$key]); continue; } $book->attachRepository($repository); } } if ($this->needProjectPHIDs) { $edge_query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(mpull($books, 'getPHID')) ->withEdgeTypes( array( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, )); $edge_query->execute(); foreach ($books as $book) { $project_phids = $edge_query->getDestinationPHIDs( array( $book->getPHID(), )); $book->attachProjectPHIDs($project_phids); } } return $books; } protected function buildWhereClause(AphrontDatabaseConnection $conn) { $where = array(); if ($this->ids) { $where[] = qsprintf( $conn, 'id IN (%Ld)', $this->ids); } if ($this->phids) { $where[] = qsprintf( $conn, 'phid IN (%Ls)', $this->phids); } - if (strlen($this->nameLike)) { + if ($this->nameLike !== null && strlen($this->nameLike)) { $where[] = qsprintf( $conn, 'name LIKE %~', $this->nameLike); } if ($this->names !== null) { $where[] = qsprintf( $conn, 'name IN (%Ls)', $this->names); } - if (strlen($this->namePrefix)) { + if ($this->namePrefix !== null && strlen($this->namePrefix)) { $where[] = qsprintf( $conn, 'name LIKE %>', $this->namePrefix); } if ($this->repositoryPHIDs !== null) { $where[] = qsprintf( $conn, 'repositoryPHID IN (%Ls)', $this->repositoryPHIDs); } $where[] = $this->buildPagingClause($conn); return $this->formatWhereClause($conn, $where); } public function getQueryApplicationClass() { return 'PhabricatorDivinerApplication'; } public function getOrderableColumns() { return parent::getOrderableColumns() + array( 'name' => array( 'column' => 'name', 'type' => 'string', 'reverse' => true, 'unique' => true, ), ); } protected function newPagingMapFromPartialObject($object) { return array( 'id' => (int)$object->getID(), 'name' => $object->getName(), ); } public function getBuiltinOrders() { return array( 'name' => array( 'vector' => array('name'), 'name' => pht('Name'), ), ) + parent::getBuiltinOrders(); } } diff --git a/src/applications/metamta/engine/PhabricatorMailEmailEngine.php b/src/applications/metamta/engine/PhabricatorMailEmailEngine.php index 6c9cf1b356..169becaf0b 100644 --- a/src/applications/metamta/engine/PhabricatorMailEmailEngine.php +++ b/src/applications/metamta/engine/PhabricatorMailEmailEngine.php @@ -1,654 +1,655 @@ <?php final class PhabricatorMailEmailEngine extends PhabricatorMailMessageEngine { public function newMessage() { $mailer = $this->getMailer(); $mail = $this->getMail(); $message = new PhabricatorMailEmailMessage(); $from_address = $this->newFromEmailAddress(); $message->setFromAddress($from_address); $reply_address = $this->newReplyToEmailAddress(); if ($reply_address) { $message->setReplyToAddress($reply_address); } $to_addresses = $this->newToEmailAddresses(); $cc_addresses = $this->newCCEmailAddresses(); if (!$to_addresses && !$cc_addresses) { $mail->setMessage( pht( 'Message has no valid recipients: all To/CC are disabled, '. 'invalid, or configured not to receive this mail.')); return null; } // If this email describes a mail processing error, we rate limit outbound // messages to each individual address. This prevents messes where // something is stuck in a loop or dumps a ton of messages on us suddenly. if ($mail->getIsErrorEmail()) { $all_recipients = array(); foreach ($to_addresses as $to_address) { $all_recipients[] = $to_address->getAddress(); } foreach ($cc_addresses as $cc_address) { $all_recipients[] = $cc_address->getAddress(); } if ($this->shouldRateLimitMail($all_recipients)) { $mail->setMessage( pht( 'This is an error email, but one or more recipients have '. 'exceeded the error email rate limit. Declining to deliver '. 'message.')); return null; } } // Some mailers require a valid "To:" in order to deliver mail. If we // don't have any "To:", try to fill it in with a placeholder "To:". // If that also fails, move the "Cc:" line to "To:". if (!$to_addresses) { $void_address = $this->newVoidEmailAddress(); $to_addresses = array($void_address); } $to_addresses = $this->getUniqueEmailAddresses($to_addresses); $cc_addresses = $this->getUniqueEmailAddresses( $cc_addresses, $to_addresses); $message->setToAddresses($to_addresses); $message->setCCAddresses($cc_addresses); $attachments = $this->newEmailAttachments(); $message->setAttachments($attachments); $subject = $this->newEmailSubject(); $message->setSubject($subject); $headers = $this->newEmailHeaders(); foreach ($this->newEmailThreadingHeaders($mailer) as $threading_header) { $headers[] = $threading_header; } $stamps = $mail->getMailStamps(); if ($stamps) { $headers[] = $this->newEmailHeader( 'X-Phabricator-Stamps', implode(' ', $stamps)); } $must_encrypt = $mail->getMustEncrypt(); $raw_body = $mail->getBody(); $body = $raw_body; if ($must_encrypt) { $parts = array(); $encrypt_uri = $mail->getMustEncryptURI(); - if (!strlen($encrypt_uri)) { + if ($encrypt_uri === null || !strlen($encrypt_uri)) { $encrypt_phid = $mail->getRelatedPHID(); if ($encrypt_phid) { $encrypt_uri = urisprintf( '/object/%s/', $encrypt_phid); } } - if (strlen($encrypt_uri)) { + if ($encrypt_uri !== null && strlen($encrypt_uri)) { $parts[] = pht( 'This secure message is notifying you of a change to this object:'); $parts[] = PhabricatorEnv::getProductionURI($encrypt_uri); } $parts[] = pht( 'The content for this message can only be transmitted over a '. 'secure channel. To view the message content, follow this '. 'link:'); $parts[] = PhabricatorEnv::getProductionURI($mail->getURI()); $body = implode("\n\n", $parts); } else { $body = $raw_body; } $body_limit = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); $body = phutil_string_cast($body); if (strlen($body) > $body_limit) { $body = id(new PhutilUTF8StringTruncator()) ->setMaximumBytes($body_limit) ->truncateString($body); $body .= "\n"; $body .= pht('(This email was truncated at %d bytes.)', $body_limit); } $message->setTextBody($body); $body_limit -= strlen($body); // If we sent a different message body than we were asked to, record // what we actually sent to make debugging and diagnostics easier. if ($body !== $raw_body) { $mail->setDeliveredBody($body); } if ($must_encrypt) { $send_html = false; } else { $send_html = $this->shouldSendHTML(); } if ($send_html) { $html_body = $mail->getHTMLBody(); if (phutil_nonempty_string($html_body)) { // NOTE: We just drop the entire HTML body if it won't fit. Safely // truncating HTML is hard, and we already have the text body to fall // back to. if (strlen($html_body) <= $body_limit) { $message->setHTMLBody($html_body); $body_limit -= strlen($html_body); } } } // Pass the headers to the mailer, then save the state so we can show // them in the web UI. If the mail must be encrypted, we remove headers // which are not on a strict whitelist to avoid disclosing information. $filtered_headers = $this->filterHeaders($headers, $must_encrypt); $message->setHeaders($filtered_headers); $mail->setUnfilteredHeaders($headers); $mail->setDeliveredHeaders($headers); if (PhabricatorEnv::getEnvConfig('phabricator.silent')) { $mail->setMessage( pht( 'This software is running in silent mode. See `%s` '. 'in the configuration to change this setting.', 'phabricator.silent')); return null; } return $message; } /* -( Message Components )------------------------------------------------- */ private function newFromEmailAddress() { $from_address = $this->newDefaultEmailAddress(); $mail = $this->getMail(); // If the mail content must be encrypted, always disguise the sender. $must_encrypt = $mail->getMustEncrypt(); if ($must_encrypt) { return $from_address; } // If we have a raw "From" address, use that. $raw_from = $mail->getRawFrom(); if ($raw_from) { list($from_email, $from_name) = $raw_from; return $this->newEmailAddress($from_email, $from_name); } // Otherwise, use as much of the information for any sending entity as // we can. $from_phid = $mail->getFrom(); $actor = $this->getActor($from_phid); if ($actor) { $actor_email = $actor->getEmailAddress(); $actor_name = $actor->getName(); } else { $actor_email = null; $actor_name = null; } $send_as_user = PhabricatorEnv::getEnvConfig('metamta.can-send-as-user'); if ($send_as_user) { if ($actor_email !== null) { $from_address->setAddress($actor_email); } } if ($actor_name !== null) { $from_address->setDisplayName($actor_name); } return $from_address; } private function newReplyToEmailAddress() { $mail = $this->getMail(); $reply_raw = $mail->getReplyTo(); if (!phutil_nonempty_string($reply_raw)) { return null; } $reply_address = new PhutilEmailAddress($reply_raw); // If we have a sending object, change the display name. $from_phid = $mail->getFrom(); $actor = $this->getActor($from_phid); if ($actor) { $reply_address->setDisplayName($actor->getName()); } // If we don't have a display name, fill in a default. - if (!strlen($reply_address->getDisplayName())) { + $reply_display_name = $reply_address->getDisplayName(); + if ($reply_display_name === null || !strlen($reply_display_name)) { $reply_address->setDisplayName(PlatformSymbols::getPlatformServerName()); } return $reply_address; } private function newToEmailAddresses() { $mail = $this->getMail(); $phids = $mail->getToPHIDs(); $addresses = $this->newEmailAddressesFromActorPHIDs($phids); foreach ($mail->getRawToAddresses() as $raw_address) { $addresses[] = new PhutilEmailAddress($raw_address); } return $addresses; } private function newCCEmailAddresses() { $mail = $this->getMail(); $phids = $mail->getCcPHIDs(); return $this->newEmailAddressesFromActorPHIDs($phids); } private function newEmailAddressesFromActorPHIDs(array $phids) { $mail = $this->getMail(); $phids = $mail->expandRecipients($phids); $addresses = array(); foreach ($phids as $phid) { $actor = $this->getActor($phid); if (!$actor) { continue; } if (!$actor->isDeliverable()) { continue; } $addresses[] = new PhutilEmailAddress($actor->getEmailAddress()); } return $addresses; } private function newEmailSubject() { $mail = $this->getMail(); $is_threaded = (bool)$mail->getThreadID(); $must_encrypt = $mail->getMustEncrypt(); $subject = array(); if ($is_threaded) { if ($this->shouldAddRePrefix()) { $subject[] = 'Re:'; } } $subject_prefix = $mail->getSubjectPrefix(); $subject_prefix = phutil_string_cast($subject_prefix); $subject_prefix = trim($subject_prefix); $subject[] = $subject_prefix; // If mail content must be encrypted, we replace the subject with // a generic one. if ($must_encrypt) { $encrypt_subject = $mail->getMustEncryptSubject(); - if (!strlen($encrypt_subject)) { + if ($encrypt_subject === null || !strlen($encrypt_subject)) { $encrypt_subject = pht('Object Updated'); } $subject[] = $encrypt_subject; } else { $vary_prefix = $mail->getVarySubjectPrefix(); if (phutil_nonempty_string($vary_prefix)) { if ($this->shouldVarySubject()) { $subject[] = $vary_prefix; } } $subject[] = $mail->getSubject(); } foreach ($subject as $key => $part) { if (!phutil_nonempty_string($part)) { unset($subject[$key]); } } $subject = implode(' ', $subject); return $subject; } private function newEmailHeaders() { $mail = $this->getMail(); $headers = array(); $headers[] = $this->newEmailHeader( 'X-Phabricator-Sent-This-Message', 'Yes'); $headers[] = $this->newEmailHeader( 'X-Mail-Transport-Agent', 'MetaMTA'); // Some clients respect this to suppress OOF and other auto-responses. $headers[] = $this->newEmailHeader( 'X-Auto-Response-Suppress', 'All'); $mailtags = $mail->getMailTags(); if ($mailtags) { $tag_header = array(); foreach ($mailtags as $mailtag) { $tag_header[] = '<'.$mailtag.'>'; } $tag_header = implode(', ', $tag_header); $headers[] = $this->newEmailHeader( 'X-Phabricator-Mail-Tags', $tag_header); } $value = $mail->getHeaders(); foreach ($value as $pair) { list($header_key, $header_value) = $pair; // NOTE: If we have \n in a header, SES rejects the email. $header_value = str_replace("\n", ' ', $header_value); $headers[] = $this->newEmailHeader($header_key, $header_value); } $is_bulk = $mail->getIsBulk(); if ($is_bulk) { $headers[] = $this->newEmailHeader('Precedence', 'bulk'); } if ($mail->getMustEncrypt()) { $headers[] = $this->newEmailHeader('X-Phabricator-Must-Encrypt', 'Yes'); } $related_phid = $mail->getRelatedPHID(); if ($related_phid) { $headers[] = $this->newEmailHeader('Thread-Topic', $related_phid); } $headers[] = $this->newEmailHeader( 'X-Phabricator-Mail-ID', $mail->getID()); $unique = Filesystem::readRandomCharacters(16); $headers[] = $this->newEmailHeader( 'X-Phabricator-Send-Attempt', $unique); return $headers; } private function newEmailThreadingHeaders() { $mailer = $this->getMailer(); $mail = $this->getMail(); $headers = array(); $thread_id = $mail->getThreadID(); if (!phutil_nonempty_string($thread_id)) { return $headers; } $is_first = $mail->getIsFirstMessage(); // NOTE: Gmail freaks out about In-Reply-To and References which aren't in // the form "<string@domain.tld>"; this is also required by RFC 2822, // although some clients are more liberal in what they accept. $domain = $this->newMailDomain(); $thread_id = '<'.$thread_id.'@'.$domain.'>'; if ($is_first && $mailer->supportsMessageIDHeader()) { $headers[] = $this->newEmailHeader('Message-ID', $thread_id); } else { $in_reply_to = $thread_id; $references = array($thread_id); $parent_id = $mail->getParentMessageID(); if ($parent_id) { $in_reply_to = $parent_id; // By RFC 2822, the most immediate parent should appear last // in the "References" header, so this order is intentional. $references[] = $parent_id; } $references = implode(' ', $references); $headers[] = $this->newEmailHeader('In-Reply-To', $in_reply_to); $headers[] = $this->newEmailHeader('References', $references); } $thread_index = $this->generateThreadIndex($thread_id, $is_first); $headers[] = $this->newEmailHeader('Thread-Index', $thread_index); return $headers; } private function newEmailAttachments() { $mail = $this->getMail(); // If the mail content must be encrypted, don't add attachments. $must_encrypt = $mail->getMustEncrypt(); if ($must_encrypt) { return array(); } return $mail->getAttachments(); } /* -( Preferences )-------------------------------------------------------- */ private function shouldAddRePrefix() { $preferences = $this->getPreferences(); $value = $preferences->getSettingValue( PhabricatorEmailRePrefixSetting::SETTINGKEY); return ($value == PhabricatorEmailRePrefixSetting::VALUE_RE_PREFIX); } private function shouldVarySubject() { $preferences = $this->getPreferences(); $value = $preferences->getSettingValue( PhabricatorEmailVarySubjectsSetting::SETTINGKEY); return ($value == PhabricatorEmailVarySubjectsSetting::VALUE_VARY_SUBJECTS); } private function shouldSendHTML() { $preferences = $this->getPreferences(); $value = $preferences->getSettingValue( PhabricatorEmailFormatSetting::SETTINGKEY); return ($value == PhabricatorEmailFormatSetting::VALUE_HTML_EMAIL); } /* -( Utilities )---------------------------------------------------------- */ private function newEmailHeader($name, $value) { return id(new PhabricatorMailHeader()) ->setName($name) ->setValue($value); } private function newEmailAddress($address, $name = null) { $object = id(new PhutilEmailAddress()) ->setAddress($address); - if (strlen($name)) { + if ($name !== null && strlen($name)) { $object->setDisplayName($name); } return $object; } public function newDefaultEmailAddress() { $raw_address = PhabricatorEnv::getEnvConfig('metamta.default-address'); - if (!strlen($raw_address)) { + if ($raw_address == null || !strlen($raw_address)) { $domain = $this->newMailDomain(); $raw_address = "noreply@{$domain}"; } $address = new PhutilEmailAddress($raw_address); if (!phutil_nonempty_string($address->getDisplayName())) { $address->setDisplayName(PlatformSymbols::getPlatformServerName()); } return $address; } public function newVoidEmailAddress() { return $this->newDefaultEmailAddress(); } private function newMailDomain() { $domain = PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain'); - if (strlen($domain)) { + if ($domain !== null && strlen($domain)) { return $domain; } $install_uri = PhabricatorEnv::getURI('/'); $install_uri = new PhutilURI($install_uri); return $install_uri->getDomain(); } private function filterHeaders(array $headers, $must_encrypt) { assert_instances_of($headers, 'PhabricatorMailHeader'); if (!$must_encrypt) { return $headers; } $whitelist = array( 'In-Reply-To', 'Message-ID', 'Precedence', 'References', 'Thread-Index', 'Thread-Topic', 'X-Mail-Transport-Agent', 'X-Auto-Response-Suppress', 'X-Phabricator-Sent-This-Message', 'X-Phabricator-Must-Encrypt', 'X-Phabricator-Mail-ID', 'X-Phabricator-Send-Attempt', ); // NOTE: The major header we want to drop is "X-Phabricator-Mail-Tags". // This header contains a significant amount of meaningful information // about the object. $whitelist_map = array(); foreach ($whitelist as $term) { $whitelist_map[phutil_utf8_strtolower($term)] = true; } foreach ($headers as $key => $header) { $name = $header->getName(); $name = phutil_utf8_strtolower($name); if (!isset($whitelist_map[$name])) { unset($headers[$key]); } } return $headers; } private function getUniqueEmailAddresses( array $addresses, array $exclude = array()) { assert_instances_of($addresses, 'PhutilEmailAddress'); assert_instances_of($exclude, 'PhutilEmailAddress'); $seen = array(); foreach ($exclude as $address) { $seen[$address->getAddress()] = true; } foreach ($addresses as $key => $address) { $raw_address = $address->getAddress(); if (isset($seen[$raw_address])) { unset($addresses[$key]); continue; } $seen[$raw_address] = true; } return array_values($addresses); } private function generateThreadIndex($seed, $is_first_mail) { // When threading, Outlook ignores the 'References' and 'In-Reply-To' // headers that most clients use. Instead, it uses a custom 'Thread-Index' // header. The format of this header is something like this (from // camel-exchange-folder.c in Evolution Exchange): /* A new post to a folder gets a 27-byte-long thread index. (The value * is apparently unique but meaningless.) Each reply to a post gets a * 32-byte-long thread index whose first 27 bytes are the same as the * parent's thread index. Each reply to any of those gets a * 37-byte-long thread index, etc. The Thread-Index header contains a * base64 representation of this value. */ // The specific implementation uses a 27-byte header for the first email // a recipient receives, and a random 5-byte suffix (32 bytes total) // thereafter. This means that all the replies are (incorrectly) siblings, // but it would be very difficult to keep track of the entire tree and this // gets us reasonable client behavior. $base = substr(md5($seed), 0, 27); if (!$is_first_mail) { // Not totally sure, but it seems like outlook orders replies by // thread-index rather than timestamp, so to get these to show up in the // right order we use the time as the last 4 bytes. $base .= ' '.pack('N', time()); } return base64_encode($base); } private function shouldRateLimitMail(array $all_recipients) { try { PhabricatorSystemActionEngine::willTakeAction( $all_recipients, new PhabricatorMetaMTAErrorMailAction(), 1); return false; } catch (PhabricatorSystemActionRateLimitException $ex) { return true; } } } diff --git a/src/applications/metamta/exception/PhabricatorMetaMTAReceivedMailProcessingException.php b/src/applications/metamta/exception/PhabricatorMetaMTAReceivedMailProcessingException.php index 5fb1209885..d989478470 100644 --- a/src/applications/metamta/exception/PhabricatorMetaMTAReceivedMailProcessingException.php +++ b/src/applications/metamta/exception/PhabricatorMetaMTAReceivedMailProcessingException.php @@ -1,20 +1,20 @@ <?php final class PhabricatorMetaMTAReceivedMailProcessingException extends Exception { private $statusCode; public function getStatusCode() { return $this->statusCode; } public function __construct($status_code /* ... */) { $args = func_get_args(); $this->statusCode = $args[0]; $args = array_slice($args, 1); - call_user_func_array(array('parent', '__construct'), $args); + call_user_func_array(array(parent::class, '__construct'), $args); } } diff --git a/src/applications/passphrase/view/PassphraseCredentialControl.php b/src/applications/passphrase/view/PassphraseCredentialControl.php index 8ba1ef9bc8..2071482003 100644 --- a/src/applications/passphrase/view/PassphraseCredentialControl.php +++ b/src/applications/passphrase/view/PassphraseCredentialControl.php @@ -1,207 +1,209 @@ <?php final class PassphraseCredentialControl extends AphrontFormControl { private $options = array(); private $credentialType; private $defaultUsername; private $allowNull; public function setAllowNull($allow_null) { $this->allowNull = $allow_null; return $this; } public function setDefaultUsername($default_username) { $this->defaultUsername = $default_username; return $this; } public function setCredentialType($credential_type) { $this->credentialType = $credential_type; return $this; } public function getCredentialType() { return $this->credentialType; } public function setOptions(array $options) { assert_instances_of($options, 'PassphraseCredential'); $this->options = $options; return $this; } protected function getCustomControlClass() { return 'passphrase-credential-control'; } protected function renderInput() { $options_map = array(); foreach ($this->options as $option) { $options_map[$option->getPHID()] = pht( '%s %s', $option->getMonogram(), $option->getName()); } // The user editing the form may not have permission to see the current // credential. Populate it into the menu to allow them to save the form // without making any changes. $current_phid = $this->getValue(); - if (strlen($current_phid) && empty($options_map[$current_phid])) { + if ($current_phid !== null && strlen($current_phid) + && empty($options_map[$current_phid])) { + $viewer = $this->getViewer(); $current_name = null; try { $user_credential = id(new PassphraseCredentialQuery()) ->setViewer($viewer) ->withPHIDs(array($current_phid)) ->executeOne(); if ($user_credential) { $current_name = pht( '%s %s', $user_credential->getMonogram(), $user_credential->getName()); } } catch (PhabricatorPolicyException $policy_exception) { // Pull the credential with the omnipotent viewer so we can look up // the ID and provide the monogram. $omnipotent_credential = id(new PassphraseCredentialQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($current_phid)) ->executeOne(); if ($omnipotent_credential) { $current_name = pht( '%s (Restricted Credential)', $omnipotent_credential->getMonogram()); } } if ($current_name === null) { $current_name = pht( 'Invalid Credential ("%s")', $current_phid); } $options_map = array( $current_phid => $current_name, ) + $options_map; } $disabled = $this->getDisabled(); if ($this->allowNull) { $options_map = array('' => pht('(No Credentials)')) + $options_map; } else { if (!$options_map) { $options_map[''] = pht('(No Existing Credentials)'); $disabled = true; } } Javelin::initBehavior('passphrase-credential-control'); $options = AphrontFormSelectControl::renderSelectTag( $this->getValue(), $options_map, array( 'id' => $this->getControlID(), 'name' => $this->getName(), 'disabled' => $disabled ? 'disabled' : null, 'sigil' => 'passphrase-credential-select', )); if ($this->credentialType) { $button = javelin_tag( 'a', array( 'href' => '#', 'class' => 'button button-grey mll', 'sigil' => 'passphrase-credential-add', 'mustcapture' => true, 'style' => 'height: 20px;', // move aphront-form to tables ), pht('Add New Credential')); } else { $button = null; } return javelin_tag( 'div', array( 'sigil' => 'passphrase-credential-control', 'meta' => array( 'type' => $this->getCredentialType(), 'username' => $this->defaultUsername, 'allowNull' => $this->allowNull, ), ), array( $options, $button, )); } /** * Verify that a given actor has permission to use all of the credentials * in a list of credential transactions. * * In general, the rule here is: * * - If you're editing an object and it uses a credential you can't use, * that's fine as long as you don't change the credential. * - If you do change the credential, the new credential must be one you * can use. * * @param PhabricatorUser The acting user. * @param list<PhabricatorApplicationTransaction> List of credential altering * transactions. * @return bool True if the transactions are valid. */ public static function validateTransactions( PhabricatorUser $actor, array $xactions) { $new_phids = array(); foreach ($xactions as $xaction) { $new = $xaction->getNewValue(); if (!$new) { // Removing a credential, so this is OK. continue; } $old = $xaction->getOldValue(); if ($old == $new) { // This is a no-op transaction, so this is also OK. continue; } // Otherwise, we need to check this credential. $new_phids[] = $new; } if (!$new_phids) { // No new credentials being set, so this is fine. return true; } $usable_credentials = id(new PassphraseCredentialQuery()) ->setViewer($actor) ->withPHIDs($new_phids) ->execute(); $usable_credentials = mpull($usable_credentials, null, 'getPHID'); foreach ($new_phids as $phid) { if (empty($usable_credentials[$phid])) { return false; } } return true; } } diff --git a/src/applications/phame/storage/PhameBlog.php b/src/applications/phame/storage/PhameBlog.php index 95507d61dc..a8ba53ae15 100644 --- a/src/applications/phame/storage/PhameBlog.php +++ b/src/applications/phame/storage/PhameBlog.php @@ -1,397 +1,397 @@ <?php final class PhameBlog extends PhameDAO implements PhabricatorPolicyInterface, PhabricatorMarkupInterface, PhabricatorSubscribableInterface, PhabricatorFlaggableInterface, PhabricatorProjectInterface, PhabricatorDestructibleInterface, PhabricatorApplicationTransactionInterface, PhabricatorConduitResultInterface, PhabricatorFulltextInterface, PhabricatorFerretInterface { protected $name; protected $subtitle; protected $description; protected $domain; protected $domainFullURI; protected $parentSite; protected $parentDomain; protected $configData; protected $creatorPHID; protected $viewPolicy; protected $editPolicy; protected $interactPolicy; protected $status; protected $mailKey; protected $profileImagePHID; protected $headerImagePHID; private $profileImageFile = self::ATTACHABLE; private $headerImageFile = self::ATTACHABLE; const STATUS_ACTIVE = 'active'; const STATUS_ARCHIVED = 'archived'; protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'configData' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text64', 'subtitle' => 'text64', 'description' => 'text', 'domain' => 'text128?', 'domainFullURI' => 'text128?', 'parentSite' => 'text128?', 'parentDomain' => 'text128?', 'status' => 'text32', 'mailKey' => 'bytes20', 'profileImagePHID' => 'phid?', 'headerImagePHID' => 'phid?', 'editPolicy' => 'policy', 'viewPolicy' => 'policy', 'interactPolicy' => 'policy', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'domain' => array( 'columns' => array('domain'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function save() { if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); } return parent::save(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPhameBlogPHIDType::TYPECONST); } public static function initializeNewBlog(PhabricatorUser $actor) { $blog = id(new PhameBlog()) ->setCreatorPHID($actor->getPHID()) ->setStatus(self::STATUS_ACTIVE) ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy()) ->setEditPolicy(PhabricatorPolicies::POLICY_USER) ->setInteractPolicy(PhabricatorPolicies::POLICY_USER); return $blog; } public function isArchived() { return ($this->getStatus() == self::STATUS_ARCHIVED); } public static function getStatusNameMap() { return array( self::STATUS_ACTIVE => pht('Active'), self::STATUS_ARCHIVED => pht('Archived'), ); } /** * Makes sure a given custom blog uri is properly configured in DNS * to point at this Phabricator instance. If there is an error in * the configuration, return a string describing the error and how * to fix it. If there is no error, return an empty string. * * @return string */ public function validateCustomDomain($domain_full_uri) { $example_domain = 'http://blog.example.com/'; $label = pht('Invalid'); // note this "uri" should be pretty busted given the desired input // so just use it to test if there's a protocol specified $uri = new PhutilURI($domain_full_uri); $domain = $uri->getDomain(); $protocol = $uri->getProtocol(); $path = $uri->getPath(); $supported_protocols = array('http', 'https'); if (!in_array($protocol, $supported_protocols)) { return pht( 'The custom domain should include a valid protocol in the URI '. '(for example, "%s"). Valid protocols are "http" or "https".', $example_domain); } if (strlen($path) && $path != '/') { return pht( 'The custom domain should not specify a path (hosting a Phame '. 'blog at a path is currently not supported). Instead, just provide '. 'the bare domain name (for example, "%s").', $example_domain); } if (strpos($domain, '.') === false) { return pht( 'The custom domain should contain at least one dot (.) because '. 'some browsers fail to set cookies on domains without a dot. '. 'Instead, use a normal looking domain name like "%s".', $example_domain); } if (!PhabricatorEnv::getEnvConfig('policy.allow-public')) { $href = PhabricatorEnv::getProductionURI( '/config/edit/policy.allow-public/'); return pht( 'For custom domains to work, this this server must be '. 'configured to allow the public access policy. Configure this '. 'setting %s, or ask an administrator to configure this setting. '. 'The domain can be specified later once this setting has been '. 'changed.', phutil_tag( 'a', array('href' => $href), pht('here'))); } return null; } public function getLiveURI() { - if (strlen($this->getDomain())) { + if ($this->getDomain() !== null && strlen($this->getDomain())) { return $this->getExternalLiveURI(); } else { return $this->getInternalLiveURI(); } } public function getExternalLiveURI() { $uri = new PhutilURI($this->getDomainFullURI()); PhabricatorEnv::requireValidRemoteURIForLink($uri); return (string)$uri; } public function getExternalParentURI() { $uri = $this->getParentDomain(); PhabricatorEnv::requireValidRemoteURIForLink($uri); return (string)$uri; } public function getInternalLiveURI() { return '/phame/live/'.$this->getID().'/'; } public function getViewURI() { return '/phame/blog/view/'.$this->getID().'/'; } public function getManageURI() { return '/phame/blog/manage/'.$this->getID().'/'; } public function getProfileImageURI() { return $this->getProfileImageFile()->getBestURI(); } public function attachProfileImageFile(PhabricatorFile $file) { $this->profileImageFile = $file; return $this; } public function getProfileImageFile() { return $this->assertAttached($this->profileImageFile); } public function getHeaderImageURI() { return $this->getHeaderImageFile()->getBestURI(); } public function attachHeaderImageFile(PhabricatorFile $file) { $this->headerImageFile = $file; return $this; } public function getHeaderImageFile() { return $this->assertAttached($this->headerImageFile); } /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_INTERACT, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_INTERACT: return $this->getInteractPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $user) { $can_edit = PhabricatorPolicyCapability::CAN_EDIT; switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: // Users who can edit or post to a blog can always view it. if (PhabricatorPolicyFilter::hasCapability($user, $this, $can_edit)) { return true; } break; } return false; } public function describeAutomaticCapability($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht( 'Users who can edit a blog can always view it.'); } return null; } /* -( PhabricatorMarkupInterface Implementation )-------------------------- */ public function getMarkupFieldKey($field) { $content = $this->getMarkupText($field); return PhabricatorMarkupEngine::digestRemarkupContent($this, $content); } public function newMarkupEngine($field) { return PhabricatorMarkupEngine::newPhameMarkupEngine(); } public function getMarkupText($field) { return $this->getDescription(); } public function didMarkupText( $field, $output, PhutilMarkupEngine $engine) { return $output; } public function shouldUseMarkupCache($field) { return (bool)$this->getPHID(); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $posts = id(new PhamePostQuery()) ->setViewer($engine->getViewer()) ->withBlogPHIDs(array($this->getPHID())) ->execute(); foreach ($posts as $post) { $engine->destroyObject($post); } $this->delete(); $this->saveTransaction(); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhameBlogEditor(); } public function getApplicationTransactionTemplate() { return new PhameBlogTransaction(); } /* -( PhabricatorSubscribableInterface Implementation )-------------------- */ public function isAutomaticallySubscribed($phid) { return false; } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('name') ->setType('string') ->setDescription(pht('The name of the blog.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('description') ->setType('string') ->setDescription(pht('Blog description.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('status') ->setType('string') ->setDescription(pht('Archived or active status.')), ); } public function getFieldValuesForConduit() { return array( 'name' => $this->getName(), 'description' => $this->getDescription(), 'status' => $this->getStatus(), ); } public function getConduitSearchAttachments() { return array(); } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhameBlogFulltextEngine(); } /* -( PhabricatorFerretInterface )----------------------------------------- */ public function newFerretEngine() { return new PhameBlogFerretEngine(); } } diff --git a/src/applications/pholio/controller/PholioImageUploadController.php b/src/applications/pholio/controller/PholioImageUploadController.php index 0ff5e061f5..44d7ea7eed 100644 --- a/src/applications/pholio/controller/PholioImageUploadController.php +++ b/src/applications/pholio/controller/PholioImageUploadController.php @@ -1,44 +1,44 @@ <?php final class PholioImageUploadController extends PholioController { public function handleRequest(AphrontRequest $request) { $viewer = $request->getViewer(); $phid = $request->getStr('filePHID'); $replaces_phid = $request->getStr('replacesPHID'); $title = $request->getStr('title'); $description = $request->getStr('description'); $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($phid)) ->executeOne(); if (!$file) { return new Aphront404Response(); } - if (!strlen($title)) { + if (!phutil_nonempty_string($title)) { $title = $file->getName(); } $image = PholioImage::initializeNewImage() ->setAuthorPHID($viewer->getPHID()) ->attachFile($file) ->setName($title) ->setDescription($description) ->makeEphemeral(); $view = id(new PholioUploadedImageView()) ->setUser($viewer) ->setImage($image) ->setReplacesPHID($replaces_phid); $content = array( 'markup' => $view, ); return id(new AphrontAjaxResponse())->setContent($content); } } diff --git a/src/applications/spaces/xaction/PhabricatorSpacesNamespaceNameTransaction.php b/src/applications/spaces/xaction/PhabricatorSpacesNamespaceNameTransaction.php index d7fcbc2c7a..660e437942 100644 --- a/src/applications/spaces/xaction/PhabricatorSpacesNamespaceNameTransaction.php +++ b/src/applications/spaces/xaction/PhabricatorSpacesNamespaceNameTransaction.php @@ -1,62 +1,62 @@ <?php final class PhabricatorSpacesNamespaceNameTransaction extends PhabricatorSpacesNamespaceTransactionType { const TRANSACTIONTYPE = 'spaces:name'; public function generateOldValue($object) { return $object->getNamespaceName(); } public function applyInternalEffects($object, $value) { $object->setNamespaceName($value); } public function getTitle() { $old = $this->getOldValue(); - if (!strlen($old)) { + if ($old === null || !strlen($old)) { return pht( '%s created this space.', $this->renderAuthor()); } else { return pht( '%s renamed this space from %s to %s.', $this->renderAuthor(), $this->renderOldValue(), $this->renderNewValue()); } } public function getTitleForFeed() { return pht( '%s renamed space %s from %s to %s.', $this->renderAuthor(), $this->renderObject(), $this->renderOldValue(), $this->renderNewValue()); } public function validateTransactions($object, array $xactions) { $errors = array(); if ($this->isEmptyTextTransaction($object->getNamespaceName(), $xactions)) { $errors[] = $this->newRequiredError( pht('Spaces must have a name.')); } $max_length = $object->getColumnMaximumByteLength('namespaceName'); foreach ($xactions as $xaction) { $new_value = $xaction->getNewValue(); $new_length = strlen($new_value); if ($new_length > $max_length) { $errors[] = $this->newInvalidError( pht('The name can be no longer than %s characters.', new PhutilNumber($max_length))); } } return $errors; } } diff --git a/src/applications/transactions/storage/PhabricatorModularTransactionType.php b/src/applications/transactions/storage/PhabricatorModularTransactionType.php index 7d5e3c533e..dac362974a 100644 --- a/src/applications/transactions/storage/PhabricatorModularTransactionType.php +++ b/src/applications/transactions/storage/PhabricatorModularTransactionType.php @@ -1,497 +1,497 @@ <?php abstract class PhabricatorModularTransactionType extends Phobject { private $storage; private $viewer; private $editor; final public function getTransactionTypeConstant() { return $this->getPhobjectClassConstant('TRANSACTIONTYPE'); } public function generateOldValue($object) { throw new PhutilMethodNotImplementedException(); } public function generateNewValue($object, $value) { return $value; } public function validateTransactions($object, array $xactions) { return array(); } public function applyInternalEffects($object, $value) { return; } public function applyExternalEffects($object, $value) { return; } public function didCommitTransaction($object, $value) { return; } public function getTransactionHasEffect($object, $old, $new) { return ($old !== $new); } public function extractFilePHIDs($object, $value) { return array(); } public function shouldHide() { return false; } public function shouldHideForFeed() { return false; } public function shouldHideForMail() { return false; } public function shouldHideForNotifications() { return null; } public function getIcon() { return null; } public function getTitle() { return null; } public function getTitleForFeed() { return null; } public function getActionName() { return null; } public function getActionStrength() { return null; } public function getColor() { return null; } public function hasChangeDetailView() { return false; } public function newChangeDetailView() { return null; } public function getMailDiffSectionHeader() { return pht('EDIT DETAILS'); } public function newRemarkupChanges() { return array(); } public function mergeTransactions( $object, PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { return null; } final public function setStorage( PhabricatorApplicationTransaction $xaction) { $this->storage = $xaction; return $this; } private function getStorage() { return $this->storage; } final public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } final protected function getViewer() { return $this->viewer; } final public function getActor() { return $this->getEditor()->getActor(); } final public function getActingAsPHID() { return $this->getEditor()->getActingAsPHID(); } final public function setEditor( PhabricatorApplicationTransactionEditor $editor) { $this->editor = $editor; return $this; } final protected function getEditor() { if (!$this->editor) { throw new PhutilInvalidStateException('setEditor'); } return $this->editor; } final protected function hasEditor() { return (bool)$this->editor; } final protected function getAuthorPHID() { return $this->getStorage()->getAuthorPHID(); } final protected function getObjectPHID() { return $this->getStorage()->getObjectPHID(); } final protected function getObject() { return $this->getStorage()->getObject(); } final protected function getOldValue() { return $this->getStorage()->getOldValue(); } final protected function getNewValue() { return $this->getStorage()->getNewValue(); } final protected function renderAuthor() { $author_phid = $this->getAuthorPHID(); return $this->getStorage()->renderHandleLink($author_phid); } final protected function renderObject() { $object_phid = $this->getObjectPHID(); return $this->getStorage()->renderHandleLink($object_phid); } final protected function renderHandle($phid) { $viewer = $this->getViewer(); $display = $viewer->renderHandle($phid); if ($this->isTextMode()) { $display->setAsText(true); } return $display; } final protected function renderOldHandle() { return $this->renderHandle($this->getOldValue()); } final protected function renderNewHandle() { return $this->renderHandle($this->getNewValue()); } final protected function renderOldPolicy() { return $this->renderPolicy($this->getOldValue(), 'old'); } final protected function renderNewPolicy() { return $this->renderPolicy($this->getNewValue(), 'new'); } final protected function renderPolicy($phid, $mode) { $viewer = $this->getViewer(); $handles = $viewer->loadHandles(array($phid)); $policy = PhabricatorPolicy::newFromPolicyAndHandle( $phid, $handles[$phid]); $ref = $policy->newRef($viewer); if ($this->isTextMode()) { $name = $ref->getPolicyDisplayName(); } else { $storage = $this->getStorage(); $name = $ref->newTransactionLink($mode, $storage); } return $this->renderValue($name); } final protected function renderHandleList(array $phids) { $viewer = $this->getViewer(); $display = $viewer->renderHandleList($phids) ->setAsInline(true); if ($this->isTextMode()) { $display->setAsText(true); } return $display; } final protected function renderValue($value) { if ($this->isTextMode()) { return sprintf('"%s"', $value); } return phutil_tag( 'span', array( 'class' => 'phui-timeline-value', ), $value); } final protected function renderValueList(array $values) { $result = array(); foreach ($values as $value) { $result[] = $this->renderValue($value); } if ($this->isTextMode()) { return implode(', ', $result); } return phutil_implode_html(', ', $result); } final protected function renderOldValue() { return $this->renderValue($this->getOldValue()); } final protected function renderNewValue() { return $this->renderValue($this->getNewValue()); } final protected function renderDate($epoch) { $viewer = $this->getViewer(); // We accept either epoch timestamps or dictionaries describing a // PhutilCalendarDateTime. if (is_array($epoch)) { $datetime = PhutilCalendarAbsoluteDateTime::newFromDictionary($epoch) ->setViewerTimezone($viewer->getTimezoneIdentifier()); $all_day = $datetime->getIsAllDay(); $epoch = $datetime->getEpoch(); } else { $all_day = false; } if ($all_day) { $display = phabricator_date($epoch, $viewer); } else if ($this->isRenderingTargetExternal()) { // When rendering to text, we explicitly render the offset from UTC to // provide context to the date: the mail may be generating with the // server's settings, or the user may later refer back to it after // changing timezones. $display = phabricator_datetimezone($epoch, $viewer); } else { $display = phabricator_datetime($epoch, $viewer); } return $this->renderValue($display); } final protected function renderOldDate() { return $this->renderDate($this->getOldValue()); } final protected function renderNewDate() { return $this->renderDate($this->getNewValue()); } final protected function newError($title, $message, $xaction = null) { return new PhabricatorApplicationTransactionValidationError( $this->getTransactionTypeConstant(), $title, $message, $xaction); } final protected function newRequiredError($message, $xaction = null) { return $this->newError(pht('Required'), $message, $xaction) ->setIsMissingFieldError(true); } final protected function newInvalidError($message, $xaction = null) { return $this->newError(pht('Invalid'), $message, $xaction); } final protected function isNewObject() { return $this->getEditor()->getIsNewObject(); } final protected function isEmptyTextTransaction($value, array $xactions) { foreach ($xactions as $xaction) { $value = $xaction->getNewValue(); } - return !strlen($value); + return $value === null || !strlen($value); } /** * When rendering to external targets (Email/Asana/etc), we need to include * more information that users can't obtain later. */ final protected function isRenderingTargetExternal() { // Right now, this is our best proxy for this: return $this->isTextMode(); // "TARGET_TEXT" means "EMail" and "TARGET_HTML" means "Web". } final protected function isTextMode() { $target = $this->getStorage()->getRenderingTarget(); return ($target == PhabricatorApplicationTransaction::TARGET_TEXT); } final protected function newRemarkupChange() { return id(new PhabricatorTransactionRemarkupChange()) ->setTransaction($this->getStorage()); } final protected function isCreateTransaction() { return $this->getStorage()->getIsCreateTransaction(); } final protected function getPHIDList(array $old, array $new) { $editor = $this->getEditor(); return $editor->getPHIDList($old, $new); } public function getMetadataValue($key, $default = null) { return $this->getStorage()->getMetadataValue($key, $default); } public function loadTransactionTypeConduitData(array $xactions) { return null; } public function getTransactionTypeForConduit($xaction) { return null; } public function getFieldValuesForConduit($xaction, $data) { return array(); } protected function requireApplicationCapability($capability) { $application_class = $this->getEditor()->getEditorApplicationClass(); $application = newv($application_class, array()); PhabricatorPolicyFilter::requireCapability( $this->getActor(), $application, $capability); } /** * Get a list of capabilities the actor must have on the object to apply * a transaction to it. * * Usually, you should use this to reduce capability requirements when a * transaction (like leaving a Conpherence thread) can be applied without * having edit permission on the object. You can override this method to * remove the CAN_EDIT requirement, or to replace it with a different * requirement. * * If you are increasing capability requirements and need to add an * additional capability or policy requirement above and beyond CAN_EDIT, it * is usually better implemented as a validation check. * * @param object Object being edited. * @param PhabricatorApplicationTransaction Transaction being applied. * @return null|const|list<const> A capability constant (or list of * capability constants) which the actor must have on the object. You can * return `null` as a shorthand for "no capabilities are required". */ public function getRequiredCapabilities( $object, PhabricatorApplicationTransaction $xaction) { return PhabricatorPolicyCapability::CAN_EDIT; } public function shouldTryMFA( $object, PhabricatorApplicationTransaction $xaction) { return false; } // NOTE: See T12921. These APIs are somewhat aspirational. For now, all of // these use "TARGET_TEXT" (even the HTML methods!) and the body methods // actually return Remarkup, not text or HTML. final public function getTitleForTextMail() { return $this->getTitleForMailWithRenderingTarget( PhabricatorApplicationTransaction::TARGET_TEXT); } final public function getTitleForHTMLMail() { return $this->getTitleForMailWithRenderingTarget( PhabricatorApplicationTransaction::TARGET_TEXT); } final public function getBodyForTextMail() { return $this->getBodyForMailWithRenderingTarget( PhabricatorApplicationTransaction::TARGET_TEXT); } final public function getBodyForHTMLMail() { return $this->getBodyForMailWithRenderingTarget( PhabricatorApplicationTransaction::TARGET_TEXT); } private function getTitleForMailWithRenderingTarget($target) { $storage = $this->getStorage(); $old_target = $storage->getRenderingTarget(); try { $storage->setRenderingTarget($target); $result = $this->getTitleForMail(); } catch (Exception $ex) { $storage->setRenderingTarget($old_target); throw $ex; } $storage->setRenderingTarget($old_target); return $result; } private function getBodyForMailWithRenderingTarget($target) { $storage = $this->getStorage(); $old_target = $storage->getRenderingTarget(); try { $storage->setRenderingTarget($target); $result = $this->getBodyForMail(); } catch (Exception $ex) { $storage->setRenderingTarget($old_target); throw $ex; } $storage->setRenderingTarget($old_target); return $result; } protected function getTitleForMail() { return false; } protected function getBodyForMail() { return false; } } diff --git a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldPHIDs.php b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldPHIDs.php index a96ebefda1..014927bcd1 100644 --- a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldPHIDs.php +++ b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldPHIDs.php @@ -1,275 +1,275 @@ <?php /** * Common code for standard field types which store lists of PHIDs. */ abstract class PhabricatorStandardCustomFieldPHIDs extends PhabricatorStandardCustomField { public function buildFieldIndexes() { $indexes = array(); $value = $this->getFieldValue(); if (is_array($value)) { foreach ($value as $phid) { $indexes[] = $this->newStringIndex($phid); } } return $indexes; } public function readValueFromRequest(AphrontRequest $request) { $value = $request->getArr($this->getFieldKey()); $this->setFieldValue($value); } public function getValueForStorage() { $value = $this->getFieldValue(); if (!$value) { return null; } return json_encode(array_values($value)); } public function setValueFromStorage($value) { // NOTE: We're accepting either a JSON string (a real storage value) or // an array (from HTTP parameter prefilling). This is a little hacky, but // should hold until this can get cleaned up more thoroughly. // TODO: Clean this up. $result = array(); - if (!is_array($value)) { + if ($value !== null && !is_array($value)) { $value = json_decode($value, true); if (is_array($value)) { $result = array_values($value); } } $this->setFieldValue($value); return $this; } public function readApplicationSearchValueFromRequest( PhabricatorApplicationSearchEngine $engine, AphrontRequest $request) { return $request->getArr($this->getFieldKey()); } public function applyApplicationSearchConstraintToQuery( PhabricatorApplicationSearchEngine $engine, PhabricatorCursorPagedPolicyAwareQuery $query, $value) { if ($value) { $query->withApplicationSearchContainsConstraint( $this->newStringIndex(null), $value); } } public function getRequiredHandlePHIDsForPropertyView() { $value = $this->getFieldValue(); if ($value) { return $value; } return array(); } public function renderPropertyViewValue(array $handles) { $value = $this->getFieldValue(); if (!$value) { return null; } $handles = mpull($handles, 'renderHovercardLink'); $handles = phutil_implode_html(', ', $handles); return $handles; } public function getRequiredHandlePHIDsForEdit() { $value = $this->getFieldValue(); if ($value) { return $value; } else { return array(); } } public function getApplicationTransactionRequiredHandlePHIDs( PhabricatorApplicationTransaction $xaction) { $old = $this->decodeValue($xaction->getOldValue()); $new = $this->decodeValue($xaction->getNewValue()); $add = array_diff($new, $old); $rem = array_diff($old, $new); return array_merge($add, $rem); } public function getApplicationTransactionTitle( PhabricatorApplicationTransaction $xaction) { $author_phid = $xaction->getAuthorPHID(); $old = $this->decodeValue($xaction->getOldValue()); $new = $this->decodeValue($xaction->getNewValue()); $add = array_diff($new, $old); $rem = array_diff($old, $new); if ($add && !$rem) { return pht( '%s updated %s, added %d: %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), phutil_count($add), $xaction->renderHandleList($add)); } else if ($rem && !$add) { return pht( '%s updated %s, removed %s: %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), phutil_count($rem), $xaction->renderHandleList($rem)); } else { return pht( '%s updated %s, added %s: %s; removed %s: %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), phutil_count($add), $xaction->renderHandleList($add), phutil_count($rem), $xaction->renderHandleList($rem)); } } public function getApplicationTransactionTitleForFeed( PhabricatorApplicationTransaction $xaction) { $author_phid = $xaction->getAuthorPHID(); $object_phid = $xaction->getObjectPHID(); $old = $this->decodeValue($xaction->getOldValue()); $new = $this->decodeValue($xaction->getNewValue()); $add = array_diff($new, $old); $rem = array_diff($old, $new); if ($add && !$rem) { return pht( '%s updated %s for %s, added %d: %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $xaction->renderHandleLink($object_phid), phutil_count($add), $xaction->renderHandleList($add)); } else if ($rem && !$add) { return pht( '%s updated %s for %s, removed %s: %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $xaction->renderHandleLink($object_phid), phutil_count($rem), $xaction->renderHandleList($rem)); } else { return pht( '%s updated %s for %s, added %s: %s; removed %s: %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $xaction->renderHandleLink($object_phid), phutil_count($add), $xaction->renderHandleList($add), phutil_count($rem), $xaction->renderHandleList($rem)); } } public function validateApplicationTransactions( PhabricatorApplicationTransactionEditor $editor, $type, array $xactions) { $errors = parent::validateApplicationTransactions( $editor, $type, $xactions); // If the user is adding PHIDs, make sure the new PHIDs are valid and // visible to the actor. It's OK for a user to edit a field which includes // some invalid or restricted values, but they can't add new ones. foreach ($xactions as $xaction) { $old = $this->decodeValue($xaction->getOldValue()); $new = $this->decodeValue($xaction->getNewValue()); $add = array_diff($new, $old); $invalid = PhabricatorObjectQuery::loadInvalidPHIDsForViewer( $editor->getActor(), $add); if ($invalid) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Some of the selected PHIDs in field "%s" are invalid or '. 'restricted: %s.', $this->getFieldName(), implode(', ', $invalid)), $xaction); $errors[] = $error; $this->setFieldError(pht('Invalid')); } } return $errors; } public function shouldAppearInHerald() { return true; } public function getHeraldFieldConditions() { return array( HeraldAdapter::CONDITION_INCLUDE_ALL, HeraldAdapter::CONDITION_INCLUDE_ANY, HeraldAdapter::CONDITION_INCLUDE_NONE, HeraldAdapter::CONDITION_EXISTS, HeraldAdapter::CONDITION_NOT_EXISTS, ); } public function getHeraldFieldStandardType() { return HeraldField::STANDARD_PHID_NULLABLE; } public function getHeraldFieldValue() { // If the field has a `null` value, make sure we hand an `array()` to // Herald. $value = parent::getHeraldFieldValue(); if ($value) { return $value; } return array(); } protected function decodeValue($value) { if ($value === null) { return array(); } $value = json_decode($value); if (!is_array($value)) { $value = array(); } return $value; } protected function getHTTPParameterType() { return new AphrontPHIDListHTTPParameterType(); } }