Page MenuHomestyx hydra

No OneTemporary

diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
index a3ee910629..06cb63c23a 100644
--- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
+++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
@@ -1,814 +1,815 @@
<?php
/**
*
* @task use Using Sessions
* @task new Creating Sessions
* @task hisec High Security
* @task partial Partial Sessions
* @task onetime One Time Login URIs
* @task cache User Cache
*/
final class PhabricatorAuthSessionEngine extends Phobject {
/**
* Session issued to normal users after they login through a standard channel.
* Associates the client with a standard user identity.
*/
const KIND_USER = 'U';
/**
* Session issued to users who login with some sort of credentials but do not
* have full accounts. These are sometimes called "grey users".
*
* TODO: We do not currently issue these sessions, see T4310.
*/
const KIND_EXTERNAL = 'X';
/**
* Session issued to logged-out users which has no real identity information.
* Its purpose is to protect logged-out users from CSRF.
*/
const KIND_ANONYMOUS = 'A';
/**
* Session kind isn't known.
*/
const KIND_UNKNOWN = '?';
const ONETIME_RECOVER = 'recover';
const ONETIME_RESET = 'reset';
const ONETIME_WELCOME = 'welcome';
const ONETIME_USERNAME = 'rename';
/**
* Get the session kind (e.g., anonymous, user, external account) from a
* session token. Returns a `KIND_` constant.
*
* @param string Session token.
* @return const Session kind constant.
*/
public static function getSessionKindFromToken($session_token) {
if (strpos($session_token, '/') === false) {
// Old-style session, these are all user sessions.
return self::KIND_USER;
}
list($kind, $key) = explode('/', $session_token, 2);
switch ($kind) {
case self::KIND_ANONYMOUS:
case self::KIND_USER:
case self::KIND_EXTERNAL:
return $kind;
default:
return self::KIND_UNKNOWN;
}
}
/**
* Load the user identity associated with a session of a given type,
* identified by token.
*
* When the user presents a session token to an API, this method verifies
* it is of the correct type and loads the corresponding identity if the
* session exists and is valid.
*
* NOTE: `$session_type` is the type of session that is required by the
* loading context. This prevents use of a Conduit sesssion as a Web
* session, for example.
*
* @param const The type of session to load.
* @param string The session token.
* @return PhabricatorUser|null
* @task use
*/
public function loadUserForSession($session_type, $session_token) {
$session_kind = self::getSessionKindFromToken($session_token);
switch ($session_kind) {
case self::KIND_ANONYMOUS:
// Don't bother trying to load a user for an anonymous session, since
// neither the session nor the user exist.
return null;
case self::KIND_UNKNOWN:
// If we don't know what kind of session this is, don't go looking for
// it.
return null;
case self::KIND_USER:
break;
case self::KIND_EXTERNAL:
// TODO: Implement these (T4310).
return null;
}
$session_table = new PhabricatorAuthSession();
$user_table = new PhabricatorUser();
$conn_r = $session_table->establishConnection('r');
$session_key = PhabricatorHash::digest($session_token);
$cache_parts = $this->getUserCacheQueryParts($conn_r);
list($cache_selects, $cache_joins, $cache_map) = $cache_parts;
$info = queryfx_one(
$conn_r,
'SELECT
s.id AS s_id,
s.sessionExpires AS s_sessionExpires,
s.sessionStart AS s_sessionStart,
s.highSecurityUntil AS s_highSecurityUntil,
s.isPartial AS s_isPartial,
s.signedLegalpadDocuments as s_signedLegalpadDocuments,
u.*
%Q
FROM %T u JOIN %T s ON u.phid = s.userPHID
AND s.type = %s AND s.sessionKey = %s %Q',
$cache_selects,
$user_table->getTableName(),
$session_table->getTableName(),
$session_type,
$session_key,
$cache_joins);
if (!$info) {
return null;
}
$session_dict = array(
'userPHID' => $info['phid'],
'sessionKey' => $session_key,
'type' => $session_type,
);
$cache_raw = array_fill_keys($cache_map, null);
foreach ($info as $key => $value) {
if (strncmp($key, 's_', 2) === 0) {
unset($info[$key]);
$session_dict[substr($key, 2)] = $value;
continue;
}
if (isset($cache_map[$key])) {
unset($info[$key]);
$cache_raw[$cache_map[$key]] = $value;
continue;
}
}
$user = $user_table->loadFromArray($info);
$user->attachRawCacheData($cache_raw);
+ $user->setAllowInlineCacheGeneration(true);
switch ($session_type) {
case PhabricatorAuthSession::TYPE_WEB:
// Explicitly prevent bots and mailing lists from establishing web
// sessions. It's normally impossible to attach authentication to these
// accounts, and likewise impossible to generate sessions, but it's
// technically possible that a session could exist in the database. If
// one does somehow, refuse to load it.
if (!$user->canEstablishWebSessions()) {
return null;
}
break;
}
$session = id(new PhabricatorAuthSession())->loadFromArray($session_dict);
$ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type);
// If more than 20% of the time on this session has been used, refresh the
// TTL back up to the full duration. The idea here is that sessions are
// good forever if used regularly, but get GC'd when they fall out of use.
// NOTE: If we begin rotating session keys when extending sessions, the
// CSRF code needs to be updated so CSRF tokens survive session rotation.
if (time() + (0.80 * $ttl) > $session->getSessionExpires()) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$conn_w = $session_table->establishConnection('w');
queryfx(
$conn_w,
'UPDATE %T SET sessionExpires = UNIX_TIMESTAMP() + %d WHERE id = %d',
$session->getTableName(),
$ttl,
$session->getID());
unset($unguarded);
}
$user->attachSession($session);
return $user;
}
/**
* Issue a new session key for a given identity. Phabricator supports
* different types of sessions (like "web" and "conduit") and each session
* type may have multiple concurrent sessions (this allows a user to be
* logged in on multiple browsers at the same time, for instance).
*
* Note that this method is transport-agnostic and does not set cookies or
* issue other types of tokens, it ONLY generates a new session key.
*
* You can configure the maximum number of concurrent sessions for various
* session types in the Phabricator configuration.
*
* @param const Session type constant (see
* @{class:PhabricatorAuthSession}).
* @param phid|null Identity to establish a session for, usually a user
* PHID. With `null`, generates an anonymous session.
* @param bool True to issue a partial session.
* @return string Newly generated session key.
*/
public function establishSession($session_type, $identity_phid, $partial) {
// Consume entropy to generate a new session key, forestalling the eventual
// heat death of the universe.
$session_key = Filesystem::readRandomCharacters(40);
if ($identity_phid === null) {
return self::KIND_ANONYMOUS.'/'.$session_key;
}
$session_table = new PhabricatorAuthSession();
$conn_w = $session_table->establishConnection('w');
// This has a side effect of validating the session type.
$session_ttl = PhabricatorAuthSession::getSessionTypeTTL($session_type);
$digest_key = PhabricatorHash::digest($session_key);
// Logging-in users don't have CSRF stuff yet, so we have to unguard this
// write.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
id(new PhabricatorAuthSession())
->setUserPHID($identity_phid)
->setType($session_type)
->setSessionKey($digest_key)
->setSessionStart(time())
->setSessionExpires(time() + $session_ttl)
->setIsPartial($partial ? 1 : 0)
->setSignedLegalpadDocuments(0)
->save();
$log = PhabricatorUserLog::initializeNewLog(
null,
$identity_phid,
($partial
? PhabricatorUserLog::ACTION_LOGIN_PARTIAL
: PhabricatorUserLog::ACTION_LOGIN));
$log->setDetails(
array(
'session_type' => $session_type,
));
$log->setSession($digest_key);
$log->save();
unset($unguarded);
return $session_key;
}
/**
* Terminate all of a user's login sessions.
*
* This is used when users change passwords, linked accounts, or add
* multifactor authentication.
*
* @param PhabricatorUser User whose sessions should be terminated.
* @param string|null Optionally, one session to keep. Normally, the current
* login session.
*
* @return void
*/
public function terminateLoginSessions(
PhabricatorUser $user,
$except_session = null) {
$sessions = id(new PhabricatorAuthSessionQuery())
->setViewer($user)
->withIdentityPHIDs(array($user->getPHID()))
->execute();
if ($except_session !== null) {
$except_session = PhabricatorHash::digest($except_session);
}
foreach ($sessions as $key => $session) {
if ($except_session !== null) {
$is_except = phutil_hashes_are_identical(
$session->getSessionKey(),
$except_session);
if ($is_except) {
continue;
}
}
$session->delete();
}
}
public function logoutSession(
PhabricatorUser $user,
PhabricatorAuthSession $session) {
$log = PhabricatorUserLog::initializeNewLog(
$user,
$user->getPHID(),
PhabricatorUserLog::ACTION_LOGOUT);
$log->save();
$extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
foreach ($extensions as $extension) {
$extension->didLogout($user, array($session));
}
$session->delete();
}
/* -( High Security )------------------------------------------------------ */
/**
* Require high security, or prompt the user to enter high security.
*
* If the user's session is in high security, this method will return a
* token. Otherwise, it will throw an exception which will eventually
* be converted into a multi-factor authentication workflow.
*
* @param PhabricatorUser User whose session needs to be in high security.
* @param AphrontReqeust Current request.
* @param string URI to return the user to if they cancel.
* @param bool True to jump partial sessions directly into high
* security instead of just upgrading them to full
* sessions.
* @return PhabricatorAuthHighSecurityToken Security token.
* @task hisec
*/
public function requireHighSecuritySession(
PhabricatorUser $viewer,
AphrontRequest $request,
$cancel_uri,
$jump_into_hisec = false) {
if (!$viewer->hasSession()) {
throw new Exception(
pht('Requiring a high-security session from a user with no session!'));
}
$session = $viewer->getSession();
// Check if the session is already in high security mode.
$token = $this->issueHighSecurityToken($session);
if ($token) {
return $token;
}
// Load the multi-factor auth sources attached to this account.
$factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
'userPHID = %s',
$viewer->getPHID());
// If the account has no associated multi-factor auth, just issue a token
// without putting the session into high security mode. This is generally
// easier for users. A minor but desirable side effect is that when a user
// adds an auth factor, existing sessions won't get a free pass into hisec,
// since they never actually got marked as hisec.
if (!$factors) {
return $this->issueHighSecurityToken($session, true);
}
// Check for a rate limit without awarding points, so the user doesn't
// get partway through the workflow only to get blocked.
PhabricatorSystemActionEngine::willTakeAction(
array($viewer->getPHID()),
new PhabricatorAuthTryFactorAction(),
0);
$validation_results = array();
if ($request->isHTTPPost()) {
$request->validateCSRF();
if ($request->getExists(AphrontRequest::TYPE_HISEC)) {
// Limit factor verification rates to prevent brute force attacks.
PhabricatorSystemActionEngine::willTakeAction(
array($viewer->getPHID()),
new PhabricatorAuthTryFactorAction(),
1);
$ok = true;
foreach ($factors as $factor) {
$id = $factor->getID();
$impl = $factor->requireImplementation();
$validation_results[$id] = $impl->processValidateFactorForm(
$factor,
$viewer,
$request);
if (!$impl->isFactorValid($factor, $validation_results[$id])) {
$ok = false;
}
}
if ($ok) {
// Give the user a credit back for a successful factor verification.
PhabricatorSystemActionEngine::willTakeAction(
array($viewer->getPHID()),
new PhabricatorAuthTryFactorAction(),
-1);
if ($session->getIsPartial() && !$jump_into_hisec) {
// If we have a partial session and are not jumping directly into
// hisec, just issue a token without putting it in high security
// mode.
return $this->issueHighSecurityToken($session, true);
}
$until = time() + phutil_units('15 minutes in seconds');
$session->setHighSecurityUntil($until);
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET highSecurityUntil = %d WHERE id = %d',
$session->getTableName(),
$until,
$session->getID());
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorUserLog::ACTION_ENTER_HISEC);
$log->save();
} else {
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorUserLog::ACTION_FAIL_HISEC);
$log->save();
}
}
}
$token = $this->issueHighSecurityToken($session);
if ($token) {
return $token;
}
throw id(new PhabricatorAuthHighSecurityRequiredException())
->setCancelURI($cancel_uri)
->setFactors($factors)
->setFactorValidationResults($validation_results);
}
/**
* Issue a high security token for a session, if authorized.
*
* @param PhabricatorAuthSession Session to issue a token for.
* @param bool Force token issue.
* @return PhabricatorAuthHighSecurityToken|null Token, if authorized.
* @task hisec
*/
private function issueHighSecurityToken(
PhabricatorAuthSession $session,
$force = false) {
$until = $session->getHighSecurityUntil();
if ($until > time() || $force) {
return new PhabricatorAuthHighSecurityToken();
}
return null;
}
/**
* Render a form for providing relevant multi-factor credentials.
*
* @param PhabricatorUser Viewing user.
* @param AphrontRequest Current request.
* @return AphrontFormView Renderable form.
* @task hisec
*/
public function renderHighSecurityForm(
array $factors,
array $validation_results,
PhabricatorUser $viewer,
AphrontRequest $request) {
$form = id(new AphrontFormView())
->setUser($viewer)
->appendRemarkupInstructions('');
foreach ($factors as $factor) {
$factor->requireImplementation()->renderValidateFactorForm(
$factor,
$form,
$viewer,
idx($validation_results, $factor->getID()));
}
$form->appendRemarkupInstructions('');
return $form;
}
/**
* Strip the high security flag from a session.
*
* Kicks a session out of high security and logs the exit.
*
* @param PhabricatorUser Acting user.
* @param PhabricatorAuthSession Session to return to normal security.
* @return void
* @task hisec
*/
public function exitHighSecurity(
PhabricatorUser $viewer,
PhabricatorAuthSession $session) {
if (!$session->getHighSecurityUntil()) {
return;
}
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d',
$session->getTableName(),
$session->getID());
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorUserLog::ACTION_EXIT_HISEC);
$log->save();
}
/* -( Partial Sessions )--------------------------------------------------- */
/**
* Upgrade a partial session to a full session.
*
* @param PhabricatorAuthSession Session to upgrade.
* @return void
* @task partial
*/
public function upgradePartialSession(PhabricatorUser $viewer) {
if (!$viewer->hasSession()) {
throw new Exception(
pht('Upgrading partial session of user with no session!'));
}
$session = $viewer->getSession();
if (!$session->getIsPartial()) {
throw new Exception(pht('Session is not partial!'));
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$session->setIsPartial(0);
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET isPartial = %d WHERE id = %d',
$session->getTableName(),
0,
$session->getID());
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorUserLog::ACTION_LOGIN_FULL);
$log->save();
unset($unguarded);
}
/* -( Legalpad Documents )-------------------------------------------------- */
/**
* Upgrade a session to have all legalpad documents signed.
*
* @param PhabricatorUser User whose session should upgrade.
* @param array LegalpadDocument objects
* @return void
* @task partial
*/
public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) {
if (!$viewer->hasSession()) {
throw new Exception(
pht('Signing session legalpad documents of user with no session!'));
}
$session = $viewer->getSession();
if ($session->getSignedLegalpadDocuments()) {
throw new Exception(pht(
'Session has already signed required legalpad documents!'));
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$session->setSignedLegalpadDocuments(1);
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d',
$session->getTableName(),
1,
$session->getID());
if (!empty($docs)) {
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorUserLog::ACTION_LOGIN_LEGALPAD);
$log->save();
}
unset($unguarded);
}
/* -( One Time Login URIs )------------------------------------------------ */
/**
* Retrieve a temporary, one-time URI which can log in to an account.
*
* These URIs are used for password recovery and to regain access to accounts
* which users have been locked out of.
*
* @param PhabricatorUser User to generate a URI for.
* @param PhabricatorUserEmail Optionally, email to verify when
* link is used.
* @param string Optional context string for the URI. This is purely cosmetic
* and used only to customize workflow and error messages.
* @return string Login URI.
* @task onetime
*/
public function getOneTimeLoginURI(
PhabricatorUser $user,
PhabricatorUserEmail $email = null,
$type = self::ONETIME_RESET) {
$key = Filesystem::readRandomCharacters(32);
$key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
$onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
id(new PhabricatorAuthTemporaryToken())
->setTokenResource($user->getPHID())
->setTokenType($onetime_type)
->setTokenExpires(time() + phutil_units('1 day in seconds'))
->setTokenCode($key_hash)
->save();
unset($unguarded);
$uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/';
if ($email) {
$uri = $uri.$email->getID().'/';
}
try {
$uri = PhabricatorEnv::getProductionURI($uri);
} catch (Exception $ex) {
// If a user runs `bin/auth recover` before configuring the base URI,
// just show the path. We don't have any way to figure out the domain.
// See T4132.
}
return $uri;
}
/**
* Load the temporary token associated with a given one-time login key.
*
* @param PhabricatorUser User to load the token for.
* @param PhabricatorUserEmail Optionally, email to verify when
* link is used.
* @param string Key user is presenting as a valid one-time login key.
* @return PhabricatorAuthTemporaryToken|null Token, if one exists.
* @task onetime
*/
public function loadOneTimeLoginKey(
PhabricatorUser $user,
PhabricatorUserEmail $email = null,
$key = null) {
$key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
$onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
return id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer($user)
->withTokenResources(array($user->getPHID()))
->withTokenTypes(array($onetime_type))
->withTokenCodes(array($key_hash))
->withExpired(false)
->executeOne();
}
/**
* Hash a one-time login key for storage as a temporary token.
*
* @param PhabricatorUser User this key is for.
* @param PhabricatorUserEmail Optionally, email to verify when
* link is used.
* @param string The one time login key.
* @return string Hash of the key.
* task onetime
*/
private function getOneTimeLoginKeyHash(
PhabricatorUser $user,
PhabricatorUserEmail $email = null,
$key = null) {
$parts = array(
$key,
$user->getAccountSecret(),
);
if ($email) {
$parts[] = $email->getVerificationCode();
}
return PhabricatorHash::digest(implode(':', $parts));
}
/* -( User Cache )--------------------------------------------------------- */
/**
* @task cache
*/
private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) {
$cache_selects = array();
$cache_joins = array();
$cache_map = array();
$keys = array();
$cache_types = PhabricatorUserCacheType::getAllCacheTypes();
foreach ($cache_types as $cache_type) {
foreach ($cache_type->getAutoloadKeys() as $autoload_key) {
$keys[] = $autoload_key;
}
}
$cache_table = id(new PhabricatorUserCache())->getTableName();
$cache_idx = 1;
foreach ($keys as $key) {
$join_as = 'ucache_'.$cache_idx;
$select_as = 'ucache_'.$cache_idx.'_v';
$cache_selects[] = qsprintf(
$conn,
'%T.cacheData %T',
$join_as,
$select_as);
$cache_joins[] = qsprintf(
$conn,
'LEFT JOIN %T AS %T ON u.phid = %T.userPHID
AND %T.cacheIndex = %s',
$cache_table,
$join_as,
$join_as,
$join_as,
PhabricatorHash::digestForIndex($key));
$cache_map[$select_as] = $key;
$cache_idx++;
}
if ($cache_selects) {
$cache_selects = ', '.implode(', ', $cache_selects);
} else {
$cache_selects = '';
}
if ($cache_joins) {
$cache_joins = implode(' ', $cache_joins);
} else {
$cache_joins = '';
}
return array($cache_selects, $cache_joins, $cache_map);
}
}
diff --git a/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php b/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php
index a1f0aec057..38b04d6883 100644
--- a/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php
+++ b/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php
@@ -1,157 +1,158 @@
<?php
final class PhabricatorMetaMTAActorQuery extends PhabricatorQuery {
private $phids = array();
private $viewer;
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function execute() {
$phids = array_fuse($this->phids);
$actors = array();
$type_map = array();
foreach ($phids as $phid) {
$type_map[phid_get_type($phid)][] = $phid;
$actors[$phid] = id(new PhabricatorMetaMTAActor())->setPHID($phid);
}
// TODO: Move this to PhabricatorPHIDType, or the objects, or some
// interface.
foreach ($type_map as $type => $phids) {
switch ($type) {
case PhabricatorPeopleUserPHIDType::TYPECONST:
$this->loadUserActors($actors, $phids);
break;
case PhabricatorPeopleExternalPHIDType::TYPECONST:
$this->loadExternalUserActors($actors, $phids);
break;
default:
$this->loadUnknownActors($actors, $phids);
break;
}
}
return $actors;
}
private function loadUserActors(array $actors, array $phids) {
assert_instances_of($actors, 'PhabricatorMetaMTAActor');
$emails = id(new PhabricatorUserEmail())->loadAllWhere(
'userPHID IN (%Ls) AND isPrimary = 1',
$phids);
$emails = mpull($emails, null, 'getUserPHID');
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->getViewer())
->withPHIDs($phids)
+ ->needUserSettings(true)
->execute();
$users = mpull($users, null, 'getPHID');
foreach ($phids as $phid) {
$actor = $actors[$phid];
$user = idx($users, $phid);
if (!$user) {
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNLOADABLE);
} else {
$actor->setName($this->getUserName($user));
if ($user->getIsDisabled()) {
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_DISABLED);
}
if ($user->getIsSystemAgent()) {
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_BOT);
}
// NOTE: We do send email to unapproved users, and to unverified users,
// because it would otherwise be impossible to get them to verify their
// email addresses. Possibly we should white-list this kind of mail and
// deny all other types of mail.
}
$email = idx($emails, $phid);
if (!$email) {
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_NO_ADDRESS);
} else {
$actor->setEmailAddress($email->getAddress());
}
}
}
private function loadExternalUserActors(array $actors, array $phids) {
assert_instances_of($actors, 'PhabricatorMetaMTAActor');
$xusers = id(new PhabricatorExternalAccountQuery())
->setViewer($this->getViewer())
->withPHIDs($phids)
->execute();
$xusers = mpull($xusers, null, 'getPHID');
foreach ($phids as $phid) {
$actor = $actors[$phid];
$xuser = idx($xusers, $phid);
if (!$xuser) {
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNLOADABLE);
continue;
}
$actor->setName($xuser->getDisplayName());
if ($xuser->getAccountType() != 'email') {
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_EXTERNAL_TYPE);
continue;
}
$actor->setEmailAddress($xuser->getAccountID());
}
}
private function loadUnknownActors(array $actors, array $phids) {
foreach ($phids as $phid) {
$actor = $actors[$phid];
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNMAILABLE);
}
}
/**
* Small helper function to make sure we format the username properly as
* specified by the `metamta.user-address-format` configuration value.
*/
private function getUserName(PhabricatorUser $user) {
$format = PhabricatorEnv::getEnvConfig('metamta.user-address-format');
switch ($format) {
case 'short':
$name = $user->getUserName();
break;
case 'real':
$name = strlen($user->getRealName()) ?
$user->getRealName() : $user->getUserName();
break;
case 'full':
default:
$name = $user->getFullName();
break;
}
return $name;
}
}
diff --git a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php
index e4da2a04d6..b0ae2de494 100644
--- a/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php
+++ b/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php
@@ -1,403 +1,406 @@
<?php
abstract class PhabricatorMailReplyHandler extends Phobject {
private $mailReceiver;
private $applicationEmail;
private $actor;
private $excludePHIDs = array();
final public function setMailReceiver($mail_receiver) {
$this->validateMailReceiver($mail_receiver);
$this->mailReceiver = $mail_receiver;
return $this;
}
final public function getMailReceiver() {
return $this->mailReceiver;
}
public function setApplicationEmail(
PhabricatorMetaMTAApplicationEmail $email) {
$this->applicationEmail = $email;
return $this;
}
public function getApplicationEmail() {
return $this->applicationEmail;
}
final public function setActor(PhabricatorUser $actor) {
$this->actor = $actor;
return $this;
}
final public function getActor() {
return $this->actor;
}
final public function setExcludeMailRecipientPHIDs(array $exclude) {
$this->excludePHIDs = $exclude;
return $this;
}
final public function getExcludeMailRecipientPHIDs() {
return $this->excludePHIDs;
}
abstract public function validateMailReceiver($mail_receiver);
abstract public function getPrivateReplyHandlerEmailAddress(
PhabricatorUser $user);
public function getReplyHandlerDomain() {
return PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain');
}
abstract protected function receiveEmail(
PhabricatorMetaMTAReceivedMail $mail);
public function processEmail(PhabricatorMetaMTAReceivedMail $mail) {
$this->dropEmptyMail($mail);
return $this->receiveEmail($mail);
}
private function dropEmptyMail(PhabricatorMetaMTAReceivedMail $mail) {
$body = $mail->getCleanTextBody();
$attachments = $mail->getAttachments();
if (strlen($body) || $attachments) {
return;
}
// Only send an error email if the user is talking to just Phabricator.
// We can assume if there is only one "To" address it is a Phabricator
// address since this code is running and everything.
$is_direct_mail = (count($mail->getToAddresses()) == 1) &&
(count($mail->getCCAddresses()) == 0);
if ($is_direct_mail) {
$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY;
} else {
$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED;
}
throw new PhabricatorMetaMTAReceivedMailProcessingException(
$status_code,
pht(
'Your message does not contain any body text or attachments, so '.
'Phabricator can not do anything useful with it. Make sure comment '.
'text appears at the top of your message: quoted replies, inline '.
'text, and signatures are discarded and ignored.'));
}
public function supportsPrivateReplies() {
return (bool)$this->getReplyHandlerDomain() &&
!$this->supportsPublicReplies();
}
public function supportsPublicReplies() {
if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) {
return false;
}
if (!$this->getReplyHandlerDomain()) {
return false;
}
return (bool)$this->getPublicReplyHandlerEmailAddress();
}
final public function supportsReplies() {
return $this->supportsPrivateReplies() ||
$this->supportsPublicReplies();
}
public function getPublicReplyHandlerEmailAddress() {
return null;
}
protected function getDefaultPublicReplyHandlerEmailAddress($prefix) {
$receiver = $this->getMailReceiver();
$receiver_id = $receiver->getID();
$domain = $this->getReplyHandlerDomain();
// We compute a hash using the object's own PHID to prevent an attacker
// from blindly interacting with objects that they haven't ever received
// mail about by just sending to D1@, D2@, etc...
$hash = PhabricatorObjectMailReceiver::computeMailHash(
$receiver->getMailKey(),
$receiver->getPHID());
$address = "{$prefix}{$receiver_id}+public+{$hash}@{$domain}";
return $this->getSingleReplyHandlerPrefix($address);
}
protected function getSingleReplyHandlerPrefix($address) {
$single_handle_prefix = PhabricatorEnv::getEnvConfig(
'metamta.single-reply-handler-prefix');
return ($single_handle_prefix)
? $single_handle_prefix.'+'.$address
: $address;
}
protected function getDefaultPrivateReplyHandlerEmailAddress(
PhabricatorUser $user,
$prefix) {
$receiver = $this->getMailReceiver();
$receiver_id = $receiver->getID();
$user_id = $user->getID();
$hash = PhabricatorObjectMailReceiver::computeMailHash(
$receiver->getMailKey(),
$user->getPHID());
$domain = $this->getReplyHandlerDomain();
$address = "{$prefix}{$receiver_id}+{$user_id}+{$hash}@{$domain}";
return $this->getSingleReplyHandlerPrefix($address);
}
final protected function enhanceBodyWithAttachments(
$body,
array $attachments) {
if (!$attachments) {
return $body;
}
$files = id(new PhabricatorFileQuery())
->setViewer($this->getActor())
->withPHIDs($attachments)
->execute();
$output = array();
$output[] = $body;
// We're going to put all the non-images first in a list, then embed
// the images.
$head = array();
$tail = array();
foreach ($files as $file) {
if ($file->isViewableImage()) {
$tail[] = $file;
} else {
$head[] = $file;
}
}
if ($head) {
$list = array();
foreach ($head as $file) {
$list[] = ' - {'.$file->getMonogram().', layout=link}';
}
$output[] = implode("\n", $list);
}
if ($tail) {
$list = array();
foreach ($tail as $file) {
$list[] = '{'.$file->getMonogram().'}';
}
$output[] = implode("\n\n", $list);
}
$output = implode("\n\n", $output);
return rtrim($output);
}
/**
* Produce a list of mail targets for a given to/cc list.
*
* Each target should be sent a separate email, and contains the information
* required to generate it with appropriate permissions and configuration.
*
* @param list<phid> List of "To" PHIDs.
* @param list<phid> List of "CC" PHIDs.
* @return list<PhabricatorMailTarget> List of targets.
*/
final public function getMailTargets(array $raw_to, array $raw_cc) {
list($to, $cc) = $this->expandRecipientPHIDs($raw_to, $raw_cc);
list($to, $cc) = $this->loadRecipientUsers($to, $cc);
list($to, $cc) = $this->filterRecipientUsers($to, $cc);
if (!$to && !$cc) {
return array();
}
$template = id(new PhabricatorMailTarget())
->setRawToPHIDs($raw_to)
->setRawCCPHIDs($raw_cc);
// Set the public reply address as the default, if one exists. We
// might replace this with a private address later.
if ($this->supportsPublicReplies()) {
$reply_to = $this->getPublicReplyHandlerEmailAddress();
if ($reply_to) {
$template->setReplyTo($reply_to);
}
}
$supports_private_replies = $this->supportsPrivateReplies();
$mail_all = !PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient');
$targets = array();
if ($mail_all) {
$target = id(clone $template)
->setViewer(PhabricatorUser::getOmnipotentUser())
->setToMap($to)
->setCCMap($cc);
$targets[] = $target;
} else {
$map = $to + $cc;
foreach ($map as $phid => $user) {
// Preserve the original To/Cc information on the target.
if (isset($to[$phid])) {
$target_to = array($phid => $user);
$target_cc = array();
} else {
$target_to = array();
$target_cc = array($phid => $user);
}
$target = id(clone $template)
->setViewer($user)
->setToMap($target_to)
->setCCMap($target_cc);
if ($supports_private_replies) {
$reply_to = $this->getPrivateReplyHandlerEmailAddress($user);
if ($reply_to) {
$target->setReplyTo($reply_to);
}
}
$targets[] = $target;
}
}
return $targets;
}
/**
* Expand lists of recipient PHIDs.
*
* This takes any compound recipients (like projects) and looks up all their
* members.
*
* @param list<phid> List of To PHIDs.
* @param list<phid> List of CC PHIDs.
* @return pair<list<phid>, list<phid>> Expanded PHID lists.
*/
private function expandRecipientPHIDs(array $to, array $cc) {
$to_result = array();
$cc_result = array();
$all_phids = array_merge($to, $cc);
if ($all_phids) {
$map = id(new PhabricatorMetaMTAMemberQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($all_phids)
->execute();
foreach ($to as $phid) {
foreach ($map[$phid] as $expanded) {
$to_result[$expanded] = $expanded;
}
}
foreach ($cc as $phid) {
foreach ($map[$phid] as $expanded) {
$cc_result[$expanded] = $expanded;
}
}
}
// Remove recipients from "CC" if they're also present in "To".
$cc_result = array_diff_key($cc_result, $to_result);
return array(array_values($to_result), array_values($cc_result));
}
/**
* Load @{class:PhabricatorUser} objects for each recipient.
*
* Invalid recipients are dropped from the results.
*
* @param list<phid> List of To PHIDs.
* @param list<phid> List of CC PHIDs.
* @return pair<wild, wild> Maps from PHIDs to users.
*/
private function loadRecipientUsers(array $to, array $cc) {
$to_result = array();
$cc_result = array();
$all_phids = array_merge($to, $cc);
if ($all_phids) {
+ // We need user settings here because we'll check translations later
+ // when generating mail.
$users = id(new PhabricatorPeopleQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($all_phids)
+ ->needUserSettings(true)
->execute();
$users = mpull($users, null, 'getPHID');
foreach ($to as $phid) {
if (isset($users[$phid])) {
$to_result[$phid] = $users[$phid];
}
}
foreach ($cc as $phid) {
if (isset($users[$phid])) {
$cc_result[$phid] = $users[$phid];
}
}
}
return array($to_result, $cc_result);
}
/**
* Remove recipients who do not have permission to view the mail receiver.
*
* @param map<string, PhabricatorUser> Map of "To" users.
* @param map<string, PhabricatorUser> Map of "CC" users.
* @return pair<wild, wild> Filtered user maps.
*/
private function filterRecipientUsers(array $to, array $cc) {
$to_result = array();
$cc_result = array();
$all_users = $to + $cc;
if ($all_users) {
$can_see = array();
$object = $this->getMailReceiver();
foreach ($all_users as $phid => $user) {
$visible = PhabricatorPolicyFilter::hasCapability(
$user,
$object,
PhabricatorPolicyCapability::CAN_VIEW);
if ($visible) {
$can_see[$phid] = true;
}
}
foreach ($to as $phid => $user) {
if (!empty($can_see[$phid])) {
$to_result[$phid] = $all_users[$phid];
}
}
foreach ($cc as $phid => $user) {
if (!empty($can_see[$phid])) {
$cc_result[$phid] = $all_users[$phid];
}
}
}
return array($to_result, $cc_result);
}
}
diff --git a/src/applications/people/query/PhabricatorPeopleQuery.php b/src/applications/people/query/PhabricatorPeopleQuery.php
index cd7121299f..a5aac3f9ed 100644
--- a/src/applications/people/query/PhabricatorPeopleQuery.php
+++ b/src/applications/people/query/PhabricatorPeopleQuery.php
@@ -1,586 +1,583 @@
<?php
final class PhabricatorPeopleQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $usernames;
private $realnames;
private $emails;
private $phids;
private $ids;
private $dateCreatedAfter;
private $dateCreatedBefore;
private $isAdmin;
private $isSystemAgent;
private $isMailingList;
private $isDisabled;
private $isApproved;
private $nameLike;
private $nameTokens;
private $needPrimaryEmail;
private $needProfile;
private $needProfileImage;
private $needAvailability;
private $needBadges;
private $cacheKeys = array();
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withEmails(array $emails) {
$this->emails = $emails;
return $this;
}
public function withRealnames(array $realnames) {
$this->realnames = $realnames;
return $this;
}
public function withUsernames(array $usernames) {
$this->usernames = $usernames;
return $this;
}
public function withDateCreatedBefore($date_created_before) {
$this->dateCreatedBefore = $date_created_before;
return $this;
}
public function withDateCreatedAfter($date_created_after) {
$this->dateCreatedAfter = $date_created_after;
return $this;
}
public function withIsAdmin($admin) {
$this->isAdmin = $admin;
return $this;
}
public function withIsSystemAgent($system_agent) {
$this->isSystemAgent = $system_agent;
return $this;
}
public function withIsMailingList($mailing_list) {
$this->isMailingList = $mailing_list;
return $this;
}
public function withIsDisabled($disabled) {
$this->isDisabled = $disabled;
return $this;
}
public function withIsApproved($approved) {
$this->isApproved = $approved;
return $this;
}
public function withNameLike($like) {
$this->nameLike = $like;
return $this;
}
public function withNameTokens(array $tokens) {
$this->nameTokens = array_values($tokens);
return $this;
}
public function needPrimaryEmail($need) {
$this->needPrimaryEmail = $need;
return $this;
}
public function needProfile($need) {
$this->needProfile = $need;
return $this;
}
public function needProfileImage($need) {
$this->needProfileImage = $need;
return $this;
}
public function needAvailability($need) {
$this->needAvailability = $need;
return $this;
}
public function needBadges($need) {
$this->needBadges = $need;
return $this;
}
public function needUserSettings($need) {
$cache_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES;
if ($need) {
$this->cacheKeys[$cache_key] = true;
} else {
unset($this->cacheKeys[$cache_key]);
}
return $this;
}
public function newResultObject() {
return new PhabricatorUser();
}
protected function loadPage() {
$table = new PhabricatorUser();
$data = $this->loadStandardPageRows($table);
if ($this->needPrimaryEmail) {
$table->putInSet(new LiskDAOSet());
}
return $table->loadAllFromArray($data);
}
protected function didFilterPage(array $users) {
if ($this->needProfile) {
$user_list = mpull($users, null, 'getPHID');
$profiles = new PhabricatorUserProfile();
$profiles = $profiles->loadAllWhere(
'userPHID IN (%Ls)',
array_keys($user_list));
$profiles = mpull($profiles, null, 'getUserPHID');
foreach ($user_list as $user_phid => $user) {
$profile = idx($profiles, $user_phid);
if (!$profile) {
$profile = PhabricatorUserProfile::initializeNewProfile($user);
}
$user->attachUserProfile($profile);
}
}
if ($this->needBadges) {
$awards = id(new PhabricatorBadgesAwardQuery())
->setViewer($this->getViewer())
->withRecipientPHIDs(mpull($users, 'getPHID'))
->execute();
$awards = mgroup($awards, 'getRecipientPHID');
foreach ($users as $user) {
$user_awards = idx($awards, $user->getPHID(), array());
$badge_phids = mpull($user_awards, 'getBadgePHID');
$user->attachBadgePHIDs($badge_phids);
}
}
if ($this->needProfileImage) {
$rebuild = array();
foreach ($users as $user) {
$image_uri = $user->getProfileImageCache();
if ($image_uri) {
// This user has a valid cache, so we don't need to fetch any
// data or rebuild anything.
$user->attachProfileImageURI($image_uri);
continue;
}
// This user's cache is invalid or missing, so we're going to rebuild
// it.
$rebuild[] = $user;
}
if ($rebuild) {
$file_phids = mpull($rebuild, 'getProfileImagePHID');
$file_phids = array_filter($file_phids);
if ($file_phids) {
// NOTE: We're using the omnipotent user here because older profile
// images do not have the 'profile' flag, so they may not be visible
// to the executing viewer. At some point, we could migrate to add
// this flag and then use the real viewer, or just use the real
// viewer after enough time has passed to limit the impact of old
// data. The consequence of missing here is that we cache a default
// image when a real image exists.
$files = id(new PhabricatorFileQuery())
->setParentQuery($this)
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($file_phids)
->execute();
$files = mpull($files, null, 'getPHID');
} else {
$files = array();
}
foreach ($rebuild as $user) {
$image_phid = $user->getProfileImagePHID();
if (isset($files[$image_phid])) {
$image_uri = $files[$image_phid]->getBestURI();
} else {
$image_uri = PhabricatorUser::getDefaultProfileImageURI();
}
$user->writeProfileImageCache($image_uri);
$user->attachProfileImageURI($image_uri);
}
}
}
if ($this->needAvailability) {
$rebuild = array();
foreach ($users as $user) {
$cache = $user->getAvailabilityCache();
if ($cache !== null) {
$user->attachAvailability($cache);
} else {
$rebuild[] = $user;
}
}
if ($rebuild) {
$this->rebuildAvailabilityCache($rebuild);
}
}
$this->fillUserCaches($users);
return $users;
}
protected function shouldGroupQueryResultRows() {
if ($this->nameTokens) {
return true;
}
return parent::shouldGroupQueryResultRows();
}
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
$joins = parent::buildJoinClauseParts($conn);
if ($this->emails) {
$email_table = new PhabricatorUserEmail();
$joins[] = qsprintf(
$conn,
'JOIN %T email ON email.userPHID = user.PHID',
$email_table->getTableName());
}
if ($this->nameTokens) {
foreach ($this->nameTokens as $key => $token) {
$token_table = 'token_'.$key;
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.userID = user.id AND %T.token LIKE %>',
PhabricatorUser::NAMETOKEN_TABLE,
$token_table,
$token_table,
$token_table,
$token);
}
}
return $joins;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->usernames !== null) {
$where[] = qsprintf(
$conn,
'user.userName IN (%Ls)',
$this->usernames);
}
if ($this->emails !== null) {
$where[] = qsprintf(
$conn,
'email.address IN (%Ls)',
$this->emails);
}
if ($this->realnames !== null) {
$where[] = qsprintf(
$conn,
'user.realName IN (%Ls)',
$this->realnames);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'user.phid IN (%Ls)',
$this->phids);
}
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'user.id IN (%Ld)',
$this->ids);
}
if ($this->dateCreatedAfter) {
$where[] = qsprintf(
$conn,
'user.dateCreated >= %d',
$this->dateCreatedAfter);
}
if ($this->dateCreatedBefore) {
$where[] = qsprintf(
$conn,
'user.dateCreated <= %d',
$this->dateCreatedBefore);
}
if ($this->isAdmin !== null) {
$where[] = qsprintf(
$conn,
'user.isAdmin = %d',
(int)$this->isAdmin);
}
if ($this->isDisabled !== null) {
$where[] = qsprintf(
$conn,
'user.isDisabled = %d',
(int)$this->isDisabled);
}
if ($this->isApproved !== null) {
$where[] = qsprintf(
$conn,
'user.isApproved = %d',
(int)$this->isApproved);
}
if ($this->isSystemAgent !== null) {
$where[] = qsprintf(
$conn,
'user.isSystemAgent = %d',
(int)$this->isSystemAgent);
}
if ($this->isMailingList !== null) {
$where[] = qsprintf(
$conn,
'user.isMailingList = %d',
(int)$this->isMailingList);
}
if (strlen($this->nameLike)) {
$where[] = qsprintf(
$conn,
'user.username LIKE %~ OR user.realname LIKE %~',
$this->nameLike,
$this->nameLike);
}
return $where;
}
protected function getPrimaryTableAlias() {
return 'user';
}
public function getQueryApplicationClass() {
return 'PhabricatorPeopleApplication';
}
public function getOrderableColumns() {
return parent::getOrderableColumns() + array(
'username' => array(
'table' => 'user',
'column' => 'username',
'type' => 'string',
'reverse' => true,
'unique' => true,
),
);
}
protected function getPagingValueMap($cursor, array $keys) {
$user = $this->loadCursorObject($cursor);
return array(
'id' => $user->getID(),
'username' => $user->getUsername(),
);
}
private function rebuildAvailabilityCache(array $rebuild) {
$rebuild = mpull($rebuild, null, 'getPHID');
// Limit the window we look at because far-future events are largely
// irrelevant and this makes the cache cheaper to build and allows it to
// self-heal over time.
$min_range = PhabricatorTime::getNow();
$max_range = $min_range + phutil_units('72 hours in seconds');
// NOTE: We don't need to generate ghosts here, because we only care if
// the user is attending, and you can't attend a ghost event: RSVP'ing
// to it creates a real event.
$events = id(new PhabricatorCalendarEventQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withInvitedPHIDs(array_keys($rebuild))
->withIsCancelled(false)
->withDateRange($min_range, $max_range)
->execute();
// Group all the events by invited user. Only examine events that users
// are actually attending.
$map = array();
foreach ($events as $event) {
foreach ($event->getInvitees() as $invitee) {
if (!$invitee->isAttending()) {
continue;
}
$invitee_phid = $invitee->getInviteePHID();
if (!isset($rebuild[$invitee_phid])) {
continue;
}
$map[$invitee_phid][] = $event;
}
}
foreach ($rebuild as $phid => $user) {
$events = idx($map, $phid, array());
$cursor = $min_range;
if ($events) {
// Find the next time when the user has no meetings. If we move forward
// because of an event, we check again for events after that one ends.
while (true) {
foreach ($events as $event) {
$from = $event->getDateFromForCache();
$to = $event->getDateTo();
if (($from <= $cursor) && ($to > $cursor)) {
$cursor = $to;
continue 2;
}
}
break;
}
}
if ($cursor > $min_range) {
$availability = array(
'until' => $cursor,
);
$availability_ttl = $cursor;
} else {
$availability = array(
'until' => null,
);
$availability_ttl = $max_range;
}
// Never TTL the cache to longer than the maximum range we examined.
$availability_ttl = min($availability_ttl, $max_range);
$user->writeAvailabilityCache($availability, $availability_ttl);
$user->attachAvailability($availability);
}
}
private function fillUserCaches(array $users) {
if (!$this->cacheKeys) {
return;
}
$user_map = mpull($users, null, 'getPHID');
$keys = array_keys($this->cacheKeys);
$hashes = array();
foreach ($keys as $key) {
$hashes[] = PhabricatorHash::digestForIndex($key);
}
// First, pull any available caches. If we wanted to be particularly clever
// we could do this with JOINs in the main query.
$cache_table = new PhabricatorUserCache();
$cache_conn = $cache_table->establishConnection('r');
$cache_data = queryfx_all(
$cache_conn,
'SELECT cacheKey, userPHID, cacheData FROM %T
WHERE cacheIndex IN (%Ls) AND userPHID IN (%Ls)',
$cache_table->getTableName(),
$hashes,
array_keys($user_map));
$need = array();
$cache_data = igroup($cache_data, 'userPHID');
foreach ($user_map as $user_phid => $user) {
$raw_rows = idx($cache_data, $user_phid, array());
- if (!$raw_rows) {
- continue;
- }
$raw_data = ipull($raw_rows, 'cacheData', 'cacheKey');
foreach ($keys as $key) {
if (isset($raw_data[$key]) || array_key_exists($key, $raw_data)) {
continue;
}
$need[$key][$user_phid] = $user;
}
$user->attachRawCacheData($raw_data);
}
// If we missed any cache values, bulk-construct them now. This is
// usually much cheaper than generating them on-demand for each user
// record.
if (!$need) {
return;
}
$writes = array();
foreach ($need as $cache_key => $need_users) {
$type = PhabricatorUserCacheType::getCacheTypeForKey($cache_key);
if (!$type) {
continue;
}
$data = $type->newValueForUsers($cache_key, $need_users);
foreach ($data as $user_phid => $value) {
$raw_value = $type->getValueForStorage($value);
$data[$user_phid] = $raw_value;
$writes[] = array(
'userPHID' => $user_phid,
'key' => $cache_key,
'type' => $type,
'value' => $raw_value,
);
}
foreach ($need_users as $user_phid => $user) {
if (isset($data[$user_phid]) || array_key_exists($user_phid, $data)) {
$user->attachRawCacheData(
array(
$cache_key => $data[$user_phid],
));
}
}
}
PhabricatorUserCache::writeCaches($writes);
}
}
diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php
index 917e0d9a02..137c4c4bd0 100644
--- a/src/applications/people/storage/PhabricatorUser.php
+++ b/src/applications/people/storage/PhabricatorUser.php
@@ -1,1542 +1,1557 @@
<?php
/**
* @task availability Availability
* @task image-cache Profile Image Cache
* @task factors Multi-Factor Authentication
* @task handles Managing Handles
* @task settings Settings
* @task cache User Cache
*/
final class PhabricatorUser
extends PhabricatorUserDAO
implements
PhutilPerson,
PhabricatorPolicyInterface,
PhabricatorCustomFieldInterface,
PhabricatorDestructibleInterface,
PhabricatorSSHPublicKeyInterface,
PhabricatorFlaggableInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorFulltextInterface,
PhabricatorConduitResultInterface {
const SESSION_TABLE = 'phabricator_session';
const NAMETOKEN_TABLE = 'user_nametoken';
const MAXIMUM_USERNAME_LENGTH = 64;
protected $userName;
protected $realName;
protected $passwordSalt;
protected $passwordHash;
protected $profileImagePHID;
protected $profileImageCache;
protected $availabilityCache;
protected $availabilityCacheTTL;
protected $conduitCertificate;
protected $isSystemAgent = 0;
protected $isMailingList = 0;
protected $isAdmin = 0;
protected $isDisabled = 0;
protected $isEmailVerified = 0;
protected $isApproved = 0;
protected $isEnrolledInMultiFactor = 0;
protected $accountSecret;
private $profileImage = self::ATTACHABLE;
private $profile = null;
private $availability = self::ATTACHABLE;
private $preferences = null;
private $omnipotent = false;
private $customFields = self::ATTACHABLE;
private $badgePHIDs = self::ATTACHABLE;
private $alternateCSRFString = self::ATTACHABLE;
private $session = self::ATTACHABLE;
private $rawCacheData = array();
private $usableCacheData = array();
private $authorities = array();
private $handlePool;
private $csrfSalt;
private $settingCacheKeys = array();
private $settingCache = array();
+ private $allowInlineCacheGeneration;
protected function readField($field) {
switch ($field) {
// Make sure these return booleans.
case 'isAdmin':
return (bool)$this->isAdmin;
case 'isDisabled':
return (bool)$this->isDisabled;
case 'isSystemAgent':
return (bool)$this->isSystemAgent;
case 'isMailingList':
return (bool)$this->isMailingList;
case 'isEmailVerified':
return (bool)$this->isEmailVerified;
case 'isApproved':
return (bool)$this->isApproved;
default:
return parent::readField($field);
}
}
/**
* Is this a live account which has passed required approvals? Returns true
* if this is an enabled, verified (if required), approved (if required)
* account, and false otherwise.
*
* @return bool True if this is a standard, usable account.
*/
public function isUserActivated() {
if ($this->isOmnipotent()) {
return true;
}
if ($this->getIsDisabled()) {
return false;
}
if (!$this->getIsApproved()) {
return false;
}
if (PhabricatorUserEmail::isEmailVerificationRequired()) {
if (!$this->getIsEmailVerified()) {
return false;
}
}
return true;
}
public function canEstablishWebSessions() {
if ($this->getIsMailingList()) {
return false;
}
if ($this->getIsSystemAgent()) {
return false;
}
return true;
}
public function canEstablishAPISessions() {
if ($this->getIsDisabled()) {
return false;
}
// Intracluster requests are permitted even if the user is logged out:
// in particular, public users are allowed to issue intracluster requests
// when browsing Diffusion.
if (PhabricatorEnv::isClusterRemoteAddress()) {
if (!$this->isLoggedIn()) {
return true;
}
}
if (!$this->isUserActivated()) {
return false;
}
if ($this->getIsMailingList()) {
return false;
}
return true;
}
public function canEstablishSSHSessions() {
if (!$this->isUserActivated()) {
return false;
}
if ($this->getIsMailingList()) {
return false;
}
return true;
}
/**
* Returns `true` if this is a standard user who is logged in. Returns `false`
* for logged out, anonymous, or external users.
*
* @return bool `true` if the user is a standard user who is logged in with
* a normal session.
*/
public function getIsStandardUser() {
$type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
return $this->getPHID() && (phid_get_type($this->getPHID()) == $type_user);
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'userName' => 'sort64',
'realName' => 'text128',
'passwordSalt' => 'text32?',
'passwordHash' => 'text128?',
'profileImagePHID' => 'phid?',
'conduitCertificate' => 'text255',
'isSystemAgent' => 'bool',
'isMailingList' => 'bool',
'isDisabled' => 'bool',
'isAdmin' => 'bool',
'isEmailVerified' => 'uint32',
'isApproved' => 'uint32',
'accountSecret' => 'bytes64',
'isEnrolledInMultiFactor' => 'bool',
'profileImageCache' => 'text255?',
'availabilityCache' => 'text255?',
'availabilityCacheTTL' => 'uint32?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'userName' => array(
'columns' => array('userName'),
'unique' => true,
),
'realName' => array(
'columns' => array('realName'),
),
'key_approved' => array(
'columns' => array('isApproved'),
),
),
self::CONFIG_NO_MUTATE => array(
'profileImageCache' => true,
'availabilityCache' => true,
'availabilityCacheTTL' => true,
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorPeopleUserPHIDType::TYPECONST);
}
public function setPassword(PhutilOpaqueEnvelope $envelope) {
if (!$this->getPHID()) {
throw new Exception(
pht(
'You can not set a password for an unsaved user because their PHID '.
'is a salt component in the password hash.'));
}
if (!strlen($envelope->openEnvelope())) {
$this->setPasswordHash('');
} else {
$this->setPasswordSalt(md5(Filesystem::readRandomBytes(32)));
$hash = $this->hashPassword($envelope);
$this->setPasswordHash($hash->openEnvelope());
}
return $this;
}
public function getMonogram() {
return '@'.$this->getUsername();
}
public function isLoggedIn() {
return !($this->getPHID() === null);
}
public function save() {
if (!$this->getConduitCertificate()) {
$this->setConduitCertificate($this->generateConduitCertificate());
}
if (!strlen($this->getAccountSecret())) {
$this->setAccountSecret(Filesystem::readRandomCharacters(64));
}
$result = parent::save();
if ($this->profile) {
$this->profile->save();
}
$this->updateNameTokens();
PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
return $result;
}
public function attachSession(PhabricatorAuthSession $session) {
$this->session = $session;
return $this;
}
public function getSession() {
return $this->assertAttached($this->session);
}
public function hasSession() {
return ($this->session !== self::ATTACHABLE);
}
private function generateConduitCertificate() {
return Filesystem::readRandomCharacters(255);
}
public function comparePassword(PhutilOpaqueEnvelope $envelope) {
if (!strlen($envelope->openEnvelope())) {
return false;
}
if (!strlen($this->getPasswordHash())) {
return false;
}
return PhabricatorPasswordHasher::comparePassword(
$this->getPasswordHashInput($envelope),
new PhutilOpaqueEnvelope($this->getPasswordHash()));
}
private function getPasswordHashInput(PhutilOpaqueEnvelope $password) {
$input =
$this->getUsername().
$password->openEnvelope().
$this->getPHID().
$this->getPasswordSalt();
return new PhutilOpaqueEnvelope($input);
}
private function hashPassword(PhutilOpaqueEnvelope $password) {
$hasher = PhabricatorPasswordHasher::getBestHasher();
$input_envelope = $this->getPasswordHashInput($password);
return $hasher->getPasswordHashForStorage($input_envelope);
}
const CSRF_CYCLE_FREQUENCY = 3600;
const CSRF_SALT_LENGTH = 8;
const CSRF_TOKEN_LENGTH = 16;
const CSRF_BREACH_PREFIX = 'B@';
const EMAIL_CYCLE_FREQUENCY = 86400;
const EMAIL_TOKEN_LENGTH = 24;
private function getRawCSRFToken($offset = 0) {
return $this->generateToken(
time() + (self::CSRF_CYCLE_FREQUENCY * $offset),
self::CSRF_CYCLE_FREQUENCY,
PhabricatorEnv::getEnvConfig('phabricator.csrf-key'),
self::CSRF_TOKEN_LENGTH);
}
public function getCSRFToken() {
if ($this->isOmnipotent()) {
// We may end up here when called from the daemons. The omnipotent user
// has no meaningful CSRF token, so just return `null`.
return null;
}
if ($this->csrfSalt === null) {
$this->csrfSalt = Filesystem::readRandomCharacters(
self::CSRF_SALT_LENGTH);
}
$salt = $this->csrfSalt;
// Generate a token hash to mitigate BREACH attacks against SSL. See
// discussion in T3684.
$token = $this->getRawCSRFToken();
$hash = PhabricatorHash::digest($token, $salt);
return self::CSRF_BREACH_PREFIX.$salt.substr(
$hash, 0, self::CSRF_TOKEN_LENGTH);
}
public function validateCSRFToken($token) {
// We expect a BREACH-mitigating token. See T3684.
$breach_prefix = self::CSRF_BREACH_PREFIX;
$breach_prelen = strlen($breach_prefix);
if (strncmp($token, $breach_prefix, $breach_prelen) !== 0) {
return false;
}
$salt = substr($token, $breach_prelen, self::CSRF_SALT_LENGTH);
$token = substr($token, $breach_prelen + self::CSRF_SALT_LENGTH);
// When the user posts a form, we check that it contains a valid CSRF token.
// Tokens cycle each hour (every CSRF_CYLCE_FREQUENCY seconds) and we accept
// either the current token, the next token (users can submit a "future"
// token if you have two web frontends that have some clock skew) or any of
// the last 6 tokens. This means that pages are valid for up to 7 hours.
// There is also some Javascript which periodically refreshes the CSRF
// tokens on each page, so theoretically pages should be valid indefinitely.
// However, this code may fail to run (if the user loses their internet
// connection, or there's a JS problem, or they don't have JS enabled).
// Choosing the size of the window in which we accept old CSRF tokens is
// an issue of balancing concerns between security and usability. We could
// choose a very narrow (e.g., 1-hour) window to reduce vulnerability to
// attacks using captured CSRF tokens, but it's also more likely that real
// users will be affected by this, e.g. if they close their laptop for an
// hour, open it back up, and try to submit a form before the CSRF refresh
// can kick in. Since the user experience of submitting a form with expired
// CSRF is often quite bad (you basically lose data, or it's a big pain to
// recover at least) and I believe we gain little additional protection
// by keeping the window very short (the overwhelming value here is in
// preventing blind attacks, and most attacks which can capture CSRF tokens
// can also just capture authentication information [sniffing networks]
// or act as the user [xss]) the 7 hour default seems like a reasonable
// balance. Other major platforms have much longer CSRF token lifetimes,
// like Rails (session duration) and Django (forever), which suggests this
// is a reasonable analysis.
$csrf_window = 6;
for ($ii = -$csrf_window; $ii <= 1; $ii++) {
$valid = $this->getRawCSRFToken($ii);
$digest = PhabricatorHash::digest($valid, $salt);
$digest = substr($digest, 0, self::CSRF_TOKEN_LENGTH);
if (phutil_hashes_are_identical($digest, $token)) {
return true;
}
}
return false;
}
private function generateToken($epoch, $frequency, $key, $len) {
if ($this->getPHID()) {
$vec = $this->getPHID().$this->getAccountSecret();
} else {
$vec = $this->getAlternateCSRFString();
}
if ($this->hasSession()) {
$vec = $vec.$this->getSession()->getSessionKey();
}
$time_block = floor($epoch / $frequency);
$vec = $vec.$key.$time_block;
return substr(PhabricatorHash::digest($vec), 0, $len);
}
public function getUserProfile() {
return $this->assertAttached($this->profile);
}
public function attachUserProfile(PhabricatorUserProfile $profile) {
$this->profile = $profile;
return $this;
}
public function loadUserProfile() {
if ($this->profile) {
return $this->profile;
}
$profile_dao = new PhabricatorUserProfile();
$this->profile = $profile_dao->loadOneWhere('userPHID = %s',
$this->getPHID());
if (!$this->profile) {
$this->profile = PhabricatorUserProfile::initializeNewProfile($this);
}
return $this->profile;
}
public function loadPrimaryEmailAddress() {
$email = $this->loadPrimaryEmail();
if (!$email) {
throw new Exception(pht('User has no primary email address!'));
}
return $email->getAddress();
}
public function loadPrimaryEmail() {
return $this->loadOneRelative(
new PhabricatorUserEmail(),
'userPHID',
'getPHID',
'(isPrimary = 1)');
}
/* -( Settings )----------------------------------------------------------- */
public function getUserSetting($key) {
// NOTE: We store available keys and cached values separately to make it
// faster to check for `null` in the cache, which is common.
if (isset($this->settingCacheKeys[$key])) {
return $this->settingCache[$key];
}
$settings_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES;
- $settings = $this->requireCacheData($settings_key);
+ if ($this->getPHID()) {
+ $settings = $this->requireCacheData($settings_key);
+ } else {
+ $settings = array();
+ }
$defaults = PhabricatorSetting::getAllEnabledSettings($this);
if (array_key_exists($key, $settings)) {
$value = $settings[$key];
// Make sure the value is valid before we return it. This makes things
// more robust when options are changed or removed.
if (isset($defaults[$key])) {
try {
id(clone $defaults[$key])
->setViewer($this)
->assertValidValue($value);
return $this->writeUserSettingCache($key, $value);
} catch (Exception $ex) {
// Fall through below and return the default value.
}
} else {
// This is an ad-hoc setting with no controlling object.
return $this->writeUserSettingCache($key, $value);
}
}
if (isset($defaults[$key])) {
$value = id(clone $defaults[$key])
->setViewer($this)
->getSettingDefaultValue();
} else {
$value = null;
}
return $this->writeUserSettingCache($key, $value);
}
/**
* Test if a given setting is set to a particular value.
*
* @param const Setting key.
* @param wild Value to compare.
* @return bool True if the setting has the specified value.
* @task settings
*/
public function compareUserSetting($key, $value) {
$actual = $this->getUserSetting($key);
return ($actual == $value);
}
private function writeUserSettingCache($key, $value) {
$this->settingCacheKeys[$key] = true;
$this->settingCache[$key] = $value;
return $value;
}
public function getTranslation() {
return $this->getUserSetting(PhabricatorTranslationSetting::SETTINGKEY);
}
public function getTimezoneIdentifier() {
return $this->getUserSetting(PhabricatorTimezoneSetting::SETTINGKEY);
}
/**
* Override the user's timezone identifier.
*
* This is primarily useful for unit tests.
*
* @param string New timezone identifier.
* @return this
* @task settings
*/
public function overrideTimezoneIdentifier($identifier) {
$timezone_key = PhabricatorTimezoneSetting::SETTINGKEY;
$this->settingCacheKeys[$timezone_key] = true;
$this->settingCache[$timezone_key] = $identifier;
return $this;
}
public function getSex() {
return $this->getUserSetting(PhabricatorPronounSetting::SETTINGKEY);
}
public function loadEditorLink(
$path,
$line,
PhabricatorRepository $repository = null) {
$editor = $this->getUserSetting(PhabricatorEditorSetting::SETTINGKEY);
if (is_array($path)) {
$multi_key = PhabricatorEditorMultipleSetting::SETTINGKEY;
$multiedit = $this->getUserSetting($multi_key);
switch ($multiedit) {
case PhabricatorEditorMultipleSetting::VALUE_SPACES:
$path = implode(' ', $path);
break;
case PhabricatorEditorMultipleSetting::VALUE_SINGLE:
default:
return null;
}
}
if (!strlen($editor)) {
return null;
}
if ($repository) {
$callsign = $repository->getCallsign();
} else {
$callsign = null;
}
$uri = strtr($editor, array(
'%%' => '%',
'%f' => phutil_escape_uri($path),
'%l' => phutil_escape_uri($line),
'%r' => phutil_escape_uri($callsign),
));
// The resulting URI must have an allowed protocol. Otherwise, we'll return
// a link to an error page explaining the misconfiguration.
$ok = PhabricatorHelpEditorProtocolController::hasAllowedProtocol($uri);
if (!$ok) {
return '/help/editorprotocol/';
}
return (string)$uri;
}
public function getAlternateCSRFString() {
return $this->assertAttached($this->alternateCSRFString);
}
public function attachAlternateCSRFString($string) {
$this->alternateCSRFString = $string;
return $this;
}
/**
* Populate the nametoken table, which used to fetch typeahead results. When
* a user types "linc", we want to match "Abraham Lincoln" from on-demand
* typeahead sources. To do this, we need a separate table of name fragments.
*/
public function updateNameTokens() {
$table = self::NAMETOKEN_TABLE;
$conn_w = $this->establishConnection('w');
$tokens = PhabricatorTypeaheadDatasource::tokenizeString(
$this->getUserName().' '.$this->getRealName());
$sql = array();
foreach ($tokens as $token) {
$sql[] = qsprintf(
$conn_w,
'(%d, %s)',
$this->getID(),
$token);
}
queryfx(
$conn_w,
'DELETE FROM %T WHERE userID = %d',
$table,
$this->getID());
if ($sql) {
queryfx(
$conn_w,
'INSERT INTO %T (userID, token) VALUES %Q',
$table,
implode(', ', $sql));
}
}
public function sendWelcomeEmail(PhabricatorUser $admin) {
if (!$this->canEstablishWebSessions()) {
throw new Exception(
pht(
'Can not send welcome mail to users who can not establish '.
'web sessions!'));
}
$admin_username = $admin->getUserName();
$admin_realname = $admin->getRealName();
$user_username = $this->getUserName();
$is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
$base_uri = PhabricatorEnv::getProductionURI('/');
$engine = new PhabricatorAuthSessionEngine();
$uri = $engine->getOneTimeLoginURI(
$this,
$this->loadPrimaryEmail(),
PhabricatorAuthSessionEngine::ONETIME_WELCOME);
$body = pht(
"Welcome to Phabricator!\n\n".
"%s (%s) has created an account for you.\n\n".
" Username: %s\n\n".
"To login to Phabricator, follow this link and set a password:\n\n".
" %s\n\n".
"After you have set a password, you can login in the future by ".
"going here:\n\n".
" %s\n",
$admin_username,
$admin_realname,
$user_username,
$uri,
$base_uri);
if (!$is_serious) {
$body .= sprintf(
"\n%s\n",
pht("Love,\nPhabricator"));
}
$mail = id(new PhabricatorMetaMTAMail())
->addTos(array($this->getPHID()))
->setForceDelivery(true)
->setSubject(pht('[Phabricator] Welcome to Phabricator'))
->setBody($body)
->saveAndSend();
}
public function sendUsernameChangeEmail(
PhabricatorUser $admin,
$old_username) {
$admin_username = $admin->getUserName();
$admin_realname = $admin->getRealName();
$new_username = $this->getUserName();
$password_instructions = null;
if (PhabricatorPasswordAuthProvider::getPasswordProvider()) {
$engine = new PhabricatorAuthSessionEngine();
$uri = $engine->getOneTimeLoginURI(
$this,
null,
PhabricatorAuthSessionEngine::ONETIME_USERNAME);
$password_instructions = sprintf(
"%s\n\n %s\n\n%s\n",
pht(
"If you use a password to login, you'll need to reset it ".
"before you can login again. You can reset your password by ".
"following this link:"),
$uri,
pht(
"And, of course, you'll need to use your new username to login ".
"from now on. If you use OAuth to login, nothing should change."));
}
$body = sprintf(
"%s\n\n %s\n %s\n\n%s",
pht(
'%s (%s) has changed your Phabricator username.',
$admin_username,
$admin_realname),
pht(
'Old Username: %s',
$old_username),
pht(
'New Username: %s',
$new_username),
$password_instructions);
$mail = id(new PhabricatorMetaMTAMail())
->addTos(array($this->getPHID()))
->setForceDelivery(true)
->setSubject(pht('[Phabricator] Username Changed'))
->setBody($body)
->saveAndSend();
}
public static function describeValidUsername() {
return pht(
'Usernames must contain only numbers, letters, period, underscore and '.
'hyphen, and can not end with a period. They must have no more than %d '.
'characters.',
new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH));
}
public static function validateUsername($username) {
// NOTE: If you update this, make sure to update:
//
// - Remarkup rule for @mentions.
// - Routing rule for "/p/username/".
// - Unit tests, obviously.
// - describeValidUsername() method, above.
if (strlen($username) > self::MAXIMUM_USERNAME_LENGTH) {
return false;
}
return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]\z/', $username);
}
public static function getDefaultProfileImageURI() {
return celerity_get_resource_uri('/rsrc/image/avatar.png');
}
public function attachProfileImageURI($uri) {
$this->profileImage = $uri;
return $this;
}
public function getProfileImageURI() {
return $this->assertAttached($this->profileImage);
}
public function getFullName() {
if (strlen($this->getRealName())) {
return $this->getUsername().' ('.$this->getRealName().')';
} else {
return $this->getUsername();
}
}
public function getTimeZone() {
return new DateTimeZone($this->getTimezoneIdentifier());
}
public function getTimeZoneOffset() {
$timezone = $this->getTimeZone();
$now = new DateTime('@'.PhabricatorTime::getNow());
$offset = $timezone->getOffset($now);
// Javascript offsets are in minutes and have the opposite sign.
$offset = -(int)($offset / 60);
return $offset;
}
public function formatShortDateTime($when, $now = null) {
if ($now === null) {
$now = PhabricatorTime::getNow();
}
try {
$when = new DateTime('@'.$when);
$now = new DateTime('@'.$now);
} catch (Exception $ex) {
return null;
}
$zone = $this->getTimeZone();
$when->setTimeZone($zone);
$now->setTimeZone($zone);
if ($when->format('Y') !== $now->format('Y')) {
// Different year, so show "Feb 31 2075".
$format = 'M j Y';
} else if ($when->format('Ymd') !== $now->format('Ymd')) {
// Same year but different month and day, so show "Feb 31".
$format = 'M j';
} else {
// Same year, month and day so show a time of day.
$pref_time = PhabricatorTimeFormatSetting::SETTINGKEY;
$format = $this->getUserSetting($pref_time);
}
return $when->format($format);
}
public function __toString() {
return $this->getUsername();
}
public static function loadOneWithEmailAddress($address) {
$email = id(new PhabricatorUserEmail())->loadOneWhere(
'address = %s',
$address);
if (!$email) {
return null;
}
return id(new PhabricatorUser())->loadOneWhere(
'phid = %s',
$email->getUserPHID());
}
public function getDefaultSpacePHID() {
// TODO: We might let the user switch which space they're "in" later on;
// for now just use the global space if one exists.
// If the viewer has access to the default space, use that.
$spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces($this);
foreach ($spaces as $space) {
if ($space->getIsDefaultNamespace()) {
return $space->getPHID();
}
}
// Otherwise, use the space with the lowest ID that they have access to.
// This just tends to keep the default stable and predictable over time,
// so adding a new space won't change behavior for users.
if ($spaces) {
$spaces = msort($spaces, 'getID');
return head($spaces)->getPHID();
}
return null;
}
/**
* Grant a user a source of authority, to let them bypass policy checks they
* could not otherwise.
*/
public function grantAuthority($authority) {
$this->authorities[] = $authority;
return $this;
}
/**
* Get authorities granted to the user.
*/
public function getAuthorities() {
return $this->authorities;
}
/* -( Availability )------------------------------------------------------- */
/**
* @task availability
*/
public function attachAvailability(array $availability) {
$this->availability = $availability;
return $this;
}
/**
* Get the timestamp the user is away until, if they are currently away.
*
* @return int|null Epoch timestamp, or `null` if the user is not away.
* @task availability
*/
public function getAwayUntil() {
$availability = $this->availability;
$this->assertAttached($availability);
if (!$availability) {
return null;
}
return idx($availability, 'until');
}
/**
* Describe the user's availability.
*
* @param PhabricatorUser Viewing user.
* @return string Human-readable description of away status.
* @task availability
*/
public function getAvailabilityDescription(PhabricatorUser $viewer) {
$until = $this->getAwayUntil();
if ($until) {
return pht('Away until %s', phabricator_datetime($until, $viewer));
} else {
return pht('Available');
}
}
/**
* Get cached availability, if present.
*
* @return wild|null Cache data, or null if no cache is available.
* @task availability
*/
public function getAvailabilityCache() {
$now = PhabricatorTime::getNow();
if ($this->availabilityCacheTTL <= $now) {
return null;
}
try {
return phutil_json_decode($this->availabilityCache);
} catch (Exception $ex) {
return null;
}
}
/**
* Write to the availability cache.
*
* @param wild Availability cache data.
* @param int|null Cache TTL.
* @return this
* @task availability
*/
public function writeAvailabilityCache(array $availability, $ttl) {
if (PhabricatorEnv::isReadOnly()) {
return $this;
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$this->establishConnection('w'),
'UPDATE %T SET availabilityCache = %s, availabilityCacheTTL = %nd
WHERE id = %d',
$this->getTableName(),
json_encode($availability),
$ttl,
$this->getID());
unset($unguarded);
return $this;
}
/* -( Profile Image Cache )------------------------------------------------ */
/**
* Get this user's cached profile image URI.
*
* @return string|null Cached URI, if a URI is cached.
* @task image-cache
*/
public function getProfileImageCache() {
$version = $this->getProfileImageVersion();
$parts = explode(',', $this->profileImageCache, 2);
if (count($parts) !== 2) {
return null;
}
if ($parts[0] !== $version) {
return null;
}
return $parts[1];
}
/**
* Generate a new cache value for this user's profile image.
*
* @return string New cache value.
* @task image-cache
*/
public function writeProfileImageCache($uri) {
$version = $this->getProfileImageVersion();
$cache = "{$version},{$uri}";
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$this->establishConnection('w'),
'UPDATE %T SET profileImageCache = %s WHERE id = %d',
$this->getTableName(),
$cache,
$this->getID());
unset($unguarded);
}
/**
* Get a version identifier for a user's profile image.
*
* This version will change if the image changes, or if any of the
* environment configuration which goes into generating a URI changes.
*
* @return string Cache version.
* @task image-cache
*/
private function getProfileImageVersion() {
$parts = array(
PhabricatorEnv::getCDNURI('/'),
PhabricatorEnv::getEnvConfig('cluster.instance'),
$this->getProfileImagePHID(),
);
$parts = serialize($parts);
return PhabricatorHash::digestForIndex($parts);
}
/* -( Multi-Factor Authentication )---------------------------------------- */
/**
* Update the flag storing this user's enrollment in multi-factor auth.
*
* With certain settings, we need to check if a user has MFA on every page,
* so we cache MFA enrollment on the user object for performance. Calling this
* method synchronizes the cache by examining enrollment records. After
* updating the cache, use @{method:getIsEnrolledInMultiFactor} to check if
* the user is enrolled.
*
* This method should be called after any changes are made to a given user's
* multi-factor configuration.
*
* @return void
* @task factors
*/
public function updateMultiFactorEnrollment() {
$factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
$enrolled = count($factors) ? 1 : 0;
if ($enrolled !== $this->isEnrolledInMultiFactor) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$this->establishConnection('w'),
'UPDATE %T SET isEnrolledInMultiFactor = %d WHERE id = %d',
$this->getTableName(),
$enrolled,
$this->getID());
unset($unguarded);
$this->isEnrolledInMultiFactor = $enrolled;
}
}
/**
* Check if the user is enrolled in multi-factor authentication.
*
* Enrolled users have one or more multi-factor authentication sources
* attached to their account. For performance, this value is cached. You
* can use @{method:updateMultiFactorEnrollment} to update the cache.
*
* @return bool True if the user is enrolled.
* @task factors
*/
public function getIsEnrolledInMultiFactor() {
return $this->isEnrolledInMultiFactor;
}
/* -( Omnipotence )-------------------------------------------------------- */
/**
* Returns true if this user is omnipotent. Omnipotent users bypass all policy
* checks.
*
* @return bool True if the user bypasses policy checks.
*/
public function isOmnipotent() {
return $this->omnipotent;
}
/**
* Get an omnipotent user object for use in contexts where there is no acting
* user, notably daemons.
*
* @return PhabricatorUser An omnipotent user.
*/
public static function getOmnipotentUser() {
static $user = null;
if (!$user) {
$user = new PhabricatorUser();
$user->omnipotent = true;
$user->makeEphemeral();
}
return $user;
}
/**
* Get a scalar string identifying this user.
*
* This is similar to using the PHID, but distinguishes between ominpotent
* and public users explicitly. This allows safe construction of cache keys
* or cache buckets which do not conflate public and omnipotent users.
*
* @return string Scalar identifier.
*/
public function getCacheFragment() {
if ($this->isOmnipotent()) {
return 'u.omnipotent';
}
$phid = $this->getPHID();
if ($phid) {
return 'u.'.$phid;
}
return 'u.public';
}
/* -( Managing Handles )--------------------------------------------------- */
/**
* Get a @{class:PhabricatorHandleList} which benefits from this viewer's
* internal handle pool.
*
* @param list<phid> List of PHIDs to load.
* @return PhabricatorHandleList Handle list object.
* @task handle
*/
public function loadHandles(array $phids) {
if ($this->handlePool === null) {
$this->handlePool = id(new PhabricatorHandlePool())
->setViewer($this);
}
return $this->handlePool->newHandleList($phids);
}
/**
* Get a @{class:PHUIHandleView} for a single handle.
*
* This benefits from the viewer's internal handle pool.
*
* @param phid PHID to render a handle for.
* @return PHUIHandleView View of the handle.
* @task handle
*/
public function renderHandle($phid) {
return $this->loadHandles(array($phid))->renderHandle($phid);
}
/**
* Get a @{class:PHUIHandleListView} for a list of handles.
*
* This benefits from the viewer's internal handle pool.
*
* @param list<phid> List of PHIDs to render.
* @return PHUIHandleListView View of the handles.
* @task handle
*/
public function renderHandleList(array $phids) {
return $this->loadHandles($phids)->renderList();
}
public function attachBadgePHIDs(array $phids) {
$this->badgePHIDs = $phids;
return $this;
}
public function getBadgePHIDs() {
return $this->assertAttached($this->badgePHIDs);
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::POLICY_PUBLIC;
case PhabricatorPolicyCapability::CAN_EDIT:
if ($this->getIsSystemAgent() || $this->getIsMailingList()) {
return PhabricatorPolicies::POLICY_ADMIN;
} else {
return PhabricatorPolicies::POLICY_NOONE;
}
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return $this->getPHID() && ($viewer->getPHID() === $this->getPHID());
}
public function describeAutomaticCapability($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_EDIT:
return pht('Only you can edit your information.');
default:
return null;
}
}
/* -( PhabricatorCustomFieldInterface )------------------------------------ */
public function getCustomFieldSpecificationForRole($role) {
return PhabricatorEnv::getEnvConfig('user.fields');
}
public function getCustomFieldBaseClass() {
return 'PhabricatorUserCustomField';
}
public function getCustomFields() {
return $this->assertAttached($this->customFields);
}
public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) {
$this->customFields = $fields;
return $this;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$this->delete();
$externals = id(new PhabricatorExternalAccount())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($externals as $external) {
$external->delete();
}
$prefs = id(new PhabricatorUserPreferencesQuery())
->setViewer($engine->getViewer())
->withUsers(array($this))
->execute();
foreach ($prefs as $pref) {
$engine->destroyObject($pref);
}
$profiles = id(new PhabricatorUserProfile())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($profiles as $profile) {
$profile->delete();
}
$keys = id(new PhabricatorAuthSSHKeyQuery())
->setViewer($engine->getViewer())
->withObjectPHIDs(array($this->getPHID()))
->execute();
foreach ($keys as $key) {
$engine->destroyObject($key);
}
$emails = id(new PhabricatorUserEmail())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($emails as $email) {
$email->delete();
}
$sessions = id(new PhabricatorAuthSession())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($sessions as $session) {
$session->delete();
}
$factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($factors as $factor) {
$factor->delete();
}
$this->saveTransaction();
}
/* -( PhabricatorSSHPublicKeyInterface )----------------------------------- */
public function getSSHPublicKeyManagementURI(PhabricatorUser $viewer) {
if ($viewer->getPHID() == $this->getPHID()) {
// If the viewer is managing their own keys, take them to the normal
// panel.
return '/settings/panel/ssh/';
} else {
// Otherwise, take them to the administrative panel for this user.
return '/settings/'.$this->getID().'/panel/ssh/';
}
}
public function getSSHKeyDefaultName() {
return 'id_rsa_phabricator';
}
public function getSSHKeyNotifyPHIDs() {
return array(
$this->getPHID(),
);
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorUserProfileEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new PhabricatorUserTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( PhabricatorFulltextInterface )--------------------------------------- */
public function newFulltextEngine() {
return new PhabricatorUserFulltextEngine();
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('username')
->setType('string')
->setDescription(pht("The user's username.")),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('realName')
->setType('string')
->setDescription(pht("The user's real name.")),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('roles')
->setType('list<string>')
->setDescription(pht('List of acccount roles.')),
);
}
public function getFieldValuesForConduit() {
$roles = array();
if ($this->getIsDisabled()) {
$roles[] = 'disabled';
}
if ($this->getIsSystemAgent()) {
$roles[] = 'bot';
}
if ($this->getIsMailingList()) {
$roles[] = 'list';
}
if ($this->getIsAdmin()) {
$roles[] = 'admin';
}
if ($this->getIsEmailVerified()) {
$roles[] = 'verified';
}
if ($this->getIsApproved()) {
$roles[] = 'approved';
}
if ($this->isUserActivated()) {
$roles[] = 'activated';
}
return array(
'username' => $this->getUsername(),
'realName' => $this->getRealName(),
'roles' => $roles,
);
}
public function getConduitSearchAttachments() {
return array();
}
/* -( User Cache )--------------------------------------------------------- */
/**
* @task cache
*/
public function attachRawCacheData(array $data) {
$this->rawCacheData = $data + $this->rawCacheData;
return $this;
}
+ public function setAllowInlineCacheGeneration($allow_cache_generation) {
+ $this->allowInlineCacheGeneration = $allow_cache_generation;
+ return $this;
+ }
/**
* @task cache
*/
protected function requireCacheData($key) {
if (isset($this->usableCacheData[$key])) {
return $this->usableCacheData[$key];
}
$type = PhabricatorUserCacheType::requireCacheTypeForKey($key);
if (isset($this->rawCacheData[$key])) {
$raw_value = $this->rawCacheData[$key];
$usable_value = $type->getValueFromStorage($raw_value);
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
+ // By default, we throw if a cache isn't available. This is consistent
+ // with the standard `needX()` + `attachX()` + `getX()` interaction.
+ if (!$this->allowInlineCacheGeneration) {
+ throw new PhabricatorDataNotAttachedException($this);
+ }
+
$usable_value = $type->getDefaultValue();
$user_phid = $this->getPHID();
if ($user_phid) {
$map = $type->newValueForUsers($key, array($this));
if (array_key_exists($user_phid, $map)) {
$usable_value = $map[$user_phid];
$raw_value = $type->getValueForStorage($usable_value);
$this->rawCacheData[$key] = $raw_value;
PhabricatorUserCache::writeCache(
$type,
$key,
$user_phid,
$raw_value);
}
}
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
/**
* @task cache
*/
public function clearCacheData($key) {
unset($this->rawCacheData[$key]);
unset($this->usableCacheData[$key]);
return $this;
}
}
diff --git a/src/infrastructure/testing/PhabricatorTestCase.php b/src/infrastructure/testing/PhabricatorTestCase.php
index 4af75d157e..c9790cd1e4 100644
--- a/src/infrastructure/testing/PhabricatorTestCase.php
+++ b/src/infrastructure/testing/PhabricatorTestCase.php
@@ -1,239 +1,247 @@
<?php
abstract class PhabricatorTestCase extends PhutilTestCase {
const NAMESPACE_PREFIX = 'phabricator_unittest_';
/**
* If true, put Lisk in process-isolated mode for the duration of the tests so
* that it will establish only isolated, side-effect-free database
* connections. Defaults to true.
*
* NOTE: You should disable this only in rare circumstances. Unit tests should
* not rely on external resources like databases, and should not produce
* side effects.
*/
const PHABRICATOR_TESTCONFIG_ISOLATE_LISK = 'isolate-lisk';
/**
* If true, build storage fixtures before running tests, and connect to them
* during test execution. This will impose a performance penalty on test
* execution (currently, it takes roughly one second to build the fixture)
* but allows you to perform tests which require data to be read from storage
* after writes. The fixture is shared across all test cases in this process.
* Defaults to false.
*
* NOTE: All connections to fixture storage open transactions when established
* and roll them back when tests complete. Each test must independently
* write data it relies on; data will not persist across tests.
*
* NOTE: Enabling this implies disabling process isolation.
*/
const PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES = 'storage-fixtures';
private $configuration;
private $env;
private static $storageFixtureReferences = 0;
private static $storageFixture;
private static $storageFixtureObjectSeed = 0;
private static $testsAreRunning = 0;
protected function getPhabricatorTestCaseConfiguration() {
return array();
}
private function getComputedConfiguration() {
$config = $this->getPhabricatorTestCaseConfiguration() + array(
self::PHABRICATOR_TESTCONFIG_ISOLATE_LISK => true,
self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES => false,
);
if ($config[self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES]) {
// Fixtures don't make sense with process isolation.
$config[self::PHABRICATOR_TESTCONFIG_ISOLATE_LISK] = false;
}
return $config;
}
public function willRunTestCases(array $test_cases) {
$root = dirname(phutil_get_library_root('phabricator'));
require_once $root.'/scripts/__init_script__.php';
$config = $this->getComputedConfiguration();
if ($config[self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES]) {
++self::$storageFixtureReferences;
if (!self::$storageFixture) {
self::$storageFixture = $this->newStorageFixture();
}
}
++self::$testsAreRunning;
}
public function didRunTestCases(array $test_cases) {
if (self::$storageFixture) {
self::$storageFixtureReferences--;
if (!self::$storageFixtureReferences) {
self::$storageFixture = null;
}
}
--self::$testsAreRunning;
}
protected function willRunTests() {
$config = $this->getComputedConfiguration();
if ($config[self::PHABRICATOR_TESTCONFIG_ISOLATE_LISK]) {
LiskDAO::beginIsolateAllLiskEffectsToCurrentProcess();
}
$this->env = PhabricatorEnv::beginScopedEnv();
// NOTE: While running unit tests, we act as though all applications are
// installed, regardless of the install's configuration. Tests which need
// to uninstall applications are responsible for adjusting state themselves
// (such tests are exceedingly rare).
$this->env->overrideEnvConfig(
'phabricator.uninstalled-applications',
array());
$this->env->overrideEnvConfig(
'phabricator.show-prototypes',
true);
// Reset application settings to defaults, particularly policies.
$this->env->overrideEnvConfig(
'phabricator.application-settings',
array());
// We can't stub this service right now, and it's not generally useful
// to publish notifications about test execution.
$this->env->overrideEnvConfig(
'notification.servers',
array());
$this->env->overrideEnvConfig(
'phabricator.base-uri',
'http://phabricator.example.com');
$this->env->overrideEnvConfig(
'auth.email-domains',
array());
// Tests do their own stubbing/voiding for events.
$this->env->overrideEnvConfig('phabricator.silent', false);
$this->env->overrideEnvConfig('cluster.read-only', false);
}
protected function didRunTests() {
$config = $this->getComputedConfiguration();
if ($config[self::PHABRICATOR_TESTCONFIG_ISOLATE_LISK]) {
LiskDAO::endIsolateAllLiskEffectsToCurrentProcess();
}
try {
if (phutil_is_hiphop_runtime()) {
$this->env->__destruct();
}
unset($this->env);
} catch (Exception $ex) {
throw new Exception(
pht(
'Some test called %s, but is still holding '.
'a reference to the scoped environment!',
'PhabricatorEnv::beginScopedEnv()'));
}
}
protected function willRunOneTest($test) {
$config = $this->getComputedConfiguration();
if ($config[self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES]) {
LiskDAO::beginIsolateAllLiskEffectsToTransactions();
}
}
protected function didRunOneTest($test) {
$config = $this->getComputedConfiguration();
if ($config[self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES]) {
LiskDAO::endIsolateAllLiskEffectsToTransactions();
}
}
protected function newStorageFixture() {
$bytes = Filesystem::readRandomCharacters(24);
$name = self::NAMESPACE_PREFIX.$bytes;
return new PhabricatorStorageFixtureScopeGuard($name);
}
/**
* Returns an integer seed to use when building unique identifiers (e.g.,
* non-colliding usernames). The seed is unstable and its value will change
* between test runs, so your tests must not rely on it.
*
* @return int A unique integer.
*/
protected function getNextObjectSeed() {
self::$storageFixtureObjectSeed += mt_rand(1, 100);
return self::$storageFixtureObjectSeed;
}
protected function generateNewTestUser() {
$seed = $this->getNextObjectSeed();
$user = id(new PhabricatorUser())
->setRealName(pht('Test User %s', $seed))
->setUserName("test{$seed}")
->setIsApproved(1);
$email = id(new PhabricatorUserEmail())
->setAddress("testuser{$seed}@example.com")
->setIsVerified(1);
$editor = new PhabricatorUserEditor();
$editor->setActor($user);
$editor->createNewUser($user, $email);
+ // When creating a new test user, we prefill their setting cache as empty.
+ // This is a little more efficient than doing a query to load the empty
+ // settings.
+ $user->attachRawCacheData(
+ array(
+ PhabricatorUserPreferencesCacheType::KEY_PREFERENCES => '[]',
+ ));
+
return $user;
}
/**
* Throws unless tests are currently executing. This method can be used to
* guard code which is specific to unit tests and should not normally be
* reachable.
*
* If tests aren't currently being executed, throws an exception.
*/
public static function assertExecutingUnitTests() {
if (!self::$testsAreRunning) {
throw new Exception(
pht(
'Executing test code outside of test execution! '.
'This code path can only be run during unit tests.'));
}
}
protected function requireBinaryForTest($binary) {
if (!Filesystem::binaryExists($binary)) {
$this->assertSkipped(
pht(
'No binary "%s" found on this system, skipping test.',
$binary));
}
}
protected function newContentSource() {
return PhabricatorContentSource::newForSource(
PhabricatorUnitTestContentSource::SOURCECONST);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Mar 16, 11:04 PM (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
72211
Default Alt Text
(108 KB)

Event Timeline