Page MenuHomePhorge

No OneTemporary

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/aphront/response/AphrontResponse.php b/src/aphront/response/AphrontResponse.php
index 5dae168c73..355177e316 100644
--- a/src/aphront/response/AphrontResponse.php
+++ b/src/aphront/response/AphrontResponse.php
@@ -1,449 +1,449 @@
<?php
abstract class AphrontResponse extends Phobject {
private $request;
private $cacheable = false;
private $canCDN;
private $responseCode = 200;
private $lastModified = null;
private $contentSecurityPolicyURIs;
private $disableContentSecurityPolicy;
protected $frameable;
private $headers = array();
public function setRequest($request) {
$this->request = $request;
return $this;
}
public function getRequest() {
return $this->request;
}
final public function addContentSecurityPolicyURI($kind, $uri) {
if ($this->contentSecurityPolicyURIs === null) {
$this->contentSecurityPolicyURIs = array(
'script-src' => array(),
'connect-src' => array(),
'frame-src' => array(),
'form-action' => array(),
'object-src' => array(),
);
}
if (!isset($this->contentSecurityPolicyURIs[$kind])) {
throw new Exception(
pht(
'Unknown Content-Security-Policy URI kind "%s".',
$kind));
}
$this->contentSecurityPolicyURIs[$kind][] = (string)$uri;
return $this;
}
final public function setDisableContentSecurityPolicy($disable) {
$this->disableContentSecurityPolicy = $disable;
return $this;
}
final public function addHeader($key, $value) {
$this->headers[] = array($key, $value);
return $this;
}
/* -( Content )------------------------------------------------------------ */
public function getContentIterator() {
// By default, make sure responses are truly returning a string, not some
// kind of object that behaves like a string.
// We're going to remove the execution time limit before dumping the
// response into the sink, and want any rendering that's going to occur
// to happen BEFORE we release the limit.
return array(
(string)$this->buildResponseString(),
);
}
public function buildResponseString() {
throw new PhutilMethodNotImplementedException();
}
/* -( Metadata )----------------------------------------------------------- */
public function getHeaders() {
$headers = array();
if (!$this->frameable) {
$headers[] = array('X-Frame-Options', 'Deny');
}
if ($this->getRequest() && $this->getRequest()->isHTTPS()) {
$hsts_key = 'security.strict-transport-security';
$use_hsts = PhabricatorEnv::getEnvConfig($hsts_key);
if ($use_hsts) {
$duration = phutil_units('365 days in seconds');
} else {
// If HSTS has been disabled, tell browsers to turn it off. This may
// not be effective because we can only disable it over a valid HTTPS
// connection, but it best represents the configured intent.
$duration = 0;
}
$headers[] = array(
'Strict-Transport-Security',
"max-age={$duration}; includeSubdomains; preload",
);
}
$csp = $this->newContentSecurityPolicyHeader();
if ($csp !== null) {
$headers[] = array('Content-Security-Policy', $csp);
}
$headers[] = array('Referrer-Policy', 'no-referrer');
foreach ($this->headers as $header) {
$headers[] = $header;
}
return $headers;
}
private function newContentSecurityPolicyHeader() {
if ($this->disableContentSecurityPolicy) {
return null;
}
// NOTE: We may return a response during preflight checks (for example,
// if a user has a bad version of PHP).
// In this case, setup isn't complete yet and we can't access environmental
// configuration. If we aren't able to read the environment, just decline
// to emit a Content-Security-Policy header.
try {
$cdn = PhabricatorEnv::getEnvConfig('security.alternate-file-domain');
$base_uri = PhabricatorEnv::getURI('/');
} catch (Exception $ex) {
return null;
}
$csp = array();
if ($cdn) {
$default = $this->newContentSecurityPolicySource($cdn);
} else {
// If an alternate file domain is not configured and the user is viewing
// a Phame blog on a custom domain or some other custom site, we'll still
// serve resources from the main site. Include the main site explicitly.
$base_uri = $this->newContentSecurityPolicySource($base_uri);
$default = "'self' {$base_uri}";
}
$csp[] = "default-src {$default}";
// We use "data:" URIs to inline small images into CSS. This policy allows
// "data:" URIs to be used anywhere, but there doesn't appear to be a way
// to say that "data:" URIs are okay in CSS files but not in the document.
$csp[] = "img-src {$default} data:";
// We use inline style="..." attributes in various places, many of which
// are legitimate. We also currently use a <style> tag to implement the
// "Monospaced Font Preference" setting.
$csp[] = "style-src {$default} 'unsafe-inline'";
// On a small number of pages, including the Stripe workflow and the
// ReCAPTCHA challenge, we embed external Javascript directly.
$csp[] = $this->newContentSecurityPolicy('script-src', $default);
// We need to specify that we can connect to ourself in order for AJAX
// requests to work.
$csp[] = $this->newContentSecurityPolicy('connect-src', "'self'");
// DarkConsole and PHPAST both use frames to render some content.
$csp[] = $this->newContentSecurityPolicy('frame-src', "'self'");
// This is a more modern flavor of of "X-Frame-Options" and prevents
// clickjacking attacks where the page is included in a tiny iframe and
// the user is convinced to click a element on the page, which really
// clicks a dangerous button hidden under a picture of a cat.
if ($this->frameable) {
$csp[] = "frame-ancestors 'self'";
} else {
$csp[] = "frame-ancestors 'none'";
}
// Block relics of the old world: Flash, Java applets, and so on. Note
// that Chrome prevents the user from viewing PDF documents if they are
// served with a policy which excludes the domain they are served from.
$csp[] = $this->newContentSecurityPolicy('object-src', "'none'");
// Don't allow forms to submit offsite.
// This can result in some trickiness with file downloads if applications
// try to start downloads by submitting a dialog. Redirect to the file's
// download URI instead of submitting a form to it.
$csp[] = $this->newContentSecurityPolicy('form-action', "'self'");
// Block use of "<base>" to change the origin of relative URIs on the page.
$csp[] = "base-uri 'none'";
$csp = implode('; ', $csp);
return $csp;
}
private function newContentSecurityPolicy($type, $defaults) {
if ($defaults === null) {
$sources = array();
} else {
$sources = (array)$defaults;
}
$uris = $this->contentSecurityPolicyURIs;
if (isset($uris[$type])) {
foreach ($uris[$type] as $uri) {
$sources[] = $this->newContentSecurityPolicySource($uri);
}
}
$sources = array_unique($sources);
return $type.' '.implode(' ', $sources);
}
private function newContentSecurityPolicySource($uri) {
// Some CSP URIs are ultimately user controlled (like notification server
// URIs and CDN URIs) so attempt to stop an attacker from injecting an
// unsafe source (like 'unsafe-eval') into the CSP header.
$uri = id(new PhutilURI($uri))
->setPath(null)
->setFragment(null)
->removeAllQueryParams();
$uri = (string)$uri;
if (preg_match('/[ ;\']/', $uri)) {
throw new Exception(
pht(
'Attempting to emit a response with an unsafe source ("%s") in the '.
'Content-Security-Policy header.',
$uri));
}
return $uri;
}
public function setCacheDurationInSeconds($duration) {
$this->cacheable = $duration;
return $this;
}
public function setCanCDN($can_cdn) {
$this->canCDN = $can_cdn;
return $this;
}
public function setLastModified($epoch_timestamp) {
$this->lastModified = $epoch_timestamp;
return $this;
}
public function setHTTPResponseCode($code) {
$this->responseCode = $code;
return $this;
}
public function getHTTPResponseCode() {
return $this->responseCode;
}
public function getHTTPResponseMessage() {
switch ($this->getHTTPResponseCode()) {
case 100: return 'Continue';
case 101: return 'Switching Protocols';
case 200: return 'OK';
case 201: return 'Created';
case 202: return 'Accepted';
case 203: return 'Non-Authoritative Information';
case 204: return 'No Content';
case 205: return 'Reset Content';
case 206: return 'Partial Content';
case 300: return 'Multiple Choices';
case 301: return 'Moved Permanently';
case 302: return 'Found';
case 303: return 'See Other';
case 304: return 'Not Modified';
case 305: return 'Use Proxy';
case 306: return 'Switch Proxy';
case 307: return 'Temporary Redirect';
case 400: return 'Bad Request';
case 401: return 'Unauthorized';
case 402: return 'Payment Required';
case 403: return 'Forbidden';
case 404: return 'Not Found';
case 405: return 'Method Not Allowed';
case 406: return 'Not Acceptable';
case 407: return 'Proxy Authentication Required';
case 408: return 'Request Timeout';
case 409: return 'Conflict';
case 410: return 'Gone';
case 411: return 'Length Required';
case 412: return 'Precondition Failed';
case 413: return 'Request Entity Too Large';
case 414: return 'Request-URI Too Long';
case 415: return 'Unsupported Media Type';
case 416: return 'Requested Range Not Satisfiable';
case 417: return 'Expectation Failed';
case 418: return "I'm a teapot";
case 426: return 'Upgrade Required';
case 500: return 'Internal Server Error';
case 501: return 'Not Implemented';
case 502: return 'Bad Gateway';
case 503: return 'Service Unavailable';
case 504: return 'Gateway Timeout';
case 505: return 'HTTP Version Not Supported';
default: return '';
}
}
public function setFrameable($frameable) {
$this->frameable = $frameable;
return $this;
}
public static function processValueForJSONEncoding(&$value, $key) {
if ($value instanceof PhutilSafeHTMLProducerInterface) {
// This renders the producer down to PhutilSafeHTML, which will then
// be simplified into a string below.
$value = hsprintf('%s', $value);
}
if ($value instanceof PhutilSafeHTML) {
- // TODO: Javelin supports implicity conversion of '__html' objects to
+ // TODO: Javelin supports implicit conversion of '__html' objects to
// JX.HTML, but only for Ajax responses, not behaviors. Just leave things
// as they are for now (where behaviors treat responses as HTML or plain
// text at their discretion).
$value = $value->getHTMLContent();
}
}
public static function encodeJSONForHTTPResponse(array $object) {
array_walk_recursive(
$object,
array(__CLASS__, 'processValueForJSONEncoding'));
$response = phutil_json_encode($object);
// Prevent content sniffing attacks by encoding "<" and ">", so browsers
// won't try to execute the document as HTML even if they ignore
// Content-Type and X-Content-Type-Options. See T865.
$response = str_replace(
array('<', '>'),
array('\u003c', '\u003e'),
$response);
return $response;
}
protected function addJSONShield($json_response) {
// Add a shield to prevent "JSON Hijacking" attacks where an attacker
// requests a JSON response using a normal <script /> tag and then uses
// Object.prototype.__defineSetter__() or similar to read response data.
// This header causes the browser to loop infinitely instead of handing over
// sensitive data.
$shield = 'for (;;);';
$response = $shield.$json_response;
return $response;
}
public function getCacheHeaders() {
$headers = array();
if ($this->cacheable) {
$cache_control = array();
$cache_control[] = sprintf('max-age=%d', $this->cacheable);
if ($this->canCDN) {
$cache_control[] = 'public';
} else {
$cache_control[] = 'private';
}
$headers[] = array(
'Cache-Control',
implode(', ', $cache_control),
);
$headers[] = array(
'Expires',
$this->formatEpochTimestampForHTTPHeader(time() + $this->cacheable),
);
} else {
$headers[] = array(
'Cache-Control',
'no-store',
);
$headers[] = array(
'Expires',
'Sat, 01 Jan 2000 00:00:00 GMT',
);
}
if ($this->lastModified) {
$headers[] = array(
'Last-Modified',
$this->formatEpochTimestampForHTTPHeader($this->lastModified),
);
}
// IE has a feature where it may override an explicit Content-Type
// declaration by inferring a content type. This can be a security risk
// and we always explicitly transmit the correct Content-Type header, so
// prevent IE from using inferred content types. This only offers protection
// on recent versions of IE; IE6/7 and Opera currently ignore this header.
$headers[] = array('X-Content-Type-Options', 'nosniff');
return $headers;
}
private function formatEpochTimestampForHTTPHeader($epoch_timestamp) {
return gmdate('D, d M Y H:i:s', $epoch_timestamp).' GMT';
}
protected function shouldCompressResponse() {
return true;
}
public function willBeginWrite() {
// If we've already sent headers, these "ini_set()" calls will warn that
// they have no effect. Today, this always happens because we're inside
// a unit test, so just skip adjusting the setting.
if (!headers_sent()) {
if ($this->shouldCompressResponse()) {
// Enable automatic compression here. Webservers sometimes do this for
// us, but we now detect the absence of compression and warn users about
// it so try to cover our bases more thoroughly.
ini_set('zlib.output_compression', 1);
} else {
ini_set('zlib.output_compression', 0);
}
}
}
public function didCompleteWrite($aborted) {
return;
}
}
diff --git a/src/applications/conduit/controller/PhabricatorConduitAPIController.php b/src/applications/conduit/controller/PhabricatorConduitAPIController.php
index a4d5fdd568..3ae402cf56 100644
--- a/src/applications/conduit/controller/PhabricatorConduitAPIController.php
+++ b/src/applications/conduit/controller/PhabricatorConduitAPIController.php
@@ -1,755 +1,755 @@
<?php
final class PhabricatorConduitAPIController
extends PhabricatorConduitController {
public function shouldRequireLogin() {
return false;
}
public function handleRequest(AphrontRequest $request) {
$method = $request->getURIData('method');
$time_start = microtime(true);
$api_request = null;
$method_implementation = null;
$log = new PhabricatorConduitMethodCallLog();
$log->setMethod($method);
$metadata = array();
$multimeter = MultimeterControl::getInstance();
if ($multimeter) {
$multimeter->setEventContext('api.'.$method);
}
try {
list($metadata, $params, $strictly_typed) = $this->decodeConduitParams(
$request,
$method);
$call = new ConduitCall($method, $params, $strictly_typed);
$method_implementation = $call->getMethodImplementation();
$result = null;
// TODO: The relationship between ConduitAPIRequest and ConduitCall is a
// little odd here and could probably be improved. Specifically, the
// APIRequest is a sub-object of the Call, which does not parallel the
- // role of AphrontRequest (which is an indepenent object).
+ // role of AphrontRequest (which is an independent object).
// In particular, the setUser() and getUser() existing independently on
// the Call and APIRequest is very awkward.
$api_request = $call->getAPIRequest();
$allow_unguarded_writes = false;
$auth_error = null;
$conduit_username = '-';
if ($call->shouldRequireAuthentication()) {
$auth_error = $this->authenticateUser($api_request, $metadata, $method);
// If we've explicitly authenticated the user here and either done
// CSRF validation or are using a non-web authentication mechanism.
$allow_unguarded_writes = true;
if ($auth_error === null) {
$conduit_user = $api_request->getUser();
if ($conduit_user && $conduit_user->getPHID()) {
$conduit_username = $conduit_user->getUsername();
}
$call->setUser($api_request->getUser());
}
}
$access_log = PhabricatorAccessLog::getLog();
if ($access_log) {
$access_log->setData(
array(
'u' => $conduit_username,
'm' => $method,
));
}
if ($call->shouldAllowUnguardedWrites()) {
$allow_unguarded_writes = true;
}
if ($auth_error === null) {
if ($allow_unguarded_writes) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
}
try {
$result = $call->execute();
$error_code = null;
$error_info = null;
} catch (ConduitException $ex) {
$result = null;
$error_code = $ex->getMessage();
if ($ex->getErrorDescription()) {
$error_info = $ex->getErrorDescription();
} else {
$error_info = $call->getErrorDescription($error_code);
}
}
if ($allow_unguarded_writes) {
unset($unguarded);
}
} else {
list($error_code, $error_info) = $auth_error;
}
} catch (Exception $ex) {
$result = null;
if ($ex instanceof ConduitException) {
$error_code = 'ERR-CONDUIT-CALL';
} else {
$error_code = 'ERR-CONDUIT-CORE';
// See T13581. When a Conduit method raises an uncaught exception
// other than a "ConduitException", log it.
phlog($ex);
}
$error_info = $ex->getMessage();
}
$log
->setCallerPHID(
isset($conduit_user)
? $conduit_user->getPHID()
: null)
->setError((string)$error_code)
->setDuration(phutil_microseconds_since($time_start));
if (!PhabricatorEnv::isReadOnly()) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$log->save();
unset($unguarded);
}
$response = id(new ConduitAPIResponse())
->setResult($result)
->setErrorCode($error_code)
->setErrorInfo($error_info);
switch ($request->getStr('output')) {
case 'human':
return $this->buildHumanReadableResponse(
$method,
$api_request,
$response->toDictionary(),
$method_implementation);
case 'json':
default:
$response = id(new AphrontJSONResponse())
->setAddJSONShield(false)
->setContent($response->toDictionary());
$capabilities = $this->getConduitCapabilities();
if ($capabilities) {
$capabilities = implode(' ', $capabilities);
$response->addHeader('X-Conduit-Capabilities', $capabilities);
}
return $response;
}
}
/**
* Authenticate the client making the request to a Phabricator user account.
*
* @param ConduitAPIRequest Request being executed.
* @param dict Request metadata.
* @return null|pair Null to indicate successful authentication, or
* an error code and error message pair.
*/
private function authenticateUser(
ConduitAPIRequest $api_request,
array $metadata,
$method) {
$request = $this->getRequest();
if ($request->getUser()->getPHID()) {
$request->validateCSRF();
return $this->validateAuthenticatedUser(
$api_request,
$request->getUser());
}
$auth_type = idx($metadata, 'auth.type');
if ($auth_type === ConduitClient::AUTH_ASYMMETRIC) {
$host = idx($metadata, 'auth.host');
if (!$host) {
return array(
'ERR-INVALID-AUTH',
pht(
'Request is missing required "%s" parameter.',
'auth.host'),
);
}
// TODO: Validate that we are the host!
$raw_key = idx($metadata, 'auth.key');
$public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($raw_key);
$ssl_public_key = $public_key->toPKCS8();
// First, verify the signature.
try {
$protocol_data = $metadata;
ConduitClient::verifySignature(
$method,
$api_request->getAllParameters(),
$protocol_data,
$ssl_public_key);
} catch (Exception $ex) {
return array(
'ERR-INVALID-AUTH',
pht(
'Signature verification failure. %s',
$ex->getMessage()),
);
}
// If the signature is valid, find the user or device which is
// associated with this public key.
$stored_key = id(new PhabricatorAuthSSHKeyQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withKeys(array($public_key))
->withIsActive(true)
->executeOne();
if (!$stored_key) {
$key_summary = id(new PhutilUTF8StringTruncator())
->setMaximumBytes(64)
->truncateString($raw_key);
return array(
'ERR-INVALID-AUTH',
pht(
'No user or device is associated with the public key "%s".',
$key_summary),
);
}
$object = $stored_key->getObject();
if ($object instanceof PhabricatorUser) {
$user = $object;
} else {
if ($object->isDisabled()) {
return array(
'ERR-INVALID-AUTH',
pht(
'The key which signed this request is associated with a '.
'disabled device ("%s").',
$object->getName()),
);
}
if (!$stored_key->getIsTrusted()) {
return array(
'ERR-INVALID-AUTH',
pht(
'The key which signed this request is not trusted. Only '.
'trusted keys can be used to sign API calls.'),
);
}
if (!PhabricatorEnv::isClusterRemoteAddress()) {
return array(
'ERR-INVALID-AUTH',
pht(
'This request originates from outside of the cluster address '.
'range. Requests signed with trusted device keys must '.
'originate from within the cluster.'),
);
}
$user = PhabricatorUser::getOmnipotentUser();
// Flag this as an intracluster request.
$api_request->setIsClusterRequest(true);
}
return $this->validateAuthenticatedUser(
$api_request,
$user);
} else if ($auth_type === null) {
// No specified authentication type, continue with other authentication
// methods below.
} else {
return array(
'ERR-INVALID-AUTH',
pht(
'Provided "%s" ("%s") is not recognized.',
'auth.type',
$auth_type),
);
}
$token_string = idx($metadata, 'token');
if (strlen($token_string)) {
if (strlen($token_string) != 32) {
return array(
'ERR-INVALID-AUTH',
pht(
'API token "%s" has the wrong length. API tokens should be '.
'32 characters long.',
$token_string),
);
}
$type = head(explode('-', $token_string));
$valid_types = PhabricatorConduitToken::getAllTokenTypes();
$valid_types = array_fuse($valid_types);
if (empty($valid_types[$type])) {
return array(
'ERR-INVALID-AUTH',
pht(
'API token "%s" has the wrong format. API tokens should be '.
'32 characters long and begin with one of these prefixes: %s.',
$token_string,
implode(', ', $valid_types)),
);
}
$token = id(new PhabricatorConduitTokenQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTokens(array($token_string))
->withExpired(false)
->executeOne();
if (!$token) {
$token = id(new PhabricatorConduitTokenQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTokens(array($token_string))
->withExpired(true)
->executeOne();
if ($token) {
return array(
'ERR-INVALID-AUTH',
pht(
'API token "%s" was previously valid, but has expired.',
$token_string),
);
} else {
return array(
'ERR-INVALID-AUTH',
pht(
'API token "%s" is not valid.',
$token_string),
);
}
}
// If this is a "cli-" token, it expires shortly after it is generated
// by default. Once it is actually used, we extend its lifetime and make
// it permanent. This allows stray tokens to get cleaned up automatically
// if they aren't being used.
if ($token->getTokenType() == PhabricatorConduitToken::TYPE_COMMANDLINE) {
if ($token->getExpires()) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$token->setExpires(null);
$token->save();
unset($unguarded);
}
}
// If this is a "clr-" token, Phabricator must be configured in cluster
// mode and the remote address must be a cluster node.
if ($token->getTokenType() == PhabricatorConduitToken::TYPE_CLUSTER) {
if (!PhabricatorEnv::isClusterRemoteAddress()) {
return array(
'ERR-INVALID-AUTH',
pht(
'This request originates from outside of the cluster address '.
'range. Requests signed with cluster API tokens must '.
'originate from within the cluster.'),
);
}
// Flag this as an intracluster request.
$api_request->setIsClusterRequest(true);
}
$user = $token->getObject();
if (!($user instanceof PhabricatorUser)) {
return array(
'ERR-INVALID-AUTH',
pht('API token is not associated with a valid user.'),
);
}
return $this->validateAuthenticatedUser(
$api_request,
$user);
}
$access_token = idx($metadata, 'access_token');
if ($access_token) {
$token = id(new PhabricatorOAuthServerAccessToken())
->loadOneWhere('token = %s', $access_token);
if (!$token) {
return array(
'ERR-INVALID-AUTH',
pht('Access token does not exist.'),
);
}
$oauth_server = new PhabricatorOAuthServer();
$authorization = $oauth_server->authorizeToken($token);
if (!$authorization) {
return array(
'ERR-INVALID-AUTH',
pht('Access token is invalid or expired.'),
);
}
$user = id(new PhabricatorPeopleQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs(array($token->getUserPHID()))
->executeOne();
if (!$user) {
return array(
'ERR-INVALID-AUTH',
pht('Access token is for invalid user.'),
);
}
$ok = $this->authorizeOAuthMethodAccess($authorization, $method);
if (!$ok) {
return array(
'ERR-OAUTH-ACCESS',
pht('You do not have authorization to call this method.'),
);
}
$api_request->setOAuthToken($token);
return $this->validateAuthenticatedUser(
$api_request,
$user);
}
// For intracluster requests, use a public user if no authentication
// information is provided. We could do this safely for any request,
// but making the API fully public means there's no way to disable badly
// behaved clients.
if (PhabricatorEnv::isClusterRemoteAddress()) {
if (PhabricatorEnv::getEnvConfig('policy.allow-public')) {
$api_request->setIsClusterRequest(true);
$user = new PhabricatorUser();
return $this->validateAuthenticatedUser(
$api_request,
$user);
}
}
// Handle sessionless auth.
// TODO: This is super messy.
// TODO: Remove this in favor of token-based auth.
if (isset($metadata['authUser'])) {
$user = id(new PhabricatorUser())->loadOneWhere(
'userName = %s',
$metadata['authUser']);
if (!$user) {
return array(
'ERR-INVALID-AUTH',
pht('Authentication is invalid.'),
);
}
$token = idx($metadata, 'authToken');
$signature = idx($metadata, 'authSignature');
$certificate = $user->getConduitCertificate();
$hash = sha1($token.$certificate);
if (!phutil_hashes_are_identical($hash, $signature)) {
return array(
'ERR-INVALID-AUTH',
pht('Authentication is invalid.'),
);
}
return $this->validateAuthenticatedUser(
$api_request,
$user);
}
// Handle session-based auth.
// TODO: Remove this in favor of token-based auth.
$session_key = idx($metadata, 'sessionKey');
if (!$session_key) {
return array(
'ERR-INVALID-SESSION',
pht('Session key is not present.'),
);
}
$user = id(new PhabricatorAuthSessionEngine())
->loadUserForSession(PhabricatorAuthSession::TYPE_CONDUIT, $session_key);
if (!$user) {
return array(
'ERR-INVALID-SESSION',
pht('Session key is invalid.'),
);
}
return $this->validateAuthenticatedUser(
$api_request,
$user);
}
private function validateAuthenticatedUser(
ConduitAPIRequest $request,
PhabricatorUser $user) {
if (!$user->canEstablishAPISessions()) {
return array(
'ERR-INVALID-AUTH',
pht('User account is not permitted to use the API.'),
);
}
$request->setUser($user);
id(new PhabricatorAuthSessionEngine())
->willServeRequestForUser($user);
return null;
}
private function buildHumanReadableResponse(
$method,
ConduitAPIRequest $request = null,
$result = null,
ConduitAPIMethod $method_implementation = null) {
$param_rows = array();
$param_rows[] = array('Method', $this->renderAPIValue($method));
if ($request) {
foreach ($request->getAllParameters() as $key => $value) {
$param_rows[] = array(
$key,
$this->renderAPIValue($value),
);
}
}
$param_table = new AphrontTableView($param_rows);
$param_table->setColumnClasses(
array(
'header',
'wide',
));
$result_rows = array();
foreach ($result as $key => $value) {
$result_rows[] = array(
$key,
$this->renderAPIValue($value),
);
}
$result_table = new AphrontTableView($result_rows);
$result_table->setColumnClasses(
array(
'header',
'wide',
));
$param_panel = id(new PHUIObjectBoxView())
->setHeaderText(pht('Method Parameters'))
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
->setTable($param_table);
$result_panel = id(new PHUIObjectBoxView())
->setHeaderText(pht('Method Result'))
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
->setTable($result_table);
$method_uri = $this->getApplicationURI('method/'.$method.'/');
$crumbs = $this->buildApplicationCrumbs()
->addTextCrumb($method, $method_uri)
->addTextCrumb(pht('Call'))
->setBorder(true);
$example_panel = null;
if ($request && $method_implementation) {
$params = $request->getAllParameters();
$example_panel = $this->renderExampleBox(
$method_implementation,
$params);
}
$title = pht('Method Call Result');
$header = id(new PHUIHeaderView())
->setHeader($title)
->setHeaderIcon('fa-exchange');
$view = id(new PHUITwoColumnView())
->setHeader($header)
->setFooter(array(
$param_panel,
$result_panel,
$example_panel,
));
$title = pht('Method Call Result');
return $this->newPage()
->setTitle($title)
->setCrumbs($crumbs)
->appendChild($view);
}
private function renderAPIValue($value) {
$json = new PhutilJSON();
if (is_array($value)) {
$value = $json->encodeFormatted($value);
}
$value = phutil_tag(
'pre',
array('style' => 'white-space: pre-wrap;'),
$value);
return $value;
}
private function decodeConduitParams(
AphrontRequest $request,
$method) {
$content_type = $request->getHTTPHeader('Content-Type');
if ($content_type == 'application/json') {
throw new Exception(
pht('Use form-encoded data to submit parameters to Conduit endpoints. '.
'Sending a JSON-encoded body and setting \'Content-Type\': '.
'\'application/json\' is not currently supported.'));
}
// Look for parameters from the Conduit API Console, which are encoded
// as HTTP POST parameters in an array, e.g.:
//
// params[name]=value&params[name2]=value2
//
// The fields are individually JSON encoded, since we require users to
// enter JSON so that we avoid type ambiguity.
$params = $request->getArr('params', null);
if ($params !== null) {
foreach ($params as $key => $value) {
if ($value == '') {
// Interpret empty string null (e.g., the user didn't type anything
// into the box).
$value = 'null';
}
$decoded_value = json_decode($value, true);
if ($decoded_value === null && strtolower($value) != 'null') {
// When json_decode() fails, it returns null. This almost certainly
// indicates that a user was using the web UI and didn't put quotes
// around a string value. We can either do what we think they meant
// (treat it as a string) or fail. For now, err on the side of
// caution and fail. In the future, if we make the Conduit API
// actually do type checking, it might be reasonable to treat it as
// a string if the parameter type is string.
throw new Exception(
pht(
"The value for parameter '%s' is not valid JSON. All ".
"parameters must be encoded as JSON values, including strings ".
"(which means you need to surround them in double quotes). ".
"Check your syntax. Value was: %s.",
$key,
$value));
}
$params[$key] = $decoded_value;
}
$metadata = idx($params, '__conduit__', array());
unset($params['__conduit__']);
return array($metadata, $params, true);
}
// Otherwise, look for a single parameter called 'params' which has the
// entire param dictionary JSON encoded.
$params_json = $request->getStr('params');
if (strlen($params_json)) {
$params = null;
try {
$params = phutil_json_decode($params_json);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht(
"Invalid parameter information was passed to method '%s'.",
$method),
$ex);
}
$metadata = idx($params, '__conduit__', array());
unset($params['__conduit__']);
return array($metadata, $params, true);
}
// If we do not have `params`, assume this is a simple HTTP request with
// HTTP key-value pairs.
$params = array();
$metadata = array();
foreach ($request->getPassthroughRequestData() as $key => $value) {
$meta_key = ConduitAPIMethod::getParameterMetadataKey($key);
if ($meta_key !== null) {
$metadata[$meta_key] = $value;
} else {
$params[$key] = $value;
}
}
return array($metadata, $params, false);
}
private function authorizeOAuthMethodAccess(
PhabricatorOAuthClientAuthorization $authorization,
$method_name) {
$method = ConduitAPIMethod::getConduitMethod($method_name);
if (!$method) {
return false;
}
$required_scope = $method->getRequiredScope();
switch ($required_scope) {
case ConduitAPIMethod::SCOPE_ALWAYS:
return true;
case ConduitAPIMethod::SCOPE_NEVER:
return false;
}
$authorization_scope = $authorization->getScope();
if (!empty($authorization_scope[$required_scope])) {
return true;
}
return false;
}
private function getConduitCapabilities() {
$capabilities = array();
if (AphrontRequestStream::supportsGzip()) {
$capabilities[] = 'gzip';
}
return $capabilities;
}
}
diff --git a/src/applications/config/check/PhabricatorDatabaseSetupCheck.php b/src/applications/config/check/PhabricatorDatabaseSetupCheck.php
index a5989f37b3..d3f6d52f04 100644
--- a/src/applications/config/check/PhabricatorDatabaseSetupCheck.php
+++ b/src/applications/config/check/PhabricatorDatabaseSetupCheck.php
@@ -1,242 +1,242 @@
<?php
final class PhabricatorDatabaseSetupCheck extends PhabricatorSetupCheck {
public function getDefaultGroup() {
return self::GROUP_IMPORTANT;
}
public function getExecutionOrder() {
// This must run after basic PHP checks, but before most other checks.
return 500;
}
protected function executeChecks() {
$host = PhabricatorEnv::getEnvConfig('mysql.host');
$matches = null;
if (preg_match('/^([^:]+):(\d+)$/', $host, $matches)) {
$host = $matches[1];
$port = $matches[2];
$this->newIssue('storage.mysql.hostport')
->setName(pht('Deprecated mysql.host Format'))
->setSummary(
pht(
'Move port information from `%s` to `%s` in your config.',
'mysql.host',
'mysql.port'))
->setMessage(
pht(
'Your `%s` configuration contains a port number, but this usage '.
'is deprecated. Instead, put the port number in `%s`.',
'mysql.host',
'mysql.port'))
->addPhabricatorConfig('mysql.host')
->addPhabricatorConfig('mysql.port')
->addCommand(
hsprintf(
'<tt>$</tt> ./bin/config set mysql.host %s',
$host))
->addCommand(
hsprintf(
'<tt>$</tt> ./bin/config set mysql.port %s',
$port));
}
$refs = PhabricatorDatabaseRef::queryAll();
$refs = mpull($refs, null, 'getRefKey');
// Test if we can connect to each database first. If we can not connect
// to a particular database, we only raise a warning: this allows new web
// nodes to start during a disaster, when some databases may be correctly
// configured but not reachable.
$connect_map = array();
$any_connection = false;
foreach ($refs as $ref_key => $ref) {
$conn_raw = $ref->newManagementConnection();
try {
queryfx($conn_raw, 'SELECT 1');
$database_exception = null;
$any_connection = true;
} catch (AphrontInvalidCredentialsQueryException $ex) {
$database_exception = $ex;
} catch (AphrontConnectionQueryException $ex) {
$database_exception = $ex;
}
if ($database_exception) {
$connect_map[$ref_key] = $database_exception;
unset($refs[$ref_key]);
}
}
if ($connect_map) {
// This is only a fatal error if we could not connect to anything. If
// possible, we still want to start if some database hosts can not be
// reached.
$is_fatal = !$any_connection;
foreach ($connect_map as $ref_key => $database_exception) {
$issue = PhabricatorSetupIssue::newDatabaseConnectionIssue(
$database_exception,
$is_fatal);
$this->addIssue($issue);
}
}
foreach ($refs as $ref_key => $ref) {
if ($this->executeRefChecks($ref)) {
return;
}
}
}
private function executeRefChecks(PhabricatorDatabaseRef $ref) {
$conn_raw = $ref->newManagementConnection();
$ref_key = $ref->getRefKey();
$engines = queryfx_all($conn_raw, 'SHOW ENGINES');
$engines = ipull($engines, 'Support', 'Engine');
$innodb = idx($engines, 'InnoDB');
if ($innodb != 'YES' && $innodb != 'DEFAULT') {
$message = pht(
'The "InnoDB" engine is not available in MySQL (on host "%s"). '.
'Enable InnoDB in your MySQL configuration.'.
"\n\n".
- '(If you aleady created tables, MySQL incorrectly used some other '.
+ '(If you already created tables, MySQL incorrectly used some other '.
'engine to create them. You need to convert them or drop and '.
'reinitialize them.)',
$ref_key);
$this->newIssue('mysql.innodb')
->setName(pht('MySQL InnoDB Engine Not Available'))
->setMessage($message)
->setIsFatal(true);
return true;
}
$namespace = PhabricatorEnv::getEnvConfig('storage.default-namespace');
$databases = queryfx_all($conn_raw, 'SHOW DATABASES');
$databases = ipull($databases, 'Database', 'Database');
if (empty($databases[$namespace.'_meta_data'])) {
$message = pht(
'Run the storage upgrade script to setup databases (host "%s" has '.
'not been initialized).',
$ref_key);
$this->newIssue('storage.upgrade')
->setName(pht('Setup MySQL Schema'))
->setMessage($message)
->setIsFatal(true)
->addCommand(hsprintf('<tt>$</tt> ./bin/storage upgrade'));
return true;
}
$conn_meta = $ref->newApplicationConnection(
$namespace.'_meta_data');
$applied = queryfx_all($conn_meta, 'SELECT patch FROM patch_status');
$applied = ipull($applied, 'patch', 'patch');
$all = PhabricatorSQLPatchList::buildAllPatches();
$diff = array_diff_key($all, $applied);
if ($diff) {
$message = pht(
'Run the storage upgrade script to upgrade databases (host "%s" is '.
'out of date). Missing patches: %s.',
$ref_key,
implode(', ', array_keys($diff)));
$this->newIssue('storage.patch')
->setName(pht('Upgrade MySQL Schema'))
->setIsFatal(true)
->setMessage($message)
->addCommand(
hsprintf('<tt>$</tt> ./bin/storage upgrade'));
return true;
}
// NOTE: It's possible that replication is broken but we have not been
// granted permission to "SHOW SLAVE STATUS" so we can't figure it out.
// We allow this kind of configuration and survive these checks, trusting
// that operations knows what they're doing. This issue is shown on the
// "Database Servers" console.
switch ($ref->getReplicaStatus()) {
case PhabricatorDatabaseRef::REPLICATION_MASTER_REPLICA:
$message = pht(
'Database host "%s" is configured as a master, but is replicating '.
'another host. This is dangerous and can mangle or destroy data. '.
'Only replicas should be replicating. Stop replication on the '.
'host or adjust configuration.',
$ref->getRefKey());
$this->newIssue('db.master.replicating')
->setName(pht('Replicating Master'))
->setIsFatal(true)
->setMessage($message);
return true;
case PhabricatorDatabaseRef::REPLICATION_REPLICA_NONE:
case PhabricatorDatabaseRef::REPLICATION_NOT_REPLICATING:
if (!$ref->getIsMaster()) {
$message = pht(
'Database replica "%s" is listed as a replica, but is not '.
'currently replicating. You are vulnerable to data loss if '.
'the master fails.',
$ref->getRefKey());
// This isn't a fatal because it can normally only put data at risk,
// not actually do anything destructive or unrecoverable.
$this->newIssue('db.replica.not-replicating')
->setName(pht('Nonreplicating Replica'))
->setMessage($message);
}
break;
}
// If we have more than one master, we require that the cluster database
// configuration written to each database node is exactly the same as the
// one we are running with.
$masters = PhabricatorDatabaseRef::getAllMasterDatabaseRefs();
if (count($masters) > 1) {
$state_actual = queryfx_one(
$conn_meta,
'SELECT stateValue FROM %T WHERE stateKey = %s',
PhabricatorStorageManagementAPI::TABLE_HOSTSTATE,
'cluster.databases');
if ($state_actual) {
$state_actual = $state_actual['stateValue'];
}
$state_expect = $ref->getPartitionStateForCommit();
if ($state_expect !== $state_actual) {
$message = pht(
'Database host "%s" has a configured cluster state which disagrees '.
'with the state on this host ("%s"). Run `bin/storage partition` '.
'to commit local state to the cluster. This host may have started '.
'with an out-of-date configuration.',
$ref->getRefKey(),
php_uname('n'));
$this->newIssue('db.state.desync')
->setName(pht('Cluster Configuration Out of Sync'))
->setMessage($message)
->setIsFatal(true);
return true;
}
}
}
}
diff --git a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php
index cca8794055..36cbd9bd51 100644
--- a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php
+++ b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php
@@ -1,311 +1,311 @@
<?php
final class PhabricatorMetaMTAConfigOptions
extends PhabricatorApplicationConfigOptions {
public function getName() {
return pht('Mail');
}
public function getDescription() {
return pht('Configure Mail.');
}
public function getIcon() {
return 'fa-send';
}
public function getGroup() {
return 'core';
}
public function getOptions() {
$send_as_user_desc = $this->deformat(pht(<<<EODOC
When a user takes an action which generates an email notification (like
commenting on a Differential revision), the "From" address can either be set
to the user's email address (like "alincoln@logcabin.com") or the
"metamta.defualt-address" address.
The user experience is generally better if the user's real address is used as
the "From" header value, since the messages are easier to organize when they
appear in mail clients, but this will only work if the server is authorized to
send email on behalf of the "From" domain. Practically, this means:
- If you are doing an install for Example Corp and all the users will have
corporate @corp.example.com addresses and any hosts this software is running
on are authorized to send email from corp.example.com, you can enable this
to make the user experience a little better.
- If you are doing an install for an open source project and your users will
be registering via third-party services and/or using personal email
addresses, you probably should not enable this or all of your outgoing
email might vanish into SFP blackholes.
- If your install is anything else, you're safer leaving this off, at least
initially, since the risk in turning it on is that your outgoing mail will
never arrive.
EODOC
));
$one_mail_per_recipient_desc = $this->deformat(pht(<<<EODOC
When a message is sent to multiple recipients (for example, several reviewers on
a code review), it can either be delieverd as one email to everyone (e.g., "To:
alincoln, usgrant, htaft") or separate emails to each user (e.g., "To:
alincoln", "To: usgrant", "To: htaft"). The major advantages and disadvantages
of each approach are:
- One mail to everyone:
- This violates policy controls. The body of the mail is generated without
respect for object policies.
- Recipients can see To/Cc at a glance.
- If you use mailing lists, you won't get duplicate mail if you're
a normal recipient and also Cc'd on a mailing list.
- Getting threading to work properly is harder, and probably requires
making mail less useful by turning off options.
- Sometimes people will "Reply All", which can send mail to too many
recipients. This software will try not to send mail to users who already
received a similar message, but can not prevent all stray email arising
from "Reply All".
- Not supported with a private reply-to address.
- Mail messages are sent in the server default translation.
- Mail that must be delivered over secure channels will leak the recipient
list in the "To" and "Cc" headers.
- One mail to each user:
- Policy controls work correctly and are enforced per-user.
- Recipients need to look in the mail body to see To/Cc.
- If you use mailing lists, recipients may sometimes get duplicate
mail.
- Getting threading to work properly is easier, and threading settings
- can be customzied by each user.
+ can be customized by each user.
- "Reply All" will never send extra mail to other users involved in the
thread.
- Required if private reply-to addresses are configured.
- Mail messages are sent in the language of user preference.
EODOC
));
$reply_hints_description = $this->deformat(pht(<<<EODOC
You can disable the hints under "REPLY HANDLER ACTIONS" if users prefer
smaller messages. The actions themselves will still work properly.
EODOC
));
$recipient_hints_description = $this->deformat(pht(<<<EODOC
You can disable the "To:" and "Cc:" footers in mail if users prefer smaller
messages.
EODOC
));
$email_preferences_description = $this->deformat(pht(<<<EODOC
You can disable the email preference link in emails if users prefer smaller
emails.
EODOC
));
$re_prefix_description = $this->deformat(pht(<<<EODOC
Mail.app on OS X Lion won't respect threading headers unless the subject is
prefixed with "Re:". If you enable this option, this software will add "Re:" to
the subject line of all mail which is expected to thread. If you've set
'metamta.one-mail-per-recipient', users can override this setting in their
preferences.
EODOC
));
$vary_subjects_description = $this->deformat(pht(<<<EODOC
If true, allow MetaMTA to change mail subjects to put text like '[Accepted]' and
'[Commented]' in them. This makes subjects more useful, but might break
threading on some clients. If you've set '%s', users can override this setting
in their preferences.
EODOC
,
'metamta.one-mail-per-recipient'));
$reply_to_description = $this->deformat(pht(<<<EODOC
If you enable `%s`, this software uses "From" to authenticate users. You can
additionally enable this setting to try to authenticate with 'Reply-To'. Note
that this is completely spoofable and insecure (any user can set any 'Reply-To'
address) but depending on the nature of your install or other deliverability
conditions this might be okay. Generally, you can't do much more by spoofing
Reply-To than be annoying (you can write but not read content). But this is
still **COMPLETELY INSECURE**.
EODOC
,
'metamta.public-replies'));
$adapter_description = $this->deformat(pht(<<<EODOC
Adapter class to use to transmit mail to the MTA. The default uses
PHPMailerLite, which will invoke "sendmail". This is appropriate if sendmail
actually works on your host, but if you haven't configured mail it may not be so
great. A number of other mailers are available (e.g., SES, SendGrid, SMTP,
custom mailers). This option is deprecated in favor of 'cluster.mailers'.
EODOC
));
$public_replies_description = $this->deformat(pht(<<<EODOC
By default, this software generates unique reply-to addresses and sends a
separate email to each recipient when you enable reply handling. This is more
secure than using "From" to establish user identity, but can mean users may
receive multiple emails when they are on mailing lists. Instead, you can use a
single, non-unique reply to address and authenticate users based on the "From"
address by setting this to 'true'. This trades away a little bit of security
for convenience, but it's reasonable in many installs. Object interactions are
still protected using hashes in the single public email address, so objects
can not be replied to blindly.
EODOC
));
$single_description = $this->deformat(pht(<<<EODOC
If you want to use a single mailbox for reply mail, you can use this
and set a common prefix for generated reply addresses. It will
make use of the fact that a mail-address such as
`devtools+D123+1hjk213h@example.com` will be delivered to the `devtools`
user's mailbox. Set this to the left part of the email address and it will be
prepended to all generated reply addresses.
For example, if you want to use `devtools@example.com`, this should be set
to `devtools`.
EODOC
));
$address_description = $this->deformat(pht(<<<EODOC
When email is sent, what format should the software use for users' email
addresses? Valid values are:
- `short`: 'gwashington <gwashington@example.com>'
- `real`: 'George Washington <gwashington@example.com>'
- `full`: 'gwashington (George Washington) <gwashington@example.com>'
The default is `full`.
EODOC
));
$mailers_description = $this->deformat(pht(<<<EODOC
Define one or more mail transmission services. For help with configuring
mailers, see **[[ %s | %s ]]** in the documentation.
EODOC
,
PhabricatorEnv::getDoclink('Configuring Outbound Email'),
pht('Configuring Outbound Email')));
$default_description = $this->deformat(pht(<<<EODOC
Default address used as a "From" or "To" email address when an address is
required but no meaningful address is available.
If you configure inbound mail, you generally do not need to set this:
the software will automatically generate and use a suitable mailbox on the
inbound mail domain.
Otherwise, this option should be configured to point at a valid mailbox which
discards all mail sent to it. If you point it at an invalid mailbox, mail sent
by the software and some mail sent by users will bounce. If you point it at a
real user mailbox, that user will get a lot of mail they don't want.
For further guidance, see **[[ %s | %s ]]** in the documentation.
EODOC
,
PhabricatorEnv::getDoclink('Configuring Outbound Email'),
pht('Configuring Outbound Email')));
return array(
$this->newOption('cluster.mailers', 'cluster.mailers', array())
->setHidden(true)
->setDescription($mailers_description),
$this->newOption('metamta.default-address', 'string', null)
->setLocked(true)
->setSummary(pht('Default address used when generating mail.'))
->setDescription($default_description),
$this->newOption(
'metamta.one-mail-per-recipient',
'bool',
true)
->setLocked(true)
->setBoolOptions(
array(
pht('Send Mail To Each Recipient'),
pht('Send Mail To All Recipients'),
))
->setSummary(
pht(
'Controls whether email for multiple recipients is sent by '.
'creating one message with everyone in the "To:" line, or '.
'multiple messages that each have a single recipeint in the '.
'"To:" line.'))
->setDescription($one_mail_per_recipient_desc),
$this->newOption('metamta.can-send-as-user', 'bool', false)
->setBoolOptions(
array(
pht('Send as User Taking Action'),
pht(
'Send as %s',
PlatformSymbols::getPlatformServerName()),
))
->setSummary(
pht(
'Controls whether email is sent "From" users.'))
->setDescription($send_as_user_desc),
$this->newOption(
'metamta.reply-handler-domain',
'string',
null)
->setLocked(true)
->setDescription(pht('Domain used for reply email addresses.'))
->addExample('devtools.example.com', ''),
$this->newOption('metamta.recipients.show-hints', 'bool', true)
->setBoolOptions(
array(
pht('Show Recipient Hints'),
pht('No Recipient Hints'),
))
->setSummary(pht('Show "To:" and "Cc:" footer hints in email.'))
->setDescription($recipient_hints_description),
$this->newOption('metamta.email-preferences', 'bool', true)
->setBoolOptions(
array(
pht('Show Email Preferences Link'),
pht('No Email Preferences Link'),
))
->setSummary(pht('Show email preferences link in email.'))
->setDescription($email_preferences_description),
$this->newOption('metamta.public-replies', 'bool', false)
->setBoolOptions(
array(
pht('Use Public Replies (Less Secure)'),
pht('Use Private Replies (More Secure)'),
))
->setSummary(
pht(
'Reply addresses can either be private (more secure) or '.
'public (which works better with mailing lists).'))
->setDescription($public_replies_description),
$this->newOption('metamta.single-reply-handler-prefix', 'string', null)
->setSummary(
pht('Allow a single mailbox to be used for all replies.'))
->setDescription($single_description),
$this->newOption('metamta.user-address-format', 'enum', 'full')
->setEnumOptions(
array(
'short' => pht('Short'),
'real' => pht('Real'),
'full' => pht('Full'),
))
->setSummary(pht('Control how user names are rendered in mail.'))
->setDescription($address_description)
->addExample('gwashington <gwashington@example.com>', 'short')
->addExample('George Washington <gwashington@example.com>', 'real')
->addExample(
'gwashington (George Washington) <gwashington@example.com>',
'full'),
$this->newOption('metamta.email-body-limit', 'int', 524288)
->setDescription(
pht(
'You can set a limit for the maximum byte size of outbound mail. '.
'Mail which is larger than this limit will be truncated before '.
'being sent. This can be useful if your MTA rejects mail which '.
'exceeds some limit (this is reasonably common). Specify a value '.
'in bytes.'))
->setSummary(pht('Global cap for size of generated emails (bytes).'))
->addExample(524288, pht('Truncate at 512KB'))
->addExample(1048576, pht('Truncate at 1MB')),
);
}
}
diff --git a/src/applications/harbormaster/xaction/build/HarbormasterBuildMessageResumeTransaction.php b/src/applications/harbormaster/xaction/build/HarbormasterBuildMessageResumeTransaction.php
index 1bd7094799..65f27ae374 100644
--- a/src/applications/harbormaster/xaction/build/HarbormasterBuildMessageResumeTransaction.php
+++ b/src/applications/harbormaster/xaction/build/HarbormasterBuildMessageResumeTransaction.php
@@ -1,119 +1,119 @@
<?php
final class HarbormasterBuildMessageResumeTransaction
extends HarbormasterBuildMessageTransaction {
const TRANSACTIONTYPE = 'message/resume';
const MESSAGETYPE = 'resume';
public function getHarbormasterBuildMessageName() {
return pht('Resume Build');
}
public function getHarbormasterBuildableMessageName() {
return pht('Resume Builds');
}
public function getHarbormasterBuildableMessageEffect() {
return pht('Build will resume.');
}
public function newConfirmPromptTitle() {
return pht('Really resume build?');
}
public function newConfirmPromptBody() {
return pht(
'Work will continue on the build. Really resume?');
}
public function getHarbormasterBuildMessageDescription() {
return pht('Resume work on a previously paused build.');
}
public function newBuildableConfirmPromptTitle(
array $builds,
array $sendable) {
return pht(
'Really resume %s build(s)?',
phutil_count($builds));
}
public function newBuildableConfirmPromptBody(
array $builds,
array $sendable) {
if (count($sendable) === count($builds)) {
return pht(
'Work will continue on all builds. Really resume?');
} else {
return pht(
'You can only resume some builds. Work will continue on builds '.
'you have permission to resume.');
}
}
public function getTitle() {
return pht(
'%s resumed this build.',
$this->renderAuthor());
}
public function getIcon() {
return 'fa-play';
}
public function applyInternalEffects($object, $value) {
$actor = $this->getActor();
$build = $object;
$build->setBuildStatus(HarbormasterBuildStatus::STATUS_BUILDING);
}
protected function newCanApplyMessageAssertion(
PhabricatorUser $viewer,
HarbormasterBuild $build) {
if ($build->isAutobuild()) {
throw new HarbormasterMessageException(
pht('Unable to Resume Build'),
pht(
'You can not resume a build that uses an autoplan.'));
}
if (!$build->isPaused() && !$build->isPausing()) {
throw new HarbormasterMessageException(
pht('Unable to Resume Build'),
pht(
'You can not resume this build because it is not paused. You can '.
'only resume a paused build.'));
}
}
protected function newCanSendMessageAssertion(
PhabricatorUser $viewer,
HarbormasterBuild $build) {
if ($build->isResuming()) {
throw new HarbormasterMessageException(
pht('Unable to Resume Build'),
pht(
- 'You can not resume this build beacuse it is already resuming.'));
+ 'You can not resume this build because it is already resuming.'));
}
if ($build->isRestarting()) {
throw new HarbormasterMessageException(
pht('Unable to Resume Build'),
pht('You can not resume this build because it is already restarting.'));
}
if ($build->isAborting()) {
throw new HarbormasterMessageException(
pht('Unable to Resume Build'),
pht('You can not resume this build because it is already aborting.'));
}
}
}
diff --git a/src/applications/repository/management/PhabricatorRepositoryManagementMaintenanceWorkflow.php b/src/applications/repository/management/PhabricatorRepositoryManagementMaintenanceWorkflow.php
index 65fe0adaad..6c2f64b31c 100644
--- a/src/applications/repository/management/PhabricatorRepositoryManagementMaintenanceWorkflow.php
+++ b/src/applications/repository/management/PhabricatorRepositoryManagementMaintenanceWorkflow.php
@@ -1,104 +1,104 @@
<?php
final class PhabricatorRepositoryManagementMaintenanceWorkflow
extends PhabricatorRepositoryManagementWorkflow {
protected function didConstruct() {
$this
->setName('maintenance')
->setExamples(
"**maintenance** --start __message__ __repository__ ...\n".
"**maintenance** --stop __repository__")
->setSynopsis(
pht('Set or clear read-only mode for repository maintenance.'))
->setArguments(
array(
array(
'name' => 'start',
'param' => 'message',
'help' => pht(
'Put repositories into maintenance mode.'),
),
array(
'name' => 'stop',
'help' => pht(
'Take repositories out of maintenance mode, returning them '.
- 'to normal serice.'),
+ 'to normal service.'),
),
array(
'name' => 'repositories',
'wildcard' => true,
),
));
}
public function execute(PhutilArgumentParser $args) {
$viewer = $this->getViewer();
$repositories = $this->loadRepositories($args, 'repositories');
if (!$repositories) {
throw new PhutilArgumentUsageException(
pht('Specify one or more repositories to act on.'));
}
$message = $args->getArg('start');
$is_start = (bool)strlen($message);
$is_stop = $args->getArg('stop');
if (!$is_start && !$is_stop) {
throw new PhutilArgumentUsageException(
pht(
'Use "--start <message>" to put repositories into maintenance '.
'mode, or "--stop" to take them out of maintenance mode.'));
}
if ($is_start && $is_stop) {
throw new PhutilArgumentUsageException(
pht(
'Specify either "--start" or "--stop", but not both.'));
}
$content_source = $this->newContentSource();
$diffusion_phid = id(new PhabricatorDiffusionApplication())->getPHID();
if ($is_start) {
$new_value = $message;
} else {
$new_value = null;
}
foreach ($repositories as $repository) {
$xactions = array();
$xactions[] = $repository->getApplicationTransactionTemplate()
->setTransactionType(
PhabricatorRepositoryMaintenanceTransaction::TRANSACTIONTYPE)
->setNewValue($new_value);
$repository->getApplicationTransactionEditor()
->setActor($viewer)
->setActingAsPHID($diffusion_phid)
->setContentSource($content_source)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->applyTransactions($repository, $xactions);
if ($is_start) {
echo tsprintf(
"%s\n",
pht(
'Put repository "%s" into maintenance mode.',
$repository->getDisplayName()));
} else {
echo tsprintf(
"%s\n",
pht(
'Took repository "%s" out of maintenance mode.',
$repository->getDisplayName()));
}
}
return 0;
}
}
diff --git a/src/applications/repository/worker/__tests__/PhabricatorChangeParserTestCase.php b/src/applications/repository/worker/__tests__/PhabricatorChangeParserTestCase.php
index c3dd6f3d60..0ffb963b71 100644
--- a/src/applications/repository/worker/__tests__/PhabricatorChangeParserTestCase.php
+++ b/src/applications/repository/worker/__tests__/PhabricatorChangeParserTestCase.php
@@ -1,1245 +1,1245 @@
<?php
final class PhabricatorChangeParserTestCase
extends PhabricatorWorkingCopyTestCase {
public function testGitParser() {
$repository = $this->buildDiscoveredRepository('CHA');
$viewer = PhabricatorUser::getOmnipotentUser();
$commits = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepositoryIDs(array($repository->getID()))
->execute();
$this->expectChanges(
$repository,
$commits,
array(
// 8ebb73c add +x
'8ebb73c3f127625ad090472f4f3bfc72804def54' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1389892449,
),
array(
'/file_moved',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_NORMAL,
1,
1389892449,
),
),
// ee9c790 add symlink
'ee9c7909e012da7d75e8e1293c7803a6e73ac26a' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1389892436,
),
array(
'/file_link',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_SYMLINK,
1,
1389892436,
),
),
// 7260ca4 add directory file
'7260ca4b6cec35e755bb5365c4ccdd3f1977772e' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1389892408,
),
array(
'/dir',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_DIRECTORY,
1,
1389892408,
),
array(
'/dir/subfile',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
1389892408,
),
),
// 1fe783c move a file
'1fe783cf207c1e5f3e01650d2d9cb80b8a707f0e' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1389892388,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_MOVE_AWAY,
DifferentialChangeType::FILE_NORMAL,
1,
1389892388,
),
array(
'/file_moved',
'/file',
'1fe783cf207c1e5f3e01650d2d9cb80b8a707f0e',
DifferentialChangeType::TYPE_MOVE_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
1389892388,
),
),
// 376af8c copy a file
'376af8cd8f5b96ec55b7d9a86ccc85b8df8fb833' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1389892377,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
1389892377,
),
array(
'/file_copy',
'/file',
'376af8cd8f5b96ec55b7d9a86ccc85b8df8fb833',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
1389892377,
),
),
// ece6ea6 changed a file
'ece6ea6c6836e8b11a103e21707b8f30e6840c94' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1389892352,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_NORMAL,
1,
1389892352,
),
),
// 513103f added a file
'513103f65b8413dd2f1a1b5c1d4852a4a598540f' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
// This is the initial commit and technically created this
// directory; arguably the parser should figure this out and
// mark this as a direct change.
0,
1389892330,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
1389892330,
),
),
));
}
public function testMercurialParser() {
$this->requireBinaryForTest('hg');
$repository = $this->buildDiscoveredRepository('CHB');
$viewer = PhabricatorUser::getOmnipotentUser();
$commits = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepositoryIDs(array($repository->getID()))
->execute();
$this->expectChanges(
$repository,
$commits,
array(
'970357a2dc4264060e65d68e42240bb4e5984085' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1390249395,
),
array(
'/file_moved',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_NORMAL,
1,
1390249395,
),
),
'fbb49af9788e5dbffbc05a060b680df1fd457be3' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1390249380,
),
array(
'/file_link',
null,
null,
DifferentialChangeType::TYPE_ADD,
// TODO: This is not correct, and should be FILE_SYMLINK. See
// note in the parser about this. This is a known bug.
DifferentialChangeType::FILE_NORMAL,
1,
1390249380,
),
),
'0e8d3465944c7ed7a7c139da7edc652cf80dba69' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1390249342,
),
array(
'/dir',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_DIRECTORY,
1,
1390249342,
),
array(
'/dir/subfile',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
1390249342,
),
),
'22c75131ff15c8a44d7a729c4542b7f4c8ed27f4' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1390249320,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_MOVE_AWAY,
DifferentialChangeType::FILE_NORMAL,
1,
1390249320,
),
array(
'/file_moved',
'/file',
'22c75131ff15c8a44d7a729c4542b7f4c8ed27f4',
DifferentialChangeType::TYPE_MOVE_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
1390249320,
),
),
'd9d252df30cb7251ad3ea121eff30c7d2e36dd67' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1390249308,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
1390249308,
),
array(
'/file_copy',
'/file',
'd9d252df30cb7251ad3ea121eff30c7d2e36dd67',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
1390249308,
),
),
'1fc0445d5e3d0f33e9dcbb68bbe419a847460d25' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1390249294,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_NORMAL,
1,
1390249294,
),
),
'61518e196efb7f80700333cc0d00634c2578871a' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_DIRECTORY,
1,
1390249286,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
1390249286,
),
),
));
}
public function testSubversionParser() {
$this->requireBinaryForTest('svn');
$repository = $this->buildDiscoveredRepository('CHC');
$viewer = PhabricatorUser::getOmnipotentUser();
$commits = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepositoryIDs(array($repository->getID()))
->execute();
$this->expectChanges(
$repository,
$commits,
array(
'15' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
15,
),
array(
'/file_copy',
null,
null,
DifferentialChangeType::TYPE_MULTICOPY,
DifferentialChangeType::FILE_NORMAL,
1,
15,
),
array(
'/file_copy_x',
'/file_copy',
'12',
DifferentialChangeType::TYPE_MOVE_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
15,
),
array(
'/file_copy_y',
'/file_copy',
'12',
DifferentialChangeType::TYPE_MOVE_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
15,
),
array(
'/file_copy_z',
'/file_copy',
'12',
DifferentialChangeType::TYPE_MOVE_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
15,
),
),
// Add a file from a different revision
'14' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
14,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
14,
),
array(
'/file_1',
'/file',
'1',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
14,
),
),
// Property change on "/"
'13' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_DIRECTORY,
1,
13,
),
),
// Copy a directory, removing and adding files to the copy
'12' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
12,
),
array(
'/dir',
null,
null,
- // TODO: This might reasonbly be considered a bug in the parser; it
+ // TODO: This might reasonably be considered a bug in the parser; it
// should probably be COPY_AWAY.
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
12,
),
array(
'/dir/a',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
12,
),
array(
'/dir/b',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
12,
),
array(
'/dir/subdir',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_DIRECTORY,
0,
12,
),
array(
'/dir/subdir/a',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
12,
),
array(
'/dir/subdir/b',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
12,
),
array(
'/dir_copy',
'/dir',
'11',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_DIRECTORY,
1,
12,
),
array(
'/dir_copy/a',
'/dir/a',
'11',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
12,
),
array(
'/dir_copy/b',
'/dir/b',
'11',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
12,
),
array(
'/dir_copy/subdir',
'/dir/subdir',
'11',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_DIRECTORY,
1,
12,
),
array(
'/dir_copy/subdir/a',
'/dir/subdir/a',
'11',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
12,
),
array(
'/dir_copy/subdir/b',
'/dir/subdir/b',
'11',
DifferentialChangeType::TYPE_DELETE,
DifferentialChangeType::FILE_NORMAL,
1,
12,
),
array(
'/dir_copy/subdir/c',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
12,
),
),
// Add a directory with a subdirectory and files, sets up next commit
'11' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
11,
),
array(
'/dir',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_DIRECTORY,
1,
11,
),
array(
'/dir/a',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
11,
),
array(
'/dir/b',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
11,
),
array(
'/dir/subdir',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_DIRECTORY,
1,
11,
),
array(
'/dir/subdir/a',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
11,
),
array(
'/dir/subdir/b',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
11,
),
),
// Remove directory
'10' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
10,
),
array(
'/dir',
null,
null,
DifferentialChangeType::TYPE_DELETE,
DifferentialChangeType::FILE_DIRECTORY,
1,
10,
),
array(
'/dir/subfile',
null,
null,
DifferentialChangeType::TYPE_DELETE,
DifferentialChangeType::FILE_NORMAL,
1,
10,
),
),
// Replace directory with file
'9' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
9,
),
array(
'/file_moved',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_DIRECTORY,
1,
9,
),
),
// Replace file with file
'8' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
8,
),
array(
'/file_moved',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_NORMAL,
1,
8,
),
),
'7' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
7,
),
array(
'/file_moved',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_NORMAL,
1,
7,
),
),
'6' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
6,
),
array(
'/file_link',
null,
null,
DifferentialChangeType::TYPE_ADD,
// TODO: This is not correct, and should be FILE_SYMLINK.
DifferentialChangeType::FILE_NORMAL,
1,
6,
),
),
'5' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
5,
),
array(
'/dir',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_DIRECTORY,
1,
5,
),
array(
'/dir/subfile',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
5,
),
),
'4' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
4,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_MOVE_AWAY,
DifferentialChangeType::FILE_NORMAL,
1,
4,
),
array(
'/file_moved',
'/file',
'2',
DifferentialChangeType::TYPE_MOVE_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
4,
),
),
'3' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
3,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
3,
),
array(
'/file_copy',
'/file',
'2',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
3,
),
),
'2' => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
2,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_CHANGE,
DifferentialChangeType::FILE_NORMAL,
1,
2,
),
),
'1' => array(
array(
'/',
null,
null,
// The Git and Svn parsers don't recognize the first commit as
// creating "/", while the Mercurial parser does. All the parsers
// should probably behave like the Mercurial parser.
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
1,
),
array(
'/file',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
1,
),
),
));
}
public function testSubversionPartialParser() {
$this->requireBinaryForTest('svn');
$repository = $this->buildBareRepository('CHD');
$repository->setDetail('svn-subpath', 'trunk/');
id(new PhabricatorRepositoryPullEngine())
->setRepository($repository)
->pullRepository();
id(new PhabricatorRepositoryDiscoveryEngine())
->setRepository($repository)
->discoverCommits();
$viewer = PhabricatorUser::getOmnipotentUser();
$commits = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepositoryIDs(array($repository->getID()))
->execute();
$this->expectChanges(
$repository,
$commits,
array(
// Copy of a file outside of the subpath from an earlier revision
// into the subpath.
4 => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
4,
),
array(
'/goat',
null,
null,
DifferentialChangeType::TYPE_COPY_AWAY,
DifferentialChangeType::FILE_NORMAL,
0,
4,
),
array(
'/trunk',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
4,
),
array(
'/trunk/goat',
'/goat',
'1',
DifferentialChangeType::TYPE_COPY_HERE,
DifferentialChangeType::FILE_NORMAL,
1,
4,
),
),
3 => array(
array(
'/',
null,
null,
DifferentialChangeType::TYPE_CHILD,
DifferentialChangeType::FILE_DIRECTORY,
0,
3,
),
array(
'/trunk',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_DIRECTORY,
1,
3,
),
array(
'/trunk/apple',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
3,
),
array(
'/trunk/banana',
null,
null,
DifferentialChangeType::TYPE_ADD,
DifferentialChangeType::FILE_NORMAL,
1,
3,
),
),
));
}
public function testSubversionValidRootParser() {
$this->requireBinaryForTest('svn');
// First, automatically configure the root correctly.
$repository = $this->buildBareRepository('CHD');
id(new PhabricatorRepositoryPullEngine())
->setRepository($repository)
->pullRepository();
$caught = null;
try {
id(new PhabricatorRepositoryDiscoveryEngine())
->setRepository($repository)
->discoverCommits();
} catch (Exception $ex) {
$caught = $ex;
}
$this->assertFalse(
($caught instanceof Exception),
pht('Natural SVN root should work properly.'));
// This time, artificially break the root. We expect this to fail.
$repository = $this->buildBareRepository('CHD');
$repository->setDetail(
'remote-uri',
$repository->getDetail('remote-uri').'trunk/');
id(new PhabricatorRepositoryPullEngine())
->setRepository($repository)
->pullRepository();
$caught = null;
try {
id(new PhabricatorRepositoryDiscoveryEngine())
->setRepository($repository)
->discoverCommits();
} catch (Exception $ex) {
$caught = $ex;
}
$this->assertTrue(
($caught instanceof Exception),
pht('Artificial SVN root should fail.'));
}
public function testSubversionForeignStubsParser() {
$this->requireBinaryForTest('svn');
$repository = $this->buildBareRepository('CHE');
$repository->setDetail('svn-subpath', 'branch/');
id(new PhabricatorRepositoryPullEngine())
->setRepository($repository)
->pullRepository();
id(new PhabricatorRepositoryDiscoveryEngine())
->setRepository($repository)
->discoverCommits();
$viewer = PhabricatorUser::getOmnipotentUser();
$commits = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepositoryIDs(array($repository->getID()))
->execute();
foreach ($commits as $commit) {
$this->parseCommit($repository, $commit);
}
// As a side effect, we expect parsing these commits to have created
// foreign stubs of other commits.
$commits = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepositoryIDs(array($repository->getID()))
->execute();
$commits = mpull($commits, null, 'getCommitIdentifier');
$this->assertTrue(
isset($commits['2']),
pht('Expect %s to exist as a foreign stub.', 'rCHE2'));
// The foreign stub should be marked imported.
$commit = $commits['2'];
$this->assertEqual(
PhabricatorRepositoryCommit::IMPORTED_ALL,
(int)$commit->getImportStatus());
}
private function parseCommit(
PhabricatorRepository $repository,
PhabricatorRepositoryCommit $commit) {
switch ($repository->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
$parser = 'PhabricatorRepositoryGitCommitChangeParserWorker';
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
$parser = 'PhabricatorRepositoryMercurialCommitChangeParserWorker';
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$parser = 'PhabricatorRepositorySvnCommitChangeParserWorker';
break;
default:
throw new Exception(pht('No support yet.'));
}
$parser_object = newv($parser, array(array()));
return $parser_object->parseChangesForUnitTest($repository, $commit);
}
private function expectChanges(
PhabricatorRepository $repository,
array $commits,
array $expect) {
foreach ($commits as $commit) {
$commit_identifier = $commit->getCommitIdentifier();
$expect_changes = idx($expect, $commit_identifier);
if ($expect_changes === null) {
$this->assertEqual(
$commit_identifier,
null,
pht(
'No test entry for commit "%s" in repository "%s"!',
$commit_identifier,
$repository->getDisplayName()));
}
$changes = $this->parseCommit($repository, $commit);
$path_map = id(new DiffusionPathQuery())
->withPathIDs(mpull($changes, 'getPathID'))
->execute();
$path_map = ipull($path_map, 'path');
$target_commits = array_filter(mpull($changes, 'getTargetCommitID'));
if ($target_commits) {
$commits = id(new DiffusionCommitQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withIDs($target_commits)
->execute();
$target_commits = mpull($commits, 'getCommitIdentifier', 'getID');
}
$dicts = array();
foreach ($changes as $key => $change) {
$target_path = idx($path_map, $change->getTargetPathID());
$target_commit = idx($target_commits, $change->getTargetCommitID());
$dicts[$key] = array(
$path_map[(int)$change->getPathID()],
$target_path,
$target_commit ? (string)$target_commit : null,
(int)$change->getChangeType(),
(int)$change->getFileType(),
(int)$change->getIsDirect(),
(int)$change->getCommitSequence(),
);
}
$dicts = ipull($dicts, null, 0);
$expect_changes = ipull($expect_changes, null, 0);
ksort($dicts);
ksort($expect_changes);
$this->assertEqual(
$expect_changes,
$dicts,
pht('Commit %s', $commit_identifier));
}
}
}
diff --git a/src/applications/search/compiler/PhutilSearchQueryCompiler.php b/src/applications/search/compiler/PhutilSearchQueryCompiler.php
index 2356a06a8f..951939b52c 100644
--- a/src/applications/search/compiler/PhutilSearchQueryCompiler.php
+++ b/src/applications/search/compiler/PhutilSearchQueryCompiler.php
@@ -1,508 +1,508 @@
<?php
final class PhutilSearchQueryCompiler
extends Phobject {
private $operators = '+ -><()~*:""&|';
private $query;
private $stemmer;
private $enableFunctions = false;
const OPERATOR_NOT = 'not';
const OPERATOR_AND = 'and';
const OPERATOR_SUBSTRING = 'sub';
const OPERATOR_EXACT = 'exact';
const OPERATOR_ABSENT = 'absent';
const OPERATOR_PRESENT = 'present';
public function setOperators($operators) {
$this->operators = $operators;
return $this;
}
public function getOperators() {
return $this->operators;
}
public function setStemmer(PhutilSearchStemmer $stemmer) {
$this->stemmer = $stemmer;
return $this;
}
public function getStemmer() {
return $this->stemmer;
}
public function setEnableFunctions($enable_functions) {
$this->enableFunctions = $enable_functions;
return $this;
}
public function getEnableFunctions() {
return $this->enableFunctions;
}
public function compileQuery(array $tokens) {
assert_instances_of($tokens, 'PhutilSearchQueryToken');
$result = array();
foreach ($tokens as $token) {
$result[] = $this->renderToken($token);
}
return $this->compileRenderedTokens($result);
}
public function compileLiteralQuery(array $tokens) {
assert_instances_of($tokens, 'PhutilSearchQueryToken');
$result = array();
foreach ($tokens as $token) {
if (!$token->isQuoted()) {
continue;
}
$result[] = $this->renderToken($token);
}
return $this->compileRenderedTokens($result);
}
public function compileStemmedQuery(array $tokens) {
assert_instances_of($tokens, 'PhutilSearchQueryToken');
$result = array();
foreach ($tokens as $token) {
if ($token->isQuoted()) {
continue;
}
$result[] = $this->renderToken($token, $this->getStemmer());
}
return $this->compileRenderedTokens($result);
}
private function compileRenderedTokens(array $list) {
if (!$list) {
return null;
}
$list = array_unique($list);
return implode(' ', $list);
}
public function newTokens($query) {
$results = $this->tokenizeQuery($query);
$tokens = array();
foreach ($results as $result) {
$tokens[] = PhutilSearchQueryToken::newFromDictionary($result);
}
return $tokens;
}
private function tokenizeQuery($query) {
$maximum_bytes = 1024;
if ($query === null) {
$query = '';
}
$query_bytes = strlen($query);
if ($query_bytes > $maximum_bytes) {
throw new PhutilSearchQueryCompilerSyntaxException(
pht(
'Query is too long (%s bytes, maximum is %s bytes).',
new PhutilNumber($query_bytes),
new PhutilNumber($maximum_bytes)));
}
$query = phutil_utf8v($query);
$length = count($query);
$enable_functions = $this->getEnableFunctions();
$mode = 'scan';
$current_operator = array();
$current_token = array();
$current_function = null;
$is_quoted = false;
$tokens = array();
if ($enable_functions) {
$operator_characters = '[~=+-]';
} else {
$operator_characters = '[+-]';
}
for ($ii = 0; $ii < $length; $ii++) {
$character = $query[$ii];
if ($mode == 'scan') {
if (preg_match('/^\s\z/u', $character)) {
continue;
}
$mode = 'function';
}
if ($mode == 'function') {
$mode = 'operator';
if ($enable_functions) {
$found = false;
for ($jj = $ii; $jj < $length; $jj++) {
if (preg_match('/^[a-zA-Z-]\z/u', $query[$jj])) {
continue;
}
if ($query[$jj] == ':') {
$found = $jj;
}
break;
}
if ($found !== false) {
$function = array_slice($query, $ii, ($jj - $ii));
$current_function = implode('', $function);
if (!strlen($current_function)) {
$current_function = null;
}
$ii = $jj;
continue;
}
}
}
if ($mode == 'operator') {
if (!$current_operator) {
if (preg_match('/^\s\z/u', $character)) {
continue;
}
}
if (preg_match('/^'.$operator_characters.'\z/', $character)) {
$current_operator[] = $character;
continue;
}
$mode = 'quote';
}
if ($mode == 'quote') {
if (preg_match('/^"\z/', $character)) {
$is_quoted = true;
$mode = 'token';
continue;
}
$mode = 'token';
}
if ($mode == 'token') {
$capture = false;
$was_quoted = $is_quoted;
if ($is_quoted) {
if (preg_match('/^"\z/', $character)) {
$capture = true;
$mode = 'scan';
$is_quoted = false;
}
} else {
if (preg_match('/^\s\z/u', $character)) {
$capture = true;
$mode = 'scan';
}
if (preg_match('/^"\z/', $character)) {
$capture = true;
$mode = 'token';
$is_quoted = true;
}
}
if ($capture) {
$token = array(
'operator' => $current_operator,
'quoted' => $was_quoted,
'value' => $current_token,
);
if ($enable_functions) {
$token['function'] = $current_function;
}
$tokens[] = $token;
$current_operator = array();
$current_token = array();
$current_function = null;
continue;
} else {
$current_token[] = $character;
}
}
}
if ($is_quoted) {
throw new PhutilSearchQueryCompilerSyntaxException(
pht(
'Query contains unmatched double quotes.'));
}
// If the input query has trailing space, like "a b ", we may exit the
// parser without a final token.
if ($current_function !== null || $current_operator || $current_token) {
$token = array(
'operator' => $current_operator,
'quoted' => false,
'value' => $current_token,
);
if ($enable_functions) {
$token['function'] = $current_function;
}
$tokens[] = $token;
}
$results = array();
$last_function = null;
foreach ($tokens as $token) {
$value = implode('', $token['value']);
$operator_string = implode('', $token['operator']);
$is_quoted = $token['quoted'];
switch ($operator_string) {
case '-':
$operator = self::OPERATOR_NOT;
break;
case '~':
$operator = self::OPERATOR_SUBSTRING;
break;
case '=':
$operator = self::OPERATOR_EXACT;
break;
case '+':
$operator = self::OPERATOR_AND;
break;
case '':
$use_substring = false;
if ($enable_functions && !$is_quoted) {
// See T12995. If this query term contains Chinese, Japanese or
// Korean characters, treat the term as a substring term by default.
// These languages do not separate words with spaces, so the term
// search mode is normally useless.
if (phutil_utf8_is_cjk($value)) {
$use_substring = true;
} else if (phutil_preg_match('/^_/', $value)) {
// See T13632. Assume users searching for any term that begins
- // with an undescore intend to perform substring search if they
+ // with an underscore intend to perform substring search if they
// don't provide an explicit search function.
$use_substring = true;
}
}
if ($use_substring) {
$operator = self::OPERATOR_SUBSTRING;
} else {
$operator = self::OPERATOR_AND;
}
break;
default:
throw new PhutilSearchQueryCompilerSyntaxException(
pht(
'Query has an invalid sequence of operators ("%s").',
$operator_string));
}
if (!strlen($value)) {
$require_value = $is_quoted;
switch ($operator) {
case self::OPERATOR_NOT:
if ($enable_functions && ($token['function'] !== null)) {
$operator = self::OPERATOR_ABSENT;
$value = null;
} else {
$require_value = true;
}
break;
case self::OPERATOR_SUBSTRING:
if ($enable_functions && ($token['function'] !== null)) {
$operator = self::OPERATOR_PRESENT;
$value = null;
} else {
$require_value = true;
}
break;
default:
$require_value = true;
break;
}
if ($require_value) {
throw new PhutilSearchQueryCompilerSyntaxException(
pht(
'Query contains a token ("%s") with no search term. Query '.
'tokens specify text to search for.',
$this->getDisplayToken($token)));
}
}
$result = array(
'operator' => $operator,
'quoted' => $is_quoted,
'value' => $value,
'raw' => $this->getDisplayToken($token),
);
if ($enable_functions) {
// If a user provides a query like "title:a b c", we interpret all
// of the terms to be title terms: the "title:" function sticks
// until we encounter another function.
// If a user provides a query like "title:"a"" (with a quoted term),
// the function is not sticky.
if ($token['function'] !== null) {
$function = $token['function'];
} else {
$function = $last_function;
}
$result['function'] = $function;
// Note that the function remains sticky across quoted terms appearing
// after the function term. For example, all of these terms are title
// terms:
//
// title:a "b c" d
$is_sticky = (!$result['quoted'] || ($token['function'] === null));
switch ($operator) {
case self::OPERATOR_ABSENT:
case self::OPERATOR_PRESENT:
$is_sticky = false;
break;
}
if ($is_sticky) {
$last_function = $function;
} else {
$last_function = null;
}
}
$results[] = $result;
}
if ($enable_functions) {
// If any function is required to be "absent", there must be no other
// terms which make assertions about it.
$present_tokens = array();
$absent_tokens = array();
foreach ($results as $result) {
$function = $result['function'];
if ($result['operator'] === self::OPERATOR_ABSENT) {
$absent_tokens[$function][] = $result;
} else {
$present_tokens[$function][] = $result;
}
}
foreach ($absent_tokens as $function => $tokens) {
$absent_token = head($tokens);
if (empty($present_tokens[$function])) {
continue;
}
$present_token = head($present_tokens[$function]);
throw new PhutilSearchQueryCompilerSyntaxException(
pht(
'Query field must be absent ("%s") and present ("%s"). This '.
'is impossible, so the query is not valid.',
$absent_token['raw'],
$present_token['raw']));
}
}
return $results;
}
private function renderToken(
PhutilSearchQueryToken $token,
PhutilSearchStemmer $stemmer = null) {
$value = $token->getValue();
if ($stemmer) {
$value = $stemmer->stemToken($value);
}
$value = $this->quoteToken($value);
$operator = $token->getOperator();
$prefix = $this->getOperatorPrefix($operator);
$value = $prefix.$value;
return $value;
}
private function getOperatorPrefix($operator) {
$operators = $this->operators;
switch ($operator) {
case self::OPERATOR_AND:
$prefix = $operators[0];
break;
case self::OPERATOR_NOT:
$prefix = $operators[2];
break;
default:
throw new PhutilSearchQueryCompilerSyntaxException(
pht(
'Unsupported operator prefix "%s".',
$operator));
}
if ($prefix == ' ') {
$prefix = null;
}
return $prefix;
}
private function quoteToken($value) {
$operators = $this->operators;
$open_quote = $this->operators[10];
$close_quote = $this->operators[11];
return $open_quote.$value.$close_quote;
}
private function getDisplayToken(array $token) {
if (isset($token['function'])) {
$function = $token['function'].':';
} else {
$function = '';
}
$operator_string = implode('', $token['operator']);
$value = implode('', $token['value']);
$is_quoted = $token['quoted'];
if ($is_quoted) {
$value = $this->quoteToken($value);
}
return sprintf('%s%s%s', $function, $operator_string, $value);
}
}
diff --git a/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php b/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php
index 82eaf08e83..10709bb79f 100644
--- a/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php
+++ b/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php
@@ -1,417 +1,417 @@
<?php
final class TransactionSearchConduitAPIMethod
extends ConduitAPIMethod {
public function getAPIMethodName() {
return 'transaction.search';
}
public function getMethodDescription() {
return pht(
'Read transactions and comments for a particular object '.
'or an entire object type.');
}
protected function newDocumentationPages(PhabricatorUser $viewer) {
$markup = pht(<<<EOREMARKUP
When an object (like a task) is edited, the relevant application creates a
"transaction" and applies it. This list of transactions on each object is the
basis for essentially all edits and comments. Reviewing the transaction
record allows you to see who edited an object, when, and how their edit changed
things.
-One common reason to call this method is that you're implmenting a webhook and
+One common reason to call this method is that you're implementing a webhook and
just received a notification that an object has changed. See the Webhooks
documentation for more detailed discussion of this use case.
One Object Type at a Time
=========================
This API method can query transactions for any type of object which supports
transactions, but only one type of object can be queried per call. For example:
you can retrieve transactions affecting Tasks, or you can retrieve transactions
affecting Revisions, but a single call can not retrieve both.
This is a technical limitation arising because (among other reasons) there is
no global ordering on transactions.
To find transactions for a specific object (like a particular task), pass the
object PHID or an appropriate object identifier (like `T123`) as an
`objectIdentifier`.
To find all transactions for an object type, pass the object type constant as
an `objectType`. For example, the correct identifier for tasks is `TASK`. (You
can quickly find an unknown type constant by looking at the PHID of an object
of that type.)
Constraints
===========
These constraints are supported:
- `phids` //Optional list<phid>.// Find specific transactions by PHID. This
is most likely to be useful if you're responding to a webhook notification
and want to inspect only the related events.
- `authorPHIDs` //Optional list<phid>.// Find transactions with particular
authors.
Transaction Format
==================
Each transaction has custom data describing what the transaction did. The
format varies from transaction to transaction. The easiest way to figure out
exactly what a particular transaction looks like is to make the associated kind
of edit to a test object, then query that object.
Not all transactions have data: by default, transactions have a `null` "type"
and no additional data. This API does not expose raw transaction data because
some of it is internal, oddly named, misspelled, confusing, not useful, or
could create security or policy problems to expose directly.
New transactions are exposed (with correctly spelled, comprehensible types and
useful, reasonable fields) as we become aware of use cases for them.
EOREMARKUP
);
$markup = $this->newRemarkupDocumentationView($markup);
return array(
$this->newDocumentationBoxPage($viewer, pht('Method Details'), $markup)
->setAnchor('details'),
);
}
protected function defineParamTypes() {
return array(
'objectIdentifier' => 'optional phid|string',
'objectType' => 'optional string',
'constraints' => 'optional map<string, wild>',
) + $this->getPagerParamTypes();
}
protected function defineReturnType() {
return 'list<dict>';
}
protected function defineErrorTypes() {
return array();
}
protected function execute(ConduitAPIRequest $request) {
$viewer = $request->getUser();
$pager = $this->newPager($request);
$object = $this->loadTemplateObject($request);
$xaction_query = PhabricatorApplicationTransactionQuery::newQueryForObject(
$object);
$xaction_query
->needHandles(false)
->setViewer($viewer);
if ($object->getPHID()) {
$xaction_query->withObjectPHIDs(array($object->getPHID()));
}
$constraints = $request->getValue('constraints', array());
$xaction_query = $this->applyConstraints($constraints, $xaction_query);
$xactions = $xaction_query->executeWithCursorPager($pager);
$comment_map = array();
if ($xactions) {
$template = head($xactions)->getApplicationTransactionCommentObject();
if ($template) {
$query = new PhabricatorApplicationTransactionTemplatedCommentQuery();
$comment_map = $query
->setViewer($viewer)
->setTemplate($template)
->withTransactionPHIDs(mpull($xactions, 'getPHID'))
->execute();
$comment_map = msort($comment_map, 'getCommentVersion');
$comment_map = array_reverse($comment_map);
$comment_map = mgroup($comment_map, 'getTransactionPHID');
}
}
$modular_classes = array();
$modular_objects = array();
$modular_xactions = array();
foreach ($xactions as $xaction) {
if (!$xaction instanceof PhabricatorModularTransaction) {
continue;
}
// TODO: Hack things so certain transactions which don't have a modular
// type yet can use a pseudotype until they modularize. Some day, we'll
// modularize everything and remove this.
switch ($xaction->getTransactionType()) {
case DifferentialTransaction::TYPE_INLINE:
$modular_template = new DifferentialRevisionInlineTransaction();
break;
default:
$modular_template = $xaction->getModularType();
break;
}
$modular_class = get_class($modular_template);
if (!isset($modular_objects[$modular_class])) {
try {
$modular_object = newv($modular_class, array());
$modular_objects[$modular_class] = $modular_object;
} catch (Exception $ex) {
continue;
}
}
$modular_classes[$xaction->getPHID()] = $modular_class;
$modular_xactions[$modular_class][] = $xaction;
}
$modular_data_map = array();
foreach ($modular_objects as $class => $modular_type) {
$modular_data_map[$class] = $modular_type
->setViewer($viewer)
->loadTransactionTypeConduitData($modular_xactions[$class]);
}
$data = array();
foreach ($xactions as $xaction) {
$comments = idx($comment_map, $xaction->getPHID());
$comment_data = array();
if ($comments) {
$removed = head($comments)->getIsDeleted();
foreach ($comments as $comment) {
if ($removed) {
// If the most recent version of the comment has been removed,
// don't show the history. This is for consistency with the web
// UI, which also prevents users from retrieving the content of
// removed comments.
$content = array(
'raw' => '',
);
} else {
$content = array(
'raw' => (string)$comment->getContent(),
);
}
$comment_data[] = array(
'id' => (int)$comment->getID(),
'phid' => (string)$comment->getPHID(),
'version' => (int)$comment->getCommentVersion(),
'authorPHID' => (string)$comment->getAuthorPHID(),
'dateCreated' => (int)$comment->getDateCreated(),
'dateModified' => (int)$comment->getDateModified(),
'removed' => (bool)$comment->getIsDeleted(),
'content' => $content,
);
}
}
$fields = array();
$type = null;
if (isset($modular_classes[$xaction->getPHID()])) {
$modular_class = $modular_classes[$xaction->getPHID()];
$modular_object = $modular_objects[$modular_class];
$modular_data = $modular_data_map[$modular_class];
$type = $modular_object->getTransactionTypeForConduit($xaction);
$fields = $modular_object->getFieldValuesForConduit(
$xaction,
$modular_data);
}
if (!$fields) {
$fields = (object)$fields;
}
// If we haven't found a modular type, fallback for some simple core
// types. Ideally, we'll modularize everything some day.
if ($type === null) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$type = 'comment';
break;
case PhabricatorTransactions::TYPE_CREATE:
$type = 'create';
break;
case PhabricatorTransactions::TYPE_EDGE:
switch ($xaction->getMetadataValue('edge:type')) {
case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST:
$type = 'projects';
$fields = $this->newEdgeTransactionFields($xaction);
break;
}
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$type = 'subscribers';
$fields = $this->newEdgeTransactionFields($xaction);
break;
}
}
$group_id = $xaction->getTransactionGroupID();
if (!strlen($group_id)) {
$group_id = null;
} else {
$group_id = (string)$group_id;
}
$data[] = array(
'id' => (int)$xaction->getID(),
'phid' => (string)$xaction->getPHID(),
'type' => $type,
'authorPHID' => (string)$xaction->getAuthorPHID(),
'objectPHID' => (string)$xaction->getObjectPHID(),
'dateCreated' => (int)$xaction->getDateCreated(),
'dateModified' => (int)$xaction->getDateModified(),
'groupID' => $group_id,
'comments' => $comment_data,
'fields' => $fields,
);
}
$results = array(
'data' => $data,
);
return $this->addPagerResults($results, $pager);
}
private function applyConstraints(
array $constraints,
PhabricatorApplicationTransactionQuery $query) {
PhutilTypeSpec::checkMap(
$constraints,
array(
'phids' => 'optional list<string>',
'authorPHIDs' => 'optional list<string>',
));
$with_phids = idx($constraints, 'phids');
if ($with_phids === array()) {
throw new Exception(
pht(
'Constraint "phids" to "transaction.search" requires nonempty list, '.
'empty list provided.'));
}
if ($with_phids) {
$query->withPHIDs($with_phids);
}
$with_authors = idx($constraints, 'authorPHIDs');
if ($with_authors === array()) {
throw new Exception(
pht(
'Constraint "authorPHIDs" to "transaction.search" requires '.
'nonempty list, empty list provided.'));
}
if ($with_authors) {
$query->withAuthorPHIDs($with_authors);
}
return $query;
}
private function newEdgeTransactionFields(
PhabricatorApplicationTransaction $xaction) {
$record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction);
$operations = array();
foreach ($record->getAddedPHIDs() as $phid) {
$operations[] = array(
'operation' => 'add',
'phid' => $phid,
);
}
foreach ($record->getRemovedPHIDs() as $phid) {
$operations[] = array(
'operation' => 'remove',
'phid' => $phid,
);
}
return array(
'operations' => $operations,
);
}
private function loadTemplateObject(ConduitAPIRequest $request) {
$viewer = $request->getUser();
$object_identifier = $request->getValue('objectIdentifier');
$object_type = $request->getValue('objectType');
$has_identifier = ($object_identifier !== null);
$has_type = ($object_type !== null);
if (!$has_type && !$has_identifier) {
throw new Exception(
pht(
'Calls to "transaction.search" must specify either an "objectType" '.
'or an "objectIdentifier"'));
} else if ($has_type && $has_identifier) {
throw new Exception(
pht(
'Calls to "transaction.search" must not specify both an '.
'"objectType" and an "objectIdentifier".'));
}
if ($has_type) {
$all_types = PhabricatorPHIDType::getAllTypes();
if (!isset($all_types[$object_type])) {
ksort($all_types);
throw new Exception(
pht(
'In call to "transaction.search", specified "objectType" ("%s") '.
'is unknown. Valid object types are: %s.',
$object_type,
implode(', ', array_keys($all_types))));
}
$object = $all_types[$object_type]->newObject();
} else {
$object = id(new PhabricatorObjectQuery())
->setViewer($viewer)
->withNames(array($object_identifier))
->executeOne();
if (!$object) {
throw new Exception(
pht(
'In call to "transaction.search", specified "objectIdentifier" '.
'("%s") does not exist.',
$object_identifier));
}
}
if (!($object instanceof PhabricatorApplicationTransactionInterface)) {
throw new Exception(
pht(
'In call to "transaction.search", selected object (of type "%s") '.
'does not implement "%s", so transactions can not be loaded for it.',
get_class($object),
'PhabricatorApplicationTransactionInterface'));
}
return $object;
}
}
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
index 908c76a106..0e7333b342 100644
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -1,5745 +1,5745 @@
<?php
/**
*
* Publishing and Managing State
* ======
*
* After applying changes, the Editor queues a worker to publish mail, feed,
* and notifications, and to perform other background work like updating search
* indexes. This allows it to do this work without impacting performance for
* users.
*
* When work is moved to the daemons, the Editor state is serialized by
* @{method:getWorkerState}, then reloaded in a daemon process by
* @{method:loadWorkerState}. **This is fragile.**
*
* State is not persisted into the daemons by default, because we can not send
* arbitrary objects into the queue. This means the default behavior of any
* state properties is to reset to their defaults without warning prior to
* publishing.
*
* The easiest way to avoid this is to keep Editors stateless: the overwhelming
* majority of Editors can be written statelessly. If you need to maintain
* state, you can either:
*
* - not require state to exist during publishing; or
* - pass state to the daemons by implementing @{method:getCustomWorkerState}
* and @{method:loadCustomWorkerState}.
*
* This architecture isn't ideal, and we may eventually split this class into
* "Editor" and "Publisher" parts to make it more robust. See T6367 for some
* discussion and context.
*
* @task mail Sending Mail
* @task feed Publishing Feed Stories
* @task search Search Index
* @task files Integration with Files
* @task workers Managing Workers
*/
abstract class PhabricatorApplicationTransactionEditor
extends PhabricatorEditor {
private $contentSource;
private $object;
private $xactions;
private $isNewObject;
private $mentionedPHIDs;
private $continueOnNoEffect;
private $continueOnMissingFields;
private $raiseWarnings;
private $parentMessageID;
private $heraldAdapter;
private $heraldTranscript;
private $subscribers;
private $unmentionablePHIDMap = array();
private $transactionGroupID;
private $applicationEmail;
private $isPreview;
private $isHeraldEditor;
private $isInverseEdgeEditor;
private $actingAsPHID;
private $heraldEmailPHIDs = array();
private $heraldForcedEmailPHIDs = array();
private $heraldHeader;
private $mailToPHIDs = array();
private $mailCCPHIDs = array();
private $feedNotifyPHIDs = array();
private $feedRelatedPHIDs = array();
private $feedShouldPublish = false;
private $mailShouldSend = false;
private $modularTypes;
private $silent;
private $mustEncrypt = array();
private $stampTemplates = array();
private $mailStamps = array();
private $oldTo = array();
private $oldCC = array();
private $mailRemovedPHIDs = array();
private $mailUnexpandablePHIDs = array();
private $mailMutedPHIDs = array();
private $webhookMap = array();
private $transactionQueue = array();
private $sendHistory = false;
private $shouldRequireMFA = false;
private $hasRequiredMFA = false;
private $request;
private $cancelURI;
private $extensions;
private $parentEditor;
private $subEditors = array();
private $publishableObject;
private $publishableTransactions;
const STORAGE_ENCODING_BINARY = 'binary';
/**
* Get the class name for the application this editor is a part of.
*
* Uninstalling the application will disable the editor.
*
* @return string Editor's application class name.
*/
abstract public function getEditorApplicationClass();
/**
* Get a description of the objects this editor edits, like "Differential
* Revisions".
*
* @return string Human readable description of edited objects.
*/
abstract public function getEditorObjectsDescription();
public function setActingAsPHID($acting_as_phid) {
$this->actingAsPHID = $acting_as_phid;
return $this;
}
public function getActingAsPHID() {
if ($this->actingAsPHID) {
return $this->actingAsPHID;
}
return $this->getActor()->getPHID();
}
/**
* When the editor tries to apply transactions that have no effect, should
* it raise an exception (default) or drop them and continue?
*
* Generally, you will set this flag for edits coming from "Edit" interfaces,
* and leave it cleared for edits coming from "Comment" interfaces, so the
* user will get a useful error if they try to submit a comment that does
* nothing (e.g., empty comment with a status change that has already been
* performed by another user).
*
* @param bool True to drop transactions without effect and continue.
* @return this
*/
public function setContinueOnNoEffect($continue) {
$this->continueOnNoEffect = $continue;
return $this;
}
public function getContinueOnNoEffect() {
return $this->continueOnNoEffect;
}
/**
* When the editor tries to apply transactions which don't populate all of
* an object's required fields, should it raise an exception (default) or
* drop them and continue?
*
* For example, if a user adds a new required custom field (like "Severity")
* to a task, all existing tasks won't have it populated. When users
* manually edit existing tasks, it's usually desirable to have them provide
* a severity. However, other operations (like batch editing just the
* owner of a task) will fail by default.
*
* By setting this flag for edit operations which apply to specific fields
* (like the priority, batch, and merge editors in Maniphest), these
* operations can continue to function even if an object is outdated.
*
* @param bool True to continue when transactions don't completely satisfy
* all required fields.
* @return this
*/
public function setContinueOnMissingFields($continue_on_missing_fields) {
$this->continueOnMissingFields = $continue_on_missing_fields;
return $this;
}
public function getContinueOnMissingFields() {
return $this->continueOnMissingFields;
}
/**
* 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;
}
public function getIsNewObject() {
return $this->isNewObject;
}
public function getMentionedPHIDs() {
return $this->mentionedPHIDs;
}
public function setIsPreview($is_preview) {
$this->isPreview = $is_preview;
return $this;
}
public function getIsPreview() {
return $this->isPreview;
}
public function setIsSilent($silent) {
$this->silent = $silent;
return $this;
}
public function getIsSilent() {
return $this->silent;
}
public function getMustEncrypt() {
return $this->mustEncrypt;
}
public function getHeraldRuleMonograms() {
// Convert the stored "<123>, <456>" string into a list: "H123", "H456".
$list = phutil_string_cast($this->heraldHeader);
$list = preg_split('/[, ]+/', $list);
foreach ($list as $key => $item) {
$item = trim($item, '<>');
if (!is_numeric($item)) {
unset($list[$key]);
continue;
}
$list[$key] = 'H'.$item;
}
return $list;
}
public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
$this->isInverseEdgeEditor = $is_inverse_edge_editor;
return $this;
}
public function getIsInverseEdgeEditor() {
return $this->isInverseEdgeEditor;
}
public function setIsHeraldEditor($is_herald_editor) {
$this->isHeraldEditor = $is_herald_editor;
return $this;
}
public function getIsHeraldEditor() {
return $this->isHeraldEditor;
}
public function addUnmentionablePHIDs(array $phids) {
foreach ($phids as $phid) {
$this->unmentionablePHIDMap[$phid] = true;
}
return $this;
}
private function getUnmentionablePHIDMap() {
return $this->unmentionablePHIDMap;
}
protected function shouldEnableMentions(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
public function setApplicationEmail(
PhabricatorMetaMTAApplicationEmail $email) {
$this->applicationEmail = $email;
return $this;
}
public function getApplicationEmail() {
return $this->applicationEmail;
}
public function setRaiseWarnings($raise_warnings) {
$this->raiseWarnings = $raise_warnings;
return $this;
}
public function getRaiseWarnings() {
return $this->raiseWarnings;
}
public function setShouldRequireMFA($should_require_mfa) {
if ($this->hasRequiredMFA) {
throw new Exception(
pht(
'Call to setShouldRequireMFA() is too late: this Editor has already '.
'checked for MFA requirements.'));
}
$this->shouldRequireMFA = $should_require_mfa;
return $this;
}
public function getShouldRequireMFA() {
return $this->shouldRequireMFA;
}
public function getTransactionTypesForObject($object) {
$old = $this->object;
try {
$this->object = $object;
$result = $this->getTransactionTypes();
$this->object = $old;
} catch (Exception $ex) {
$this->object = $old;
throw $ex;
}
return $result;
}
public function getTransactionTypes() {
$types = array();
$types[] = PhabricatorTransactions::TYPE_CREATE;
$types[] = PhabricatorTransactions::TYPE_HISTORY;
$types[] = PhabricatorTransactions::TYPE_FILE;
if ($this->object instanceof PhabricatorEditEngineSubtypeInterface) {
$types[] = PhabricatorTransactions::TYPE_SUBTYPE;
}
if ($this->object instanceof PhabricatorSubscribableInterface) {
$types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS;
}
if ($this->object instanceof PhabricatorCustomFieldInterface) {
$types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD;
}
if ($this->object instanceof PhabricatorTokenReceiverInterface) {
$types[] = PhabricatorTransactions::TYPE_TOKEN;
}
if ($this->object instanceof PhabricatorProjectInterface ||
$this->object instanceof PhabricatorMentionableInterface) {
$types[] = PhabricatorTransactions::TYPE_EDGE;
}
if ($this->object instanceof PhabricatorSpacesInterface) {
$types[] = PhabricatorTransactions::TYPE_SPACE;
}
$types[] = PhabricatorTransactions::TYPE_MFA;
$template = $this->object->getApplicationTransactionTemplate();
if ($template instanceof PhabricatorModularTransaction) {
$xtypes = $template->newModularTransactionTypes();
foreach ($xtypes as $xtype) {
$types[] = $xtype->getTransactionTypeConstant();
}
}
if ($template) {
$comment = $template->getApplicationTransactionCommentObject();
if ($comment) {
$types[] = PhabricatorTransactions::TYPE_COMMENT;
}
}
return $types;
}
private function adjustTransactionValues(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
if ($xaction->shouldGenerateOldValue()) {
$old = $this->getTransactionOldValue($object, $xaction);
$xaction->setOldValue($old);
}
$new = $this->getTransactionNewValue($object, $xaction);
$xaction->setNewValue($new);
- // Apply an optional transformation to convert "external" tranaction
+ // Apply an optional transformation to convert "external" transaction
// values (provided by APIs) into "internal" values.
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
// TODO: Provide a modular hook for modern transactions to do a
// transformation.
list($old, $new) = array($old, $new);
return;
} else {
switch ($type) {
case PhabricatorTransactions::TYPE_FILE:
list($old, $new) = $this->newFileTransactionInternalValues(
$object,
$xaction,
$old,
$new);
break;
}
}
$xaction->setOldValue($old);
$xaction->setNewValue($new);
}
private function newFileTransactionInternalValues(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction,
$old,
$new) {
$old_map = array();
if (!$this->getIsNewObject()) {
$phid = $object->getPHID();
$attachment_table = new PhabricatorFileAttachment();
$attachment_conn = $attachment_table->establishConnection('w');
$rows = queryfx_all(
$attachment_conn,
'SELECT filePHID, attachmentMode FROM %R WHERE objectPHID = %s',
$attachment_table,
$phid);
$old_map = ipull($rows, 'attachmentMode', 'filePHID');
}
$mode_ref = PhabricatorFileAttachment::MODE_REFERENCE;
$mode_detach = PhabricatorFileAttachment::MODE_DETACH;
$new_map = $old_map;
foreach ($new as $file_phid => $attachment_mode) {
$is_ref = ($attachment_mode === $mode_ref);
$is_detach = ($attachment_mode === $mode_detach);
if ($is_detach) {
unset($new_map[$file_phid]);
continue;
}
$old_mode = idx($old_map, $file_phid);
// If we're adding a reference to a file but it is already attached,
// don't touch it.
if ($is_ref) {
if ($old_mode !== null) {
continue;
}
}
$new_map[$file_phid] = $attachment_mode;
}
foreach (array_keys($old_map + $new_map) as $key) {
if (isset($old_map[$key]) && isset($new_map[$key])) {
if ($old_map[$key] === $new_map[$key]) {
unset($old_map[$key]);
unset($new_map[$key]);
}
}
}
return array($old_map, $new_map);
}
private function getTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->generateOldValue($object);
}
switch ($type) {
case PhabricatorTransactions::TYPE_CREATE:
case PhabricatorTransactions::TYPE_HISTORY:
return null;
case PhabricatorTransactions::TYPE_SUBTYPE:
return $object->getEditEngineSubtype();
case PhabricatorTransactions::TYPE_MFA:
return null;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return array_values($this->subscribers);
case PhabricatorTransactions::TYPE_VIEW_POLICY:
if ($this->getIsNewObject()) {
return null;
}
return $object->getViewPolicy();
case PhabricatorTransactions::TYPE_EDIT_POLICY:
if ($this->getIsNewObject()) {
return null;
}
return $object->getEditPolicy();
case PhabricatorTransactions::TYPE_JOIN_POLICY:
if ($this->getIsNewObject()) {
return null;
}
return $object->getJoinPolicy();
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
if ($this->getIsNewObject()) {
return null;
}
return $object->getInteractPolicy();
case PhabricatorTransactions::TYPE_SPACE:
if ($this->getIsNewObject()) {
return null;
}
$space_phid = $object->getSpacePHID();
if ($space_phid === null) {
$default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
if ($default_space) {
$space_phid = $default_space->getPHID();
}
}
return $space_phid;
case PhabricatorTransactions::TYPE_EDGE:
$edge_type = $xaction->getMetadataValue('edge:type');
if (!$edge_type) {
throw new Exception(
pht(
"Edge transaction has no '%s'!",
'edge:type'));
}
// See T13082. If this is an inverse edit, the parent editor has
// already populated the transaction values correctly.
if ($this->getIsInverseEdgeEditor()) {
return $xaction->getOldValue();
}
$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:
// NOTE: Custom fields have their old value pre-populated when they are
// built by PhabricatorCustomFieldList.
return $xaction->getOldValue();
case PhabricatorTransactions::TYPE_COMMENT:
return null;
case PhabricatorTransactions::TYPE_FILE:
return null;
default:
return $this->getCustomTransactionOldValue($object, $xaction);
}
}
private function getTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->generateNewValue($object, $xaction->getNewValue());
}
switch ($type) {
case PhabricatorTransactions::TYPE_CREATE:
return null;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return $this->getPHIDTransactionNewValue($xaction);
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_INLINESTATE:
case PhabricatorTransactions::TYPE_SUBTYPE:
case PhabricatorTransactions::TYPE_HISTORY:
case PhabricatorTransactions::TYPE_FILE:
return $xaction->getNewValue();
case PhabricatorTransactions::TYPE_MFA:
return true;
case PhabricatorTransactions::TYPE_SPACE:
$space_phid = $xaction->getNewValue();
if (!phutil_nonempty_string($space_phid)) {
// If an install has no Spaces or the Spaces controls are not visible
// to the viewer, we might end up with the empty string here instead
// of a strict `null`, because some controller just used `getStr()`
// to read the space PHID from the request.
// Just make this work like callers might reasonably expect so we
// don't need to handle this specially in every EditController.
return $this->getActor()->getDefaultSpacePHID();
} else {
return $space_phid;
}
case PhabricatorTransactions::TYPE_EDGE:
// See T13082. If this is an inverse edit, the parent editor has
// already populated appropriate transaction values.
if ($this->getIsInverseEdgeEditor()) {
return $xaction->getNewValue();
}
$new_value = $this->getEdgeTransactionNewValue($xaction);
$edge_type = $xaction->getMetadataValue('edge:type');
$type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
if ($edge_type == $type_project) {
$new_value = $this->applyProjectConflictRules($new_value);
}
return $new_value;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->getNewValueFromApplicationTransactions($xaction);
case PhabricatorTransactions::TYPE_COMMENT:
return null;
default:
return $this->getCustomTransactionNewValue($object, $xaction);
}
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception(pht('Capability not supported!'));
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception(pht('Capability not supported!'));
}
protected function transactionHasEffect(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_CREATE:
case PhabricatorTransactions::TYPE_HISTORY:
return true;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->getApplicationTransactionHasEffect($xaction);
case PhabricatorTransactions::TYPE_EDGE:
// A straight value comparison here doesn't always get the right
// result, because newly added edges aren't fully populated. Instead,
// compare the changes in a more granular way.
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$old_dst = array_keys($old);
$new_dst = array_keys($new);
// NOTE: For now, we don't consider edge reordering to be a change.
// We have very few order-dependent edges and effectively no order
// oriented UI. This might change in the future.
sort($old_dst);
sort($new_dst);
if ($old_dst !== $new_dst) {
// We've added or removed edges, so this transaction definitely
// has an effect.
return true;
}
// We haven't added or removed edges, but we might have changed
// edge data.
foreach ($old as $key => $old_value) {
$new_value = $new[$key];
if ($old_value['data'] !== $new_value['data']) {
return true;
}
}
return false;
}
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
return $xtype->getTransactionHasEffect(
$object,
$xaction->getOldValue(),
$xaction->getNewValue());
}
if ($xaction->hasComment()) {
return true;
}
return ($xaction->getOldValue() !== $xaction->getNewValue());
}
protected function shouldApplyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function applyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
throw new PhutilMethodNotImplementedException();
}
private function applyInternalEffects(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->applyInternalEffects($object, $xaction->getNewValue());
}
switch ($type) {
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->applyApplicationTransactionInternalEffects($xaction);
case PhabricatorTransactions::TYPE_CREATE:
case PhabricatorTransactions::TYPE_HISTORY:
case PhabricatorTransactions::TYPE_SUBTYPE:
case PhabricatorTransactions::TYPE_MFA:
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
case PhabricatorTransactions::TYPE_INLINESTATE:
case PhabricatorTransactions::TYPE_EDGE:
case PhabricatorTransactions::TYPE_SPACE:
case PhabricatorTransactions::TYPE_COMMENT:
case PhabricatorTransactions::TYPE_FILE:
return $this->applyBuiltinInternalTransaction($object, $xaction);
}
return $this->applyCustomInternalTransaction($object, $xaction);
}
private function applyExternalEffects(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->applyExternalEffects($object, $xaction->getNewValue());
}
switch ($type) {
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;
return $this->applyBuiltinExternalTransaction($object, $xaction);
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->applyApplicationTransactionExternalEffects($xaction);
case PhabricatorTransactions::TYPE_CREATE:
case PhabricatorTransactions::TYPE_HISTORY:
case PhabricatorTransactions::TYPE_SUBTYPE:
case PhabricatorTransactions::TYPE_MFA:
case PhabricatorTransactions::TYPE_EDGE:
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
case PhabricatorTransactions::TYPE_INLINESTATE:
case PhabricatorTransactions::TYPE_SPACE:
case PhabricatorTransactions::TYPE_COMMENT:
case PhabricatorTransactions::TYPE_FILE:
return $this->applyBuiltinExternalTransaction($object, $xaction);
}
return $this->applyCustomExternalTransaction($object, $xaction);
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
throw new Exception(
pht(
"Transaction type '%s' is missing an internal apply implementation!",
$type));
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
throw new Exception(
pht(
"Transaction type '%s' is missing an external apply implementation!",
$type));
}
/**
* @{class:PhabricatorTransactions} provides many built-in transactions
* which should not require much - if any - code in specific applications.
*
* This method is a hook for the exceedingly-rare cases where you may need
* to do **additional** work for built-in transactions. Developers should
* extend this method, making sure to return the parent implementation
* regardless of handling any transactions.
*
* See also @{method:applyBuiltinExternalTransaction}.
*/
protected function applyBuiltinInternalTransaction(
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_JOIN_POLICY:
$object->setJoinPolicy($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
$object->setInteractPolicy($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_SPACE:
$object->setSpacePHID($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_SUBTYPE:
$object->setEditEngineSubtype($xaction->getNewValue());
break;
}
}
/**
* See @{method::applyBuiltinInternalTransaction}.
*/
protected function applyBuiltinExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_EDGE:
if ($this->getIsInverseEdgeEditor()) {
// If we're writing an inverse edge transaction, don't actually
// do anything. The initiating editor on the other side of the
// transaction will take care of the edge writes.
break;
}
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$src = $object->getPHID();
$const = $xaction->getMetadataValue('edge:type');
foreach ($new as $dst_phid => $edge) {
$new[$dst_phid]['src'] = $src;
}
$editor = new PhabricatorEdgeEditor();
foreach ($old as $dst_phid => $edge) {
if (!empty($new[$dst_phid])) {
if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
continue;
}
}
$editor->removeEdge($src, $const, $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, $const, $dst_phid, $data);
}
$editor->save();
$this->updateWorkboardColumns($object, $const, $old, $new);
break;
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_SPACE:
$this->scrambleFileSecrets($object);
break;
case PhabricatorTransactions::TYPE_HISTORY:
$this->sendHistory = true;
break;
case PhabricatorTransactions::TYPE_FILE:
$this->applyFileTransaction($object, $xaction);
break;
}
}
private function applyFileTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$old_map = $xaction->getOldValue();
$new_map = $xaction->getNewValue();
$add_phids = array();
$rem_phids = array();
foreach ($new_map as $phid => $mode) {
$add_phids[$phid] = $mode;
}
foreach ($old_map as $phid => $mode) {
if (!isset($new_map[$phid])) {
$rem_phids[] = $phid;
}
}
$now = PhabricatorTime::getNow();
$object_phid = $object->getPHID();
$attacher_phid = $this->getActingAsPHID();
$attachment_table = new PhabricatorFileAttachment();
$attachment_conn = $attachment_table->establishConnection('w');
$add_sql = array();
foreach ($add_phids as $add_phid => $add_mode) {
$add_sql[] = qsprintf(
$attachment_conn,
'(%s, %s, %s, %ns, %d, %d)',
$object_phid,
$add_phid,
$add_mode,
$attacher_phid,
$now,
$now);
}
$rem_sql = array();
foreach ($rem_phids as $rem_phid) {
$rem_sql[] = qsprintf(
$attachment_conn,
'%s',
$rem_phid);
}
foreach (PhabricatorLiskDAO::chunkSQL($add_sql) as $chunk) {
queryfx(
$attachment_conn,
'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
attacherPHID, dateCreated, dateModified)
VALUES %LQ
ON DUPLICATE KEY UPDATE
attachmentMode = VALUES(attachmentMode),
attacherPHID = VALUES(attacherPHID),
dateModified = VALUES(dateModified)',
$attachment_table,
$chunk);
}
foreach (PhabricatorLiskDAO::chunkSQL($rem_sql) as $chunk) {
queryfx(
$attachment_conn,
'DELETE FROM %R WHERE objectPHID = %s AND filePHID in (%LQ)',
$attachment_table,
$object_phid,
$chunk);
}
}
/**
* Fill in a transaction's common values, like author and content source.
*/
protected function populateTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$actor = $this->getActor();
// TODO: This needs to be more sophisticated once we have meta-policies.
$xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
if ($actor->isOmnipotent()) {
$xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE);
} else {
$xaction->setEditPolicy($this->getActingAsPHID());
}
// If the transaction already has an explicit author PHID, allow it to
// stand. This is used by applications like Owners that hook into the
// post-apply change pipeline.
if (!$xaction->getAuthorPHID()) {
$xaction->setAuthorPHID($this->getActingAsPHID());
}
$xaction->setContentSource($this->getContentSource());
$xaction->attachViewer($actor);
$xaction->attachObject($object);
if ($object->getPHID()) {
$xaction->setObjectPHID($object->getPHID());
}
if ($this->getIsSilent()) {
$xaction->setIsSilentTransaction(true);
}
return $xaction;
}
protected function didApplyInternalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
return $xactions;
}
protected function applyFinalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
return $xactions;
}
final protected function didCommitTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
// See T13082. When we're writing edges that imply corresponding inverse
// transactions, apply those inverse transactions now. We have to wait
// until the object we're editing (with this editor) has committed its
// transactions to do this. If we don't, the inverse editor may race,
// build a mail before we actually commit this object, and render "alice
// added an edge: Unknown Object".
if ($type === PhabricatorTransactions::TYPE_EDGE) {
// Don't do anything if we're already an inverse edge editor.
if ($this->getIsInverseEdgeEditor()) {
continue;
}
$edge_const = $xaction->getMetadataValue('edge:type');
$edge_type = PhabricatorEdgeType::getByConstant($edge_const);
if ($edge_type->shouldWriteInverseTransactions()) {
$this->applyInverseEdgeTransactions(
$object,
$xaction,
$edge_type->getInverseEdgeConstant());
}
continue;
}
$xtype = $this->getModularTransactionType($object, $type);
if (!$xtype) {
continue;
}
$xtype = clone $xtype;
$xtype->setStorage($xaction);
$xtype->didCommitTransaction($object, $xaction->getNewValue());
}
}
public function setContentSource(PhabricatorContentSource $content_source) {
$this->contentSource = $content_source;
return $this;
}
public function setContentSourceFromRequest(AphrontRequest $request) {
$this->setRequest($request);
return $this->setContentSource(
PhabricatorContentSource::newFromRequest($request));
}
public function getContentSource() {
return $this->contentSource;
}
public function setRequest(AphrontRequest $request) {
$this->request = $request;
return $this;
}
public function getRequest() {
return $this->request;
}
public function setCancelURI($cancel_uri) {
$this->cancelURI = $cancel_uri;
return $this;
}
public function getCancelURI() {
return $this->cancelURI;
}
protected function getTransactionGroupID() {
if ($this->transactionGroupID === null) {
$this->transactionGroupID = Filesystem::readRandomCharacters(32);
}
return $this->transactionGroupID;
}
final public function applyTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$is_new = ($object->getID() === null);
$this->isNewObject = $is_new;
$is_preview = $this->getIsPreview();
$read_locking = false;
$transaction_open = false;
// If we're attempting to apply transactions, lock and reload the object
// before we go anywhere. If we don't do this at the very beginning, we
// may be looking at an older version of the object when we populate and
// filter the transactions. See PHI1165 for an example.
if (!$is_preview) {
if (!$is_new) {
$this->buildOldRecipientLists($object, $xactions);
$object->openTransaction();
$transaction_open = true;
$object->beginReadLocking();
$read_locking = true;
$object->reload();
}
}
try {
$this->object = $object;
$this->xactions = $xactions;
$this->validateEditParameters($object, $xactions);
$xactions = $this->newMFATransactions($object, $xactions);
$actor = $this->requireActor();
// NOTE: Some transaction expansion requires that the edited object be
// attached.
foreach ($xactions as $xaction) {
$xaction->attachObject($object);
$xaction->attachViewer($actor);
}
$xactions = $this->expandTransactions($object, $xactions);
$xactions = $this->expandSupportTransactions($object, $xactions);
$xactions = $this->combineTransactions($xactions);
foreach ($xactions as $xaction) {
$xaction = $this->populateTransaction($object, $xaction);
}
if (!$is_preview) {
$errors = array();
$type_map = mgroup($xactions, 'getTransactionType');
foreach ($this->getTransactionTypes() as $type) {
$type_xactions = idx($type_map, $type, array());
$errors[] = $this->validateTransaction(
$object,
$type,
$type_xactions);
}
$errors[] = $this->validateAllTransactions($object, $xactions);
$errors[] = $this->validateTransactionsWithExtensions(
$object,
$xactions);
$errors = array_mergev($errors);
$continue_on_missing = $this->getContinueOnMissingFields();
foreach ($errors as $key => $error) {
if ($continue_on_missing && $error->getIsMissingFieldError()) {
unset($errors[$key]);
}
}
if ($errors) {
throw new PhabricatorApplicationTransactionValidationException(
$errors);
}
if ($this->raiseWarnings) {
$warnings = array();
foreach ($xactions as $xaction) {
if ($this->hasWarnings($object, $xaction)) {
$warnings[] = $xaction;
}
}
if ($warnings) {
throw new PhabricatorApplicationTransactionWarningException(
$warnings);
}
}
}
foreach ($xactions as $xaction) {
$this->adjustTransactionValues($object, $xaction);
}
// Now that we've merged and combined transactions, check for required
// capabilities. Note that we're doing this before filtering
// transactions: if you try to apply an edit which you do not have
// permission to apply, we want to give you a permissions error even
// if the edit would have no effect.
$this->applyCapabilityChecks($object, $xactions);
$xactions = $this->filterTransactions($object, $xactions);
if (!$is_preview) {
$this->hasRequiredMFA = true;
if ($this->getShouldRequireMFA()) {
$this->requireMFA($object, $xactions);
}
if ($this->shouldApplyInitialEffects($object, $xactions)) {
if (!$transaction_open) {
$object->openTransaction();
$transaction_open = true;
}
}
}
if ($this->shouldApplyInitialEffects($object, $xactions)) {
$this->applyInitialEffects($object, $xactions);
}
// TODO: Once everything is on EditEngine, just use getIsNewObject() to
// figure this out instead.
$mark_as_create = false;
$create_type = PhabricatorTransactions::TYPE_CREATE;
foreach ($xactions as $xaction) {
if ($xaction->getTransactionType() == $create_type) {
$mark_as_create = true;
}
}
if ($mark_as_create) {
foreach ($xactions as $xaction) {
$xaction->setIsCreateTransaction(true);
}
}
$xactions = $this->sortTransactions($xactions);
if ($is_preview) {
$this->loadHandles($xactions);
return $xactions;
}
$comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
->setActor($actor)
->setActingAsPHID($this->getActingAsPHID())
->setContentSource($this->getContentSource())
->setIsNewComment(true);
if (!$transaction_open) {
$object->openTransaction();
$transaction_open = true;
}
// We can technically test any object for CAN_INTERACT, but we can
// run into some issues in doing so (for example, in project unit tests).
// For now, only test for CAN_INTERACT if the object is explicitly a
// lockable object.
$was_locked = false;
if ($object instanceof PhabricatorEditEngineLockableInterface) {
$was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object);
}
foreach ($xactions as $xaction) {
$this->applyInternalEffects($object, $xaction);
}
$xactions = $this->didApplyInternalEffects($object, $xactions);
try {
$object->save();
} catch (AphrontDuplicateKeyQueryException $ex) {
// This callback has an opportunity to throw a better exception,
// so execution may end here.
$this->didCatchDuplicateKeyException($object, $xactions, $ex);
throw $ex;
}
$group_id = $this->getTransactionGroupID();
foreach ($xactions as $xaction) {
if ($was_locked) {
$is_override = $this->isLockOverrideTransaction($xaction);
if ($is_override) {
$xaction->setIsLockOverrideTransaction(true);
}
}
$xaction->setObjectPHID($object->getPHID());
$xaction->setTransactionGroupID($group_id);
if ($xaction->getComment()) {
$xaction->setPHID($xaction->generatePHID());
$comment_editor->applyEdit($xaction, $xaction->getComment());
} else {
// TODO: This is a transitional hack to let us migrate edge
// transactions to a more efficient storage format. For now, we're
// going to write a new slim format to the database but keep the old
// bulky format on the objects so we don't have to upgrade all the
// edit logic to the new format yet. See T13051.
$edge_type = PhabricatorTransactions::TYPE_EDGE;
if ($xaction->getTransactionType() == $edge_type) {
$bulky_old = $xaction->getOldValue();
$bulky_new = $xaction->getNewValue();
$record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction);
$slim_old = $record->getModernOldEdgeTransactionData();
$slim_new = $record->getModernNewEdgeTransactionData();
$xaction->setOldValue($slim_old);
$xaction->setNewValue($slim_new);
$xaction->save();
$xaction->setOldValue($bulky_old);
$xaction->setNewValue($bulky_new);
} else {
$xaction->save();
}
}
}
foreach ($xactions as $xaction) {
$this->applyExternalEffects($object, $xaction);
}
$xactions = $this->applyFinalEffects($object, $xactions);
if ($read_locking) {
$object->endReadLocking();
$read_locking = false;
}
if ($transaction_open) {
$object->saveTransaction();
$transaction_open = false;
}
$this->didCommitTransactions($object, $xactions);
} catch (Exception $ex) {
if ($read_locking) {
$object->endReadLocking();
$read_locking = false;
}
if ($transaction_open) {
$object->killTransaction();
$transaction_open = false;
}
throw $ex;
}
// If we need to perform cache engine updates, execute them now.
id(new PhabricatorCacheEngine())
->updateObject($object);
// Now that we've completely applied the core transaction set, try to apply
// Herald rules. Herald rules are allowed to either take direct actions on
// the database (like writing flags), or take indirect actions (like saving
// some targets for CC when we generate mail a little later), or return
// transactions which we'll apply normally using another Editor.
// First, check if *this* is a sub-editor which is itself applying Herald
// rules: if it is, stop working and return so we don't descend into
// madness.
// Otherwise, we're not a Herald editor, so process Herald rules (possibly
// using a Herald editor to apply resulting transactions) and then send out
// mail, notifications, and feed updates about everything.
if ($this->getIsHeraldEditor()) {
// We are the Herald editor, so stop work here and return the updated
// transactions.
return $xactions;
} else if ($this->getIsInverseEdgeEditor()) {
// Do not run Herald if we're just recording that this object was
// mentioned elsewhere. This tends to create Herald side effects which
// feel arbitrary, and can really slow down edits which mention a large
// number of other objects. See T13114.
} else if ($this->shouldApplyHeraldRules($object, $xactions)) {
// We are not the Herald editor, so try to apply Herald rules.
$herald_xactions = $this->applyHeraldRules($object, $xactions);
if ($herald_xactions) {
$xscript_id = $this->getHeraldTranscript()->getID();
foreach ($herald_xactions as $herald_xaction) {
// Don't set a transcript ID if this is a transaction from another
// application or source, like Owners.
if ($herald_xaction->getAuthorPHID()) {
continue;
}
$herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
}
// NOTE: We're acting as the omnipotent user because rules deal with
// their own policy issues. We use a synthetic author PHID (the
// Herald application) as the author of record, so that transactions
// will render in a reasonable way ("Herald assigned this task ...").
$herald_actor = PhabricatorUser::getOmnipotentUser();
$herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
// TODO: It would be nice to give transactions a more specific source
// which points at the rule which generated them. You can figure this
// out from transcripts, but it would be cleaner if you didn't have to.
$herald_source = PhabricatorContentSource::newForSource(
PhabricatorHeraldContentSource::SOURCECONST);
$herald_editor = $this->newEditorCopy()
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->setIsHeraldEditor(true)
->setActor($herald_actor)
->setActingAsPHID($herald_phid)
->setContentSource($herald_source);
$herald_xactions = $herald_editor->applyTransactions(
$object,
$herald_xactions);
// Merge the new transactions into the transaction list: we want to
// send email and publish feed stories about them, too.
$xactions = array_merge($xactions, $herald_xactions);
}
// If Herald did not generate transactions, we may still need to handle
// "Send an Email" rules.
$adapter = $this->getHeraldAdapter();
$this->heraldEmailPHIDs = $adapter->getEmailPHIDs();
$this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs();
$this->webhookMap = $adapter->getWebhookMap();
}
$xactions = $this->didApplyTransactions($object, $xactions);
if ($object instanceof PhabricatorCustomFieldInterface) {
// Maybe this makes more sense to move into the search index itself? For
// now I'm putting it here since I think we might end up with things that
// need it to be up to date once the next page loads, but if we don't go
// there we could move it into search once search moves to the daemons.
// It now happens in the search indexer as well, but the search indexer is
// always daemonized, so the logic above still potentially holds. We could
// possibly get rid of this. The major motivation for putting it in the
// indexer was to enable reindexing to work.
$fields = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
$fields->readFieldsFromStorage($object);
$fields->rebuildIndexes($object);
}
$herald_xscript = $this->getHeraldTranscript();
if ($herald_xscript) {
$herald_header = $herald_xscript->getXHeraldRulesHeader();
$herald_header = HeraldTranscript::saveXHeraldRulesHeader(
$object->getPHID(),
$herald_header);
} else {
$herald_header = HeraldTranscript::loadXHeraldRulesHeader(
$object->getPHID());
}
$this->heraldHeader = $herald_header;
// See PHI1134. If we're a subeditor, we don't publish information about
// the edit yet. Our parent editor still needs to finish applying
// transactions and execute Herald, which may change the information we
// publish.
// For example, Herald actions may change the parent object's title or
// visibility, or Herald may apply rules like "Must Encrypt" that affect
// email.
// Once the parent finishes work, it will queue its own publish step and
// then queue publish steps for its children.
$this->publishableObject = $object;
$this->publishableTransactions = $xactions;
if (!$this->parentEditor) {
$this->queuePublishing();
}
return $xactions;
}
private function queuePublishing() {
$object = $this->publishableObject;
$xactions = $this->publishableTransactions;
if (!$object) {
throw new Exception(
pht(
'Editor method "queuePublishing()" was called, but no publishable '.
'object is present. This Editor is not ready to publish.'));
}
// We're going to compute some of the data we'll use to publish these
// transactions here, before queueing a worker.
//
// Primarily, this is more correct: we want to publish the object as it
// exists right now. The worker may not execute for some time, and we want
// to use the current To/CC list, not respect any changes which may occur
// between now and when the worker executes.
//
// As a secondary benefit, this tends to reduce the amount of state that
// Editors need to pass into workers.
$object = $this->willPublish($object, $xactions);
if (!$this->getIsSilent()) {
if ($this->shouldSendMail($object, $xactions)) {
$this->mailShouldSend = true;
$this->mailToPHIDs = $this->getMailTo($object);
$this->mailCCPHIDs = $this->getMailCC($object);
$this->mailUnexpandablePHIDs = $this->newMailUnexpandablePHIDs($object);
// Add any recipients who were previously on the notification list
// but were removed by this change.
$this->applyOldRecipientLists();
if ($object instanceof PhabricatorSubscribableInterface) {
$this->mailMutedPHIDs = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorMutedByEdgeType::EDGECONST);
} else {
$this->mailMutedPHIDs = array();
}
$mail_xactions = $this->getTransactionsForMail($object, $xactions);
$stamps = $this->newMailStamps($object, $xactions);
foreach ($stamps as $stamp) {
$this->mailStamps[] = $stamp->toDictionary();
}
}
if ($this->shouldPublishFeedStory($object, $xactions)) {
$this->feedShouldPublish = true;
$this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs(
$object,
$xactions);
$this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs(
$object,
$xactions);
}
}
PhabricatorWorker::scheduleTask(
'PhabricatorApplicationTransactionPublishWorker',
array(
'objectPHID' => $object->getPHID(),
'actorPHID' => $this->getActingAsPHID(),
'xactionPHIDs' => mpull($xactions, 'getPHID'),
'state' => $this->getWorkerState(),
),
array(
'objectPHID' => $object->getPHID(),
'priority' => PhabricatorWorker::PRIORITY_ALERTS,
));
foreach ($this->subEditors as $sub_editor) {
$sub_editor->queuePublishing();
}
$this->flushTransactionQueue($object);
}
protected function didCatchDuplicateKeyException(
PhabricatorLiskDAO $object,
array $xactions,
Exception $ex) {
return;
}
public function publishTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$this->object = $object;
$this->xactions = $xactions;
// Hook for edges or other properties that may need (re-)loading
$object = $this->willPublish($object, $xactions);
// The object might have changed, so reassign it.
$this->object = $object;
$messages = array();
if ($this->mailShouldSend) {
$messages = $this->buildMail($object, $xactions);
}
if ($this->supportsSearch()) {
PhabricatorSearchWorker::queueDocumentForIndexing(
$object->getPHID(),
array(
'transactionPHIDs' => mpull($xactions, 'getPHID'),
));
}
if ($this->feedShouldPublish) {
$mailed = array();
foreach ($messages as $mail) {
foreach ($mail->buildRecipientList() as $phid) {
$mailed[$phid] = $phid;
}
}
$this->publishFeedStory($object, $xactions, $mailed);
}
if ($this->sendHistory) {
$history_mail = $this->buildHistoryMail($object);
if ($history_mail) {
$messages[] = $history_mail;
}
}
foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
$messages[] = $message;
}
// NOTE: This actually sends the mail. We do this last to reduce the chance
// that we send some mail, hit an exception, then send the mail again when
// retrying.
foreach ($messages as $mail) {
$mail->save();
}
$this->queueWebhooks($object, $xactions);
return $xactions;
}
protected function didApplyTransactions($object, array $xactions) {
// Hook for subclasses.
return $xactions;
}
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 PhabricatorHandleQuery())
->setViewer($this->requireActor())
->withPHIDs($merged)
->execute();
}
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 PhutilInvalidStateException('setContentSource');
}
// 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 PhabricatorApplicationTransactionStructureException(
$xaction,
pht('You can not apply transactions which already have IDs/PHIDs!'));
}
if ($xaction->getObjectPHID()) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'You can not apply transactions which already have %s!',
'objectPHIDs'));
}
if ($xaction->getCommentPHID()) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'You can not apply transactions which already have %s!',
'commentPHIDs'));
}
if ($xaction->getCommentVersion() !== 0) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'You can not apply transactions which already have '.
'commentVersions!'));
}
$expect_value = !$xaction->shouldGenerateOldValue();
$has_value = $xaction->hasOldValue();
// See T13082. In the narrow case of applying inverse edge edits, we
// expect the old value to be populated.
if ($this->getIsInverseEdgeEditor()) {
$expect_value = true;
}
if ($expect_value && !$has_value) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'This transaction is supposed to have an %s set, but it does not!',
'oldValue'));
}
if ($has_value && !$expect_value) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'This transaction should generate its %s automatically, '.
'but has already had one set!',
'oldValue'));
}
$type = $xaction->getTransactionType();
if (empty($types[$type])) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'Transaction has type "%s", but that transaction type is not '.
'supported by this editor (%s).',
$type,
get_class($this)));
}
}
}
private function applyCapabilityChecks(
PhabricatorLiskDAO $object,
array $xactions) {
assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
$can_edit = PhabricatorPolicyCapability::CAN_EDIT;
if ($this->getIsNewObject()) {
// If we're creating a new object, we don't need any special capabilities
// on the object. The actor has already made it through creation checks,
// and objects which haven't been created yet often can not be
// meaningfully tested for capabilities anyway.
$required_capabilities = array();
} else {
if (!$xactions && !$this->xactions) {
// If we aren't doing anything, require CAN_EDIT to improve consistency.
$required_capabilities = array($can_edit);
} else {
$required_capabilities = array();
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if (!$xtype) {
$capabilities = $this->getLegacyRequiredCapabilities($xaction);
} else {
$capabilities = $xtype->getRequiredCapabilities($object, $xaction);
}
// For convenience, we allow flexibility in the return types because
// it's very unusual that a transaction actually requires multiple
// capability checks.
if ($capabilities === null) {
$capabilities = array();
} else {
$capabilities = (array)$capabilities;
}
foreach ($capabilities as $capability) {
$required_capabilities[$capability] = $capability;
}
}
}
}
$required_capabilities = array_fuse($required_capabilities);
$actor = $this->getActor();
if ($required_capabilities) {
id(new PhabricatorPolicyFilter())
->setViewer($actor)
->requireCapabilities($required_capabilities)
->raisePolicyExceptions(true)
->apply(array($object));
}
}
private function getLegacyRequiredCapabilities(
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
// TODO: Comments technically require CAN_INTERACT, but this is
// currently somewhat special and handled through EditEngine. For now,
// don't enforce it here.
return null;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
// Anyone can subscribe to or unsubscribe from anything they can view,
// with no other permissions.
$old = array_fuse($xaction->getOldValue());
$new = array_fuse($xaction->getNewValue());
// To remove users other than yourself, you must be able to edit the
// object.
$rem = array_diff_key($old, $new);
foreach ($rem as $phid) {
if ($phid !== $this->getActingAsPHID()) {
return PhabricatorPolicyCapability::CAN_EDIT;
}
}
// To add users other than yourself, you must be able to interact.
// This allows "@mentioning" users to work as long as you can comment
// on objects.
// If you can edit, we return that policy instead so that you can
// override a soft lock and still make edits.
// TODO: This is a little bit hacky. We really want to be able to say
// "this requires either interact or edit", but there's currently no
// way to specify this kind of requirement.
$can_edit = PhabricatorPolicyFilter::hasCapability(
$this->getActor(),
$this->object,
PhabricatorPolicyCapability::CAN_EDIT);
$add = array_diff_key($new, $old);
foreach ($add as $phid) {
if ($phid !== $this->getActingAsPHID()) {
if ($can_edit) {
return PhabricatorPolicyCapability::CAN_EDIT;
} else {
return PhabricatorPolicyCapability::CAN_INTERACT;
}
}
}
return null;
case PhabricatorTransactions::TYPE_TOKEN:
// TODO: This technically requires CAN_INTERACT, like comments.
return null;
case PhabricatorTransactions::TYPE_HISTORY:
// This is a special magic transaction which sends you history via
// email and is only partially supported in the upstream. You don't
// need any capabilities to apply it.
return null;
case PhabricatorTransactions::TYPE_MFA:
// Signing a transaction group with MFA does not require permissions
// on its own.
return null;
case PhabricatorTransactions::TYPE_FILE:
return null;
case PhabricatorTransactions::TYPE_EDGE:
return $this->getLegacyRequiredEdgeCapabilities($xaction);
default:
// For other older (non-modular) transactions, always require exactly
// CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
// capabilities must move to ModularTransactions.
return PhabricatorPolicyCapability::CAN_EDIT;
}
}
private function getLegacyRequiredEdgeCapabilities(
PhabricatorApplicationTransaction $xaction) {
// You don't need to have edit permission on an object to mention it or
// otherwise add a relationship pointing toward it.
if ($this->getIsInverseEdgeEditor()) {
return null;
}
$edge_type = $xaction->getMetadataValue('edge:type');
switch ($edge_type) {
case PhabricatorMutedByEdgeType::EDGECONST:
// At time of writing, you can only write this edge for yourself, so
// you don't need permissions. If you can eventually mute an object
// for other users, this would need to be revisited.
return null;
case PhabricatorProjectSilencedEdgeType::EDGECONST:
// At time of writing, you can only write this edge for yourself, so
// you don't need permissions. If you can eventually silence project
// for other users, this would need to be revisited.
return null;
case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
return null;
case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST:
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$add = array_keys(array_diff_key($new, $old));
$rem = array_keys(array_diff_key($old, $new));
$actor_phid = $this->requireActor()->getPHID();
$is_join = (($add === array($actor_phid)) && !$rem);
$is_leave = (($rem === array($actor_phid)) && !$add);
if ($is_join) {
// You need CAN_JOIN to join a project.
return PhabricatorPolicyCapability::CAN_JOIN;
}
if ($is_leave) {
$object = $this->object;
// You usually don't need any capabilities to leave a project...
if ($object->getIsMembershipLocked()) {
// ...you must be able to edit to leave locked projects, though.
return PhabricatorPolicyCapability::CAN_EDIT;
} else {
return null;
}
}
// You need CAN_EDIT to change members other than yourself.
return PhabricatorPolicyCapability::CAN_EDIT;
case PhabricatorObjectHasWatcherEdgeType::EDGECONST:
// See PHI1024. Watching a project does not require CAN_EDIT.
return null;
default:
return PhabricatorPolicyCapability::CAN_EDIT;
}
}
private function buildSubscribeTransaction(
PhabricatorLiskDAO $object,
array $xactions,
array $changes) {
if (!($object instanceof PhabricatorSubscribableInterface)) {
return null;
}
if ($this->shouldEnableMentions($object, $xactions)) {
// Identify newly mentioned users. We ignore users who were previously
// mentioned so that we don't re-subscribe users after an edit of text
// which mentions them.
$old_texts = mpull($changes, 'getOldValue');
$new_texts = mpull($changes, 'getNewValue');
$old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
$this->getActor(),
$old_texts);
$new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
$this->getActor(),
$new_texts);
$phids = array_diff($new_phids, $old_phids);
} else {
$phids = array();
}
$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);
}
if ($phids) {
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->getActor())
->withPHIDs($phids)
->execute();
$users = mpull($users, null, 'getPHID');
foreach ($phids as $key => $phid) {
$user = idx($users, $phid);
// Don't subscribe invalid users.
if (!$user) {
unset($phids[$key]);
continue;
}
// Don't subscribe bots that get mentioned. If users truly intend
// to subscribe them, they can add them explicitly, but it's generally
// not useful to subscribe bots to objects.
if ($user->getIsSystemAgent()) {
unset($phids[$key]);
continue;
}
// Do not subscribe mentioned users who do not have permission to see
// the object.
if ($object instanceof PhabricatorPolicyInterface) {
$can_view = PhabricatorPolicyFilter::hasCapability(
$user,
$object,
PhabricatorPolicyCapability::CAN_VIEW);
if (!$can_view) {
unset($phids[$key]);
continue;
}
}
// Don't subscribe users who are already automatically subscribed.
if ($object->isAutomaticallySubscribed($phid)) {
unset($phids[$key]);
continue;
}
}
$phids = array_values($phids);
}
if (!$phids) {
return null;
}
$xaction = $object->getApplicationTransactionTemplate()
->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
->setNewValue(array('+' => $phids));
return $xaction;
}
protected function mergeTransactions(
PhabricatorApplicationTransaction $u,
PhabricatorApplicationTransaction $v) {
$object = $this->object;
$type = $u->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
return $xtype->mergeTransactions($object, $u, $v);
}
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;
}
/**
* Optionally expand transactions which imply other effects. For example,
* resigning from a revision in Differential implies removing yourself as
* a reviewer.
*/
protected function expandTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$results = array();
foreach ($xactions as $xaction) {
foreach ($this->expandTransaction($object, $xaction) as $expanded) {
$results[] = $expanded;
}
}
return $results;
}
protected function expandTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return array($xaction);
}
public function getExpandedSupportTransactions(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$xactions = array($xaction);
$xactions = $this->expandSupportTransactions(
$object,
$xactions);
if (count($xactions) == 1) {
return array();
}
foreach ($xactions as $index => $cxaction) {
if ($cxaction === $xaction) {
unset($xactions[$index]);
break;
}
}
return $xactions;
}
private function expandSupportTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$this->loadSubscribers($object);
$xactions = $this->applyImplicitCC($object, $xactions);
$changes = $this->getRemarkupChanges($xactions);
$subscribe_xaction = $this->buildSubscribeTransaction(
$object,
$xactions,
$changes);
if ($subscribe_xaction) {
$xactions[] = $subscribe_xaction;
}
// TODO: For now, this is just a placeholder.
$engine = PhabricatorMarkupEngine::getEngine('extract');
$engine->setConfig('viewer', $this->requireActor());
$block_xactions = $this->expandRemarkupBlockTransactions(
$object,
$xactions,
$changes,
$engine);
foreach ($block_xactions as $xaction) {
$xactions[] = $xaction;
}
$file_xaction = $this->newFileTransaction(
$object,
$xactions,
$changes);
if ($file_xaction) {
$xactions[] = $file_xaction;
}
return $xactions;
}
private function newFileTransaction(
PhabricatorLiskDAO $object,
array $xactions,
array $remarkup_changes) {
assert_instances_of(
$remarkup_changes,
'PhabricatorTransactionRemarkupChange');
$new_map = array();
$viewer = $this->getActor();
$old_blocks = mpull($remarkup_changes, 'getOldValue');
foreach ($old_blocks as $key => $old_block) {
$old_blocks[$key] = phutil_string_cast($old_block);
}
$new_blocks = mpull($remarkup_changes, 'getNewValue');
foreach ($new_blocks as $key => $new_block) {
$new_blocks[$key] = phutil_string_cast($new_block);
}
$old_refs = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
$viewer,
$old_blocks);
$old_refs = array_fuse($old_refs);
$new_refs = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
$viewer,
$new_blocks);
$new_refs = array_fuse($new_refs);
$add_refs = array_diff_key($new_refs, $old_refs);
foreach ($add_refs as $file_phid) {
$new_map[$file_phid] = PhabricatorFileAttachment::MODE_REFERENCE;
}
foreach ($remarkup_changes as $remarkup_change) {
$metadata = $remarkup_change->getMetadata();
$attached_phids = idx($metadata, 'attachedFilePHIDs', array());
foreach ($attached_phids as $file_phid) {
// If the blocks don't include a new embedded reference to this file,
// do not actually attach it. A common way for this to happen is for
// a user to upload a file, then change their mind and remove the
// reference. We do not want to attach the file if they decided against
// referencing it.
if (!isset($new_map[$file_phid])) {
continue;
}
$new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
}
}
$file_phids = $this->extractFilePHIDs($object, $xactions);
foreach ($file_phids as $file_phid) {
$new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
}
if (!$new_map) {
return null;
}
$xaction = $object->getApplicationTransactionTemplate()
->setTransactionType(PhabricatorTransactions::TYPE_FILE)
->setMetadataValue('attach.implicit', true)
->setNewValue($new_map);
return $xaction;
}
private function getRemarkupChanges(array $xactions) {
$changes = array();
foreach ($xactions as $key => $xaction) {
foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
$changes[] = $change;
}
}
return $changes;
}
private function getRemarkupChangesFromTransaction(
PhabricatorApplicationTransaction $transaction) {
return $transaction->getRemarkupChanges();
}
private function expandRemarkupBlockTransactions(
PhabricatorLiskDAO $object,
array $xactions,
array $changes,
PhutilMarkupEngine $engine) {
$block_xactions = $this->expandCustomRemarkupBlockTransactions(
$object,
$xactions,
$changes,
$engine);
$mentioned_phids = array();
if ($this->shouldEnableMentions($object, $xactions)) {
foreach ($changes as $change) {
// Here, we don't care about processing only new mentions after an edit
// because there is no way for an object to ever "unmention" itself on
// another object, so we can ignore the old value.
$engine->markupText($change->getNewValue());
$mentioned_phids += $engine->getTextMetadata(
PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS,
array());
}
}
if (!$mentioned_phids) {
return $block_xactions;
}
$mentioned_objects = id(new PhabricatorObjectQuery())
->setViewer($this->getActor())
->withPHIDs($mentioned_phids)
->execute();
$unmentionable_map = $this->getUnmentionablePHIDMap();
$mentionable_phids = array();
if ($this->shouldEnableMentions($object, $xactions)) {
foreach ($mentioned_objects as $mentioned_object) {
if ($mentioned_object instanceof PhabricatorMentionableInterface) {
$mentioned_phid = $mentioned_object->getPHID();
if (isset($unmentionable_map[$mentioned_phid])) {
continue;
}
// don't let objects mention themselves
if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
continue;
}
$mentionable_phids[$mentioned_phid] = $mentioned_phid;
}
}
}
if ($mentionable_phids) {
$edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST;
$block_xactions[] = newv(get_class(head($xactions)), array())
->setIgnoreOnNoEffect(true)
->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
->setMetadataValue('edge:type', $edge_type)
->setNewValue(array('+' => $mentionable_phids));
}
return $block_xactions;
}
protected function expandCustomRemarkupBlockTransactions(
PhabricatorLiskDAO $object,
array $xactions,
array $changes,
PhutilMarkupEngine $engine) {
return array();
}
/**
* 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) {
$other_xaction = $result[$other_key];
// Don't merge transactions with different authors. For example,
// don't merge Herald transactions and owners transactions.
if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
continue;
}
$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);
}
public function mergePHIDOrEdgeTransactions(
PhabricatorApplicationTransaction $u,
PhabricatorApplicationTransaction $v) {
$result = $u->getNewValue();
foreach ($v->getNewValue() as $key => $value) {
if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) {
if (empty($result[$key])) {
$result[$key] = $value;
} else {
// We're merging two lists of edge adds, sets, or removes. Merge
// them by merging individual PHIDs within them.
$merged = $result[$key];
foreach ($value as $dst => $v_spec) {
if (empty($merged[$dst])) {
$merged[$dst] = $v_spec;
} else {
// Two transactions are trying to perform the same operation on
// the same edge. Normalize the edge data and then merge it. This
// allows transactions to specify how data merges execute in a
// precise way.
$u_spec = $merged[$dst];
if (!is_array($u_spec)) {
$u_spec = array('dst' => $u_spec);
}
if (!is_array($v_spec)) {
$v_spec = array('dst' => $v_spec);
}
$ux_data = idx($u_spec, 'data', array());
$vx_data = idx($v_spec, 'data', array());
$merged_data = $this->mergeEdgeData(
$u->getMetadataValue('edge:type'),
$ux_data,
$vx_data);
$u_spec['data'] = $merged_data;
$merged[$dst] = $u_spec;
}
}
$result[$key] = $merged;
}
} else {
$result[$key] = array_merge($value, idx($result, $key, array()));
}
}
$u->setNewValue($result);
// When combining an "ignore" transaction with a normal transaction, make
// sure we don't propagate the "ignore" flag.
if (!$v->getIgnoreOnNoEffect()) {
$u->setIgnoreOnNoEffect(false);
}
return $u;
}
protected function mergeEdgeData($type, array $u, array $v) {
return $v + $u;
}
protected function getPHIDTransactionNewValue(
PhabricatorApplicationTransaction $xaction,
$old = null) {
if ($old !== null) {
$old = array_fuse($old);
} else {
$old = array_fuse($xaction->getOldValue());
}
return $this->getPHIDList($old, $xaction->getNewValue());
}
public function getPHIDList(array $old, array $new) {
$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(
pht(
"Invalid '%s' value for PHID transaction. Value should contain only ".
"keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
'new',
'+',
'-',
'='));
}
$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(
pht(
"Invalid '%s' value for Edge transaction. Value should contain only ".
"keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
'new',
'+',
'-',
'='));
}
$old = $xaction->getOldValue();
$lists = array($new_set, $new_add, $new_rem);
foreach ($lists as $list) {
$this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
}
$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,
$dst_phid);
}
if ($new_set !== null) {
foreach ($new_set as $dst_phid => $edge) {
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge,
$dst_phid);
}
}
foreach ($new_add as $dst_phid => $edge) {
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge,
$dst_phid);
}
foreach ($new_rem as $dst_phid => $edge) {
unset($result[$dst_phid]);
}
return $result;
}
private function checkEdgeList($list, $edge_type) {
if (!$list) {
return;
}
foreach ($list as $key => $item) {
if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
throw new Exception(
pht(
'Edge transactions must have destination PHIDs as in edge '.
'lists (found key "%s" on transaction of type "%s").',
$key,
$edge_type));
}
if (!is_array($item) && $item !== $key) {
throw new Exception(
pht(
'Edge transactions must have PHIDs or edge specs as values '.
'(found value "%s" on transaction of type "%s").',
$item,
$edge_type));
}
}
}
private function normalizeEdgeTransactionValue(
PhabricatorApplicationTransaction $xaction,
$edge,
$dst_phid) {
if (!is_array($edge)) {
if ($edge != $dst_phid) {
throw new Exception(
pht(
'Transaction edge data must either be the edge PHID or an edge '.
'specification dictionary.'));
}
$edge = array();
} else {
foreach ($edge as $key => $value) {
switch ($key) {
case 'src':
case 'dst':
case 'type':
case 'data':
case 'dateCreated':
case 'dateModified':
case 'seq':
case 'dataID':
break;
default:
throw new Exception(
pht(
'Transaction edge specification contains unexpected key "%s".',
$key));
}
}
}
$edge['dst'] = $dst_phid;
$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(
pht(
"Edge transaction includes edge of type '%s', but ".
"transaction is of type '%s'. Each edge transaction ".
"must alter edges of only one type.",
$this_type,
$edge_type));
}
}
if (!isset($edge['data'])) {
$edge['data'] = array();
}
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;
$type_mfa = PhabricatorTransactions::TYPE_MFA;
$no_effect = array();
$has_comment = false;
$any_effect = false;
$meta_xactions = array();
foreach ($xactions as $key => $xaction) {
if ($xaction->getTransactionType() === $type_mfa) {
$meta_xactions[$key] = $xaction;
continue;
}
if ($this->transactionHasEffect($object, $xaction)) {
if ($xaction->getTransactionType() != $type_comment) {
$any_effect = true;
}
} else if ($xaction->getIgnoreOnNoEffect()) {
unset($xactions[$key]);
} else {
$no_effect[$key] = $xaction;
}
if ($xaction->hasComment()) {
$has_comment = true;
}
}
// If every transaction is a meta-transaction applying to the transaction
// group, these transactions are junk.
if (count($meta_xactions) == count($xactions)) {
$no_effect = $xactions;
$any_effect = false;
}
if (!$no_effect) {
return $xactions;
}
// If none of the transactions have an effect, the meta-transactions also
// have no effect. Add them to the "no effect" list so we get a full set
// of errors for everything.
if (!$any_effect && !$has_comment) {
$no_effect += $meta_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->hasComment()) {
$xaction->setTransactionType($type_comment);
$xaction->setOldValue(null);
$xaction->setNewValue(null);
} else {
unset($xactions[$key]);
}
}
return $xactions;
}
/**
* Hook for validating transactions. This callback will be invoked for each
* available transaction type, even if an edit does not apply any transactions
* of that type. This allows you to raise exceptions when required fields are
* missing, by detecting that the object has no field value and there is no
* transaction which sets one.
*
* @param PhabricatorLiskDAO Object being edited.
* @param string Transaction type to validate.
* @param list<PhabricatorApplicationTransaction> Transactions of given type,
* which may be empty if the edit does not apply any transactions of the
* given type.
* @return list<PhabricatorApplicationTransactionValidationError> List of
* validation errors.
*/
protected function validateTransaction(
PhabricatorLiskDAO $object,
$type,
array $xactions) {
$errors = array();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$errors[] = $xtype->validateTransactions($object, $xactions);
}
switch ($type) {
case PhabricatorTransactions::TYPE_VIEW_POLICY:
$errors[] = $this->validatePolicyTransaction(
$object,
$xactions,
$type,
PhabricatorPolicyCapability::CAN_VIEW);
break;
case PhabricatorTransactions::TYPE_EDIT_POLICY:
$errors[] = $this->validatePolicyTransaction(
$object,
$xactions,
$type,
PhabricatorPolicyCapability::CAN_EDIT);
break;
case PhabricatorTransactions::TYPE_SPACE:
$errors[] = $this->validateSpaceTransactions(
$object,
$xactions,
$type);
break;
case PhabricatorTransactions::TYPE_SUBTYPE:
$errors[] = $this->validateSubtypeTransactions(
$object,
$xactions,
$type);
break;
case PhabricatorTransactions::TYPE_MFA:
$errors[] = $this->validateMFATransactions(
$object,
$xactions,
$type);
break;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$groups = array();
foreach ($xactions as $xaction) {
$groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
}
$field_list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_EDIT);
$field_list->setViewer($this->getActor());
$role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
foreach ($field_list->getFields() as $field) {
if (!$field->shouldEnableForRole($role_xactions)) {
continue;
}
$errors[] = $field->validateApplicationTransactions(
$this,
$type,
idx($groups, $field->getFieldKey(), array()));
}
break;
case PhabricatorTransactions::TYPE_FILE:
$errors[] = $this->validateFileTransactions(
$object,
$xactions,
$type);
break;
}
return array_mergev($errors);
}
private function validateFileTransactions(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type) {
$errors = array();
$mode_map = PhabricatorFileAttachment::getModeList();
$mode_map = array_fuse($mode_map);
$file_phids = array();
foreach ($xactions as $xaction) {
$new = $xaction->getNewValue();
if (!is_array($new)) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'File attachment transaction must have a map of files to '.
'attachment modes, found "%s".',
phutil_describe_type($new)),
$xaction);
continue;
}
foreach ($new as $file_phid => $attachment_mode) {
$file_phids[$file_phid] = $file_phid;
if (is_string($attachment_mode) && isset($mode_map[$attachment_mode])) {
continue;
}
if (!is_string($attachment_mode)) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'File attachment mode (for file "%s") is invalid. Expected '.
'a string, found "%s".',
$file_phid,
phutil_describe_type($attachment_mode)),
$xaction);
} else {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'File attachment mode "%s" (for file "%s") is invalid. Valid '.
'modes are: %s.',
$attachment_mode,
$file_phid,
pht_list($mode_map)),
$xaction);
}
}
}
if ($file_phids) {
$file_map = id(new PhabricatorFileQuery())
->setViewer($this->getActor())
->withPHIDs($file_phids)
->execute();
$file_map = mpull($file_map, null, 'getPHID');
} else {
$file_map = array();
}
foreach ($xactions as $xaction) {
$new = $xaction->getNewValue();
if (!is_array($new)) {
continue;
}
foreach ($new as $file_phid => $attachment_mode) {
if (isset($file_map[$file_phid])) {
continue;
}
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'File "%s" is invalid: it could not be loaded, or you do not '.
'have permission to view it. You must be able to see a file to '.
'attach it to an object.',
$file_phid),
$xaction);
}
}
return $errors;
}
public function validatePolicyTransaction(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type,
$capability) {
$actor = $this->requireActor();
$errors = array();
// Note $this->xactions is necessary; $xactions is $this->xactions of
// $transaction_type
$policy_object = $this->adjustObjectForPolicyChecks(
$object,
$this->xactions);
// Make sure the user isn't editing away their ability to $capability this
// object.
foreach ($xactions as $xaction) {
try {
PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
$actor,
$policy_object,
$capability,
$xaction->getNewValue());
} catch (PhabricatorPolicyException $ex) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'You can not select this %s policy, because you would no longer '.
'be able to %s the object.',
$capability,
$capability),
$xaction);
}
}
if ($this->getIsNewObject()) {
if (!$xactions) {
$has_capability = PhabricatorPolicyFilter::hasCapability(
$actor,
$policy_object,
$capability);
if (!$has_capability) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'The selected %s policy excludes you. Choose a %s policy '.
'which allows you to %s the object.',
$capability,
$capability,
$capability));
}
}
}
return $errors;
}
private function validateSpaceTransactions(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type) {
$errors = array();
$actor = $this->getActor();
$has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
$actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
$active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
$actor);
foreach ($xactions as $xaction) {
$space_phid = $xaction->getNewValue();
if ($space_phid === null) {
if (!$has_spaces) {
// The install doesn't have any spaces, so this is fine.
continue;
}
// The install has some spaces, so every object needs to be put
// in a valid space.
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht('You must choose a space for this object.'),
$xaction);
continue;
}
// If the PHID isn't `null`, it needs to be a valid space that the
// viewer can see.
if (empty($actor_spaces[$space_phid])) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'You can not shift this object in the selected space, because '.
'the space does not exist or you do not have access to it.'),
$xaction);
} else if (empty($active_spaces[$space_phid])) {
// It's OK to edit objects in an archived space, so just move on if
// we aren't adjusting the value.
$old_space_phid = $this->getTransactionOldValue($object, $xaction);
if ($space_phid == $old_space_phid) {
continue;
}
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Archived'),
pht(
'You can not shift this object into the selected space, because '.
'the space is archived. Objects can not be created inside (or '.
'moved into) archived spaces.'),
$xaction);
}
}
return $errors;
}
private function validateSubtypeTransactions(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type) {
$errors = array();
$map = $object->newEditEngineSubtypeMap();
$old = $object->getEditEngineSubtype();
foreach ($xactions as $xaction) {
$new = $xaction->getNewValue();
if ($old == $new) {
continue;
}
if (!$map->isValidSubtype($new)) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'The subtype "%s" is not a valid subtype.',
$new),
$xaction);
continue;
}
}
return $errors;
}
private function validateMFATransactions(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type) {
$errors = array();
$factors = id(new PhabricatorAuthFactorConfigQuery())
->setViewer($this->getActor())
->withUserPHIDs(array($this->getActingAsPHID()))
->withFactorProviderStatuses(
array(
PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
))
->execute();
foreach ($xactions as $xaction) {
if (!$factors) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('No MFA'),
pht(
'You do not have any MFA factors attached to your account, so '.
'you can not sign this transaction group with MFA. Add MFA to '.
'your account in Settings.'),
$xaction);
}
}
if ($xactions) {
$this->setShouldRequireMFA(true);
}
return $errors;
}
protected function adjustObjectForPolicyChecks(
PhabricatorLiskDAO $object,
array $xactions) {
$copy = clone $object;
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$clone_xaction = clone $xaction;
$clone_xaction->setOldValue(array_values($this->subscribers));
$clone_xaction->setNewValue(
$this->getPHIDTransactionNewValue(
$clone_xaction));
PhabricatorPolicyRule::passTransactionHintToRule(
$copy,
new PhabricatorSubscriptionsSubscribersPolicyRule(),
array_fuse($clone_xaction->getNewValue()));
break;
case PhabricatorTransactions::TYPE_SPACE:
$space_phid = $this->getTransactionNewValue($object, $xaction);
$copy->setSpacePHID($space_phid);
break;
}
}
return $copy;
}
protected function validateAllTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
return array();
}
/**
* Check for a missing text field.
*
* A text field is missing if the object has no value and there are no
* transactions which set a value, or if the transactions remove the value.
* This method is intended to make implementing @{method:validateTransaction}
* more convenient:
*
* $missing = $this->validateIsEmptyTextField(
* $object->getName(),
* $xactions);
*
* This will return `true` if the net effect of the object and transactions
* is an empty field.
*
* @param wild Current field value.
* @param list<PhabricatorApplicationTransaction> Transactions editing the
* field.
* @return bool True if the field will be an empty text field after edits.
*/
protected function validateIsEmptyTextField($field_value, array $xactions) {
if (($field_value !== null && strlen($field_value)) && empty($xactions)) {
return false;
}
if ($xactions && strlen(last($xactions)->getNewValue())) {
return false;
}
return true;
}
/* -( 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->getActingAsPHID();
$type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
if (phid_get_type($actor_phid) != $type_user) {
// Transactions by application actors like Herald, Harbormaster and
// Diffusion should not CC the applications.
return $xactions;
}
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(),
PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
$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;
}
}
$actor = $this->getActor();
$user = id(new PhabricatorPeopleQuery())
->setViewer($actor)
->withPHIDs(array($actor_phid))
->executeOne();
if (!$user) {
return $xactions;
}
// When a bot acts (usually via the API), don't automatically subscribe
// them as a side effect. They can always subscribe explicitly if they
// want, and bot subscriptions normally just clutter things up since bots
// usually do not read email.
if ($user->getIsSystemAgent()) {
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) {
return $xaction->isCommentTransaction();
}
/* -( Sending Mail )------------------------------------------------------- */
/**
* @task mail
*/
protected function shouldSendMail(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
/**
* @task mail
*/
private function buildMail(
PhabricatorLiskDAO $object,
array $xactions) {
$email_to = $this->mailToPHIDs;
$email_cc = $this->mailCCPHIDs;
$email_cc = array_merge($email_cc, $this->heraldEmailPHIDs);
$unexpandable = $this->mailUnexpandablePHIDs;
if (!is_array($unexpandable)) {
$unexpandable = array();
}
$messages = $this->buildMailWithRecipients(
$object,
$xactions,
$email_to,
$email_cc,
$unexpandable);
$this->runHeraldMailRules($messages);
return $messages;
}
private function buildMailWithRecipients(
PhabricatorLiskDAO $object,
array $xactions,
array $email_to,
array $email_cc,
array $unexpandable) {
$targets = $this->buildReplyHandler($object)
->setUnexpandablePHIDs($unexpandable)
->getMailTargets($email_to, $email_cc);
// Set this explicitly before we start swapping out the effective actor.
$this->setActingAsPHID($this->getActingAsPHID());
$xaction_phids = mpull($xactions, 'getPHID');
$messages = array();
foreach ($targets as $target) {
$original_actor = $this->getActor();
$viewer = $target->getViewer();
$this->setActor($viewer);
$locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation());
$caught = null;
$mail = null;
try {
// Reload the transactions for the current viewer.
if ($xaction_phids) {
$query = PhabricatorApplicationTransactionQuery::newQueryForObject(
$object);
$mail_xactions = $query
->setViewer($viewer)
->withObjectPHIDs(array($object->getPHID()))
->withPHIDs($xaction_phids)
->execute();
// Sort the mail transactions in the input order.
$mail_xactions = mpull($mail_xactions, null, 'getPHID');
$mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
$mail_xactions = array_values($mail_xactions);
} else {
$mail_xactions = array();
}
// Reload handles for the current viewer. This covers older code which
// emits a list of handle PHIDs upfront.
$this->loadHandles($mail_xactions);
$mail = $this->buildMailForTarget($object, $mail_xactions, $target);
if ($mail) {
if ($this->mustEncrypt) {
$mail
->setMustEncrypt(true)
->setMustEncryptReasons($this->mustEncrypt);
}
}
} catch (Exception $ex) {
$caught = $ex;
}
$this->setActor($original_actor);
unset($locale);
if ($caught) {
throw $ex;
}
if ($mail) {
$messages[] = $mail;
}
}
return $messages;
}
protected function getTransactionsForMail(
PhabricatorLiskDAO $object,
array $xactions) {
return $xactions;
}
private function buildMailForTarget(
PhabricatorLiskDAO $object,
array $xactions,
PhabricatorMailTarget $target) {
// Check if any of the transactions are visible for this viewer. If we
// don't have any visible transactions, don't send the mail.
$any_visible = false;
foreach ($xactions as $xaction) {
if (!$xaction->shouldHideForMail($xactions)) {
$any_visible = true;
break;
}
}
if (!$any_visible) {
return null;
}
$mail_xactions = $this->getTransactionsForMail($object, $xactions);
$mail = $this->buildMailTemplate($object);
$body = $this->buildMailBody($object, $mail_xactions);
$mail_tags = $this->getMailTags($object, $mail_xactions);
$action = $this->getMailAction($object, $mail_xactions);
$stamps = $this->generateMailStamps($object, $this->mailStamps);
if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) {
$this->addEmailPreferenceSectionToMailBody(
$body,
$object,
$mail_xactions);
}
$muted_phids = $this->mailMutedPHIDs;
if (!is_array($muted_phids)) {
$muted_phids = array();
}
$mail
->setSensitiveContent(false)
->setFrom($this->getActingAsPHID())
->setSubjectPrefix($this->getMailSubjectPrefix())
->setVarySubjectPrefix('['.$action.']')
->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
->setRelatedPHID($object->getPHID())
->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
->setMutedPHIDs($muted_phids)
->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs)
->setMailTags($mail_tags)
->setIsBulk(true)
->setBody($body->render())
->setHTMLBody($body->renderHTML());
foreach ($body->getAttachments() as $attachment) {
$mail->addAttachment($attachment);
}
if ($this->heraldHeader) {
$mail->addHeader('X-Herald-Rules', $this->heraldHeader);
}
if ($object instanceof PhabricatorProjectInterface) {
$this->addMailProjectMetadata($object, $mail);
}
if ($this->getParentMessageID()) {
$mail->setParentMessageID($this->getParentMessageID());
}
// If we have stamps, attach the raw dictionary version (not the actual
// objects) to the mail so that debugging tools can see what we used to
// render the final list.
if ($this->mailStamps) {
$mail->setMailStampMetadata($this->mailStamps);
}
// If we have rendered stamps, attach them to the mail.
if ($stamps) {
$mail->setMailStamps($stamps);
}
return $target->willSendMail($mail);
}
private function addMailProjectMetadata(
PhabricatorLiskDAO $object,
PhabricatorMetaMTAMail $template) {
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
if (!$project_phids) {
return;
}
// TODO: This viewer isn't quite right. It would be slightly better to use
// the mail recipient, but that's not very easy given the way rendering
// works today.
$handles = id(new PhabricatorHandleQuery())
->setViewer($this->requireActor())
->withPHIDs($project_phids)
->execute();
$project_tags = array();
foreach ($handles as $handle) {
if (!$handle->isComplete()) {
continue;
}
$project_tags[] = '<'.$handle->getObjectName().'>';
}
if (!$project_tags) {
return;
}
$project_tags = implode(', ', $project_tags);
$template->addHeader('X-Phabricator-Projects', $project_tags);
}
protected function getMailThreadID(PhabricatorLiskDAO $object) {
return $object->getPHID();
}
/**
* @task mail
*/
protected function getStrongestAction(
PhabricatorLiskDAO $object,
array $xactions) {
return head(msortv($xactions, 'newActionStrengthSortVector'));
}
/**
* @task mail
*/
protected function buildReplyHandler(PhabricatorLiskDAO $object) {
throw new Exception(pht('Capability not supported.'));
}
/**
* @task mail
*/
protected function getMailSubjectPrefix() {
throw new Exception(pht('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
*/
public function getMailTagsMap() {
// TODO: We should move shared mail tags, like "comment", here.
return array();
}
/**
* @task mail
*/
protected function getMailAction(
PhabricatorLiskDAO $object,
array $xactions) {
return $this->getStrongestAction($object, $xactions)->getActionName();
}
/**
* @task mail
*/
protected function buildMailTemplate(PhabricatorLiskDAO $object) {
throw new Exception(pht('Capability not supported.'));
}
/**
* @task mail
*/
protected function getMailTo(PhabricatorLiskDAO $object) {
throw new Exception(pht('Capability not supported.'));
}
protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) {
return array();
}
/**
* @task mail
*/
protected function getMailCC(PhabricatorLiskDAO $object) {
$phids = array();
$has_support = false;
if ($object instanceof PhabricatorSubscribableInterface) {
$phid = $object->getPHID();
$phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid);
$has_support = true;
}
if ($object instanceof PhabricatorProjectInterface) {
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
if ($project_phids) {
$projects = id(new PhabricatorProjectQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($project_phids)
->needWatchers(true)
->execute();
$watcher_phids = array();
foreach ($projects as $project) {
foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
$watcher_phids[$phid] = $phid;
}
}
if ($watcher_phids) {
// We need to do a visibility check for all the watchers, as
// watching a project is not a guarantee that you can see objects
// associated with it.
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->requireActor())
->withPHIDs($watcher_phids)
->execute();
$watchers = array();
foreach ($users as $user) {
$can_see = PhabricatorPolicyFilter::hasCapability(
$user,
$object,
PhabricatorPolicyCapability::CAN_VIEW);
if ($can_see) {
$watchers[] = $user->getPHID();
}
}
$phids[] = $watchers;
}
}
$has_support = true;
}
if (!$has_support) {
throw new Exception(
pht('The object being edited does not implement any standard '.
'interfaces (like PhabricatorSubscribableInterface) which allow '.
'CCs to be generated automatically. Override the "getMailCC()" '.
'method and generate CCs explicitly.'));
}
return array_mergev($phids);
}
/**
* @task mail
*/
protected function buildMailBody(
PhabricatorLiskDAO $object,
array $xactions) {
$body = id(new PhabricatorMetaMTAMailBody())
->setViewer($this->requireActor())
->setContextObject($object);
$button_label = $this->getObjectLinkButtonLabelForMail($object);
$button_uri = $this->getObjectLinkButtonURIForMail($object);
$this->addHeadersAndCommentsToMailBody(
$body,
$xactions,
$button_label,
$button_uri);
$this->addCustomFieldsToMailBody($body, $object, $xactions);
return $body;
}
protected function getObjectLinkButtonLabelForMail(
PhabricatorLiskDAO $object) {
return null;
}
protected function getObjectLinkButtonURIForMail(
PhabricatorLiskDAO $object) {
// Most objects define a "getURI()" method which does what we want, but
// this isn't formally part of an interface at time of writing. Try to
// call the method, expecting an exception if it does not exist.
try {
$uri = $object->getURI();
return PhabricatorEnv::getProductionURI($uri);
} catch (Exception $ex) {
return null;
}
}
/**
* @task mail
*/
protected function addEmailPreferenceSectionToMailBody(
PhabricatorMetaMTAMailBody $body,
PhabricatorLiskDAO $object,
array $xactions) {
$href = PhabricatorEnv::getProductionURI(
'/settings/panel/emailpreferences/');
$body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
}
/**
* @task mail
*/
protected function addHeadersAndCommentsToMailBody(
PhabricatorMetaMTAMailBody $body,
array $xactions,
$object_label = null,
$object_uri = null) {
// First, remove transactions which shouldn't be rendered in mail.
foreach ($xactions as $key => $xaction) {
if ($xaction->shouldHideForMail($xactions)) {
unset($xactions[$key]);
}
}
$headers = array();
$headers_html = array();
$comments = array();
$details = array();
$seen_comment = false;
foreach ($xactions as $xaction) {
// Most mail has zero or one comments. In these cases, we render the
// "alice added a comment." transaction in the header, like a normal
// transaction.
// Some mail, like Differential undraft mail or "!history" mail, may
// have two or more comments. In these cases, we'll put the first
// "alice added a comment." transaction in the header normally, but
// move the other transactions down so they provide context above the
// actual comment.
$comment = $this->getBodyForTextMail($xaction);
if ($comment !== null) {
$is_comment = true;
$comments[] = array(
'xaction' => $xaction,
'comment' => $comment,
'initial' => !$seen_comment,
);
} else {
$is_comment = false;
}
if (!$is_comment || !$seen_comment) {
$header = $this->getTitleForTextMail($xaction);
if ($header !== null) {
$headers[] = $header;
}
$header_html = $this->getTitleForHTMLMail($xaction);
if ($header_html !== null) {
$headers_html[] = $header_html;
}
}
if ($xaction->hasChangeDetailsForMail()) {
$details[] = $xaction;
}
if ($is_comment) {
$seen_comment = true;
}
}
$headers_text = implode("\n", $headers);
$body->addRawPlaintextSection($headers_text);
$headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
$header_button = null;
if ($object_label !== null && $object_uri !== null) {
$button_style = array(
'text-decoration: none;',
'padding: 4px 8px;',
'margin: 0 8px 8px;',
'float: right;',
'color: #464C5C;',
'font-weight: bold;',
'border-radius: 3px;',
'background-color: #F7F7F9;',
'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
'display: inline-block;',
'border: 1px solid rgba(71,87,120,.2);',
);
$header_button = phutil_tag(
'a',
array(
'style' => implode(' ', $button_style),
'href' => $object_uri,
),
$object_label);
}
$xactions_style = array();
$header_action = phutil_tag(
'td',
array(),
$header_button);
$header_action = phutil_tag(
'td',
array(
'style' => implode(' ', $xactions_style),
),
array(
$headers_html,
// Add an extra newline to prevent the "View Object" button from
// running into the transaction text in Mail.app text snippet
// previews.
"\n",
));
$headers_html = phutil_tag(
'table',
array(),
phutil_tag('tr', array(), array($header_action, $header_button)));
$body->addRawHTMLSection($headers_html);
foreach ($comments as $spec) {
$xaction = $spec['xaction'];
$comment = $spec['comment'];
$is_initial = $spec['initial'];
// If this is not the first comment in the mail, add the header showing
// who wrote the comment immediately above the comment.
if (!$is_initial) {
$header = $this->getTitleForTextMail($xaction);
if ($header !== null) {
$body->addRawPlaintextSection($header);
}
$header_html = $this->getTitleForHTMLMail($xaction);
if ($header_html !== null) {
$body->addRawHTMLSection($header_html);
}
}
$body->addRemarkupSection(null, $comment);
}
foreach ($details as $xaction) {
$details = $xaction->renderChangeDetailsForMail($body->getViewer());
if ($details !== null) {
$label = $this->getMailDiffSectionHeader($xaction);
$body->addHTMLSection($label, $details);
}
}
}
private function getMailDiffSectionHeader($xaction) {
$type = $xaction->getTransactionType();
$object = $this->object;
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
return $xtype->getMailDiffSectionHeader();
}
return pht('EDIT DETAILS');
}
/**
* @task mail
*/
protected function addCustomFieldsToMailBody(
PhabricatorMetaMTAMailBody $body,
PhabricatorLiskDAO $object,
array $xactions) {
if ($object instanceof PhabricatorCustomFieldInterface) {
$field_list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_TRANSACTIONMAIL);
$field_list->setViewer($this->getActor());
$field_list->readFieldsFromStorage($object);
foreach ($field_list->getFields() as $field) {
$field->updateTransactionMailBody(
$body,
$this,
$xactions);
}
}
}
/**
* @task mail
*/
private function runHeraldMailRules(array $messages) {
foreach ($messages as $message) {
$engine = new HeraldEngine();
$adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
->setObject($message);
$rules = $engine->loadRulesForAdapter($adapter);
$effects = $engine->applyRules($rules, $adapter);
$engine->applyEffects($effects, $adapter, $rules);
}
}
/* -( Publishing Feed Stories )-------------------------------------------- */
/**
* @task feed
*/
protected function shouldPublishFeedStory(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
/**
* @task feed
*/
protected function getFeedStoryType() {
return 'PhabricatorApplicationTransactionFeedStory';
}
/**
* @task feed
*/
protected function getFeedRelatedPHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
$phids = array(
$object->getPHID(),
$this->getActingAsPHID(),
);
if ($object instanceof PhabricatorProjectInterface) {
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
foreach ($project_phids as $project_phid) {
$phids[] = $project_phid;
}
}
return $phids;
}
/**
* @task feed
*/
protected function getFeedNotifyPHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
// If some transactions are forcing notification delivery, add the forced
// recipients to the notify list.
$force_list = array();
foreach ($xactions as $xaction) {
$force_phids = $xaction->getForceNotifyPHIDs();
if (!$force_phids) {
continue;
}
foreach ($force_phids as $force_phid) {
$force_list[] = $force_phid;
}
}
$to_list = $this->getMailTo($object);
$cc_list = $this->getMailCC($object);
$full_list = array_merge($force_list, $to_list, $cc_list);
$full_list = array_fuse($full_list);
return array_keys($full_list);
}
/**
* @task feed
*/
protected function getFeedStoryData(
PhabricatorLiskDAO $object,
array $xactions) {
$xactions = msortv($xactions, 'newActionStrengthSortVector');
return array(
'objectPHID' => $object->getPHID(),
'transactionPHIDs' => mpull($xactions, 'getPHID'),
);
}
/**
* @task feed
*/
protected function publishFeedStory(
PhabricatorLiskDAO $object,
array $xactions,
array $mailed_phids) {
// Remove transactions which don't publish feed stories or notifications.
// These never show up anywhere, so we don't need to do anything with them.
foreach ($xactions as $key => $xaction) {
if (!$xaction->shouldHideForFeed()) {
continue;
}
if (!$xaction->shouldHideForNotifications()) {
continue;
}
unset($xactions[$key]);
}
if (!$xactions) {
return;
}
$related_phids = $this->feedRelatedPHIDs;
$subscribed_phids = $this->feedNotifyPHIDs;
// Remove muted users from the subscription list so they don't get
// notifications, either.
$muted_phids = $this->mailMutedPHIDs;
if (!is_array($muted_phids)) {
$muted_phids = array();
}
$subscribed_phids = array_fuse($subscribed_phids);
foreach ($muted_phids as $muted_phid) {
unset($subscribed_phids[$muted_phid]);
}
$subscribed_phids = array_values($subscribed_phids);
$story_type = $this->getFeedStoryType();
$story_data = $this->getFeedStoryData($object, $xactions);
$unexpandable_phids = $this->mailUnexpandablePHIDs;
if (!is_array($unexpandable_phids)) {
$unexpandable_phids = array();
}
id(new PhabricatorFeedStoryPublisher())
->setStoryType($story_type)
->setStoryData($story_data)
->setStoryTime(time())
->setStoryAuthorPHID($this->getActingAsPHID())
->setRelatedPHIDs($related_phids)
->setPrimaryObjectPHID($object->getPHID())
->setSubscribedPHIDs($subscribed_phids)
->setUnexpandablePHIDs($unexpandable_phids)
->setMailRecipientPHIDs($mailed_phids)
->setMailTags($this->getMailTags($object, $xactions))
->publish();
}
/* -( Search Index )------------------------------------------------------- */
/**
* @task search
*/
protected function supportsSearch() {
return false;
}
/* -( Herald Integration )-------------------------------------------------- */
protected function shouldApplyHeraldRules(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function buildHeraldAdapter(
PhabricatorLiskDAO $object,
array $xactions) {
throw new Exception(pht('No herald adapter specified.'));
}
private function setHeraldAdapter(HeraldAdapter $adapter) {
$this->heraldAdapter = $adapter;
return $this;
}
protected function getHeraldAdapter() {
return $this->heraldAdapter;
}
private function setHeraldTranscript(HeraldTranscript $transcript) {
$this->heraldTranscript = $transcript;
return $this;
}
protected function getHeraldTranscript() {
return $this->heraldTranscript;
}
private function applyHeraldRules(
PhabricatorLiskDAO $object,
array $xactions) {
$adapter = $this->buildHeraldAdapter($object, $xactions)
->setContentSource($this->getContentSource())
->setIsNewObject($this->getIsNewObject())
->setActingAsPHID($this->getActingAsPHID())
->setAppliedTransactions($xactions);
if ($this->getApplicationEmail()) {
$adapter->setApplicationEmail($this->getApplicationEmail());
}
// If this editor is operating in silent mode, tell Herald that we aren't
// going to send any mail. This allows it to skip "the first time this
// rule matches, send me an email" rules which would otherwise match even
// though we aren't going to send any mail.
if ($this->getIsSilent()) {
$adapter->setForbiddenAction(
HeraldMailableState::STATECONST,
HeraldCoreStateReasons::REASON_SILENT);
}
$xscript = HeraldEngine::loadAndApplyRules($adapter);
$this->setHeraldAdapter($adapter);
$this->setHeraldTranscript($xscript);
if ($adapter instanceof HarbormasterBuildableAdapterInterface) {
$buildable_phid = $adapter->getHarbormasterBuildablePHID();
HarbormasterBuildable::applyBuildPlans(
$buildable_phid,
$adapter->getHarbormasterContainerPHID(),
$adapter->getQueuedHarbormasterBuildRequests());
// Whether we queued any builds or not, any automatic buildable for this
// object is now done preparing builds and can transition into a
// completed status.
$buildables = id(new HarbormasterBuildableQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withManualBuildables(false)
->withBuildablePHIDs(array($buildable_phid))
->execute();
foreach ($buildables as $buildable) {
// If this buildable has already moved beyond preparation, we don't
// need to nudge it again.
if (!$buildable->isPreparing()) {
continue;
}
$buildable->sendMessage(
$this->getActor(),
HarbormasterMessageType::BUILDABLE_BUILD,
true);
}
}
$this->mustEncrypt = $adapter->getMustEncryptReasons();
// See PHI1134. Propagate "Must Encrypt" state to sub-editors.
foreach ($this->subEditors as $sub_editor) {
$sub_editor->mustEncrypt = $this->mustEncrypt;
}
$apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
$queue_xactions = $adapter->getQueuedTransactions();
return array_merge(
array_values($apply_xactions),
array_values($queue_xactions));
}
protected function didApplyHeraldRules(
PhabricatorLiskDAO $object,
HeraldAdapter $adapter,
HeraldTranscript $transcript) {
return array();
}
/* -( Custom Fields )------------------------------------------------------ */
/**
* @task customfield
*/
private function getCustomFieldForTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$field_key = $xaction->getMetadataValue('customfield:key');
if (!$field_key) {
throw new Exception(
pht(
"Custom field transaction has no '%s'!",
'customfield:key'));
}
$field = PhabricatorCustomField::getObjectField(
$object,
PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
$field_key);
if (!$field) {
throw new Exception(
pht(
"Custom field transaction has invalid '%s'; field '%s' ".
"is disabled or does not exist.",
'customfield:key',
$field_key));
}
if (!$field->shouldAppearInApplicationTransactions()) {
throw new Exception(
pht(
"Custom field transaction '%s' does not implement ".
"integration for %s.",
$field_key,
'ApplicationTransactions'));
}
$field->setViewer($this->getActor());
return $field;
}
/* -( Files )-------------------------------------------------------------- */
/**
* Extract the PHIDs of any files which these transactions attach.
*
* @task files
*/
private function extractFilePHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
$phids = array();
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
} else {
$phids[] = $this->extractFilePHIDsFromCustomTransaction(
$object,
$xaction);
}
}
$phids = array_unique(array_filter(array_mergev($phids)));
return $phids;
}
/**
* @task files
*/
protected function extractFilePHIDsFromCustomTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return array();
}
private function applyInverseEdgeTransactions(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction,
$inverse_type) {
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$add = array_keys(array_diff_key($new, $old));
$rem = array_keys(array_diff_key($old, $new));
$add = array_fuse($add);
$rem = array_fuse($rem);
$all = $add + $rem;
$nodes = id(new PhabricatorObjectQuery())
->setViewer($this->requireActor())
->withPHIDs($all)
->execute();
$object_phid = $object->getPHID();
foreach ($nodes as $node) {
if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
continue;
}
if ($node instanceof PhabricatorUser) {
// TODO: At least for now, don't record inverse edge transactions
// for users (for example, "alincoln joined project X"): Feed fills
// this role instead.
continue;
}
$node_phid = $node->getPHID();
$editor = $node->getApplicationTransactionEditor();
$template = $node->getApplicationTransactionTemplate();
// See T13082. We have to build these transactions with synthetic values
// because we've already applied the actual edit to the edge database
// table. If we try to apply this transaction naturally, it will no-op
// itself because it doesn't have any effect.
$edge_query = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($node_phid))
->withEdgeTypes(array($inverse_type));
$edge_query->execute();
$edge_phids = $edge_query->getDestinationPHIDs();
$edge_phids = array_fuse($edge_phids);
$new_phids = $edge_phids;
$old_phids = $edge_phids;
if (isset($add[$node_phid])) {
unset($old_phids[$object_phid]);
} else {
$old_phids[$object_phid] = $object_phid;
}
$template
->setTransactionType($xaction->getTransactionType())
->setMetadataValue('edge:type', $inverse_type)
->setOldValue($old_phids)
->setNewValue($new_phids);
$editor = $this->newSubEditor($editor)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->setIsInverseEdgeEditor(true);
$editor->applyTransactions($node, array($template));
}
}
/* -( Workers )------------------------------------------------------------ */
/**
* Load any object state which is required to publish transactions.
*
* This hook is invoked in the main process before we compute data related
* to publishing transactions (like email "To" and "CC" lists), and again in
* the worker before publishing occurs.
*
* @return object Publishable object.
* @task workers
*/
protected function willPublish(PhabricatorLiskDAO $object, array $xactions) {
return $object;
}
/**
* Convert the editor state to a serializable dictionary which can be passed
* to a worker.
*
* This data will be loaded with @{method:loadWorkerState} in the worker.
*
* @return dict<string, wild> Serializable editor state.
* @task workers
*/
private function getWorkerState() {
$state = array();
foreach ($this->getAutomaticStateProperties() as $property) {
$state[$property] = $this->$property;
}
$custom_state = $this->getCustomWorkerState();
$custom_encoding = $this->getCustomWorkerStateEncoding();
$state += array(
'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
'custom.encoding' => $custom_encoding,
);
return $state;
}
/**
* Hook; return custom properties which need to be passed to workers.
*
* @return dict<string, wild> Custom properties.
* @task workers
*/
protected function getCustomWorkerState() {
return array();
}
/**
* Hook; return storage encoding for custom properties which need to be
* passed to workers.
*
* This primarily allows binary data to be passed to workers and survive
* JSON encoding.
*
* @return dict<string, string> Property encodings.
* @task workers
*/
protected function getCustomWorkerStateEncoding() {
return array();
}
/**
* Load editor state using a dictionary emitted by @{method:getWorkerState}.
*
* This method is used to load state when running worker operations.
*
* @param dict<string, wild> Editor state, from @{method:getWorkerState}.
* @return this
* @task workers
*/
final public function loadWorkerState(array $state) {
foreach ($this->getAutomaticStateProperties() as $property) {
$this->$property = idx($state, $property);
}
$exclude = idx($state, 'excludeMailRecipientPHIDs', array());
$this->setExcludeMailRecipientPHIDs($exclude);
$custom_state = idx($state, 'custom', array());
$custom_encodings = idx($state, 'custom.encoding', array());
$custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
$this->loadCustomWorkerState($custom);
return $this;
}
/**
* Hook; set custom properties on the editor from data emitted by
* @{method:getCustomWorkerState}.
*
* @param dict<string, wild> Custom state,
* from @{method:getCustomWorkerState}.
* @return this
* @task workers
*/
protected function loadCustomWorkerState(array $state) {
return $this;
}
/**
* Get a list of object properties which should be automatically sent to
* workers in the state data.
*
* These properties will be automatically stored and loaded by the editor in
* the worker.
*
* @return list<string> List of properties.
* @task workers
*/
private function getAutomaticStateProperties() {
return array(
'parentMessageID',
'isNewObject',
'heraldEmailPHIDs',
'heraldForcedEmailPHIDs',
'heraldHeader',
'mailToPHIDs',
'mailCCPHIDs',
'feedNotifyPHIDs',
'feedRelatedPHIDs',
'feedShouldPublish',
'mailShouldSend',
'mustEncrypt',
'mailStamps',
'mailUnexpandablePHIDs',
'mailMutedPHIDs',
'webhookMap',
'silent',
'sendHistory',
);
}
/**
* Apply encodings prior to storage.
*
* See @{method:getCustomWorkerStateEncoding}.
*
* @param map<string, wild> Map of values to encode.
* @param map<string, string> Map of encodings to apply.
* @return map<string, wild> Map of encoded values.
* @task workers
*/
private function encodeStateForStorage(
array $state,
array $encodings) {
foreach ($state as $key => $value) {
$encoding = idx($encodings, $key);
switch ($encoding) {
case self::STORAGE_ENCODING_BINARY:
// The mechanics of this encoding (serialize + base64) are a little
// awkward, but it allows us encode arrays and still be JSON-safe
// with binary data.
$value = @serialize($value);
if ($value === false) {
throw new Exception(
pht(
'Failed to serialize() value for key "%s".',
$key));
}
$value = base64_encode($value);
if ($value === false) {
throw new Exception(
pht(
'Failed to base64 encode value for key "%s".',
$key));
}
break;
}
$state[$key] = $value;
}
return $state;
}
/**
* Undo storage encoding applied when storing state.
*
* See @{method:getCustomWorkerStateEncoding}.
*
* @param map<string, wild> Map of encoded values.
* @param map<string, string> Map of encodings.
* @return map<string, wild> Map of decoded values.
* @task workers
*/
private function decodeStateFromStorage(
array $state,
array $encodings) {
foreach ($state as $key => $value) {
$encoding = idx($encodings, $key);
switch ($encoding) {
case self::STORAGE_ENCODING_BINARY:
$value = base64_decode($value);
if ($value === false) {
throw new Exception(
pht(
'Failed to base64_decode() value for key "%s".',
$key));
}
$value = unserialize($value);
break;
}
$state[$key] = $value;
}
return $state;
}
/**
* Remove conflicts from a list of projects.
*
* Objects aren't allowed to be tagged with multiple milestones in the same
* group, nor projects such that one tag is the ancestor of any other tag.
* If the list of PHIDs include mutually exclusive projects, remove the
* conflicting projects.
*
* @param list<phid> List of project PHIDs.
* @return list<phid> List with conflicts removed.
*/
private function applyProjectConflictRules(array $phids) {
if (!$phids) {
return array();
}
// Overall, the last project in the list wins in cases of conflict (so when
// you add something, the thing you just added sticks and removes older
// values).
// Beyond that, there are two basic cases:
// Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
// If multiple projects are milestones of the same parent, we only keep the
// last one.
// Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
// in the list, we remove "A" and keep "A > B". If "A" comes later, we
// remove "A > B" and keep "A".
// Note that it's OK to be in "A > B" and "A > C". There's only a conflict
// if one project is an ancestor of another. It's OK to have something
// tagged with multiple projects which share a common ancestor, so long as
// they are not mutual ancestors.
$viewer = PhabricatorUser::getOmnipotentUser();
$projects = id(new PhabricatorProjectQuery())
->setViewer($viewer)
->withPHIDs(array_keys($phids))
->execute();
$projects = mpull($projects, null, 'getPHID');
// We're going to build a map from each project with milestones to the last
// milestone in the list. This last milestone is the milestone we'll keep.
$milestone_map = array();
// We're going to build a set of the projects which have no descendants
// later in the list. This allows us to apply both ancestor rules.
$ancestor_map = array();
foreach ($phids as $phid => $ignored) {
$project = idx($projects, $phid);
if (!$project) {
continue;
}
// This is the last milestone we've seen, so set it as the selection for
// the project's parent. This might be setting a new value or overwriting
// an earlier value.
if ($project->isMilestone()) {
$parent_phid = $project->getParentProjectPHID();
$milestone_map[$parent_phid] = $phid;
}
// Since this is the last item in the list we've examined so far, add it
// to the set of projects with no later descendants.
$ancestor_map[$phid] = $phid;
// Remove any ancestors from the set, since this is a later descendant.
foreach ($project->getAncestorProjects() as $ancestor) {
$ancestor_phid = $ancestor->getPHID();
unset($ancestor_map[$ancestor_phid]);
}
}
// Now that we've built the maps, we can throw away all the projects which
// have conflicts.
foreach ($phids as $phid => $ignored) {
$project = idx($projects, $phid);
if (!$project) {
// If a PHID is invalid, we just leave it as-is. We could clean it up,
// but leaving it untouched is less likely to cause collateral damage.
continue;
}
// If this was a milestone, check if it was the last milestone from its
// group in the list. If not, remove it from the list.
if ($project->isMilestone()) {
$parent_phid = $project->getParentProjectPHID();
if ($milestone_map[$parent_phid] !== $phid) {
unset($phids[$phid]);
continue;
}
}
// If a later project in the list is a subproject of this one, it will
// have removed ancestors from the map. If this project does not point
// at itself in the ancestor map, it should be discarded in favor of a
// subproject that comes later.
if (idx($ancestor_map, $phid) !== $phid) {
unset($phids[$phid]);
continue;
}
// If a later project in the list is an ancestor of this one, it will
// have added itself to the map. If any ancestor of this project points
// at itself in the map, this project should be discarded in favor of
// that later ancestor.
foreach ($project->getAncestorProjects() as $ancestor) {
$ancestor_phid = $ancestor->getPHID();
if (isset($ancestor_map[$ancestor_phid])) {
unset($phids[$phid]);
continue 2;
}
}
}
return $phids;
}
/**
* When the view policy for an object is changed, scramble the secret keys
* for attached files to invalidate existing URIs.
*/
private function scrambleFileSecrets($object) {
// If this is a newly created object, we don't need to scramble anything
// since it couldn't have been previously published.
if ($this->getIsNewObject()) {
return;
}
// If the object is a file itself, scramble it.
if ($object instanceof PhabricatorFile) {
if ($this->shouldScramblePolicy($object->getViewPolicy())) {
$object->scrambleSecret();
$object->save();
}
}
$omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
$files = id(new PhabricatorFileQuery())
->setViewer($omnipotent_viewer)
->withAttachedObjectPHIDs(array($object->getPHID()))
->execute();
foreach ($files as $file) {
$view_policy = $file->getViewPolicy();
if ($this->shouldScramblePolicy($view_policy)) {
$file->scrambleSecret();
$file->save();
}
}
}
/**
* Check if a policy is strong enough to justify scrambling. Objects which
* are set to very open policies don't need to scramble their files, and
* files with very open policies don't need to be scrambled when associated
* objects change.
*/
private function shouldScramblePolicy($policy) {
switch ($policy) {
case PhabricatorPolicies::POLICY_PUBLIC:
case PhabricatorPolicies::POLICY_USER:
return false;
}
return true;
}
private function updateWorkboardColumns($object, $const, $old, $new) {
// If an object is removed from a project, remove it from any proxy
// columns for that project. This allows a task which is moved up from a
// milestone to the parent to move back into the "Backlog" column on the
// parent workboard.
if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) {
return;
}
// TODO: This should likely be some future WorkboardInterface.
$appears_on_workboards = ($object instanceof ManiphestTask);
if (!$appears_on_workboards) {
return;
}
$removed_phids = array_keys(array_diff_key($old, $new));
if (!$removed_phids) {
return;
}
// Find any proxy columns for the removed projects.
$proxy_columns = id(new PhabricatorProjectColumnQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withProxyPHIDs($removed_phids)
->execute();
if (!$proxy_columns) {
return array();
}
$proxy_phids = mpull($proxy_columns, 'getPHID');
$position_table = new PhabricatorProjectColumnPosition();
$conn_w = $position_table->establishConnection('w');
queryfx(
$conn_w,
'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
$position_table->getTableName(),
$object->getPHID(),
$proxy_phids);
}
private function getModularTransactionTypes(
PhabricatorLiskDAO $object) {
if ($this->modularTypes === null) {
$template = $object->getApplicationTransactionTemplate();
if ($template instanceof PhabricatorModularTransaction) {
$xtypes = $template->newModularTransactionTypes();
foreach ($xtypes as $key => $xtype) {
$xtype = clone $xtype;
$xtype->setEditor($this);
$xtypes[$key] = $xtype;
}
} else {
$xtypes = array();
}
$this->modularTypes = $xtypes;
}
return $this->modularTypes;
}
private function getModularTransactionType($object, $type) {
$types = $this->getModularTransactionTypes($object);
return idx($types, $type);
}
public function getCreateObjectTitle($author, $object) {
return pht('%s created this object.', $author);
}
public function getCreateObjectTitleForFeed($author, $object) {
return pht('%s created an object: %s.', $author, $object);
}
/* -( Queue )-------------------------------------------------------------- */
protected function queueTransaction(
PhabricatorApplicationTransaction $xaction) {
$this->transactionQueue[] = $xaction;
return $this;
}
private function flushTransactionQueue($object) {
if (!$this->transactionQueue) {
return;
}
$xactions = $this->transactionQueue;
$this->transactionQueue = array();
$editor = $this->newEditorCopy();
return $editor->applyTransactions($object, $xactions);
}
final protected function newSubEditor(
PhabricatorApplicationTransactionEditor $template = null) {
$editor = $this->newEditorCopy($template);
$editor->parentEditor = $this;
$this->subEditors[] = $editor;
return $editor;
}
private function newEditorCopy(
PhabricatorApplicationTransactionEditor $template = null) {
if ($template === null) {
$template = newv(get_class($this), array());
}
$editor = id(clone $template)
->setActor($this->getActor())
->setContentSource($this->getContentSource())
->setContinueOnNoEffect($this->getContinueOnNoEffect())
->setContinueOnMissingFields($this->getContinueOnMissingFields())
->setParentMessageID($this->getParentMessageID())
->setIsSilent($this->getIsSilent());
if ($this->actingAsPHID !== null) {
$editor->setActingAsPHID($this->actingAsPHID);
}
$editor->mustEncrypt = $this->mustEncrypt;
$editor->transactionGroupID = $this->getTransactionGroupID();
return $editor;
}
/* -( Stamps )------------------------------------------------------------- */
public function newMailStampTemplates($object) {
$actor = $this->getActor();
$templates = array();
$extensions = $this->newMailExtensions($object);
foreach ($extensions as $extension) {
$stamps = $extension->newMailStampTemplates($object);
foreach ($stamps as $stamp) {
$key = $stamp->getKey();
if (isset($templates[$key])) {
throw new Exception(
pht(
'Mail extension ("%s") defines a stamp template with the '.
'same key ("%s") as another template. Each stamp template '.
'must have a unique key.',
get_class($extension),
$key));
}
$stamp->setViewer($actor);
$templates[$key] = $stamp;
}
}
return $templates;
}
final public function getMailStamp($key) {
if (!isset($this->stampTemplates)) {
throw new PhutilInvalidStateException('newMailStampTemplates');
}
if (!isset($this->stampTemplates[$key])) {
throw new Exception(
pht(
'Editor ("%s") has no mail stamp template with provided key ("%s").',
get_class($this),
$key));
}
return $this->stampTemplates[$key];
}
private function newMailStamps($object, array $xactions) {
$actor = $this->getActor();
$this->stampTemplates = $this->newMailStampTemplates($object);
$extensions = $this->newMailExtensions($object);
$stamps = array();
foreach ($extensions as $extension) {
$extension->newMailStamps($object, $xactions);
}
return $this->stampTemplates;
}
private function newMailExtensions($object) {
$actor = $this->getActor();
$all_extensions = PhabricatorMailEngineExtension::getAllExtensions();
$extensions = array();
foreach ($all_extensions as $key => $template) {
$extension = id(clone $template)
->setViewer($actor)
->setEditor($this);
if ($extension->supportsObject($object)) {
$extensions[$key] = $extension;
}
}
return $extensions;
}
protected function newAuxiliaryMail($object, array $xactions) {
return array();
}
private function generateMailStamps($object, $data) {
if (!$data || !is_array($data)) {
return null;
}
$templates = $this->newMailStampTemplates($object);
foreach ($data as $spec) {
if (!is_array($spec)) {
continue;
}
$key = idx($spec, 'key');
if (!isset($templates[$key])) {
continue;
}
$type = idx($spec, 'type');
if ($templates[$key]->getStampType() !== $type) {
continue;
}
$value = idx($spec, 'value');
$templates[$key]->setValueFromDictionary($value);
}
$results = array();
foreach ($templates as $template) {
$value = $template->getValueForRendering();
$rendered = $template->renderStamps($value);
if ($rendered === null) {
continue;
}
$rendered = (array)$rendered;
foreach ($rendered as $stamp) {
$results[] = $stamp;
}
}
natcasesort($results);
return $results;
}
public function getRemovedRecipientPHIDs() {
return $this->mailRemovedPHIDs;
}
private function buildOldRecipientLists($object, $xactions) {
// See T4776. Before we start making any changes, build a list of the old
// recipients. If a change removes a user from the recipient list for an
// object we still want to notify the user about that change. This allows
// them to respond if they didn't want to be removed.
if (!$this->shouldSendMail($object, $xactions)) {
return;
}
$this->oldTo = $this->getMailTo($object);
$this->oldCC = $this->getMailCC($object);
return $this;
}
private function applyOldRecipientLists() {
$actor_phid = $this->getActingAsPHID();
// If you took yourself off the recipient list (for example, by
// unsubscribing or resigning) assume that you know what you did and
// don't need to be notified.
// If you just moved from "To" to "Cc" (or vice versa), you're still a
// recipient so we don't need to add you back in.
$map = array_fuse($this->mailToPHIDs) + array_fuse($this->mailCCPHIDs);
foreach ($this->oldTo as $phid) {
if ($phid === $actor_phid) {
continue;
}
if (isset($map[$phid])) {
continue;
}
$this->mailToPHIDs[] = $phid;
$this->mailRemovedPHIDs[] = $phid;
}
foreach ($this->oldCC as $phid) {
if ($phid === $actor_phid) {
continue;
}
if (isset($map[$phid])) {
continue;
}
$this->mailCCPHIDs[] = $phid;
$this->mailRemovedPHIDs[] = $phid;
}
return $this;
}
private function queueWebhooks($object, array $xactions) {
$hook_viewer = PhabricatorUser::getOmnipotentUser();
$webhook_map = $this->webhookMap;
if (!is_array($webhook_map)) {
$webhook_map = array();
}
// Add any "Firehose" hooks to the list of hooks we're going to call.
$firehose_hooks = id(new HeraldWebhookQuery())
->setViewer($hook_viewer)
->withStatuses(
array(
HeraldWebhook::HOOKSTATUS_FIREHOSE,
))
->execute();
foreach ($firehose_hooks as $firehose_hook) {
// This is "the hook itself is the reason this hook is being called",
// since we're including it because it's configured as a firehose
// hook.
$hook_phid = $firehose_hook->getPHID();
$webhook_map[$hook_phid][] = $hook_phid;
}
if (!$webhook_map) {
return;
}
// NOTE: We're going to queue calls to disabled webhooks, they'll just
// immediately fail in the worker queue. This makes the behavior more
// visible.
$call_hooks = id(new HeraldWebhookQuery())
->setViewer($hook_viewer)
->withPHIDs(array_keys($webhook_map))
->execute();
foreach ($call_hooks as $call_hook) {
$trigger_phids = idx($webhook_map, $call_hook->getPHID());
$request = HeraldWebhookRequest::initializeNewWebhookRequest($call_hook)
->setObjectPHID($object->getPHID())
->setTransactionPHIDs(mpull($xactions, 'getPHID'))
->setTriggerPHIDs($trigger_phids)
->setRetryMode(HeraldWebhookRequest::RETRY_FOREVER)
->setIsSilentAction((bool)$this->getIsSilent())
->setIsSecureAction((bool)$this->getMustEncrypt())
->save();
$request->queueCall();
}
}
private function hasWarnings($object, $xaction) {
// TODO: For the moment, this is a very un-modular hack to support
// a small number of warnings related to draft revisions. See PHI433.
if (!($object instanceof DifferentialRevision)) {
return false;
}
$type = $xaction->getTransactionType();
// TODO: This doesn't warn for inlines in Audit, even though they have
// the same overall workflow.
if ($type === DifferentialTransaction::TYPE_INLINE) {
return (bool)$xaction->getComment()->getAttribute('editing', false);
}
if (!$object->isDraft()) {
return false;
}
if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
return false;
}
// We're only going to raise a warning if the transaction adds subscribers
// other than the acting user. (This implementation is clumsy because the
// code runs before a lot of normalization occurs.)
$old = $this->getTransactionOldValue($object, $xaction);
$new = $this->getPHIDTransactionNewValue($xaction, $old);
$old = array_fuse($old);
$new = array_fuse($new);
$add = array_diff_key($new, $old);
unset($add[$this->getActingAsPHID()]);
if (!$add) {
return false;
}
return true;
}
private function buildHistoryMail(PhabricatorLiskDAO $object) {
$viewer = $this->requireActor();
$recipient_phid = $this->getActingAsPHID();
// Load every transaction so we can build a mail message with a complete
// history for the object.
$query = PhabricatorApplicationTransactionQuery::newQueryForObject($object);
$xactions = $query
->setViewer($viewer)
->withObjectPHIDs(array($object->getPHID()))
->execute();
$xactions = array_reverse($xactions);
$mail_messages = $this->buildMailWithRecipients(
$object,
$xactions,
array($recipient_phid),
array(),
array());
$mail = head($mail_messages);
// Since the user explicitly requested "!history", force delivery of this
// message regardless of their other mail settings.
$mail->setForceDelivery(true);
return $mail;
}
public function newAutomaticInlineTransactions(
PhabricatorLiskDAO $object,
$transaction_type,
PhabricatorCursorPagedPolicyAwareQuery $query_template) {
$actor = $this->getActor();
$inlines = id(clone $query_template)
->setViewer($actor)
->withObjectPHIDs(array($object->getPHID()))
->withPublishableComments(true)
->needAppliedDrafts(true)
->needReplyToComments(true)
->execute();
$inlines = msort($inlines, 'getID');
$xactions = array();
foreach ($inlines as $key => $inline) {
$xactions[] = $object->getApplicationTransactionTemplate()
->setTransactionType($transaction_type)
->attachComment($inline);
}
$state_xaction = $this->newInlineStateTransaction(
$object,
$query_template);
if ($state_xaction) {
$xactions[] = $state_xaction;
}
return $xactions;
}
protected function newInlineStateTransaction(
PhabricatorLiskDAO $object,
PhabricatorCursorPagedPolicyAwareQuery $query_template) {
$actor_phid = $this->getActingAsPHID();
$author_phid = $object->getAuthorPHID();
$actor_is_author = ($actor_phid == $author_phid);
$state_map = PhabricatorTransactions::getInlineStateMap();
$inline_query = id(clone $query_template)
->setViewer($this->getActor())
->withObjectPHIDs(array($object->getPHID()))
->withFixedStates(array_keys($state_map))
->withPublishableComments(true);
if ($actor_is_author) {
$inline_query->withPublishedComments(true);
}
$inlines = $inline_query->execute();
if (!$inlines) {
return null;
}
$old_value = mpull($inlines, 'getFixedState', 'getPHID');
$new_value = array();
foreach ($old_value as $key => $state) {
$new_value[$key] = $state_map[$state];
}
// See PHI995. Copy some information about the inlines into the transaction
// so we can tailor rendering behavior. In particular, we don't want to
// render transactions about users marking their own inlines as "Done".
$inline_details = array();
foreach ($inlines as $inline) {
$inline_details[$inline->getPHID()] = array(
'authorPHID' => $inline->getAuthorPHID(),
);
}
return $object->getApplicationTransactionTemplate()
->setTransactionType(PhabricatorTransactions::TYPE_INLINESTATE)
->setIgnoreOnNoEffect(true)
->setMetadataValue('inline.details', $inline_details)
->setOldValue($old_value)
->setNewValue($new_value);
}
private function requireMFA(PhabricatorLiskDAO $object, array $xactions) {
$actor = $this->getActor();
// Let omnipotent editors skip MFA. This is mostly aimed at scripts.
if ($actor->isOmnipotent()) {
return;
}
$editor_class = get_class($this);
$object_phid = $object->getPHID();
if ($object_phid) {
$workflow_key = sprintf(
'editor(%s).phid(%s)',
$editor_class,
$object_phid);
} else {
$workflow_key = sprintf(
'editor(%s).new()',
$editor_class);
}
$request = $this->getRequest();
if ($request === null) {
$source_type = $this->getContentSource()->getSourceTypeConstant();
$conduit_type = PhabricatorConduitContentSource::SOURCECONST;
$is_conduit = ($source_type === $conduit_type);
if ($is_conduit) {
throw new Exception(
pht(
'This transaction group requires MFA to apply, but you can not '.
'provide an MFA response via Conduit. Edit this object via the '.
'web UI.'));
} else {
throw new Exception(
pht(
'This transaction group requires MFA to apply, but the Editor was '.
'not configured with a Request. This workflow can not perform an '.
'MFA check.'));
}
}
$cancel_uri = $this->getCancelURI();
if ($cancel_uri === null) {
throw new Exception(
pht(
'This transaction group requires MFA to apply, but the Editor was '.
'not configured with a Cancel URI. This workflow can not perform '.
'an MFA check.'));
}
$token = id(new PhabricatorAuthSessionEngine())
->setWorkflowKey($workflow_key)
->requireHighSecurityToken($actor, $request, $cancel_uri);
if (!$token->getIsUnchallengedToken()) {
foreach ($xactions as $xaction) {
$xaction->setIsMFATransaction(true);
}
}
}
private function newMFATransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$has_engine = ($object instanceof PhabricatorEditEngineMFAInterface);
if ($has_engine) {
$engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
->setViewer($this->getActor());
$require_mfa = $engine->shouldRequireMFA();
$try_mfa = $engine->shouldTryMFA();
} else {
$require_mfa = false;
$try_mfa = false;
}
// If the user is mentioning an MFA object on another object or creating
// a relationship like "parent" or "child" to this object, we always
// allow the edit to move forward without requiring MFA.
if ($this->getIsInverseEdgeEditor()) {
return $xactions;
}
if (!$require_mfa) {
// If the object hasn't already opted into MFA, see if any of the
// transactions want it.
if (!$try_mfa) {
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
if ($xtype->shouldTryMFA($object, $xaction)) {
$try_mfa = true;
break;
}
}
}
}
if ($try_mfa) {
$this->setShouldRequireMFA(true);
}
return $xactions;
}
$type_mfa = PhabricatorTransactions::TYPE_MFA;
$has_mfa = false;
foreach ($xactions as $xaction) {
if ($xaction->getTransactionType() === $type_mfa) {
$has_mfa = true;
break;
}
}
if ($has_mfa) {
return $xactions;
}
$template = $object->getApplicationTransactionTemplate();
$mfa_xaction = id(clone $template)
->setTransactionType($type_mfa)
->setNewValue(true);
array_unshift($xactions, $mfa_xaction);
return $xactions;
}
private function getTitleForTextMail(
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$object = $this->object;
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
$comment = $xtype->getTitleForTextMail();
if ($comment !== false) {
return $comment;
}
}
return $xaction->getTitleForTextMail();
}
private function getTitleForHTMLMail(
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$object = $this->object;
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
$comment = $xtype->getTitleForHTMLMail();
if ($comment !== false) {
return $comment;
}
}
return $xaction->getTitleForHTMLMail();
}
private function getBodyForTextMail(
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$object = $this->object;
$xtype = $this->getModularTransactionType($object, $type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
$comment = $xtype->getBodyForTextMail();
if ($comment !== false) {
return $comment;
}
}
return $xaction->getBodyForMail();
}
private function isLockOverrideTransaction(
PhabricatorApplicationTransaction $xaction) {
// See PHI1209. When an object is locked, certain types of transactions
// can still be applied without requiring a policy check, like subscribing
// or unsubscribing. We don't want these transactions to show the "Lock
// Override" icon in the transaction timeline.
// We could test if a transaction did no direct policy checks, but it may
// have done additional policy checks during validation, so this is not a
// reliable test (and could cause false negatives, where edits which did
// override a lock are not marked properly).
// For now, do this in a narrow way and just check against a hard-coded
// list of non-override transaction situations. Some day, this should
// likely be modularized.
// Inverse edge edits don't interact with locks.
if ($this->getIsInverseEdgeEditor()) {
return false;
}
// For now, all edits other than subscribes always override locks.
$type = $xaction->getTransactionType();
if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) {
return true;
}
// Subscribes override locks if they affect any users other than the
// acting user.
$acting_phid = $this->getActingAsPHID();
$old = array_fuse($xaction->getOldValue());
$new = array_fuse($xaction->getNewValue());
$add = array_diff_key($new, $old);
$rem = array_diff_key($old, $new);
$all = $add + $rem;
foreach ($all as $phid) {
if ($phid !== $acting_phid) {
return true;
}
}
return false;
}
/* -( Extensions )--------------------------------------------------------- */
private function validateTransactionsWithExtensions(
PhabricatorLiskDAO $object,
array $xactions) {
$errors = array();
$extensions = $this->getEditorExtensions();
foreach ($extensions as $extension) {
$extension_errors = $extension
->setObject($object)
->validateTransactions($object, $xactions);
assert_instances_of(
$extension_errors,
'PhabricatorApplicationTransactionValidationError');
$errors[] = $extension_errors;
}
return array_mergev($errors);
}
private function getEditorExtensions() {
if ($this->extensions === null) {
$this->extensions = $this->newEditorExtensions();
}
return $this->extensions;
}
private function newEditorExtensions() {
$extensions = PhabricatorEditorExtension::getAllExtensions();
$actor = $this->getActor();
$object = $this->object;
foreach ($extensions as $key => $extension) {
$extension = id(clone $extension)
->setViewer($actor)
->setEditor($this)
->setObject($object);
if (!$extension->supportsObject($this, $object)) {
unset($extensions[$key]);
continue;
}
$extensions[$key] = $extension;
}
return $extensions;
}
}
diff --git a/src/docs/contributor/database.diviner b/src/docs/contributor/database.diviner
index fc39c1ff1c..dc553678a8 100644
--- a/src/docs/contributor/database.diviner
+++ b/src/docs/contributor/database.diviner
@@ -1,213 +1,213 @@
@title Database Schema
@group developer
This document describes key components of the database schema and should answer
questions like how to store new types of data.
Database System
===============
Phorge uses MySQL or another MySQL-compatible database (like MariaDB
or Amazon RDS).
Phorge uses the InnoDB table engine. The only exception is the
`search_documentfield` table which uses MyISAM because MySQL doesn't support
fulltext search in InnoDB (recent versions do, but we haven't added support
yet).
We are unlikely to ever support other incompatible databases like PostgreSQL or
SQLite.
PHP Drivers
===========
Phorge supports [[ http://www.php.net/book.mysql | MySQL ]] and
[[ http://www.php.net/book.mysqli | MySQLi ]] PHP extensions.
Databases
=========
Each Phorge application has its own database. The names are prefixed by
`phorge_` (this is configurable).
Phorge uses a separate database for each application. To understand why,
see @{article:Why does Phorge need so many databases?}.
Connections
===========
Phorge specifies if it will use any opened connection just for reading or
also for writing. This allows opening write connections to a primary and read
connections to a replica in primary/replica setups (which are not actually
supported yet).
Tables
======
Most table names are prefixed by their application names. For example,
Differential revisions are stored in database `phorge_differential` and
table `differential_revision`. This generally makes queries easier to recognize
and understand.
The exception is a few tables which share the same schema over different
databases such as `edge`.
We use lower-case table names with words separated by underscores.
Column Names
============
Phorge uses `camelCase` names for columns. The main advantage is that they
directly map to properties in PHP classes.
Don't use MySQL reserved words (such as `order`) for column names.
Data Types
==========
Phorge defines a set of abstract data types (like `uint32`, `epoch`, and
`phid`) which map to MySQL column types. The mapping depends on the MySQL
version.
Phorge uses `utf8mb4` character sets where available (MySQL 5.5 or newer),
and `binary` character sets in most other cases. The primary motivation is to
allow 4-byte unicode characters to be stored (the `utf8` character set, which
is more widely available, does not support them). On newer MySQL, we use
`utf8mb4` to take advantage of improved collation rules.
Phorge stores dates with an `epoch` abstract data type, which maps to
`int unsigned`. Although this makes dates less readable when browsing the
database, it makes date and time manipulation more consistent and
straightforward in the application.
We don't use the `enum` data type because each change to the list of possible
values requires altering the table (which is slow with big tables). We use
numbers (or short strings in some cases) mapped to PHP constants instead.
JSON and Other Serialized Data
==============================
Some data don't require structured access -- we don't need to filter or order by
them. We store these data as text fields in JSON format. This approach has
several advantages:
- If we decide to add another unstructured field then we don't need to alter
the table (which is slow for big tables in MySQL).
- Table structure is not cluttered by fields which could be unused most of the
time.
An example of such usage can be found in column
`differential_diffproperty.data`.
Primary Keys
============
Most tables have an auto-increment column named `id`. Adding an ID column is
appropriate for most tables (even tables that have another natural unique key),
as it improves consistency and makes it easier to perform generic operations
on objects.
For example, @{class:LiskMigrationIterator} allows you to very easily apply a
migration to a table using a constant amount of memory provided the table has
an `id` column.
Indexes
======
Create all indexes necessary for fast query execution in most cases. Don't
create indexes which are not used. You can analyze queries @{article:Using
DarkConsole}.
Older MySQL versions are not able to use indexes for tuple search:
`(a, b) IN ((%s, %d), (%s, %d))`. Use `AND` and `OR` instead:
`((a = %s AND b = %d) OR (a = %s AND b = %d))`.
Foreign Keys
============
We don't use foreign keys because they're complicated and we haven't experienced
significant issues with data inconsistency that foreign keys could help prevent.
Empirically, we have witnessed first hand as `ON DELETE CASCADE` relationships
accidentally destroy huge amounts of data. We may pursue foreign keys
eventually, but there isn't a strong case for them at the present time.
PHIDs
=====
-Each globally referencable object in Phorge has an associated PHID
+Each globally referenceable object in Phorge has an associated PHID
("Phorge ID") which serves as a global identifier, similar to a GUID.
We use PHIDs for referencing data in different databases.
We use both auto-incrementing IDs and global PHIDs because each is useful in
different contexts. Auto-incrementing IDs are meaningfully ordered and allow
us to construct short, human-readable object names (like `D2258`) and URIs.
Global PHIDs allow us to represent relationships between different types of
objects in a homogeneous way.
For example, infrastructure like "subscribers" can be implemented easily with
PHID relationships: different types of objects (users, projects, mailing lists)
are permitted to subscribe to different types of objects (revisions, tasks,
etc). Without PHIDs, we would need to add a "type" column to avoid ID collision;
using PHIDs makes implementing features like this simpler.
For more information, see @{article:Handles Technical Documentation}
Transactions
============
Transactional code should be written using transactions. Example of such code is
inserting multiple records where one doesn't make sense without the other, or
selecting data later used for update. See chapter in @{class:LiskDAO}.
Advanced Features
=================
We don't use MySQL advanced features such as triggers, stored procedures or
events because we like expressing the application logic in PHP more than in SQL.
Some of these features (especially triggers) can also cause a great deal of
confusion, and are generally more difficult to debug, profile, version control,
update, and understand than application code.
Schema Denormalization
======================
Phorge uses schema denormalization sparingly. Avoid denormalization unless
there is a compelling reason (usually, performance) to denormalize.
Schema Changes and Migrations
=============================
To create a new schema change or migration:
**Create a database patch**. Database patches go in
`resources/sql/autopatches/`. To change a schema, use a `.sql` file and write
in SQL. To perform a migration, use a `.php` file and write in PHP. Name your
file `YYYYMMDD.patchname.ext`. For example, `20141225.christmas.sql`.
**Keep patches small**. Most schema change statements are not transactional. If
a patch contains several SQL statements and fails partway through, it normally
can not be rolled back. When a user tries to apply the patch again later, the
first statement (which, for example, adds a column) may fail (because the column
already exists). This can be avoided by keeping patches small (generally, one
statement per patch).
**Use namespace and character set variables**. When defining a `.sql` patch,
you should use these variables instead of hard-coding namespaces or character
set names:
| Variable | Meaning | Notes |
|---|---|---|
| `{$NAMESPACE}` | Storage Namespace | Defaults to `phabricator` |
| `{$CHARSET}` | Default Charset | Mostly used to specify table charset |
| `{$COLLATE_TEXT}` | Text Collation | For most text (case-sensitive) |
| `{$COLLATE_SORT}` | Sort Collation | For sortable text (case-insensitive) |
| `{$CHARSET_FULLTEXT}` | Fulltext Charset | Specify explicitly for fulltext |
| `{$COLLATE_FULLTEXT}` | Fulltext Collate | Specify explicitly for fulltext |
**Test your patch**. Run `bin/storage upgrade` to test your patch.
See Also
========
- @{class:LiskDAO}
diff --git a/src/docs/contributor/general_coding_standards.diviner b/src/docs/contributor/general_coding_standards.diviner
index 5127aebbfc..532b922f9f 100644
--- a/src/docs/contributor/general_coding_standards.diviner
+++ b/src/docs/contributor/general_coding_standards.diviner
@@ -1,148 +1,148 @@
@title General Coding Standards
@group standards
This document is a general coding standard for contributing to Phorge,
Arcanist, and Diviner.
= Overview =
This document contains practices and guidelines which apply across languages.
Contributors should follow these guidelines. These guidelines are not
hard-and-fast but should be followed unless there is a compelling reason to
deviate from them.
= Code Complexity =
- Prefer to write simple code which is easy to understand. The simplest code
is not necessarily the smallest, and some changes which make code larger
(such as decomposing complex expressions and choosing more descriptive
names) may also make it simpler. Be willing to make size tradeoffs in favor
of simplicity.
- Prefer simple methods and functions which take a small number of parameters.
Avoid methods and functions which are long and complex, or take an
innumerable host of parameters. When possible, decompose monolithic, complex
methods into several focused, simpler ones.
- Avoid putting many ideas on a single line of code.
For example, avoid this kind of code:
COUNTEREXAMPLE
$category_map = array_combine(
$dates,
array_map(create_function('$z', 'return date("F Y", $z);'), $dates));
Expressing this complex transformation more simply produces more readable code:
$category_map = array();
foreach ($dates as $date) {
$category_map[$date] = date('F Y', $date);
}
And, obviously, don't do this sort of thing:
COUNTEREXAMPLE
if ($val = $some->complicatedConstruct() && !!~blarg_blarg_blarg() & $flags
? HOPE_YOU_MEMORIZED == $all_the_lexical_binding_powers : <<<'Q'
${hahaha}
Q
);
= Performance =
- Prefer to write efficient code.
- Strongly prefer to drive optimization decisions with hard data. Avoid
optimizing based on intuition or rumor if you can not support it with
concrete measurements.
- Prefer to optimize code which is slow and runs often. Optimizing code which
is fast and runs rarely is usually a waste of time, and can even be harmful
if it makes that code more difficult to understand or maintain. You can
determine if code is fast or slow by measuring it.
- Reject performance discussions that aren't rooted in concrete data.
In Phorge, you can usually use the builtin XHProf profiling to quickly
gather concrete performance data.
= Naming Things =
- Follow language-specific conventions.
- Name things unambiguously.
- Choose descriptive names.
- Avoid nonstandard abbreviations (common abbreviations like ID, URI and HTTP
are fine).
- Spell words correctly.
- Use correct grammar.
For example, avoid these sorts of naming choices:
COUNTEREXAMPLE
$PIE->GET_FLAVOR(); // Unconventional.
$thing->doStuff(); // Ambiguous.
$list->empty(); // Ambiguous -- is it isEmpty() or makeEmpty()?
$e = 3; // Not descriptive.
$this->updtHndlr(); // Nonstandard abbreviation.
$this->chackSpulls(); // Misspelling, ungrammatical.
Prefer these:
$pie->getFlavor(); // Conventional.
$pie->bake(); // Unambiguous.
$list->isEmpty(); // Unambiguous.
$list->makeEmpty(); // Unambiguous.
$edge_count = 3; // Descriptive.
$this->updateHandler(); // No nonstandard abbreviations.
$this->getID(); // Standard abbreviation.
$this->checkSpelling(); // Correct spelling and grammar.
= Error Handling =
- Strongly prefer to detect errors.
- Strongly prefer to fail fast and loudly. The maximum cost of script
termination is known, bounded, and fairly small. The maximum cost of
continuing script execution when errors have occurred is unknown and
unbounded. This also makes APIs much easier to use and problems far easier
to debug.
When you ignore errors, defer error handling, or degrade the severity of errors
by treating them as warnings and then dismissing them, you risk dangerous
behavior which may be difficult to troubleshoot:
COUNTEREXAMPLE
exec('echo '.$data.' > file.bak'); // Bad!
do_something_dangerous();
exec('echo '.$data.' > file.bak', $out, $err); // Also bad!
if ($err) {
debug_rlog("Unable to copy file!");
}
do_something_dangerous();
Instead, fail loudly:
exec('echo '.$data.' > file.bak', $out, $err); // Better
if ($err) {
throw new Exception("Unable to copy file!");
}
do_something_dangerous();
But the best approach is to use or write an API which simplifies condition
handling and makes it easier to get right than wrong:
execx('echo %s > file.bak', $data); // Good
do_something_dangerous();
Filesystem::writeFile('file.bak', $data); // Best
do_something_dangerous();
See @{article@arcanist:Command Execution} for details on the APIs used in this
example.
= Documentation, Comments and Formatting =
- Prefer to remove code by deleting it over removing it by commenting it out.
- It shall live forever in source control, and can be retrieved therefrom if
+ It shall live forever in source control, and can be retrieved there from if
it is ever again called upon.
- In source code, use only ASCII printable characters plus space and linefeed.
Do not use UTF-8 or other multibyte encodings.
diff --git a/src/docs/user/configuration/configuring_inbound_email.diviner b/src/docs/user/configuration/configuring_inbound_email.diviner
index dfebfde7aa..c7c9818057 100644
--- a/src/docs/user/configuration/configuring_inbound_email.diviner
+++ b/src/docs/user/configuration/configuring_inbound_email.diviner
@@ -1,294 +1,294 @@
@title Configuring Inbound Email
@group config
This document contains instructions for configuring inbound email, so users
may interact with some Phorge applications via email.
Preamble
========
Phorge can process inbound mail in two general ways:
**Handling Replies**: When users reply to email notifications about changes,
Phorge can turn email into comments on the relevant discussion thread.
**Creating Objects**: You can configure an address like `bugs@yourcompany.com`
to create new objects (like tasks) when users send email.
In either case, users can interact with objects via mail commands to apply a
broader set of changes to objects beyond commenting. (For example, you can use
`!close` to close a task or `!priority` to change task priority.)
To configure inbound mail, you will generally:
- Configure some mail domain to submit mail to Phorge for processing.
- For handling replies, set `metamta.reply-handler-domain` in your
configuration.
- For handling email that creates objects, configure inbound addresses in the
relevant application.
See below for details on each of these steps.
Configuration Overview
======================
Usually, the most challenging part of configuring inbound mail is getting mail
delivered to Phorge for processing. This step can be made much easier if
you use a third-party mail service which can submit mail to Phorge via
webhooks.
Some available approaches for delivering mail to Phorge are:
| Receive Mail With | Setup | Cost | Notes |
|--------|-------|------|-------|
| Postmark | Easy | Cheap | Recommended |
| SendGrid | Easy | Cheap | |
| Mailgun | Easy | Cheap | Discouraged |
| Local MTA | Difficult | Free | Discouraged |
The remainder of this document walks through configuring Phorge to
receive mail, and then configuring your chosen transport to deliver mail
to Phorge.
Configuring "Reply" Email
=========================
By default, Phorge uses a `noreply@phorge.example.com` email address
as the "From" address when it sends mail. The exact address it uses can be
configured with `metamta.default-address`.
When a user takes an action that generates mail, Phorge sets the
-"Reply-To" addresss for the mail to that user's name and address. This means
+"Reply-To" address for the mail to that user's name and address. This means
that users can reply to email to discuss changes, but: the conversation won't
be recorded in Phorge; and users will not be able to use email commands
to take actions or make edits.
To change this behavior so that users can interact with objects in Phorge
over email, change the configuration key `metamta.reply-handler-domain` to some
domain you configure according to the instructions below, e.g.
`phorge.example.com`. Once you set this key, email will use a
"Reply-To" like `T123+273+af310f9220ad@phorge.example.com`, which -- when
configured correctly, according to the instructions below -- will parse incoming
email and allow users to interact with Differential revisions, Maniphest tasks,
etc. over email.
If you don't want Phorge to take up an entire domain (or subdomain) you
can configure a general prefix so you can use a single mailbox to receive mail
on. To make use of this set `metamta.single-reply-handler-prefix` to the
prefix of your choice, and Phorge will prepend this to the "Reply-To"
mail address. This works because everything up to the first (optional) '+'
character in an email address is considered the receiver, and everything
after is essentially ignored.
Configuring "Create" Email
==========================
You can set up application email addresses to allow users to create objects via
email. For example, you could configure `bugs@phorge.example.com` to
create a Maniphest task out of any email which is sent to it.
You can find application email settings for each application at:
{nav icon=home, name=Home >
Applications >
type=instructions, name="Select an Application" >
icon=cog, name=Configure}
Not all applications support creating objects via email.
In some applications, including Maniphest, you can also configure Herald rules
with the `[ Content source ]` and/or `[ Receiving email address ]` fields to
route or handle objects based on which address mail was sent to.
You'll also need to configure the actual mail domain to submit mail to
Phorge by following the instructions below. Phorge will let you add
any address as an application address, but can only process mail which is
actually delivered to it.
Security
========
The email reply channel is "somewhat" authenticated. Each reply-to address is
unique to the recipient and includes a hash of user information and a unique
object ID, so it can only be used to update that object and only be used to act
on behalf of the recipient.
However, if an address is leaked (which is fairly easy -- for instance,
forwarding an email will leak a live reply address, or a user might take a
screenshot), //anyone// who can send mail to your reply-to domain may interact
with the object the email relates to as the user who leaked the mail. Because
the authentication around email has this weakness, some actions (like accepting
revisions) are not permitted over email.
This implementation is an attempt to balance utility and security, but makes
some sacrifices on both sides to achieve it because of the difficulty of
authenticating senders in the general case (e.g., where you are an open source
project and need to interact with users whose email accounts you have no control
over).
You can also set `metamta.public-replies`, which will change how Phorge
delivers email. Instead of sending each recipient a unique mail with a personal
reply-to address, it will send a single email to everyone with a public reply-to
address. This decreases security because anyone who can spoof a "From" address
can act as another user, but increases convenience if you use mailing lists and,
practically, is a reasonable setting for many installs. The reply-to address
will still contain a hash unique to the object it represents, so users who have
not received an email about an object can not blindly interact with it.
If you enable application email addresses, those addresses also use the weaker
"From" authentication mechanism.
NOTE: Phorge does not currently attempt to verify "From" addresses because
this is technically complex, seems unreasonably difficult in the general case,
and no installs have had a need for it yet. If you have a specific case where a
reasonable mechanism exists to provide sender verification (e.g., DKIM
signatures are sufficient to authenticate the sender under your configuration,
or you are willing to require all users to sign their email), file a feature
request.
Testing and Debugging Inbound Email
===================================
You can use the `bin/mail` utility to test and review inbound mail. This can
help you determine if mail is being delivered to Phorge or not:
phorge/ $ ./bin/mail list-inbound # List inbound messages.
phorge/ $ ./bin/mail show-inbound # Show details about a message.
You can also test receiving mail, but note that this just simulates receiving
the mail and doesn't send any information over the network. It is
primarily aimed at developing email handlers: it will still work properly
if your inbound email configuration is incorrect or even disabled.
phorge/ $ ./bin/mail receive-test # Receive test message.
Run `bin/mail help <command>` for detailed help on using these commands.
Mailgun Setup
=============
To use Mailgun, you need a Mailgun account. You can sign up at
<http://www.mailgun.com>. Provided you have such an account, configure it
like this:
- Configure a mail domain according to Mailgun's instructions.
- Add a Mailgun route with a `catch_all()` rule which takes the action
`forward("https://phorge.example.com/mail/mailgun/")`. Replace the
example domain with your actual domain.
- Configure a mailer in `cluster.mailers` with your Mailgun API key.
Use of Mailgun is discouraged because of concerns that they may not be a
trustworthy custodian of sensitive data. See <https://phurl.io/u/mailgun> for
discussion and context.
Postmark Setup
==============
To process inbound mail from Postmark, configure this URI as your inbound
webhook URI in the Postmark control panel:
```
https://<phorge.yourdomain.com>/mail/postmark/
```
See also the Postmark section in @{article:Configuring Outbound Email} for
discussion of the remote address whitelist used to verify that requests this
endpoint receives are authentic requests originating from Postmark.
SendGrid Setup
==============
To use SendGrid, you need a SendGrid account with access to the "Parse API" for
inbound email. Provided you have such an account, configure it like this:
- Configure an MX record according to SendGrid's instructions, i.e. add
`phorge.example.com MX 10 mx.sendgrid.net.` or similar.
- Go to the "Parse Incoming Emails" page on SendGrid
(<http://sendgrid.com/developer/reply>) and add the domain as the
"Hostname".
- Add the URL `https://phorge.example.com/mail/sendgrid/` as the "Url",
using your domain (and HTTP instead of HTTPS if you are not configured with
SSL).
- If you get an error that the hostname "can't be located or verified", it
means your MX record is either incorrectly configured or hasn't propagated
yet.
- Set `metamta.reply-handler-domain` to `phorge.example.com`
(whatever you configured the MX record for).
That's it! If everything is working properly you should be able to send email
to `anything@phorge.example.com` and it should appear in
`bin/mail list-inbound` within a few seconds.
Local MTA: Installing Mailparse
===============================
If you're going to run your own MTA, you need to install the PECL mailparse
extension. In theory, you can do that with:
$ sudo pecl install mailparse
You may run into an error like "needs mbstring". If so, try:
$ sudo yum install php-mbstring # or equivalent
$ sudo pecl install -n mailparse
If you get a linker error like this:
COUNTEREXAMPLE
PHP Warning: PHP Startup: Unable to load dynamic library
'/usr/lib64/php/modules/mailparse.so' - /usr/lib64/php/modules/mailparse.so:
undefined symbol: mbfl_name2no_encoding in Unknown on line 0
...you need to edit your php.ini file so that mbstring.so is loaded **before**
mailparse.so. This is not the default if you have individual files in
`php.d/`.
Local MTA: Configuring Sendmail
===============================
Before you can configure Sendmail, you need to install Mailparse. See the
section "Installing Mailparse" above.
Sendmail is very difficult to configure. First, you need to configure it for
your domain so that mail can be delivered correctly. In broad strokes, this
probably means something like this:
- add an MX record;
- make sendmail listen on external interfaces;
- open up port 25 if necessary (e.g., in your EC2 security policy);
- add your host to /etc/mail/local-host-names; and
- restart sendmail.
Now, you can actually configure sendmail to deliver to Phorge. In
`/etc/aliases`, add an entry like this:
phorge: "| /path/to/phorge/scripts/mail/mail_handler.php"
If you use the `PHABRICATOR_ENV` environmental variable to select a
configuration, you can pass the value to the script as an argument:
.../path/to/mail_handler.php <ENV>
This is an advanced feature which is rarely used. Most installs should run
without an argument.
After making this change, run `sudo newaliases`. Now you likely need to symlink
this script into `/etc/smrsh/`:
sudo ln -s /path/to/phorge/scripts/mail/mail_handler.php /etc/smrsh/
Finally, edit `/etc/mail/virtusertable` and add an entry like this:
@yourdomain.com phorge@localhost
That will forward all mail to @yourdomain.com to the Phorge processing
script. Run `sudo /etc/mail/make` or similar and then restart sendmail with
`sudo /etc/init.d/sendmail restart`.
diff --git a/src/docs/user/userguide/multi_factor_auth.diviner b/src/docs/user/userguide/multi_factor_auth.diviner
index cccf3e12b9..d445727e6c 100644
--- a/src/docs/user/userguide/multi_factor_auth.diviner
+++ b/src/docs/user/userguide/multi_factor_auth.diviner
@@ -1,222 +1,222 @@
@title User Guide: Multi-Factor Authentication
@group userguide
Explains how multi-factor authentication works in Phorge.
Overview
========
Multi-factor authentication allows you to add additional credentials to your
account to make it more secure.
Once multi-factor authentication is configured on your account, you'll usually
use your mobile phone to provide an authorization code or an extra confirmation
when you try to log in to a new session or take certain actions (like changing
your password).
Requiring you to prove you're really you by asking for something you know (your
password) //and// something you have (your mobile phone) makes it much harder
for attackers to access your account. The phone is an additional "factor" which
protects your account from attacks.
How Multi-Factor Authentication Works
=====================================
If you've configured multi-factor authentication and try to log in to your
account or take certain sensitive actions (like changing your password),
you'll be stopped and asked to enter additional credentials.
Usually, this means you'll receive an SMS with a authorization code on your
phone, or you'll open an app on your phone which will show you a authorization
code or ask you to confirm the action. If you're given a authorization code,
you'll enter it into Phorge.
If you're logging in, Phorge will log you in after you enter the code.
If you're taking a sensitive action, Phorge will sometimes put your
account in "high security" mode for a few minutes. In this mode, you can take
sensitive actions like changing passwords or SSH keys freely, without
entering any more credentials.
You can explicitly leave high security once you're done performing account
management, or your account will naturally return to normal security after a
short period of time.
While your account is in high security, you'll see a notification on screen
with instructions for returning to normal security.
Configuring Multi-Factor Authentication
=======================================
To manage authentication factors for your account, go to
{nav Settings > Multi-Factor Auth}. You can use this control panel to add
or remove authentication factors from your account.
You can also rename a factor by clicking the name. This can help you identify
factors if you have several similar factors attached to your account.
For a description of the available factors, see the next few sections.
Factor: Mobile Phone App (TOTP)
===============================
TOTP stands for "Time-based One-Time Password". This factor operates by having
you enter authorization codes from your mobile phone into Phorge. The codes
change every 30 seconds, so you will need to have your phone with you in order
to enter them.
To use this factor, you'll download an application onto your smartphone which
can compute these codes. Two applications which work well are **Authy** and
**Google Authenticator**. These applications are free, and you can find and
download them from the appropriate store on your device.
Your company may have a preferred application, or may use some other
application, so check any in-house documentation for details. In general, any
TOTP application should work properly.
After you've downloaded the application onto your phone, use the Phorge
settings panel to add a factor to your account. You'll be prompted to scan a
QR code, and then read an authorization code from your phone and type it into
Phorge.
Later, when you need to authenticate, you'll follow this same process: launch
the application, read the authorization code, and type it into Phorge.
This will prove you have your phone.
Don't lose your phone! You'll need it to log into Phorge in the future.
Factor: SMS
===========
This factor operates by texting you a short authorization code when you try to
log in or perform a sensitive action.
To use SMS, first add your phone number in {nav Settings > Contact Numbers}.
Once a primary contact number is configured on your account, you'll be able
to add an SMS factor.
To enroll in SMS, you'll be sent a confirmation code to make sure your contact
number is correct and SMS is being delivered properly. Enter it when prompted.
When you're asked to confirm your identity in the future, you'll be texted
an authorization code to enter into the prompt.
(WARNING) SMS is a very weak factor and can be compromised or intercepted. For
details, see: <https://phurl.io/u/sms>.
Factor: Duo
===========
This factor supports integration with [[ https://duo.com/ | Duo Security ]], a
third-party authentication service popular with enterprises that have a lot of
policies to enforce.
To use Duo, you'll install the Duo application on your phone. When you try
to take a sensitive action, you'll be asked to confirm it in the application.
Administration: Configuration
=============================
New Phorge installs start without any multi-factor providers enabled.
Users won't be able to add new factors until you set up multi-factor
authentication by configuring at least one provider.
Configure new providers in {nav Auth > Multi-Factor}.
Providers may be in these states:
- **Active**: Users may add new factors. Users will be prompted to respond
to challenges from these providers when they take a sensitive action.
- **Deprecated**: Users may not add new factors, but they will still be
- asked to respond to challenges from exising factors.
+ asked to respond to challenges from existing factors.
- **Disabled**: Users may not add new factors, and existing factors will
not be used. If MFA is required and a user only has disabled factors,
they will be forced to add a new factor.
If you want to change factor types for your organization, the process will
normally look something like this:
- Configure and test a new provider.
- Deprecate the old provider.
- Notify users that the old provider is deprecated and that they should move
to the new provider at their convenience, but before some upcoming
deadline.
- Once the deadline arrives, disable the old provider.
Administration: Requiring MFA
=============================
As an administrator, you can require all users to add MFA to their accounts by
setting the `security.require-multi-factor-auth` option in Config.
Administration: Recovering from Lost Factors
============================================
If a user has lost a factor associated with their account (for example, their
phone has been lost or damaged), an administrator with host access can strip
the factor off their account so that they can log in without it.
IMPORTANT: Before stripping factors from a user account, be absolutely certain
that the user is who they claim to be!
It is important to verify the user is who they claim they are before stripping
factors because an attacker might pretend to be a user who has lost their phone
in order to bypass multi-factor authentication. It is much easier for a typical
attacker to spoof an email with a sad story in it than it is for a typical
attacker to gain access to a mobile phone.
A good way to verify user identity is to meet them in person and have them
solemnly swear an oath that they lost their phone and are very sorry and
definitely won't do it again. You can also work out a secret handshake in
advance and require them to perform it. But no matter what you do, be certain
the user (not an attacker //pretending// to be the user) is really the one
making the request before stripping factors.
After verifying identity, administrators with host access can strip
authentication factors from user accounts using the `bin/auth strip` command.
For example, to strip all factors from the account of a user who has lost
their phone, run this command:
```lang=console
# Strip all factors from a given user account.
phorge/ $ ./bin/auth strip --user <username> --all-types
```
You can run `bin/auth help strip` for more detail and all available flags and
arguments.
This command can selectively strip factors by factor type. You can use
`bin/auth list-factors` to get a list of available factor types.
```lang=console
# Show supported factor types.
phorge/ $ ./bin/auth list-factors
```
Once you've identified the factor types you want to strip, you can strip
matching factors by using the `--type` flag to specify one or more factor
types:
```lang=console
# Strip all SMS and TOTP factors for a user.
phorge/ $ ./bin/auth strip --user <username> --type sms --type totp
```
The `bin/auth strip` command can also selectively strip factors for certain
providers. This is more granular than stripping all factors of a given type.
You can use `bin/auth list-mfa-providers` to get a list of providers.
Once you have a provider PHID, use `--provider` to select factors to strip:
```lang=console
# Strip all factors for a particular provider.
phorge/ $ ./bin/auth strip --user <username> --provider <providerPHID>
```
diff --git a/src/docs/user/userguide/webhooks.diviner b/src/docs/user/userguide/webhooks.diviner
index c2d0678b26..47b02cc261 100644
--- a/src/docs/user/userguide/webhooks.diviner
+++ b/src/docs/user/userguide/webhooks.diviner
@@ -1,223 +1,223 @@
@title User Guide: Webhooks
@group userguide
Guide to configuring webhooks.
Overview
========
If you'd like to react to events in Phorge or publish them into external
systems, you can configure webhooks.
Configure webhooks in {nav Herald > Webhooks}. Users must have the
"Can Create Webhooks" permission to create new webhooks.
Triggering Hooks
================
Webhooks can be triggered in two ways:
- Set the hook mode to **Firehose**. In this mode, your hook will be called
for every event.
- Set the hook mode to **Enabled**, then write Herald rules which use the
**Call webhooks** action to choose when the hook is called. This allows
you to choose a narrower range of events to be notified about.
Testing Hooks
=============
To test a webhook, use {nav New Test Request} from the web interface.
You can also use the command-line tool, which supports a few additional
options:
```
phorge/ $ ./bin/webhook call --id 42 --object D123
```
Verifying Requests
==================
When your webhook callback URI receives a request, it didn't necessarily come
from Phorge. An attacker or mischievous user can normally call your hook
directly and pretend to be notifying you of an event.
To verify that the request is authentic, first retrieve the webhook key from
the web UI with {nav View HMAC Key}. This is a shared secret which will let you
verify that Phorge originated a request.
When you receive a request, compute the SHA256 HMAC value of the request body
using the HMAC key as the key. The value should match the value in the
`X-Phabricator-Webhook-Signature` field.
To compute the SHA256 HMAC of a string in PHP, do this:
```lang=php
$signature = hash_hmac('sha256', $request_body, $hmac_key);
```
To compute the SHA256 HMAC of a string in Python, do this:
```lang=python
from subprocess import check_output
signature = check_output(
[
"php",
"-r",
"echo hash_hmac('sha256', $argv[1], $argv[2]);",
"--",
request_body,
hmac_key
])
```
Other languages often provide similar support.
If you somehow disclose the key by accident, use {nav Regenerate HMAC Key} to
throw it away and generate a new one.
Request Format
==============
Webhook callbacks are POST requests with a JSON payload in the body. The
payload looks like this:
```lang=json
{
"object": {
"type": "TASK",
"phid": "PHID-TASK-abcd..."
},
"triggers": [
{
"phid": "PHID-HRUL-abcd..."
}
],
"action": {
"test": false,
"silent": false,
"secure": false,
"epoch": 12345
},
"transactions": [
{
"phid": "PHID-XACT-TASK-abcd..."
}
]
}
```
The **object** map describes the object which was edited.
The **triggers** are a list of reasons why the hook was called. When the hook
is triggered by Herald rules, the specific rules which triggered the call will
be listed. For firehose rules, the rule itself will be listed as the trigger.
For test calls, the user making the request will be listed as a trigger.
The **action** map has metadata about the action:
- `test` This was a test call from the web UI or console.
- `silent` This is a silent edit which won't send mail or notifications in
Phorge. If your hook is doing something like copying events into
a chatroom, it may want to respect this flag.
- `secure` Details about this object should only be transmitted over
secure channels. Your hook may want to respect this flag.
- `epoch` The epoch timestamp when the callback was queued.
The **transactions** list contains information about the actual changes which
triggered the callback.
Responding to Requests
======================
Although trivial hooks may not need any more information than this to act, the
information conveyed in the hook body is a minimum set of pointers to relevant
data and likely insufficient for more complex hooks.
Complex hooks should expect to react to receiving a request by making API
calls to Conduit to retrieve additional information about the object and
transactions.
Hooks that are interested in reading object state should generally make a call
to a method like `maniphest.search` or `differential.revision.search` using
the PHID from the `object` field to retrieve full details about the object
state.
Hooks that are interested in changes should generally make a call to
`transaction.search`, passing the transaction PHIDs as a constraint to retrieve
details about the transactions.
For example, your call to `transaction.search` may look something like this:
```lang=json
{
"objectIdentifier": "PHID-XXXX-abcdef",
"constraints": {
"phids": [
"PHID-XACT-XXXX-11111111",
"PHID-XACT-XXXX-22222222"
]
}
}
```
The `phid.query` method can also be used to retrieve generic information about
a list of objects.
Retries and Rate Limiting
=========================
Test requests are never retried: they execute exactly once.
Live requests are automatically retried. If your endpoint does not return a
-HTTP 2XX response, the request will be retried regularly until it suceeds.
+HTTP 2XX response, the request will be retried regularly until it succeeds.
Retries will continue until the request succeeds or is garbage collected. By
default, this is after 7 days.
If a webhook is disabled, outstanding queued requests will be failed
permanently. Activity which occurs while it is disabled will never be sent to
the callback URI. (Disabling a hook does not "pause" it so that it can be
"resumed" later and pick back up where it left off in the event stream.)
If a webhook encounters a significant number of errors in a short period of
time, the webhook will be paused for a few minutes before additional requests
are made. The web UI shows a warning indicator when a hook is paused because of
errors.
Hook requests time out after 10 seconds. Consider offloading response handling
to some kind of worker queue if you expect to routinely require more than 10
seconds to respond to requests.
Hook callbacks are single-threaded: you will never receive more than one
simultaneous call to the same webhook from Phorge. If you have a firehose
hook on an active install, it may be important to respond to requests quickly
to avoid accumulating a backlog.
Callbacks may be invoked out-of-order. You should not assume that the order
you receive requests in is chronological order. If your hook is order-dependent,
you can ignore the transactions in the callback and use `transaction.search` to
retrieve a consistent list of ordered changes to the object.
Callbacks may be delayed for an arbitrarily long amount of time, up to the
garbage collection limit. You should not assume that calls are real time. If
your hook is doing something time-sensitive, you can measure the delivery delay
by comparing the current time to the `epoch` value in the `action` field and
ignoring old actions or handling them in some special way.
Next Steps
==========
Continue by:
- learning more about Herald with @{article:Herald User Guide}; or
- interacting with the Conduit API with @{article:Conduit API Overview}.
diff --git a/src/infrastructure/customfield/field/PhabricatorCustomField.php b/src/infrastructure/customfield/field/PhabricatorCustomField.php
index 9e2bf6895f..4e55348c36 100644
--- a/src/infrastructure/customfield/field/PhabricatorCustomField.php
+++ b/src/infrastructure/customfield/field/PhabricatorCustomField.php
@@ -1,1713 +1,1713 @@
<?php
/**
* @task apps Building Applications with Custom Fields
* @task core Core Properties and Field Identity
* @task proxy Field Proxies
* @task context Contextual Data
* @task render Rendering Utilities
* @task storage Field Storage
* @task edit Integration with Edit Views
* @task view Integration with Property Views
* @task list Integration with List views
* @task appsearch Integration with ApplicationSearch
* @task appxaction Integration with ApplicationTransactions
* @task xactionmail Integration with Transaction Mail
* @task globalsearch Integration with Global Search
* @task herald Integration with Herald
*/
abstract class PhabricatorCustomField extends Phobject {
private $viewer;
private $object;
private $proxy;
const ROLE_APPLICATIONTRANSACTIONS = 'ApplicationTransactions';
const ROLE_TRANSACTIONMAIL = 'ApplicationTransactions.mail';
const ROLE_APPLICATIONSEARCH = 'ApplicationSearch';
const ROLE_STORAGE = 'storage';
const ROLE_DEFAULT = 'default';
const ROLE_EDIT = 'edit';
const ROLE_VIEW = 'view';
const ROLE_LIST = 'list';
const ROLE_GLOBALSEARCH = 'GlobalSearch';
const ROLE_CONDUIT = 'conduit';
const ROLE_HERALD = 'herald';
const ROLE_EDITENGINE = 'EditEngine';
const ROLE_HERALDACTION = 'herald.action';
const ROLE_EXPORT = 'export';
/* -( Building Applications with Custom Fields )--------------------------- */
/**
* @task apps
*/
public static function getObjectFields(
PhabricatorCustomFieldInterface $object,
$role) {
try {
$attachment = $object->getCustomFields();
} catch (PhabricatorDataNotAttachedException $ex) {
$attachment = new PhabricatorCustomFieldAttachment();
$object->attachCustomFields($attachment);
}
try {
$field_list = $attachment->getCustomFieldList($role);
} catch (PhabricatorCustomFieldNotAttachedException $ex) {
$base_class = $object->getCustomFieldBaseClass();
$spec = $object->getCustomFieldSpecificationForRole($role);
if (!is_array($spec)) {
throw new Exception(
pht(
"Expected an array from %s for object of class '%s'.",
'getCustomFieldSpecificationForRole()',
get_class($object)));
}
$fields = self::buildFieldList(
$base_class,
$spec,
$object);
$fields = self::adjustCustomFieldsForObjectSubtype(
$object,
$role,
$fields);
foreach ($fields as $key => $field) {
// NOTE: We perform this filtering in "buildFieldList()", but may need
// to filter again after subtype adjustment.
if (!$field->isFieldEnabled()) {
unset($fields[$key]);
continue;
}
if (!$field->shouldEnableForRole($role)) {
unset($fields[$key]);
continue;
}
}
foreach ($fields as $field) {
$field->setObject($object);
}
$field_list = new PhabricatorCustomFieldList($fields);
$attachment->addCustomFieldList($role, $field_list);
}
return $field_list;
}
/**
* @task apps
*/
public static function getObjectField(
PhabricatorCustomFieldInterface $object,
$role,
$field_key) {
$fields = self::getObjectFields($object, $role)->getFields();
return idx($fields, $field_key);
}
/**
* @task apps
*/
public static function buildFieldList(
$base_class,
array $spec,
$object,
array $options = array()) {
$field_objects = id(new PhutilClassMapQuery())
->setAncestorClass($base_class)
->execute();
$fields = array();
foreach ($field_objects as $field_object) {
$field_object = clone $field_object;
foreach ($field_object->createFields($object) as $field) {
$key = $field->getFieldKey();
if (isset($fields[$key])) {
throw new Exception(
pht(
"Both '%s' and '%s' define a custom field with ".
"field key '%s'. Field keys must be unique.",
get_class($fields[$key]),
get_class($field),
$key));
}
$fields[$key] = $field;
}
}
foreach ($fields as $key => $field) {
if (!$field->isFieldEnabled()) {
unset($fields[$key]);
}
}
$fields = array_select_keys($fields, array_keys($spec)) + $fields;
if (empty($options['withDisabled'])) {
foreach ($fields as $key => $field) {
if (isset($spec[$key]['disabled'])) {
$is_disabled = $spec[$key]['disabled'];
} else {
$is_disabled = $field->shouldDisableByDefault();
}
if ($is_disabled) {
if ($field->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);
}
public function getModernFieldKey() {
if ($this->proxy) {
return $this->proxy->getModernFieldKey();
}
return $this->getFieldKey();
}
/**
* 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->getModernFieldKey();
}
/**
* 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.
*
* @param object The object to create fields for.
* @return list<PhabricatorCustomField> List of fields.
* @task core
*/
public function createFields($object) {
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) {
// NOTE: All of these calls proxy individually, so we don't need to
// proxy this call as a whole.
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_GLOBALSEARCH:
return $this->shouldAppearInGlobalSearch();
case self::ROLE_CONDUIT:
return $this->shouldAppearInConduitDictionary();
case self::ROLE_TRANSACTIONMAIL:
return $this->shouldAppearInTransactionMail();
case self::ROLE_HERALD:
return $this->shouldAppearInHerald();
case self::ROLE_HERALDACTION:
return $this->shouldAppearInHeraldActions();
case self::ROLE_EDITENGINE:
return $this->shouldAppearInEditView() ||
$this->shouldAppearInEditEngine();
case self::ROLE_EXPORT:
return $this->shouldAppearInDataExport();
case self::ROLE_DEFAULT:
return true;
default:
throw new Exception(pht("Unknown field role '%s'!", $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;
}
public function shouldDisableByDefault() {
return false;
}
/**
* 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.
* @return this
* @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;
}
/**
* Read object data into local field storage, if applicable.
*
* @param PhabricatorCustomFieldInterface The object this field belongs to.
* @return this
* @task context
*/
public function readValueFromObject(PhabricatorCustomFieldInterface $object) {
if ($this->proxy) {
$this->proxy->readValueFromObject($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;
}
/* -( Rendering Utilities )------------------------------------------------ */
/**
* @task render
*/
protected function renderHandleList(array $handles) {
if (!$handles) {
return null;
}
$out = array();
foreach ($handles as $handle) {
$out[] = $handle->renderHovercardLink();
}
return phutil_implode_html(phutil_tag('br'), $out);
}
/* -( 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() {
// NOTE: This intentionally isn't proxied, to avoid call cycles.
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);
}
public function didSetValueFromStorage() {
if ($this->proxy) {
return $this->proxy->didSetValueFromStorage();
}
return $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<PhabricatorCustomFieldIndexStorage> List of indexes.
* @task appsearch
*/
public function buildFieldIndexes() {
if ($this->proxy) {
return $this->proxy->buildFieldIndexes();
}
return array();
}
/**
* Return an index against which this field can be meaningfully ordered
* against to implement ApplicationSearch.
*
* This should be a single index, normally built using
* @{method:newStringIndex} and @{method:newNumericIndex}.
*
* The value of the index is not used.
*
* Return null from this method if the field can not be ordered.
*
* @return PhabricatorCustomFieldIndexStorage A single index to order by.
* @task appsearch
*/
public function buildOrderIndex() {
if ($this->proxy) {
return $this->proxy->buildOrderIndex();
}
return null;
}
/**
* 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() {
// NOTE: This intentionally isn't proxied, to avoid call cycles.
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() {
// NOTE: This intentionally isn't proxied, to avoid call cycles.
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->getFieldIndex();
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->getFieldIndex();
return $this->newNumericIndexStorage()
->setIndexKey($key)
->setIndexValue($value);
}
/**
* Read a query value from a request, for storage in a saved query. Normally,
* this method should, e.g., read a string out of the request.
*
* @param PhabricatorApplicationSearchEngine Engine building the query.
* @param AphrontRequest Request to read from.
* @return wild
* @task appsearch
*/
public function readApplicationSearchValueFromRequest(
PhabricatorApplicationSearchEngine $engine,
AphrontRequest $request) {
if ($this->proxy) {
return $this->proxy->readApplicationSearchValueFromRequest(
$engine,
$request);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Constrain a query, given a field value. Generally, this method should
* use `with...()` methods to apply filters or other constraints to the
* query.
*
* @param PhabricatorApplicationSearchEngine Engine executing the query.
* @param PhabricatorCursorPagedPolicyAwareQuery Query to constrain.
* @param wild Constraint provided by the user.
* @return void
* @task appsearch
*/
public function applyApplicationSearchConstraintToQuery(
PhabricatorApplicationSearchEngine $engine,
PhabricatorCursorPagedPolicyAwareQuery $query,
$value) {
if ($this->proxy) {
return $this->proxy->applyApplicationSearchConstraintToQuery(
$engine,
$query,
$value);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Append search controls to the interface.
*
* @param PhabricatorApplicationSearchEngine Engine constructing the form.
* @param AphrontFormView The form to update.
* @param wild Value from the saved query.
* @return void
* @task appsearch
*/
public function appendToApplicationSearchForm(
PhabricatorApplicationSearchEngine $engine,
AphrontFormView $form,
$value) {
if ($this->proxy) {
return $this->proxy->appendToApplicationSearchForm(
$engine,
$form,
$value);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/* -( 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 getApplicationTransactionType() {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionType();
}
return PhabricatorTransactions::TYPE_CUSTOMFIELD;
}
/**
* @task appxaction
*/
public function getApplicationTransactionMetadata() {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionMetadata();
}
return array();
}
/**
* @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 getApplicationTransactionRemarkupBlocks(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionRemarkupBlocks($xaction);
}
return array();
}
/**
* @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;
}
/**
* Validate transactions for an object. This allows you to raise an error
* when a transaction would set a field to an invalid value, or when a field
* is required but no transactions provide value.
*
* @param PhabricatorLiskDAO Editor applying the transactions.
* @param string Transaction type. This type is always
* `PhabricatorTransactions::TYPE_CUSTOMFIELD`, it is provided for
* convenience when constructing exceptions.
* @param list<PhabricatorApplicationTransaction> Transactions being applied,
* which may be empty if this field is not being edited.
* @return list<PhabricatorApplicationTransactionValidationError> Validation
* errors.
*
* @task appxaction
*/
public function validateApplicationTransactions(
PhabricatorApplicationTransactionEditor $editor,
$type,
array $xactions) {
if ($this->proxy) {
return $this->proxy->validateApplicationTransactions(
$editor,
$type,
$xactions);
}
return array();
}
public function getApplicationTransactionTitle(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionTitle(
$xaction);
}
$author_phid = $xaction->getAuthorPHID();
return pht(
'%s updated this object.',
$xaction->renderHandleLink($author_phid));
}
public function getApplicationTransactionTitleForFeed(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionTitleForFeed(
$xaction);
}
$author_phid = $xaction->getAuthorPHID();
$object_phid = $xaction->getObjectPHID();
return pht(
'%s updated %s.',
$xaction->renderHandleLink($author_phid),
$xaction->renderHandleLink($object_phid));
}
public function getApplicationTransactionHasChangeDetails(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionHasChangeDetails(
$xaction);
}
return false;
}
public function getApplicationTransactionChangeDetails(
PhabricatorApplicationTransaction $xaction,
PhabricatorUser $viewer) {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionChangeDetails(
$xaction,
$viewer);
}
return null;
}
public function getApplicationTransactionRequiredHandlePHIDs(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->getApplicationTransactionRequiredHandlePHIDs(
$xaction);
}
return array();
}
public function shouldHideInApplicationTransactions(
PhabricatorApplicationTransaction $xaction) {
if ($this->proxy) {
return $this->proxy->shouldHideInApplicationTransactions($xaction);
}
return false;
}
/* -( Transaction Mail )--------------------------------------------------- */
/**
* @task xactionmail
*/
public function shouldAppearInTransactionMail() {
if ($this->proxy) {
return $this->proxy->shouldAppearInTransactionMail();
}
return false;
}
/**
* @task xactionmail
*/
public function updateTransactionMailBody(
PhabricatorMetaMTAMailBody $body,
PhabricatorApplicationTransactionEditor $editor,
array $xactions) {
if ($this->proxy) {
return $this->proxy->updateTransactionMailBody($body, $editor, $xactions);
}
return;
}
/* -( Edit View )---------------------------------------------------------- */
public function getEditEngineFields(PhabricatorEditEngine $engine) {
$field = $this->newStandardEditField();
return array(
$field,
);
}
protected function newEditField() {
$field = id(new PhabricatorCustomFieldEditField())
->setCustomField($this);
$http_type = $this->getHTTPParameterType();
if ($http_type) {
$field->setCustomFieldHTTPParameterType($http_type);
}
$conduit_type = $this->getConduitEditParameterType();
if ($conduit_type) {
$field->setCustomFieldConduitParameterType($conduit_type);
}
$bulk_type = $this->getBulkParameterType();
if ($bulk_type) {
$field->setCustomFieldBulkParameterType($bulk_type);
}
$comment_action = $this->getCommentAction();
if ($comment_action) {
$field
->setCustomFieldCommentAction($comment_action)
->setCommentActionLabel(
pht(
'Change %s',
$this->getFieldName()));
}
return $field;
}
protected function newStandardEditField() {
if ($this->proxy) {
return $this->proxy->newStandardEditField();
}
if ($this->shouldAppearInEditView()) {
$form_field = true;
} else {
$form_field = false;
}
$bulk_label = $this->getBulkEditLabel();
return $this->newEditField()
->setKey($this->getFieldKey())
->setEditTypeKey($this->getModernFieldKey())
->setLabel($this->getFieldName())
->setBulkEditLabel($bulk_label)
->setDescription($this->getFieldDescription())
->setTransactionType($this->getApplicationTransactionType())
->setIsFormField($form_field)
->setValue($this->getNewValueForApplicationTransactions());
}
protected function getBulkEditLabel() {
if ($this->proxy) {
return $this->proxy->getBulkEditLabel();
}
return pht('Set "%s" to', $this->getFieldName());
}
public function getBulkParameterType() {
return $this->newBulkParameterType();
}
protected function newBulkParameterType() {
if ($this->proxy) {
return $this->proxy->newBulkParameterType();
}
return null;
}
protected function getHTTPParameterType() {
if ($this->proxy) {
return $this->proxy->getHTTPParameterType();
}
return null;
}
/**
* @task edit
*/
public function shouldAppearInEditView() {
if ($this->proxy) {
return $this->proxy->shouldAppearInEditView();
}
return false;
}
/**
* @task edit
*/
public function shouldAppearInEditEngine() {
if ($this->proxy) {
return $this->proxy->shouldAppearInEditEngine();
}
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 getRequiredHandlePHIDsForEdit() {
if ($this->proxy) {
return $this->proxy->getRequiredHandlePHIDsForEdit();
}
return array();
}
/**
* @task edit
*/
public function getInstructionsForEdit() {
if ($this->proxy) {
return $this->proxy->getInstructionsForEdit();
}
return null;
}
/**
* @task edit
*/
public function renderEditControl(array $handles) {
if ($this->proxy) {
return $this->proxy->renderEditControl($handles);
}
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(array $handles) {
if ($this->proxy) {
return $this->proxy->renderPropertyViewValue($handles);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* @task view
*/
public function getStyleForPropertyView() {
if ($this->proxy) {
return $this->proxy->getStyleForPropertyView();
}
return 'property';
}
/**
* @task view
*/
public function getIconForPropertyView() {
if ($this->proxy) {
return $this->proxy->getIconForPropertyView();
}
return null;
}
/**
* @task view
*/
public function getRequiredHandlePHIDsForPropertyView() {
if ($this->proxy) {
return $this->proxy->getRequiredHandlePHIDsForPropertyView();
}
return array();
}
/* -( List View )---------------------------------------------------------- */
/**
* @task list
*/
public function shouldAppearInListView() {
if ($this->proxy) {
return $this->proxy->shouldAppearInListView();
}
return false;
}
/**
* @task list
*/
public function renderOnListItem(PHUIObjectItemView $view) {
if ($this->proxy) {
return $this->proxy->renderOnListItem($view);
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/* -( Global Search )------------------------------------------------------ */
/**
* @task globalsearch
*/
public function shouldAppearInGlobalSearch() {
if ($this->proxy) {
return $this->proxy->shouldAppearInGlobalSearch();
}
return false;
}
/**
* @task globalsearch
*/
public function updateAbstractDocument(
PhabricatorSearchAbstractDocument $document) {
if ($this->proxy) {
return $this->proxy->updateAbstractDocument($document);
}
return $document;
}
/* -( Data Export )-------------------------------------------------------- */
public function shouldAppearInDataExport() {
if ($this->proxy) {
return $this->proxy->shouldAppearInDataExport();
}
try {
$this->newExportFieldType();
return true;
} catch (PhabricatorCustomFieldImplementationIncompleteException $ex) {
return false;
}
}
public function newExportField() {
if ($this->proxy) {
return $this->proxy->newExportField();
}
return $this->newExportFieldType()
->setLabel($this->getFieldName());
}
public function newExportData() {
if ($this->proxy) {
return $this->proxy->newExportData();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
protected function newExportFieldType() {
if ($this->proxy) {
return $this->proxy->newExportFieldType();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/* -( Conduit )------------------------------------------------------------ */
/**
* @task conduit
*/
public function shouldAppearInConduitDictionary() {
if ($this->proxy) {
return $this->proxy->shouldAppearInConduitDictionary();
}
return false;
}
/**
* @task conduit
*/
public function getConduitDictionaryValue() {
if ($this->proxy) {
return $this->proxy->getConduitDictionaryValue();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
public function shouldAppearInConduitTransactions() {
if ($this->proxy) {
return $this->proxy->shouldAppearInConduitDictionary();
}
return false;
}
public function getConduitSearchParameterType() {
return $this->newConduitSearchParameterType();
}
protected function newConduitSearchParameterType() {
if ($this->proxy) {
return $this->proxy->newConduitSearchParameterType();
}
return null;
}
public function getConduitEditParameterType() {
return $this->newConduitEditParameterType();
}
protected function newConduitEditParameterType() {
if ($this->proxy) {
return $this->proxy->newConduitEditParameterType();
}
return null;
}
public function getCommentAction() {
return $this->newCommentAction();
}
protected function newCommentAction() {
if ($this->proxy) {
return $this->proxy->newCommentAction();
}
return null;
}
/* -( Herald )------------------------------------------------------------- */
/**
* Return `true` to make this field available in Herald.
*
* @return bool True to expose the field in Herald.
* @task herald
*/
public function shouldAppearInHerald() {
if ($this->proxy) {
return $this->proxy->shouldAppearInHerald();
}
return false;
}
/**
* Get the name of the field in Herald. By default, this uses the
* normal field name.
*
* @return string Herald field name.
* @task herald
*/
public function getHeraldFieldName() {
if ($this->proxy) {
return $this->proxy->getHeraldFieldName();
}
return $this->getFieldName();
}
/**
* Get the field value for evaluation by Herald.
*
* @return wild Field value.
* @task herald
*/
public function getHeraldFieldValue() {
if ($this->proxy) {
return $this->proxy->getHeraldFieldValue();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Get the available conditions for this field in Herald.
*
* @return list<const> List of Herald condition constants.
* @task herald
*/
public function getHeraldFieldConditions() {
if ($this->proxy) {
return $this->proxy->getHeraldFieldConditions();
}
throw new PhabricatorCustomFieldImplementationIncompleteException($this);
}
/**
* Get the Herald value type for the given condition.
*
* @param const Herald condition constant.
* @return const|null Herald value type, or null to use the default.
* @task herald
*/
public function getHeraldFieldValueType($condition) {
if ($this->proxy) {
return $this->proxy->getHeraldFieldValueType($condition);
}
return null;
}
public function getHeraldFieldStandardType() {
if ($this->proxy) {
return $this->proxy->getHeraldFieldStandardType();
}
return null;
}
public function getHeraldDatasource() {
if ($this->proxy) {
return $this->proxy->getHeraldDatasource();
}
return null;
}
public function shouldAppearInHeraldActions() {
if ($this->proxy) {
return $this->proxy->shouldAppearInHeraldActions();
}
return false;
}
public function getHeraldActionName() {
if ($this->proxy) {
return $this->proxy->getHeraldActionName();
}
return null;
}
public function getHeraldActionStandardType() {
if ($this->proxy) {
return $this->proxy->getHeraldActionStandardType();
}
return null;
}
public function getHeraldActionDescription($value) {
if ($this->proxy) {
return $this->proxy->getHeraldActionDescription($value);
}
return null;
}
public function getHeraldActionEffectDescription($value) {
if ($this->proxy) {
return $this->proxy->getHeraldActionEffectDescription($value);
}
return null;
}
public function getHeraldActionDatasource() {
if ($this->proxy) {
return $this->proxy->getHeraldActionDatasource();
}
return null;
}
private static function adjustCustomFieldsForObjectSubtype(
PhabricatorCustomFieldInterface $object,
$role,
array $fields) {
assert_instances_of($fields, __CLASS__);
// We only apply subtype adjustment for some roles. For example, when
// writing Herald rules or building a Search interface, we always want to
// show all the fields in their default state, so we do not apply any
// adjustments.
$subtype_roles = array(
self::ROLE_EDITENGINE,
self::ROLE_VIEW,
self::ROLE_EDIT,
);
$subtype_roles = array_fuse($subtype_roles);
if (!isset($subtype_roles[$role])) {
return $fields;
}
// If the object doesn't support subtypes, we can't possibly make
// any adjustments based on subtype.
if (!($object instanceof PhabricatorEditEngineSubtypeInterface)) {
return $fields;
}
$subtype_map = $object->newEditEngineSubtypeMap();
$subtype_key = $object->getEditEngineSubtype();
$subtype_object = $subtype_map->getSubtype($subtype_key);
$map = array();
foreach ($fields as $field) {
$modern_key = $field->getModernFieldKey();
if (!strlen($modern_key)) {
continue;
}
$map[$modern_key] = $field;
}
foreach ($map as $field_key => $field) {
// For now, only support overriding standard custom fields. In the
// future there's no technical or product reason we couldn't let you
- // override (some properites of) other fields like "Title", but they
+ // override (some properties of) other fields like "Title", but they
// don't usually support appropriate "setX()" methods today.
if (!($field instanceof PhabricatorStandardCustomField)) {
// For fields that are proxies on top of StandardCustomField, which
// is how most application custom fields work today, we can reconfigure
// the proxied field instead.
$field = $field->getProxy();
if (!$field || !($field instanceof PhabricatorStandardCustomField)) {
continue;
}
}
$subtype_config = $subtype_object->getSubtypeFieldConfiguration(
$field_key);
if (!$subtype_config) {
continue;
}
if (isset($subtype_config['disabled'])) {
$field->setIsEnabled(!$subtype_config['disabled']);
}
if (isset($subtype_config['name'])) {
$field->setFieldName($subtype_config['name']);
}
}
return $fields;
}
}
diff --git a/src/infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php b/src/infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php
index 4f30ad089e..505c7ecfd1 100644
--- a/src/infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php
+++ b/src/infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php
@@ -1,111 +1,111 @@
<?php
final class PhutilRemarkupLiteralBlockRule extends PhutilRemarkupBlockRule {
public function getPriority() {
return 450;
}
public function getMatchingLineCount(array $lines, $cursor) {
- // NOTE: We're consuming all continguous blocks of %%% literals, so this:
+ // NOTE: We're consuming all contiguous blocks of %%% literals, so this:
//
// %%%a%%%
// %%%b%%%
//
// ...is equivalent to:
//
// %%%a
// b%%%
//
// If they are separated by a blank newline, they are parsed as two
// different blocks. This more clearly represents the original text in the
// output text and assists automated escaping of blocks coming into the
// system.
$start_pattern = '(^\s*%%%)';
$end_pattern = '(%%%\s*$)';
$trivial_pattern = '(^\s*%%%\s*$)';
if (!preg_match($start_pattern, $lines[$cursor])) {
return 0;
}
$start_cursor = $cursor;
$found_empty = false;
$block_start = null;
while (true) {
if (!isset($lines[$cursor])) {
break;
}
$line = $lines[$cursor];
if ($block_start === null) {
$is_start = preg_match($start_pattern, $line);
// If we've matched a block and then consumed one or more empty lines
// after it, stop merging more blocks into the match.
if ($found_empty) {
break;
}
if ($is_start) {
$block_start = $cursor;
}
}
if ($block_start !== null) {
$is_end = preg_match($end_pattern, $line);
// If a line contains only "%%%", it will match both the start and
// end patterns, but it only counts as a block start.
if ($is_end && ($cursor === $block_start)) {
$is_trivial = preg_match($trivial_pattern, $line);
if ($is_trivial) {
$is_end = false;
}
}
if ($is_end) {
$block_start = null;
$cursor++;
continue;
}
}
if ($block_start === null) {
if (strlen(trim($line))) {
break;
}
$found_empty = true;
}
$cursor++;
}
return ($cursor - $start_cursor);
}
public function markupText($text, $children) {
$text = rtrim($text);
$text = phutil_split_lines($text, $retain_endings = true);
foreach ($text as $key => $line) {
$line = preg_replace('/^\s*%%%/', '', $line);
$line = preg_replace('/%%%(\s*)\z/', '\1', $line);
$text[$key] = $line;
}
if ($this->getEngine()->isTextMode()) {
return implode('', $text);
}
return phutil_tag(
'p',
array(
'class' => 'remarkup-literal',
),
phutil_implode_html(phutil_tag('br', array()), $text));
}
}
diff --git a/src/infrastructure/markup/syntax/highlighter/xhpast/PhutilXHPASTSyntaxHighlighterFuture.php b/src/infrastructure/markup/syntax/highlighter/xhpast/PhutilXHPASTSyntaxHighlighterFuture.php
index c34153ba42..f617122ec8 100644
--- a/src/infrastructure/markup/syntax/highlighter/xhpast/PhutilXHPASTSyntaxHighlighterFuture.php
+++ b/src/infrastructure/markup/syntax/highlighter/xhpast/PhutilXHPASTSyntaxHighlighterFuture.php
@@ -1,262 +1,262 @@
<?php
final class PhutilXHPASTSyntaxHighlighterFuture extends FutureProxy {
private $source;
private $scrub;
public function __construct(Future $proxied, $source, $scrub = false) {
parent::__construct($proxied);
$this->source = $source;
$this->scrub = $scrub;
}
protected function didReceiveResult($result) {
try {
return $this->applyXHPHighlight($result);
} catch (Exception $ex) {
// XHP can't highlight source that isn't syntactically valid. Fall back
// to the fragment lexer.
$source = ($this->scrub
? preg_replace('/^.*\n/', '', $this->source)
: $this->source);
return id(new PhutilLexerSyntaxHighlighter())
->setConfig('lexer', new PhutilPHPFragmentLexer())
->setConfig('language', 'php')
->getHighlightFuture($source)
->resolve();
}
}
private function applyXHPHighlight($result) {
// We perform two passes here: one using the AST to find symbols we care
// about -- particularly, class names and function names. These are used
- // in the crossreference stuff to link into Diffusion. After we've done our
+ // in the cross-reference stuff to link into Diffusion. After we've done our
// AST pass, we do a followup pass on the token stream to catch all the
// simple stuff like strings and comments.
$tree = XHPASTTree::newFromDataAndResolvedExecFuture(
$this->source,
$result);
$root = $tree->getRootNode();
$tokens = $root->getTokens();
$interesting_symbols = $this->findInterestingSymbols($root);
if ($this->scrub) {
// If we're scrubbing, we prepended "<?php\n" to the text to force the
// highlighter to treat it as PHP source. Now, we need to remove that.
$ok = false;
if (count($tokens) >= 2) {
if ($tokens[0]->getTypeName() === 'T_OPEN_TAG') {
if ($tokens[1]->getTypeName() === 'T_WHITESPACE') {
$ok = true;
}
}
}
if (!$ok) {
throw new Exception(
pht(
'Expected T_OPEN_TAG, T_WHITESPACE tokens at head of results '.
'for highlighting parse of PHP snippet.'));
}
// Remove the "<?php".
unset($tokens[0]);
$value = $tokens[1]->getValue();
if ((strlen($value) < 1) || ($value[0] != "\n")) {
throw new Exception(
pht(
'Expected "\\n" at beginning of T_WHITESPACE token at head of '.
'tokens for highlighting parse of PHP snippet.'));
}
$value = substr($value, 1);
$tokens[1]->overwriteValue($value);
}
$out = array();
foreach ($tokens as $key => $token) {
$value = $token->getValue();
$class = null;
$multi = false;
$attrs = array();
if (isset($interesting_symbols[$key])) {
$sym = $interesting_symbols[$key];
$class = $sym[0];
$attrs['data-symbol-context'] = idx($sym, 'context');
$attrs['data-symbol-name'] = idx($sym, 'symbol');
} else {
switch ($token->getTypeName()) {
case 'T_WHITESPACE':
break;
case 'T_DOC_COMMENT':
$class = 'dc';
$multi = true;
break;
case 'T_COMMENT':
$class = 'c';
$multi = true;
break;
case 'T_CONSTANT_ENCAPSED_STRING':
case 'T_ENCAPSED_AND_WHITESPACE':
case 'T_INLINE_HTML':
$class = 's';
$multi = true;
break;
case 'T_VARIABLE':
$class = 'nv';
break;
case 'T_OPEN_TAG':
case 'T_OPEN_TAG_WITH_ECHO':
case 'T_CLOSE_TAG':
$class = 'o';
break;
case 'T_LNUMBER':
case 'T_DNUMBER':
$class = 'm';
break;
case 'T_STRING':
static $magic = array(
'true' => true,
'false' => true,
'null' => true,
);
if (isset($magic[strtolower($value)])) {
$class = 'k';
break;
}
$class = 'nx';
break;
default:
$class = 'k';
break;
}
}
if ($class) {
$attrs['class'] = $class;
if ($multi) {
// If the token may have multiple lines in it, make sure each
// <span> crosses no more than one line so the lines can be put
// in a table, etc., later.
$value = phutil_split_lines($value, $retain_endings = true);
} else {
$value = array($value);
}
foreach ($value as $val) {
$out[] = phutil_tag('span', $attrs, $val);
}
} else {
$out[] = $value;
}
}
return phutil_implode_html('', $out);
}
private function findInterestingSymbols(XHPASTNode $root) {
// Class name symbols appear in:
// class X extends X implements X, X { ... }
// new X();
// $x instanceof X
// catch (X $x)
// function f(X $x)
// X::f();
// X::$m;
// X::CONST;
// These are PHP builtin tokens which can appear in a classname context.
// Don't link them since they don't go anywhere useful.
static $builtin_class_tokens = array(
'self' => true,
'parent' => true,
'static' => true,
);
// Fortunately XHPAST puts all of these in a special node type so it's
// easy to find them.
$result_map = array();
$class_names = $root->selectDescendantsOfType('n_CLASS_NAME');
foreach ($class_names as $class_name) {
foreach ($class_name->getTokens() as $key => $token) {
if (isset($builtin_class_tokens[$token->getValue()])) {
// This is something like "self::method()".
continue;
}
$result_map[$key] = array(
'nc', // "Name, Class"
'symbol' => $class_name->getConcreteString(),
);
}
}
// Function name symbols appear in:
// f()
$function_calls = $root->selectDescendantsOfType('n_FUNCTION_CALL');
foreach ($function_calls as $call) {
$call = $call->getChildByIndex(0);
if ($call->getTypeName() == 'n_SYMBOL_NAME') {
// This is a normal function call, not some $f() shenanigans.
foreach ($call->getTokens() as $key => $token) {
$result_map[$key] = array(
'nf', // "Name, Function"
'symbol' => $call->getConcreteString(),
);
}
}
}
// Upon encountering $x->y, link y without context, since $x is unknown.
$prop_access = $root->selectDescendantsOfType('n_OBJECT_PROPERTY_ACCESS');
foreach ($prop_access as $access) {
$right = $access->getChildByIndex(1);
if ($right->getTypeName() == 'n_INDEX_ACCESS') {
// otherwise $x->y[0] doesn't get highlighted
$right = $right->getChildByIndex(0);
}
if ($right->getTypeName() == 'n_STRING') {
foreach ($right->getTokens() as $key => $token) {
$result_map[$key] = array(
'na', // "Name, Attribute"
'symbol' => $right->getConcreteString(),
);
}
}
}
// Upon encountering x::y, try to link y with context x.
$static_access = $root->selectDescendantsOfType('n_CLASS_STATIC_ACCESS');
foreach ($static_access as $access) {
$class = $access->getChildByIndex(0);
$right = $access->getChildByIndex(1);
if ($class->getTypeName() == 'n_CLASS_NAME' &&
($right->getTypeName() == 'n_STRING' ||
$right->getTypeName() == 'n_VARIABLE')) {
$classname = head($class->getTokens())->getValue();
$result = array(
'na',
'symbol' => ltrim($right->getConcreteString(), '$'),
);
if (!isset($builtin_class_tokens[$classname])) {
$result['context'] = $classname;
}
foreach ($right->getTokens() as $key => $token) {
$result_map[$key] = $result;
}
}
}
return $result_map;
}
}
diff --git a/src/infrastructure/parser/PhutilPygmentizeParser.php b/src/infrastructure/parser/PhutilPygmentizeParser.php
index 35c1d5739c..39b0e441c5 100644
--- a/src/infrastructure/parser/PhutilPygmentizeParser.php
+++ b/src/infrastructure/parser/PhutilPygmentizeParser.php
@@ -1,83 +1,83 @@
<?php
/**
* Parser that converts `pygmetize` output or similar HTML blocks from "class"
* attributes to "style" attributes.
*/
final class PhutilPygmentizeParser extends Phobject {
private $map = array();
public function setMap(array $map) {
$this->map = $map;
return $this;
}
public function getMap() {
return $this->map;
}
public function parse($block) {
$class_look = 'class="';
$class_len = strlen($class_look);
$class_start = null;
$map = $this->map;
$len = strlen($block);
$out = '';
$mode = 'text';
for ($ii = 0; $ii < $len; $ii++) {
$c = $block[$ii];
switch ($mode) {
case 'text':
- // We're in general text between tags, and just passing characers
+ // We're in general text between tags, and just passing characters
// through unmodified.
if ($c == '<') {
$mode = 'tag';
}
$out .= $c;
break;
case 'tag':
// We're inside a tag, and looking for `class="` so we can rewrite
// it.
if ($c == '>') {
$mode = 'text';
}
if ($c == 'c') {
if (!substr_compare($block, $class_look, $ii, $class_len)) {
$mode = 'class';
$ii += $class_len;
$class_start = $ii;
}
}
if ($mode != 'class') {
$out .= $c;
}
break;
case 'class':
// We're inside a `class="..."` tag, and looking for the ending quote
// so we can replace it.
if ($c == '"') {
$class = substr($block, $class_start, $ii - $class_start);
// If this class is present in the map, rewrite it into an inline
// style attribute.
if (isset($map[$class])) {
$out .= 'style="'.phutil_escape_html($map[$class]).'"';
} else {
$out .= 'class="'.$class.'"';
}
$mode = 'tag';
}
break;
}
}
return $out;
}
}
diff --git a/src/view/control/AphrontTableView.php b/src/view/control/AphrontTableView.php
index 0e06b97b4a..ab1f6be0ed 100644
--- a/src/view/control/AphrontTableView.php
+++ b/src/view/control/AphrontTableView.php
@@ -1,414 +1,414 @@
<?php
final class AphrontTableView extends AphrontView {
protected $data;
protected $headers;
protected $shortHeaders = array();
protected $rowClasses = array();
protected $columnClasses = array();
protected $cellClasses = array();
protected $zebraStripes = true;
protected $noDataString;
protected $className;
protected $notice;
protected $columnVisibility = array();
private $deviceVisibility = array();
private $columnWidths = array();
protected $sortURI;
protected $sortParam;
protected $sortSelected;
protected $sortReverse;
protected $sortValues = array();
private $deviceReadyTable;
private $rowDividers = array();
public function __construct(array $data) {
$this->data = $data;
}
public function setHeaders(array $headers) {
$this->headers = $headers;
return $this;
}
public function setColumnClasses(array $column_classes) {
$this->columnClasses = $column_classes;
return $this;
}
public function setRowClasses(array $row_classes) {
$this->rowClasses = $row_classes;
return $this;
}
public function setCellClasses(array $cell_classes) {
$this->cellClasses = $cell_classes;
return $this;
}
public function setColumnWidths(array $widths) {
$this->columnWidths = $widths;
return $this;
}
public function setRowDividers(array $dividers) {
$this->rowDividers = $dividers;
return $this;
}
public function setNoDataString($no_data_string) {
$this->noDataString = $no_data_string;
return $this;
}
public function setClassName($class_name) {
$this->className = $class_name;
return $this;
}
public function setNotice($notice) {
$this->notice = $notice;
return $this;
}
public function setZebraStripes($zebra_stripes) {
$this->zebraStripes = $zebra_stripes;
return $this;
}
public function setColumnVisibility(array $visibility) {
$this->columnVisibility = $visibility;
return $this;
}
public function setDeviceVisibility(array $device_visibility) {
$this->deviceVisibility = $device_visibility;
return $this;
}
public function setDeviceReadyTable($ready) {
$this->deviceReadyTable = $ready;
return $this;
}
public function setShortHeaders(array $short_headers) {
$this->shortHeaders = $short_headers;
return $this;
}
/**
* Parse a sorting parameter:
*
* list($sort, $reverse) = AphrontTableView::parseSortParam($sort_param);
*
* @param string Sort request parameter.
* @return pair Sort value, sort direction.
*/
public static function parseSort($sort) {
return array(ltrim($sort, '-'), preg_match('/^-/', $sort));
}
public function makeSortable(
PhutilURI $base_uri,
$param,
$selected,
$reverse,
array $sort_values) {
$this->sortURI = $base_uri;
$this->sortParam = $param;
$this->sortSelected = $selected;
$this->sortReverse = $reverse;
$this->sortValues = array_values($sort_values);
return $this;
}
public function render() {
require_celerity_resource('aphront-table-view-css');
$table = array();
$col_classes = array();
foreach ($this->columnClasses as $key => $class) {
if (phutil_nonempty_string($class)) {
$col_classes[] = $class;
} else {
$col_classes[] = null;
}
}
$visibility = array_values($this->columnVisibility);
$device_visibility = array_values($this->deviceVisibility);
$column_widths = $this->columnWidths;
$headers = $this->headers;
$short_headers = $this->shortHeaders;
$sort_values = $this->sortValues;
if ($headers) {
while (count($headers) > count($visibility)) {
$visibility[] = true;
}
while (count($headers) > count($device_visibility)) {
$device_visibility[] = true;
}
while (count($headers) > count($short_headers)) {
$short_headers[] = null;
}
while (count($headers) > count($sort_values)) {
$sort_values[] = null;
}
$tr = array();
foreach ($headers as $col_num => $header) {
if (!$visibility[$col_num]) {
continue;
}
$classes = array();
if (!empty($col_classes[$col_num])) {
$classes[] = $col_classes[$col_num];
}
if (empty($device_visibility[$col_num])) {
$classes[] = 'aphront-table-view-nodevice';
}
if ($sort_values[$col_num] !== null) {
$classes[] = 'aphront-table-view-sortable';
$sort_value = $sort_values[$col_num];
$sort_glyph_class = 'aphront-table-down-sort';
if ($sort_value == $this->sortSelected) {
if ($this->sortReverse) {
$sort_glyph_class = 'aphront-table-up-sort';
} else if (!$this->sortReverse) {
$sort_value = '-'.$sort_value;
}
$classes[] = 'aphront-table-view-sortable-selected';
}
$sort_glyph = phutil_tag(
'span',
array(
'class' => $sort_glyph_class,
),
'');
$header = phutil_tag(
'a',
array(
'href' => $this->sortURI->alter($this->sortParam, $sort_value),
'class' => 'aphront-table-view-sort-link',
),
array(
$header,
' ',
$sort_glyph,
));
}
if ($classes) {
$class = implode(' ', $classes);
} else {
$class = null;
}
if ($short_headers[$col_num] !== null) {
$header_nodevice = phutil_tag(
'span',
array(
'class' => 'aphront-table-view-nodevice',
),
$header);
$header_device = phutil_tag(
'span',
array(
'class' => 'aphront-table-view-device',
),
$short_headers[$col_num]);
$header = hsprintf('%s %s', $header_nodevice, $header_device);
}
$style = null;
if (isset($column_widths[$col_num])) {
$style = 'width: '.$column_widths[$col_num].';';
}
$tr[] = phutil_tag(
'th',
array(
'class' => $class,
'style' => $style,
),
$header);
}
$table[] = phutil_tag('tr', array(), $tr);
}
foreach ($col_classes as $key => $value) {
if (isset($sort_values[$key]) &&
($sort_values[$key] == $this->sortSelected)) {
$value = trim($value.' sorted-column');
}
if ($value !== null) {
$col_classes[$key] = $value;
}
}
$dividers = $this->rowDividers;
$data = $this->data;
if ($data) {
$row_num = 0;
$row_idx = 0;
foreach ($data as $row) {
$is_divider = !empty($dividers[$row_num]);
$row_size = count($row);
while (count($row) > count($col_classes)) {
$col_classes[] = null;
}
while (count($row) > count($visibility)) {
$visibility[] = true;
}
while (count($row) > count($device_visibility)) {
$device_visibility[] = true;
}
$tr = array();
// NOTE: Use of a separate column counter is to allow this to work
// correctly if the row data has string or non-sequential keys.
$col_num = 0;
foreach ($row as $value) {
if (!$visibility[$col_num]) {
++$col_num;
continue;
}
$class = $col_classes[$col_num];
if (empty($device_visibility[$col_num])) {
$class = trim($class.' aphront-table-view-nodevice');
}
if (!empty($this->cellClasses[$row_num][$col_num])) {
$class = trim($class.' '.$this->cellClasses[$row_num][$col_num]);
}
if ($is_divider) {
$tr[] = phutil_tag(
'td',
array(
'class' => 'row-divider',
'colspan' => count($visibility),
),
$value);
$row_idx = -1;
break;
}
$tr[] = phutil_tag(
'td',
array(
'class' => $class,
),
$value);
++$col_num;
}
$class = idx($this->rowClasses, $row_num);
if ($this->zebraStripes && ($row_idx % 2)) {
if ($class !== null) {
$class = 'alt alt-'.$class;
} else {
$class = 'alt';
}
}
$table[] = phutil_tag('tr', array('class' => $class), $tr);
++$row_num;
++$row_idx;
}
} else {
$colspan = max(count(array_filter($visibility)), 1);
$table[] = phutil_tag(
'tr',
array('class' => 'no-data'),
phutil_tag(
'td',
array('colspan' => $colspan),
coalesce($this->noDataString, pht('No data available.'))));
}
$classes = array();
$classes[] = 'aphront-table-view';
if ($this->className !== null) {
$classes[] = $this->className;
}
if ($this->deviceReadyTable) {
$classes[] = 'aphront-table-view-device-ready';
}
if ($this->columnWidths) {
$classes[] = 'aphront-table-view-fixed';
}
$notice = null;
if ($this->notice) {
$notice = phutil_tag(
'div',
array(
'class' => 'aphront-table-notice',
),
$this->notice);
}
$html = phutil_tag(
'table',
array(
'class' => implode(' ', $classes),
),
$table);
return phutil_tag_div(
'aphront-table-wrap',
array(
$notice,
$html,
));
}
public static function renderSingleDisplayLine($line) {
// TODO: Is there a cleaner way to do this? We use a relative div with
// overflow hidden to provide the bounds, and an absolute span with
// white-space: pre to prevent wrapping. We need to append a character
// (&nbsp; -- nonbreaking space) afterward to give the bounds div height
// (alternatively, we could hard-code the line height). This is gross but
- // it's not clear that there's a better appraoch.
+ // it's not clear that there's a better approach.
return phutil_tag(
'div',
array(
'class' => 'single-display-line-bounds',
),
array(
phutil_tag(
'span',
array(
'class' => 'single-display-line-content',
),
$line),
"\xC2\xA0",
));
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Jan 19, 17:33 (1 w, 5 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1127006
Default Alt Text
(414 KB)

Event Timeline