diff --git a/src/applications/auth/controller/PhabricatorEmailVerificationController.php b/src/applications/auth/controller/PhabricatorEmailVerificationController.php index e8138339af..996795eb94 100644 --- a/src/applications/auth/controller/PhabricatorEmailVerificationController.php +++ b/src/applications/auth/controller/PhabricatorEmailVerificationController.php @@ -1,89 +1,89 @@ getViewer(); $code = $request->getURIData('code'); if ($viewer->getIsDisabled()) { // We allowed unapproved and disabled users to hit this controller, but // want to kick out disabled users now. return new Aphront400Response(); } $email = id(new PhabricatorUserEmail())->loadOneWhere( 'userPHID = %s AND verificationCode = %s', $viewer->getPHID(), $code); $submit = null; if (!$email) { $title = pht('Unable to Verify Email'); $content = pht( 'The verification code you provided is incorrect, or the email '. 'address has been removed, or the email address is owned by another '. 'user. Make sure you followed the link in the email correctly and are '. 'logged in with the user account associated with the email address.'); $continue = pht('Rats!'); } else if ($email->getIsVerified() && $viewer->getIsEmailVerified()) { $title = pht('Address Already Verified'); $content = pht( 'This email address has already been verified.'); - $continue = pht('Continue to Phabricator'); + $continue = pht('Continue'); } else if ($request->isFormPost()) { id(new PhabricatorUserEditor()) ->setActor($viewer) ->verifyEmail($viewer, $email); $title = pht('Address Verified'); $content = pht( 'The email address %s is now verified.', phutil_tag('strong', array(), $email->getAddress())); - $continue = pht('Continue to Phabricator'); + $continue = pht('Continue'); } else { $title = pht('Verify Email Address'); $content = pht( 'Verify this email address (%s) and attach it to your account?', phutil_tag('strong', array(), $email->getAddress())); $continue = pht('Cancel'); $submit = pht('Verify %s', $email->getAddress()); } $dialog = id(new AphrontDialogView()) ->setUser($viewer) ->setTitle($title) ->addCancelButton('/', $continue) ->appendChild($content); if ($submit) { $dialog->addSubmitButton($submit); } $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Verify Email')); $crumbs->setBorder(true); return $this->newPage() ->setTitle(pht('Verify Email')) ->setCrumbs($crumbs) ->appendChild($dialog); } } diff --git a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php index 2c25fa3f4a..f8c8b013bf 100644 --- a/src/applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php +++ b/src/applications/auth/controller/contact/PhabricatorAuthContactNumberTestController.php @@ -1,64 +1,64 @@ getViewer(); $id = $request->getURIData('id'); $number = id(new PhabricatorAuthContactNumberQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$number) { return new Aphront404Response(); } $id = $number->getID(); $cancel_uri = $number->getURI(); // NOTE: This is a global limit shared by all users. PhabricatorSystemActionEngine::willTakeAction( array(id(new PhabricatorAuthApplication())->getPHID()), new PhabricatorAuthTestSMSAction(), 1); if ($request->isFormPost()) { $uri = PhabricatorEnv::getURI('/'); $uri = new PhutilURI($uri); $mail = id(new PhabricatorMetaMTAMail()) ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE) ->addTos(array($viewer->getPHID())) ->setSensitiveContent(false) ->setBody( pht( - 'This is a terse test text message from Phabricator (%s).', + 'This is a terse test text message (from "%s").', $uri->getDomain())) ->save(); return id(new AphrontRedirectResponse())->setURI($mail->getURI()); } $number_display = phutil_tag( 'strong', array(), $number->getDisplayName()); return $this->newDialog() ->setTitle(pht('Set Test Message')) ->appendParagraph( pht( 'Send a test message to %s?', $number_display)) ->addSubmitButton(pht('Send SMS')) ->addCancelButton($cancel_uri); } } diff --git a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php index a84337a764..65f0aa5e4b 100644 --- a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php @@ -1,867 +1,869 @@ loadConfigurationsForProvider($provider, $user)) { return false; } return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $messages = array(); if ($this->loadConfigurationsForProvider($provider, $user)) { $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'You already have Duo authentication attached to your account '. 'for this provider.'), )); } return $messages; } public function getConfigurationListDetails( PhabricatorAuthFactorConfig $config, PhabricatorAuthFactorProvider $provider, PhabricatorUser $viewer) { $duo_user = $config->getAuthFactorConfigProperty('duo.username'); return pht('Duo Username: %s', $duo_user); } public function newEditEngineFields( PhabricatorEditEngine $engine, PhabricatorAuthFactorProvider $provider) { $viewer = $engine->getViewer(); $credential_phid = $provider->getAuthFactorProviderProperty( self::PROP_CREDENTIAL); $hostname = $provider->getAuthFactorProviderProperty(self::PROP_HOSTNAME); $usernames = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); $enroll = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); $credential_type = PassphrasePasswordCredentialType::CREDENTIAL_TYPE; $provides_type = PassphrasePasswordCredentialType::PROVIDES_TYPE; $credentials = id(new PassphraseCredentialQuery()) ->setViewer($viewer) ->withIsDestroyed(false) ->withProvidesTypes(array($provides_type)) ->execute(); $xaction_hostname = PhabricatorAuthFactorProviderDuoHostnameTransaction::TRANSACTIONTYPE; $xaction_credential = PhabricatorAuthFactorProviderDuoCredentialTransaction::TRANSACTIONTYPE; $xaction_usernames = PhabricatorAuthFactorProviderDuoUsernamesTransaction::TRANSACTIONTYPE; $xaction_enroll = PhabricatorAuthFactorProviderDuoEnrollTransaction::TRANSACTIONTYPE; return array( id(new PhabricatorTextEditField()) ->setLabel(pht('Duo API Hostname')) ->setKey('duo.hostname') ->setValue($hostname) ->setTransactionType($xaction_hostname) ->setIsRequired(true), id(new PhabricatorCredentialEditField()) ->setLabel(pht('Duo API Credential')) ->setKey('duo.credential') ->setValue($credential_phid) ->setTransactionType($xaction_credential) ->setCredentialType($credential_type) ->setCredentials($credentials), id(new PhabricatorSelectEditField()) ->setLabel(pht('Duo Username')) ->setKey('duo.usernames') ->setValue($usernames) ->setTransactionType($xaction_usernames) ->setOptions( array( - 'username' => pht('Use Phabricator Username'), + 'username' => pht( + 'Use %s Username', + PlatformSymbols::getPlatformServerName()), 'email' => pht('Use Primary Email Address'), )), id(new PhabricatorSelectEditField()) ->setLabel(pht('Create Accounts')) ->setKey('duo.enroll') ->setValue($enroll) ->setTransactionType($xaction_enroll) ->setOptions( array( 'deny' => pht('Require Existing Duo Account'), 'allow' => pht('Create New Duo Account'), )), ); } public function processAddFactorForm( PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { $token = $this->loadMFASyncToken($provider, $request, $form, $user); if ($this->isAuthResult($token)) { $form->appendChild($this->newAutomaticControl($token)); return; } $enroll = $token->getTemporaryTokenProperty('duo.enroll'); $duo_id = $token->getTemporaryTokenProperty('duo.user-id'); $duo_uri = $token->getTemporaryTokenProperty('duo.uri'); $duo_user = $token->getTemporaryTokenProperty('duo.username'); $is_external = ($enroll === 'external'); $is_auto = ($enroll === 'auto'); $is_blocked = ($enroll === 'blocked'); if (!$token->getIsNewTemporaryToken()) { if ($is_auto) { return $this->newDuoConfig($user, $duo_user); } else if ($is_external || $is_blocked) { $parameters = array( 'username' => $duo_user, ); $result = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $result_code = $result['response']['result']; switch ($result_code) { case 'auth': case 'allow': return $this->newDuoConfig($user, $duo_user); case 'enroll': if ($is_blocked) { // We'll render an equivalent static control below, so skip // rendering here. We explicitly don't want to give the user // an enroll workflow. break; } $duo_uri = $result['response']['enroll_portal_url']; $waiting_icon = id(new PHUIIconView()) ->setIcon('fa-mobile', 'red'); $waiting_control = id(new PHUIFormTimerControl()) ->setIcon($waiting_icon) ->setError(pht('Not Complete')) ->appendChild( pht( 'You have not completed Duo enrollment yet. '. 'Complete enrollment, then click continue.')); $form->appendControl($waiting_control); break; default: case 'deny': break; } } else { $parameters = array( 'user_id' => $duo_id, 'activation_code' => $duo_uri, ); $future = $this->newDuoFuture($provider) ->setMethod('enroll_status', $parameters); $result = $future->resolve(); $response = $result['response']; switch ($response) { case 'success': return $this->newDuoConfig($user, $duo_user); case 'waiting': $waiting_icon = id(new PHUIIconView()) ->setIcon('fa-mobile', 'red'); $waiting_control = id(new PHUIFormTimerControl()) ->setIcon($waiting_icon) ->setError(pht('Not Complete')) ->appendChild( pht( 'You have not activated this enrollment in the Duo '. 'application on your phone yet. Complete activation, then '. 'click continue.')); $form->appendControl($waiting_control); break; case 'invalid': default: throw new Exception( pht( 'This Duo enrollment attempt is invalid or has '. 'expired ("%s"). Cancel the workflow and try again.', $response)); } } } if ($is_blocked) { $blocked_icon = id(new PHUIIconView()) ->setIcon('fa-times', 'red'); $blocked_control = id(new PHUIFormTimerControl()) ->setIcon($blocked_icon) ->appendChild( pht( 'Your Duo account ("%s") has not completed Duo enrollment. '. 'Check your email and complete enrollment to continue.', phutil_tag('strong', array(), $duo_user))); $form->appendControl($blocked_control); } else if ($is_auto) { $auto_icon = id(new PHUIIconView()) ->setIcon('fa-check', 'green'); $auto_control = id(new PHUIFormTimerControl()) ->setIcon($auto_icon) ->appendChild( pht( 'Duo account ("%s") is fully enrolled.', phutil_tag('strong', array(), $duo_user))); $form->appendControl($auto_control); } else { $duo_button = phutil_tag( 'a', array( 'href' => $duo_uri, 'class' => 'button button-grey', 'target' => ($is_external ? '_blank' : null), ), pht('Enroll Duo Account: %s', $duo_user)); $duo_button = phutil_tag( 'div', array( 'class' => 'mfa-form-enroll-button', ), $duo_button); if ($is_external) { $form->appendRemarkupInstructions( pht( 'Complete enrolling your phone with Duo:')); $form->appendControl( id(new AphrontFormMarkupControl()) ->setValue($duo_button)); } else { $form->appendRemarkupInstructions( pht( 'Scan this QR code with the Duo application on your mobile '. 'phone:')); $qr_code = $this->newQRCode($duo_uri); $form->appendChild($qr_code); $form->appendRemarkupInstructions( pht( 'If you are currently using your phone to view this page, '. 'click this button to open the Duo application:')); $form->appendControl( id(new AphrontFormMarkupControl()) ->setValue($duo_button)); } $form->appendRemarkupInstructions( pht( 'Once you have completed setup on your phone, click continue.')); } } protected function newMFASyncTokenProperties( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $duo_user = $this->getDuoUsername($provider, $user); // Duo automatically normalizes usernames to lowercase. Just do that here // so that our value agrees more closely with Duo. $duo_user = phutil_utf8_strtolower($duo_user); $parameters = array( 'username' => $duo_user, ); $result = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $external_uri = null; $result_code = $result['response']['result']; $status_message = $result['response']['status_msg']; switch ($result_code) { case 'auth': case 'allow': // If the user already has a Duo account, they don't need to do // anything. return array( 'duo.enroll' => 'auto', 'duo.username' => $duo_user, ); case 'enroll': if (!$this->shouldAllowDuoEnrollment($provider)) { return array( 'duo.enroll' => 'blocked', 'duo.username' => $duo_user, ); } $external_uri = $result['response']['enroll_portal_url']; // Otherwise, enrollment is permitted so we're going to continue. break; default: case 'deny': return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Your Duo account ("%s") is not permitted to access this '. 'system. Contact your Duo administrator for help. '. 'The Duo preauth API responded with status message ("%s"): %s', $duo_user, $result_code, $status_message)); } // Duo's "/enroll" API isn't repeatable for the same username. If we're // the first call, great: we can do inline enrollment, which is way more // user friendly. Otherwise, we have to send the user on an adventure. $parameters = array( 'username' => $duo_user, 'valid_secs' => phutil_units('1 hour in seconds'), ); try { $result = $this->newDuoFuture($provider) ->setMethod('enroll', $parameters) ->resolve(); } catch (HTTPFutureHTTPResponseStatus $ex) { return array( 'duo.enroll' => 'external', 'duo.username' => $duo_user, 'duo.uri' => $external_uri, ); } return array( 'duo.enroll' => 'inline', 'duo.uri' => $result['response']['activation_code'], 'duo.username' => $duo_user, 'duo.user-id' => $result['response']['user_id'], ); } protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { // If we already issued a valid challenge for this workflow and session, // don't issue a new one. $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge) { return array(); } if (!$this->hasCSRF($config)) { return $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'An authorization request will be pushed to the Duo '. 'application on your phone.')); } $provider = $config->getFactorProvider(); // Otherwise, issue a new challenge. $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username'); $parameters = array( 'username' => $duo_user, ); $response = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $response = $response['response']; $next_step = $response['result']; $status_message = $response['status_msg']; switch ($next_step) { case 'auth': // We're good to go. break; case 'allow': // Duo is telling us to bypass MFA. For now, refuse. return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Duo is not requiring a challenge, which defeats the '. 'purpose of MFA. Duo must be configured to challenge you.')); case 'enroll': return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Your Duo account ("%s") requires enrollment. Contact your '. 'Duo administrator for help. Duo status message: %s', $duo_user, $status_message)); case 'deny': default: return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Your Duo account ("%s") is not permitted to access this '. 'system. Contact your Duo administrator for help. The Duo '. 'preauth API responded with status message ("%s"): %s', $duo_user, $next_step, $status_message)); } $has_push = false; $devices = $response['devices']; foreach ($devices as $device) { $capabilities = array_fuse($device['capabilities']); if (isset($capabilities['push'])) { $has_push = true; break; } } if (!$has_push) { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( - 'This factor has been removed from your device, so Phabricator '. + 'This factor has been removed from your device, so this server '. 'can not send you a challenge. To continue, an administrator '. 'must strip this factor from your account.')); } $push_info = array( pht('Domain') => $this->getInstallDisplayName(), ); $push_info = phutil_build_http_querystring($push_info); $parameters = array( 'username' => $duo_user, 'factor' => 'push', 'async' => '1', // Duo allows us to specify a device, or to pass "auto" to have it pick // the first one. For now, just let it pick. 'device' => 'auto', // This is a hard-coded prefix for the word "... request" in the Duo UI, // which defaults to "Login". We could pass richer information from // workflows here, but it's not very flexible anyway. 'type' => 'Authentication', 'display_username' => $viewer->getUsername(), 'pushinfo' => $push_info, ); $result = $this->newDuoFuture($provider) ->setMethod('auth', $parameters) ->resolve(); $duo_xaction = $result['response']['txid']; // The Duo push timeout is 60 seconds. Set our challenge to expire slightly // more quickly so that we'll re-issue a new challenge before Duo times out. // This should keep users away from a dead-end where they can't respond to - // Duo but Phabricator won't issue a new challenge yet. + // Duo but we won't issue a new challenge yet. $ttl_seconds = 55; return array( $this->newChallenge($config, $viewer) ->setChallengeKey($duo_xaction) ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), ); } protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge->getIsAnsweredChallenge()) { return $this->newResult() ->setAnsweredChallenge($challenge); } $provider = $config->getFactorProvider(); $duo_xaction = $challenge->getChallengeKey(); $parameters = array( 'txid' => $duo_xaction, ); // This endpoint always long-polls, so use a timeout to force it to act // more asynchronously. try { $result = $this->newDuoFuture($provider) ->setHTTPMethod('GET') ->setMethod('auth_status', $parameters) ->setTimeout(3) ->resolve(); $state = $result['response']['result']; $status = $result['response']['status']; } catch (HTTPFutureCURLResponseStatus $exception) { if ($exception->isTimeout()) { $state = 'waiting'; $status = 'poll'; } else { throw $exception; } } $now = PhabricatorTime::getNow(); switch ($state) { case 'allow': $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); $challenge ->markChallengeAsAnswered($ttl); return $this->newResult() ->setAnsweredChallenge($challenge); case 'waiting': // If we didn't just issue this challenge, give the user a stronger // hint that they need to follow the instructions. if (!$challenge->getIsNewChallenge()) { return $this->newResult() ->setIsContinue(true) ->setIcon( id(new PHUIIconView()) ->setIcon('fa-exclamation-triangle', 'yellow')) ->setErrorMessage( pht( 'You must approve the challenge which was sent to your '. 'phone. Open the Duo application and confirm the challenge, '. 'then continue.')); } // Otherwise, we'll construct a default message later on. break; default: case 'deny': if ($status === 'timeout') { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'This request has timed out because you took too long to '. 'respond.')); } else { $wait_duration = ($challenge->getChallengeTTL() - $now) + 1; return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'You denied this request. Wait %s second(s) to try again.', new PhutilNumber($wait_duration))); } break; } return null; } public function renderValidateFactorForm( PhabricatorAuthFactorConfig $config, AphrontFormView $form, PhabricatorUser $viewer, PhabricatorAuthFactorResult $result) { $control = $this->newAutomaticControl($result); $control ->setLabel(pht('Duo')) ->setCaption(pht('Factor Name: %s', $config->getFactorName())); $form->appendChild($control); } public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { return false; } protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { return $this->getResultForPrompt( $config, $viewer, $request, $challenges); } protected function newResultForPrompt( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { $result = $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'A challenge has been sent to your phone. Open the Duo '. 'application and confirm the challenge, then continue.')); $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge) { $result ->setStatusChallenge($challenge) ->setIcon( id(new PHUIIconView()) ->setIcon('fa-refresh', 'green ph-spin')); } return $result; } private function newDuoFuture(PhabricatorAuthFactorProvider $provider) { $credential_phid = $provider->getAuthFactorProviderProperty( self::PROP_CREDENTIAL); $omnipotent = PhabricatorUser::getOmnipotentUser(); $credential = id(new PassphraseCredentialQuery()) ->setViewer($omnipotent) ->withPHIDs(array($credential_phid)) ->needSecrets(true) ->executeOne(); if (!$credential) { throw new Exception( pht( 'Unable to load Duo API credential ("%s").', $credential_phid)); } $duo_key = $credential->getUsername(); $duo_secret = $credential->getSecret(); if (!$duo_secret) { throw new Exception( pht( 'Duo API credential ("%s") has no secret key.', $credential_phid)); } $duo_host = $provider->getAuthFactorProviderProperty( self::PROP_HOSTNAME); self::requireDuoAPIHostname($duo_host); return id(new PhabricatorDuoFuture()) ->setIntegrationKey($duo_key) ->setSecretKey($duo_secret) ->setAPIHostname($duo_host) ->setTimeout(10) ->setHTTPMethod('POST'); } private function getDuoUsername( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); switch ($mode) { case 'username': return $user->getUsername(); case 'email': return $user->loadPrimaryEmailAddress(); default: throw new Exception( pht( 'Duo username pairing mode ("%s") is not supported.', $mode)); } } private function shouldAllowDuoEnrollment( PhabricatorAuthFactorProvider $provider) { $mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); switch ($mode) { case 'deny': return false; case 'allow': return true; default: throw new Exception( pht( 'Duo enrollment mode ("%s") is not supported.', $mode)); } } private function newDuoConfig(PhabricatorUser $user, $duo_user) { $config_properties = array( 'duo.username' => $duo_user, ); $config = $this->newConfigForUser($user) ->setFactorName(pht('Duo (%s)', $duo_user)) ->setProperties($config_properties); return $config; } public static function requireDuoAPIHostname($hostname) { if (preg_match('/\.duosecurity\.com\z/', $hostname)) { return; } throw new Exception( pht( 'Duo API hostname ("%s") is invalid, hostname must be '. '"*.duosecurity.com".', $hostname)); } public function newChallengeStatusView( PhabricatorAuthFactorConfig $config, PhabricatorAuthFactorProvider $provider, PhabricatorUser $viewer, PhabricatorAuthChallenge $challenge) { $duo_xaction = $challenge->getChallengeKey(); $parameters = array( 'txid' => $duo_xaction, ); $default_result = id(new PhabricatorAuthChallengeUpdate()) ->setRetry(true); try { $result = $this->newDuoFuture($provider) ->setHTTPMethod('GET') ->setMethod('auth_status', $parameters) ->setTimeout(5) ->resolve(); $state = $result['response']['result']; } catch (HTTPFutureCURLResponseStatus $exception) { // If we failed or timed out, retry. Usually, this is a timeout. return id(new PhabricatorAuthChallengeUpdate()) ->setRetry(true); } // For now, don't update the view for anything but an "Allow". Updates // here are just about providing more visual feedback for user convenience. if ($state !== 'allow') { return id(new PhabricatorAuthChallengeUpdate()) ->setRetry(false); } $icon = id(new PHUIIconView()) ->setIcon('fa-check-circle-o', 'green'); $view = id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild(pht('You responded to this challenge correctly.')) ->newTimerView(); return id(new PhabricatorAuthChallengeUpdate()) ->setState('allow') ->setRetry(false) ->setMarkup($view); } } diff --git a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php index ba46de980e..33f640e692 100644 --- a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php @@ -1,401 +1,402 @@ isSMSMailerConfigured(); } public function getProviderCreateDescription() { $messages = array(); if (!$this->isSMSMailerConfigured()) { $messages[] = id(new PHUIInfoView()) ->setErrors( array( pht( 'You have not configured an outbound SMS mailer. You must '. 'configure one before you can set up SMS. See: %s', phutil_tag( 'a', array( 'href' => '/config/edit/cluster.mailers/', ), 'cluster.mailers')), )); } $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'SMS is weak, and relatively easy for attackers to compromise. '. 'Strongly consider using a different MFA provider.'), )); return $messages; } public function canCreateNewConfiguration( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { if (!$this->loadUserContactNumber($user)) { return false; } if ($this->loadConfigurationsForProvider($provider, $user)) { return false; } return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $messages = array(); if (!$this->loadUserContactNumber($user)) { $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'You have not configured a primary contact number. Configure '. 'a contact number before adding SMS as an authentication '. 'factor.'), )); } if ($this->loadConfigurationsForProvider($provider, $user)) { $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'You already have SMS authentication attached to your account.'), )); } return $messages; } public function getEnrollDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { return pht( 'To verify your phone as an authentication factor, a text message with '. 'a secret code will be sent to the phone number you have listed as '. 'your primary contact number.'); } public function getEnrollButtonText( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $contact_number = $this->loadUserContactNumber($user); return pht('Send SMS: %s', $contact_number->getDisplayName()); } public function processAddFactorForm( PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { $token = $this->loadMFASyncToken($provider, $request, $form, $user); $code = $request->getStr('sms.code'); $e_code = true; if (!$token->getIsNewTemporaryToken()) { $expect_code = $token->getTemporaryTokenProperty('code'); $okay = phutil_hashes_are_identical( $this->normalizeSMSCode($code), $this->normalizeSMSCode($expect_code)); if ($okay) { $config = $this->newConfigForUser($user) ->setFactorName(pht('SMS')); return $config; } else { if (!strlen($code)) { $e_code = pht('Required'); } else { $e_code = pht('Invalid'); } } } $form->appendRemarkupInstructions( pht( 'Enter the code from the text message which was sent to your '. 'primary contact number.')); $form->appendChild( id(new PHUIFormNumberControl()) ->setLabel(pht('SMS Code')) ->setName('sms.code') ->setValue($code) ->setError($e_code)); } protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { // If we already issued a valid challenge for this workflow and session, // don't issue a new one. $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge) { return array(); } if (!$this->loadUserContactNumber($viewer)) { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Your account has no primary contact number.')); } if (!$this->isSMSMailerConfigured()) { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'No outbound mailer which can deliver SMS messages is '. 'configured.')); } if (!$this->hasCSRF($config)) { return $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'A text message with an authorization code will be sent to your '. 'primary contact number.')); } // Otherwise, issue a new challenge. $challenge_code = $this->newSMSChallengeCode(); $envelope = new PhutilOpaqueEnvelope($challenge_code); $this->sendSMSCodeToUser($envelope, $viewer); $ttl_seconds = phutil_units('15 minutes in seconds'); return array( $this->newChallenge($config, $viewer) ->setChallengeKey($challenge_code) ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), ); } protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge->getIsAnsweredChallenge()) { return $this->newResult() ->setAnsweredChallenge($challenge); } return null; } public function renderValidateFactorForm( PhabricatorAuthFactorConfig $config, AphrontFormView $form, PhabricatorUser $viewer, PhabricatorAuthFactorResult $result) { $control = $this->newAutomaticControl($result); if (!$control) { $value = $result->getValue(); $error = $result->getErrorMessage(); $name = $this->getChallengeResponseParameterName($config); $control = id(new PHUIFormNumberControl()) ->setName($name) ->setDisableAutocomplete(true) ->setValue($value) ->setError($error); } $control ->setLabel(pht('SMS Code')) ->setCaption(pht('Factor Name: %s', $config->getFactorName())); $form->appendChild($control); } public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $value = $this->getChallengeResponseFromRequest($config, $request); return (bool)strlen($value); } protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); $code = $this->getChallengeResponseFromRequest( $config, $request); $result = $this->newResult() ->setValue($code); if ($challenge->getIsAnsweredChallenge()) { return $result->setAnsweredChallenge($challenge); } if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); $challenge ->markChallengeAsAnswered($ttl); return $result->setAnsweredChallenge($challenge); } if (strlen($code)) { $error_message = pht('Invalid'); } else { $error_message = pht('Required'); } $result->setErrorMessage($error_message); return $result; } private function newSMSChallengeCode() { $value = Filesystem::readRandomInteger(0, 99999999); $value = sprintf('%08d', $value); return $value; } private function isSMSMailerConfigured() { $mailers = PhabricatorMetaMTAMail::newMailers( array( 'outbound' => true, 'media' => array( PhabricatorMailSMSMessage::MESSAGETYPE, ), )); return (bool)$mailers; } private function loadUserContactNumber(PhabricatorUser $user) { $contact_numbers = id(new PhabricatorAuthContactNumberQuery()) ->setViewer($user) ->withObjectPHIDs(array($user->getPHID())) ->withStatuses( array( PhabricatorAuthContactNumber::STATUS_ACTIVE, )) ->withIsPrimary(true) ->execute(); if (count($contact_numbers) !== 1) { return null; } return head($contact_numbers); } protected function newMFASyncTokenProperties( PhabricatorAuthFactorProvider $providerr, PhabricatorUser $user) { $sms_code = $this->newSMSChallengeCode(); $envelope = new PhutilOpaqueEnvelope($sms_code); $this->sendSMSCodeToUser($envelope, $user); return array( 'code' => $sms_code, ); } private function sendSMSCodeToUser( PhutilOpaqueEnvelope $envelope, PhabricatorUser $user) { return id(new PhabricatorMetaMTAMail()) ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE) ->addTos(array($user->getPHID())) ->setForceDelivery(true) ->setSensitiveContent(true) ->setBody( pht( - 'Phabricator (%s) MFA Code: %s', + '%s (%s) MFA Code: %s', + PlatformSymbols::getPlatformServerName(), $this->getInstallDisplayName(), $envelope->openEnvelope())) ->save(); } private function normalizeSMSCode($code) { return trim($code); } } diff --git a/src/applications/auth/management/PhabricatorAuthManagementTrustOAuthClientWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementTrustOAuthClientWorkflow.php index ee5e50b38e..8455b5697f 100644 --- a/src/applications/auth/management/PhabricatorAuthManagementTrustOAuthClientWorkflow.php +++ b/src/applications/auth/management/PhabricatorAuthManagementTrustOAuthClientWorkflow.php @@ -1,64 +1,63 @@ setName('trust-oauth-client') ->setExamples('**trust-oauth-client** [--id client_id]') ->setSynopsis( pht( - 'Set Phabricator to trust an OAuth client. Phabricator '. - 'redirects to trusted OAuth clients that users have authorized '. - 'without user intervention.')) + 'Mark an OAuth client as trusted. Trusted OAuth clients may be '. + 'reauthorized without requiring users to manually confirm the '. + 'action.')) ->setArguments( array( array( 'name' => 'id', 'param' => 'id', 'help' => pht('The id of the OAuth client.'), ), )); } public function execute(PhutilArgumentParser $args) { $id = $args->getArg('id'); if (!$id) { throw new PhutilArgumentUsageException( pht( - 'Specify an OAuth client id with %s.', - '--id')); + 'Specify an OAuth client id with "--id".')); } $client = id(new PhabricatorOAuthServerClientQuery()) ->setViewer($this->getViewer()) ->withIDs(array($id)) ->executeOne(); if (!$client) { throw new PhutilArgumentUsageException( pht( 'Failed to find an OAuth client with id %s.', $id)); } if ($client->getIsTrusted()) { throw new PhutilArgumentUsageException( pht( - 'Phabricator already trusts OAuth client "%s".', + 'OAuth client "%s" is already trusted.', $client->getName())); } $client->setIsTrusted(1); $client->save(); $console = PhutilConsole::getConsole(); $console->writeOut( "%s\n", pht( - 'Updated; Phabricator trusts OAuth client %s.', + 'OAuth client "%s" is now trusted.', $client->getName())); } } diff --git a/src/applications/auth/management/PhabricatorAuthManagementUntrustOAuthClientWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementUntrustOAuthClientWorkflow.php index a0b3e02fa4..d3d7ff3aea 100644 --- a/src/applications/auth/management/PhabricatorAuthManagementUntrustOAuthClientWorkflow.php +++ b/src/applications/auth/management/PhabricatorAuthManagementUntrustOAuthClientWorkflow.php @@ -1,64 +1,63 @@ setName('untrust-oauth-client') ->setExamples('**untrust-oauth-client** [--id client_id]') ->setSynopsis( pht( - 'Set Phabricator to not trust an OAuth client. Phabricator '. - 'redirects to trusted OAuth clients that users have authorized '. - 'without user intervention.')) + 'Remove trust from an OAuth client. Users must manually confirm '. + 'reauthorization of untrusted OAuth clients.')) ->setArguments( array( array( 'name' => 'id', 'param' => 'id', 'help' => pht('The id of the OAuth client.'), ), )); } public function execute(PhutilArgumentParser $args) { $id = $args->getArg('id'); if (!$id) { throw new PhutilArgumentUsageException( pht( 'Specify an OAuth client ID with %s.', '--id')); } $client = id(new PhabricatorOAuthServerClientQuery()) ->setViewer($this->getViewer()) ->withIDs(array($id)) ->executeOne(); if (!$client) { throw new PhutilArgumentUsageException( pht( 'Failed to find an OAuth client with ID %s.', $id)); } if (!$client->getIsTrusted()) { throw new PhutilArgumentUsageException( pht( - 'Phabricator already does not trust OAuth client "%s".', + 'OAuth client "%s" is already untrusted.', $client->getName())); } $client->setIsTrusted(0); $client->save(); $console = PhutilConsole::getConsole(); $console->writeOut( "%s\n", pht( - 'Updated; Phabricator does not trust OAuth client %s.', + 'OAuth client "%s" is now trusted.', $client->getName())); } } diff --git a/src/applications/auth/provider/PhabricatorPhabricatorAuthProvider.php b/src/applications/auth/provider/PhabricatorPhabricatorAuthProvider.php index 8799c43c6f..4c1c31f146 100644 --- a/src/applications/auth/provider/PhabricatorPhabricatorAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorPhabricatorAuthProvider.php @@ -1,209 +1,207 @@ isCreate()) { return pht( - "**Step 1 of 2 - Name Phabricator OAuth Instance**\n\n". - 'Choose a permanent name for the OAuth server instance of '. - 'Phabricator. //This// instance of Phabricator uses this name '. - 'internally to keep track of the OAuth server instance of '. - 'Phabricator, in case the URL changes later.'); + "**Step 1 of 2 - Name Remote Server**\n\n". + 'Choose a permanent name for the remote server you want to connect '. + 'to. This name is used internally to keep track of the remote '. + 'server, in case the URL changes later.'); } return parent::getConfigurationHelp(); } protected function getProviderConfigurationHelp() { $config = $this->getProviderConfig(); $base_uri = rtrim( $config->getProperty(self::PROPERTY_PHABRICATOR_URI), '/'); $login_uri = PhabricatorEnv::getURI($this->getLoginURI()); return pht( - "**Step 2 of 2 - Configure Phabricator OAuth Instance**\n\n". - "To configure Phabricator OAuth, create a new application here:". + "**Step 2 of 2 - Configure OAuth Server**\n\n". + "To configure OAuth, create a new application here:". "\n\n". "%s/oauthserver/client/create/". "\n\n". "When creating your application, use these settings:". "\n\n". " - **Redirect URI:** Set this to: `%s`". "\n\n". "After completing configuration, copy the **Client ID** and ". "**Client Secret** to the fields above. (You may need to generate the ". "client secret by clicking 'New Secret' first.)", $base_uri, $login_uri); } protected function newOAuthAdapter() { $config = $this->getProviderConfig(); return id(new PhutilPhabricatorAuthAdapter()) ->setAdapterDomain($config->getProviderDomain()) ->setPhabricatorBaseURI( $config->getProperty(self::PROPERTY_PHABRICATOR_URI)); } protected function getLoginIcon() { - return 'Phabricator'; + return PlatformSymbols::getPlatformServerName(); } private function isCreate() { return !$this->getProviderConfig()->getID(); } public function readFormValuesFromProvider() { $config = $this->getProviderConfig(); $uri = $config->getProperty(self::PROPERTY_PHABRICATOR_URI); return parent::readFormValuesFromProvider() + array( self::PROPERTY_PHABRICATOR_NAME => $this->getProviderDomain(), self::PROPERTY_PHABRICATOR_URI => $uri, ); } public function readFormValuesFromRequest(AphrontRequest $request) { $is_setup = $this->isCreate(); if ($is_setup) { $parent_values = array(); $name = $request->getStr(self::PROPERTY_PHABRICATOR_NAME); } else { $parent_values = parent::readFormValuesFromRequest($request); $name = $this->getProviderDomain(); } return $parent_values + array( self::PROPERTY_PHABRICATOR_NAME => $name, self::PROPERTY_PHABRICATOR_URI => $request->getStr(self::PROPERTY_PHABRICATOR_URI), ); } public function processEditForm( AphrontRequest $request, array $values) { $is_setup = $this->isCreate(); if (!$is_setup) { list($errors, $issues, $values) = parent::processEditForm($request, $values); } else { $errors = array(); $issues = array(); } $key_name = self::PROPERTY_PHABRICATOR_NAME; $key_uri = self::PROPERTY_PHABRICATOR_URI; if (!strlen($values[$key_name])) { - $errors[] = pht('Phabricator instance name is required.'); + $errors[] = pht('Server name is required.'); $issues[$key_name] = pht('Required'); } else if (!preg_match('/^[a-z0-9.]+\z/', $values[$key_name])) { $errors[] = pht( - 'Phabricator instance name must contain only lowercase letters, '. + 'Server name must contain only lowercase letters, '. 'digits, and periods.'); $issues[$key_name] = pht('Invalid'); } if (!strlen($values[$key_uri])) { - $errors[] = pht('Phabricator base URI is required.'); + $errors[] = pht('Base URI is required.'); $issues[$key_uri] = pht('Required'); } else { $uri = new PhutilURI($values[$key_uri]); if (!$uri->getProtocol()) { $errors[] = pht( - 'Phabricator base URI should include protocol (like "%s").', + 'Base URI should include protocol (like "%s").', 'https://'); $issues[$key_uri] = pht('Invalid'); } } if (!$errors && $is_setup) { $config = $this->getProviderConfig(); $config->setProviderDomain($values[$key_name]); } return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $is_setup = $this->isCreate(); $e_required = $request->isFormPost() ? null : true; $v_name = $values[self::PROPERTY_PHABRICATOR_NAME]; if ($is_setup) { $e_name = idx($issues, self::PROPERTY_PHABRICATOR_NAME, $e_required); } else { $e_name = null; } $v_uri = $values[self::PROPERTY_PHABRICATOR_URI]; $e_uri = idx($issues, self::PROPERTY_PHABRICATOR_URI, $e_required); if ($is_setup) { $form ->appendChild( id(new AphrontFormTextControl()) - ->setLabel(pht('Phabricator Instance Name')) + ->setLabel(pht('Server Name')) ->setValue($v_name) ->setName(self::PROPERTY_PHABRICATOR_NAME) ->setError($e_name) ->setCaption(pht( 'Use lowercase letters, digits, and periods. For example: %s', phutil_tag( 'tt', array(), - '`phabricator.oauthserver`')))); + '`example.oauthserver`')))); } else { $form ->appendChild( id(new AphrontFormStaticControl()) - ->setLabel(pht('Phabricator Instance Name')) + ->setLabel(pht('Server Name')) ->setValue($v_name)); } $form ->appendChild( id(new AphrontFormTextControl()) - ->setLabel(pht('Phabricator Base URI')) + ->setLabel(pht('Base URI')) ->setValue($v_uri) ->setName(self::PROPERTY_PHABRICATOR_URI) ->setCaption( pht( - 'The URI where the OAuth server instance of Phabricator is '. - 'installed. For example: %s', - phutil_tag('tt', array(), 'https://phabricator.mycompany.com/'))) + 'The URI where the OAuth server is installed. For example: %s', + phutil_tag('tt', array(), 'https://devtools.example.com/'))) ->setError($e_uri)); if (!$is_setup) { parent::extendEditForm($request, $form, $values, $issues); } } public function hasSetupStep() { return true; } public function getPhabricatorURI() { $config = $this->getProviderConfig(); return $config->getProperty(self::PROPERTY_PHABRICATOR_URI); } } diff --git a/src/applications/auth/worker/PhabricatorAuthInviteWorker.php b/src/applications/auth/worker/PhabricatorAuthInviteWorker.php index 7e9e29be50..25ad6961f2 100644 --- a/src/applications/auth/worker/PhabricatorAuthInviteWorker.php +++ b/src/applications/auth/worker/PhabricatorAuthInviteWorker.php @@ -1,60 +1,62 @@ getTaskData(); $viewer = PhabricatorUser::getOmnipotentUser(); $address = idx($data, 'address'); $author_phid = idx($data, 'authorPHID'); $author = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($author_phid)) ->executeOne(); if (!$author) { throw new PhabricatorWorkerPermanentFailureException( pht('Invite has invalid author PHID ("%s").', $author_phid)); } $invite = id(new PhabricatorAuthInviteQuery()) ->setViewer($viewer) ->withEmailAddresses(array($address)) ->executeOne(); if ($invite) { // If we're inviting a user who has already been invited, we just // regenerate their invite code. $invite->regenerateVerificationCode(); } else { // Otherwise, we're creating a new invite. $invite = id(new PhabricatorAuthInvite()) ->setEmailAddress($address); } // Whether this is a new invite or not, tag this most recent author as // the invite author. $invite->setAuthorPHID($author_phid); $code = $invite->getVerificationCode(); $invite_uri = '/auth/invite/'.$code.'/'; $invite_uri = PhabricatorEnv::getProductionURI($invite_uri); $template = idx($data, 'template'); $template = str_replace('{$INVITE_URI}', $invite_uri, $template); $invite->save(); $mail = id(new PhabricatorMetaMTAMail()) ->addRawTos(array($invite->getEmailAddress())) ->setForceDelivery(true) ->setSubject( pht( - '[Phabricator] %s has invited you to join Phabricator', - $author->getFullName())) + '[%s] %s has invited you to join %s', + PlatformSymbols::getPlatformServerName(), + $author->getFullName(), + PlatformSymbols::getPlatformServerName())) ->setBody($template) ->saveAndSend(); } } diff --git a/src/applications/calendar/codex/PhabricatorCalendarEventPolicyCodex.php b/src/applications/calendar/codex/PhabricatorCalendarEventPolicyCodex.php index 97e21d901e..01f6335139 100644 --- a/src/applications/calendar/codex/PhabricatorCalendarEventPolicyCodex.php +++ b/src/applications/calendar/codex/PhabricatorCalendarEventPolicyCodex.php @@ -1,80 +1,80 @@ getObject(); if (!$object->isImportedEvent()) { return null; } return pht('Uses Import Policy'); } public function getPolicyIcon() { $object = $this->getObject(); if (!$object->isImportedEvent()) { return null; } return 'fa-download'; } public function getPolicyTagClasses() { $object = $this->getObject(); if (!$object->isImportedEvent()) { return array(); } return array( 'policy-adjusted-special', ); } public function getPolicySpecialRuleDescriptions() { $object = $this->getObject(); $rules = array(); $rules[] = $this->newRule() ->setDescription( pht('The host of an event can always view and edit it.')); $rules[] = $this->newRule() ->setCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, )) ->setDescription( pht('Users who are invited to an event can always view it.')); $rules[] = $this->newRule() ->setCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, )) ->setIsActive($object->isImportedEvent()) ->setDescription( pht( 'Imported events can only be viewed by users who can view '. 'the import source.')); $rules[] = $this->newRule() ->setCapabilities( array( PhabricatorPolicyCapability::CAN_EDIT, )) ->setIsActive($object->isImportedEvent()) ->setDescription( pht( - 'Imported events can not be edited in Phabricator.')); + 'Imported events can not be edited.')); return $rules; } } diff --git a/src/applications/config/application/PhabricatorConfigApplication.php b/src/applications/config/application/PhabricatorConfigApplication.php index e7a1b7eb1b..22731ac822 100644 --- a/src/applications/config/application/PhabricatorConfigApplication.php +++ b/src/applications/config/application/PhabricatorConfigApplication.php @@ -1,79 +1,79 @@ getIsAdmin(); } public function getTitleGlyph() { return "\xE2\x9C\xA8"; } public function getApplicationGroup() { return self::GROUP_ADMIN; } public function canUninstall() { return false; } public function getName() { return pht('Config'); } public function getShortDescription() { - return pht('Configure Phabricator'); + return pht('Configure %s', PlatformSymbols::getPlatformServerName()); } public function getRoutes() { return array( '/config/' => array( '' => 'PhabricatorConfigConsoleController', 'edit/(?P[\w\.\-]+)/' => 'PhabricatorConfigEditController', 'database/'. '(?:(?P[^/]+)/'. '(?:(?P[^/]+)/'. '(?:(?P[^/]+)/'. '(?:(?:col/(?P[^/]+)|key/(?P[^/]+))/)?)?)?)?' => 'PhabricatorConfigDatabaseStatusController', 'dbissue/' => 'PhabricatorConfigDatabaseIssueController', '(?Pignore|unignore)/(?P[^/]+)/' => 'PhabricatorConfigIgnoreController', 'issue/' => array( '' => 'PhabricatorConfigIssueListController', 'panel/' => 'PhabricatorConfigIssuePanelController', '(?P[^/]+)/' => 'PhabricatorConfigIssueViewController', ), 'cache/' => array( '' => 'PhabricatorConfigCacheController', 'purge/' => 'PhabricatorConfigPurgeCacheController', ), 'module/' => array( '(?:(?P[^/]+)/)?' => 'PhabricatorConfigModuleController', ), 'cluster/' => array( 'databases/' => 'PhabricatorConfigClusterDatabasesController', 'notifications/' => 'PhabricatorConfigClusterNotificationsController', 'repositories/' => 'PhabricatorConfigClusterRepositoriesController', 'search/' => 'PhabricatorConfigClusterSearchController', ), 'settings/' => array( '' => 'PhabricatorConfigSettingsListController', '(?Padvanced|all)/' => 'PhabricatorConfigSettingsListController', 'history/' => 'PhabricatorConfigSettingsHistoryController', ), ), ); } }