diff --git a/src/applications/people/controller/PhabricatorPeopleProfileController.php b/src/applications/people/controller/PhabricatorPeopleProfileController.php
index a0b84aa705..a15797808d 100644
--- a/src/applications/people/controller/PhabricatorPeopleProfileController.php
+++ b/src/applications/people/controller/PhabricatorPeopleProfileController.php
@@ -1,131 +1,130 @@
username = idx($data, 'username');
}
public function processRequest() {
$viewer = $this->getRequest()->getUser();
$user = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withUsernames(array($this->username))
->executeOne();
if (!$user) {
return new Aphront404Response();
}
require_celerity_resource('phabricator-profile-css');
$profile = $user->loadUserProfile();
$username = phutil_escape_uri($user->getUserName());
$picture = $user->loadProfileImageURI();
$header = id(new PhabricatorHeaderView())
->setHeader($user->getUserName().' ('.$user->getRealName().')')
->setSubheader($profile->getTitle())
->setImage($picture);
$actions = id(new PhabricatorActionListView())
->setObject($user)
->setObjectURI($this->getRequest()->getRequestURI())
->setUser($viewer);
$can_edit = ($user->getPHID() == $viewer->getPHID());
$actions->addAction(
id(new PhabricatorActionView())
->setIcon('edit')
->setName(pht('Edit Profile'))
->setHref($this->getApplicationURI('editprofile/'.$user->getID().'/'))
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit));
$actions->addAction(
id(new PhabricatorActionView())
->setIcon('image')
->setName(pht('Edit Profile Picture'))
->setHref($this->getApplicationURI('picture/'.$user->getID().'/'))
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit));
if ($viewer->getIsAdmin()) {
$actions->addAction(
id(new PhabricatorActionView())
->setIcon('blame')
->setName(pht('Administrate User'))
->setHref($this->getApplicationURI('edit/'.$user->getID().'/')));
}
$properties = $this->buildPropertyView($user);
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addCrumb(
id(new PhabricatorCrumbView())
->setName($user->getUsername()));
$feed = $this->renderUserFeed($user);
return $this->buildApplicationPage(
array(
$crumbs,
$header,
$actions,
$properties,
$feed,
),
array(
'title' => $user->getUsername(),
'device' => true,
'dust' => true,
));
}
private function buildPropertyView(PhabricatorUser $user) {
$viewer = $this->getRequest()->getUser();
$view = id(new PhabricatorPropertyListView())
->setUser($viewer)
->setObject($user);
- $fields = PhabricatorCustomField::getObjectFields(
+ $field_list = PhabricatorCustomField::getObjectFields(
$user,
PhabricatorCustomField::ROLE_VIEW);
- $field_list = new PhabricatorCustomFieldList($fields);
$field_list->appendFieldsToPropertyList($user, $viewer, $view);
return $view;
}
private function renderUserFeed(PhabricatorUser $user) {
$viewer = $this->getRequest()->getUser();
$query = new PhabricatorFeedQuery();
$query->setFilterPHIDs(
array(
$user->getPHID(),
));
$query->setLimit(100);
$query->setViewer($viewer);
$stories = $query->execute();
$builder = new PhabricatorFeedBuilder($stories);
$builder->setUser($viewer);
$view = $builder->buildView();
return hsprintf(
'
%s
',
$view->render());
}
}
diff --git a/src/applications/people/controller/PhabricatorPeopleProfileEditController.php b/src/applications/people/controller/PhabricatorPeopleProfileEditController.php
index 9f752257e0..cf75625047 100644
--- a/src/applications/people/controller/PhabricatorPeopleProfileEditController.php
+++ b/src/applications/people/controller/PhabricatorPeopleProfileEditController.php
@@ -1,95 +1,88 @@
id = $data['id'];
}
public function processRequest() {
$request = $this->getRequest();
$viewer = $request->getUser();
$user = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withIDs(array($this->id))
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->executeOne();
if (!$user) {
return new Aphront404Response();
}
$profile_uri = '/p/'.$user->getUsername().'/';
- $fields = PhabricatorCustomField::getObjectFields(
+ $field_list = PhabricatorCustomField::getObjectFields(
$user,
PhabricatorCustomField::ROLE_EDIT);
- $field_list = new PhabricatorCustomFieldList($fields);
+ $field_list->readFieldsFromStorage($user);
if ($request->isFormPost()) {
- $xactions = array();
- foreach ($fields as $field) {
- $field->readValueFromRequest($request);
- $xactions[] = id(new PhabricatorUserTransaction())
- ->setTransactionType(PhabricatorTransactions::TYPE_CUSTOMFIELD)
- ->setMetadataValue('customfield:key', $field->getFieldKey())
- ->setNewValue($field->getNewValueForApplicationTransactions());
- }
+ $xactions = $field_list->buildFieldTransactionsFromRequest(
+ new PhabricatorUserTransaction(),
+ $request);
$editor = id(new PhabricatorUserProfileEditor())
->setActor($viewer)
->setContentSource(
PhabricatorContentSource::newFromRequest($request))
->setContinueOnNoEffect(true);
$editor->applyTransactions($user, $xactions);
return id(new AphrontRedirectResponse())->setURI($profile_uri);
- } else {
- $field_list->readFieldsFromStorage($user);
}
$title = pht('Edit Profile');
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addCrumb(
id(new PhabricatorCrumbView())
->setName($user->getUsername())
->setHref($profile_uri));
$crumbs->addCrumb(
id(new PhabricatorCrumbView())
->setName($title));
$form = id(new AphrontFormView())
->setUser($viewer);
$field_list->appendFieldsToForm($form);
$form
->appendChild(
id(new AphrontFormSubmitControl())
->addCancelButton($profile_uri)
->setValue(pht('Save Profile')));
return $this->buildApplicationPage(
array(
$crumbs,
$form,
),
array(
'title' => $title,
'device' => true,
'dust' => true,
));
}
}
diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php
index 7e3b1b8417..7bea2f0441 100644
--- a/src/applications/people/storage/PhabricatorUser.php
+++ b/src/applications/people/storage/PhabricatorUser.php
@@ -1,858 +1,858 @@
timezoneIdentifier,
date_default_timezone_get());
// Make sure these return booleans.
case 'isAdmin':
return (bool)$this->isAdmin;
case 'isDisabled':
return (bool)$this->isDisabled;
case 'isSystemAgent':
return (bool)$this->isSystemAgent;
default:
return parent::readField($field);
}
}
public function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_PARTIAL_OBJECTS => true,
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorPeoplePHIDTypeUser::TYPECONST);
}
public function setPassword(PhutilOpaqueEnvelope $envelope) {
if (!$this->getPHID()) {
throw new Exception(
"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(mt_rand()));
$hash = $this->hashPassword($envelope);
$this->setPasswordHash($hash);
}
return $this;
}
// To satisfy PhutilPerson.
public function getSex() {
return $this->sex;
}
public function getTranslation() {
try {
if ($this->translation &&
class_exists($this->translation) &&
is_subclass_of($this->translation, 'PhabricatorTranslation')) {
return $this->translation;
}
} catch (PhutilMissingSymbolException $ex) {
return null;
}
return null;
}
public function isLoggedIn() {
return !($this->getPHID() === null);
}
public function save() {
if (!$this->getConduitCertificate()) {
$this->setConduitCertificate($this->generateConduitCertificate());
}
$result = parent::save();
if ($this->profile) {
$this->profile->save();
}
$this->updateNameTokens();
id(new PhabricatorSearchIndexer())
->indexDocumentByPHID($this->getPHID());
return $result;
}
private function generateConduitCertificate() {
return Filesystem::readRandomCharacters(255);
}
public function comparePassword(PhutilOpaqueEnvelope $envelope) {
if (!strlen($envelope->openEnvelope())) {
return false;
}
if (!strlen($this->getPasswordHash())) {
return false;
}
$password_hash = $this->hashPassword($envelope);
return ($password_hash === $this->getPasswordHash());
}
private function hashPassword(PhutilOpaqueEnvelope $envelope) {
$hash = $this->getUsername().
$envelope->openEnvelope().
$this->getPHID().
$this->getPasswordSalt();
for ($ii = 0; $ii < 1000; $ii++) {
$hash = md5($hash);
}
return $hash;
}
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);
}
/**
* @phutil-external-symbol class PhabricatorStartup
*/
public function getCSRFToken() {
$salt = PhabricatorStartup::getGlobal('csrf.salt');
if (!$salt) {
$salt = Filesystem::readRandomCharacters(self::CSRF_SALT_LENGTH);
PhabricatorStartup::setGlobal('csrf.salt', $salt);
}
// Generate a token hash to mitigate BREACH attacks against SSL. See
// discussion in T3684.
$token = $this->getRawCSRFToken();
$hash = PhabricatorHash::digest($token, $salt);
return 'B@'.$salt.substr($hash, 0, self::CSRF_TOKEN_LENGTH);
}
public function validateCSRFToken($token) {
if (!$this->getPHID()) {
return true;
}
$salt = null;
$version = 'plain';
// This is a BREACH-mitigating token. See T3684.
$breach_prefix = self::CSRF_BREACH_PREFIX;
$breach_prelen = strlen($breach_prefix);
if (!strncmp($token, $breach_prefix, $breach_prelen)) {
$version = 'breach';
$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);
switch ($version) {
// TODO: We can remove this after the BREACH version has been in the
// wild for a while.
case 'plain':
if ($token == $valid) {
return true;
}
break;
case 'breach':
$digest = PhabricatorHash::digest($valid, $salt);
if (substr($digest, 0, self::CSRF_TOKEN_LENGTH) == $token) {
return true;
}
break;
default:
throw new Exception("Unknown CSRF token format!");
}
}
return false;
}
private function generateToken($epoch, $frequency, $key, $len) {
$time_block = floor($epoch / $frequency);
$vec = $this->getPHID().$this->getPasswordHash().$key.$time_block;
return substr(PhabricatorHash::digest($vec), 0, $len);
}
/**
* Issue a new session key to this user. 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 string Session type, like "web".
* @return string Newly generated session key.
*/
public function establishSession($session_type) {
$conn_w = $this->establishConnection('w');
if (strpos($session_type, '-') !== false) {
throw new Exception("Session type must not contain hyphen ('-')!");
}
// We allow multiple sessions of the same type, so when a caller requests
// a new session of type "web", we give them the first available session in
// "web-1", "web-2", ..., "web-N", up to some configurable limit. If none
// of these sessions is available, we overwrite the oldest session and
// reissue a new one in its place.
$session_limit = 1;
switch ($session_type) {
case 'web':
$session_limit = PhabricatorEnv::getEnvConfig('auth.sessions.web');
break;
case 'conduit':
$session_limit = PhabricatorEnv::getEnvConfig('auth.sessions.conduit');
break;
default:
throw new Exception("Unknown session type '{$session_type}'!");
}
$session_limit = (int)$session_limit;
if ($session_limit <= 0) {
throw new Exception(
"Session limit for '{$session_type}' must be at least 1!");
}
// NOTE: Session establishment is sensitive to race conditions, as when
// piping `arc` to `arc`:
//
// arc export ... | arc paste ...
//
// To avoid this, we overwrite an old session only if it hasn't been
// re-established since we read it.
// Consume entropy to generate a new session key, forestalling the eventual
// heat death of the universe.
$session_key = Filesystem::readRandomCharacters(40);
// Load all the currently active sessions.
$sessions = queryfx_all(
$conn_w,
'SELECT type, sessionKey, sessionStart FROM %T
WHERE userPHID = %s AND type LIKE %>',
PhabricatorUser::SESSION_TABLE,
$this->getPHID(),
$session_type.'-');
$sessions = ipull($sessions, null, 'type');
$sessions = isort($sessions, 'sessionStart');
$existing_sessions = array_keys($sessions);
// UNGUARDED WRITES: Logging-in users don't have CSRF stuff yet.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$retries = 0;
while (true) {
// Choose which 'type' we'll actually establish, i.e. what number we're
// going to append to the basic session type. To do this, just check all
// the numbers sequentially until we find an available session.
$establish_type = null;
for ($ii = 1; $ii <= $session_limit; $ii++) {
$try_type = $session_type.'-'.$ii;
if (!in_array($try_type, $existing_sessions)) {
$establish_type = $try_type;
$expect_key = PhabricatorHash::digest($session_key);
$existing_sessions[] = $try_type;
// Ensure the row exists so we can issue an update below. We don't
// care if we race here or not.
queryfx(
$conn_w,
'INSERT IGNORE INTO %T (userPHID, type, sessionKey, sessionStart)
VALUES (%s, %s, %s, 0)',
self::SESSION_TABLE,
$this->getPHID(),
$establish_type,
PhabricatorHash::digest($session_key));
break;
}
}
// If we didn't find an available session, choose the oldest session and
// overwrite it.
if (!$establish_type) {
$oldest = reset($sessions);
$establish_type = $oldest['type'];
$expect_key = $oldest['sessionKey'];
}
// This is so that we'll only overwrite the session if it hasn't been
// refreshed since we read it. If it has, the session key will be
// different and we know we're racing other processes. Whichever one
// won gets the session, we go back and try again.
queryfx(
$conn_w,
'UPDATE %T SET sessionKey = %s, sessionStart = UNIX_TIMESTAMP()
WHERE userPHID = %s AND type = %s AND sessionKey = %s',
self::SESSION_TABLE,
PhabricatorHash::digest($session_key),
$this->getPHID(),
$establish_type,
$expect_key);
if ($conn_w->getAffectedRows()) {
// The update worked, so the session is valid.
break;
} else {
// We know this just got grabbed, so don't try it again.
unset($sessions[$establish_type]);
}
if (++$retries > $session_limit) {
throw new Exception("Failed to establish a session!");
}
}
$log = PhabricatorUserLog::newLog(
$this,
$this,
PhabricatorUserLog::ACTION_LOGIN);
$log->setDetails(
array(
'session_type' => $session_type,
'session_issued' => $establish_type,
));
$log->setSession($session_key);
$log->save();
return $session_key;
}
public function destroySession($session_key) {
$conn_w = $this->establishConnection('w');
queryfx(
$conn_w,
'DELETE FROM %T WHERE userPHID = %s AND sessionKey = %s',
self::SESSION_TABLE,
$this->getPHID(),
PhabricatorHash::digest($session_key));
}
private function generateEmailToken(
PhabricatorUserEmail $email,
$offset = 0) {
$key = implode(
'-',
array(
PhabricatorEnv::getEnvConfig('phabricator.csrf-key'),
$this->getPHID(),
$email->getVerificationCode(),
));
return $this->generateToken(
time() + ($offset * self::EMAIL_CYCLE_FREQUENCY),
self::EMAIL_CYCLE_FREQUENCY,
$key,
self::EMAIL_TOKEN_LENGTH);
}
public function validateEmailToken(
PhabricatorUserEmail $email,
$token) {
for ($ii = -1; $ii <= 1; $ii++) {
$valid = $this->generateEmailToken($email, $ii);
if ($token == $valid) {
return true;
}
}
return false;
}
public function getEmailLoginURI(PhabricatorUserEmail $email = null) {
if (!$email) {
$email = $this->loadPrimaryEmail();
if (!$email) {
throw new Exception("User has no primary email!");
}
}
$token = $this->generateEmailToken($email);
$uri = PhabricatorEnv::getProductionURI('/login/etoken/'.$token.'/');
$uri = new PhutilURI($uri);
return $uri->alter('email', $email->getAddress());
}
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) {
$profile_dao->setUserPHID($this->getPHID());
$this->profile = $profile_dao;
}
return $this->profile;
}
public function loadPrimaryEmailAddress() {
$email = $this->loadPrimaryEmail();
if (!$email) {
throw new Exception("User has no primary email address!");
}
return $email->getAddress();
}
public function loadPrimaryEmail() {
return $this->loadOneRelative(
new PhabricatorUserEmail(),
'userPHID',
'getPHID',
'(isPrimary = 1)');
}
public function loadPreferences() {
if ($this->preferences) {
return $this->preferences;
}
$preferences = id(new PhabricatorUserPreferences())->loadOneWhere(
'userPHID = %s',
$this->getPHID());
if (!$preferences) {
$preferences = new PhabricatorUserPreferences();
$preferences->setUserPHID($this->getPHID());
$default_dict = array(
PhabricatorUserPreferences::PREFERENCE_TITLES => 'glyph',
PhabricatorUserPreferences::PREFERENCE_EDITOR => '',
PhabricatorUserPreferences::PREFERENCE_MONOSPACED => '',
PhabricatorUserPreferences::PREFERENCE_DARK_CONSOLE => 0);
$preferences->setPreferences($default_dict);
}
$this->preferences = $preferences;
return $preferences;
}
public function loadEditorLink($path, $line, $callsign) {
$editor = $this->loadPreferences()->getPreference(
PhabricatorUserPreferences::PREFERENCE_EDITOR);
if (is_array($path)) {
$multiedit = $this->loadPreferences()->getPreference(
PhabricatorUserPreferences::PREFERENCE_MULTIEDIT);
switch ($multiedit) {
case '':
$path = implode(' ', $path);
break;
case 'disable':
return null;
}
}
if ($editor) {
return strtr($editor, array(
'%%' => '%',
'%f' => phutil_escape_uri($path),
'%l' => phutil_escape_uri($line),
'%r' => phutil_escape_uri($callsign),
));
}
}
private static function tokenizeName($name) {
if (function_exists('mb_strtolower')) {
$name = mb_strtolower($name, 'UTF-8');
} else {
$name = strtolower($name);
}
$name = trim($name);
if (!strlen($name)) {
return array();
}
return preg_split('/\s+/', $name);
}
/**
* 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() {
$tokens = array_merge(
self::tokenizeName($this->getRealName()),
self::tokenizeName($this->getUserName()));
$tokens = array_unique($tokens);
$table = self::NAMETOKEN_TABLE;
$conn_w = $this->establishConnection('w');
$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) {
$admin_username = $admin->getUserName();
$admin_realname = $admin->getRealName();
$user_username = $this->getUserName();
$is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
$base_uri = PhabricatorEnv::getProductionURI('/');
$uri = $this->getEmailLoginURI();
$body = <<addTos(array($this->getPHID()))
->setSubject('[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 (PhabricatorAuthProviderPassword::getPasswordProvider()) {
$uri = $this->getEmailLoginURI();
$password_instructions = <<addTos(array($this->getPHID()))
->setSubject('[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_-]$/', $username);
}
public static function getDefaultProfileImageURI() {
return celerity_get_resource_uri('/rsrc/image/avatar.png');
}
public function attachStatus(PhabricatorUserStatus $status) {
$this->status = $status;
return $this;
}
public function getStatus() {
$this->assertAttached($this->status);
return $this->status;
}
public function hasStatus() {
return $this->status !== self::ATTACHABLE;
}
public function attachProfileImageURI($uri) {
$this->profileImage = $uri;
return $this;
}
public function loadProfileImageURI() {
if ($this->profileImage) {
return $this->profileImage;
}
$src_phid = $this->getProfileImagePHID();
if ($src_phid) {
$file = id(new PhabricatorFile())->loadOneWhere('phid = %s', $src_phid);
if ($file) {
$this->profileImage = $file->getBestURI();
}
}
if (!$this->profileImage) {
$this->profileImage = self::getDefaultProfileImageURI();
}
return $this->profileImage;
}
public function getFullName() {
return $this->getUsername().' ('.$this->getRealName().')';
}
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());
}
/* -( 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;
}
/* -( 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:
return PhabricatorPolicies::POLICY_NOONE;
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return $this->getPHID() && ($viewer->getPHID() === $this->getPHID());
}
/* -( PhabricatorCustomFieldInterface )------------------------------------ */
public function getCustomFieldSpecificationForRole($role) {
return PhabricatorEnv::getEnvConfig('user.fields');
}
public function getCustomFieldBaseClass() {
return 'PhabricatorUserCustomField';
}
public function getCustomFields($role) {
if (idx($this->customFields, $role) === null) {
PhabricatorCustomField::raiseUnattachedException($this, $role);
}
return $this->customFields[$role];
}
- public function attachCustomFields($role, array $fields) {
- $this->customFields[$role] = $fields;
+ public function attachCustomFields($role, PhabricatorCustomFieldList $list) {
+ $this->customFields[$role] = $list;
return $this;
}
}
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
index 8369e0712b..50490f6a82 100644
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -1,1349 +1,1354 @@
continueOnNoEffect = $continue;
return $this;
}
public function getContinueOnNoEffect() {
return $this->continueOnNoEffect;
}
/**
* Not strictly necessary, but reply handlers ideally set this value to
* make email threading work better.
*/
public function setParentMessageID($parent_message_id) {
$this->parentMessageID = $parent_message_id;
return $this;
}
public function getParentMessageID() {
return $this->parentMessageID;
}
protected function getIsNewObject() {
return $this->isNewObject;
}
protected function getMentionedPHIDs() {
return $this->mentionedPHIDs;
}
public function setIsPreview($is_preview) {
$this->isPreview = $is_preview;
return $this;
}
public function getIsPreview() {
return $this->isPreview;
}
public function getTransactionTypes() {
$types = array();
if ($this->object instanceof PhabricatorSubscribableInterface) {
$types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS;
}
if ($this->object instanceof PhabricatorCustomFieldInterface) {
$types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD;
}
return $types;
}
private function adjustTransactionValues(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$old = $this->getTransactionOldValue($object, $xaction);
$xaction->setOldValue($old);
$new = $this->getTransactionNewValue($object, $xaction);
$xaction->setNewValue($new);
}
private function getTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return array_values($this->subscribers);
case PhabricatorTransactions::TYPE_VIEW_POLICY:
return $object->getViewPolicy();
case PhabricatorTransactions::TYPE_EDIT_POLICY:
return $object->getEditPolicy();
case PhabricatorTransactions::TYPE_EDGE:
$edge_type = $xaction->getMetadataValue('edge:type');
if (!$edge_type) {
throw new Exception("Edge transaction has no 'edge:type'!");
}
$old_edges = array();
if ($object->getPHID()) {
$edge_src = $object->getPHID();
$old_edges = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($edge_src))
->withEdgeTypes(array($edge_type))
->needEdgeData(true)
->execute();
$old_edges = $old_edges[$edge_src][$edge_type];
}
return $old_edges;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
- $field = $this->getCustomFieldForTransaction($object, $xaction);
- return $field->getOldValueForApplicationTransactions();
+ // NOTE: Custom fields have their old value pre-populated when they are
+ // built by PhabricatorCustomFieldList.
+ return $xaction->getOldValue();
default:
return $this->getCustomTransactionOldValue($object, $xaction);
}
}
private function getTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return $this->getPHIDTransactionNewValue($xaction);
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
return $xaction->getNewValue();
case PhabricatorTransactions::TYPE_EDGE:
return $this->getEdgeTransactionNewValue($xaction);
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->getNewValueFromApplicationTransactions($xaction);
default:
return $this->getCustomTransactionNewValue($object, $xaction);
}
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception("Capability not supported!");
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception("Capability not supported!");
}
protected function transactionHasEffect(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
return $xaction->hasComment();
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->getApplicationTransactionHasEffect($xaction);
}
return ($xaction->getOldValue() !== $xaction->getNewValue());
}
protected function shouldApplyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function applyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
throw new Exception('Not implemented.');
}
private function applyInternalEffects(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_VIEW_POLICY:
$object->setViewPolicy($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_EDIT_POLICY:
$object->setEditPolicy($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->applyApplicationTransactionInternalEffects($xaction);
}
return $this->applyCustomInternalTransaction($object, $xaction);
}
private function applyExternalEffects(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$subeditor = id(new PhabricatorSubscriptionsEditor())
->setObject($object)
->setActor($this->requireActor());
$old_map = array_fuse($xaction->getOldValue());
$new_map = array_fuse($xaction->getNewValue());
$subeditor->unsubscribe(
array_keys(
array_diff_key($old_map, $new_map)));
$subeditor->subscribeExplicit(
array_keys(
array_diff_key($new_map, $old_map)));
$subeditor->save();
// for the rest of these edits, subscribers should include those just
// added as well as those just removed.
$subscribers = array_unique(array_merge(
$this->subscribers,
$xaction->getOldValue(),
$xaction->getNewValue()));
$this->subscribers = $subscribers;
break;
case PhabricatorTransactions::TYPE_EDGE:
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$src = $object->getPHID();
$type = $xaction->getMetadataValue('edge:type');
foreach ($new as $dst_phid => $edge) {
$new[$dst_phid]['src'] = $src;
}
$editor = id(new PhabricatorEdgeEditor())
->setActor($this->getActor());
foreach ($old as $dst_phid => $edge) {
if (!empty($new[$dst_phid])) {
if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
continue;
}
}
$editor->removeEdge($src, $type, $dst_phid);
}
foreach ($new as $dst_phid => $edge) {
if (!empty($old[$dst_phid])) {
if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
continue;
}
}
$data = array(
'data' => $edge['data'],
);
$editor->addEdge($src, $type, $dst_phid, $data);
}
$editor->save();
break;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->applyApplicationTransactionExternalEffects($xaction);
}
return $this->applyCustomExternalTransaction($object, $xaction);
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception("Capability not supported!");
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception("Capability not supported!");
}
protected function applyFinalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
}
public function setContentSource(PhabricatorContentSource $content_source) {
$this->contentSource = $content_source;
return $this;
}
public function setContentSourceFromRequest(AphrontRequest $request) {
return $this->setContentSource(
PhabricatorContentSource::newFromRequest($request));
}
public function getContentSource() {
return $this->contentSource;
}
final public function applyTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$this->object = $object;
$this->xactions = $xactions;
$this->isNewObject = ($object->getPHID() === null);
$this->validateEditParameters($object, $xactions);
$actor = $this->requireActor();
$this->loadSubscribers($object);
$xactions = $this->applyImplicitCC($object, $xactions);
$mention_xaction = $this->buildMentionTransaction($object, $xactions);
if ($mention_xaction) {
$xactions[] = $mention_xaction;
}
$xactions = $this->combineTransactions($xactions);
foreach ($xactions as $xaction) {
// TODO: This needs to be more sophisticated once we have meta-policies.
$xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
$xaction->setEditPolicy($actor->getPHID());
$xaction->setAuthorPHID($actor->getPHID());
$xaction->setContentSource($this->getContentSource());
}
$is_preview = $this->getIsPreview();
$read_locking = false;
$transaction_open = false;
if (!$is_preview) {
if ($object->getID()) {
foreach ($xactions as $xaction) {
// If any of the transactions require a read lock, hold one and
// reload the object. We need to do this fairly early so that the
// call to `adjustTransactionValues()` (which populates old values)
// is based on the synchronized state of the object, which may differ
// from the state when it was originally loaded.
if ($this->shouldReadLock($object, $xaction)) {
$object->openTransaction();
$object->beginReadLocking();
$transaction_open = true;
$read_locking = true;
$object->reload();
break;
}
}
}
if ($this->shouldApplyInitialEffects($object, $xactions)) {
if (!$transaction_open) {
$object->openTransaction();
$transaction_open = true;
}
}
}
if ($this->shouldApplyInitialEffects($object, $xactions)) {
$this->applyInitialEffects($object, $xactions);
}
foreach ($xactions as $xaction) {
$this->adjustTransactionValues($object, $xaction);
}
$xactions = $this->filterTransactions($object, $xactions);
if (!$xactions) {
if ($read_locking) {
$object->endReadLocking();
$read_locking = false;
}
if ($transaction_open) {
$object->killTransaction();
$transaction_open = false;
}
return array();
}
$xactions = $this->sortTransactions($xactions);
if ($is_preview) {
$this->loadHandles($xactions);
return $xactions;
}
$comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
->setActor($actor)
->setContentSource($this->getContentSource());
if (!$transaction_open) {
$object->openTransaction();
}
foreach ($xactions as $xaction) {
$this->applyInternalEffects($object, $xaction);
}
$object->save();
foreach ($xactions as $xaction) {
$xaction->setObjectPHID($object->getPHID());
if ($xaction->getComment()) {
$xaction->setPHID($xaction->generatePHID());
$comment_editor->applyEdit($xaction, $xaction->getComment());
} else {
$xaction->save();
}
}
foreach ($xactions as $xaction) {
$this->applyExternalEffects($object, $xaction);
}
$this->applyFinalEffects($object, $xactions);
if ($read_locking) {
$object->endReadLocking();
$read_locking = false;
}
$object->saveTransaction();
$this->loadHandles($xactions);
$mail = null;
if ($this->supportsMail()) {
$mail = $this->sendMail($object, $xactions);
}
if ($this->supportsSearch()) {
id(new PhabricatorSearchIndexer())
->indexDocumentByPHID($object->getPHID());
}
if ($this->supportsFeed()) {
$mailed = array();
if ($mail) {
$mailed = $mail->buildRecipientList();
}
$this->publishFeedStory(
$object,
$xactions,
$mailed);
}
$this->didApplyTransactions($xactions);
return $xactions;
}
protected function didApplyTransactions(array $xactions) {
// Hook for subclasses.
return;
}
/**
* Determine if the editor should hold a read lock on the object while
* applying a transaction.
*
* If the editor does not hold a lock, two editors may read an object at the
* same time, then apply their changes without any synchronization. For most
* transactions, this does not matter much. However, it is important for some
* transactions. For example, if an object has a transaction count on it, both
* editors may read the object with `count = 23`, then independently update it
* and save the object with `count = 24` twice. This will produce the wrong
* state: the object really has 25 transactions, but the count is only 24.
*
* Generally, transactions fall into one of four buckets:
*
* - Append operations: Actions like adding a comment to an object purely
* add information to its state, and do not depend on the current object
* state in any way. These transactions never need to hold locks.
* - Overwrite operations: Actions like changing the title or description
* of an object replace the current value with a new value, so the end
* state is consistent without a lock. We currently do not lock these
* transactions, although we may in the future.
* - Edge operations: Edge and subscription operations have internal
* synchronization which limits the damage race conditions can cause.
* We do not currently lock these transactions, although we may in the
* future.
* - Update operations: Actions like incrementing a count on an object.
* These operations generally should use locks, unless it is not
* important that the state remain consistent in the presence of races.
*
* @param PhabricatorLiskDAO Object being updated.
* @param PhabricatorApplicationTransaction Transaction being applied.
* @return bool True to synchronize the edit with a lock.
*/
protected function shouldReadLock(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return false;
}
private function loadHandles(array $xactions) {
$phids = array();
foreach ($xactions as $key => $xaction) {
$phids[$key] = $xaction->getRequiredHandlePHIDs();
}
$handles = array();
$merged = array_mergev($phids);
if ($merged) {
$handles = id(new PhabricatorObjectHandleData($merged))
->setViewer($this->requireActor())
->loadHandles();
}
foreach ($xactions as $key => $xaction) {
$xaction->setHandles(array_select_keys($handles, $phids[$key]));
}
}
private function loadSubscribers(PhabricatorLiskDAO $object) {
if ($object->getPHID() &&
($object instanceof PhabricatorSubscribableInterface)) {
$subs = PhabricatorSubscribersQuery::loadSubscribersForPHID(
$object->getPHID());
$this->subscribers = array_fuse($subs);
} else {
$this->subscribers = array();
}
}
private function validateEditParameters(
PhabricatorLiskDAO $object,
array $xactions) {
if (!$this->getContentSource()) {
throw new Exception(
"Call setContentSource() before applyTransactions()!");
}
// Do a bunch of sanity checks that the incoming transactions are fresh.
// They should be unsaved and have only "transactionType" and "newValue"
// set.
$types = array_fill_keys($this->getTransactionTypes(), true);
assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
foreach ($xactions as $xaction) {
if ($xaction->getPHID() || $xaction->getID()) {
throw new Exception(
"You can not apply transactions which already have IDs/PHIDs!");
}
if ($xaction->getObjectPHID()) {
throw new Exception(
"You can not apply transactions which already have objectPHIDs!");
}
if ($xaction->getAuthorPHID()) {
throw new Exception(
"You can not apply transactions which already have authorPHIDs!");
}
if ($xaction->getCommentPHID()) {
throw new Exception(
"You can not apply transactions which already have commentPHIDs!");
}
if ($xaction->getCommentVersion() !== 0) {
throw new Exception(
"You can not apply transactions which already have commentVersions!");
}
- if ($xaction->getOldValue() !== null) {
- throw new Exception(
- "You can not apply transactions which already have oldValue!");
+
+ $custom_field_type = PhabricatorTransactions::TYPE_CUSTOMFIELD;
+ if ($xaction->getTransactionType() != $custom_field_type) {
+ if ($xaction->getOldValue() !== null) {
+ throw new Exception(
+ "You can not apply transactions which already have oldValue!");
+ }
}
$type = $xaction->getTransactionType();
if (empty($types[$type])) {
throw new Exception("Transaction has unknown type '{$type}'.");
}
}
// The actor must have permission to view and edit the object.
$actor = $this->requireActor();
PhabricatorPolicyFilter::requireCapability(
$actor,
$xaction,
PhabricatorPolicyCapability::CAN_VIEW);
PhabricatorPolicyFilter::requireCapability(
$actor,
$xaction,
PhabricatorPolicyCapability::CAN_EDIT);
}
private function buildMentionTransaction(
PhabricatorLiskDAO $object,
array $xactions) {
if (!($object instanceof PhabricatorSubscribableInterface)) {
return null;
}
$texts = array();
foreach ($xactions as $xaction) {
$texts[] = $this->getMentionableTextsFromTransaction($xaction);
}
$texts = array_mergev($texts);
$phids = PhabricatorMarkupEngine::extractPHIDsFromMentions($texts);
$this->mentionedPHIDs = $phids;
if ($object->getPHID()) {
// Don't try to subscribe already-subscribed mentions: we want to generate
// a dialog about an action having no effect if the user explicitly adds
// existing CCs, but not if they merely mention existing subscribers.
$phids = array_diff($phids, $this->subscribers);
}
foreach ($phids as $key => $phid) {
if ($object->isAutomaticallySubscribed($phid)) {
unset($phids[$key]);
}
}
$phids = array_values($phids);
if (!$phids) {
return null;
}
$xaction = newv(get_class(head($xactions)), array());
$xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
$xaction->setNewValue(array('+' => $phids));
return $xaction;
}
protected function getMentionableTextsFromTransaction(
PhabricatorApplicationTransaction $transaction) {
$texts = array();
if ($transaction->getComment()) {
$texts[] = $transaction->getComment()->getContent();
}
return $texts;
}
protected function mergeTransactions(
PhabricatorApplicationTransaction $u,
PhabricatorApplicationTransaction $v) {
$type = $u->getTransactionType();
switch ($type) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return $this->mergePHIDOrEdgeTransactions($u, $v);
case PhabricatorTransactions::TYPE_EDGE:
$u_type = $u->getMetadataValue('edge:type');
$v_type = $v->getMetadataValue('edge:type');
if ($u_type == $v_type) {
return $this->mergePHIDOrEdgeTransactions($u, $v);
}
return null;
}
// By default, do not merge the transactions.
return null;
}
/**
* Attempt to combine similar transactions into a smaller number of total
* transactions. For example, two transactions which edit the title of an
* object can be merged into a single edit.
*/
private function combineTransactions(array $xactions) {
$stray_comments = array();
$result = array();
$types = array();
foreach ($xactions as $key => $xaction) {
$type = $xaction->getTransactionType();
if (isset($types[$type])) {
foreach ($types[$type] as $other_key) {
$merged = $this->mergeTransactions($result[$other_key], $xaction);
if ($merged) {
$result[$other_key] = $merged;
if ($xaction->getComment() &&
($xaction->getComment() !== $merged->getComment())) {
$stray_comments[] = $xaction->getComment();
}
if ($result[$other_key]->getComment() &&
($result[$other_key]->getComment() !== $merged->getComment())) {
$stray_comments[] = $result[$other_key]->getComment();
}
// Move on to the next transaction.
continue 2;
}
}
}
$result[$key] = $xaction;
$types[$type][] = $key;
}
// If we merged any comments away, restore them.
foreach ($stray_comments as $comment) {
$xaction = newv(get_class(head($result)), array());
$xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT);
$xaction->setComment($comment);
$result[] = $xaction;
}
return array_values($result);
}
protected function mergePHIDOrEdgeTransactions(
PhabricatorApplicationTransaction $u,
PhabricatorApplicationTransaction $v) {
$result = $u->getNewValue();
foreach ($v->getNewValue() as $key => $value) {
$result[$key] = array_merge($value, idx($result, $key, array()));
}
$u->setNewValue($result);
return $u;
}
protected function getPHIDTransactionNewValue(
PhabricatorApplicationTransaction $xaction) {
$old = array_fuse($xaction->getOldValue());
$new = $xaction->getNewValue();
$new_add = idx($new, '+', array());
unset($new['+']);
$new_rem = idx($new, '-', array());
unset($new['-']);
$new_set = idx($new, '=', null);
if ($new_set !== null) {
$new_set = array_fuse($new_set);
}
unset($new['=']);
if ($new) {
throw new Exception(
"Invalid 'new' value for PHID transaction. Value should contain only ".
"keys '+' (add PHIDs), '-' (remove PHIDs) and '=' (set PHIDS).");
}
$result = array();
foreach ($old as $phid) {
if ($new_set !== null && empty($new_set[$phid])) {
continue;
}
$result[$phid] = $phid;
}
if ($new_set !== null) {
foreach ($new_set as $phid) {
$result[$phid] = $phid;
}
}
foreach ($new_add as $phid) {
$result[$phid] = $phid;
}
foreach ($new_rem as $phid) {
unset($result[$phid]);
}
return array_values($result);
}
protected function getEdgeTransactionNewValue(
PhabricatorApplicationTransaction $xaction) {
$new = $xaction->getNewValue();
$new_add = idx($new, '+', array());
unset($new['+']);
$new_rem = idx($new, '-', array());
unset($new['-']);
$new_set = idx($new, '=', null);
unset($new['=']);
if ($new) {
throw new Exception(
"Invalid 'new' value for Edge transaction. Value should contain only ".
"keys '+' (add edges), '-' (remove edges) and '=' (set edges).");
}
$old = $xaction->getOldValue();
$lists = array($new_set, $new_add, $new_rem);
foreach ($lists as $list) {
$this->checkEdgeList($list);
}
$result = array();
foreach ($old as $dst_phid => $edge) {
if ($new_set !== null && empty($new_set[$dst_phid])) {
continue;
}
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge);
}
if ($new_set !== null) {
foreach ($new_set as $dst_phid => $edge) {
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge);
}
}
foreach ($new_add as $dst_phid => $edge) {
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge);
}
foreach ($new_rem as $dst_phid => $edge) {
unset($result[$dst_phid]);
}
return $result;
}
private function checkEdgeList($list) {
if (!$list) {
return;
}
foreach ($list as $key => $item) {
if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
throw new Exception(
"Edge transactions must have destination PHIDs as in edge ".
"lists (found key '{$key}').");
}
if (!is_array($item) && $item !== $key) {
throw new Exception(
"Edge transactions must have PHIDs or edge specs as values ".
"(found value '{$item}').");
}
}
}
protected function normalizeEdgeTransactionValue(
PhabricatorApplicationTransaction $xaction,
$edge) {
if (!is_array($edge)) {
$edge = array(
'dst' => $edge,
);
}
$edge_type = $xaction->getMetadataValue('edge:type');
if (empty($edge['type'])) {
$edge['type'] = $edge_type;
} else {
if ($edge['type'] != $edge_type) {
$this_type = $edge['type'];
throw new Exception(
"Edge transaction includes edge of type '{$this_type}', but ".
"transaction is of type '{$edge_type}'. Each edge transaction must ".
"alter edges of only one type.");
}
}
if (!isset($edge['data'])) {
$edge['data'] = null;
}
return $edge;
}
protected function sortTransactions(array $xactions) {
$head = array();
$tail = array();
// Move bare comments to the end, so the actions precede them.
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
if ($type == PhabricatorTransactions::TYPE_COMMENT) {
$tail[] = $xaction;
} else {
$head[] = $xaction;
}
}
return array_values(array_merge($head, $tail));
}
protected function filterTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$type_comment = PhabricatorTransactions::TYPE_COMMENT;
$no_effect = array();
$has_comment = false;
$any_effect = false;
foreach ($xactions as $key => $xaction) {
if ($this->transactionHasEffect($object, $xaction)) {
if ($xaction->getTransactionType() != $type_comment) {
$any_effect = true;
}
} else {
$no_effect[$key] = $xaction;
}
if ($xaction->hasComment()) {
$has_comment = true;
}
}
if (!$no_effect) {
return $xactions;
}
if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
throw new PhabricatorApplicationTransactionNoEffectException(
$no_effect,
$any_effect,
$has_comment);
}
if (!$any_effect && !$has_comment) {
// If we only have empty comment transactions, just drop them all.
return array();
}
foreach ($no_effect as $key => $xaction) {
if ($xaction->getComment()) {
$xaction->setTransactionType($type_comment);
$xaction->setOldValue(null);
$xaction->setNewValue(null);
} else {
unset($xactions[$key]);
}
}
return $xactions;
}
/* -( Implicit CCs )------------------------------------------------------- */
/**
* When a user interacts with an object, we might want to add them to CC.
*/
final public function applyImplicitCC(
PhabricatorLiskDAO $object,
array $xactions) {
if (!($object instanceof PhabricatorSubscribableInterface)) {
// If the object isn't subscribable, we can't CC them.
return $xactions;
}
$actor_phid = $this->requireActor()->getPHID();
if ($object->isAutomaticallySubscribed($actor_phid)) {
// If they're auto-subscribed, don't CC them.
return $xactions;
}
$should_cc = false;
foreach ($xactions as $xaction) {
if ($this->shouldImplyCC($object, $xaction)) {
$should_cc = true;
break;
}
}
if (!$should_cc) {
// Only some types of actions imply a CC (like adding a comment).
return $xactions;
}
if ($object->getPHID()) {
if (isset($this->subscribers[$actor_phid])) {
// If the user is already subscribed, don't implicitly CC them.
return $xactions;
}
$unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorEdgeConfig::TYPE_OBJECT_HAS_UNSUBSCRIBER);
$unsub = array_fuse($unsub);
if (isset($unsub[$actor_phid])) {
// If the user has previously unsubscribed from this object explicitly,
// don't implicitly CC them.
return $xactions;
}
}
$xaction = newv(get_class(head($xactions)), array());
$xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
$xaction->setNewValue(array('+' => array($actor_phid)));
array_unshift($xactions, $xaction);
return $xactions;
}
protected function shouldImplyCC(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
return true;
default:
return false;
}
}
/* -( Sending Mail )------------------------------------------------------- */
/**
* @task mail
*/
protected function supportsMail() {
return false;
}
/**
* @task mail
*/
protected function sendMail(
PhabricatorLiskDAO $object,
array $xactions) {
$email_to = array_unique($this->getMailTo($object));
$email_cc = array_unique($this->getMailCC($object));
$phids = array_merge($email_to, $email_cc);
$handles = id(new PhabricatorObjectHandleData($phids))
->setViewer($this->requireActor())
->loadHandles();
$template = $this->buildMailTemplate($object);
$body = $this->buildMailBody($object, $xactions);
$mail_tags = $this->getMailTags($object, $xactions);
$action = $this->getStrongestAction($object, $xactions)->getActionName();
$template
->setFrom($this->requireActor()->getPHID())
->setSubjectPrefix($this->getMailSubjectPrefix())
->setVarySubjectPrefix('['.$action.']')
->setThreadID($object->getPHID(), $this->getIsNewObject())
->setRelatedPHID($object->getPHID())
->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
->setMailTags($mail_tags)
->setIsBulk(true)
->setBody($body->render());
if ($this->getParentMessageID()) {
$template->setParentMessageID($this->getParentMessageID());
}
$mails = $this
->buildReplyHandler($object)
->multiplexMail(
$template,
array_select_keys($handles, $email_to),
array_select_keys($handles, $email_cc));
foreach ($mails as $mail) {
$mail->saveAndSend();
}
$template->addTos($email_to);
$template->addCCs($email_cc);
return $template;
}
/**
* @task mail
*/
protected function getStrongestAction(
PhabricatorLiskDAO $object,
array $xactions) {
return last(msort($xactions, 'getActionStrength'));
}
/**
* @task mail
*/
protected function buildReplyHandler(PhabricatorLiskDAO $object) {
throw new Exception("Capability not supported.");
}
/**
* @task mail
*/
protected function getMailSubjectPrefix() {
throw new Exception("Capability not supported.");
}
/**
* @task mail
*/
protected function getMailTags(
PhabricatorLiskDAO $object,
array $xactions) {
$tags = array();
foreach ($xactions as $xaction) {
$tags[] = $xaction->getMailTags();
}
return array_mergev($tags);
}
/**
* @task mail
*/
protected function buildMailTemplate(PhabricatorLiskDAO $object) {
throw new Exception("Capability not supported.");
}
/**
* @task mail
*/
protected function getMailTo(PhabricatorLiskDAO $object) {
throw new Exception("Capability not supported.");
}
/**
* @task mail
*/
protected function getMailCC(PhabricatorLiskDAO $object) {
if ($object instanceof PhabricatorSubscribableInterface) {
return $this->subscribers;
}
throw new Exception("Capability not supported.");
}
/**
* @task mail
*/
protected function buildMailBody(
PhabricatorLiskDAO $object,
array $xactions) {
$headers = array();
$comments = array();
foreach ($xactions as $xaction) {
if ($xaction->shouldHideForMail()) {
continue;
}
$headers[] = id(clone $xaction)->setRenderingTarget('text')->getTitle();
$comment = $xaction->getComment();
if ($comment && strlen($comment->getContent())) {
$comments[] = $comment->getContent();
}
}
$body = new PhabricatorMetaMTAMailBody();
$body->addRawSection(implode("\n", $headers));
foreach ($comments as $comment) {
$body->addRawSection($comment);
}
return $body;
}
/* -( Publishing Feed Stories )-------------------------------------------- */
/**
* @task feed
*/
protected function supportsFeed() {
return false;
}
/**
* @task feed
*/
protected function getFeedStoryType() {
return 'PhabricatorApplicationTransactionFeedStory';
}
/**
* @task feed
*/
protected function getFeedRelatedPHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
return array(
$object->getPHID(),
$this->requireActor()->getPHID(),
);
}
/**
* @task feed
*/
protected function getFeedNotifyPHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
return array_unique(array_merge(
$this->getMailTo($object),
$this->getMailCC($object)));
}
/**
* @task feed
*/
protected function getFeedStoryData(
PhabricatorLiskDAO $object,
array $xactions) {
$xactions = msort($xactions, 'getActionStrength');
$xactions = array_reverse($xactions);
return array(
'objectPHID' => $object->getPHID(),
'transactionPHIDs' => mpull($xactions, 'getPHID'),
);
}
/**
* @task feed
*/
protected function publishFeedStory(
PhabricatorLiskDAO $object,
array $xactions,
array $mailed_phids) {
$related_phids = $this->getFeedRelatedPHIDs($object, $xactions);
$subscribed_phids = $this->getFeedNotifyPHIDs($object, $xactions);
$story_type = $this->getFeedStoryType();
$story_data = $this->getFeedStoryData($object, $xactions);
id(new PhabricatorFeedStoryPublisher())
->setStoryType($story_type)
->setStoryData($story_data)
->setStoryTime(time())
->setStoryAuthorPHID($this->requireActor()->getPHID())
->setRelatedPHIDs($related_phids)
->setPrimaryObjectPHID($object->getPHID())
->setSubscribedPHIDs($subscribed_phids)
->setMailRecipientPHIDs($mailed_phids)
->publish();
}
/* -( Search Index )------------------------------------------------------- */
/**
* @task search
*/
protected function supportsSearch() {
return false;
}
/* -( Custom Fields )------------------------------------------------------ */
/**
* @task customfield
*/
private function getCustomFieldForTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$field_key = $xaction->getMetadataValue('customfield:key');
if (!$field_key) {
throw new Exception(
"Custom field transaction has no 'customfield:key'!");
}
$field = PhabricatorCustomField::getObjectField(
$object,
PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
$field_key);
if (!$field) {
throw new Exception(
"Custom field transaction has invalid 'customfield:key'; field ".
"'{$field_key}' is disabled or does not exist.");
}
if (!$field->shouldAppearInApplicationTransactions()) {
throw new Exception(
"Custom field transaction '{$field_key}' does not implement ".
"integration for ApplicationTransactions.");
}
return $field;
}
}
diff --git a/src/infrastructure/customfield/field/PhabricatorCustomField.php b/src/infrastructure/customfield/field/PhabricatorCustomField.php
index 71f2a06a13..6902d70ed4 100644
--- a/src/infrastructure/customfield/field/PhabricatorCustomField.php
+++ b/src/infrastructure/customfield/field/PhabricatorCustomField.php
@@ -1,869 +1,870 @@
getCustomFields($role);
+ $field_list = $object->getCustomFields($role);
} catch (PhabricatorCustomFieldNotAttachedException $ex) {
$base_class = $object->getCustomFieldBaseClass();
$spec = $object->getCustomFieldSpecificationForRole($role);
if (!is_array($spec)) {
$obj_class = get_class($object);
throw new Exception(
"Expected an array from getCustomFieldSpecificationForRole() for ".
"object of class '{$obj_class}'.");
}
$fields = PhabricatorCustomField::buildFieldList($base_class, $spec);
foreach ($fields as $key => $field) {
if (!$field->shouldEnableForRole($role)) {
unset($fields[$key]);
}
}
foreach ($fields as $field) {
$field->setObject($object);
}
- $object->attachCustomFields($role, $fields);
+ $field_list = new PhabricatorCustomFieldList($fields);
+ $object->attachCustomFields($role, $field_list);
}
- return $fields;
+ return $field_list;
}
/**
* @task apps
*/
public static function getObjectField(
PhabricatorCustomFieldInterface $object,
$role,
$field_key) {
- return idx(self::getObjectFields($object, $role), $field_key);
+ return idx(self::getObjectFields($object, $role)->getFields(), $field_key);
}
/**
* @task apps
*/
public static function buildFieldList($base_class, array $spec) {
$field_objects = id(new PhutilSymbolLoader())
->setAncestorClass($base_class)
->loadObjects();
$fields = array();
$from_map = array();
foreach ($field_objects as $field_object) {
$current_class = get_class($field_object);
foreach ($field_object->createFields() as $field) {
$key = $field->getFieldKey();
if (isset($fields[$key])) {
$original_class = $from_map[$key];
throw new Exception(
"Both '{$original_class}' and '{$current_class}' define a custom ".
"field with field key '{$key}'. Field keys must be unique.");
}
$from_map[$key] = $current_class;
$fields[$key] = $field;
}
}
foreach ($fields as $key => $field) {
if (!$field->isFieldEnabled()) {
unset($fields[$key]);
}
}
$fields = array_select_keys($fields, array_keys($spec)) + $fields;
foreach ($spec as $key => $config) {
if (empty($fields[$key])) {
continue;
}
if (!empty($config['disabled'])) {
if ($fields[$key]->canDisableField()) {
unset($fields[$key]);
}
}
}
return $fields;
}
/* -( Core Properties and Field Identity )--------------------------------- */
/**
* Return a key which uniquely identifies this field, like
* "mycompany:dinosaur:count". Normally you should provide some level of
* namespacing to prevent collisions.
*
* @return string String which uniquely identifies this field.
* @task core
*/
public function getFieldKey() {
if ($this->proxy) {
return $this->proxy->getFieldKey();
}
throw new PhabricatorCustomFieldImplementationIncompleteException(
$this,
$field_key_is_incomplete = true);
}
/**
* Return a human-readable field name.
*
* @return string Human readable field name.
* @task core
*/
public function getFieldName() {
if ($this->proxy) {
return $this->proxy->getFieldName();
}
return $this->getFieldKey();
}
/**
* Return a short, human-readable description of the field's behavior. This
* provides more context to administrators when they are customizing fields.
*
* @return string|null Optional human-readable description.
* @task core
*/
public function getFieldDescription() {
if ($this->proxy) {
return $this->proxy->getFieldDescription();
}
return null;
}
/**
* Most field implementations are unique, in that one class corresponds to
* one field. However, some field implementations are general and a single
* implementation may drive several fields.
*
* For general implementations, the general field implementation can return
* multiple field instances here.
*
* @return list List of fields.
* @task core
*/
public function createFields() {
return array($this);
}
/**
* You can return `false` here if the field should not be enabled for any
* role. For example, it might depend on something (like an application or
* library) which isn't installed, or might have some global configuration
* which allows it to be disabled.
*
* @return bool False to completely disable this field for all roles.
* @task core
*/
public function isFieldEnabled() {
if ($this->proxy) {
return $this->proxy->isFieldEnabled();
}
return true;
}
/**
* Low level selector for field availability. Fields can appear in different
* roles (like an edit view, a list view, etc.), but not every field needs
* to appear everywhere. Fields that are disabled in a role won't appear in
* that context within applications.
*
* Normally, you do not need to override this method. Instead, override the
* methods specific to roles you want to enable. For example, implement
* @{method:shouldUseStorage()} to activate the `'storage'` role.
*
* @return bool True to enable the field for the given role.
* @task core
*/
public function shouldEnableForRole($role) {
if ($this->proxy) {
return $this->proxy->shouldEnableForRole($role);
}
switch ($role) {
case self::ROLE_APPLICATIONTRANSACTIONS:
return $this->shouldAppearInApplicationTransactions();
case self::ROLE_APPLICATIONSEARCH:
return $this->shouldAppearInApplicationSearch();
case self::ROLE_STORAGE:
return $this->shouldUseStorage();
case self::ROLE_EDIT:
return $this->shouldAppearInEditView();
case self::ROLE_VIEW:
return $this->shouldAppearInPropertyView();
case self::ROLE_LIST:
return $this->shouldAppearInListView();
case self::ROLE_DEFAULT:
return true;
default:
throw new Exception("Unknown field role '{$role}'!");
}
}
/**
* Allow administrators to disable this field. Most fields should allow this,
* but some are fundamental to the behavior of the application and can be
* locked down to avoid chaos, disorder, and the decline of civilization.
*
* @return bool False to prevent this field from being disabled through
* configuration.
* @task core
*/
public function canDisableField() {
return true;
}
/**
* Return an index string which uniquely identifies this field.
*
* @return string Index string which uniquely identifies this field.
* @task core
*/
final public function getFieldIndex() {
return PhabricatorHash::digestForIndex($this->getFieldKey());
}
/* -( Field Proxies )------------------------------------------------------ */
/**
* Proxies allow a field to use some other field's implementation for most
* of their behavior while still subclassing an application field. When a
* proxy is set for a field with @{method:setProxy}, all of its methods will
* call through to the proxy by default.
*
* This is most commonly used to implement configuration-driven custom fields
* using @{class:PhabricatorStandardCustomField}.
*
* This method must be overridden to return `true` before a field can accept
* proxies.
*
* @return bool True if you can @{method:setProxy} this field.
* @task proxy
*/
public function canSetProxy() {
if ($this instanceof PhabricatorStandardCustomFieldInterface) {
return true;
}
return false;
}
/**
* Set the proxy implementation for this field. See @{method:canSetProxy} for
* discussion of field proxies.
*
* @param PhabricatorCustomField Field implementation.
* @return this
*/
final public function setProxy(PhabricatorCustomField $proxy) {
if (!$this->canSetProxy()) {
throw new PhabricatorCustomFieldNotProxyException($this);
}
$this->proxy = $proxy;
return $this;
}
/**
* Get the field's proxy implementation, if any. For discussion, see
* @{method:canSetProxy}.
*
* @return PhabricatorCustomField|null Proxy field, if one is set.
*/
final public function getProxy() {
return $this->proxy;
}
/* -( Contextual Data )---------------------------------------------------- */
/**
* Sets the object this field belongs to.
*
* @param PhabricatorCustomFieldInterface The object this field belongs to.
* @task context
*/
final public function setObject(PhabricatorCustomFieldInterface $object) {
if ($this->proxy) {
$this->proxy->setObject($object);
return $this;
}
$this->object = $object;
$this->didSetObject($object);
return $this;
}
/**
* Get the object this field belongs to.
*
* @return PhabricatorCustomFieldInterface The object this field belongs to.
* @task context
*/
final public function getObject() {
if ($this->proxy) {
return $this->proxy->getObject();
}
return $this->object;
}
/**
* This is a hook, primarily for subclasses to load object data.
*
* @return PhabricatorCustomFieldInterface The object this field belongs to.
* @return void
*/
protected function didSetObject(PhabricatorCustomFieldInterface $object) {
return;
}
/**
* @task context
*/
final public function setViewer(PhabricatorUser $viewer) {
if ($this->proxy) {
$this->proxy->setViewer($viewer);
return $this;
}
$this->viewer = $viewer;
return $this;
}
/**
* @task context
*/
final public function getViewer() {
if ($this->proxy) {
return $this->proxy->getViewer();
}
return $this->viewer;
}
/**
* @task context
*/
final protected function requireViewer() {
if ($this->proxy) {
return $this->proxy->requireViewer();
}
if (!$this->viewer) {
throw new PhabricatorCustomFieldDataNotAvailableException($this);
}
return $this->viewer;
}
/* -( Storage )------------------------------------------------------------ */
/**
* Return true to use field storage.
*
* Fields which can be edited by the user will most commonly use storage,
* while some other types of fields (for instance, those which just display
* information in some stylized way) may not. Many builtin fields do not use
* storage because their data is available on the object itself.
*
* If you implement this, you must also implement @{method:getValueForStorage}
* and @{method:setValueFromStorage}.
*
* @return bool True to use storage.
* @task storage
*/
public function shouldUseStorage() {
if ($this->proxy) {
return $this->proxy->shouldUseStorage();
}
return false;
}
/**
* Return a new, empty storage object. This should be a subclass of
* @{class:PhabricatorCustomFieldStorage} which is bound to the application's
* database.
*
* @return PhabricatorCustomFieldStorage New empty storage object.
* @task storage
*/
public function newStorageObject() {
if ($this->proxy) {
return $this->proxy->newStorageObject();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Return a serialized representation of the field value, appropriate for
* storing in auxiliary field storage. You must implement this method if
* you implement @{method:shouldUseStorage}.
*
* If the field value is a scalar, it can be returned unmodiifed. If not,
* it should be serialized (for example, using JSON).
*
* @return string Serialized field value.
* @task storage
*/
public function getValueForStorage() {
if ($this->proxy) {
return $this->proxy->getValueForStorage();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Set the field's value given a serialized storage value. This is called
* when the field is loaded; if no data is available, the value will be
* null. You must implement this method if you implement
* @{method:shouldUseStorage}.
*
* Usually, the value can be loaded directly. If it isn't a scalar, you'll
* need to undo whatever serialization you applied in
* @{method:getValueForStorage}.
*
* @param string|null Serialized field representation (from
* @{method:getValueForStorage}) or null if no value has
* ever been stored.
* @return this
* @task storage
*/
public function setValueFromStorage($value) {
if ($this->proxy) {
return $this->proxy->setValueFromStorage($value);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/* -( ApplicationSearch )-------------------------------------------------- */
/**
* Appearing in ApplicationSearch allows a field to be indexed and searched
* for.
*
* @return bool True to appear in ApplicationSearch.
* @task appsearch
*/
public function shouldAppearInApplicationSearch() {
if ($this->proxy) {
return $this->proxy->shouldAppearInApplicationSearch();
}
return false;
}
/**
* Return one or more indexes which this field can meaningfully query against
* to implement ApplicationSearch.
*
* Normally, you should build these using @{method:newStringIndex} and
* @{method:newNumericIndex}. For example, if a field holds a numeric value
* it might return a single numeric index:
*
* return array($this->newNumericIndex($this->getValue()));
*
* If a field holds a more complex value (like a list of users), it might
* return several string indexes:
*
* $indexes = array();
* foreach ($this->getValue() as $phid) {
* $indexes[] = $this->newStringIndex($phid);
* }
* return $indexes;
*
* @return list List of indexes.
* @task appsearch
*/
public function buildFieldIndexes() {
if ($this->proxy) {
return $this->proxy->buildFieldIndexes();
}
return array();
}
/**
* Build a new empty storage object for storing string indexes. Normally,
* this should be a concrete subclass of
* @{class:PhabricatorCustomFieldStringIndexStorage}.
*
* @return PhabricatorCustomFieldStringIndexStorage Storage object.
* @task appsearch
*/
protected function newStringIndexStorage() {
if ($this->proxy) {
return $this->proxy->newStringIndexStorage();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Build a new empty storage object for storing string indexes. Normally,
* this should be a concrete subclass of
* @{class:PhabricatorCustomFieldStringIndexStorage}.
*
* @return PhabricatorCustomFieldStringIndexStorage Storage object.
* @task appsearch
*/
protected function newNumericIndexStorage() {
if ($this->proxy) {
return $this->proxy->newStringIndexStorage();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Build and populate storage for a string index.
*
* @param string String to index.
* @return PhabricatorCustomFieldStringIndexStorage Populated storage.
* @task appsearch
*/
protected function newStringIndex($value) {
if ($this->proxy) {
return $this->proxy->newStringIndex();
}
$key = $this->getFieldIndexKey();
return $this->newStringIndexStorage()
->setIndexKey($key)
->setIndexValue($value);
}
/**
* Build and populate storage for a numeric index.
*
* @param string Numeric value to index.
* @return PhabricatorCustomFieldNumericIndexStorage Populated storage.
* @task appsearch
*/
protected function newNumericIndex($value) {
if ($this->proxy) {
return $this->proxy->newNumericIndex();
}
$key = $this->getFieldIndexKey();
return $this->newNumericIndexStorage()
->setIndexKey($key)
->setIndexValue($value);
}
/* -( ApplicationTransactions )-------------------------------------------- */
/**
* Appearing in ApplicationTrasactions allows a field to be edited using
* standard workflows.
*
* @return bool True to appear in ApplicationTransactions.
* @task appxaction
*/
public function shouldAppearInApplicationTransactions() {
if ($this->proxy) {
return $this->proxy->shouldAppearInApplicationTransactions();
}
return false;
}
/**
* @task appxaction
*/
public function getOldValueForApplicationTransactions() {
if ($this->proxy) {
return $this->proxy->getOldValueForApplicationTransactions();
}
return $this->getValueForStorage();
}
/**
* @task appxaction
*/
public function getNewValueForApplicationTransactions() {
if ($this->proxy) {
return $this->proxy->getNewValueForApplicationTransactions();
}
return $this->getValueForStorage();
}
/**
* @task appxaction
*/
public function setValueFromApplicationTransactions($value) {
if ($this->proxy) {
return $this->proxy->setValueFromApplicationTransactions($value);
}
return $this->setValueFromStorage($value);
}
/**
* @task appxaction
*/
public function getNewValueFromApplicationTransactions(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->getNewValueFromApplicationTransactions($xaction);
}
return $xaction->getNewValue();
}
/**
* @task appxaction
*/
public function getApplicationTransactionHasEffect(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionHasEffect($xaction);
}
return ($xaction->getOldValue() !== $xaction->getNewValue());
}
/**
* @task appxaction
*/
public function applyApplicationTransactionInternalEffects(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->applyApplicationTransactionInternalEffects($xaction);
}
return;
}
/**
* @task appxaction
*/
public function applyApplicationTransactionExternalEffects(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->applyApplicationTransactionExternalEffects($xaction);
}
if (!$this->shouldEnableForRole(self::ROLE_STORAGE)) {
return;
}
$this->setValueFromApplicationTransactions($xaction->getNewValue());
$value = $this->getValueForStorage();
$table = $this->newStorageObject();
$conn_w = $table->establishConnection('w');
if ($value === null) {
queryfx(
$conn_w,
'DELETE FROM %T WHERE objectPHID = %s AND fieldIndex = %s',
$table->getTableName(),
$this->getObject()->getPHID(),
$this->getFieldIndex());
} else {
queryfx(
$conn_w,
'INSERT INTO %T (objectPHID, fieldIndex, fieldValue)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE fieldValue = VALUES(fieldValue)',
$table->getTableName(),
$this->getObject()->getPHID(),
$this->getFieldIndex(),
$value);
}
return;
}
/* -( Edit View )---------------------------------------------------------- */
/**
* @task edit
*/
public function shouldAppearInEditView() {
if ($this->proxy) {
return $this->proxy->shouldAppearInEditView();
}
return false;
}
/**
* @task edit
*/
public function readValueFromRequest(AphrontRequest $request) {
if ($this->proxy) {
return $this->proxy->readValueFromRequest($request);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* @task edit
*/
public function renderEditControl() {
if ($this->proxy) {
return $this->proxy->renderEditControl();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/* -( Property View )------------------------------------------------------ */
/**
* @task view
*/
public function shouldAppearInPropertyView() {
if ($this->proxy) {
return $this->proxy->shouldAppearInPropertyView();
}
return false;
}
/**
* @task view
*/
public function renderPropertyViewLabel() {
if ($this->proxy) {
return $this->proxy->renderPropertyViewLabel();
}
return $this->getFieldName();
}
/**
* @task view
*/
public function renderPropertyViewValue() {
if ($this->proxy) {
return $this->proxy->renderPropertyViewValue();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* @task view
*/
public function getStyleForPropertyView() {
if ($this->proxy) {
return $this->proxy->getStyleForPropertyView();
}
return 'property';
}
/* -( List View )---------------------------------------------------------- */
/**
* @task list
*/
public function shouldAppearInListView() {
if ($this->proxy) {
return $this->proxy->shouldAppearInListView();
}
return false;
}
/**
* @task list
*/
public function renderOnListItem(PhabricatorObjectItemView $view) {
if ($this->proxy) {
return $this->proxy->renderOnListItem($view);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
}
diff --git a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
index 75b3ae175c..e5d17822f8 100644
--- a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
+++ b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
@@ -1,123 +1,154 @@
fields = $fields;
}
+ public function getFields() {
+ return $this->fields;
+ }
/**
* Read stored values for all fields which support storage.
*
* @param PhabricatorCustomFieldInterface Object to read field values for.
* @return void
*/
public function readFieldsFromStorage(
PhabricatorCustomFieldInterface $object) {
$keys = array();
foreach ($this->fields as $field) {
if ($field->shouldEnableForRole(PhabricatorCustomField::ROLE_STORAGE)) {
$keys[$field->getFieldIndex()] = $field;
}
}
if (!$keys) {
return;
}
// NOTE: We assume all fields share the same storage. This isn't guaranteed
// to be true, but always is for now.
$table = head($keys)->newStorageObject();
$objects = $table->loadAllWhere(
'objectPHID = %s AND fieldIndex IN (%Ls)',
$object->getPHID(),
array_keys($keys));
$objects = mpull($objects, null, 'getFieldIndex');
foreach ($keys as $key => $field) {
$storage = idx($objects, $key);
if ($storage) {
$field->setValueFromStorage($storage->getFieldValue());
} else {
$field->setValueFromStorage(null);
}
}
}
public function appendFieldsToForm(AphrontFormView $form) {
foreach ($this->fields as $field) {
if ($field->shouldEnableForRole(PhabricatorCustomField::ROLE_EDIT)) {
$form->appendChild($field->renderEditControl());
}
}
}
public function appendFieldsToPropertyList(
PhabricatorCustomFieldInterface $object,
PhabricatorUser $viewer,
PhabricatorPropertyListView $view) {
$this->readFieldsFromStorage($object);
$fields = $this->fields;
foreach ($fields as $field) {
$field->setViewer($viewer);
}
// Move all the blocks to the end, regardless of their configuration order,
// because it always looks silly to render a block in the middle of a list
// of properties.
$head = array();
$tail = array();
foreach ($fields as $key => $field) {
$style = $field->getStyleForPropertyView();
switch ($style) {
case 'property':
$head[$key] = $field;
break;
case 'block':
$tail[$key] = $field;
break;
default:
throw new Exception(
"Unknown field property view style '{$style}'; valid styles are ".
"'block' and 'property'.");
}
}
$fields = $head + $tail;
foreach ($fields as $field) {
$label = $field->renderPropertyViewLabel();
$value = $field->renderPropertyViewValue();
if ($value !== null) {
switch ($field->getStyleForPropertyView()) {
case 'property':
$view->addProperty($label, $value);
break;
case 'block':
$view->invokeWillRenderEvent();
if ($label !== null) {
$view->addSectionHeader($label);
}
$view->addTextContent($value);
break;
}
}
}
}
+ public function buildFieldTransactionsFromRequest(
+ PhabricatorApplicationTransaction $template,
+ AphrontRequest $request) {
+
+ $xactions = array();
+
+ $role = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
+ foreach ($this->fields as $field) {
+ if (!$field->shouldEnableForRole($role)) {
+ continue;
+ }
+
+ $old_value = $field->getOldValueForApplicationTransactions();
+
+ $field->readValueFromRequest($request);
+
+ $xaction = id(clone $template)
+ ->setTransactionType(PhabricatorTransactions::TYPE_CUSTOMFIELD)
+ ->setMetadataValue('customfield:key', $field->getFieldKey())
+ ->setOldValue($old_value)
+ ->setNewValue($field->getNewValueForApplicationTransactions());
+
+ $xactions[] = $xaction;
+ }
+
+ return $xactions;
+ }
+
}
diff --git a/src/infrastructure/customfield/interface/PhabricatorCustomFieldInterface.php b/src/infrastructure/customfield/interface/PhabricatorCustomFieldInterface.php
index c31f767629..6bec3b345a 100644
--- a/src/infrastructure/customfield/interface/PhabricatorCustomFieldInterface.php
+++ b/src/infrastructure/customfield/interface/PhabricatorCustomFieldInterface.php
@@ -1,41 +1,41 @@
>>);
}
public function getCustomFieldBaseClass() {
return <<<<'YourApplicationHereCustomField'>>>>;
}
public function getCustomFields($role) {
if (idx($this->customFields, $role) === null) {
PhabricatorCustomField::raiseUnattachedException($this, $role);
}
return $this->customFields[$role];
}
- public function attachCustomFields($role, array $fields) {
- $this->customFields[$role] = $fields;
+ public function attachCustomFields($role, PhabricatorCustomFieldList $list) {
+ $this->customFields[$role] = $list;
return $this;
}
*/