Page MenuHomestyx hydra

No OneTemporary

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/aphront/AphrontRequest.php b/src/aphront/AphrontRequest.php
index d88c02af6d..554cd449aa 100644
--- a/src/aphront/AphrontRequest.php
+++ b/src/aphront/AphrontRequest.php
@@ -1,972 +1,972 @@
<?php
/**
* @task data Accessing Request Data
* @task cookie Managing Cookies
* @task cluster Working With a Phabricator Cluster
*/
final class AphrontRequest extends Phobject {
// NOTE: These magic request-type parameters are automatically included in
// certain requests (e.g., by phabricator_form(), JX.Request,
// JX.Workflow, and ConduitClient) and help us figure out what sort of
// response the client expects.
const TYPE_AJAX = '__ajax__';
const TYPE_FORM = '__form__';
const TYPE_CONDUIT = '__conduit__';
const TYPE_WORKFLOW = '__wflow__';
const TYPE_CONTINUE = '__continue__';
const TYPE_PREVIEW = '__preview__';
const TYPE_HISEC = '__hisec__';
const TYPE_QUICKSAND = '__quicksand__';
private $host;
private $path;
private $requestData;
private $user;
private $applicationConfiguration;
private $site;
private $controller;
private $uriData = array();
private $cookiePrefix;
private $submitKey;
public function __construct($host, $path) {
$this->host = $host;
$this->path = $path;
}
public function setURIMap(array $uri_data) {
$this->uriData = $uri_data;
return $this;
}
public function getURIMap() {
return $this->uriData;
}
public function getURIData($key, $default = null) {
return idx($this->uriData, $key, $default);
}
/**
* Read line range parameter data from the request.
*
* Applications like Paste, Diffusion, and Harbormaster use "$12-14" in the
* URI to allow users to link to particular lines.
*
* @param string $key URI data key to pull line range information from.
* @param int|null $limit Maximum length of the range.
* @return null|pair<int, int> Null, or beginning and end of the range.
*/
public function getURILineRange($key, $limit) {
$range = $this->getURIData($key);
return self::parseURILineRange($range, $limit);
}
public static function parseURILineRange($range, $limit) {
if (!phutil_nonempty_string($range)) {
return null;
}
$range = explode('-', $range, 2);
foreach ($range as $key => $value) {
$value = (int)$value;
if (!$value) {
// If either value is "0", discard the range.
return null;
}
$range[$key] = $value;
}
// If the range is like "$10", treat it like "$10-10".
if (count($range) == 1) {
$range[] = head($range);
}
// If the range is "$7-5", treat it like "$5-7".
if ($range[1] < $range[0]) {
$range = array_reverse($range);
}
// If the user specified something like "$1-999999999" and we have a limit,
// clamp it to a more reasonable range.
if ($limit !== null) {
if ($range[1] - $range[0] > $limit) {
$range[1] = $range[0] + $limit;
}
}
return $range;
}
public function setApplicationConfiguration(
$application_configuration) {
$this->applicationConfiguration = $application_configuration;
return $this;
}
public function getApplicationConfiguration() {
return $this->applicationConfiguration;
}
public function setPath($path) {
$this->path = $path;
return $this;
}
public function getPath() {
return $this->path;
}
public function getHost() {
// The "Host" header may include a port number, or may be a malicious
// header in the form "realdomain.com:ignored@evil.com". Invoke the full
// parser to extract the real domain correctly. See here for coverage of
// a similar issue in Django:
//
// https://www.djangoproject.com/weblog/2012/oct/17/security/
$uri = new PhutilURI('http://'.$this->host);
return $uri->getDomain();
}
public function setSite(AphrontSite $site) {
$this->site = $site;
return $this;
}
public function getSite() {
return $this->site;
}
public function setController(AphrontController $controller) {
$this->controller = $controller;
return $this;
}
public function getController() {
return $this->controller;
}
/* -( Accessing Request Data )--------------------------------------------- */
/**
* @task data
*/
public function setRequestData(array $request_data) {
$this->requestData = $request_data;
return $this;
}
/**
* @task data
*/
public function getRequestData() {
return $this->requestData;
}
/**
* @task data
*/
public function getInt($name, $default = null) {
if (isset($this->requestData[$name])) {
// Converting from array to int is "undefined". Don't rely on whatever
// PHP decides to do.
if (is_array($this->requestData[$name])) {
return $default;
}
return (int)$this->requestData[$name];
} else {
return $default;
}
}
/**
* @task data
*/
public function getBool($name, $default = null) {
if (isset($this->requestData[$name])) {
if ($this->requestData[$name] === 'true') {
return true;
} else if ($this->requestData[$name] === 'false') {
return false;
} else {
return (bool)$this->requestData[$name];
}
} else {
return $default;
}
}
/**
* @task data
*/
public function getStr($name, $default = null) {
if (isset($this->requestData[$name])) {
$str = (string)$this->requestData[$name];
// Normalize newline craziness.
$str = str_replace(
array("\r\n", "\r"),
array("\n", "\n"),
$str);
return $str;
} else {
return $default;
}
}
/**
* @task data
*/
public function getJSONMap($name, $default = array()) {
if (!isset($this->requestData[$name])) {
return $default;
}
$raw_data = phutil_string_cast($this->requestData[$name]);
$raw_data = trim($raw_data);
if (!phutil_nonempty_string($raw_data)) {
return $default;
}
if ($raw_data[0] !== '{') {
throw new Exception(
pht(
'Request parameter "%s" is not formatted properly. Expected a '.
'JSON object, but value does not start with "{".',
$name));
}
try {
$json_object = phutil_json_decode($raw_data);
} catch (PhutilJSONParserException $ex) {
throw new Exception(
pht(
'Request parameter "%s" is not formatted properly. Expected a '.
'JSON object, but encountered a syntax error: %s.',
$name,
$ex->getMessage()));
}
return $json_object;
}
/**
* @task data
*/
public function getArr($name, $default = array()) {
if (isset($this->requestData[$name]) &&
is_array($this->requestData[$name])) {
return $this->requestData[$name];
} else {
return $default;
}
}
/**
* @task data
*/
public function getStrList($name, $default = array()) {
if (!isset($this->requestData[$name])) {
return $default;
}
$list = $this->getStr($name);
$list = preg_split('/[\s,]+/', $list, $limit = -1, PREG_SPLIT_NO_EMPTY);
return $list;
}
/**
* @task data
*/
public function getExists($name) {
return array_key_exists($name, $this->requestData);
}
public function getFileExists($name) {
return isset($_FILES[$name]) &&
(idx($_FILES[$name], 'error') !== UPLOAD_ERR_NO_FILE);
}
public function isHTTPGet() {
return ($_SERVER['REQUEST_METHOD'] == 'GET');
}
public function isHTTPPost() {
return ($_SERVER['REQUEST_METHOD'] == 'POST');
}
public function isAjax() {
return $this->getExists(self::TYPE_AJAX) && !$this->isQuicksand();
}
public function isWorkflow() {
return $this->getExists(self::TYPE_WORKFLOW) && !$this->isQuicksand();
}
public function isQuicksand() {
return $this->getExists(self::TYPE_QUICKSAND);
}
public function isConduit() {
return $this->getExists(self::TYPE_CONDUIT);
}
public static function getCSRFTokenName() {
return '__csrf__';
}
public static function getCSRFHeaderName() {
return 'X-Phabricator-Csrf';
}
public static function getViaHeaderName() {
return 'X-Phabricator-Via';
}
public function validateCSRF() {
$token_name = self::getCSRFTokenName();
$token = $this->getStr($token_name);
// No token in the request, check the HTTP header which is added for Ajax
// requests.
if (empty($token)) {
$token = self::getHTTPHeader(self::getCSRFHeaderName());
}
$valid = $this->getUser()->validateCSRFToken($token);
if (!$valid) {
// Add some diagnostic details so we can figure out if some CSRF issues
// are JS problems or people accessing Ajax URIs directly with their
// browsers.
$info = array();
$info[] = pht(
'You are trying to save some data to permanent storage, but the '.
'request your browser made included an incorrect token. Reload the '.
'page and try again. You may need to clear your cookies.');
if ($this->isAjax()) {
$info[] = pht('This was an Ajax request.');
} else {
$info[] = pht('This was a Web request.');
}
if ($token) {
$info[] = pht('This request had an invalid CSRF token.');
} else {
$info[] = pht('This request had no CSRF token.');
}
// Give a more detailed explanation of how to avoid the exception
// in developer mode.
if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
// TODO: Clean this up, see T1921.
$info[] = pht(
"To avoid this error, use %s to construct forms. If you are already ".
"using %s, make sure the form 'action' uses a relative URI (i.e., ".
"begins with a '%s'). Forms using absolute URIs do not include CSRF ".
"tokens, to prevent leaking tokens to external sites.\n\n".
"If this page performs writes which do not require CSRF protection ".
"(usually, filling caches or logging), you can use %s to ".
"temporarily bypass CSRF protection while writing. You should use ".
"this only for writes which can not be protected with normal CSRF ".
"mechanisms.\n\n".
"Some UI elements (like %s) also have methods which will allow you ".
"to render links as forms (like %s).",
'phabricator_form()',
'phabricator_form()',
'/',
'AphrontWriteGuard::beginScopedUnguardedWrites()',
'PhabricatorActionListView',
'setRenderAsForm(true)');
}
$message = implode("\n", $info);
// This should only be able to happen if you load a form, pull your
// internet for 6 hours, and then reconnect and immediately submit,
// but give the user some indication of what happened since the workflow
// is incredibly confusing otherwise.
throw new AphrontMalformedRequestException(
pht('Invalid Request (CSRF)'),
$message,
true);
}
return true;
}
public function isFormPost() {
$post = $this->getExists(self::TYPE_FORM) &&
!$this->getExists(self::TYPE_HISEC) &&
$this->isHTTPPost();
if (!$post) {
return false;
}
return $this->validateCSRF();
}
public function hasCSRF() {
try {
$this->validateCSRF();
return true;
} catch (AphrontMalformedRequestException $ex) {
return false;
}
}
public function isFormOrHisecPost() {
$post = $this->getExists(self::TYPE_FORM) &&
$this->isHTTPPost();
if (!$post) {
return false;
}
return $this->validateCSRF();
}
public function setCookiePrefix($prefix) {
$this->cookiePrefix = $prefix;
return $this;
}
private function getPrefixedCookieName($name) {
if (phutil_nonempty_string($this->cookiePrefix)) {
return $this->cookiePrefix.'_'.$name;
} else {
return $name;
}
}
public function getCookie($name, $default = null) {
$name = $this->getPrefixedCookieName($name);
$value = idx($_COOKIE, $name, $default);
// Internally, PHP deletes cookies by setting them to the value 'deleted'
// with an expiration date in the past.
// At least in Safari, the browser may send this cookie anyway in some
// circumstances. After logging out, the 302'd GET to /login/ consistently
// includes deleted cookies on my local install. If a cookie value is
// literally 'deleted', pretend it does not exist.
if ($value === 'deleted') {
return null;
}
return $value;
}
public function clearCookie($name) {
$this->setCookieWithExpiration($name, '', time() - (60 * 60 * 24 * 30));
unset($_COOKIE[$name]);
}
/**
* Get the domain which cookies should be set on for this request, or null
* if the request does not correspond to a valid cookie domain.
*
* @return PhutilURI|null Domain URI, or null if no valid domain exists.
*
* @task cookie
*/
private function getCookieDomainURI() {
if (PhabricatorEnv::getEnvConfig('security.require-https') &&
!$this->isHTTPS()) {
return null;
}
$host = $this->getHost();
// If there's no base domain configured, just use whatever the request
// domain is. This makes setup easier, and we'll tell administrators to
// configure a base domain during the setup process.
$base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
if (!phutil_nonempty_string($base_uri)) {
return new PhutilURI('http://'.$host.'/');
}
$alternates = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris');
$allowed_uris = array_merge(
array($base_uri),
$alternates);
foreach ($allowed_uris as $allowed_uri) {
$uri = new PhutilURI($allowed_uri);
if ($uri->getDomain() == $host) {
return $uri;
}
}
return null;
}
/**
* Determine if security policy rules will allow cookies to be set when
* responding to the request.
*
* @return bool True if setCookie() will succeed. If this method returns
* false, setCookie() will throw.
*
* @task cookie
*/
public function canSetCookies() {
return (bool)$this->getCookieDomainURI();
}
/**
* Set a cookie which does not expire for a long time.
*
* To set a temporary cookie, see @{method:setTemporaryCookie}.
*
* @param string $name Cookie name.
* @param string $value Cookie value.
- * @return this
+ * @return $this
* @task cookie
*/
public function setCookie($name, $value) {
$far_future = time() + (60 * 60 * 24 * 365 * 5);
return $this->setCookieWithExpiration($name, $value, $far_future);
}
/**
* Set a cookie which expires soon.
*
* To set a durable cookie, see @{method:setCookie}.
*
* @param string $name Cookie name.
* @param string $value Cookie value.
- * @return this
+ * @return $this
* @task cookie
*/
public function setTemporaryCookie($name, $value) {
return $this->setCookieWithExpiration($name, $value, 0);
}
/**
* Set a cookie with a given expiration policy.
*
* @param string $name Cookie name.
* @param string $value Cookie value.
* @param int $expire Epoch timestamp for cookie expiration.
- * @return this
+ * @return $this
* @task cookie
*/
private function setCookieWithExpiration(
$name,
$value,
$expire) {
$is_secure = false;
$base_domain_uri = $this->getCookieDomainURI();
if (!$base_domain_uri) {
$configured_as = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
$accessed_as = $this->getHost();
throw new AphrontMalformedRequestException(
pht('Bad Host Header'),
pht(
'This server is configured as "%s", but you are using the domain '.
'name "%s" to access a page which is trying to set a cookie. '.
'Access this service on the configured primary domain or a '.
'configured alternate domain. Cookies will not be set on other '.
'domains for security reasons.',
$configured_as,
$accessed_as),
true);
}
$base_domain = $base_domain_uri->getDomain();
$is_secure = ($base_domain_uri->getProtocol() == 'https');
$name = $this->getPrefixedCookieName($name);
if (php_sapi_name() == 'cli') {
// Do nothing, to avoid triggering "Cannot modify header information"
// warnings.
// TODO: This is effectively a test for whether we're running in a unit
// test or not. Move this actual call to HTTPSink?
} else {
setcookie(
$name,
$value,
$expire,
$path = '/',
$base_domain,
$is_secure,
$http_only = true);
}
$_COOKIE[$name] = $value;
return $this;
}
public function setUser($user) {
$this->user = $user;
return $this;
}
public function getUser() {
return $this->user;
}
public function getViewer() {
return $this->user;
}
public function getRequestURI() {
$uri_path = phutil_escape_uri($this->getPath());
$uri_query = idx($_SERVER, 'QUERY_STRING', '');
return id(new PhutilURI($uri_path.'?'.$uri_query))
->removeQueryParam('__path__');
}
public function getAbsoluteRequestURI() {
$uri = $this->getRequestURI();
$uri->setDomain($this->getHost());
if ($this->isHTTPS()) {
$protocol = 'https';
} else {
$protocol = 'http';
}
$uri->setProtocol($protocol);
// If the request used a nonstandard port, preserve it while building the
// absolute URI.
// First, get the default port for the request protocol.
$default_port = id(new PhutilURI($protocol.'://example.com/'))
->getPortWithProtocolDefault();
// NOTE: See note in getHost() about malicious "Host" headers. This
// construction defuses some obscure potential attacks.
$port = id(new PhutilURI($protocol.'://'.$this->host))
->getPort();
if (($port !== null) && ($port !== $default_port)) {
$uri->setPort($port);
}
return $uri;
}
public function isDialogFormPost() {
return $this->isFormPost() && $this->getStr('__dialog__');
}
public function getRemoteAddress() {
$address = PhabricatorEnv::getRemoteAddress();
if (!$address) {
return null;
}
return $address->getAddress();
}
public function isHTTPS() {
if (empty($_SERVER['HTTPS'])) {
return false;
}
if (!strcasecmp($_SERVER['HTTPS'], 'off')) {
return false;
}
return true;
}
public function isContinueRequest() {
return $this->isFormOrHisecPost() && $this->getStr('__continue__');
}
public function isPreviewRequest() {
return $this->isFormPost() && $this->getStr('__preview__');
}
/**
* Get application request parameters in a flattened form suitable for
* inclusion in an HTTP request, excluding parameters with special meanings.
* This is primarily useful if you want to ask the user for more input and
* then resubmit their request.
*
* @return dict<string, string> Original request parameters.
*/
public function getPassthroughRequestParameters($include_quicksand = false) {
return self::flattenData(
$this->getPassthroughRequestData($include_quicksand));
}
/**
* Get request data other than "magic" parameters.
*
* @return dict<string, wild> Request data, with magic filtered out.
*/
public function getPassthroughRequestData($include_quicksand = false) {
$data = $this->getRequestData();
// Remove magic parameters like __dialog__ and __ajax__.
foreach ($data as $key => $value) {
if ($include_quicksand && $key == self::TYPE_QUICKSAND) {
continue;
}
if (!strncmp($key, '__', 2)) {
unset($data[$key]);
}
}
return $data;
}
/**
* Flatten an array of key-value pairs (possibly including arrays as values)
* into a list of key-value pairs suitable for submitting via HTTP request
* (with arrays flattened).
*
* @param dict<string, wild> $data Data to flatten.
* @return dict<string, string> Flat data suitable for inclusion in an HTTP
* request.
*/
public static function flattenData(array $data) {
$result = array();
foreach ($data as $key => $value) {
if (is_array($value)) {
foreach (self::flattenData($value) as $fkey => $fvalue) {
$fkey = '['.preg_replace('/(?=\[)|$/', ']', $fkey, $limit = 1);
$result[$key.$fkey] = $fvalue;
}
} else {
$result[$key] = (string)$value;
}
}
ksort($result);
return $result;
}
/**
* Read the value of an HTTP header from `$_SERVER`, or a similar datasource.
*
* This function accepts a canonical header name, like `"Accept-Encoding"`,
* and looks up the appropriate value in `$_SERVER` (in this case,
* `"HTTP_ACCEPT_ENCODING"`).
*
* @param string $name Canonical header name, like
`"Accept-Encoding"`.
* @param wild $default (optional) Default value to return if
header is not present.
* @param array $data (optional) Read this instead of `$_SERVER`.
* @return string|wild Header value if present, or `$default` if not.
*/
public static function getHTTPHeader($name, $default = null, $data = null) {
// PHP mangles HTTP headers by uppercasing them and replacing hyphens with
// underscores, then prepending 'HTTP_'.
$php_index = strtoupper($name);
$php_index = str_replace('-', '_', $php_index);
$try_names = array();
$try_names[] = 'HTTP_'.$php_index;
if ($php_index == 'CONTENT_TYPE' || $php_index == 'CONTENT_LENGTH') {
// These headers may be available under alternate names. See
// http://www.php.net/manual/en/reserved.variables.server.php#110763
$try_names[] = $php_index;
}
if ($data === null) {
$data = $_SERVER;
}
foreach ($try_names as $try_name) {
if (array_key_exists($try_name, $data)) {
return $data[$try_name];
}
}
return $default;
}
/* -( Working With a Phabricator Cluster )--------------------------------- */
/**
* Is this a proxied request originating from within the Phabricator cluster?
*
* IMPORTANT: This means the request is dangerous!
*
* These requests are **more dangerous** than normal requests (they can not
* be safely proxied, because proxying them may cause a loop). Cluster
* requests are not guaranteed to come from a trusted source, and should
* never be treated as safer than normal requests. They are strictly less
* safe.
*/
public function isProxiedClusterRequest() {
return (bool)self::getHTTPHeader('X-Phabricator-Cluster');
}
/**
* Build a new @{class:HTTPSFuture} which proxies this request to another
* node in the cluster.
*
* IMPORTANT: This is very dangerous!
*
* The future forwards authentication information present in the request.
* Proxied requests must only be sent to trusted hosts. (We attempt to
* enforce this.)
*
* This is not a general-purpose proxying method; it is a specialized
* method with niche applications and severe security implications.
*
* @param string URI $uri identifying the host we are proxying the request to.
* @return HTTPSFuture New proxy future.
*
* @phutil-external-symbol class PhabricatorStartup
*/
public function newClusterProxyFuture($uri) {
$uri = new PhutilURI($uri);
$domain = $uri->getDomain();
$ip = gethostbyname($domain);
if (!$ip) {
throw new Exception(
pht(
'Unable to resolve domain "%s"!',
$domain));
}
if (!PhabricatorEnv::isClusterAddress($ip)) {
throw new Exception(
pht(
'Refusing to proxy a request to IP address ("%s") which is not '.
'in the cluster address block (this address was derived by '.
'resolving the domain "%s").',
$ip,
$domain));
}
$uri->setPath($this->getPath());
$uri->removeAllQueryParams();
foreach (self::flattenData($_GET) as $query_key => $query_value) {
$uri->appendQueryParam($query_key, $query_value);
}
$input = PhabricatorStartup::getRawInput();
$future = id(new HTTPSFuture($uri))
->addHeader('Host', self::getHost())
->addHeader('X-Phabricator-Cluster', true)
->setMethod($_SERVER['REQUEST_METHOD'])
->write($input);
if (isset($_SERVER['PHP_AUTH_USER'])) {
$future->setHTTPBasicAuthCredentials(
$_SERVER['PHP_AUTH_USER'],
new PhutilOpaqueEnvelope(idx($_SERVER, 'PHP_AUTH_PW', '')));
}
$headers = array();
$seen = array();
// NOTE: apache_request_headers() might provide a nicer way to do this,
// but isn't available under FCGI until PHP 5.4.0.
foreach ($_SERVER as $key => $value) {
if (!preg_match('/^HTTP_/', $key)) {
continue;
}
// Unmangle the header as best we can.
$key = substr($key, strlen('HTTP_'));
$key = str_replace('_', ' ', $key);
$key = strtolower($key);
$key = ucwords($key);
$key = str_replace(' ', '-', $key);
// By default, do not forward headers.
$should_forward = false;
// Forward "X-Hgarg-..." headers.
if (preg_match('/^X-Hgarg-/', $key)) {
$should_forward = true;
}
if ($should_forward) {
$headers[] = array($key, $value);
$seen[$key] = true;
}
}
// In some situations, this may not be mapped into the HTTP_X constants.
// CONTENT_LENGTH is similarly affected, but we trust cURL to take care
// of that if it matters, since we're handing off a request body.
if (empty($seen['Content-Type'])) {
if (isset($_SERVER['CONTENT_TYPE'])) {
$headers[] = array('Content-Type', $_SERVER['CONTENT_TYPE']);
}
}
foreach ($headers as $header) {
list($key, $value) = $header;
switch ($key) {
case 'Host':
case 'Authorization':
// Don't forward these headers, we've already handled them elsewhere.
unset($headers[$key]);
break;
default:
break;
}
}
foreach ($headers as $header) {
list($key, $value) = $header;
$future->addHeader($key, $value);
}
return $future;
}
public function updateEphemeralCookies() {
$submit_cookie = PhabricatorCookies::COOKIE_SUBMIT;
$submit_key = $this->getCookie($submit_cookie);
if (phutil_nonempty_string($submit_key)) {
$this->clearCookie($submit_cookie);
$this->submitKey = $submit_key;
}
}
public function getSubmitKey() {
return $this->submitKey;
}
}
diff --git a/src/aphront/httpparametertype/AphrontHTTPParameterType.php b/src/aphront/httpparametertype/AphrontHTTPParameterType.php
index a31101a9fc..d149ee01dc 100644
--- a/src/aphront/httpparametertype/AphrontHTTPParameterType.php
+++ b/src/aphront/httpparametertype/AphrontHTTPParameterType.php
@@ -1,309 +1,309 @@
<?php
/**
* Defines how to read a complex value from an HTTP request.
*
* Most HTTP parameters are simple (like strings or integers) but some
* parameters accept more complex values (like lists of users or project names).
*
* This class handles reading simple and complex values from a request,
* performing any required parsing or lookups, and returning a result in a
* standard format.
*
* @task read Reading Values from a Request
* @task info Information About the Type
* @task util Parsing Utilities
* @task impl Implementation
*/
abstract class AphrontHTTPParameterType extends Phobject {
private $viewer;
/* -( Reading Values from a Request )-------------------------------------- */
/**
* Set the current viewer.
*
* Some parameter types perform complex parsing involving lookups. For
* example, a type might lookup usernames or project names. These types need
* to use the current viewer to execute queries.
*
* @param PhabricatorUser $viewer Current viewer.
- * @return this
+ * @return $this
* @task read
*/
final public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
/**
* Get the current viewer.
*
* @return PhabricatorUser Current viewer.
* @task read
*/
final public function getViewer() {
if (!$this->viewer) {
throw new PhutilInvalidStateException('setViewer');
}
return $this->viewer;
}
/**
* Test if a value is present in a request.
*
* @param AphrontRequest $request The incoming request.
* @param string $key The key to examine.
* @return bool True if a readable value is present in the request.
* @task read
*/
final public function getExists(AphrontRequest $request, $key) {
return $this->getParameterExists($request, $key);
}
/**
* Read a value from a request.
*
* If the value is not present, a default value is returned (usually `null`).
* Use @{method:getExists} to test if a value is present.
*
* @param AphrontRequest $request The incoming request.
* @param string $key The key to examine.
* @return wild Value, or default if value is not present.
* @task read
*/
final public function getValue(AphrontRequest $request, $key) {
if (!$this->getExists($request, $key)) {
return $this->getParameterDefault();
}
return $this->getParameterValue($request, $key);
}
/**
* Get the default value for this parameter type.
*
* @return wild Default value for this type.
* @task read
*/
final public function getDefaultValue() {
return $this->getParameterDefault();
}
/* -( Information About the Type )----------------------------------------- */
/**
* Get a short name for this type, like `string` or `list<phid>`.
*
* @return string Short type name.
* @task info
*/
final public function getTypeName() {
return $this->getParameterTypeName();
}
/**
* Get a list of human-readable descriptions of acceptable formats for this
* type.
*
* For example, a type might return strings like these:
*
* > Any positive integer.
* > A comma-separated list of PHIDs.
*
* This is used to explain to users how to specify a type when generating
* documentation.
*
* @return list<string> Human-readable list of acceptable formats.
* @task info
*/
final public function getFormatDescriptions() {
return $this->getParameterFormatDescriptions();
}
/**
* Get a list of human-readable examples of how to format this type as an
* HTTP GET parameter.
*
* For example, a type might return strings like these:
*
* > v=123
* > v[]=1&v[]=2
*
* This is used to show users how to specify parameters of this type in
* generated documentation.
*
* @return list<string> Human-readable list of format examples.
* @task info
*/
final public function getExamples() {
return $this->getParameterExamples();
}
/* -( Utilities )---------------------------------------------------------- */
/**
* Call another type's existence check.
*
* This method allows a type to reuse the existence behavior of a different
* type. For example, a "list of users" type may have the same basic
* existence check that a simpler "list of strings" type has, and can just
* call the simpler type to reuse its behavior.
*
* @param AphrontHTTPParameterType $type The other type.
* @param AphrontRequest $request Incoming request.
* @param string $key Key to examine.
* @return bool True if the parameter exists.
* @task util
*/
final protected function getExistsWithType(
AphrontHTTPParameterType $type,
AphrontRequest $request,
$key) {
$type->setViewer($this->getViewer());
return $type->getParameterExists($request, $key);
}
/**
* Call another type's value parser.
*
* This method allows a type to reuse the parsing behavior of a different
* type. For example, a "list of users" type may start by running the same
* basic parsing that a simpler "list of strings" type does.
*
* @param AphrontHTTPParameterType $type The other type.
* @param AphrontRequest $request Incoming request.
* @param string $key Key to examine.
* @return wild Parsed value.
* @task util
*/
final protected function getValueWithType(
AphrontHTTPParameterType $type,
AphrontRequest $request,
$key) {
$type->setViewer($this->getViewer());
return $type->getValue($request, $key);
}
/**
* Get a list of all available parameter types.
*
* @return list<AphrontHTTPParameterType> List of all available types.
* @task util
*/
final public static function getAllTypes() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getTypeName')
->setSortMethod('getTypeName')
->execute();
}
/* -( Implementation )----------------------------------------------------- */
/**
* Test if a parameter exists in a request.
*
* See @{method:getExists}. By default, this method tests if the key is
* present in the request.
*
* To call another type's behavior in order to perform this check, use
* @{method:getExistsWithType}.
*
* @param AphrontRequest $request The incoming request.
* @param string $key The key to examine.
* @return bool True if a readable value is present in the request.
* @task impl
*/
protected function getParameterExists(AphrontRequest $request, $key) {
return $request->getExists($key);
}
/**
* Parse a value from a request.
*
* See @{method:getValue}. This method will //only// be called if this type
* has already asserted that the value exists with
* @{method:getParameterExists}.
*
* To call another type's behavior in order to parse a value, use
* @{method:getValueWithType}.
*
* @param AphrontRequest $request The incoming request.
* @param string $key The key to examine.
* @return wild Parsed value.
* @task impl
*/
abstract protected function getParameterValue(AphrontRequest $request, $key);
/**
* Return a simple type name string, like "string" or "list<phid>".
*
* See @{method:getTypeName}.
*
* @return string Short type name.
* @task impl
*/
abstract protected function getParameterTypeName();
/**
* Return a human-readable list of format descriptions.
*
* See @{method:getFormatDescriptions}.
*
* @return list<string> Human-readable list of acceptable formats.
* @task impl
*/
abstract protected function getParameterFormatDescriptions();
/**
* Return a human-readable list of examples.
*
* See @{method:getExamples}.
*
* @return list<string> Human-readable list of format examples.
* @task impl
*/
abstract protected function getParameterExamples();
/**
* Return the default value for this parameter type.
*
* See @{method:getDefaultValue}. If unspecified, the default is `null`.
*
* @return wild Default value.
* @task impl
*/
protected function getParameterDefault() {
return null;
}
}
diff --git a/src/aphront/writeguard/AphrontWriteGuard.php b/src/aphront/writeguard/AphrontWriteGuard.php
index 6a3d053cf1..672e06b7a3 100644
--- a/src/aphront/writeguard/AphrontWriteGuard.php
+++ b/src/aphront/writeguard/AphrontWriteGuard.php
@@ -1,267 +1,267 @@
<?php
/**
* Guard writes against CSRF. The Aphront structure takes care of most of this
* for you, you just need to call:
*
* AphrontWriteGuard::willWrite();
*
* ...before executing a write against any new kind of storage engine. MySQL
* databases and the default file storage engines are already covered, but if
* you introduce new types of datastores make sure their writes are guarded. If
* you don't guard writes and make a mistake doing CSRF checks in a controller,
* a CSRF vulnerability can escape undetected.
*
* If you need to execute writes on a page which doesn't have CSRF tokens (for
* example, because you need to do logging), you can temporarily disable the
* write guard by calling:
*
* AphrontWriteGuard::beginUnguardedWrites();
* do_logging_write();
* AphrontWriteGuard::endUnguardedWrites();
*
* This is dangerous, because it disables the backup layer of CSRF protection
* this class provides. You should need this only very, very rarely.
*
* @task protect Protecting Writes
* @task disable Disabling Protection
* @task manage Managing Write Guards
* @task internal Internals
*/
final class AphrontWriteGuard extends Phobject {
private static $instance;
private static $allowUnguardedWrites = false;
private $callback;
private $allowDepth = 0;
/* -( Managing Write Guards )---------------------------------------------- */
/**
* Construct a new write guard for a request. Only one write guard may be
* active at a time. You must explicitly call @{method:dispose} when you are
* done with a write guard:
*
* $guard = new AphrontWriteGuard($callback);
* // ...
* $guard->dispose();
*
* Normally, you do not need to manage guards yourself -- the Aphront stack
* handles it for you.
*
* This class accepts a callback, which will be invoked when a write is
* attempted. The callback should validate the presence of a CSRF token in
* the request, or abort the request (e.g., by throwing an exception) if a
* valid token isn't present.
*
* @param $callback Callable CSRF callback.
- * @return this
+ * @return $this
* @task manage
*/
public function __construct($callback) {
if (self::$instance) {
throw new Exception(
pht(
'An %s already exists. Dispose of the previous guard '.
'before creating a new one.',
__CLASS__));
}
if (self::$allowUnguardedWrites) {
throw new Exception(
pht(
'An %s is being created in a context which permits '.
'unguarded writes unconditionally. This is not allowed and '.
'indicates a serious error.',
__CLASS__));
}
$this->callback = $callback;
self::$instance = $this;
}
/**
* Dispose of the active write guard. You must call this method when you are
* done with a write guard. You do not normally need to call this yourself.
*
* @return void
* @task manage
*/
public function dispose() {
if (!self::$instance) {
throw new Exception(pht(
'Attempting to dispose of write guard, but no write guard is active!'));
}
if ($this->allowDepth > 0) {
throw new Exception(
pht(
'Imbalanced %s: more %s calls than %s calls.',
__CLASS__,
'beginUnguardedWrites()',
'endUnguardedWrites()'));
}
self::$instance = null;
}
/**
* Determine if there is an active write guard.
*
* @return bool
* @task manage
*/
public static function isGuardActive() {
return (bool)self::$instance;
}
/**
* Return on instance of AphrontWriteGuard if it's active, or null
*
* @return AphrontWriteGuard|null
*/
public static function getInstance() {
return self::$instance;
}
/* -( Protecting Writes )-------------------------------------------------- */
/**
* Declare intention to perform a write, validating that writes are allowed.
* You should call this method before executing a write whenever you implement
* a new storage engine where information can be permanently kept.
*
* Writes are permitted if:
*
* - The request has valid CSRF tokens.
* - Unguarded writes have been temporarily enabled by a call to
* @{method:beginUnguardedWrites}.
* - All write guarding has been disabled with
* @{method:allowDangerousUnguardedWrites}.
*
* If none of these conditions are true, this method will throw and prevent
* the write.
*
* @return void
* @task protect
*/
public static function willWrite() {
if (!self::$instance) {
if (!self::$allowUnguardedWrites) {
throw new Exception(
pht(
'Unguarded write! There must be an active %s to perform writes.',
__CLASS__));
} else {
// Unguarded writes are being allowed unconditionally.
return;
}
}
$instance = self::$instance;
if ($instance->allowDepth == 0) {
call_user_func($instance->callback);
}
}
/* -( Disabling Write Protection )----------------------------------------- */
/**
* Enter a scope which permits unguarded writes. This works like
* @{method:beginUnguardedWrites} but returns an object which will end
* the unguarded write scope when its __destruct() method is called. This
* is useful to more easily handle exceptions correctly in unguarded write
* blocks:
*
* // Restores the guard even if do_logging() throws.
* function unguarded_scope() {
* $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
* do_logging();
* }
*
* @return AphrontScopedUnguardedWriteCapability Object which ends unguarded
* writes when it leaves scope.
* @task disable
*/
public static function beginScopedUnguardedWrites() {
self::beginUnguardedWrites();
return new AphrontScopedUnguardedWriteCapability();
}
/**
* Begin a block which permits unguarded writes. You should use this very
* sparingly, and only for things like logging where CSRF is not a concern.
*
* You must pair every call to @{method:beginUnguardedWrites} with a call to
* @{method:endUnguardedWrites}:
*
* AphrontWriteGuard::beginUnguardedWrites();
* do_logging();
* AphrontWriteGuard::endUnguardedWrites();
*
* @return void
* @task disable
*/
public static function beginUnguardedWrites() {
if (!self::$instance) {
return;
}
self::$instance->allowDepth++;
}
/**
* Declare that you have finished performing unguarded writes. You must
* call this exactly once for each call to @{method:beginUnguardedWrites}.
*
* @return void
* @task disable
*/
public static function endUnguardedWrites() {
if (!self::$instance) {
return;
}
if (self::$instance->allowDepth <= 0) {
throw new Exception(
pht(
'Imbalanced %s: more %s calls than %s calls.',
__CLASS__,
'endUnguardedWrites()',
'beginUnguardedWrites()'));
}
self::$instance->allowDepth--;
}
/**
* Allow execution of unguarded writes. This is ONLY appropriate for use in
* script contexts or other contexts where you are guaranteed to never be
* vulnerable to CSRF concerns. Calling this method is EXTREMELY DANGEROUS
* if you do not understand the consequences.
*
* If you need to perform unguarded writes on an otherwise guarded workflow
* which is vulnerable to CSRF, use @{method:beginUnguardedWrites}.
*
* @return void
* @task disable
*/
public static function allowDangerousUnguardedWrites($allow) {
if (self::$instance) {
throw new Exception(
pht(
'You can not unconditionally disable %s by calling %s while a write '.
'guard is active. Use %s to temporarily allow unguarded writes.',
__CLASS__,
__FUNCTION__.'()',
'beginUnguardedWrites()'));
}
self::$allowUnguardedWrites = true;
}
}
diff --git a/src/applications/conduit/protocol/exception/ConduitException.php b/src/applications/conduit/protocol/exception/ConduitException.php
index d604de65ef..56ec517f9c 100644
--- a/src/applications/conduit/protocol/exception/ConduitException.php
+++ b/src/applications/conduit/protocol/exception/ConduitException.php
@@ -1,32 +1,32 @@
<?php
/**
* @concrete-extensible
*/
class ConduitException extends Exception {
private $errorDescription;
/**
* Set a detailed error description. If omitted, the generic error description
* will be used instead. This is useful to provide specific information about
* an exception (e.g., which values were wrong in an invalid request).
*
* @param string $error_description Detailed error description.
- * @return this
+ * @return $this
*/
final public function setErrorDescription($error_description) {
$this->errorDescription = $error_description;
return $this;
}
/**
* Get a detailed error description, if available.
*
* @return string|null Error description, if one is available.
*/
final public function getErrorDescription() {
return $this->errorDescription;
}
}
diff --git a/src/applications/config/issue/PhabricatorSetupIssue.php b/src/applications/config/issue/PhabricatorSetupIssue.php
index 3b6503113d..728699ec09 100644
--- a/src/applications/config/issue/PhabricatorSetupIssue.php
+++ b/src/applications/config/issue/PhabricatorSetupIssue.php
@@ -1,231 +1,231 @@
<?php
final class PhabricatorSetupIssue extends Phobject {
private $issueKey;
private $name;
private $message;
private $isFatal;
private $summary;
private $shortName;
private $group;
private $databaseRef;
private $isIgnored = false;
private $phpExtensions = array();
private $phabricatorConfig = array();
private $relatedPhabricatorConfig = array();
private $phpConfig = array();
private $commands = array();
private $mysqlConfig = array();
private $originalPHPConfigValues = array();
private $links;
public static function newDatabaseConnectionIssue(
Exception $ex,
$is_fatal) {
$message = pht(
"Unable to connect to MySQL!\n\n".
"%s\n\n".
"Make sure databases connection information and MySQL are ".
"correctly configured.",
$ex->getMessage());
$issue = id(new self())
->setIssueKey('mysql.connect')
->setName(pht('Can Not Connect to MySQL'))
->setMessage($message)
->setIsFatal($is_fatal)
->addRelatedPhabricatorConfig('mysql.host')
->addRelatedPhabricatorConfig('mysql.port')
->addRelatedPhabricatorConfig('mysql.user')
->addRelatedPhabricatorConfig('mysql.pass');
if (PhabricatorEnv::getEnvConfig('cluster.databases')) {
$issue->addRelatedPhabricatorConfig('cluster.databases');
}
return $issue;
}
public function addCommand($command) {
$this->commands[] = $command;
return $this;
}
public function getCommands() {
return $this->commands;
}
public function setShortName($short_name) {
$this->shortName = $short_name;
return $this;
}
public function getShortName() {
if ($this->shortName === null) {
return $this->getName();
}
return $this->shortName;
}
public function setDatabaseRef(PhabricatorDatabaseRef $database_ref) {
$this->databaseRef = $database_ref;
return $this;
}
public function getDatabaseRef() {
return $this->databaseRef;
}
public function setGroup($group) {
$this->group = $group;
return $this;
}
public function getGroup() {
if ($this->group) {
return $this->group;
} else {
return PhabricatorSetupCheck::GROUP_OTHER;
}
}
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setSummary($summary) {
$this->summary = $summary;
return $this;
}
public function getSummary() {
if ($this->summary === null) {
return $this->getMessage();
}
return $this->summary;
}
public function setIssueKey($issue_key) {
$this->issueKey = $issue_key;
return $this;
}
public function getIssueKey() {
return $this->issueKey;
}
public function setIsFatal($is_fatal) {
$this->isFatal = $is_fatal;
return $this;
}
public function getIsFatal() {
return $this->isFatal;
}
public function addPHPConfig($php_config) {
$this->phpConfig[] = $php_config;
return $this;
}
/**
* Set an explicit value to display when showing the user PHP configuration
* values.
*
* If Phabricator has changed a value by the time a config issue is raised,
* you can provide the original value here so the UI makes sense. For example,
* we alter `memory_limit` during startup, so if the original value is not
* provided it will look like it is always set to `-1`.
*
* @param string $php_config PHP configuration option to provide a value for.
* @param string $value Explicit value to show in the UI.
- * @return this
+ * @return $this
*/
public function addPHPConfigOriginalValue($php_config, $value) {
$this->originalPHPConfigValues[$php_config] = $value;
return $this;
}
public function getPHPConfigOriginalValue($php_config, $default = null) {
return idx($this->originalPHPConfigValues, $php_config, $default);
}
public function getPHPConfig() {
return $this->phpConfig;
}
public function addMySQLConfig($mysql_config) {
$this->mysqlConfig[] = $mysql_config;
return $this;
}
public function getMySQLConfig() {
return $this->mysqlConfig;
}
public function addPhabricatorConfig($phabricator_config) {
$this->phabricatorConfig[] = $phabricator_config;
return $this;
}
public function getPhabricatorConfig() {
return $this->phabricatorConfig;
}
public function addRelatedPhabricatorConfig($phabricator_config) {
$this->relatedPhabricatorConfig[] = $phabricator_config;
return $this;
}
public function getRelatedPhabricatorConfig() {
return $this->relatedPhabricatorConfig;
}
public function addPHPExtension($php_extension) {
$this->phpExtensions[] = $php_extension;
return $this;
}
public function getPHPExtensions() {
return $this->phpExtensions;
}
public function setMessage($message) {
$this->message = $message;
return $this;
}
public function getMessage() {
return $this->message;
}
public function setIsIgnored($is_ignored) {
$this->isIgnored = $is_ignored;
return $this;
}
public function getIsIgnored() {
return $this->isIgnored;
}
public function addLink($href, $name) {
$this->links[] = array(
'href' => $href,
'name' => $name,
);
return $this;
}
public function getLinks() {
return $this->links;
}
}
diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php
index 3548e02258..acba7c9290 100644
--- a/src/applications/differential/parser/DifferentialChangesetParser.php
+++ b/src/applications/differential/parser/DifferentialChangesetParser.php
@@ -1,1976 +1,1976 @@
<?php
final class DifferentialChangesetParser extends Phobject {
const HIGHLIGHT_BYTE_LIMIT = 262144;
protected $visible = array();
protected $new = array();
protected $old = array();
protected $intra = array();
protected $depthOnlyLines = array();
protected $newRender = null;
protected $oldRender = null;
protected $filename = null;
protected $hunkStartLines = array();
protected $comments = array();
protected $specialAttributes = array();
protected $changeset;
protected $renderCacheKey = null;
private $handles = array();
private $user;
private $leftSideChangesetID;
private $leftSideAttachesToNewFile;
private $rightSideChangesetID;
private $rightSideAttachesToNewFile;
private $originalLeft;
private $originalRight;
private $renderingReference;
private $isSubparser;
private $isTopLevel;
private $coverage;
private $markupEngine;
private $highlightErrors;
private $disableCache;
private $renderer;
private $highlightingDisabled;
private $showEditAndReplyLinks = true;
private $canMarkDone;
private $objectOwnerPHID;
private $offsetMode;
private $rangeStart;
private $rangeEnd;
private $mask;
private $linesOfContext = 8;
private $highlightEngine;
private $viewer;
private $viewState;
private $availableDocumentEngines;
public function setRange($start, $end) {
$this->rangeStart = $start;
$this->rangeEnd = $end;
return $this;
}
public function setMask(array $mask) {
$this->mask = $mask;
return $this;
}
public function renderChangeset() {
return $this->render($this->rangeStart, $this->rangeEnd, $this->mask);
}
public function setShowEditAndReplyLinks($bool) {
$this->showEditAndReplyLinks = $bool;
return $this;
}
public function getShowEditAndReplyLinks() {
return $this->showEditAndReplyLinks;
}
public function setViewState(PhabricatorChangesetViewState $view_state) {
$this->viewState = $view_state;
return $this;
}
public function getViewState() {
return $this->viewState;
}
public function setRenderer(DifferentialChangesetRenderer $renderer) {
$this->renderer = $renderer;
return $this;
}
public function getRenderer() {
return $this->renderer;
}
public function setDisableCache($disable_cache) {
$this->disableCache = $disable_cache;
return $this;
}
public function getDisableCache() {
return $this->disableCache;
}
public function setCanMarkDone($can_mark_done) {
$this->canMarkDone = $can_mark_done;
return $this;
}
public function getCanMarkDone() {
return $this->canMarkDone;
}
public function setObjectOwnerPHID($phid) {
$this->objectOwnerPHID = $phid;
return $this;
}
public function getObjectOwnerPHID() {
return $this->objectOwnerPHID;
}
public function setOffsetMode($offset_mode) {
$this->offsetMode = $offset_mode;
return $this;
}
public function getOffsetMode() {
return $this->offsetMode;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
private function newRenderer() {
$viewer = $this->getViewer();
$viewstate = $this->getViewstate();
$renderer_key = $viewstate->getRendererKey();
if ($renderer_key === null) {
$is_unified = $viewer->compareUserSetting(
PhabricatorUnifiedDiffsSetting::SETTINGKEY,
PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED);
if ($is_unified) {
$renderer_key = '1up';
} else {
$renderer_key = $viewstate->getDefaultDeviceRendererKey();
}
}
switch ($renderer_key) {
case '1up':
$renderer = new DifferentialChangesetOneUpRenderer();
break;
default:
$renderer = new DifferentialChangesetTwoUpRenderer();
break;
}
return $renderer;
}
const CACHE_VERSION = 14;
const CACHE_MAX_SIZE = 8e6;
const ATTR_GENERATED = 'attr:generated';
const ATTR_DELETED = 'attr:deleted';
const ATTR_UNCHANGED = 'attr:unchanged';
const ATTR_MOVEAWAY = 'attr:moveaway';
public function setOldLines(array $lines) {
$this->old = $lines;
return $this;
}
public function setNewLines(array $lines) {
$this->new = $lines;
return $this;
}
public function setSpecialAttributes(array $attributes) {
$this->specialAttributes = $attributes;
return $this;
}
public function setIntraLineDiffs(array $diffs) {
$this->intra = $diffs;
return $this;
}
public function setDepthOnlyLines(array $lines) {
$this->depthOnlyLines = $lines;
return $this;
}
public function getDepthOnlyLines() {
return $this->depthOnlyLines;
}
public function setVisibleLinesMask(array $mask) {
$this->visible = $mask;
return $this;
}
public function setLinesOfContext($lines_of_context) {
$this->linesOfContext = $lines_of_context;
return $this;
}
public function getLinesOfContext() {
return $this->linesOfContext;
}
/**
* Configure which Changeset comments added to the right side of the visible
* diff will be attached to. The ID must be the ID of a real Differential
* Changeset.
*
* The complexity here is that we may show an arbitrary side of an arbitrary
* changeset as either the left or right part of a diff. This method allows
* the left and right halves of the displayed diff to be correctly mapped to
* storage changesets.
*
* @param id $id The Differential Changeset ID that comments added to the
* right side of the visible diff should be attached to.
* @param bool $is_new If true, attach new comments to the right side of the
* storage changeset. Note that this may be false, if the left
* side of some storage changeset is being shown as the right
* side of a display diff.
- * @return this
+ * @return $this
*/
public function setRightSideCommentMapping($id, $is_new) {
$this->rightSideChangesetID = $id;
$this->rightSideAttachesToNewFile = $is_new;
return $this;
}
/**
* See setRightSideCommentMapping(), but this sets information for the left
* side of the display diff.
*/
public function setLeftSideCommentMapping($id, $is_new) {
$this->leftSideChangesetID = $id;
$this->leftSideAttachesToNewFile = $is_new;
return $this;
}
public function setOriginals(
DifferentialChangeset $left,
DifferentialChangeset $right) {
$this->originalLeft = $left;
$this->originalRight = $right;
return $this;
}
public function diffOriginals() {
$engine = new PhabricatorDifferenceEngine();
$changeset = $engine->generateChangesetFromFileContent(
implode('', mpull($this->originalLeft->getHunks(), 'getChanges')),
implode('', mpull($this->originalRight->getHunks(), 'getChanges')));
$parser = new DifferentialHunkParser();
return $parser->parseHunksForHighlightMasks(
$changeset->getHunks(),
$this->originalLeft->getHunks(),
$this->originalRight->getHunks());
}
/**
* Set a key for identifying this changeset in the render cache. If set, the
* parser will attempt to use the changeset render cache, which can improve
* performance for frequently-viewed changesets.
*
* By default, there is no render cache key and parsers do not use the cache.
* This is appropriate for rarely-viewed changesets.
*
* @param string $key Key for identifying this changeset in the render
* cache.
- * @return this
+ * @return $this
*/
public function setRenderCacheKey($key) {
$this->renderCacheKey = $key;
return $this;
}
private function getRenderCacheKey() {
return $this->renderCacheKey;
}
public function setChangeset(DifferentialChangeset $changeset) {
$this->changeset = $changeset;
$this->setFilename($changeset->getFilename());
return $this;
}
public function setRenderingReference($ref) {
$this->renderingReference = $ref;
return $this;
}
private function getRenderingReference() {
return $this->renderingReference;
}
public function getChangeset() {
return $this->changeset;
}
public function setFilename($filename) {
$this->filename = $filename;
return $this;
}
public function setHandles(array $handles) {
assert_instances_of($handles, 'PhabricatorObjectHandle');
$this->handles = $handles;
return $this;
}
public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
$this->markupEngine = $engine;
return $this;
}
public function setCoverage($coverage) {
$this->coverage = $coverage;
return $this;
}
private function getCoverage() {
return $this->coverage;
}
public function parseInlineComment(
PhabricatorInlineComment $comment) {
// Parse only comments which are actually visible.
if ($this->isCommentVisibleOnRenderedDiff($comment)) {
$this->comments[] = $comment;
}
return $this;
}
private function loadCache() {
$render_cache_key = $this->getRenderCacheKey();
if (!$render_cache_key) {
return false;
}
$data = null;
$changeset = new DifferentialChangeset();
$conn_r = $changeset->establishConnection('r');
$data = queryfx_one(
$conn_r,
'SELECT * FROM %T WHERE cacheIndex = %s',
DifferentialChangeset::TABLE_CACHE,
PhabricatorHash::digestForIndex($render_cache_key));
if (!$data) {
return false;
}
if ($data['cache'][0] == '{') {
// This is likely an old-style JSON cache which we will not be able to
// deserialize.
return false;
}
$data = unserialize($data['cache']);
if (!is_array($data) || !$data) {
return false;
}
foreach (self::getCacheableProperties() as $cache_key) {
if (!array_key_exists($cache_key, $data)) {
// If we're missing a cache key, assume we're looking at an old cache
// and ignore it.
return false;
}
}
if ($data['cacheVersion'] !== self::CACHE_VERSION) {
return false;
}
// Someone displays contents of a partially cached shielded file.
if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) {
return false;
}
unset($data['cacheVersion'], $data['cacheHost']);
$cache_prop = array_select_keys($data, self::getCacheableProperties());
foreach ($cache_prop as $cache_key => $v) {
$this->$cache_key = $v;
}
return true;
}
protected static function getCacheableProperties() {
return array(
'visible',
'new',
'old',
'intra',
'depthOnlyLines',
'newRender',
'oldRender',
'specialAttributes',
'hunkStartLines',
'cacheVersion',
'cacheHost',
'highlightingDisabled',
);
}
public function saveCache() {
if (PhabricatorEnv::isReadOnly()) {
return false;
}
if ($this->highlightErrors) {
return false;
}
$render_cache_key = $this->getRenderCacheKey();
if (!$render_cache_key) {
return false;
}
$cache = array();
foreach (self::getCacheableProperties() as $cache_key) {
switch ($cache_key) {
case 'cacheVersion':
$cache[$cache_key] = self::CACHE_VERSION;
break;
case 'cacheHost':
$cache[$cache_key] = php_uname('n');
break;
default:
$cache[$cache_key] = $this->$cache_key;
break;
}
}
$cache = serialize($cache);
// We don't want to waste too much space by a single changeset.
if (strlen($cache) > self::CACHE_MAX_SIZE) {
return;
}
$changeset = new DifferentialChangeset();
$conn_w = $changeset->establishConnection('w');
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
try {
queryfx(
$conn_w,
'INSERT INTO %T (cacheIndex, cache, dateCreated) VALUES (%s, %B, %d)
ON DUPLICATE KEY UPDATE cache = VALUES(cache)',
DifferentialChangeset::TABLE_CACHE,
PhabricatorHash::digestForIndex($render_cache_key),
$cache,
PhabricatorTime::getNow());
} catch (AphrontQueryException $ex) {
// Ignore these exceptions. A common cause is that the cache is
// larger than 'max_allowed_packet', in which case we're better off
// not writing it.
// TODO: It would be nice to tailor this more narrowly.
}
unset($unguarded);
}
private function markGenerated($new_corpus_block = '') {
$generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false);
if (!$generated_guess) {
$generated_path_regexps = PhabricatorEnv::getEnvConfig(
'differential.generated-paths');
foreach ($generated_path_regexps as $regexp) {
if (preg_match($regexp, $this->changeset->getFilename())) {
$generated_guess = true;
break;
}
}
}
$event = new PhabricatorEvent(
PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED,
array(
'corpus' => $new_corpus_block,
'is_generated' => $generated_guess,
)
);
PhutilEventEngine::dispatchEvent($event);
$generated = $event->getValue('is_generated');
$attribute = $this->changeset->isGeneratedChangeset();
if ($attribute) {
$generated = true;
}
$this->specialAttributes[self::ATTR_GENERATED] = $generated;
}
public function isGenerated() {
return idx($this->specialAttributes, self::ATTR_GENERATED, false);
}
public function isDeleted() {
return idx($this->specialAttributes, self::ATTR_DELETED, false);
}
public function isUnchanged() {
return idx($this->specialAttributes, self::ATTR_UNCHANGED, false);
}
public function isMoveAway() {
return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false);
}
private function applyIntraline(&$render, $intra, $corpus) {
foreach ($render as $key => $text) {
$result = $text;
if (isset($intra[$key])) {
$result = PhabricatorDifferenceEngine::applyIntralineDiff(
$result,
$intra[$key]);
}
$result = $this->adjustRenderedLineForDisplay($result);
$render[$key] = $result;
}
}
private function getHighlightFuture($corpus) {
$language = $this->getViewState()->getHighlightLanguage();
if (!$language) {
$language = $this->highlightEngine->getLanguageFromFilename(
$this->filename);
if (($language != 'txt') &&
(strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) {
$this->highlightingDisabled = true;
$language = 'txt';
}
}
return $this->highlightEngine->getHighlightFuture(
$language,
$corpus);
}
protected function processHighlightedSource($data, $result) {
$result_lines = phutil_split_lines($result);
foreach ($data as $key => $info) {
if (!$info) {
unset($result_lines[$key]);
}
}
return $result_lines;
}
private function tryCacheStuff() {
$changeset = $this->getChangeset();
if (!$changeset->hasSourceTextBody()) {
// TODO: This isn't really correct (the change is not "generated"), the
// intent is just to not render a text body for Subversion directory
// changes, etc.
$this->markGenerated();
return;
}
$viewstate = $this->getViewState();
$skip_cache = false;
if ($this->disableCache) {
$skip_cache = true;
}
$character_encoding = $viewstate->getCharacterEncoding();
if ($character_encoding !== null) {
$skip_cache = true;
}
$highlight_language = $viewstate->getHighlightLanguage();
if ($highlight_language !== null) {
$skip_cache = true;
}
if ($skip_cache || !$this->loadCache()) {
$this->process();
if (!$skip_cache) {
$this->saveCache();
}
}
}
private function process() {
$changeset = $this->changeset;
$hunk_parser = new DifferentialHunkParser();
$hunk_parser->parseHunksForLineData($changeset->getHunks());
$this->realignDiff($changeset, $hunk_parser);
$hunk_parser->reparseHunksForSpecialAttributes();
$unchanged = false;
if (!$hunk_parser->getHasAnyChanges()) {
$filetype = $this->changeset->getFileType();
if ($filetype == DifferentialChangeType::FILE_TEXT ||
$filetype == DifferentialChangeType::FILE_SYMLINK) {
$unchanged = true;
}
}
$moveaway = false;
$changetype = $this->changeset->getChangeType();
if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) {
$moveaway = true;
}
$this->setSpecialAttributes(array(
self::ATTR_UNCHANGED => $unchanged,
self::ATTR_DELETED => $hunk_parser->getIsDeleted(),
self::ATTR_MOVEAWAY => $moveaway,
));
$lines_context = $this->getLinesOfContext();
$hunk_parser->generateIntraLineDiffs();
$hunk_parser->generateVisibleLinesMask($lines_context);
$this->setOldLines($hunk_parser->getOldLines());
$this->setNewLines($hunk_parser->getNewLines());
$this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs());
$this->setDepthOnlyLines($hunk_parser->getDepthOnlyLines());
$this->setVisibleLinesMask($hunk_parser->getVisibleLinesMask());
$this->hunkStartLines = $hunk_parser->getHunkStartLines(
$changeset->getHunks());
$new_corpus = $hunk_parser->getNewCorpus();
$new_corpus_block = implode('', $new_corpus);
$this->markGenerated($new_corpus_block);
if ($this->isTopLevel &&
!$this->comments &&
($this->isGenerated() ||
$this->isUnchanged() ||
$this->isDeleted())) {
return;
}
$old_corpus = $hunk_parser->getOldCorpus();
$old_corpus_block = implode('', $old_corpus);
$old_future = $this->getHighlightFuture($old_corpus_block);
$new_future = $this->getHighlightFuture($new_corpus_block);
$futures = array(
'old' => $old_future,
'new' => $new_future,
);
$corpus_blocks = array(
'old' => $old_corpus_block,
'new' => $new_corpus_block,
);
$this->highlightErrors = false;
foreach (new FutureIterator($futures) as $key => $future) {
try {
try {
$highlighted = $future->resolve();
} catch (PhutilSyntaxHighlighterException $ex) {
$this->highlightErrors = true;
$highlighted = id(new PhutilDefaultSyntaxHighlighter())
->getHighlightFuture($corpus_blocks[$key])
->resolve();
}
switch ($key) {
case 'old':
$this->oldRender = $this->processHighlightedSource(
$this->old,
$highlighted);
break;
case 'new':
$this->newRender = $this->processHighlightedSource(
$this->new,
$highlighted);
break;
}
} catch (Exception $ex) {
phlog($ex);
throw $ex;
}
}
$this->applyIntraline(
$this->oldRender,
ipull($this->intra, 0),
$old_corpus);
$this->applyIntraline(
$this->newRender,
ipull($this->intra, 1),
$new_corpus);
}
private function shouldRenderPropertyChangeHeader($changeset) {
if (!$this->isTopLevel) {
// We render properties only at top level; otherwise we get multiple
// copies of them when a user clicks "Show More".
return false;
}
return true;
}
public function render(
$range_start = null,
$range_len = null,
$mask_force = array()) {
$viewer = $this->getViewer();
$renderer = $this->getRenderer();
if (!$renderer) {
$renderer = $this->newRenderer();
$this->setRenderer($renderer);
}
// "Top level" renders are initial requests for the whole file, versus
// requests for a specific range generated by clicking "show more". We
// generate property changes and "shield" UI elements only for toplevel
// requests.
$this->isTopLevel = (($range_start === null) && ($range_len === null));
$this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine();
$viewstate = $this->getViewState();
$encoding = null;
$character_encoding = $viewstate->getCharacterEncoding();
if ($character_encoding) {
// We are forcing this changeset to be interpreted with a specific
// character encoding, so force all the hunks into that encoding and
// propagate it to the renderer.
$encoding = $character_encoding;
foreach ($this->changeset->getHunks() as $hunk) {
$hunk->forceEncoding($character_encoding);
}
} else {
// We're just using the default, so tell the renderer what that is
// (by reading the encoding from the first hunk).
foreach ($this->changeset->getHunks() as $hunk) {
$encoding = $hunk->getDataEncoding();
break;
}
}
$this->tryCacheStuff();
// If we're rendering in an offset mode, treat the range numbers as line
// numbers instead of rendering offsets.
$offset_mode = $this->getOffsetMode();
if ($offset_mode) {
if ($offset_mode == 'new') {
$offset_map = $this->new;
} else {
$offset_map = $this->old;
}
// NOTE: Inline comments use zero-based lengths. For example, a comment
// that starts and ends on line 123 has length 0. Rendering considers
// this range to have length 1. Probably both should agree, but that
// ship likely sailed long ago. Tweak things here to get the two systems
// to agree. See PHI985, where this affected mail rendering of inline
// comments left on the final line of a file.
$range_end = $this->getOffset($offset_map, $range_start + $range_len);
$range_start = $this->getOffset($offset_map, $range_start);
$range_len = ($range_end - $range_start) + 1;
}
$render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset);
$rows = max(
count($this->old),
count($this->new));
$renderer = $this->getRenderer()
->setUser($this->getViewer())
->setChangeset($this->changeset)
->setRenderPropertyChangeHeader($render_pch)
->setIsTopLevel($this->isTopLevel)
->setOldRender($this->oldRender)
->setNewRender($this->newRender)
->setHunkStartLines($this->hunkStartLines)
->setOldChangesetID($this->leftSideChangesetID)
->setNewChangesetID($this->rightSideChangesetID)
->setOldAttachesToNewFile($this->leftSideAttachesToNewFile)
->setNewAttachesToNewFile($this->rightSideAttachesToNewFile)
->setCodeCoverage($this->getCoverage())
->setRenderingReference($this->getRenderingReference())
->setHandles($this->handles)
->setOldLines($this->old)
->setNewLines($this->new)
->setOriginalCharacterEncoding($encoding)
->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks())
->setCanMarkDone($this->getCanMarkDone())
->setObjectOwnerPHID($this->getObjectOwnerPHID())
->setHighlightingDisabled($this->highlightingDisabled)
->setDepthOnlyLines($this->getDepthOnlyLines());
if ($this->markupEngine) {
$renderer->setMarkupEngine($this->markupEngine);
}
list($engine, $old_ref, $new_ref) = $this->newDocumentEngine();
if ($engine) {
$engine_blocks = $engine->newEngineBlocks(
$old_ref,
$new_ref);
} else {
$engine_blocks = null;
}
$has_document_engine = ($engine_blocks !== null);
// Remove empty comments that don't have any unsaved draft data.
PhabricatorInlineComment::loadAndAttachVersionedDrafts(
$viewer,
$this->comments);
foreach ($this->comments as $key => $comment) {
if ($comment->isVoidComment($viewer)) {
unset($this->comments[$key]);
}
}
// See T13515. Sometimes, we collapse file content by default: for
// example, if the file is marked as containing generated code.
// If a file has inline comments, that normally means we never collapse
// it. However, if the viewer has already collapsed all of the inlines,
// it's fine to collapse the file.
$expanded_comments = array();
foreach ($this->comments as $comment) {
if ($comment->isHidden()) {
continue;
}
$expanded_comments[] = $comment;
}
$collapsed_count = (count($this->comments) - count($expanded_comments));
$shield_raw = null;
$shield_text = null;
$shield_type = null;
if ($this->isTopLevel && !$expanded_comments && !$has_document_engine) {
if ($this->isGenerated()) {
$shield_text = pht(
'This file contains generated code, which does not normally '.
'need to be reviewed.');
} else if ($this->isMoveAway()) {
// We put an empty shield on these files. Normally, they do not have
// any diff content anyway. However, if they come through `arc`, they
// may have content. We don't want to show it (it's not useful) and
// we bailed out of fully processing it earlier anyway.
// We could show a message like "this file was moved", but we show
// that as a change header anyway, so it would be redundant. Instead,
// just render an empty shield to skip rendering the diff body.
$shield_raw = '';
} else if ($this->isUnchanged()) {
$type = 'text';
if (!$rows) {
// NOTE: Normally, diffs which don't change files do not include
// file content (for example, if you "chmod +x" a file and then
// run "git show", the file content is not available). Similarly,
// if you move a file from A to B without changing it, diffs normally
// do not show the file content. In some cases `arc` is able to
// synthetically generate content for these diffs, but for raw diffs
// we'll never have it so we need to be prepared to not render a link.
$type = 'none';
}
$shield_type = $type;
$type_add = DifferentialChangeType::TYPE_ADD;
if ($this->changeset->getChangeType() == $type_add) {
// Although the generic message is sort of accurate in a technical
// sense, this more-tailored message is less confusing.
$shield_text = pht('This is an empty file.');
} else {
$shield_text = pht('The contents of this file were not changed.');
}
} else if ($this->isDeleted()) {
$shield_text = pht('This file was completely deleted.');
} else if ($this->changeset->getAffectedLineCount() > 2500) {
$shield_text = pht(
'This file has a very large number of changes (%s lines).',
new PhutilNumber($this->changeset->getAffectedLineCount()));
}
}
$shield = null;
if ($shield_raw !== null) {
$shield = $shield_raw;
} else if ($shield_text !== null) {
if ($shield_type === null) {
$shield_type = 'default';
}
// If we have inlines and the shield would normally show the whole file,
// downgrade it to show only text around the inlines.
if ($collapsed_count) {
if ($shield_type === 'text') {
$shield_type = 'default';
}
$shield_text = array(
$shield_text,
' ',
pht(
'This file has %d collapsed inline comment(s).',
new PhutilNumber($collapsed_count)),
);
}
$shield = $renderer->renderShield($shield_text, $shield_type);
}
if ($shield !== null) {
return $renderer->renderChangesetTable($shield);
}
// This request should render the "undershield" headers if it's a top-level
// request which made it this far (indicating the changeset has no shield)
// or it's a request with no mask information (indicating it's the request
// that removes the rendering shield). Possibly, this second class of
// request might need to be made more explicit.
$is_undershield = (empty($mask_force) || $this->isTopLevel);
$renderer->setIsUndershield($is_undershield);
$old_comments = array();
$new_comments = array();
$old_mask = array();
$new_mask = array();
$feedback_mask = array();
$lines_context = $this->getLinesOfContext();
if ($this->comments) {
// If there are any comments which appear in sections of the file which
// we don't have, we're going to move them backwards to the closest
// earlier line. Two cases where this may happen are:
//
// - Porting ghost comments forward into a file which was mostly
// deleted.
// - Porting ghost comments forward from a full-context diff to a
// partial-context diff.
list($old_backmap, $new_backmap) = $this->buildLineBackmaps();
foreach ($this->comments as $comment) {
$new_side = $this->isCommentOnRightSideWhenDisplayed($comment);
$line = $comment->getLineNumber();
// See T13524. Lint inlines from Harbormaster may not have a line
// number.
if ($line === null) {
$back_line = null;
} else if ($new_side) {
$back_line = idx($new_backmap, $line);
} else {
$back_line = idx($old_backmap, $line);
}
if ($back_line != $line) {
// TODO: This should probably be cleaner, but just be simple and
// obvious for now.
$ghost = $comment->getIsGhost();
if ($ghost) {
$moved = pht(
'This comment originally appeared on line %s, but that line '.
'does not exist in this version of the diff. It has been '.
'moved backward to the nearest line.',
new PhutilNumber($line));
$ghost['reason'] = $ghost['reason']."\n\n".$moved;
$comment->setIsGhost($ghost);
}
$comment->setLineNumber($back_line);
$comment->setLineLength(0);
}
$start = max($comment->getLineNumber() - $lines_context, 0);
$end = $comment->getLineNumber() +
$comment->getLineLength() +
$lines_context;
for ($ii = $start; $ii <= $end; $ii++) {
if ($new_side) {
$new_mask[$ii] = true;
} else {
$old_mask[$ii] = true;
}
}
}
foreach ($this->old as $ii => $old) {
if (isset($old['line']) && isset($old_mask[$old['line']])) {
$feedback_mask[$ii] = true;
}
}
foreach ($this->new as $ii => $new) {
if (isset($new['line']) && isset($new_mask[$new['line']])) {
$feedback_mask[$ii] = true;
}
}
$this->comments = id(new PHUIDiffInlineThreader())
->reorderAndThreadCommments($this->comments);
$old_max_display = 1;
foreach ($this->old as $old) {
if (isset($old['line'])) {
$old_max_display = $old['line'];
}
}
$new_max_display = 1;
foreach ($this->new as $new) {
if (isset($new['line'])) {
$new_max_display = $new['line'];
}
}
foreach ($this->comments as $comment) {
$display_line = $comment->getLineNumber() + $comment->getLineLength();
$display_line = max(1, $display_line);
if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
$display_line = min($new_max_display, $display_line);
$new_comments[$display_line][] = $comment;
} else {
$display_line = min($old_max_display, $display_line);
$old_comments[$display_line][] = $comment;
}
}
}
$renderer
->setOldComments($old_comments)
->setNewComments($new_comments);
if ($engine_blocks !== null) {
$reference = $this->getRenderingReference();
$parts = explode('/', $reference);
if (count($parts) == 2) {
list($id, $vs) = $parts;
} else {
$id = $parts[0];
$vs = 0;
}
// If we don't have an explicit "vs" changeset, it's the left side of
// the "id" changeset.
if (!$vs) {
$vs = $id;
}
if ($mask_force) {
$engine_blocks->setRevealedIndexes(array_keys($mask_force));
}
if ($range_start !== null || $range_len !== null) {
$range_min = $range_start;
if ($range_len === null) {
$range_max = null;
} else {
$range_max = (int)$range_start + (int)$range_len;
}
$engine_blocks->setRange($range_min, $range_max);
}
$renderer
->setDocumentEngine($engine)
->setDocumentEngineBlocks($engine_blocks);
return $renderer->renderDocumentEngineBlocks(
$engine_blocks,
(string)$id,
(string)$vs);
}
// If we've made it here with a type of file we don't know how to render,
// bail out with a default empty rendering. Normally, we'd expect a
// document engine to catch these changes before we make it this far.
switch ($this->changeset->getFileType()) {
case DifferentialChangeType::FILE_DIRECTORY:
case DifferentialChangeType::FILE_BINARY:
case DifferentialChangeType::FILE_IMAGE:
$output = $renderer->renderChangesetTable(null);
return $output;
}
if ($this->originalLeft && $this->originalRight) {
list($highlight_old, $highlight_new) = $this->diffOriginals();
$highlight_old = array_flip($highlight_old);
$highlight_new = array_flip($highlight_new);
$renderer
->setHighlightOld($highlight_old)
->setHighlightNew($highlight_new);
}
$renderer
->setOriginalOld($this->originalLeft)
->setOriginalNew($this->originalRight);
if ($range_start === null) {
$range_start = 0;
}
if ($range_len === null) {
$range_len = $rows;
}
$range_len = min($range_len, $rows - $range_start);
list($gaps, $mask) = $this->calculateGapsAndMask(
$mask_force,
$feedback_mask,
$range_start,
$range_len);
$renderer
->setGaps($gaps)
->setMask($mask);
$html = $renderer->renderTextChange(
$range_start,
$range_len,
$rows);
return $renderer->renderChangesetTable($html);
}
/**
* This function calculates a lot of stuff we need to know to display
* the diff:
*
* Gaps - compute gaps in the visible display diff, where we will render
* "Show more context" spacers. If a gap is smaller than the context size,
* we just display it. Otherwise, we record it into $gaps and will render a
* "show more context" element instead of diff text below. A given $gap
* is a tuple of $gap_line_number_start and $gap_length.
*
* Mask - compute the actual lines that need to be shown (because they
* are near changes lines, near inline comments, or the request has
* explicitly asked for them, i.e. resulting from the user clicking
* "show more"). The $mask returned is a sparsely populated dictionary
* of $visible_line_number => true.
*
* @return array($gaps, $mask)
*/
private function calculateGapsAndMask(
$mask_force,
$feedback_mask,
$range_start,
$range_len) {
$lines_context = $this->getLinesOfContext();
$gaps = array();
$gap_start = 0;
$in_gap = false;
$base_mask = $this->visible + $mask_force + $feedback_mask;
$base_mask[$range_start + $range_len] = true;
for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) {
if (isset($base_mask[$ii])) {
if ($in_gap) {
$gap_length = $ii - $gap_start;
if ($gap_length <= $lines_context) {
for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) {
$base_mask[$jj] = true;
}
} else {
$gaps[] = array($gap_start, $gap_length);
}
$in_gap = false;
}
} else {
if (!$in_gap) {
$gap_start = $ii;
$in_gap = true;
}
}
}
$gaps = array_reverse($gaps);
$mask = $base_mask;
return array($gaps, $mask);
}
/**
* Determine if an inline comment will appear on the rendered diff,
* taking into consideration which halves of which changesets will actually
* be shown.
*
* @param PhabricatorInlineComment $comment Comment to test for visibility.
* @return bool True if the comment is visible on the rendered diff.
*/
private function isCommentVisibleOnRenderedDiff(
PhabricatorInlineComment $comment) {
$changeset_id = $comment->getChangesetID();
$is_new = $comment->getIsNewFile();
if ($changeset_id == $this->rightSideChangesetID &&
$is_new == $this->rightSideAttachesToNewFile) {
return true;
}
if ($changeset_id == $this->leftSideChangesetID &&
$is_new == $this->leftSideAttachesToNewFile) {
return true;
}
return false;
}
/**
* Determine if a comment will appear on the right side of the display diff.
* Note that the comment must appear somewhere on the rendered changeset, as
* per isCommentVisibleOnRenderedDiff().
*
* @param PhabricatorInlineComment $comment Comment to test for display
* location.
* @return bool True for right, false for left.
*/
private function isCommentOnRightSideWhenDisplayed(
PhabricatorInlineComment $comment) {
if (!$this->isCommentVisibleOnRenderedDiff($comment)) {
throw new Exception(pht('Comment is not visible on changeset!'));
}
$changeset_id = $comment->getChangesetID();
$is_new = $comment->getIsNewFile();
if ($changeset_id == $this->rightSideChangesetID &&
$is_new == $this->rightSideAttachesToNewFile) {
return true;
}
return false;
}
/**
* Parse the 'range' specification that this class and the client-side JS
* emit to indicate that a user clicked "Show more..." on a diff. Generally,
* use is something like this:
*
* $spec = $request->getStr('range');
* $parsed = DifferentialChangesetParser::parseRangeSpecification($spec);
* list($start, $end, $mask) = $parsed;
* $parser->render($start, $end, $mask);
*
* @param string $spec Range specification, indicating the range of the diff
* that should be rendered.
* @return tuple List of <start, end, mask> suitable for passing to
* @{method:render}.
*/
public static function parseRangeSpecification($spec) {
$range_s = null;
$range_e = null;
$mask = array();
if ($spec) {
$match = null;
if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) {
$range_s = (int)$match[1];
$range_e = (int)$match[2];
if (count($match) > 3) {
$start = (int)$match[3];
$len = (int)$match[4];
for ($ii = $start; $ii < $start + $len; $ii++) {
$mask[$ii] = true;
}
}
}
}
return array($range_s, $range_e, $mask);
}
/**
* Render "modified coverage" information; test coverage on modified lines.
* This synthesizes diff information with unit test information into a useful
* indicator of how well tested a change is.
*/
public function renderModifiedCoverage() {
$na = phutil_tag('em', array(), '-');
$coverage = $this->getCoverage();
if (!$coverage) {
return $na;
}
$covered = 0;
$not_covered = 0;
foreach ($this->new as $k => $new) {
if ($new === null) {
continue;
}
if (!$new['line']) {
continue;
}
if (!$new['type']) {
continue;
}
if (empty($coverage[$new['line'] - 1])) {
continue;
}
switch ($coverage[$new['line'] - 1]) {
case 'C':
$covered++;
break;
case 'U':
$not_covered++;
break;
}
}
if (!$covered && !$not_covered) {
return $na;
}
return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered)));
}
/**
* Build maps from lines comments appear on to actual lines.
*/
private function buildLineBackmaps() {
$old_back = array();
$new_back = array();
foreach ($this->old as $ii => $old) {
if ($old === null) {
continue;
}
$old_back[$old['line']] = $old['line'];
}
foreach ($this->new as $ii => $new) {
if ($new === null) {
continue;
}
$new_back[$new['line']] = $new['line'];
}
$max_old_line = 0;
$max_new_line = 0;
foreach ($this->comments as $comment) {
if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
$max_new_line = max($max_new_line, $comment->getLineNumber());
} else {
$max_old_line = max($max_old_line, $comment->getLineNumber());
}
}
$cursor = 1;
for ($ii = 1; $ii <= $max_old_line; $ii++) {
if (empty($old_back[$ii])) {
$old_back[$ii] = $cursor;
} else {
$cursor = $old_back[$ii];
}
}
$cursor = 1;
for ($ii = 1; $ii <= $max_new_line; $ii++) {
if (empty($new_back[$ii])) {
$new_back[$ii] = $cursor;
} else {
$cursor = $new_back[$ii];
}
}
return array($old_back, $new_back);
}
private function getOffset(array $map, $line) {
if (!$map) {
return null;
}
$line = (int)$line;
foreach ($map as $key => $spec) {
if ($spec && isset($spec['line'])) {
if ((int)$spec['line'] >= $line) {
return $key;
}
}
}
return $key;
}
private function realignDiff(
DifferentialChangeset $changeset,
DifferentialHunkParser $hunk_parser) {
// Normalizing and realigning the diff depends on rediffing the files, and
// we currently need complete representations of both files to do anything
// reasonable. If we only have parts of the files, skip realignment.
// We have more than one hunk, so we're definitely missing part of the file.
$hunks = $changeset->getHunks();
if (count($hunks) !== 1) {
return null;
}
// The first hunk doesn't start at the beginning of the file, so we're
// missing some context.
$first_hunk = head($hunks);
if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) {
return null;
}
$old_file = $changeset->makeOldFile();
$new_file = $changeset->makeNewFile();
if ($old_file === $new_file) {
// If the old and new files are exactly identical, the synthetic
// diff below will give us nonsense and whitespace modes are
// irrelevant anyway. This occurs when you, e.g., copy a file onto
// itself in Subversion (see T271).
return null;
}
$engine = id(new PhabricatorDifferenceEngine())
->setNormalize(true);
$normalized_changeset = $engine->generateChangesetFromFileContent(
$old_file,
$new_file);
$type_parser = new DifferentialHunkParser();
$type_parser->parseHunksForLineData($normalized_changeset->getHunks());
$hunk_parser->setNormalized(true);
$hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap());
$hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap());
}
private function adjustRenderedLineForDisplay($line) {
// IMPORTANT: We're using "str_replace()" against raw HTML here, which can
// easily become unsafe. The input HTML has already had syntax highlighting
// and intraline diff highlighting applied, so it's full of "<span />" tags.
static $search;
static $replace;
if ($search === null) {
$rules = $this->newSuspiciousCharacterRules();
$map = array();
foreach ($rules as $key => $spec) {
$tag = phutil_tag(
'span',
array(
'data-copy-text' => $key,
'class' => $spec['class'],
'title' => $spec['title'],
),
$spec['replacement']);
$map[$key] = phutil_string_cast($tag);
}
$search = array_keys($map);
$replace = array_values($map);
}
$is_html = false;
if ($line instanceof PhutilSafeHTML) {
$is_html = true;
$line = hsprintf('%s', $line);
}
$line = phutil_string_cast($line);
// TODO: This should be flexible, eventually.
$tab_width = 2;
$line = self::replaceTabsWithSpaces($line, $tab_width);
$line = str_replace($search, $replace, $line);
if ($is_html) {
$line = phutil_safe_html($line);
}
return $line;
}
private function newSuspiciousCharacterRules() {
// The "title" attributes are cached in the database, so they're
// intentionally not wrapped in "pht(...)".
$rules = array(
"\xE2\x80\x8B" => array(
'title' => 'ZWS',
'class' => 'suspicious-character',
'replacement' => '!',
),
"\xC2\xA0" => array(
'title' => 'NBSP',
'class' => 'suspicious-character',
'replacement' => '!',
),
"\x7F" => array(
'title' => 'DEL (0x7F)',
'class' => 'suspicious-character',
'replacement' => "\xE2\x90\xA1",
),
);
// Unicode defines special pictures for the control characters in the
// range between "0x00" and "0x1F".
$control = array(
'NULL',
'SOH',
'STX',
'ETX',
'EOT',
'ENQ',
'ACK',
'BEL',
'BS',
null, // "\t" Tab
null, // "\n" New Line
'VT',
'FF',
null, // "\r" Carriage Return,
'SO',
'SI',
'DLE',
'DC1',
'DC2',
'DC3',
'DC4',
'NAK',
'SYN',
'ETB',
'CAN',
'EM',
'SUB',
'ESC',
'FS',
'GS',
'RS',
'US',
);
foreach ($control as $idx => $label) {
if ($label === null) {
continue;
}
$rules[chr($idx)] = array(
'title' => sprintf('%s (0x%02X)', $label, $idx),
'class' => 'suspicious-character',
'replacement' => "\xE2\x90".chr(0x80 + $idx),
);
}
return $rules;
}
public static function replaceTabsWithSpaces($line, $tab_width) {
static $tags = array();
if (empty($tags[$tab_width])) {
for ($ii = 1; $ii <= $tab_width; $ii++) {
$tag = phutil_tag(
'span',
array(
'data-copy-text' => "\t",
),
str_repeat(' ', $ii));
$tag = phutil_string_cast($tag);
$tags[$ii] = $tag;
}
}
// Expand all prefix tabs until we encounter any non-tab character. This
// is cheap and often immediately produces the correct result with no
// further work (and, particularly, no need to handle any unicode cases).
$len = strlen($line);
$head = 0;
for ($head = 0; $head < $len; $head++) {
$char = $line[$head];
if ($char !== "\t") {
break;
}
}
if ($head) {
if (empty($tags[$tab_width * $head])) {
$tags[$tab_width * $head] = str_repeat($tags[$tab_width], $head);
}
$prefix = $tags[$tab_width * $head];
$line = substr($line, $head);
} else {
$prefix = '';
}
// If we have no remaining tabs elsewhere in the string after taking care
// of all the prefix tabs, we're done.
if (strpos($line, "\t") === false) {
return $prefix.$line;
}
$len = strlen($line);
// If the line is particularly long, don't try to do anything special with
// it. Use a faster approximation of the correct tabstop expansion instead.
// This usually still arrives at the right result.
if ($len > 256) {
return $prefix.str_replace("\t", $tags[$tab_width], $line);
}
$in_tag = false;
$pos = 0;
// See PHI1210. If the line only has single-byte characters, we don't need
// to vectorize it and can avoid an expensive UTF8 call.
$fast_path = preg_match('/^[\x01-\x7F]*\z/', $line);
if ($fast_path) {
$replace = array();
for ($ii = 0; $ii < $len; $ii++) {
$char = $line[$ii];
if ($char === '>') {
$in_tag = false;
continue;
}
if ($in_tag) {
continue;
}
if ($char === '<') {
$in_tag = true;
continue;
}
if ($char === "\t") {
$count = $tab_width - ($pos % $tab_width);
$pos += $count;
$replace[$ii] = $tags[$count];
continue;
}
$pos++;
}
if ($replace) {
// Apply replacements starting at the end of the string so they
// don't mess up the offsets for following replacements.
$replace = array_reverse($replace, true);
foreach ($replace as $replace_pos => $replacement) {
$line = substr_replace($line, $replacement, $replace_pos, 1);
}
}
} else {
$line = phutil_utf8v_combined($line);
foreach ($line as $key => $char) {
if ($char === '>') {
$in_tag = false;
continue;
}
if ($in_tag) {
continue;
}
if ($char === '<') {
$in_tag = true;
continue;
}
if ($char === "\t") {
$count = $tab_width - ($pos % $tab_width);
$pos += $count;
$line[$key] = $tags[$count];
continue;
}
$pos++;
}
$line = implode('', $line);
}
return $prefix.$line;
}
private function newDocumentEngine() {
$changeset = $this->changeset;
$viewer = $this->getViewer();
list($old_file, $new_file) = $this->loadFileObjectsForChangeset();
$no_old = !$changeset->hasOldState();
$no_new = !$changeset->hasNewState();
if ($no_old) {
$old_ref = null;
} else {
$old_ref = id(new PhabricatorDocumentRef())
->setName($changeset->getOldFile());
if ($old_file) {
$old_ref->setFile($old_file);
} else {
$old_data = $this->getRawDocumentEngineData($this->old);
$old_ref->setData($old_data);
}
}
if ($no_new) {
$new_ref = null;
} else {
$new_ref = id(new PhabricatorDocumentRef())
->setName($changeset->getFilename());
if ($new_file) {
$new_ref->setFile($new_file);
} else {
$new_data = $this->getRawDocumentEngineData($this->new);
$new_ref->setData($new_data);
}
}
$old_engines = null;
if ($old_ref) {
$old_engines = PhabricatorDocumentEngine::getEnginesForRef(
$viewer,
$old_ref);
}
$new_engines = null;
if ($new_ref) {
$new_engines = PhabricatorDocumentEngine::getEnginesForRef(
$viewer,
$new_ref);
}
if ($new_engines !== null && $old_engines !== null) {
$shared_engines = array_intersect_key($new_engines, $old_engines);
$default_engine = head_key($new_engines);
} else if ($new_engines !== null) {
$shared_engines = $new_engines;
$default_engine = head_key($shared_engines);
} else if ($old_engines !== null) {
$shared_engines = $old_engines;
$default_engine = head_key($shared_engines);
} else {
return null;
}
foreach ($shared_engines as $key => $shared_engine) {
if (!$shared_engine->canDiffDocuments($old_ref, $new_ref)) {
unset($shared_engines[$key]);
}
}
$this->availableDocumentEngines = $shared_engines;
$viewstate = $this->getViewState();
$engine_key = $viewstate->getDocumentEngineKey();
if (phutil_nonempty_string($engine_key)) {
if (isset($shared_engines[$engine_key])) {
$document_engine = $shared_engines[$engine_key];
} else {
$document_engine = null;
}
} else {
// If we aren't rendering with a specific engine, only use a default
// engine if the best engine for the new file is a shared engine which
// can diff files. If we're less picky (for example, by accepting any
// shared engine) we can end up with silly behavior (like ".json" files
// rendering as Jupyter documents).
if (isset($shared_engines[$default_engine])) {
$document_engine = $shared_engines[$default_engine];
} else {
$document_engine = null;
}
}
if ($document_engine) {
return array(
$document_engine,
$old_ref,
$new_ref);
}
return null;
}
private function loadFileObjectsForChangeset() {
$changeset = $this->changeset;
$viewer = $this->getViewer();
$old_phid = $changeset->getOldFileObjectPHID();
$new_phid = $changeset->getNewFileObjectPHID();
$old_file = null;
$new_file = null;
if ($old_phid || $new_phid) {
$file_phids = array();
if ($old_phid) {
$file_phids[] = $old_phid;
}
if ($new_phid) {
$file_phids[] = $new_phid;
}
$files = id(new PhabricatorFileQuery())
->setViewer($viewer)
->withPHIDs($file_phids)
->execute();
$files = mpull($files, null, 'getPHID');
if ($old_phid) {
$old_file = idx($files, $old_phid);
if (!$old_file) {
throw new Exception(
pht(
'Failed to load file data for changeset ("%s").',
$old_phid));
}
$changeset->attachOldFileObject($old_file);
}
if ($new_phid) {
$new_file = idx($files, $new_phid);
if (!$new_file) {
throw new Exception(
pht(
'Failed to load file data for changeset ("%s").',
$new_phid));
}
$changeset->attachNewFileObject($new_file);
}
}
return array($old_file, $new_file);
}
public function newChangesetResponse() {
// NOTE: This has to happen first because it has side effects. Yuck.
$rendered_changeset = $this->renderChangeset();
$renderer = $this->getRenderer();
$renderer_key = $renderer->getRendererKey();
$viewstate = $this->getViewState();
$undo_templates = $renderer->renderUndoTemplates();
foreach ($undo_templates as $key => $undo_template) {
$undo_templates[$key] = hsprintf('%s', $undo_template);
}
$document_engine = $renderer->getDocumentEngine();
if ($document_engine) {
$document_engine_key = $document_engine->getDocumentEngineKey();
} else {
$document_engine_key = null;
}
$available_keys = array();
$engines = $this->availableDocumentEngines;
if (!$engines) {
$engines = array();
}
$available_keys = mpull($engines, 'getDocumentEngineKey');
// TODO: Always include "source" as a usable engine to default to
// the buitin rendering. This is kind of a hack and does not actually
// use the source engine. The source engine isn't a diff engine, so
// selecting it causes us to fall through and render with builtin
// behavior. For now, overall behavir is reasonable.
$available_keys[] = PhabricatorSourceDocumentEngine::ENGINEKEY;
$available_keys = array_fuse($available_keys);
$available_keys = array_values($available_keys);
$state = array(
'undoTemplates' => $undo_templates,
'rendererKey' => $renderer_key,
'highlight' => $viewstate->getHighlightLanguage(),
'characterEncoding' => $viewstate->getCharacterEncoding(),
'requestDocumentEngineKey' => $viewstate->getDocumentEngineKey(),
'responseDocumentEngineKey' => $document_engine_key,
'availableDocumentEngineKeys' => $available_keys,
'isHidden' => $viewstate->getHidden(),
);
return id(new PhabricatorChangesetResponse())
->setRenderedChangeset($rendered_changeset)
->setChangesetState($state);
}
private function getRawDocumentEngineData(array $lines) {
$text = array();
foreach ($lines as $line) {
if ($line === null) {
continue;
}
// If this is a "No newline at end of file." annotation, don't hand it
// off to the DocumentEngine.
if ($line['type'] === '\\') {
continue;
}
$text[] = $line['text'];
}
return implode('', $text);
}
}
diff --git a/src/applications/differential/query/DifferentialRevisionQuery.php b/src/applications/differential/query/DifferentialRevisionQuery.php
index 87ebafd97f..5d82df1a3e 100644
--- a/src/applications/differential/query/DifferentialRevisionQuery.php
+++ b/src/applications/differential/query/DifferentialRevisionQuery.php
@@ -1,1091 +1,1091 @@
<?php
/**
* @task config Query Configuration
* @task exec Query Execution
* @task internal Internals
*/
final class DifferentialRevisionQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $authors = array();
private $draftAuthors = array();
private $ccs = array();
private $reviewers = array();
private $revIDs = array();
private $commitHashes = array();
private $phids = array();
private $responsibles = array();
private $branches = array();
private $repositoryPHIDs;
private $updatedEpochMin;
private $updatedEpochMax;
private $statuses;
private $isOpen;
private $createdEpochMin;
private $createdEpochMax;
private $noReviewers;
private $paths;
const ORDER_MODIFIED = 'order-modified';
const ORDER_CREATED = 'order-created';
private $needActiveDiffs = false;
private $needDiffIDs = false;
private $needCommitPHIDs = false;
private $needHashes = false;
private $needReviewers = false;
private $needReviewerAuthority;
private $needDrafts;
private $needFlags;
/* -( Query Configuration )------------------------------------------------ */
/**
* Find revisions affecting one or more items in a list of paths.
*
* @param list<string> $paths List of file paths.
- * @return this
+ * @return $this
* @task config
*/
public function withPaths(array $paths) {
$this->paths = $paths;
return $this;
}
/**
* Filter results to revisions authored by one of the given PHIDs. Calling
* this function will clear anything set by previous calls to
* @{method:withAuthors}.
*
* @param array $author_phids List of PHIDs of authors
- * @return this
+ * @return $this
* @task config
*/
public function withAuthors(array $author_phids) {
$this->authors = $author_phids;
return $this;
}
/**
* Filter results to revisions which CC one of the listed people. Calling this
* function will clear anything set by previous calls to @{method:withCCs}.
*
* @param array $cc_phids List of PHIDs of subscribers.
- * @return this
+ * @return $this
* @task config
*/
public function withCCs(array $cc_phids) {
$this->ccs = $cc_phids;
return $this;
}
/**
* Filter results to revisions that have one of the provided PHIDs as
* reviewers. Calling this function will clear anything set by previous calls
* to @{method:withReviewers}.
*
* @param array $reviewer_phids List of PHIDs of reviewers
- * @return this
+ * @return $this
* @task config
*/
public function withReviewers(array $reviewer_phids) {
if ($reviewer_phids === array()) {
throw new Exception(
pht(
'Empty "withReviewers()" constraint is invalid. Provide one or '.
'more values, or remove the constraint.'));
}
$with_none = false;
foreach ($reviewer_phids as $key => $phid) {
switch ($phid) {
case DifferentialNoReviewersDatasource::FUNCTION_TOKEN:
$with_none = true;
unset($reviewer_phids[$key]);
break;
default:
break;
}
}
$this->noReviewers = $with_none;
if ($reviewer_phids) {
$this->reviewers = array_values($reviewer_phids);
}
return $this;
}
/**
* Filter results to revisions that have one of the provided commit hashes.
* Calling this function will clear anything set by previous calls to
* @{method:withCommitHashes}.
*
* @param array $commit_hashes List of pairs <Class
* ArcanistDifferentialRevisionHash::HASH_$type constant,
* hash>
- * @return this
+ * @return $this
* @task config
*/
public function withCommitHashes(array $commit_hashes) {
$this->commitHashes = $commit_hashes;
return $this;
}
public function withStatuses(array $statuses) {
$this->statuses = $statuses;
return $this;
}
public function withIsOpen($is_open) {
$this->isOpen = $is_open;
return $this;
}
/**
* Filter results to revisions on given branches.
*
* @param list $branches List of branch names.
- * @return this
+ * @return $this
* @task config
*/
public function withBranches(array $branches) {
$this->branches = $branches;
return $this;
}
/**
* Filter results to only return revisions whose ids are in the given set.
*
* @param array $ids List of revision ids
- * @return this
+ * @return $this
* @task config
*/
public function withIDs(array $ids) {
$this->revIDs = $ids;
return $this;
}
/**
* Filter results to only return revisions whose PHIDs are in the given set.
*
* @param array $phids List of revision PHIDs
- * @return this
+ * @return $this
* @task config
*/
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
/**
* Given a set of users, filter results to return only revisions they are
* responsible for (i.e., they are either authors or reviewers).
*
* @param array $responsible_phids List of user PHIDs.
- * @return this
+ * @return $this
* @task config
*/
public function withResponsibleUsers(array $responsible_phids) {
$this->responsibles = $responsible_phids;
return $this;
}
public function withRepositoryPHIDs(array $repository_phids) {
$this->repositoryPHIDs = $repository_phids;
return $this;
}
public function withUpdatedEpochBetween($min, $max) {
$this->updatedEpochMin = $min;
$this->updatedEpochMax = $max;
return $this;
}
public function withCreatedEpochBetween($min, $max) {
$this->createdEpochMin = $min;
$this->createdEpochMax = $max;
return $this;
}
/**
* Set whether or not the query should load the active diff for each
* revision.
*
* @param bool $need_active_diffs True to load and attach diffs.
- * @return this
+ * @return $this
* @task config
*/
public function needActiveDiffs($need_active_diffs) {
$this->needActiveDiffs = $need_active_diffs;
return $this;
}
/**
* Set whether or not the query should load the associated commit PHIDs for
* each revision.
*
* @param bool $need_commit_phids True to load and attach diffs.
- * @return this
+ * @return $this
* @task config
*/
public function needCommitPHIDs($need_commit_phids) {
$this->needCommitPHIDs = $need_commit_phids;
return $this;
}
/**
* Set whether or not the query should load associated diff IDs for each
* revision.
*
* @param bool $need_diff_ids True to load and attach diff IDs.
- * @return this
+ * @return $this
* @task config
*/
public function needDiffIDs($need_diff_ids) {
$this->needDiffIDs = $need_diff_ids;
return $this;
}
/**
* Set whether or not the query should load associated commit hashes for each
* revision.
*
* @param bool $need_hashes True to load and attach commit hashes.
- * @return this
+ * @return $this
* @task config
*/
public function needHashes($need_hashes) {
$this->needHashes = $need_hashes;
return $this;
}
/**
* Set whether or not the query should load associated reviewers.
*
* @param bool $need_reviewers True to load and attach reviewers.
- * @return this
+ * @return $this
* @task config
*/
public function needReviewers($need_reviewers) {
$this->needReviewers = $need_reviewers;
return $this;
}
/**
* Request information about the viewer's authority to act on behalf of each
* reviewer. In particular, they have authority to act on behalf of projects
* they are a member of.
*
* @param bool $need_reviewer_authority True to load and attach authority.
- * @return this
+ * @return $this
* @task config
*/
public function needReviewerAuthority($need_reviewer_authority) {
$this->needReviewerAuthority = $need_reviewer_authority;
return $this;
}
public function needFlags($need_flags) {
$this->needFlags = $need_flags;
return $this;
}
public function needDrafts($need_drafts) {
$this->needDrafts = $need_drafts;
return $this;
}
/* -( Query Execution )---------------------------------------------------- */
public function newResultObject() {
return new DifferentialRevision();
}
/**
* Execute the query as configured, returning matching
* @{class:DifferentialRevision} objects.
*
* @return list List of matching DifferentialRevision objects.
* @task exec
*/
protected function loadPage() {
$data = $this->loadData();
$data = $this->didLoadRawRows($data);
$table = $this->newResultObject();
return $table->loadAllFromArray($data);
}
protected function willFilterPage(array $revisions) {
$viewer = $this->getViewer();
$repository_phids = mpull($revisions, 'getRepositoryPHID');
$repository_phids = array_filter($repository_phids);
$repositories = array();
if ($repository_phids) {
$repositories = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withPHIDs($repository_phids)
->execute();
$repositories = mpull($repositories, null, 'getPHID');
}
// If a revision is associated with a repository:
//
// - the viewer must be able to see the repository; or
// - the viewer must have an automatic view capability.
//
// In the latter case, we'll load the revision but not load the repository.
$can_view = PhabricatorPolicyCapability::CAN_VIEW;
foreach ($revisions as $key => $revision) {
$repo_phid = $revision->getRepositoryPHID();
if (!$repo_phid) {
// The revision has no associated repository. Attach `null` and move on.
$revision->attachRepository(null);
continue;
}
$repository = idx($repositories, $repo_phid);
if ($repository) {
// The revision has an associated repository, and the viewer can see
// it. Attach it and move on.
$revision->attachRepository($repository);
continue;
}
if ($revision->hasAutomaticCapability($can_view, $viewer)) {
// The revision has an associated repository which the viewer can not
// see, but the viewer has an automatic capability on this revision.
// Load the revision without attaching a repository.
$revision->attachRepository(null);
continue;
}
if ($this->getViewer()->isOmnipotent()) {
// The viewer is omnipotent. Allow the revision to load even without
// a repository.
$revision->attachRepository(null);
continue;
}
// The revision has an associated repository, and the viewer can't see
// it, and the viewer has no special capabilities. Filter out this
// revision.
$this->didRejectResult($revision);
unset($revisions[$key]);
}
if (!$revisions) {
return array();
}
$table = new DifferentialRevision();
$conn_r = $table->establishConnection('r');
if ($this->needCommitPHIDs) {
$this->loadCommitPHIDs($revisions);
}
$need_active = $this->needActiveDiffs;
$need_ids = $need_active || $this->needDiffIDs;
if ($need_ids) {
$this->loadDiffIDs($conn_r, $revisions);
}
if ($need_active) {
$this->loadActiveDiffs($conn_r, $revisions);
}
if ($this->needHashes) {
$this->loadHashes($conn_r, $revisions);
}
if ($this->needReviewers || $this->needReviewerAuthority) {
$this->loadReviewers($conn_r, $revisions);
}
return $revisions;
}
protected function didFilterPage(array $revisions) {
$viewer = $this->getViewer();
if ($this->needFlags) {
$flags = id(new PhabricatorFlagQuery())
->setViewer($viewer)
->withOwnerPHIDs(array($viewer->getPHID()))
->withObjectPHIDs(mpull($revisions, 'getPHID'))
->execute();
$flags = mpull($flags, null, 'getObjectPHID');
foreach ($revisions as $revision) {
$revision->attachFlag(
$viewer,
idx($flags, $revision->getPHID()));
}
}
if ($this->needDrafts) {
PhabricatorDraftEngine::attachDrafts(
$viewer,
$revisions);
}
return $revisions;
}
private function loadData() {
$table = $this->newResultObject();
$conn = $table->establishConnection('r');
$selects = array();
// NOTE: If the query includes "responsiblePHIDs", we execute it as a
// UNION of revisions they own and revisions they're reviewing. This has
// much better performance than doing it with JOIN/WHERE.
if ($this->responsibles) {
$basic_authors = $this->authors;
$basic_reviewers = $this->reviewers;
try {
// Build the query where the responsible users are authors.
$this->authors = array_merge($basic_authors, $this->responsibles);
$this->reviewers = $basic_reviewers;
$selects[] = $this->buildSelectStatement($conn);
// Build the query where the responsible users are reviewers, or
// projects they are members of are reviewers.
$this->authors = $basic_authors;
$this->reviewers = array_merge($basic_reviewers, $this->responsibles);
$selects[] = $this->buildSelectStatement($conn);
// Put everything back like it was.
$this->authors = $basic_authors;
$this->reviewers = $basic_reviewers;
} catch (Exception $ex) {
$this->authors = $basic_authors;
$this->reviewers = $basic_reviewers;
throw $ex;
}
} else {
$selects[] = $this->buildSelectStatement($conn);
}
if (count($selects) > 1) {
$unions = null;
foreach ($selects as $select) {
if (!$unions) {
$unions = $select;
continue;
}
$unions = qsprintf(
$conn,
'%Q UNION DISTINCT %Q',
$unions,
$select);
}
$query = qsprintf(
$conn,
'%Q %Q %Q',
$unions,
$this->buildOrderClause($conn, true),
$this->buildLimitClause($conn));
} else {
$query = head($selects);
}
return queryfx_all($conn, '%Q', $query);
}
private function buildSelectStatement(AphrontDatabaseConnection $conn_r) {
$table = new DifferentialRevision();
$select = $this->buildSelectClause($conn_r);
$from = qsprintf(
$conn_r,
'FROM %T r',
$table->getTableName());
$joins = $this->buildJoinsClause($conn_r);
$where = $this->buildWhereClause($conn_r);
$group_by = $this->buildGroupClause($conn_r);
$having = $this->buildHavingClause($conn_r);
$order_by = $this->buildOrderClause($conn_r);
$limit = $this->buildLimitClause($conn_r);
return qsprintf(
$conn_r,
'(%Q %Q %Q %Q %Q %Q %Q %Q)',
$select,
$from,
$joins,
$where,
$group_by,
$having,
$order_by,
$limit);
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
private function buildJoinsClause(AphrontDatabaseConnection $conn) {
$joins = array();
if ($this->paths) {
$path_table = new DifferentialAffectedPath();
$joins[] = qsprintf(
$conn,
'JOIN %R paths ON paths.revisionID = r.id',
$path_table);
}
if ($this->commitHashes) {
$joins[] = qsprintf(
$conn,
'JOIN %T hash_rel ON hash_rel.revisionID = r.id',
ArcanistDifferentialRevisionHash::TABLE_NAME);
}
if ($this->ccs) {
$joins[] = qsprintf(
$conn,
'JOIN %T e_ccs ON e_ccs.src = r.phid '.
'AND e_ccs.type = %s '.
'AND e_ccs.dst in (%Ls)',
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
PhabricatorObjectHasSubscriberEdgeType::EDGECONST,
$this->ccs);
}
if ($this->reviewers) {
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T reviewer ON reviewer.revisionPHID = r.phid
AND reviewer.reviewerStatus != %s
AND reviewer.reviewerPHID in (%Ls)',
id(new DifferentialReviewer())->getTableName(),
DifferentialReviewerStatus::STATUS_RESIGNED,
$this->reviewers);
}
if ($this->noReviewers) {
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T no_reviewer ON no_reviewer.revisionPHID = r.phid
AND no_reviewer.reviewerStatus != %s',
id(new DifferentialReviewer())->getTableName(),
DifferentialReviewerStatus::STATUS_RESIGNED);
}
if ($this->draftAuthors) {
$joins[] = qsprintf(
$conn,
'JOIN %T has_draft ON has_draft.srcPHID = r.phid
AND has_draft.type = %s
AND has_draft.dstPHID IN (%Ls)',
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
PhabricatorObjectHasDraftEdgeType::EDGECONST,
$this->draftAuthors);
}
$joins[] = $this->buildJoinClauseParts($conn);
return $this->formatJoinClause($conn, $joins);
}
/**
* @task internal
*/
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$viewer = $this->getViewer();
$where = array();
if ($this->paths !== null) {
$paths = $this->paths;
$path_map = id(new DiffusionPathIDQuery($paths))
->loadPathIDs();
if (!$path_map) {
// If none of the paths have entries in the PathID table, we can not
// possibly find any revisions affecting them.
throw new PhabricatorEmptyQueryException();
}
$where[] = qsprintf(
$conn,
'paths.pathID IN (%Ld)',
array_fuse($path_map));
// If we have repository PHIDs, additionally constrain this query to
// try to help MySQL execute it efficiently.
if ($this->repositoryPHIDs !== null) {
$repositories = id(new PhabricatorRepositoryQuery())
->setViewer($viewer)
->setParentQuery($this)
->withPHIDs($this->repositoryPHIDs)
->execute();
if (!$repositories) {
throw new PhabricatorEmptyQueryException();
}
$repository_ids = mpull($repositories, 'getID');
$where[] = qsprintf(
$conn,
'paths.repositoryID IN (%Ld)',
$repository_ids);
}
}
if ($this->authors) {
$where[] = qsprintf(
$conn,
'r.authorPHID IN (%Ls)',
$this->authors);
}
if ($this->revIDs) {
$where[] = qsprintf(
$conn,
'r.id IN (%Ld)',
$this->revIDs);
}
if ($this->repositoryPHIDs) {
$where[] = qsprintf(
$conn,
'r.repositoryPHID IN (%Ls)',
$this->repositoryPHIDs);
}
if ($this->commitHashes) {
$hash_clauses = array();
foreach ($this->commitHashes as $info) {
list($type, $hash) = $info;
$hash_clauses[] = qsprintf(
$conn,
'(hash_rel.type = %s AND hash_rel.hash = %s)',
$type,
$hash);
}
$hash_clauses = qsprintf($conn, '%LO', $hash_clauses);
$where[] = $hash_clauses;
}
if ($this->phids) {
$where[] = qsprintf(
$conn,
'r.phid IN (%Ls)',
$this->phids);
}
if ($this->branches) {
$where[] = qsprintf(
$conn,
'r.branchName in (%Ls)',
$this->branches);
}
if ($this->updatedEpochMin !== null) {
$where[] = qsprintf(
$conn,
'r.dateModified >= %d',
$this->updatedEpochMin);
}
if ($this->updatedEpochMax !== null) {
$where[] = qsprintf(
$conn,
'r.dateModified <= %d',
$this->updatedEpochMax);
}
if ($this->createdEpochMin !== null) {
$where[] = qsprintf(
$conn,
'r.dateCreated >= %d',
$this->createdEpochMin);
}
if ($this->createdEpochMax !== null) {
$where[] = qsprintf(
$conn,
'r.dateCreated <= %d',
$this->createdEpochMax);
}
if ($this->statuses !== null) {
$where[] = qsprintf(
$conn,
'r.status in (%Ls)',
$this->statuses);
}
if ($this->isOpen !== null) {
if ($this->isOpen) {
$statuses = DifferentialLegacyQuery::getModernValues(
DifferentialLegacyQuery::STATUS_OPEN);
} else {
$statuses = DifferentialLegacyQuery::getModernValues(
DifferentialLegacyQuery::STATUS_CLOSED);
}
$where[] = qsprintf(
$conn,
'r.status in (%Ls)',
$statuses);
}
$reviewer_subclauses = array();
if ($this->noReviewers) {
$reviewer_subclauses[] = qsprintf(
$conn,
'no_reviewer.reviewerPHID IS NULL');
}
if ($this->reviewers) {
$reviewer_subclauses[] = qsprintf(
$conn,
'reviewer.reviewerPHID IS NOT NULL');
}
if ($reviewer_subclauses) {
$where[] = qsprintf($conn, '%LO', $reviewer_subclauses);
}
$where[] = $this->buildWhereClauseParts($conn);
return $this->formatWhereClause($conn, $where);
}
/**
* @task internal
*/
protected function shouldGroupQueryResultRows() {
if ($this->paths) {
// (If we have exactly one repository and exactly one path, we don't
// technically need to group, but it's simpler to always group.)
return true;
}
if (count($this->ccs) > 1) {
return true;
}
if (count($this->reviewers) > 1) {
return true;
}
if (count($this->commitHashes) > 1) {
return true;
}
if ($this->noReviewers) {
return true;
}
return parent::shouldGroupQueryResultRows();
}
public function getBuiltinOrders() {
$orders = parent::getBuiltinOrders() + array(
'updated' => array(
'vector' => array('updated', 'id'),
'name' => pht('Date Updated (Latest First)'),
'aliases' => array(self::ORDER_MODIFIED),
),
'outdated' => array(
'vector' => array('-updated', '-id'),
'name' => pht('Date Updated (Oldest First)'),
),
);
// Alias the "newest" builtin to the historical key for it.
$orders['newest']['aliases'][] = self::ORDER_CREATED;
return $orders;
}
protected function getDefaultOrderVector() {
return array('updated', 'id');
}
public function getOrderableColumns() {
return array(
'updated' => array(
'table' => $this->getPrimaryTableAlias(),
'column' => 'dateModified',
'type' => 'int',
),
) + parent::getOrderableColumns();
}
protected function newPagingMapFromPartialObject($object) {
return array(
'id' => (int)$object->getID(),
'updated' => (int)$object->getDateModified(),
);
}
private function loadCommitPHIDs(array $revisions) {
assert_instances_of($revisions, 'DifferentialRevision');
if (!$revisions) {
return;
}
$revisions = mpull($revisions, null, 'getPHID');
$edge_query = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array_keys($revisions))
->withEdgeTypes(
array(
DifferentialRevisionHasCommitEdgeType::EDGECONST,
));
$edge_query->execute();
foreach ($revisions as $phid => $revision) {
$commit_phids = $edge_query->getDestinationPHIDs(array($phid));
$revision->attachCommitPHIDs($commit_phids);
}
}
private function loadDiffIDs($conn_r, array $revisions) {
assert_instances_of($revisions, 'DifferentialRevision');
$diff_table = new DifferentialDiff();
$diff_ids = queryfx_all(
$conn_r,
'SELECT revisionID, id FROM %T WHERE revisionID IN (%Ld)
ORDER BY id DESC',
$diff_table->getTableName(),
mpull($revisions, 'getID'));
$diff_ids = igroup($diff_ids, 'revisionID');
foreach ($revisions as $revision) {
$ids = idx($diff_ids, $revision->getID(), array());
$ids = ipull($ids, 'id');
$revision->attachDiffIDs($ids);
}
}
private function loadActiveDiffs($conn_r, array $revisions) {
assert_instances_of($revisions, 'DifferentialRevision');
$diff_table = new DifferentialDiff();
$load_ids = array();
foreach ($revisions as $revision) {
$diffs = $revision->getDiffIDs();
if ($diffs) {
$load_ids[] = max($diffs);
}
}
$active_diffs = array();
if ($load_ids) {
$active_diffs = $diff_table->loadAllWhere(
'id IN (%Ld)',
$load_ids);
}
$active_diffs = mpull($active_diffs, null, 'getRevisionID');
foreach ($revisions as $revision) {
$revision->attachActiveDiff(idx($active_diffs, $revision->getID()));
}
}
private function loadHashes(
AphrontDatabaseConnection $conn_r,
array $revisions) {
assert_instances_of($revisions, 'DifferentialRevision');
$data = queryfx_all(
$conn_r,
'SELECT * FROM %T WHERE revisionID IN (%Ld)',
'differential_revisionhash',
mpull($revisions, 'getID'));
$data = igroup($data, 'revisionID');
foreach ($revisions as $revision) {
$hashes = idx($data, $revision->getID(), array());
$list = array();
foreach ($hashes as $hash) {
$list[] = array($hash['type'], $hash['hash']);
}
$revision->attachHashes($list);
}
}
private function loadReviewers(
AphrontDatabaseConnection $conn,
array $revisions) {
assert_instances_of($revisions, 'DifferentialRevision');
$reviewer_table = new DifferentialReviewer();
$reviewer_rows = queryfx_all(
$conn,
'SELECT * FROM %T WHERE revisionPHID IN (%Ls)
ORDER BY id ASC',
$reviewer_table->getTableName(),
mpull($revisions, 'getPHID'));
$reviewer_list = $reviewer_table->loadAllFromArray($reviewer_rows);
$reviewer_map = mgroup($reviewer_list, 'getRevisionPHID');
foreach ($reviewer_map as $key => $reviewers) {
$reviewer_map[$key] = mpull($reviewers, null, 'getReviewerPHID');
}
$viewer = $this->getViewer();
$viewer_phid = $viewer->getPHID();
$allow_key = 'differential.allow-self-accept';
$allow_self = PhabricatorEnv::getEnvConfig($allow_key);
// Figure out which of these reviewers the viewer has authority to act as.
if ($this->needReviewerAuthority && $viewer_phid) {
$authority = $this->loadReviewerAuthority(
$revisions,
$reviewer_map,
$allow_self);
}
foreach ($revisions as $revision) {
$reviewers = idx($reviewer_map, $revision->getPHID(), array());
foreach ($reviewers as $reviewer_phid => $reviewer) {
if ($this->needReviewerAuthority) {
if (!$viewer_phid) {
// Logged-out users never have authority.
$has_authority = false;
} else if ((!$allow_self) &&
($revision->getAuthorPHID() == $viewer_phid)) {
// The author can never have authority unless we allow self-accept.
$has_authority = false;
} else {
// Otherwise, look up whether the viewer has authority.
$has_authority = isset($authority[$reviewer_phid]);
}
$reviewer->attachAuthority($viewer, $has_authority);
}
$reviewers[$reviewer_phid] = $reviewer;
}
$revision->attachReviewers($reviewers);
}
}
private function loadReviewerAuthority(
array $revisions,
array $reviewers,
$allow_self) {
$revision_map = mpull($revisions, null, 'getPHID');
$viewer_phid = $this->getViewer()->getPHID();
// Find all the project/package reviewers which the user may have authority
// over.
$project_phids = array();
$package_phids = array();
$project_type = PhabricatorProjectProjectPHIDType::TYPECONST;
$package_type = PhabricatorOwnersPackagePHIDType::TYPECONST;
foreach ($reviewers as $revision_phid => $reviewer_list) {
if (!$allow_self) {
if ($revision_map[$revision_phid]->getAuthorPHID() == $viewer_phid) {
// If self-review isn't permitted, the user will never have
// authority over projects on revisions they authored because you
// can't accept your own revisions, so we don't need to load any
// data about these reviewers.
continue;
}
}
foreach ($reviewer_list as $reviewer_phid => $reviewer) {
$phid_type = phid_get_type($reviewer_phid);
if ($phid_type == $project_type) {
$project_phids[] = $reviewer_phid;
}
if ($phid_type == $package_type) {
$package_phids[] = $reviewer_phid;
}
}
}
// The viewer has authority over themselves.
$user_authority = array_fuse(array($viewer_phid));
// And over any projects they are a member of.
$project_authority = array();
if ($project_phids) {
$project_authority = id(new PhabricatorProjectQuery())
->setViewer($this->getViewer())
->withPHIDs($project_phids)
->withMemberPHIDs(array($viewer_phid))
->execute();
$project_authority = mpull($project_authority, 'getPHID');
$project_authority = array_fuse($project_authority);
}
// And over any packages they own.
$package_authority = array();
if ($package_phids) {
$package_authority = id(new PhabricatorOwnersPackageQuery())
->setViewer($this->getViewer())
->withPHIDs($package_phids)
->withAuthorityPHIDs(array($viewer_phid))
->execute();
$package_authority = mpull($package_authority, 'getPHID');
$package_authority = array_fuse($package_authority);
}
return $user_authority + $project_authority + $package_authority;
}
public function getQueryApplicationClass() {
return PhabricatorDifferentialApplication::class;
}
protected function getPrimaryTableAlias() {
return 'r';
}
}
diff --git a/src/applications/diffusion/request/DiffusionRequest.php b/src/applications/diffusion/request/DiffusionRequest.php
index eb1eaf9722..62107c74fe 100644
--- a/src/applications/diffusion/request/DiffusionRequest.php
+++ b/src/applications/diffusion/request/DiffusionRequest.php
@@ -1,701 +1,701 @@
<?php
/**
* Contains logic to parse Diffusion requests, which have a complicated URI
* structure.
*
* @task new Creating Requests
* @task uri Managing Diffusion URIs
*/
abstract class DiffusionRequest extends Phobject {
protected $path;
protected $line;
protected $branch;
protected $lint;
protected $symbolicCommit;
protected $symbolicType;
protected $stableCommit;
protected $repository;
protected $repositoryCommit;
protected $repositoryCommitData;
private $isClusterRequest = false;
private $initFromConduit = true;
private $user;
private $branchObject = false;
private $refAlternatives;
final public function supportsBranches() {
return $this->getRepository()->supportsRefs();
}
abstract protected function isStableCommit($symbol);
protected function didInitialize() {
return null;
}
/* -( Creating Requests )-------------------------------------------------- */
/**
* Create a new synthetic request from a parameter dictionary. If you need
* a @{class:DiffusionRequest} object in order to issue a DiffusionQuery, you
* can use this method to build one.
*
* Parameters are:
*
* - `repository` Repository object or identifier.
* - `user` Viewing user. Required if `repository` is an identifier.
* - `branch` Optional, branch name.
* - `path` Optional, file path.
* - `commit` Optional, commit identifier.
* - `line` Optional, line range.
*
* @param map $data See documentation.
* @return DiffusionRequest New request object.
* @task new
*/
final public static function newFromDictionary(array $data) {
$repository_key = 'repository';
$identifier_key = 'callsign';
$viewer_key = 'user';
$repository = idx($data, $repository_key);
$identifier = idx($data, $identifier_key);
$have_repository = ($repository !== null);
$have_identifier = ($identifier !== null);
if ($have_repository && $have_identifier) {
throw new Exception(
pht(
'Specify "%s" or "%s", but not both.',
$repository_key,
$identifier_key));
}
if (!$have_repository && !$have_identifier) {
throw new Exception(
pht(
'One of "%s" and "%s" is required.',
$repository_key,
$identifier_key));
}
if ($have_repository) {
if (!($repository instanceof PhabricatorRepository)) {
if (empty($data[$viewer_key])) {
throw new Exception(
pht(
'Parameter "%s" is required if "%s" is provided.',
$viewer_key,
$identifier_key));
}
$identifier = $repository;
$repository = null;
}
}
if ($identifier !== null) {
$object = self::newFromIdentifier(
$identifier,
$data[$viewer_key],
idx($data, 'edit'));
} else {
$object = self::newFromRepository($repository);
}
if (!$object) {
return null;
}
$object->initializeFromDictionary($data);
return $object;
}
/**
* Internal.
*
* @task new
*/
private function __construct() {
// <private>
}
/**
* Internal. Use @{method:newFromDictionary}, not this method.
*
* @param string $identifier Repository identifier.
* @param PhabricatorUser $viewer Viewing user.
* @param bool $need_edit (optional)
* @return DiffusionRequest New request object.
* @task new
*/
private static function newFromIdentifier(
$identifier,
PhabricatorUser $viewer,
$need_edit = false) {
$query = id(new PhabricatorRepositoryQuery())
->setViewer($viewer)
->withIdentifiers(array($identifier))
->needProfileImage(true)
->needURIs(true);
if ($need_edit) {
$query->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
));
}
$repository = $query->executeOne();
if (!$repository) {
return null;
}
return self::newFromRepository($repository);
}
/**
* Internal. Use @{method:newFromDictionary}, not this method.
*
* @param PhabricatorRepository $repository Repository object.
* @return DiffusionRequest New request object.
* @task new
*/
private static function newFromRepository(
PhabricatorRepository $repository) {
$map = array(
PhabricatorRepositoryType::REPOSITORY_TYPE_GIT => 'DiffusionGitRequest',
PhabricatorRepositoryType::REPOSITORY_TYPE_SVN => 'DiffusionSvnRequest',
PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL =>
'DiffusionMercurialRequest',
);
$class = idx($map, $repository->getVersionControlSystem());
if (!$class) {
throw new Exception(pht('Unknown version control system!'));
}
$object = new $class();
$object->repository = $repository;
return $object;
}
/**
* Internal. Use @{method:newFromDictionary}, not this method.
*
* @param map $data Map of parsed data.
* @return void
* @task new
*/
private function initializeFromDictionary(array $data) {
$blob = idx($data, 'blob');
if (phutil_nonempty_string($blob)) {
$blob = self::parseRequestBlob($blob, $this->supportsBranches());
$data = $blob + $data;
}
$this->path = idx($data, 'path');
$this->line = idx($data, 'line');
$this->initFromConduit = idx($data, 'initFromConduit', true);
$this->lint = idx($data, 'lint');
$this->symbolicCommit = idx($data, 'commit');
if ($this->supportsBranches()) {
$this->branch = idx($data, 'branch');
}
if (!$this->getUser()) {
$user = idx($data, 'user');
if (!$user) {
throw new Exception(
pht(
'You must provide a %s in the dictionary!',
'PhabricatorUser'));
}
$this->setUser($user);
}
$this->didInitialize();
}
final public function setUser(PhabricatorUser $user) {
$this->user = $user;
return $this;
}
final public function getUser() {
return $this->user;
}
public function getRepository() {
return $this->repository;
}
public function setPath($path) {
$this->path = $path;
return $this;
}
public function getPath() {
return coalesce($this->path, '');
}
public function getLine() {
return $this->line;
}
public function getCommit() {
// TODO: Probably remove all of this.
if ($this->getSymbolicCommit() !== null) {
return $this->getSymbolicCommit();
}
return $this->getStableCommit();
}
/**
* Get the symbolic commit associated with this request.
*
* A symbolic commit may be a commit hash, an abbreviated commit hash, a
* branch name, a tag name, or an expression like "HEAD^^^". The symbolic
* commit may also be absent.
*
* This method always returns the symbol present in the original request,
* in unmodified form.
*
* See also @{method:getStableCommit}.
*
* @return string|null Symbolic commit, if one was present in the request.
*/
public function getSymbolicCommit() {
return $this->symbolicCommit;
}
/**
* Modify the request to move the symbolic commit elsewhere.
*
* @param string $symbol New symbolic commit.
- * @return this
+ * @return $this
*/
public function updateSymbolicCommit($symbol) {
$this->symbolicCommit = $symbol;
$this->symbolicType = null;
$this->stableCommit = null;
return $this;
}
/**
* Get the ref type (`commit` or `tag`) of the location associated with this
* request.
*
* If a symbolic commit is present in the request, this method identifies
* the type of the symbol. Otherwise, it identifies the type of symbol of
* the location the request is implicitly associated with. This will probably
* always be `commit`.
*
* @return string Symbolic commit type (`commit` or `tag`).
*/
public function getSymbolicType() {
if ($this->symbolicType === null) {
// As a side effect, this resolves the symbolic type.
$this->getStableCommit();
}
return $this->symbolicType;
}
/**
* Retrieve the stable, permanent commit name identifying the repository
* location associated with this request.
*
* This returns a non-symbolic identifier for the current commit: in Git and
* Mercurial, a 40-character SHA1; in SVN, a revision number.
*
* See also @{method:getSymbolicCommit}.
*
* @return string Stable commit name, like a git hash or SVN revision. Not
* a symbolic commit reference.
*/
public function getStableCommit() {
if (!$this->stableCommit) {
if ($this->isStableCommit($this->symbolicCommit)) {
$this->stableCommit = $this->symbolicCommit;
$this->symbolicType = 'commit';
} else {
$this->queryStableCommit();
}
}
return $this->stableCommit;
}
public function getBranch() {
return $this->branch;
}
public function getLint() {
return $this->lint;
}
protected function getArcanistBranch() {
return $this->getBranch();
}
public function loadBranch() {
// TODO: Get rid of this and do real Queries on real objects.
if ($this->branchObject === false) {
$this->branchObject = PhabricatorRepositoryBranch::loadBranch(
$this->getRepository()->getID(),
$this->getArcanistBranch());
}
return $this->branchObject;
}
public function loadCoverage() {
// TODO: This should also die.
$branch = $this->loadBranch();
if (!$branch) {
return;
}
$path = $this->getPath();
$path_map = id(new DiffusionPathIDQuery(array($path)))->loadPathIDs();
$coverage_row = queryfx_one(
id(new PhabricatorRepository())->establishConnection('r'),
'SELECT * FROM %T WHERE branchID = %d AND pathID = %d
ORDER BY commitID DESC LIMIT 1',
'repository_coverage',
$branch->getID(),
$path_map[$path]);
if (!$coverage_row) {
return null;
}
return idx($coverage_row, 'coverage');
}
public function loadCommit() {
if (empty($this->repositoryCommit)) {
$repository = $this->getRepository();
$commit = id(new DiffusionCommitQuery())
->setViewer($this->getUser())
->withRepository($repository)
->withIdentifiers(array($this->getStableCommit()))
->executeOne();
if ($commit) {
$commit->attachRepository($repository);
}
$this->repositoryCommit = $commit;
}
return $this->repositoryCommit;
}
public function loadCommitData() {
if (empty($this->repositoryCommitData)) {
$commit = $this->loadCommit();
$data = id(new PhabricatorRepositoryCommitData())->loadOneWhere(
'commitID = %d',
$commit->getID());
if (!$data) {
$data = new PhabricatorRepositoryCommitData();
$data->setCommitMessage(
pht('(This commit has not been fully parsed yet.)'));
}
$this->repositoryCommitData = $data;
}
return $this->repositoryCommitData;
}
/* -( Managing Diffusion URIs )-------------------------------------------- */
public function generateURI(array $params) {
if (empty($params['stable'])) {
$default_commit = $this->getSymbolicCommit();
} else {
$default_commit = $this->getStableCommit();
}
$defaults = array(
'path' => $this->getPath(),
'branch' => $this->getBranch(),
'commit' => $default_commit,
'lint' => idx($params, 'lint', $this->getLint()),
);
foreach ($defaults as $key => $val) {
if (!isset($params[$key])) { // Overwrite NULL.
$params[$key] = $val;
}
}
return $this->getRepository()->generateURI($params);
}
/**
* Internal. Public only for unit tests.
*
* Parse the request URI into components.
*
* @param string $blob URI blob.
* @param bool $supports_branches True if this VCS supports branches.
* @return map Parsed URI.
*
* @task uri
*/
public static function parseRequestBlob($blob, $supports_branches) {
$result = array(
'branch' => null,
'path' => null,
'commit' => null,
'line' => null,
);
$matches = null;
if ($supports_branches) {
// Consume the front part of the URI, up to the first "/". This is the
// path-component encoded branch name.
if (preg_match('@^([^/]+)/@', $blob, $matches)) {
$result['branch'] = phutil_unescape_uri_path_component($matches[1]);
$blob = substr($blob, strlen($matches[1]) + 1);
}
}
// Consume the back part of the URI, up to the first "$". Use a negative
// lookbehind to prevent matching '$$'. We double the '$' symbol when
// encoding so that files with names like "money/$100" will survive.
$pattern = '@(?:(?:^|[^$])(?:[$][$])*)[$]([\d,-]+)$@';
if (preg_match($pattern, $blob, $matches)) {
$result['line'] = $matches[1];
$blob = substr($blob, 0, -(strlen($matches[1]) + 1));
}
// We've consumed the line number if it exists, so unescape "$" in the
// rest of the string.
$blob = str_replace('$$', '$', $blob);
// Consume the commit name, stopping on ';;'. We allow any character to
// appear in commits names, as they can sometimes be symbolic names (like
// tag names or refs).
if (preg_match('@(?:(?:^|[^;])(?:;;)*);([^;].*)$@', $blob, $matches)) {
$result['commit'] = $matches[1];
$blob = substr($blob, 0, -(strlen($matches[1]) + 1));
}
// We've consumed the commit if it exists, so unescape ";" in the rest
// of the string.
$blob = str_replace(';;', ';', $blob);
if (strlen($blob)) {
$result['path'] = $blob;
}
if ($result['path'] !== null) {
$parts = explode('/', $result['path']);
foreach ($parts as $part) {
// Prevent any hyjinx since we're ultimately shipping this to the
// filesystem under a lot of workflows.
if ($part == '..') {
throw new Exception(pht('Invalid path URI.'));
}
}
}
return $result;
}
/**
* Check that the working copy of the repository is present and readable.
*
* @param string $path Path to the working copy.
*/
protected function validateWorkingCopy($path) {
if (!is_readable(dirname($path))) {
$this->raisePermissionException();
}
if (!Filesystem::pathExists($path)) {
$this->raiseCloneException();
}
}
protected function raisePermissionException() {
$host = php_uname('n');
throw new DiffusionSetupException(
pht(
'The clone of this repository ("%s") on the local machine ("%s") '.
'could not be read. Ensure that the repository is in a '.
'location where the web server has read permissions.',
$this->getRepository()->getDisplayName(),
$host));
}
protected function raiseCloneException() {
$host = php_uname('n');
throw new DiffusionSetupException(
pht(
'The working copy for this repository ("%s") has not been cloned yet '.
'on this machine ("%s"). Make sure you have started the '.
'daemons. If this problem persists for longer than a clone should '.
'take, check the daemon logs (in the Daemon Console) to see if there '.
'were errors cloning the repository. Consult the "Diffusion User '.
'Guide" in the documentation for help setting up repositories.',
$this->getRepository()->getDisplayName(),
$host));
}
private function queryStableCommit() {
$types = array();
if ($this->symbolicCommit) {
$ref = $this->symbolicCommit;
} else {
if ($this->supportsBranches()) {
$ref = $this->getBranch();
$types = array(
PhabricatorRepositoryRefCursor::TYPE_BRANCH,
);
} else {
$ref = 'HEAD';
}
}
$results = $this->resolveRefs(array($ref), $types);
$matches = idx($results, $ref, array());
if (!$matches) {
$message = pht(
'Ref "%s" does not exist in this repository.',
$ref);
throw id(new DiffusionRefNotFoundException($message))
->setRef($ref);
}
if (count($matches) > 1) {
$match = $this->chooseBestRefMatch($ref, $matches);
} else {
$match = head($matches);
}
$this->stableCommit = $match['identifier'];
$this->symbolicType = $match['type'];
}
public function getRefAlternatives() {
// Make sure we've resolved the reference into a stable commit first.
try {
$this->getStableCommit();
} catch (DiffusionRefNotFoundException $ex) {
// If we have a bad reference, just return the empty set of
// alternatives.
}
return $this->refAlternatives;
}
private function chooseBestRefMatch($ref, array $results) {
// First, filter out less-desirable matches.
$candidates = array();
foreach ($results as $result) {
// Exclude closed heads.
if ($result['type'] == 'branch') {
if (idx($result, 'closed')) {
continue;
}
}
$candidates[] = $result;
}
// If we filtered everything, undo the filtering.
if (!$candidates) {
$candidates = $results;
}
// TODO: Do a better job of selecting the best match?
$match = head($candidates);
// After choosing the best alternative, save all the alternatives so the
// UI can show them to the user.
if (count($candidates) > 1) {
$this->refAlternatives = $candidates;
}
return $match;
}
public function resolveRefs(array $refs, array $types = array()) {
// First, try to resolve refs from fast cache sources.
$cached_query = id(new DiffusionCachedResolveRefsQuery())
->setRepository($this->getRepository())
->withRefs($refs);
if ($types) {
$cached_query->withTypes($types);
}
$cached_results = $cached_query->execute();
// Throw away all the refs we resolved. Hopefully, we'll throw away
// everything here.
foreach ($refs as $key => $ref) {
if (isset($cached_results[$ref])) {
unset($refs[$key]);
}
}
// If we couldn't pull everything out of the cache, execute the underlying
// VCS operation.
if ($refs) {
$vcs_results = DiffusionQuery::callConduitWithDiffusionRequest(
$this->getUser(),
$this,
'diffusion.resolverefs',
array(
'types' => $types,
'refs' => $refs,
));
} else {
$vcs_results = array();
}
return $vcs_results + $cached_results;
}
public function setIsClusterRequest($is_cluster_request) {
$this->isClusterRequest = $is_cluster_request;
return $this;
}
public function getIsClusterRequest() {
return $this->isClusterRequest;
}
}
diff --git a/src/applications/diviner/query/DivinerAtomQuery.php b/src/applications/diviner/query/DivinerAtomQuery.php
index 278b0a4234..e3d695e5dc 100644
--- a/src/applications/diviner/query/DivinerAtomQuery.php
+++ b/src/applications/diviner/query/DivinerAtomQuery.php
@@ -1,515 +1,515 @@
<?php
final class DivinerAtomQuery extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $bookPHIDs;
private $names;
private $types;
private $contexts;
private $indexes;
private $isDocumentable;
private $isGhost;
private $nodeHashes;
private $titles;
private $nameContains;
private $repositoryPHIDs;
private $needAtoms;
private $needExtends;
private $needChildren;
private $needRepositories;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withBookPHIDs(array $phids) {
$this->bookPHIDs = $phids;
return $this;
}
public function withTypes(array $types) {
$this->types = $types;
return $this;
}
public function withNames(array $names) {
$this->names = $names;
return $this;
}
public function withContexts(array $contexts) {
$this->contexts = $contexts;
return $this;
}
public function withIndexes(array $indexes) {
$this->indexes = $indexes;
return $this;
}
public function withNodeHashes(array $hashes) {
$this->nodeHashes = $hashes;
return $this;
}
public function withTitles($titles) {
$this->titles = $titles;
return $this;
}
public function withNameContains($text) {
$this->nameContains = $text;
return $this;
}
public function needAtoms($need) {
$this->needAtoms = $need;
return $this;
}
public function needChildren($need) {
$this->needChildren = $need;
return $this;
}
/**
* Include or exclude "ghosts", which are symbols which used to exist but do
* not exist currently (for example, a function which existed in an older
* version of the codebase but was deleted).
*
* These symbols had PHIDs assigned to them, and may have other sorts of
* metadata that we don't want to lose (like comments or flags), so we don't
* delete them outright. They might also come back in the future: the change
* which deleted the symbol might be reverted, or the documentation might
* have been generated incorrectly by accident. In these cases, we can
* restore the original data.
*
* @param bool $ghosts
- * @return this
+ * @return $this
*/
public function withGhosts($ghosts) {
$this->isGhost = $ghosts;
return $this;
}
public function needExtends($need) {
$this->needExtends = $need;
return $this;
}
public function withIsDocumentable($documentable) {
$this->isDocumentable = $documentable;
return $this;
}
public function withRepositoryPHIDs(array $repository_phids) {
$this->repositoryPHIDs = $repository_phids;
return $this;
}
public function needRepositories($need_repositories) {
$this->needRepositories = $need_repositories;
return $this;
}
protected function loadPage() {
$table = new DivinerLiveSymbol();
$conn_r = $table->establishConnection('r');
$data = queryfx_all(
$conn_r,
'SELECT * FROM %T %Q %Q %Q',
$table->getTableName(),
$this->buildWhereClause($conn_r),
$this->buildOrderClause($conn_r),
$this->buildLimitClause($conn_r));
return $table->loadAllFromArray($data);
}
protected function willFilterPage(array $atoms) {
assert_instances_of($atoms, 'DivinerLiveSymbol');
$books = array_unique(mpull($atoms, 'getBookPHID'));
$books = id(new DivinerBookQuery())
->setViewer($this->getViewer())
->withPHIDs($books)
->execute();
$books = mpull($books, null, 'getPHID');
foreach ($atoms as $key => $atom) {
$book = idx($books, $atom->getBookPHID());
if (!$book) {
$this->didRejectResult($atom);
unset($atoms[$key]);
continue;
}
$atom->attachBook($book);
}
if ($this->needAtoms) {
$atom_data = id(new DivinerLiveAtom())->loadAllWhere(
'symbolPHID IN (%Ls)',
mpull($atoms, 'getPHID'));
$atom_data = mpull($atom_data, null, 'getSymbolPHID');
foreach ($atoms as $key => $atom) {
$data = idx($atom_data, $atom->getPHID());
$atom->attachAtom($data);
}
}
// Load all of the symbols this symbol extends, recursively. Commonly,
// this means all the ancestor classes and interfaces it extends and
// implements.
if ($this->needExtends) {
// First, load all the matching symbols by name. This does 99% of the
// work in most cases, assuming things are named at all reasonably.
$names = array();
foreach ($atoms as $atom) {
if (!$atom->getAtom()) {
continue;
}
foreach ($atom->getAtom()->getExtends() as $xref) {
$names[] = $xref->getName();
}
}
if ($names) {
$xatoms = id(new DivinerAtomQuery())
->setViewer($this->getViewer())
->withNames($names)
->withGhosts(false)
->needExtends(true)
->needAtoms(true)
->needChildren($this->needChildren)
->execute();
$xatoms = mgroup($xatoms, 'getName', 'getType', 'getBookPHID');
} else {
$xatoms = array();
}
foreach ($atoms as $atom) {
$atom_lang = null;
$atom_extends = array();
if ($atom->getAtom()) {
$atom_lang = $atom->getAtom()->getLanguage();
$atom_extends = $atom->getAtom()->getExtends();
}
$extends = array();
foreach ($atom_extends as $xref) {
// If there are no symbols of the matching name and type, we can't
// resolve this.
if (empty($xatoms[$xref->getName()][$xref->getType()])) {
continue;
}
// If we found matches in the same documentation book, prefer them
// over other matches. Otherwise, look at all the matches.
$matches = $xatoms[$xref->getName()][$xref->getType()];
if (isset($matches[$atom->getBookPHID()])) {
$maybe = $matches[$atom->getBookPHID()];
} else {
$maybe = array_mergev($matches);
}
if (!$maybe) {
continue;
}
// Filter out matches in a different language, since, e.g., PHP
// classes can not implement JS classes.
$same_lang = array();
foreach ($maybe as $xatom) {
if ($xatom->getAtom()->getLanguage() == $atom_lang) {
$same_lang[] = $xatom;
}
}
if (!$same_lang) {
continue;
}
// If we have duplicates remaining, just pick the first one. There's
// nothing more we can do to figure out which is the real one.
$extends[] = head($same_lang);
}
$atom->attachExtends($extends);
}
}
if ($this->needChildren) {
$child_hashes = $this->getAllChildHashes($atoms, $this->needExtends);
if ($child_hashes) {
$children = id(new DivinerAtomQuery())
->setViewer($this->getViewer())
->withNodeHashes($child_hashes)
->needAtoms($this->needAtoms)
->execute();
$children = mpull($children, null, 'getNodeHash');
} else {
$children = array();
}
$this->attachAllChildren($atoms, $children, $this->needExtends);
}
if ($this->needRepositories) {
$repositories = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withPHIDs(mpull($atoms, 'getRepositoryPHID'))
->execute();
$repositories = mpull($repositories, null, 'getPHID');
foreach ($atoms as $key => $atom) {
if ($atom->getRepositoryPHID() === null) {
$atom->attachRepository(null);
continue;
}
$repository = idx($repositories, $atom->getRepositoryPHID());
if (!$repository) {
$this->didRejectResult($atom);
unset($atom[$key]);
continue;
}
$atom->attachRepository($repository);
}
}
return $atoms;
}
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
if ($this->ids) {
$where[] = qsprintf(
$conn,
'id IN (%Ld)',
$this->ids);
}
if ($this->phids) {
$where[] = qsprintf(
$conn,
'phid IN (%Ls)',
$this->phids);
}
if ($this->bookPHIDs) {
$where[] = qsprintf(
$conn,
'bookPHID IN (%Ls)',
$this->bookPHIDs);
}
if ($this->types) {
$where[] = qsprintf(
$conn,
'type IN (%Ls)',
$this->types);
}
if ($this->names) {
$where[] = qsprintf(
$conn,
'name IN (%Ls)',
$this->names);
}
if ($this->titles) {
$hashes = array();
foreach ($this->titles as $title) {
$slug = DivinerAtomRef::normalizeTitleString($title);
$hash = PhabricatorHash::digestForIndex($slug);
$hashes[] = $hash;
}
$where[] = qsprintf(
$conn,
'titleSlugHash in (%Ls)',
$hashes);
}
if ($this->contexts) {
$with_null = false;
$contexts = $this->contexts;
foreach ($contexts as $key => $value) {
if ($value === null) {
unset($contexts[$key]);
$with_null = true;
continue;
}
}
if ($contexts && $with_null) {
$where[] = qsprintf(
$conn,
'context IN (%Ls) OR context IS NULL',
$contexts);
} else if ($contexts) {
$where[] = qsprintf(
$conn,
'context IN (%Ls)',
$contexts);
} else if ($with_null) {
$where[] = qsprintf(
$conn,
'context IS NULL');
}
}
if ($this->indexes) {
$where[] = qsprintf(
$conn,
'atomIndex IN (%Ld)',
$this->indexes);
}
if ($this->isDocumentable !== null) {
$where[] = qsprintf(
$conn,
'isDocumentable = %d',
(int)$this->isDocumentable);
}
if ($this->isGhost !== null) {
if ($this->isGhost) {
$where[] = qsprintf($conn, 'graphHash IS NULL');
} else {
$where[] = qsprintf($conn, 'graphHash IS NOT NULL');
}
}
if ($this->nodeHashes) {
$where[] = qsprintf(
$conn,
'nodeHash IN (%Ls)',
$this->nodeHashes);
}
if ($this->nameContains) {
// NOTE: This `CONVERT()` call makes queries case-insensitive, since
// the column has binary collation. Eventually, this should move into
// fulltext.
$where[] = qsprintf(
$conn,
'CONVERT(name USING utf8) LIKE %~',
$this->nameContains);
}
if ($this->repositoryPHIDs) {
$where[] = qsprintf(
$conn,
'repositoryPHID IN (%Ls)',
$this->repositoryPHIDs);
}
$where[] = $this->buildPagingClause($conn);
return $this->formatWhereClause($conn, $where);
}
/**
* Walk a list of atoms and collect all the node hashes of the atoms'
* children. When recursing, also walk up the tree and collect children of
* atoms they extend.
*
* @param list<DivinerLiveSymbol> $symbols List of symbols to collect child
* hashes of.
* @param bool $recurse_up True to collect children of
* extended atoms, as well.
* @return map<string, string> Hashes of atoms' children.
*/
private function getAllChildHashes(array $symbols, $recurse_up) {
assert_instances_of($symbols, 'DivinerLiveSymbol');
$hashes = array();
foreach ($symbols as $symbol) {
$child_hashes = array();
if ($symbol->getAtom()) {
$child_hashes = $symbol->getAtom()->getChildHashes();
}
foreach ($child_hashes as $hash) {
$hashes[$hash] = $hash;
}
if ($recurse_up) {
$hashes += $this->getAllChildHashes($symbol->getExtends(), true);
}
}
return $hashes;
}
/**
* Attach child atoms to existing atoms. In recursive mode, also attach child
* atoms to atoms that these atoms extend.
*
* @param list<DivinerLiveSymbol> $symbols List of symbols to attach children
* to.
* @param map<string, DivinerLiveSymbol> $children Map of symbols, keyed by
* node hash.
* @param bool $recurse_up True to attach children to extended atoms, as
* well.
* @return void
*/
private function attachAllChildren(
array $symbols,
array $children,
$recurse_up) {
assert_instances_of($symbols, 'DivinerLiveSymbol');
assert_instances_of($children, 'DivinerLiveSymbol');
foreach ($symbols as $symbol) {
$child_hashes = array();
$symbol_children = array();
if ($symbol->getAtom()) {
$child_hashes = $symbol->getAtom()->getChildHashes();
}
foreach ($child_hashes as $hash) {
if (isset($children[$hash])) {
$symbol_children[] = $children[$hash];
}
}
$symbol->attachChildren($symbol_children);
if ($recurse_up) {
$this->attachAllChildren($symbol->getExtends(), $children, true);
}
}
}
public function getQueryApplicationClass() {
return PhabricatorDivinerApplication::class;
}
}
diff --git a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
index ddd45ce802..8bcbb08285 100644
--- a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
+++ b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
@@ -1,102 +1,102 @@
<?php
/**
* @task config Configuration
*/
abstract class DoorkeeperFeedStoryPublisher extends Phobject {
private $feedStory;
private $viewer;
private $renderWithImpliedContext;
/* -( Configuration )------------------------------------------------------ */
/**
* Render story text using contextual language to identify the object the
* story is about, instead of the full object name. For example, without
* contextual language a story might render like this:
*
* alincoln created D123: Chop Wood for Log Cabin v2.0
*
* With contextual language, it will render like this instead:
*
* alincoln created this revision.
*
* If the interface where the text will be displayed is specific to an
* individual object (like Asana tasks that represent one review or commit
* are), it's generally more natural to use language that assumes context.
* If the target context may show information about several objects (like
* JIRA issues which can have several linked revisions), it's generally
* more useful not to assume context.
*
* @param bool $render_with_implied_context True to assume object context
* when rendering.
- * @return this
+ * @return $this
* @task config
*/
public function setRenderWithImpliedContext($render_with_implied_context) {
$this->renderWithImpliedContext = $render_with_implied_context;
return $this;
}
/**
* Determine if rendering should assume object context. For discussion, see
* @{method:setRenderWithImpliedContext}.
*
* @return bool True if rendering should assume object context is implied.
* @task config
*/
public function getRenderWithImpliedContext() {
return $this->renderWithImpliedContext;
}
public function setFeedStory(PhabricatorFeedStory $feed_story) {
$this->feedStory = $feed_story;
return $this;
}
public function getFeedStory() {
return $this->feedStory;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
abstract public function canPublishStory(
PhabricatorFeedStory $story,
$object);
/**
* Hook for publishers to mutate the story object, particularly by loading
* and attaching additional data.
*/
public function willPublishStory($object) {
return $object;
}
public function getStoryText($object) {
return $this->getFeedStory()->renderAsTextForDoorkeeper($this);
}
abstract public function isStoryAboutObjectCreation($object);
abstract public function isStoryAboutObjectClosure($object);
abstract public function getOwnerPHID($object);
abstract public function getActiveUserPHIDs($object);
abstract public function getPassiveUserPHIDs($object);
abstract public function getCCUserPHIDs($object);
abstract public function getObjectTitle($object);
abstract public function getObjectURI($object);
abstract public function getObjectDescription($object);
abstract public function isObjectClosed($object);
abstract public function getResponsibilityTitle($object);
}
diff --git a/src/applications/drydock/storage/DrydockLease.php b/src/applications/drydock/storage/DrydockLease.php
index 12a18b3544..cdf357863c 100644
--- a/src/applications/drydock/storage/DrydockLease.php
+++ b/src/applications/drydock/storage/DrydockLease.php
@@ -1,668 +1,668 @@
<?php
final class DrydockLease extends DrydockDAO
implements
PhabricatorPolicyInterface,
PhabricatorConduitResultInterface {
protected $resourcePHID;
protected $resourceType;
protected $until;
protected $ownerPHID;
protected $authorizingPHID;
protected $attributes = array();
protected $status = DrydockLeaseStatus::STATUS_PENDING;
protected $acquiredEpoch;
protected $activatedEpoch;
private $resource = self::ATTACHABLE;
private $unconsumedCommands = self::ATTACHABLE;
private $releaseOnDestruction;
private $isAcquired = false;
private $isActivated = false;
private $activateWhenAcquired = false;
private $slotLocks = array();
public static function initializeNewLease() {
$lease = new DrydockLease();
// Pregenerate a PHID so that the caller can set something up to release
// this lease before queueing it for activation.
$lease->setPHID($lease->generatePHID());
return $lease;
}
/**
* Flag this lease to be released when its destructor is called. This is
* mostly useful if you have a script which acquires, uses, and then releases
* a lease, as you don't need to explicitly handle exceptions to properly
* release the lease.
*/
public function setReleaseOnDestruction($release) {
$this->releaseOnDestruction = $release;
return $this;
}
public function __destruct() {
if (!$this->releaseOnDestruction) {
return;
}
if (!$this->canRelease()) {
return;
}
$actor = PhabricatorUser::getOmnipotentUser();
$drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
$command = DrydockCommand::initializeNewCommand($actor)
->setTargetPHID($this->getPHID())
->setAuthorPHID($drydock_phid)
->setCommand(DrydockCommand::COMMAND_RELEASE)
->save();
$this->scheduleUpdate();
}
public function setStatus($status) {
if ($status == DrydockLeaseStatus::STATUS_ACQUIRED) {
if (!$this->getAcquiredEpoch()) {
$this->setAcquiredEpoch(PhabricatorTime::getNow());
}
}
if ($status == DrydockLeaseStatus::STATUS_ACTIVE) {
if (!$this->getActivatedEpoch()) {
$this->setActivatedEpoch(PhabricatorTime::getNow());
}
}
return parent::setStatus($status);
}
public function getLeaseName() {
return pht('Lease %d', $this->getID());
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'attributes' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'status' => 'text32',
'until' => 'epoch?',
'resourceType' => 'text128',
'ownerPHID' => 'phid?',
'resourcePHID' => 'phid?',
'acquiredEpoch' => 'epoch?',
'activatedEpoch' => 'epoch?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_resource' => array(
'columns' => array('resourcePHID', 'status'),
),
'key_status' => array(
'columns' => array('status'),
),
'key_owner' => array(
'columns' => array('ownerPHID'),
),
'key_recent' => array(
'columns' => array('resourcePHID', 'dateModified'),
),
),
) + parent::getConfiguration();
}
public function setAttribute($key, $value) {
$this->attributes[$key] = $value;
return $this;
}
public function getAttribute($key, $default = null) {
return idx($this->attributes, $key, $default);
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(DrydockLeasePHIDType::TYPECONST);
}
public function getInterface($type) {
return $this->getResource()->getInterface($this, $type);
}
public function getResource() {
return $this->assertAttached($this->resource);
}
public function attachResource(DrydockResource $resource = null) {
$this->resource = $resource;
return $this;
}
public function hasAttachedResource() {
return ($this->resource !== null);
}
public function getUnconsumedCommands() {
return $this->assertAttached($this->unconsumedCommands);
}
public function attachUnconsumedCommands(array $commands) {
$this->unconsumedCommands = $commands;
return $this;
}
public function isReleasing() {
foreach ($this->getUnconsumedCommands() as $command) {
if ($command->getCommand() == DrydockCommand::COMMAND_RELEASE) {
return true;
}
}
return false;
}
public function queueForActivation() {
if ($this->getID()) {
throw new Exception(
pht('Only new leases may be queued for activation!'));
}
if (!$this->getAuthorizingPHID()) {
throw new Exception(
pht(
'Trying to queue a lease for activation without an authorizing '.
'object. Use "%s" to specify the PHID of the authorizing object. '.
'The authorizing object must be approved to use the allowed '.
'blueprints.',
'setAuthorizingPHID()'));
}
if (!$this->getAllowedBlueprintPHIDs()) {
throw new Exception(
pht(
'Trying to queue a lease for activation without any allowed '.
'Blueprints. Use "%s" to specify allowed blueprints. The '.
'authorizing object must be approved to use the allowed blueprints.',
'setAllowedBlueprintPHIDs()'));
}
$this
->setStatus(DrydockLeaseStatus::STATUS_PENDING)
->save();
$this->scheduleUpdate();
$this->logEvent(DrydockLeaseQueuedLogType::LOGCONST);
return $this;
}
public function setActivateWhenAcquired($activate) {
$this->activateWhenAcquired = true;
return $this;
}
public function needSlotLock($key) {
$this->slotLocks[] = $key;
return $this;
}
public function acquireOnResource(DrydockResource $resource) {
$expect_status = DrydockLeaseStatus::STATUS_PENDING;
$actual_status = $this->getStatus();
if ($actual_status != $expect_status) {
throw new Exception(
pht(
'Trying to acquire a lease on a resource which is in the wrong '.
'state: status must be "%s", actually "%s".',
$expect_status,
$actual_status));
}
if ($this->activateWhenAcquired) {
$new_status = DrydockLeaseStatus::STATUS_ACTIVE;
} else {
$new_status = DrydockLeaseStatus::STATUS_ACQUIRED;
}
if ($new_status == DrydockLeaseStatus::STATUS_ACTIVE) {
if ($resource->getStatus() == DrydockResourceStatus::STATUS_PENDING) {
throw new Exception(
pht(
'Trying to acquire an active lease on a pending resource. '.
'You can not immediately activate leases on resources which '.
'need time to start up.'));
}
}
// Before we associate the lease with the resource, we lock the resource
// and reload it to make sure it is still pending or active. If we don't
// do this, the resource may have just been reclaimed. (Once we acquire
// the resource that stops it from being released, so we're nearly safe.)
$resource_phid = $resource->getPHID();
$hash = PhabricatorHash::digestForIndex($resource_phid);
$lock_key = 'drydock.resource:'.$hash;
$lock = PhabricatorGlobalLock::newLock($lock_key);
try {
$lock->lock(15);
} catch (Exception $ex) {
throw new DrydockResourceLockException(
pht(
'Failed to acquire lock for resource ("%s") while trying to '.
'acquire lease ("%s").',
$resource->getPHID(),
$this->getPHID()));
}
$resource->reload();
if (($resource->getStatus() !== DrydockResourceStatus::STATUS_ACTIVE) &&
($resource->getStatus() !== DrydockResourceStatus::STATUS_PENDING)) {
throw new DrydockAcquiredBrokenResourceException(
pht(
'Trying to acquire lease ("%s") on a resource ("%s") in the '.
'wrong status ("%s").',
$this->getPHID(),
$resource->getPHID(),
$resource->getStatus()));
}
$caught = null;
try {
$this->openTransaction();
try {
DrydockSlotLock::acquireLocks($this->getPHID(), $this->slotLocks);
$this->slotLocks = array();
} catch (DrydockSlotLockException $ex) {
$this->killTransaction();
$this->logEvent(
DrydockSlotLockFailureLogType::LOGCONST,
array(
'locks' => $ex->getLockMap(),
));
throw $ex;
}
$this
->setResourcePHID($resource->getPHID())
->attachResource($resource)
->setStatus($new_status)
->save();
$this->saveTransaction();
} catch (Exception $ex) {
$caught = $ex;
}
$lock->unlock();
if ($caught) {
throw $caught;
}
$this->isAcquired = true;
$this->logEvent(DrydockLeaseAcquiredLogType::LOGCONST);
if ($new_status == DrydockLeaseStatus::STATUS_ACTIVE) {
$this->didActivate();
}
return $this;
}
public function isAcquiredLease() {
return $this->isAcquired;
}
public function activateOnResource(DrydockResource $resource) {
$expect_status = DrydockLeaseStatus::STATUS_ACQUIRED;
$actual_status = $this->getStatus();
if ($actual_status != $expect_status) {
throw new Exception(
pht(
'Trying to activate a lease which has the wrong status: status '.
'must be "%s", actually "%s".',
$expect_status,
$actual_status));
}
if ($resource->getStatus() == DrydockResourceStatus::STATUS_PENDING) {
// TODO: Be stricter about this?
throw new Exception(
pht(
'Trying to activate a lease on a pending resource.'));
}
$this->openTransaction();
try {
DrydockSlotLock::acquireLocks($this->getPHID(), $this->slotLocks);
$this->slotLocks = array();
} catch (DrydockSlotLockException $ex) {
$this->killTransaction();
$this->logEvent(
DrydockSlotLockFailureLogType::LOGCONST,
array(
'locks' => $ex->getLockMap(),
));
throw $ex;
}
$this
->setStatus(DrydockLeaseStatus::STATUS_ACTIVE)
->save();
$this->saveTransaction();
$this->isActivated = true;
$this->didActivate();
return $this;
}
public function isActivatedLease() {
return $this->isActivated;
}
public function scheduleUpdate($epoch = null) {
PhabricatorWorker::scheduleTask(
'DrydockLeaseUpdateWorker',
array(
'leasePHID' => $this->getPHID(),
'isExpireTask' => ($epoch !== null),
),
array(
'objectPHID' => $this->getPHID(),
'delayUntil' => ($epoch ? (int)$epoch : null),
));
}
public function getAllocatedResourcePHIDs() {
return $this->getAttribute('internal.resourcePHIDs.allocated', array());
}
public function setAllocatedResourcePHIDs(array $phids) {
return $this->setAttribute('internal.resourcePHIDs.allocated', $phids);
}
public function addAllocatedResourcePHIDs(array $phids) {
$allocated_phids = $this->getAllocatedResourcePHIDs();
foreach ($phids as $phid) {
$allocated_phids[$phid] = $phid;
}
return $this->setAllocatedResourcePHIDs($allocated_phids);
}
public function removeAllocatedResourcePHIDs(array $phids) {
$allocated_phids = $this->getAllocatedResourcePHIDs();
foreach ($phids as $phid) {
unset($allocated_phids[$phid]);
}
return $this->setAllocatedResourcePHIDs($allocated_phids);
}
public function getReclaimedResourcePHIDs() {
return $this->getAttribute('internal.resourcePHIDs.reclaimed', array());
}
public function setReclaimedResourcePHIDs(array $phids) {
return $this->setAttribute('internal.resourcePHIDs.reclaimed', $phids);
}
public function addReclaimedResourcePHIDs(array $phids) {
$reclaimed_phids = $this->getReclaimedResourcePHIDs();
foreach ($phids as $phid) {
$reclaimed_phids[$phid] = $phid;
}
return $this->setReclaimedResourcePHIDs($reclaimed_phids);
}
public function removeReclaimedResourcePHIDs(array $phids) {
$reclaimed_phids = $this->getReclaimedResourcePHIDs();
foreach ($phids as $phid) {
unset($reclaimed_phids[$phid]);
}
return $this->setReclaimedResourcePHIDs($reclaimed_phids);
}
public function setAwakenTaskIDs(array $ids) {
$this->setAttribute('internal.awakenTaskIDs', $ids);
return $this;
}
public function setAllowedBlueprintPHIDs(array $phids) {
$this->setAttribute('internal.blueprintPHIDs', $phids);
return $this;
}
public function getAllowedBlueprintPHIDs() {
return $this->getAttribute('internal.blueprintPHIDs', array());
}
private function didActivate() {
$viewer = PhabricatorUser::getOmnipotentUser();
$need_update = false;
$this->logEvent(DrydockLeaseActivatedLogType::LOGCONST);
$commands = id(new DrydockCommandQuery())
->setViewer($viewer)
->withTargetPHIDs(array($this->getPHID()))
->withConsumed(false)
->execute();
if ($commands) {
$need_update = true;
}
if ($need_update) {
$this->scheduleUpdate();
}
$expires = $this->getUntil();
if ($expires) {
$this->scheduleUpdate($expires);
}
$this->awakenTasks();
}
public function logEvent($type, array $data = array()) {
$log = id(new DrydockLog())
->setEpoch(PhabricatorTime::getNow())
->setType($type)
->setData($data);
$log->setLeasePHID($this->getPHID());
$resource_phid = $this->getResourcePHID();
if ($resource_phid) {
$resource = $this->getResource();
$log->setResourcePHID($resource->getPHID());
$log->setBlueprintPHID($resource->getBlueprintPHID());
}
return $log->save();
}
/**
* Awaken yielded tasks after a state change.
*
- * @return this
+ * @return $this
*/
public function awakenTasks() {
$awaken_ids = $this->getAttribute('internal.awakenTaskIDs');
if (is_array($awaken_ids) && $awaken_ids) {
PhabricatorWorker::awakenTaskIDs($awaken_ids);
}
return $this;
}
public function getURI() {
$id = $this->getID();
return "/drydock/lease/{$id}/";
}
public function getDisplayName() {
return pht('Drydock Lease %d', $this->getID());
}
/* -( Status )------------------------------------------------------------- */
public function getStatusObject() {
return DrydockLeaseStatus::newStatusObject($this->getStatus());
}
public function getStatusIcon() {
return $this->getStatusObject()->getIcon();
}
public function getStatusColor() {
return $this->getStatusObject()->getColor();
}
public function getStatusDisplayName() {
return $this->getStatusObject()->getDisplayName();
}
public function isActivating() {
return $this->getStatusObject()->isActivating();
}
public function isActive() {
return $this->getStatusObject()->isActive();
}
public function canRelease() {
if (!$this->getID()) {
return false;
}
return $this->getStatusObject()->canRelease();
}
public function canReceiveCommands() {
return $this->getStatusObject()->canReceiveCommands();
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
if ($this->getResource()) {
return $this->getResource()->getPolicy($capability);
}
// TODO: Implement reasonable policies.
return PhabricatorPolicies::getMostOpenPolicy();
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
if ($this->getResource()) {
return $this->getResource()->hasAutomaticCapability($capability, $viewer);
}
return false;
}
public function describeAutomaticCapability($capability) {
return pht('Leases inherit policies from the resources they lease.');
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('resourcePHID')
->setType('phid?')
->setDescription(pht('PHID of the leased resource, if any.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('resourceType')
->setType('string')
->setDescription(pht('Type of resource being leased.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('until')
->setType('int?')
->setDescription(pht('Epoch at which this lease expires, if any.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('ownerPHID')
->setType('phid?')
->setDescription(pht('The PHID of the object that owns this lease.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('authorizingPHID')
->setType('phid')
->setDescription(pht(
'The PHID of the object that authorized this lease.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('status')
->setType('map<string, wild>')
->setDescription(pht(
"The string constant and name of this lease's status.")),
);
}
public function getFieldValuesForConduit() {
$status = $this->getStatus();
$until = $this->getUntil();
if ($until) {
$until = (int)$until;
} else {
$until = null;
}
return array(
'resourcePHID' => $this->getResourcePHID(),
'resourceType' => $this->getResourceType(),
'until' => $until,
'ownerPHID' => $this->getOwnerPHID(),
'authorizingPHID' => $this->getAuthorizingPHID(),
'status' => array(
'value' => $status,
'name' => DrydockLeaseStatus::getNameForStatus($status),
),
);
}
public function getConduitSearchAttachments() {
return array();
}
}
diff --git a/src/applications/files/query/PhabricatorFileQuery.php b/src/applications/files/query/PhabricatorFileQuery.php
index bb81d54b7d..f0dee1175b 100644
--- a/src/applications/files/query/PhabricatorFileQuery.php
+++ b/src/applications/files/query/PhabricatorFileQuery.php
@@ -1,547 +1,547 @@
<?php
final class PhabricatorFileQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $authorPHIDs;
private $explicitUploads;
private $transforms;
private $dateCreatedAfter;
private $dateCreatedBefore;
private $contentHashes;
private $minLength;
private $maxLength;
private $names;
private $isPartial;
private $isDeleted;
private $needTransforms;
private $builtinKeys;
private $isBuiltin;
private $storageEngines;
private $attachedObjectPHIDs;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withAuthorPHIDs(array $phids) {
$this->authorPHIDs = $phids;
return $this;
}
public function withDateCreatedBefore($date_created_before) {
$this->dateCreatedBefore = $date_created_before;
return $this;
}
public function withDateCreatedAfter($date_created_after) {
$this->dateCreatedAfter = $date_created_after;
return $this;
}
public function withContentHashes(array $content_hashes) {
$this->contentHashes = $content_hashes;
return $this;
}
public function withBuiltinKeys(array $keys) {
$this->builtinKeys = $keys;
return $this;
}
public function withIsBuiltin($is_builtin) {
$this->isBuiltin = $is_builtin;
return $this;
}
public function withAttachedObjectPHIDs(array $phids) {
$this->attachedObjectPHIDs = $phids;
return $this;
}
/**
* Select files which are transformations of some other file. For example,
* you can use this query to find previously generated thumbnails of an image
* file.
*
* As a parameter, provide a list of transformation specifications. Each
* specification is a dictionary with the keys `originalPHID` and `transform`.
* The `originalPHID` is the PHID of the original file (the file which was
* transformed) and the `transform` is the name of the transform to query
* for. If you pass `true` as the `transform`, all transformations of the
* file will be selected.
*
* For example:
*
* array(
* array(
* 'originalPHID' => 'PHID-FILE-aaaa',
* 'transform' => 'sepia',
* ),
* array(
* 'originalPHID' => 'PHID-FILE-bbbb',
* 'transform' => true,
* ),
* )
*
* This selects the `"sepia"` transformation of the file with PHID
* `PHID-FILE-aaaa` and all transformations of the file with PHID
* `PHID-FILE-bbbb`.
*
* @param list<dict> $specs List of transform specifications, described
* above.
- * @return this
+ * @return $this
*/
public function withTransforms(array $specs) {
foreach ($specs as $spec) {
if (!is_array($spec) ||
empty($spec['originalPHID']) ||
empty($spec['transform'])) {
throw new Exception(
pht(
"Transform specification must be a dictionary with keys ".
"'%s' and '%s'!",
'originalPHID',
'transform'));
}
}
$this->transforms = $specs;
return $this;
}
public function withLengthBetween($min, $max) {
$this->minLength = $min;
$this->maxLength = $max;
return $this;
}
public function withNames(array $names) {
$this->names = $names;
return $this;
}
public function withIsPartial($partial) {
$this->isPartial = $partial;
return $this;
}
public function withIsDeleted($deleted) {
$this->isDeleted = $deleted;
return $this;
}
public function withNameNgrams($ngrams) {
return $this->withNgramsConstraint(
id(new PhabricatorFileNameNgrams()),
$ngrams);
}
public function withStorageEngines(array $engines) {
$this->storageEngines = $engines;
return $this;
}
public function showOnlyExplicitUploads($explicit_uploads) {
$this->explicitUploads = $explicit_uploads;
return $this;
}
public function needTransforms(array $transforms) {
$this->needTransforms = $transforms;
return $this;
}
public function newResultObject() {
return new PhabricatorFile();
}
protected function loadPage() {
$files = $this->loadStandardPage($this->newResultObject());
if (!$files) {
return $files;
}
// Figure out which files we need to load attached objects for. In most
// cases, we need to load attached objects to perform policy checks for
// files.
// However, in some special cases where we know files will always be
// visible, we skip this. See T8478 and T13106.
$need_objects = array();
$need_xforms = array();
foreach ($files as $file) {
$always_visible = false;
if ($file->getIsProfileImage()) {
$always_visible = true;
}
if ($file->isBuiltin()) {
$always_visible = true;
}
if ($always_visible) {
// We just treat these files as though they aren't attached to
// anything. This saves a query in common cases when we're loading
// profile images or builtins. We could be slightly more nuanced
// about this and distinguish between "not attached to anything" and
// "might be attached but policy checks don't need to care".
$file->attachObjectPHIDs(array());
continue;
}
$need_objects[] = $file;
$need_xforms[] = $file;
}
$viewer = $this->getViewer();
$is_omnipotent = $viewer->isOmnipotent();
// If we have any files left which do need objects, load the edges now.
$object_phids = array();
if ($need_objects) {
$attachments_map = $this->newAttachmentsMap($need_objects);
foreach ($need_objects as $file) {
$file_phid = $file->getPHID();
$phids = $attachments_map[$file_phid];
$file->attachObjectPHIDs($phids);
if ($is_omnipotent) {
// If the viewer is omnipotent, we don't need to load the associated
// objects either since the viewer can certainly see the object.
// Skipping this can improve performance and prevent cycles. This
// could possibly become part of the profile/builtin code above which
// short circuits attacment policy checks in cases where we know them
// to be unnecessary.
continue;
}
foreach ($phids as $phid) {
$object_phids[$phid] = true;
}
}
}
// If this file is a transform of another file, load that file too. If you
// can see the original file, you can see the thumbnail.
// TODO: It might be nice to put this directly on PhabricatorFile and
// remove the PhabricatorTransformedFile table, which would be a little
// simpler.
if ($need_xforms) {
$xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
'transformedPHID IN (%Ls)',
mpull($need_xforms, 'getPHID'));
$xform_phids = mpull($xforms, 'getOriginalPHID', 'getTransformedPHID');
foreach ($xform_phids as $derived_phid => $original_phid) {
$object_phids[$original_phid] = true;
}
} else {
$xform_phids = array();
}
$object_phids = array_keys($object_phids);
// Now, load the objects.
$objects = array();
if ($object_phids) {
// NOTE: We're explicitly turning policy exceptions off, since the rule
// here is "you can see the file if you can see ANY associated object".
// Without this explicit flag, we'll incorrectly throw unless you can
// see ALL associated objects.
$objects = id(new PhabricatorObjectQuery())
->setParentQuery($this)
->setViewer($this->getViewer())
->withPHIDs($object_phids)
->setRaisePolicyExceptions(false)
->execute();
$objects = mpull($objects, null, 'getPHID');
}
foreach ($files as $file) {
$file_objects = array_select_keys($objects, $file->getObjectPHIDs());
$file->attachObjects($file_objects);
}
foreach ($files as $key => $file) {
$original_phid = idx($xform_phids, $file->getPHID());
if ($original_phid == PhabricatorPHIDConstants::PHID_VOID) {
// This is a special case for builtin files, which are handled
// oddly.
$original = null;
} else if ($original_phid) {
$original = idx($objects, $original_phid);
if (!$original) {
// If the viewer can't see the original file, also prevent them from
// seeing the transformed file.
$this->didRejectResult($file);
unset($files[$key]);
continue;
}
} else {
$original = null;
}
$file->attachOriginalFile($original);
}
return $files;
}
private function newAttachmentsMap(array $files) {
$file_phids = mpull($files, 'getPHID');
$attachments_table = new PhabricatorFileAttachment();
$attachments_conn = $attachments_table->establishConnection('r');
$attachments = queryfx_all(
$attachments_conn,
'SELECT filePHID, objectPHID FROM %R WHERE filePHID IN (%Ls)
AND attachmentMode IN (%Ls)',
$attachments_table,
$file_phids,
array(
PhabricatorFileAttachment::MODE_ATTACH,
));
$attachments_map = array_fill_keys($file_phids, array());
foreach ($attachments as $row) {
$file_phid = $row['filePHID'];
$object_phid = $row['objectPHID'];
$attachments_map[$file_phid][] = $object_phid;
}
return $attachments_map;
}
protected function didFilterPage(array $files) {
$xform_keys = $this->needTransforms;
if ($xform_keys !== null) {
$xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
'originalPHID IN (%Ls) AND transform IN (%Ls)',
mpull($files, 'getPHID'),
$xform_keys);
if ($xforms) {
$xfiles = id(new PhabricatorFile())->loadAllWhere(
'phid IN (%Ls)',
mpull($xforms, 'getTransformedPHID'));
$xfiles = mpull($xfiles, null, 'getPHID');
}
$xform_map = array();
foreach ($xforms as $xform) {
$xfile = idx($xfiles, $xform->getTransformedPHID());
if (!$xfile) {
continue;
}
$original_phid = $xform->getOriginalPHID();
$xform_key = $xform->getTransform();
$xform_map[$original_phid][$xform_key] = $xfile;
}
$default_xforms = array_fill_keys($xform_keys, null);
foreach ($files as $file) {
$file_xforms = idx($xform_map, $file->getPHID(), array());
$file_xforms += $default_xforms;
$file->attachTransforms($file_xforms);
}
}
return $files;
}
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
$joins = parent::buildJoinClauseParts($conn);
if ($this->transforms) {
$joins[] = qsprintf(
$conn,
'JOIN %T t ON t.transformedPHID = f.phid',
id(new PhabricatorTransformedFile())->getTableName());
}
if ($this->shouldJoinAttachmentsTable()) {
$joins[] = qsprintf(
$conn,
'JOIN %R attachments ON attachments.filePHID = f.phid
AND attachmentMode IN (%Ls)',
new PhabricatorFileAttachment(),
array(
PhabricatorFileAttachment::MODE_ATTACH,
));
}
return $joins;
}
private function shouldJoinAttachmentsTable() {
return ($this->attachedObjectPHIDs !== null);
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'f.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'f.phid IN (%Ls)',
$this->phids);
}
if ($this->authorPHIDs !== null) {
$where[] = qsprintf(
$conn,
'f.authorPHID IN (%Ls)',
$this->authorPHIDs);
}
if ($this->explicitUploads !== null) {
$where[] = qsprintf(
$conn,
'f.isExplicitUpload = %d',
(int)$this->explicitUploads);
}
if ($this->transforms !== null) {
$clauses = array();
foreach ($this->transforms as $transform) {
if ($transform['transform'] === true) {
$clauses[] = qsprintf(
$conn,
'(t.originalPHID = %s)',
$transform['originalPHID']);
} else {
$clauses[] = qsprintf(
$conn,
'(t.originalPHID = %s AND t.transform = %s)',
$transform['originalPHID'],
$transform['transform']);
}
}
$where[] = qsprintf($conn, '%LO', $clauses);
}
if ($this->dateCreatedAfter !== null) {
$where[] = qsprintf(
$conn,
'f.dateCreated >= %d',
$this->dateCreatedAfter);
}
if ($this->dateCreatedBefore !== null) {
$where[] = qsprintf(
$conn,
'f.dateCreated <= %d',
$this->dateCreatedBefore);
}
if ($this->contentHashes !== null) {
$where[] = qsprintf(
$conn,
'f.contentHash IN (%Ls)',
$this->contentHashes);
}
if ($this->minLength !== null) {
$where[] = qsprintf(
$conn,
'byteSize >= %d',
$this->minLength);
}
if ($this->maxLength !== null) {
$where[] = qsprintf(
$conn,
'byteSize <= %d',
$this->maxLength);
}
if ($this->names !== null) {
$where[] = qsprintf(
$conn,
'name in (%Ls)',
$this->names);
}
if ($this->isPartial !== null) {
$where[] = qsprintf(
$conn,
'isPartial = %d',
(int)$this->isPartial);
}
if ($this->isDeleted !== null) {
$where[] = qsprintf(
$conn,
'isDeleted = %d',
(int)$this->isDeleted);
}
if ($this->builtinKeys !== null) {
$where[] = qsprintf(
$conn,
'builtinKey IN (%Ls)',
$this->builtinKeys);
}
if ($this->isBuiltin !== null) {
if ($this->isBuiltin) {
$where[] = qsprintf(
$conn,
'builtinKey IS NOT NULL');
} else {
$where[] = qsprintf(
$conn,
'builtinKey IS NULL');
}
}
if ($this->storageEngines !== null) {
$where[] = qsprintf(
$conn,
'storageEngine IN (%Ls)',
$this->storageEngines);
}
if ($this->attachedObjectPHIDs !== null) {
$where[] = qsprintf(
$conn,
'attachments.objectPHID IN (%Ls)',
$this->attachedObjectPHIDs);
}
return $where;
}
protected function getPrimaryTableAlias() {
return 'f';
}
public function getQueryApplicationClass() {
return PhabricatorFilesApplication::class;
}
}
diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php
index fbb6f788f0..7d90f73744 100644
--- a/src/applications/files/storage/PhabricatorFile.php
+++ b/src/applications/files/storage/PhabricatorFile.php
@@ -1,1850 +1,1850 @@
<?php
/**
* Parameters
* ==========
*
* When creating a new file using a method like @{method:newFromFileData}, these
* parameters are supported:
*
* | name | Human readable filename.
* | authorPHID | User PHID of uploader.
* | ttl.absolute | Temporary file lifetime as an epoch timestamp.
* | ttl.relative | Temporary file lifetime, relative to now, in seconds.
* | viewPolicy | File visibility policy.
* | isExplicitUpload | Used to show users files they explicitly uploaded.
* | canCDN | Allows the file to be cached and delivered over a CDN.
* | profile | Marks the file as a profile image.
* | format | Internal encoding format.
* | mime-type | Optional, explicit file MIME type.
* | builtin | Optional filename, identifies this as a builtin.
*
*/
final class PhabricatorFile extends PhabricatorFileDAO
implements
PhabricatorApplicationTransactionInterface,
PhabricatorTokenReceiverInterface,
PhabricatorSubscribableInterface,
PhabricatorFlaggableInterface,
PhabricatorPolicyInterface,
PhabricatorDestructibleInterface,
PhabricatorConduitResultInterface,
PhabricatorIndexableInterface,
PhabricatorNgramsInterface {
const METADATA_IMAGE_WIDTH = 'width';
const METADATA_IMAGE_HEIGHT = 'height';
const METADATA_CAN_CDN = 'canCDN';
const METADATA_BUILTIN = 'builtin';
const METADATA_PARTIAL = 'partial';
const METADATA_PROFILE = 'profile';
const METADATA_STORAGE = 'storage';
const METADATA_INTEGRITY = 'integrity';
const METADATA_CHUNK = 'chunk';
const METADATA_ALT_TEXT = 'alt';
const STATUS_ACTIVE = 'active';
const STATUS_DELETED = 'deleted';
protected $name;
protected $mimeType;
protected $byteSize;
protected $authorPHID;
protected $secretKey;
protected $contentHash;
protected $metadata = array();
protected $mailKey;
protected $builtinKey;
protected $storageEngine;
protected $storageFormat;
protected $storageHandle;
protected $ttl;
protected $isExplicitUpload = 1;
protected $viewPolicy = PhabricatorPolicies::POLICY_USER;
protected $isPartial = 0;
protected $isDeleted = 0;
private $objects = self::ATTACHABLE;
private $objectPHIDs = self::ATTACHABLE;
private $originalFile = self::ATTACHABLE;
private $transforms = self::ATTACHABLE;
public static function initializeNewFile() {
$app = id(new PhabricatorApplicationQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withClasses(array('PhabricatorFilesApplication'))
->executeOne();
$view_policy = $app->getPolicy(
FilesDefaultViewCapability::CAPABILITY);
return id(new PhabricatorFile())
->setViewPolicy($view_policy)
->setIsPartial(0)
->attachOriginalFile(null)
->attachObjects(array())
->attachObjectPHIDs(array());
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'metadata' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'name' => 'sort255?',
'mimeType' => 'text255?',
'byteSize' => 'uint64',
'storageEngine' => 'text32',
'storageFormat' => 'text32',
'storageHandle' => 'text255',
'authorPHID' => 'phid?',
'secretKey' => 'bytes20?',
'contentHash' => 'bytes64?',
'ttl' => 'epoch?',
'isExplicitUpload' => 'bool?',
'mailKey' => 'bytes20',
'isPartial' => 'bool',
'builtinKey' => 'text64?',
'isDeleted' => 'bool',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'authorPHID' => array(
'columns' => array('authorPHID'),
),
'contentHash' => array(
'columns' => array('contentHash'),
),
'key_ttl' => array(
'columns' => array('ttl'),
),
'key_dateCreated' => array(
'columns' => array('dateCreated'),
),
'key_partial' => array(
'columns' => array('authorPHID', 'isPartial'),
),
'key_builtin' => array(
'columns' => array('builtinKey'),
'unique' => true,
),
'key_engine' => array(
'columns' => array('storageEngine', 'storageHandle(64)'),
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorFileFilePHIDType::TYPECONST);
}
public function save() {
if (!$this->getSecretKey()) {
$this->setSecretKey($this->generateSecretKey());
}
if (!$this->getMailKey()) {
$this->setMailKey(Filesystem::readRandomCharacters(20));
}
return parent::save();
}
public function saveAndIndex() {
$this->save();
if ($this->isIndexableFile()) {
PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
}
return $this;
}
private function isIndexableFile() {
if ($this->getIsChunk()) {
return false;
}
return true;
}
/**
* Get file monogram in the format of "F123"
* @return string
*/
public function getMonogram() {
return 'F'.$this->getID();
}
public function scrambleSecret() {
return $this->setSecretKey($this->generateSecretKey());
}
public static function readUploadedFileData($spec) {
if (!$spec) {
throw new Exception(pht('No file was uploaded!'));
}
$err = idx($spec, 'error');
if ($err) {
throw new PhabricatorFileUploadException($err);
}
$tmp_name = idx($spec, 'tmp_name');
// NOTE: If we parsed the request body ourselves, the files we wrote will
// not be registered in the `is_uploaded_file()` list. It's fine to skip
// this check: it just protects against sloppy code from the long ago era
// of "register_globals".
if (ini_get('enable_post_data_reading')) {
$is_valid = @is_uploaded_file($tmp_name);
if (!$is_valid) {
throw new Exception(pht('File is not an uploaded file.'));
}
}
$file_data = Filesystem::readFile($tmp_name);
$file_size = idx($spec, 'size');
if (strlen($file_data) != $file_size) {
throw new Exception(pht('File size disagrees with uploaded size.'));
}
return $file_data;
}
public static function newFromPHPUpload($spec, array $params = array()) {
$file_data = self::readUploadedFileData($spec);
$file_name = nonempty(
idx($params, 'name'),
idx($spec, 'name'));
$params = array(
'name' => $file_name,
) + $params;
return self::newFromFileData($file_data, $params);
}
public static function newFromXHRUpload($data, array $params = array()) {
return self::newFromFileData($data, $params);
}
public static function newFileFromContentHash($hash, array $params) {
if ($hash === null) {
return null;
}
// Check to see if a file with same hash already exists.
$file = id(new PhabricatorFile())->loadOneWhere(
'contentHash = %s LIMIT 1',
$hash);
if (!$file) {
return null;
}
$copy_of_storage_engine = $file->getStorageEngine();
$copy_of_storage_handle = $file->getStorageHandle();
$copy_of_storage_format = $file->getStorageFormat();
$copy_of_storage_properties = $file->getStorageProperties();
$copy_of_byte_size = $file->getByteSize();
$copy_of_mime_type = $file->getMimeType();
$new_file = self::initializeNewFile();
$new_file->setByteSize($copy_of_byte_size);
$new_file->setContentHash($hash);
$new_file->setStorageEngine($copy_of_storage_engine);
$new_file->setStorageHandle($copy_of_storage_handle);
$new_file->setStorageFormat($copy_of_storage_format);
$new_file->setStorageProperties($copy_of_storage_properties);
$new_file->setMimeType($copy_of_mime_type);
$new_file->copyDimensions($file);
$new_file->readPropertiesFromParameters($params);
$new_file->saveAndIndex();
return $new_file;
}
public static function newChunkedFile(
PhabricatorFileStorageEngine $engine,
$length,
array $params) {
$file = self::initializeNewFile();
$file->setByteSize($length);
// NOTE: Once we receive the first chunk, we'll detect its MIME type and
// update the parent file if a MIME type hasn't been provided. This matters
// for large media files like video.
$mime_type = idx($params, 'mime-type', '');
if (!strlen($mime_type)) {
$file->setMimeType('application/octet-stream');
}
$chunked_hash = idx($params, 'chunkedHash');
// Get rid of this parameter now; we aren't passing it any further down
// the stack.
unset($params['chunkedHash']);
if ($chunked_hash) {
$file->setContentHash($chunked_hash);
} else {
// See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some
// discussion of this.
$seed = Filesystem::readRandomBytes(64);
$hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput(
$seed);
$file->setContentHash($hash);
}
$file->setStorageEngine($engine->getEngineIdentifier());
$file->setStorageHandle(PhabricatorFileChunk::newChunkHandle());
// Chunked files are always stored raw because they do not actually store
// data. The chunks do, and can be individually formatted.
$file->setStorageFormat(PhabricatorFileRawStorageFormat::FORMATKEY);
$file->setIsPartial(1);
$file->readPropertiesFromParameters($params);
return $file;
}
private static function buildFromFileData($data, array $params = array()) {
if (isset($params['storageEngines'])) {
$engines = $params['storageEngines'];
} else {
$size = strlen($data);
$engines = PhabricatorFileStorageEngine::loadStorageEngines($size);
if (!$engines) {
throw new Exception(
pht(
'No configured storage engine can store this file. See '.
'"Configuring File Storage" in the documentation for '.
'information on configuring storage engines.'));
}
}
assert_instances_of($engines, 'PhabricatorFileStorageEngine');
if (!$engines) {
throw new Exception(pht('No valid storage engines are available!'));
}
$file = self::initializeNewFile();
$aes_type = PhabricatorFileAES256StorageFormat::FORMATKEY;
$has_aes = PhabricatorKeyring::getDefaultKeyName($aes_type);
if ($has_aes !== null) {
$default_key = PhabricatorFileAES256StorageFormat::FORMATKEY;
} else {
$default_key = PhabricatorFileRawStorageFormat::FORMATKEY;
}
$key = idx($params, 'format', $default_key);
// Callers can pass in an object explicitly instead of a key. This is
// primarily useful for unit tests.
if ($key instanceof PhabricatorFileStorageFormat) {
$format = clone $key;
} else {
$format = clone PhabricatorFileStorageFormat::requireFormat($key);
}
$format->setFile($file);
$properties = $format->newStorageProperties();
$file->setStorageFormat($format->getStorageFormatKey());
$file->setStorageProperties($properties);
$data_handle = null;
$engine_identifier = null;
$integrity_hash = null;
$exceptions = array();
foreach ($engines as $engine) {
$engine_class = get_class($engine);
try {
$result = $file->writeToEngine(
$engine,
$data,
$params);
list($engine_identifier, $data_handle, $integrity_hash) = $result;
// We stored the file somewhere so stop trying to write it to other
// places.
break;
} catch (PhabricatorFileStorageConfigurationException $ex) {
// If an engine is outright misconfigured (or misimplemented), raise
// that immediately since it probably needs attention.
throw $ex;
} catch (Exception $ex) {
phlog($ex);
// If an engine doesn't work, keep trying all the other valid engines
// in case something else works.
$exceptions[$engine_class] = $ex;
}
}
if (!$data_handle) {
throw new PhutilAggregateException(
pht('All storage engines failed to write file:'),
$exceptions);
}
$file->setByteSize(strlen($data));
$hash = self::hashFileContent($data);
$file->setContentHash($hash);
$file->setStorageEngine($engine_identifier);
$file->setStorageHandle($data_handle);
$file->setIntegrityHash($integrity_hash);
$file->readPropertiesFromParameters($params);
if (!$file->getMimeType()) {
$tmp = new TempFile();
Filesystem::writeFile($tmp, $data);
$file->setMimeType(Filesystem::getMimeType($tmp));
unset($tmp);
}
try {
$file->updateDimensions(false);
} catch (Exception $ex) {
// Do nothing.
}
$file->saveAndIndex();
return $file;
}
public static function newFromFileData($data, array $params = array()) {
$hash = self::hashFileContent($data);
if ($hash !== null) {
$file = self::newFileFromContentHash($hash, $params);
if ($file) {
return $file;
}
}
return self::buildFromFileData($data, $params);
}
public function migrateToEngine(
PhabricatorFileStorageEngine $engine,
$make_copy) {
if (!$this->getID() || !$this->getStorageHandle()) {
throw new Exception(
pht("You can not migrate a file which hasn't yet been saved."));
}
$data = $this->loadFileData();
$params = array(
'name' => $this->getName(),
);
list($new_identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
$engine,
$data,
$params);
$old_engine = $this->instantiateStorageEngine();
$old_identifier = $this->getStorageEngine();
$old_handle = $this->getStorageHandle();
$this->setStorageEngine($new_identifier);
$this->setStorageHandle($new_handle);
$this->setIntegrityHash($integrity_hash);
$this->save();
if (!$make_copy) {
$this->deleteFileDataIfUnused(
$old_engine,
$old_identifier,
$old_handle);
}
return $this;
}
public function migrateToStorageFormat(PhabricatorFileStorageFormat $format) {
if (!$this->getID() || !$this->getStorageHandle()) {
throw new Exception(
pht("You can not migrate a file which hasn't yet been saved."));
}
$data = $this->loadFileData();
$params = array(
'name' => $this->getName(),
);
$engine = $this->instantiateStorageEngine();
$old_handle = $this->getStorageHandle();
$properties = $format->newStorageProperties();
$this->setStorageFormat($format->getStorageFormatKey());
$this->setStorageProperties($properties);
list($identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
$engine,
$data,
$params);
$this->setStorageHandle($new_handle);
$this->setIntegrityHash($integrity_hash);
$this->save();
$this->deleteFileDataIfUnused(
$engine,
$identifier,
$old_handle);
return $this;
}
public function cycleMasterStorageKey(PhabricatorFileStorageFormat $format) {
if (!$this->getID() || !$this->getStorageHandle()) {
throw new Exception(
pht("You can not cycle keys for a file which hasn't yet been saved."));
}
$properties = $format->cycleStorageProperties();
$this->setStorageProperties($properties);
$this->save();
return $this;
}
private function writeToEngine(
PhabricatorFileStorageEngine $engine,
$data,
array $params) {
$engine_class = get_class($engine);
$format = $this->newStorageFormat();
$data_iterator = array($data);
$formatted_iterator = $format->newWriteIterator($data_iterator);
$formatted_data = $this->loadDataFromIterator($formatted_iterator);
$integrity_hash = $engine->newIntegrityHash($formatted_data, $format);
$data_handle = $engine->writeFile($formatted_data, $params);
if (!$data_handle || strlen($data_handle) > 255) {
// This indicates an improperly implemented storage engine.
throw new PhabricatorFileStorageConfigurationException(
pht(
"Storage engine '%s' executed %s but did not return a valid ".
"handle ('%s') to the data: it must be nonempty and no longer ".
"than 255 characters.",
$engine_class,
'writeFile()',
$data_handle));
}
$engine_identifier = $engine->getEngineIdentifier();
if (!$engine_identifier || strlen($engine_identifier) > 32) {
throw new PhabricatorFileStorageConfigurationException(
pht(
"Storage engine '%s' returned an improper engine identifier '{%s}': ".
"it must be nonempty and no longer than 32 characters.",
$engine_class,
$engine_identifier));
}
return array($engine_identifier, $data_handle, $integrity_hash);
}
/**
* Download a remote resource over HTTP and save the response body as a file.
*
* This method respects `security.outbound-blacklist`, and protects against
* HTTP redirection (by manually following "Location" headers and verifying
* each destination). It does not protect against DNS rebinding. See
* discussion in T6755.
*/
public static function newFromFileDownload($uri, array $params = array()) {
$timeout = 5;
$redirects = array();
$current = $uri;
while (true) {
try {
if (count($redirects) > 10) {
throw new Exception(
pht('Too many redirects trying to fetch remote URI.'));
}
$resolved = PhabricatorEnv::requireValidRemoteURIForFetch(
$current,
array(
'http',
'https',
));
list($resolved_uri, $resolved_domain) = $resolved;
$current = new PhutilURI($current);
if ($current->getProtocol() == 'http') {
// For HTTP, we can use a pre-resolved URI to defuse DNS rebinding.
$fetch_uri = $resolved_uri;
$fetch_host = $resolved_domain;
} else {
// For HTTPS, we can't: cURL won't verify the SSL certificate if
// the domain has been replaced with an IP. But internal services
// presumably will not have valid certificates for rebindable
// domain names on attacker-controlled domains, so the DNS rebinding
// attack should generally not be possible anyway.
$fetch_uri = $current;
$fetch_host = null;
}
$future = id(new HTTPSFuture($fetch_uri))
->setFollowLocation(false)
->setTimeout($timeout);
if ($fetch_host !== null) {
$future->addHeader('Host', $fetch_host);
}
list($status, $body, $headers) = $future->resolve();
if ($status->isRedirect()) {
// This is an HTTP 3XX status, so look for a "Location" header.
$location = null;
foreach ($headers as $header) {
list($name, $value) = $header;
if (phutil_utf8_strtolower($name) == 'location') {
$location = $value;
break;
}
}
// HTTP 3XX status with no "Location" header, just treat this like
// a normal HTTP error.
if ($location === null) {
throw $status;
}
if (isset($redirects[$location])) {
throw new Exception(
pht('Encountered loop while following redirects.'));
}
$redirects[$location] = $location;
$current = $location;
// We'll fall off the bottom and go try this URI now.
} else if ($status->isError()) {
// This is something other than an HTTP 2XX or HTTP 3XX status, so
// just bail out.
throw $status;
} else {
// This is HTTP 2XX, so use the response body to save the file data.
// Provide a default name based on the URI, truncating it if the URI
// is exceptionally long.
$default_name = basename($uri);
$default_name = id(new PhutilUTF8StringTruncator())
->setMaximumBytes(64)
->truncateString($default_name);
$params = $params + array(
'name' => $default_name,
);
return self::newFromFileData($body, $params);
}
} catch (Exception $ex) {
if ($redirects) {
throw new PhutilProxyException(
pht(
'Failed to fetch remote URI "%s" after following %s redirect(s) '.
'(%s): %s',
$uri,
phutil_count($redirects),
implode(' > ', array_keys($redirects)),
$ex->getMessage()),
$ex);
} else {
throw $ex;
}
}
}
}
public static function normalizeFileName($file_name) {
$pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@";
$file_name = preg_replace($pattern, '_', $file_name);
$file_name = preg_replace('@_+@', '_', $file_name);
$file_name = trim($file_name, '_');
$disallowed_filenames = array(
'.' => 'dot',
'..' => 'dotdot',
'' => 'file',
);
$file_name = idx($disallowed_filenames, $file_name, $file_name);
return $file_name;
}
public function delete() {
// We want to delete all the rows which mark this file as the transformation
// of some other file (since we're getting rid of it). We also delete all
// the transformations of this file, so that a user who deletes an image
// doesn't need to separately hunt down and delete a bunch of thumbnails and
// resizes of it.
$outbound_xforms = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTransforms(
array(
array(
'originalPHID' => $this->getPHID(),
'transform' => true,
),
))
->execute();
foreach ($outbound_xforms as $outbound_xform) {
$outbound_xform->delete();
}
$inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
'transformedPHID = %s',
$this->getPHID());
$this->openTransaction();
foreach ($inbound_xforms as $inbound_xform) {
$inbound_xform->delete();
}
$ret = parent::delete();
$this->saveTransaction();
$this->deleteFileDataIfUnused(
$this->instantiateStorageEngine(),
$this->getStorageEngine(),
$this->getStorageHandle());
return $ret;
}
/**
* Destroy stored file data if there are no remaining files which reference
* it.
*/
public function deleteFileDataIfUnused(
PhabricatorFileStorageEngine $engine,
$engine_identifier,
$handle) {
// Check to see if any files are using storage.
$usage = id(new PhabricatorFile())->loadAllWhere(
'storageEngine = %s AND storageHandle = %s LIMIT 1',
$engine_identifier,
$handle);
// If there are no files using the storage, destroy the actual storage.
if (!$usage) {
try {
$engine->deleteFile($handle);
} catch (Exception $ex) {
// In the worst case, we're leaving some data stranded in a storage
// engine, which is not a big deal.
phlog($ex);
}
}
}
public static function hashFileContent($data) {
// NOTE: Hashing can fail if the algorithm isn't available in the current
// build of PHP. It's fine if we're unable to generate a content hash:
// it just means we'll store extra data when users upload duplicate files
// instead of being able to deduplicate it.
$hash = hash('sha256', $data, $raw_output = false);
if ($hash === false) {
return null;
}
return $hash;
}
public function loadFileData() {
$iterator = $this->getFileDataIterator();
return $this->loadDataFromIterator($iterator);
}
/**
* Return an iterable which emits file content bytes.
*
* @param int $begin (optional) Offset for the start of data.
* @param int $end (optional) Offset for the end of data.
* @return Iterable Iterable object which emits requested data.
*/
public function getFileDataIterator($begin = null, $end = null) {
$engine = $this->instantiateStorageEngine();
$format = $this->newStorageFormat();
$iterator = $engine->getRawFileDataIterator(
$this,
$begin,
$end,
$format);
return $iterator;
}
/**
* Get file URI in the format of "/F123"
* @return string
*/
public function getURI() {
return $this->getInfoURI();
}
/**
* Get file view URI in the format of
* https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename
* @return string
*/
public function getViewURI() {
if (!$this->getPHID()) {
throw new Exception(
pht('You must save a file before you can generate a view URI.'));
}
return $this->getCDNURI('data');
}
/**
* Get file view URI in the format of
* https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename or
* https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
* @return string
*/
public function getCDNURI($request_kind) {
if (($request_kind !== 'data') &&
($request_kind !== 'download')) {
throw new Exception(
pht(
'Unknown file content request kind "%s".',
$request_kind));
}
$name = self::normalizeFileName($this->getName());
$name = phutil_escape_uri($name);
$parts = array();
$parts[] = 'file';
$parts[] = $request_kind;
// If this is an instanced install, add the instance identifier to the URI.
// Instanced configurations behind a CDN may not be able to control the
// request domain used by the CDN (as with AWS CloudFront). Embedding the
// instance identity in the path allows us to distinguish between requests
// originating from different instances but served through the same CDN.
$instance = PhabricatorEnv::getEnvConfig('cluster.instance');
if (phutil_nonempty_string($instance)) {
$parts[] = '@'.$instance;
}
$parts[] = $this->getSecretKey();
$parts[] = $this->getPHID();
$parts[] = $name;
$path = '/'.implode('/', $parts);
// If this file is only partially uploaded, we're just going to return a
// local URI to make sure that Ajax works, since the page is inevitably
// going to give us an error back.
if ($this->getIsPartial()) {
return PhabricatorEnv::getURI($path);
} else {
return PhabricatorEnv::getCDNURI($path);
}
}
/**
* Get file info URI in the format of "/F123"
* @return string
*/
public function getInfoURI() {
return '/'.$this->getMonogram();
}
public function getBestURI() {
if ($this->isViewableInBrowser()) {
return $this->getViewURI();
} else {
return $this->getInfoURI();
}
}
/**
* Get file view URI in the format of
* https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
* @return string
*/
public function getDownloadURI() {
return $this->getCDNURI('download');
}
public function getURIForTransform(PhabricatorFileTransform $transform) {
return $this->getTransformedURI($transform->getTransformKey());
}
private function getTransformedURI($transform) {
$parts = array();
$parts[] = 'file';
$parts[] = 'xform';
$instance = PhabricatorEnv::getEnvConfig('cluster.instance');
if (phutil_nonempty_string($instance)) {
$parts[] = '@'.$instance;
}
$parts[] = $transform;
$parts[] = $this->getPHID();
$parts[] = $this->getSecretKey();
$path = implode('/', $parts);
$path = $path.'/';
return PhabricatorEnv::getCDNURI($path);
}
/**
* Whether the file can be viewed in a browser
* @return bool True if MIME type of the file is listed in the
* files.viewable-mime-types setting
*/
public function isViewableInBrowser() {
return ($this->getViewableMimeType() !== null);
}
/**
* Whether the file is an image viewable in the browser
* @return bool True if MIME type of the file is listed in the
* files.image-mime-types setting and file is viewable in the browser
*/
public function isViewableImage() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.image-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
/**
* Whether the file is an audio file
* @return bool True if MIME type of the file is listed in the
* files.audio-mime-types setting and file is viewable in the browser
*/
public function isAudio() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.audio-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
/**
* Whether the file is a video file
* @return bool True if MIME type of the file is listed in the
* files.video-mime-types setting and file is viewable in the browser
*/
public function isVideo() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.video-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
/**
* Whether the file is a PDF file
* @return bool True if MIME type of the file is application/pdf and file is
* viewable in the browser
*/
public function isPDF() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = array(
'application/pdf' => 'application/pdf',
);
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
public function isTransformableImage() {
// NOTE: The way the 'gd' extension works in PHP is that you can install it
// with support for only some file types, so it might be able to handle
// PNG but not JPEG. Try to generate thumbnails for whatever we can. Setup
// warns you if you don't have complete support.
$matches = null;
$ok = false;
if ($this->getViewableMimeType() !== null) {
$ok = preg_match(
'@^image/(gif|png|jpe?g)@',
$this->getViewableMimeType(),
$matches);
}
if (!$ok) {
return false;
}
switch ($matches[1]) {
case 'jpg';
case 'jpeg':
return function_exists('imagejpeg');
case 'png':
return function_exists('imagepng');
case 'gif':
return function_exists('imagegif');
default:
throw new Exception(pht('Unknown type matched as image MIME type.'));
}
}
public static function getTransformableImageFormats() {
$supported = array();
if (function_exists('imagejpeg')) {
$supported[] = 'jpg';
}
if (function_exists('imagepng')) {
$supported[] = 'png';
}
if (function_exists('imagegif')) {
$supported[] = 'gif';
}
return $supported;
}
public function getDragAndDropDictionary() {
return array(
'id' => $this->getID(),
'phid' => $this->getPHID(),
'uri' => $this->getBestURI(),
);
}
public function instantiateStorageEngine() {
return self::buildEngine($this->getStorageEngine());
}
public static function buildEngine($engine_identifier) {
$engines = self::buildAllEngines();
foreach ($engines as $engine) {
if ($engine->getEngineIdentifier() == $engine_identifier) {
return $engine;
}
}
throw new Exception(
pht(
"Storage engine '%s' could not be located!",
$engine_identifier));
}
public static function buildAllEngines() {
return id(new PhutilClassMapQuery())
->setAncestorClass('PhabricatorFileStorageEngine')
->execute();
}
/**
* Whether the file is listed as a viewable MIME type
* @return bool True if MIME type of the file is listed in the
* files.viewable-mime-types setting
*/
public function getViewableMimeType() {
$mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types');
$mime_type = $this->getMimeType();
$mime_parts = explode(';', $mime_type);
$mime_type = trim(reset($mime_parts));
return idx($mime_map, $mime_type);
}
public function getDisplayIconForMimeType() {
$mime_map = PhabricatorEnv::getEnvConfig('files.icon-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type, 'fa-file-o');
}
public function validateSecretKey($key) {
return ($key == $this->getSecretKey());
}
public function generateSecretKey() {
return Filesystem::readRandomCharacters(20);
}
public function setStorageProperties(array $properties) {
$this->metadata[self::METADATA_STORAGE] = $properties;
return $this;
}
public function getStorageProperties() {
return idx($this->metadata, self::METADATA_STORAGE, array());
}
public function getStorageProperty($key, $default = null) {
$properties = $this->getStorageProperties();
return idx($properties, $key, $default);
}
public function loadDataFromIterator($iterator) {
$result = '';
foreach ($iterator as $chunk) {
$result .= $chunk;
}
return $result;
}
public function updateDimensions($save = true) {
if (!$this->isViewableImage()) {
throw new Exception(pht('This file is not a viewable image.'));
}
if (!function_exists('imagecreatefromstring')) {
throw new Exception(pht('Cannot retrieve image information.'));
}
if ($this->getIsChunk()) {
throw new Exception(
pht('Refusing to assess image dimensions of file chunk.'));
}
$engine = $this->instantiateStorageEngine();
if ($engine->isChunkEngine()) {
throw new Exception(
pht('Refusing to assess image dimensions of chunked file.'));
}
$data = $this->loadFileData();
$img = @imagecreatefromstring($data);
if ($img === false) {
throw new Exception(pht('Error when decoding image.'));
}
$this->metadata[self::METADATA_IMAGE_WIDTH] = imagesx($img);
$this->metadata[self::METADATA_IMAGE_HEIGHT] = imagesy($img);
if ($save) {
$this->save();
}
return $this;
}
public function copyDimensions(PhabricatorFile $file) {
$metadata = $file->getMetadata();
$width = idx($metadata, self::METADATA_IMAGE_WIDTH);
if ($width) {
$this->metadata[self::METADATA_IMAGE_WIDTH] = $width;
}
$height = idx($metadata, self::METADATA_IMAGE_HEIGHT);
if ($height) {
$this->metadata[self::METADATA_IMAGE_HEIGHT] = $height;
}
return $this;
}
/**
* Load (or build) the {@class:PhabricatorFile} objects for builtin file
* resources. The builtin mechanism allows files shipped with Phabricator
* to be treated like normal files so that APIs do not need to special case
* things like default images or deleted files.
*
* Builtins are located in `resources/builtin/` and identified by their
* name.
*
* @param PhabricatorUser $user Viewing user.
* @param list<PhabricatorFilesBuiltinFile> $builtins List of builtin file
* specs.
* @return dict<string, PhabricatorFile> Dictionary of named builtins.
*/
public static function loadBuiltins(PhabricatorUser $user, array $builtins) {
$builtins = mpull($builtins, null, 'getBuiltinFileKey');
// NOTE: Anyone is allowed to access builtin files.
$files = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withBuiltinKeys(array_keys($builtins))
->execute();
$results = array();
foreach ($files as $file) {
$builtin_key = $file->getBuiltinName();
if ($builtin_key !== null) {
$results[$builtin_key] = $file;
}
}
$build = array();
foreach ($builtins as $key => $builtin) {
if (isset($results[$key])) {
continue;
}
$data = $builtin->loadBuiltinFileData();
$params = array(
'name' => $builtin->getBuiltinDisplayName(),
'canCDN' => true,
'builtin' => $key,
);
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
try {
$file = self::newFromFileData($data, $params);
} catch (AphrontDuplicateKeyQueryException $ex) {
$file = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withBuiltinKeys(array($key))
->executeOne();
if (!$file) {
throw new Exception(
pht(
'Collided mid-air when generating builtin file "%s", but '.
'then failed to load the object we collided with.',
$key));
}
}
unset($unguarded);
$file->attachObjectPHIDs(array());
$file->attachObjects(array());
$results[$key] = $file;
}
return $results;
}
/**
* Convenience wrapper for @{method:loadBuiltins}.
*
* @param PhabricatorUser $user Viewing user.
* @param string $name Single builtin name to load.
* @return PhabricatorFile Corresponding builtin file.
*/
public static function loadBuiltin(PhabricatorUser $user, $name) {
$builtin = id(new PhabricatorFilesOnDiskBuiltinFile())
->setName($name);
$key = $builtin->getBuiltinFileKey();
return idx(self::loadBuiltins($user, array($builtin)), $key);
}
public function getObjects() {
return $this->assertAttached($this->objects);
}
public function attachObjects(array $objects) {
$this->objects = $objects;
return $this;
}
public function getObjectPHIDs() {
return $this->assertAttached($this->objectPHIDs);
}
public function attachObjectPHIDs(array $object_phids) {
$this->objectPHIDs = $object_phids;
return $this;
}
public function getOriginalFile() {
return $this->assertAttached($this->originalFile);
}
public function attachOriginalFile(PhabricatorFile $file = null) {
$this->originalFile = $file;
return $this;
}
public function getImageHeight() {
if (!$this->isViewableImage()) {
return null;
}
return idx($this->metadata, self::METADATA_IMAGE_HEIGHT);
}
public function getImageWidth() {
if (!$this->isViewableImage()) {
return null;
}
return idx($this->metadata, self::METADATA_IMAGE_WIDTH);
}
public function getAltText() {
$alt = $this->getCustomAltText();
if (phutil_nonempty_string($alt)) {
return $alt;
}
return $this->getDefaultAltText();
}
public function getCustomAltText() {
return idx($this->metadata, self::METADATA_ALT_TEXT);
}
public function setCustomAltText($value) {
$value = phutil_string_cast($value);
if (!strlen($value)) {
$value = null;
}
if ($value === null) {
unset($this->metadata[self::METADATA_ALT_TEXT]);
} else {
$this->metadata[self::METADATA_ALT_TEXT] = $value;
}
return $this;
}
public function getDefaultAltText() {
$parts = array();
$name = $this->getName();
if (strlen($name)) {
$parts[] = $name;
}
$stats = array();
$image_x = $this->getImageHeight();
$image_y = $this->getImageWidth();
if ($image_x && $image_y) {
$stats[] = pht(
"%d\xC3\x97%d px",
new PhutilNumber($image_x),
new PhutilNumber($image_y));
}
$bytes = $this->getByteSize();
if ($bytes) {
$stats[] = phutil_format_bytes($bytes);
}
if ($stats) {
$parts[] = pht('(%s)', implode(', ', $stats));
}
if (!$parts) {
return null;
}
return implode(' ', $parts);
}
public function getCanCDN() {
if (!$this->isViewableImage()) {
return false;
}
return idx($this->metadata, self::METADATA_CAN_CDN);
}
public function setCanCDN($can_cdn) {
$this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0;
return $this;
}
public function isBuiltin() {
return ($this->getBuiltinName() !== null);
}
public function getBuiltinName() {
return idx($this->metadata, self::METADATA_BUILTIN);
}
public function setBuiltinName($name) {
$this->metadata[self::METADATA_BUILTIN] = $name;
return $this;
}
public function getIsProfileImage() {
return idx($this->metadata, self::METADATA_PROFILE);
}
public function setIsProfileImage($value) {
$this->metadata[self::METADATA_PROFILE] = $value;
return $this;
}
public function getIsChunk() {
return idx($this->metadata, self::METADATA_CHUNK);
}
public function setIsChunk($value) {
$this->metadata[self::METADATA_CHUNK] = $value;
return $this;
}
public function setIntegrityHash($integrity_hash) {
$this->metadata[self::METADATA_INTEGRITY] = $integrity_hash;
return $this;
}
public function getIntegrityHash() {
return idx($this->metadata, self::METADATA_INTEGRITY);
}
public function newIntegrityHash() {
$engine = $this->instantiateStorageEngine();
if ($engine->isChunkEngine()) {
return null;
}
$format = $this->newStorageFormat();
$storage_handle = $this->getStorageHandle();
$data = $engine->readFile($storage_handle);
return $engine->newIntegrityHash($data, $format);
}
/**
* Write the policy edge between this file and some object.
* This method is successful even if the file is already attached.
*
* @param phid $phid Object PHID to attach to.
- * @return this
+ * @return $this
*/
public function attachToObject($phid) {
self::attachFileToObject($this->getPHID(), $phid);
return $this;
}
/**
* Write the policy edge between a file and some object.
* This method is successful even if the file is already attached.
* NOTE: Please avoid to use this static method directly.
* Instead, use PhabricatorFile#attachToObject(phid).
*
* @param phid $file_phid File PHID to attach from.
* @param phid $object_phid Object PHID to attach to.
* @return void
*/
public static function attachFileToObject($file_phid, $object_phid) {
// It can be easy to confuse the two arguments. Be strict.
if (phid_get_type($file_phid) !== PhabricatorFileFilePHIDType::TYPECONST) {
throw new Exception(pht('The first argument must be a phid of a file.'));
}
$attachment_table = new PhabricatorFileAttachment();
$attachment_conn = $attachment_table->establishConnection('w');
queryfx(
$attachment_conn,
'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
attacherPHID, dateCreated, dateModified)
VALUES (%s, %s, %s, %ns, %d, %d)
ON DUPLICATE KEY UPDATE
attachmentMode = VALUES(attachmentMode),
attacherPHID = VALUES(attacherPHID),
dateModified = VALUES(dateModified)',
$attachment_table,
$object_phid,
$file_phid,
PhabricatorFileAttachment::MODE_ATTACH,
null,
PhabricatorTime::getNow(),
PhabricatorTime::getNow());
}
/**
* Configure a newly created file object according to specified parameters.
*
* This method is called both when creating a file from fresh data, and
* when creating a new file which reuses existing storage.
*
* @param map<string, wild> $params Bag of parameters, see
* @{class:PhabricatorFile} for documentation.
- * @return this
+ * @return $this
*/
private function readPropertiesFromParameters(array $params) {
PhutilTypeSpec::checkMap(
$params,
array(
'name' => 'optional string',
'authorPHID' => 'optional string',
'ttl.relative' => 'optional int',
'ttl.absolute' => 'optional int',
'viewPolicy' => 'optional string',
'isExplicitUpload' => 'optional bool',
'canCDN' => 'optional bool',
'profile' => 'optional bool',
'format' => 'optional string|PhabricatorFileStorageFormat',
'mime-type' => 'optional string',
'builtin' => 'optional string',
'storageEngines' => 'optional list<PhabricatorFileStorageEngine>',
'chunk' => 'optional bool',
));
$file_name = idx($params, 'name');
$this->setName($file_name);
$author_phid = idx($params, 'authorPHID');
$this->setAuthorPHID($author_phid);
$absolute_ttl = idx($params, 'ttl.absolute');
$relative_ttl = idx($params, 'ttl.relative');
if ($absolute_ttl !== null && $relative_ttl !== null) {
throw new Exception(
pht(
'Specify an absolute TTL or a relative TTL, but not both.'));
} else if ($absolute_ttl !== null) {
if ($absolute_ttl < PhabricatorTime::getNow()) {
throw new Exception(
pht(
'Absolute TTL must be in the present or future, but TTL "%s" '.
'is in the past.',
$absolute_ttl));
}
$this->setTtl($absolute_ttl);
} else if ($relative_ttl !== null) {
if ($relative_ttl < 0) {
throw new Exception(
pht(
'Relative TTL must be zero or more seconds, but "%s" is '.
'negative.',
$relative_ttl));
}
$max_relative = phutil_units('365 days in seconds');
if ($relative_ttl > $max_relative) {
throw new Exception(
pht(
'Relative TTL must not be more than "%s" seconds, but TTL '.
'"%s" was specified.',
$max_relative,
$relative_ttl));
}
$absolute_ttl = PhabricatorTime::getNow() + $relative_ttl;
$this->setTtl($absolute_ttl);
}
$view_policy = idx($params, 'viewPolicy');
if ($view_policy) {
$this->setViewPolicy($params['viewPolicy']);
}
$is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0);
$this->setIsExplicitUpload($is_explicit);
$can_cdn = idx($params, 'canCDN');
if ($can_cdn) {
$this->setCanCDN(true);
}
$builtin = idx($params, 'builtin');
if ($builtin) {
$this->setBuiltinName($builtin);
$this->setBuiltinKey($builtin);
}
$profile = idx($params, 'profile');
if ($profile) {
$this->setIsProfileImage(true);
}
$mime_type = idx($params, 'mime-type');
if ($mime_type) {
$this->setMimeType($mime_type);
}
$is_chunk = idx($params, 'chunk');
if ($is_chunk) {
$this->setIsChunk(true);
}
return $this;
}
public function getRedirectResponse() {
$uri = $this->getBestURI();
// TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI
// (if the file is a viewable image) and sometimes a local URI (if not).
// For now, just detect which one we got and configure the response
// appropriately. In the long run, if this endpoint is served from a CDN
// domain, we can't issue a local redirect to an info URI (which is not
// present on the CDN domain). We probably never actually issue local
// redirects here anyway, since we only ever transform viewable images
// right now.
$is_external = strlen(id(new PhutilURI($uri))->getDomain());
return id(new AphrontRedirectResponse())
->setIsExternal($is_external)
->setURI($uri);
}
public function newDownloadResponse() {
// We're cheating a little bit here and relying on the fact that
// getDownloadURI() always returns a fully qualified URI with a complete
// domain.
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setCloseDialogBeforeRedirect(true)
->setURI($this->getDownloadURI());
}
public function attachTransforms(array $map) {
$this->transforms = $map;
return $this;
}
public function getTransform($key) {
return $this->assertAttachedKey($this->transforms, $key);
}
public function newStorageFormat() {
$key = $this->getStorageFormat();
$template = PhabricatorFileStorageFormat::requireFormat($key);
$format = id(clone $template)
->setFile($this);
return $format;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorFileEditor();
}
public function getApplicationTransactionTemplate() {
return new PhabricatorFileTransaction();
}
/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
if ($this->isBuiltin()) {
return PhabricatorPolicies::getMostOpenPolicy();
}
if ($this->getIsProfileImage()) {
return PhabricatorPolicies::getMostOpenPolicy();
}
return $this->getViewPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return PhabricatorPolicies::POLICY_NOONE;
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
$viewer_phid = $viewer->getPHID();
if ($viewer_phid) {
if ($this->getAuthorPHID() == $viewer_phid) {
return true;
}
}
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
// If you can see the file this file is a transform of, you can see
// this file.
if ($this->getOriginalFile()) {
return true;
}
// If you can see any object this file is attached to, you can see
// the file.
return (count($this->getObjects()) > 0);
}
return false;
}
public function describeAutomaticCapability($capability) {
$out = array();
$out[] = pht('The user who uploaded a file can always view and edit it.');
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
$out[] = pht(
'Files attached to objects are visible to users who can view '.
'those objects.');
$out[] = pht(
'Thumbnails are visible only to users who can view the original '.
'file.');
break;
}
return $out;
}
/* -( PhabricatorSubscribableInterface Implementation )-------------------- */
public function isAutomaticallySubscribed($phid) {
return ($this->authorPHID == $phid);
}
/* -( PhabricatorTokenReceiverInterface )---------------------------------- */
public function getUsersToNotifyOfTokenGiven() {
return array(
$this->getAuthorPHID(),
);
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$attachments = id(new PhabricatorFileAttachment())->loadAllWhere(
'filePHID = %s',
$this->getPHID());
foreach ($attachments as $attachment) {
$attachment->delete();
}
$this->delete();
$this->saveTransaction();
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('name')
->setType('string')
->setDescription(pht('The name of the file.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('uri')
->setType('uri')
->setDescription(pht('View URI for the file.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('dataURI')
->setType('uri')
->setDescription(pht('Download URI for the file data.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('size')
->setType('int')
->setDescription(pht('File size, in bytes.')),
);
}
public function getFieldValuesForConduit() {
return array(
'name' => $this->getName(),
'uri' => PhabricatorEnv::getURI($this->getURI()),
'dataURI' => $this->getCDNURI('data'),
'size' => (int)$this->getByteSize(),
'alt' => array(
'custom' => $this->getCustomAltText(),
'default' => $this->getDefaultAltText(),
),
);
}
public function getConduitSearchAttachments() {
return array();
}
/* -( PhabricatorNgramInterface )------------------------------------------ */
public function newNgrams() {
return array(
id(new PhabricatorFileNameNgrams())
->setValue($this->getName()),
);
}
}
diff --git a/src/applications/herald/adapter/HeraldAdapter.php b/src/applications/herald/adapter/HeraldAdapter.php
index 8824f645e3..26f00c887a 100644
--- a/src/applications/herald/adapter/HeraldAdapter.php
+++ b/src/applications/herald/adapter/HeraldAdapter.php
@@ -1,1215 +1,1215 @@
<?php
abstract class HeraldAdapter extends Phobject {
const CONDITION_CONTAINS = 'contains';
const CONDITION_NOT_CONTAINS = '!contains';
const CONDITION_IS = 'is';
const CONDITION_IS_NOT = '!is';
const CONDITION_IS_ANY = 'isany';
const CONDITION_IS_NOT_ANY = '!isany';
const CONDITION_INCLUDE_ALL = 'all';
const CONDITION_INCLUDE_ANY = 'any';
const CONDITION_INCLUDE_NONE = 'none';
const CONDITION_IS_ME = 'me';
const CONDITION_IS_NOT_ME = '!me';
const CONDITION_REGEXP = 'regexp';
const CONDITION_NOT_REGEXP = '!regexp';
const CONDITION_RULE = 'conditions';
const CONDITION_NOT_RULE = '!conditions';
const CONDITION_EXISTS = 'exists';
const CONDITION_NOT_EXISTS = '!exists';
const CONDITION_UNCONDITIONALLY = 'unconditionally';
const CONDITION_NEVER = 'never';
const CONDITION_REGEXP_PAIR = 'regexp-pair';
const CONDITION_HAS_BIT = 'bit';
const CONDITION_NOT_BIT = '!bit';
const CONDITION_IS_TRUE = 'true';
const CONDITION_IS_FALSE = 'false';
private $contentSource;
private $isNewObject;
private $applicationEmail;
private $appliedTransactions = array();
private $queuedTransactions = array();
private $emailPHIDs = array();
private $forcedEmailPHIDs = array();
private $fieldMap;
private $actionMap;
private $edgeCache = array();
private $forbiddenActions = array();
private $viewer;
private $mustEncryptReasons = array();
private $actingAsPHID;
private $webhookMap = array();
public function getEmailPHIDs() {
return array_values($this->emailPHIDs);
}
public function getForcedEmailPHIDs() {
return array_values($this->forcedEmailPHIDs);
}
final public function setActingAsPHID($acting_as_phid) {
$this->actingAsPHID = $acting_as_phid;
return $this;
}
final public function getActingAsPHID() {
return $this->actingAsPHID;
}
public function addEmailPHID($phid, $force) {
$this->emailPHIDs[$phid] = $phid;
if ($force) {
$this->forcedEmailPHIDs[$phid] = $phid;
}
return $this;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
// See PHI276. Normally, Herald runs without regard for policy checks.
// However, we use a real viewer during test console runs: this makes
// intracluster calls to Diffusion APIs work even if web nodes don't
// have privileged credentials.
if ($this->viewer) {
return $this->viewer;
}
return PhabricatorUser::getOmnipotentUser();
}
public function setContentSource(PhabricatorContentSource $content_source) {
$this->contentSource = $content_source;
return $this;
}
public function getContentSource() {
return $this->contentSource;
}
public function getIsNewObject() {
if (is_bool($this->isNewObject)) {
return $this->isNewObject;
}
throw new Exception(
pht(
'You must %s to a boolean first!',
'setIsNewObject()'));
}
public function setIsNewObject($new) {
$this->isNewObject = (bool)$new;
return $this;
}
public function supportsApplicationEmail() {
return false;
}
public function setApplicationEmail(
PhabricatorMetaMTAApplicationEmail $email) {
$this->applicationEmail = $email;
return $this;
}
public function getApplicationEmail() {
return $this->applicationEmail;
}
public function getPHID() {
return $this->getObject()->getPHID();
}
abstract public function getHeraldName();
final public function willGetHeraldField($field_key) {
// This method is called during rule evaluation, before we engage the
// Herald profiler. We make sure we have a concrete implementation so time
// spent loading fields out of the classmap is not mistakenly attributed to
// whichever field happens to evaluate first.
$this->requireFieldImplementation($field_key);
}
public function getHeraldField($field_key) {
return $this->requireFieldImplementation($field_key)
->getHeraldFieldValue($this->getObject());
}
public function applyHeraldEffects(array $effects) {
assert_instances_of($effects, 'HeraldEffect');
$result = array();
foreach ($effects as $effect) {
$result[] = $this->applyStandardEffect($effect);
}
return $result;
}
public function isAvailableToUser(PhabricatorUser $viewer) {
$applications = id(new PhabricatorApplicationQuery())
->setViewer($viewer)
->withInstalled(true)
->withClasses(array($this->getAdapterApplicationClass()))
->execute();
return !empty($applications);
}
/**
* Set the list of transactions which just took effect.
*
* These transactions are set by @{class:PhabricatorApplicationEditor}
* automatically, before it invokes Herald.
*
* @param list<PhabricatorApplicationTransaction> $xactions List of
* transactions.
- * @return this
+ * @return $this
*/
final public function setAppliedTransactions(array $xactions) {
assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
$this->appliedTransactions = $xactions;
return $this;
}
/**
* Get a list of transactions which just took effect.
*
* When an object is edited normally, transactions are applied and then
* Herald executes. You can call this method to examine the transactions
* if you want to react to them.
*
* @return list<PhabricatorApplicationTransaction> List of transactions.
*/
final public function getAppliedTransactions() {
return $this->appliedTransactions;
}
final public function queueTransaction(
PhabricatorApplicationTransaction $transaction) {
$this->queuedTransactions[] = $transaction;
}
final public function getQueuedTransactions() {
return $this->queuedTransactions;
}
final public function newTransaction() {
$object = $this->newObject();
if (!($object instanceof PhabricatorApplicationTransactionInterface)) {
throw new Exception(
pht(
'Unable to build a new transaction for adapter object; it does '.
'not implement "%s".',
'PhabricatorApplicationTransactionInterface'));
}
$xaction = $object->getApplicationTransactionTemplate();
if (!($xaction instanceof PhabricatorApplicationTransaction)) {
throw new Exception(
pht(
'Expected object (of class "%s") to return a transaction template '.
'(of class "%s"), but it returned something else ("%s").',
get_class($object),
'PhabricatorApplicationTransaction',
phutil_describe_type($xaction)));
}
return $xaction;
}
/**
* NOTE: You generally should not override this; it exists to support legacy
* adapters which had hard-coded content types.
*/
public function getAdapterContentType() {
return get_class($this);
}
abstract public function getAdapterContentName();
abstract public function getAdapterContentDescription();
abstract public function getAdapterApplicationClass();
abstract public function getObject();
public function getAdapterContentIcon() {
$application_class = $this->getAdapterApplicationClass();
$application = newv($application_class, array());
return $application->getIcon();
}
/**
* Return a new characteristic object for this adapter.
*
* The adapter will use this object to test for interfaces, generate
* transactions, and interact with custom fields.
*
* Adapters must return an object from this method to enable custom
* field rules and various implicit actions.
*
* Normally, you'll return an empty version of the adapted object:
*
* return new ApplicationObject();
*
* @return null|object Template object.
*/
protected function newObject() {
return null;
}
public function supportsRuleType($rule_type) {
return false;
}
public function canTriggerOnObject($object) {
return false;
}
public function isTestAdapterForObject($object) {
return false;
}
public function canCreateTestAdapterForObject($object) {
return $this->isTestAdapterForObject($object);
}
public function newTestAdapter(PhabricatorUser $viewer, $object) {
return id(clone $this)
->setObject($object);
}
public function getAdapterTestDescription() {
return null;
}
public function explainValidTriggerObjects() {
return pht('This adapter can not trigger on objects.');
}
public function getTriggerObjectPHIDs() {
return array($this->getPHID());
}
public function getAdapterSortKey() {
return sprintf(
'%08d%s',
$this->getAdapterSortOrder(),
$this->getAdapterContentName());
}
public function getAdapterSortOrder() {
return 1000;
}
/* -( Fields )------------------------------------------------------------- */
private function getFieldImplementationMap() {
if ($this->fieldMap === null) {
// We can't use PhutilClassMapQuery here because field expansion
// depends on the adapter and object.
$object = $this->getObject();
$map = array();
$all = HeraldField::getAllFields();
foreach ($all as $key => $field) {
$field = id(clone $field)->setAdapter($this);
if (!$field->supportsObject($object)) {
continue;
}
$subfields = $field->getFieldsForObject($object);
foreach ($subfields as $subkey => $subfield) {
if (isset($map[$subkey])) {
throw new Exception(
pht(
'Two HeraldFields (of classes "%s" and "%s") have the same '.
'field key ("%s") after expansion for an object of class '.
'"%s" inside adapter "%s". Each field must have a unique '.
'field key.',
get_class($subfield),
get_class($map[$subkey]),
$subkey,
get_class($object),
get_class($this)));
}
$subfield = id(clone $subfield)->setAdapter($this);
$map[$subkey] = $subfield;
}
}
$this->fieldMap = $map;
}
return $this->fieldMap;
}
private function getFieldImplementation($key) {
return idx($this->getFieldImplementationMap(), $key);
}
public function getFields() {
return array_keys($this->getFieldImplementationMap());
}
public function getFieldNameMap() {
return mpull($this->getFieldImplementationMap(), 'getHeraldFieldName');
}
public function getFieldGroupKey($field_key) {
$field = $this->getFieldImplementation($field_key);
if (!$field) {
return null;
}
return $field->getFieldGroupKey();
}
public function isFieldAvailable($field_key) {
$field = $this->getFieldImplementation($field_key);
if (!$field) {
return null;
}
return $field->isFieldAvailable();
}
/* -( Conditions )--------------------------------------------------------- */
public function getConditionNameMap() {
return array(
self::CONDITION_CONTAINS => pht('contains'),
self::CONDITION_NOT_CONTAINS => pht('does not contain'),
self::CONDITION_IS => pht('is'),
self::CONDITION_IS_NOT => pht('is not'),
self::CONDITION_IS_ANY => pht('is any of'),
self::CONDITION_IS_TRUE => pht('is true'),
self::CONDITION_IS_FALSE => pht('is false'),
self::CONDITION_IS_NOT_ANY => pht('is not any of'),
self::CONDITION_INCLUDE_ALL => pht('include all of'),
self::CONDITION_INCLUDE_ANY => pht('include any of'),
self::CONDITION_INCLUDE_NONE => pht('include none of'),
self::CONDITION_IS_ME => pht('is myself'),
self::CONDITION_IS_NOT_ME => pht('is not myself'),
self::CONDITION_REGEXP => pht('matches regexp'),
self::CONDITION_NOT_REGEXP => pht('does not match regexp'),
self::CONDITION_RULE => pht('matches:'),
self::CONDITION_NOT_RULE => pht('does not match:'),
self::CONDITION_EXISTS => pht('exists'),
self::CONDITION_NOT_EXISTS => pht('does not exist'),
self::CONDITION_UNCONDITIONALLY => '', // don't show anything!
self::CONDITION_NEVER => '', // don't show anything!
self::CONDITION_REGEXP_PAIR => pht('matches regexp pair'),
self::CONDITION_HAS_BIT => pht('has bit'),
self::CONDITION_NOT_BIT => pht('lacks bit'),
);
}
public function getConditionsForField($field) {
return $this->requireFieldImplementation($field)
->getHeraldFieldConditions();
}
private function requireFieldImplementation($field_key) {
$field = $this->getFieldImplementation($field_key);
if (!$field) {
throw new Exception(
pht(
'No field with key "%s" is available to Herald adapter "%s".',
$field_key,
get_class($this)));
}
return $field;
}
public function doesConditionMatch(
HeraldEngine $engine,
HeraldRule $rule,
HeraldCondition $condition,
$field_value) {
$condition_type = $condition->getFieldCondition();
$condition_value = $condition->getValue();
switch ($condition_type) {
case self::CONDITION_CONTAINS:
case self::CONDITION_NOT_CONTAINS:
// "Contains and "does not contain" can take an array of strings, as in
// "Any changed filename" for diffs.
$result_if_match = ($condition_type == self::CONDITION_CONTAINS);
foreach ((array)$field_value as $value) {
if (stripos($value, $condition_value) !== false) {
return $result_if_match;
}
}
return !$result_if_match;
case self::CONDITION_IS:
return ($field_value == $condition_value);
case self::CONDITION_IS_NOT:
return ($field_value != $condition_value);
case self::CONDITION_IS_ME:
return ($field_value == $rule->getAuthorPHID());
case self::CONDITION_IS_NOT_ME:
return ($field_value != $rule->getAuthorPHID());
case self::CONDITION_IS_ANY:
if (!is_array($condition_value)) {
throw new HeraldInvalidConditionException(
pht('Expected condition value to be an array.'));
}
$condition_value = array_fuse($condition_value);
return isset($condition_value[$field_value]);
case self::CONDITION_IS_NOT_ANY:
if (!is_array($condition_value)) {
throw new HeraldInvalidConditionException(
pht('Expected condition value to be an array.'));
}
$condition_value = array_fuse($condition_value);
return !isset($condition_value[$field_value]);
case self::CONDITION_INCLUDE_ALL:
if (!is_array($field_value)) {
throw new HeraldInvalidConditionException(
pht('Object produced non-array value!'));
}
if (!is_array($condition_value)) {
throw new HeraldInvalidConditionException(
pht('Expected condition value to be an array.'));
}
$have = array_select_keys(array_fuse($field_value), $condition_value);
return (count($have) == count($condition_value));
case self::CONDITION_INCLUDE_ANY:
return (bool)array_select_keys(
array_fuse($field_value),
$condition_value);
case self::CONDITION_INCLUDE_NONE:
return !array_select_keys(
array_fuse($field_value),
$condition_value);
case self::CONDITION_EXISTS:
case self::CONDITION_IS_TRUE:
return (bool)$field_value;
case self::CONDITION_NOT_EXISTS:
case self::CONDITION_IS_FALSE:
return !$field_value;
case self::CONDITION_UNCONDITIONALLY:
return (bool)$field_value;
case self::CONDITION_NEVER:
return false;
case self::CONDITION_REGEXP:
case self::CONDITION_NOT_REGEXP:
$result_if_match = ($condition_type == self::CONDITION_REGEXP);
// We add the 'S' flag because we use the regexp multiple times.
// It shouldn't cause any troubles if the flag is already there
// - /.*/S is evaluated same as /.*/SS.
$condition_pattern = $condition_value.'S';
foreach ((array)$field_value as $value) {
try {
$result = phutil_preg_match($condition_pattern, $value);
} catch (PhutilRegexException $ex) {
$message = array();
$message[] = pht(
'Regular expression "%s" in Herald rule "%s" is not valid, '.
'or exceeded backtracking or recursion limits while '.
'executing. Verify the expression and correct it or rewrite '.
'it with less backtracking.',
$condition_value,
$rule->getMonogram());
$message[] = $ex->getMessage();
$message = implode("\n\n", $message);
throw new HeraldInvalidConditionException($message);
}
if ($result) {
return $result_if_match;
}
}
return !$result_if_match;
case self::CONDITION_REGEXP_PAIR:
// Match a JSON-encoded pair of regular expressions against a
// dictionary. The first regexp must match the dictionary key, and the
// second regexp must match the dictionary value. If any key/value pair
// in the dictionary matches both regexps, the condition is satisfied.
$regexp_pair = null;
try {
$regexp_pair = phutil_json_decode($condition_value);
} catch (PhutilJSONParserException $ex) {
throw new HeraldInvalidConditionException(
pht('Regular expression pair is not valid JSON!'));
}
if (count($regexp_pair) != 2) {
throw new HeraldInvalidConditionException(
pht('Regular expression pair is not a pair!'));
}
$key_regexp = array_shift($regexp_pair);
$value_regexp = array_shift($regexp_pair);
foreach ((array)$field_value as $key => $value) {
$key_matches = @preg_match($key_regexp, $key);
if ($key_matches === false) {
throw new HeraldInvalidConditionException(
pht('First regular expression is invalid!'));
}
if ($key_matches) {
$value_matches = @preg_match($value_regexp, $value);
if ($value_matches === false) {
throw new HeraldInvalidConditionException(
pht('Second regular expression is invalid!'));
}
if ($value_matches) {
return true;
}
}
}
return false;
case self::CONDITION_RULE:
case self::CONDITION_NOT_RULE:
$rule = $engine->getRule($condition_value);
if (!$rule) {
throw new HeraldInvalidConditionException(
pht('Condition references a rule which does not exist!'));
}
$is_not = ($condition_type == self::CONDITION_NOT_RULE);
$result = $engine->doesRuleMatch($rule, $this);
if ($is_not) {
$result = !$result;
}
return $result;
case self::CONDITION_HAS_BIT:
return (($condition_value & $field_value) === (int)$condition_value);
case self::CONDITION_NOT_BIT:
return (($condition_value & $field_value) !== (int)$condition_value);
default:
throw new HeraldInvalidConditionException(
pht("Unknown condition '%s'.", $condition_type));
}
}
public function willSaveCondition(HeraldCondition $condition) {
$condition_type = $condition->getFieldCondition();
$condition_value = $condition->getValue();
switch ($condition_type) {
case self::CONDITION_REGEXP:
case self::CONDITION_NOT_REGEXP:
$ok = @preg_match($condition_value, '');
if ($ok === false) {
throw new HeraldInvalidConditionException(
pht(
'The regular expression "%s" is not valid. Regular expressions '.
'must have enclosing characters (e.g. "@/path/to/file@", not '.
'"/path/to/file") and be syntactically correct.',
$condition_value));
}
break;
case self::CONDITION_REGEXP_PAIR:
$json = null;
try {
$json = phutil_json_decode($condition_value);
} catch (PhutilJSONParserException $ex) {
throw new HeraldInvalidConditionException(
pht(
'The regular expression pair "%s" is not valid JSON. Enter a '.
'valid JSON array with two elements.',
$condition_value));
}
if (count($json) != 2) {
throw new HeraldInvalidConditionException(
pht(
'The regular expression pair "%s" must have exactly two '.
'elements.',
$condition_value));
}
$key_regexp = array_shift($json);
$val_regexp = array_shift($json);
$key_ok = @preg_match($key_regexp, '');
if ($key_ok === false) {
throw new HeraldInvalidConditionException(
pht(
'The first regexp in the regexp pair, "%s", is not a valid '.
'regexp.',
$key_regexp));
}
$val_ok = @preg_match($val_regexp, '');
if ($val_ok === false) {
throw new HeraldInvalidConditionException(
pht(
'The second regexp in the regexp pair, "%s", is not a valid '.
'regexp.',
$val_regexp));
}
break;
case self::CONDITION_CONTAINS:
case self::CONDITION_NOT_CONTAINS:
case self::CONDITION_IS:
case self::CONDITION_IS_NOT:
case self::CONDITION_IS_ANY:
case self::CONDITION_IS_NOT_ANY:
case self::CONDITION_INCLUDE_ALL:
case self::CONDITION_INCLUDE_ANY:
case self::CONDITION_INCLUDE_NONE:
case self::CONDITION_IS_ME:
case self::CONDITION_IS_NOT_ME:
case self::CONDITION_RULE:
case self::CONDITION_NOT_RULE:
case self::CONDITION_EXISTS:
case self::CONDITION_NOT_EXISTS:
case self::CONDITION_UNCONDITIONALLY:
case self::CONDITION_NEVER:
case self::CONDITION_HAS_BIT:
case self::CONDITION_NOT_BIT:
case self::CONDITION_IS_TRUE:
case self::CONDITION_IS_FALSE:
// No explicit validation for these types, although there probably
// should be in some cases.
break;
default:
throw new HeraldInvalidConditionException(
pht(
'Unknown condition "%s"!',
$condition_type));
}
}
/* -( Actions )------------------------------------------------------------ */
private function getActionImplementationMap() {
if ($this->actionMap === null) {
// We can't use PhutilClassMapQuery here because action expansion
// depends on the adapter and object.
$object = $this->getObject();
$map = array();
$all = HeraldAction::getAllActions();
foreach ($all as $key => $action) {
$action = id(clone $action)->setAdapter($this);
if (!$action->supportsObject($object)) {
continue;
}
$subactions = $action->getActionsForObject($object);
foreach ($subactions as $subkey => $subaction) {
if (isset($map[$subkey])) {
throw new Exception(
pht(
'Two HeraldActions (of classes "%s" and "%s") have the same '.
'action key ("%s") after expansion for an object of class '.
'"%s" inside adapter "%s". Each action must have a unique '.
'action key.',
get_class($subaction),
get_class($map[$subkey]),
$subkey,
get_class($object),
get_class($this)));
}
$subaction = id(clone $subaction)->setAdapter($this);
$map[$subkey] = $subaction;
}
}
$this->actionMap = $map;
}
return $this->actionMap;
}
private function requireActionImplementation($action_key) {
$action = $this->getActionImplementation($action_key);
if (!$action) {
throw new Exception(
pht(
'No action with key "%s" is available to Herald adapter "%s".',
$action_key,
get_class($this)));
}
return $action;
}
private function getActionsForRuleType($rule_type) {
$actions = $this->getActionImplementationMap();
foreach ($actions as $key => $action) {
if (!$action->supportsRuleType($rule_type)) {
unset($actions[$key]);
}
}
return $actions;
}
public function getActionImplementation($key) {
return idx($this->getActionImplementationMap(), $key);
}
public function getActionKeys() {
return array_keys($this->getActionImplementationMap());
}
public function getActionGroupKey($action_key) {
$action = $this->getActionImplementation($action_key);
if (!$action) {
return null;
}
return $action->getActionGroupKey();
}
public function isActionAvailable($action_key) {
$action = $this->getActionImplementation($action_key);
if (!$action) {
return null;
}
return $action->isActionAvailable();
}
public function getActions($rule_type) {
$actions = array();
foreach ($this->getActionsForRuleType($rule_type) as $key => $action) {
$actions[] = $key;
}
return $actions;
}
public function getActionNameMap($rule_type) {
$map = array();
foreach ($this->getActionsForRuleType($rule_type) as $key => $action) {
$map[$key] = $action->getHeraldActionName();
}
return $map;
}
public function willSaveAction(
HeraldRule $rule,
HeraldActionRecord $action) {
$impl = $this->requireActionImplementation($action->getAction());
$target = $action->getTarget();
$target = $impl->willSaveActionValue($target);
$action->setTarget($target);
}
/* -( Values )------------------------------------------------------------- */
public function getValueTypeForFieldAndCondition($field, $condition) {
return $this->requireFieldImplementation($field)
->getHeraldFieldValueType($condition);
}
public function getValueTypeForAction($action, $rule_type) {
$impl = $this->requireActionImplementation($action);
return $impl->getHeraldActionValueType();
}
/* -( Repetition )--------------------------------------------------------- */
public function getRepetitionOptions() {
$options = array();
$options[] = HeraldRule::REPEAT_EVERY;
// Some rules, like pre-commit rules, only ever fire once. It doesn't
// make sense to use state-based repetition policies like "only the first
// time" for these rules.
if (!$this->isSingleEventAdapter()) {
$options[] = HeraldRule::REPEAT_FIRST;
$options[] = HeraldRule::REPEAT_CHANGE;
}
return $options;
}
protected function initializeNewAdapter() {
$this->setObject($this->newObject());
return $this;
}
/**
* Does this adapter's event fire only once?
*
* Single use adapters (like pre-commit and diff adapters) only fire once,
* so fields like "Is new object" don't make sense to apply to their content.
*
* @return bool
*/
public function isSingleEventAdapter() {
return false;
}
public static function getAllAdapters() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getAdapterContentType')
->setSortMethod('getAdapterSortKey')
->execute();
}
public static function getAdapterForContentType($content_type) {
$adapters = self::getAllAdapters();
foreach ($adapters as $adapter) {
if ($adapter->getAdapterContentType() == $content_type) {
$adapter = id(clone $adapter);
$adapter->initializeNewAdapter();
return $adapter;
}
}
throw new Exception(
pht(
'No adapter exists for Herald content type "%s".',
$content_type));
}
public static function getEnabledAdapterMap(PhabricatorUser $viewer) {
$map = array();
$adapters = self::getAllAdapters();
foreach ($adapters as $adapter) {
if (!$adapter->isAvailableToUser($viewer)) {
continue;
}
$type = $adapter->getAdapterContentType();
$name = $adapter->getAdapterContentName();
$map[$type] = $name;
}
return $map;
}
public function getEditorValueForCondition(
PhabricatorUser $viewer,
HeraldCondition $condition) {
$field = $this->requireFieldImplementation($condition->getFieldName());
return $field->getEditorValue(
$viewer,
$condition->getFieldCondition(),
$condition->getValue());
}
public function getEditorValueForAction(
PhabricatorUser $viewer,
HeraldActionRecord $action_record) {
$action = $this->requireActionImplementation($action_record->getAction());
return $action->getEditorValue(
$viewer,
$action_record->getTarget());
}
public function renderRuleAsText(
HeraldRule $rule,
PhabricatorUser $viewer) {
require_celerity_resource('herald-css');
$icon = id(new PHUIIconView())
->setIcon('fa-chevron-circle-right lightgreytext')
->addClass('herald-list-icon');
if ($rule->getMustMatchAll()) {
$match_text = pht('When all of these conditions are met:');
} else {
$match_text = pht('When any of these conditions are met:');
}
$match_title = phutil_tag(
'p',
array(
'class' => 'herald-list-description',
),
$match_text);
$match_list = array();
foreach ($rule->getConditions() as $condition) {
$match_list[] = phutil_tag(
'div',
array(
'class' => 'herald-list-item',
),
array(
$icon,
$this->renderConditionAsText($condition, $viewer),
));
}
if ($rule->isRepeatFirst()) {
$action_text = pht(
'Take these actions the first time this rule matches:');
} else if ($rule->isRepeatOnChange()) {
$action_text = pht(
'Take these actions if this rule did not match the last time:');
} else {
$action_text = pht(
'Take these actions every time this rule matches:');
}
$action_title = phutil_tag(
'p',
array(
'class' => 'herald-list-description',
),
$action_text);
$action_list = array();
foreach ($rule->getActions() as $action) {
$action_list[] = phutil_tag(
'div',
array(
'class' => 'herald-list-item',
),
array(
$icon,
$this->renderActionAsText($viewer, $action),
));
}
return array(
$match_title,
$match_list,
$action_title,
$action_list,
);
}
private function renderConditionAsText(
HeraldCondition $condition,
PhabricatorUser $viewer) {
$field_type = $condition->getFieldName();
$field = $this->getFieldImplementation($field_type);
if (!$field) {
return pht('Unknown Field: "%s"', $field_type);
}
$field_name = $field->getHeraldFieldName();
$condition_type = $condition->getFieldCondition();
$condition_name = idx($this->getConditionNameMap(), $condition_type);
$value = $this->renderConditionValueAsText($condition, $viewer);
return array(
$field_name,
' ',
$condition_name,
' ',
$value,
);
}
private function renderActionAsText(
PhabricatorUser $viewer,
HeraldActionRecord $action_record) {
$action_type = $action_record->getAction();
$action_value = $action_record->getTarget();
$action = $this->getActionImplementation($action_type);
if (!$action) {
return pht('Unknown Action ("%s")', $action_type);
}
$action->setViewer($viewer);
return $action->renderActionDescription($action_value);
}
private function renderConditionValueAsText(
HeraldCondition $condition,
PhabricatorUser $viewer) {
$field = $this->requireFieldImplementation($condition->getFieldName());
return $field->renderConditionValue(
$viewer,
$condition->getFieldCondition(),
$condition->getValue());
}
public function renderFieldTranscriptValue(
PhabricatorUser $viewer,
$field_type,
$field_value) {
$field = $this->getFieldImplementation($field_type);
if ($field) {
return $field->renderTranscriptValue(
$viewer,
$field_value);
}
return phutil_tag(
'em',
array(),
pht(
'Unable to render value for unknown field type ("%s").',
$field_type));
}
/* -( Applying Effects )--------------------------------------------------- */
/**
* @task apply
*/
protected function applyStandardEffect(HeraldEffect $effect) {
$action = $effect->getAction();
$rule_type = $effect->getRule()->getRuleType();
$impl = $this->getActionImplementation($action);
if (!$impl) {
return new HeraldApplyTranscript(
$effect,
false,
array(
array(
HeraldAction::DO_STANDARD_INVALID_ACTION,
$action,
),
));
}
if (!$impl->supportsRuleType($rule_type)) {
return new HeraldApplyTranscript(
$effect,
false,
array(
array(
HeraldAction::DO_STANDARD_WRONG_RULE_TYPE,
$rule_type,
),
));
}
$impl->applyEffect($this->getObject(), $effect);
return $impl->getApplyTranscript($effect);
}
public function loadEdgePHIDs($type) {
if (!isset($this->edgeCache[$type])) {
$phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$this->getObject()->getPHID(),
$type);
$this->edgeCache[$type] = array_fuse($phids);
}
return $this->edgeCache[$type];
}
/* -( Forbidden Actions )-------------------------------------------------- */
final public function getForbiddenActions() {
return array_keys($this->forbiddenActions);
}
final public function setForbiddenAction($action, $reason) {
$this->forbiddenActions[$action] = $reason;
return $this;
}
final public function getRequiredFieldStates($field_key) {
return $this->requireFieldImplementation($field_key)
->getRequiredAdapterStates();
}
final public function getRequiredActionStates($action_key) {
return $this->requireActionImplementation($action_key)
->getRequiredAdapterStates();
}
final public function getForbiddenReason($action) {
if (!isset($this->forbiddenActions[$action])) {
throw new Exception(
pht(
'Action "%s" is not forbidden!',
$action));
}
return $this->forbiddenActions[$action];
}
/* -( Must Encrypt )------------------------------------------------------- */
final public function addMustEncryptReason($reason) {
$this->mustEncryptReasons[] = $reason;
return $this;
}
final public function getMustEncryptReasons() {
return $this->mustEncryptReasons;
}
/* -( Webhooks )----------------------------------------------------------- */
public function supportsWebhooks() {
return true;
}
final public function queueWebhook($webhook_phid, $rule_phid) {
$this->webhookMap[$webhook_phid][] = $rule_phid;
return $this;
}
final public function getWebhookMap() {
return $this->webhookMap;
}
}
diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php
index 826c950577..6d586917fc 100644
--- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php
+++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php
@@ -1,1266 +1,1266 @@
<?php
/**
* @task recipients Managing Recipients
*/
final class PhabricatorMetaMTAMail
extends PhabricatorMetaMTADAO
implements
PhabricatorPolicyInterface,
PhabricatorDestructibleInterface {
const RETRY_DELAY = 5;
protected $actorPHID;
protected $parameters = array();
protected $status;
protected $message;
protected $relatedPHID;
private $recipientExpansionMap;
private $routingMap;
public function __construct() {
$this->status = PhabricatorMailOutboundStatus::STATUS_QUEUE;
$this->parameters = array(
'sensitive' => true,
'mustEncrypt' => false,
);
parent::__construct();
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'parameters' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'actorPHID' => 'phid?',
'status' => 'text32',
'relatedPHID' => 'phid?',
// T6203/NULLABILITY
// This should just be empty if there's no body.
'message' => 'text?',
),
self::CONFIG_KEY_SCHEMA => array(
'status' => array(
'columns' => array('status'),
),
'key_actorPHID' => array(
'columns' => array('actorPHID'),
),
'relatedPHID' => array(
'columns' => array('relatedPHID'),
),
'key_created' => array(
'columns' => array('dateCreated'),
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorMetaMTAMailPHIDType::TYPECONST);
}
protected function setParam($param, $value) {
$this->parameters[$param] = $value;
return $this;
}
protected function getParam($param, $default = null) {
// Some old mail was saved without parameters because no parameters were
// set or encoding failed. Recover in these cases so we can perform
// mail migrations, see T9251.
if (!is_array($this->parameters)) {
$this->parameters = array();
}
return idx($this->parameters, $param, $default);
}
/**
* These tags are used to allow users to opt out of receiving certain types
* of mail, like updates when a task's projects change.
*
* @param list<const> $tags
- * @return this
+ * @return $this
*/
public function setMailTags(array $tags) {
$this->setParam('mailtags', array_unique($tags));
return $this;
}
public function getMailTags() {
return $this->getParam('mailtags', array());
}
/**
* In Gmail, conversations will be broken if you reply to a thread and the
* server sends back a response without referencing your Message-ID, even if
* it references a Message-ID earlier in the thread. To avoid this, use the
* parent email's message ID explicitly if it's available. This overwrites the
* "In-Reply-To" and "References" headers we would otherwise generate. This
* needs to be set whenever an action is triggered by an email message. See
* T251 for more details.
*
* @param string $id The "Message-ID" of the email which precedes this one.
- * @return this
+ * @return $this
*/
public function setParentMessageID($id) {
$this->setParam('parent-message-id', $id);
return $this;
}
public function getParentMessageID() {
return $this->getParam('parent-message-id');
}
public function getSubject() {
return $this->getParam('subject');
}
public function addTos(array $phids) {
$phids = array_unique($phids);
$this->setParam('to', $phids);
return $this;
}
public function addRawTos(array $raw_email) {
// Strip addresses down to bare emails, since the MailAdapter API currently
// requires we pass it just the address (like `alincoln@logcabin.org`), not
// a full string like `"Abraham Lincoln" <alincoln@logcabin.org>`.
foreach ($raw_email as $key => $email) {
$object = new PhutilEmailAddress($email);
$raw_email[$key] = $object->getAddress();
}
$this->setParam('raw-to', $raw_email);
return $this;
}
public function addCCs(array $phids) {
$phids = array_unique($phids);
$this->setParam('cc', $phids);
return $this;
}
public function setExcludeMailRecipientPHIDs(array $exclude) {
$this->setParam('exclude', $exclude);
return $this;
}
private function getExcludeMailRecipientPHIDs() {
return $this->getParam('exclude', array());
}
public function setMutedPHIDs(array $muted) {
$this->setParam('muted', $muted);
return $this;
}
private function getMutedPHIDs() {
return $this->getParam('muted', array());
}
public function setForceHeraldMailRecipientPHIDs(array $force) {
$this->setParam('herald-force-recipients', $force);
return $this;
}
private function getForceHeraldMailRecipientPHIDs() {
return $this->getParam('herald-force-recipients', array());
}
public function addPHIDHeaders($name, array $phids) {
$phids = array_unique($phids);
foreach ($phids as $phid) {
$this->addHeader($name, '<'.$phid.'>');
}
return $this;
}
public function addHeader($name, $value) {
$this->parameters['headers'][] = array($name, $value);
return $this;
}
public function getHeaders() {
return $this->getParam('headers', array());
}
public function addAttachment(PhabricatorMailAttachment $attachment) {
$this->parameters['attachments'][] = $attachment->toDictionary();
return $this;
}
public function getAttachments() {
$dicts = $this->getParam('attachments', array());
$result = array();
foreach ($dicts as $dict) {
$result[] = PhabricatorMailAttachment::newFromDictionary($dict);
}
return $result;
}
public function getAttachmentFilePHIDs() {
$file_phids = array();
$dictionaries = $this->getParam('attachments');
if ($dictionaries) {
foreach ($dictionaries as $dictionary) {
$file_phid = idx($dictionary, 'filePHID');
if ($file_phid) {
$file_phids[] = $file_phid;
}
}
}
return $file_phids;
}
public function loadAttachedFiles(PhabricatorUser $viewer) {
$file_phids = $this->getAttachmentFilePHIDs();
if (!$file_phids) {
return array();
}
return id(new PhabricatorFileQuery())
->setViewer($viewer)
->withPHIDs($file_phids)
->execute();
}
public function setAttachments(array $attachments) {
assert_instances_of($attachments, 'PhabricatorMailAttachment');
$this->setParam('attachments', mpull($attachments, 'toDictionary'));
return $this;
}
public function setFrom($from) {
$this->setParam('from', $from);
$this->setActorPHID($from);
return $this;
}
public function getFrom() {
return $this->getParam('from');
}
public function setRawFrom($raw_email, $raw_name) {
$this->setParam('raw-from', array($raw_email, $raw_name));
return $this;
}
public function getRawFrom() {
return $this->getParam('raw-from');
}
public function setReplyTo($reply_to) {
$this->setParam('reply-to', $reply_to);
return $this;
}
public function getReplyTo() {
return $this->getParam('reply-to');
}
public function setSubject($subject) {
$this->setParam('subject', $subject);
return $this;
}
public function setSubjectPrefix($prefix) {
$this->setParam('subject-prefix', $prefix);
return $this;
}
public function getSubjectPrefix() {
return $this->getParam('subject-prefix');
}
public function setVarySubjectPrefix($prefix) {
$this->setParam('vary-subject-prefix', $prefix);
return $this;
}
public function getVarySubjectPrefix() {
return $this->getParam('vary-subject-prefix');
}
public function setBody($body) {
$this->setParam('body', $body);
return $this;
}
public function setSensitiveContent($bool) {
$this->setParam('sensitive', $bool);
return $this;
}
public function hasSensitiveContent() {
return $this->getParam('sensitive', true);
}
public function setMustEncrypt($bool) {
return $this->setParam('mustEncrypt', $bool);
}
public function getMustEncrypt() {
return $this->getParam('mustEncrypt', false);
}
public function setMustEncryptURI($uri) {
return $this->setParam('mustEncrypt.uri', $uri);
}
public function getMustEncryptURI() {
return $this->getParam('mustEncrypt.uri');
}
public function setMustEncryptSubject($subject) {
return $this->setParam('mustEncrypt.subject', $subject);
}
public function getMustEncryptSubject() {
return $this->getParam('mustEncrypt.subject');
}
public function setMustEncryptReasons(array $reasons) {
return $this->setParam('mustEncryptReasons', $reasons);
}
public function getMustEncryptReasons() {
return $this->getParam('mustEncryptReasons', array());
}
public function setMailStamps(array $stamps) {
return $this->setParam('stamps', $stamps);
}
public function getMailStamps() {
return $this->getParam('stamps', array());
}
public function setMailStampMetadata($metadata) {
return $this->setParam('stampMetadata', $metadata);
}
public function getMailStampMetadata() {
return $this->getParam('stampMetadata', array());
}
public function getMailerKey() {
return $this->getParam('mailer.key');
}
public function setTryMailers(array $mailers) {
return $this->setParam('mailers.try', $mailers);
}
public function setHTMLBody($html) {
$this->setParam('html-body', $html);
return $this;
}
public function getBody() {
return $this->getParam('body');
}
public function getHTMLBody() {
return $this->getParam('html-body');
}
public function setIsErrorEmail($is_error) {
$this->setParam('is-error', $is_error);
return $this;
}
public function getIsErrorEmail() {
return $this->getParam('is-error', false);
}
public function getToPHIDs() {
return $this->getParam('to', array());
}
public function getRawToAddresses() {
return $this->getParam('raw-to', array());
}
public function getCcPHIDs() {
return $this->getParam('cc', array());
}
public function setMessageType($message_type) {
return $this->setParam('message.type', $message_type);
}
public function getMessageType() {
return $this->getParam(
'message.type',
PhabricatorMailEmailMessage::MESSAGETYPE);
}
/**
* Force delivery of a message, even if recipients have preferences which
* would otherwise drop the message.
*
* This is primarily intended to let users who don't want any email still
* receive things like password resets.
*
* @param bool $force True to force delivery despite user preferences.
- * @return this
+ * @return $this
*/
public function setForceDelivery($force) {
$this->setParam('force', $force);
return $this;
}
public function getForceDelivery() {
return $this->getParam('force', false);
}
/**
* Flag that this is an auto-generated bulk message and should have bulk
* headers added to it if appropriate. Broadly, this means some flavor of
* "Precedence: bulk" or similar, but is implementation and configuration
* dependent.
*
* @param bool $is_bulk True if the mail is automated bulk mail.
- * @return this
+ * @return $this
*/
public function setIsBulk($is_bulk) {
$this->setParam('is-bulk', $is_bulk);
return $this;
}
public function getIsBulk() {
return $this->getParam('is-bulk');
}
/**
* Use this method to set an ID used for message threading. MetaMTA will
* set appropriate headers (Message-ID, In-Reply-To, References and
* Thread-Index) based on the capabilities of the underlying mailer.
*
* @param string $thread_id Unique identifier, appropriate for use in a
* Message-ID, In-Reply-To or References headers.
* @param bool $is_first_message (optional) If true, indicates this is the
* first message in the thread.
- * @return this
+ * @return $this
*/
public function setThreadID($thread_id, $is_first_message = false) {
$this->setParam('thread-id', $thread_id);
$this->setParam('is-first-message', $is_first_message);
return $this;
}
public function getThreadID() {
return $this->getParam('thread-id');
}
public function getIsFirstMessage() {
return (bool)$this->getParam('is-first-message');
}
/**
* Save a newly created mail to the database. The mail will eventually be
* delivered by the MetaMTA daemon.
*
- * @return this
+ * @return $this
*/
public function saveAndSend() {
return $this->save();
}
/**
- * @return this
+ * @return $this
*/
public function save() {
if ($this->getID()) {
return parent::save();
}
// NOTE: When mail is sent from CLI scripts that run tasks in-process, we
// may re-enter this method from within scheduleTask(). The implementation
// is intended to avoid anything awkward if we end up reentering this
// method.
$this->openTransaction();
// Save to generate a mail ID and PHID.
$result = parent::save();
// Write the recipient edges.
$editor = new PhabricatorEdgeEditor();
$edge_type = PhabricatorMetaMTAMailHasRecipientEdgeType::EDGECONST;
$recipient_phids = array_merge(
$this->getToPHIDs(),
$this->getCcPHIDs());
$expanded_phids = $this->expandRecipients($recipient_phids);
$all_phids = array_unique(array_merge(
$recipient_phids,
$expanded_phids));
foreach ($all_phids as $curr_phid) {
$editor->addEdge($this->getPHID(), $edge_type, $curr_phid);
}
$editor->save();
$this->saveTransaction();
// Queue a task to send this mail.
$mailer_task = PhabricatorWorker::scheduleTask(
'PhabricatorMetaMTAWorker',
$this->getID(),
array(
'priority' => PhabricatorWorker::PRIORITY_ALERTS,
));
return $result;
}
/**
* Attempt to deliver an email immediately, in this process.
*
* @return void
*/
public function sendNow() {
if ($this->getStatus() != PhabricatorMailOutboundStatus::STATUS_QUEUE) {
throw new Exception(pht('Trying to send an already-sent mail!'));
}
$mailers = self::newMailers(
array(
'outbound' => true,
'media' => array(
$this->getMessageType(),
),
));
$try_mailers = $this->getParam('mailers.try');
if ($try_mailers) {
$mailers = mpull($mailers, null, 'getKey');
$mailers = array_select_keys($mailers, $try_mailers);
}
return $this->sendWithMailers($mailers);
}
public static function newMailers(array $constraints) {
PhutilTypeSpec::checkMap(
$constraints,
array(
'types' => 'optional list<string>',
'inbound' => 'optional bool',
'outbound' => 'optional bool',
'media' => 'optional list<string>',
));
$mailers = array();
$config = PhabricatorEnv::getEnvConfig('cluster.mailers');
$adapters = PhabricatorMailAdapter::getAllAdapters();
$next_priority = -1;
foreach ($config as $spec) {
$type = $spec['type'];
if (!isset($adapters[$type])) {
throw new Exception(
pht(
'Unknown mailer ("%s")!',
$type));
}
$key = $spec['key'];
$mailer = id(clone $adapters[$type])
->setKey($key);
$priority = idx($spec, 'priority');
if (!$priority) {
$priority = $next_priority;
$next_priority--;
}
$mailer->setPriority($priority);
$defaults = $mailer->newDefaultOptions();
$options = idx($spec, 'options', array()) + $defaults;
$mailer->setOptions($options);
$mailer->setSupportsInbound(idx($spec, 'inbound', true));
$mailer->setSupportsOutbound(idx($spec, 'outbound', true));
$media = idx($spec, 'media');
if ($media !== null) {
$mailer->setMedia($media);
}
$mailers[] = $mailer;
}
// Remove mailers with the wrong types.
if (isset($constraints['types'])) {
$types = $constraints['types'];
$types = array_fuse($types);
foreach ($mailers as $key => $mailer) {
$mailer_type = $mailer->getAdapterType();
if (!isset($types[$mailer_type])) {
unset($mailers[$key]);
}
}
}
// If we're only looking for inbound mailers, remove mailers with inbound
// support disabled.
if (!empty($constraints['inbound'])) {
foreach ($mailers as $key => $mailer) {
if (!$mailer->getSupportsInbound()) {
unset($mailers[$key]);
}
}
}
// If we're only looking for outbound mailers, remove mailers with outbound
// support disabled.
if (!empty($constraints['outbound'])) {
foreach ($mailers as $key => $mailer) {
if (!$mailer->getSupportsOutbound()) {
unset($mailers[$key]);
}
}
}
// Select only the mailers which can transmit messages with requested media
// types.
if (!empty($constraints['media'])) {
foreach ($mailers as $key => $mailer) {
$supports_any = false;
foreach ($constraints['media'] as $medium) {
if ($mailer->supportsMessageType($medium)) {
$supports_any = true;
break;
}
}
if (!$supports_any) {
unset($mailers[$key]);
}
}
}
$sorted = array();
$groups = mgroup($mailers, 'getPriority');
krsort($groups);
foreach ($groups as $group) {
// Reorder services within the same priority group randomly.
shuffle($group);
foreach ($group as $mailer) {
$sorted[] = $mailer;
}
}
return $sorted;
}
public function sendWithMailers(array $mailers) {
if (!$mailers) {
$any_mailers = self::newMailers(array());
// NOTE: We can end up here with some custom list of "$mailers", like
// from a unit test. In that case, this message could be misleading. We
// can't really tell if the caller made up the list, so just assume they
// aren't tricking us.
if ($any_mailers) {
$void_message = pht(
'No configured mailers support sending outbound mail.');
} else {
$void_message = pht(
'No mailers are configured.');
}
return $this
->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID)
->setMessage($void_message)
->save();
}
$actors = $this->loadAllActors();
// If we're sending one mail to everyone, some recipients will be in
// "Cc" rather than "To". We'll move them to "To" later (or supply a
// dummy "To") but need to look for the recipient in either the
// "To" or "Cc" fields here.
$target_phid = head($this->getToPHIDs());
if (!$target_phid) {
$target_phid = head($this->getCcPHIDs());
}
$preferences = $this->loadPreferences($target_phid);
// Attach any files we're about to send to this message, so the recipients
// can view them.
$viewer = PhabricatorUser::getOmnipotentUser();
$files = $this->loadAttachedFiles($viewer);
foreach ($files as $file) {
$file->attachToObject($this->getPHID());
}
$type_map = PhabricatorMailExternalMessage::getAllMessageTypes();
$type = idx($type_map, $this->getMessageType());
if (!$type) {
throw new Exception(
pht(
'Unable to send message with unknown message type "%s".',
$type));
}
$exceptions = array();
foreach ($mailers as $mailer) {
try {
$message = $type->newMailMessageEngine()
->setMailer($mailer)
->setMail($this)
->setActors($actors)
->setPreferences($preferences)
->newMessage($mailer);
} catch (Exception $ex) {
$exceptions[] = $ex;
continue;
}
if (!$message) {
// If we don't get a message back, that means the mail doesn't actually
// need to be sent (for example, because recipients have declined to
// receive the mail). Void it and return.
return $this
->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID)
->save();
}
try {
$mailer->sendMessage($message);
} catch (PhabricatorMetaMTAPermanentFailureException $ex) {
// If any mailer raises a permanent failure, stop trying to send the
// mail with other mailers.
$this
->setStatus(PhabricatorMailOutboundStatus::STATUS_FAIL)
->setMessage($ex->getMessage())
->save();
throw $ex;
} catch (Exception $ex) {
$exceptions[] = $ex;
continue;
}
// Keep track of which mailer actually ended up accepting the message.
$mailer_key = $mailer->getKey();
if ($mailer_key !== null) {
$this->setParam('mailer.key', $mailer_key);
}
// Now that we sent the message, store the final deliverability outcomes
// and reasoning so we can explain why things happened the way they did.
$actor_list = array();
foreach ($actors as $actor) {
$actor_list[$actor->getPHID()] = array(
'deliverable' => $actor->isDeliverable(),
'reasons' => $actor->getDeliverabilityReasons(),
);
}
$this->setParam('actors.sent', $actor_list);
$this->setParam('routing.sent', $this->getParam('routing'));
$this->setParam('routingmap.sent', $this->getRoutingRuleMap());
return $this
->setStatus(PhabricatorMailOutboundStatus::STATUS_SENT)
->save();
}
// If we make it here, no mailer could send the mail but no mailer failed
// permanently either. We update the error message for the mail, but leave
// it in the current status (usually, STATUS_QUEUE) and try again later.
$messages = array();
foreach ($exceptions as $ex) {
$messages[] = $ex->getMessage();
}
$messages = implode("\n\n", $messages);
$this
->setMessage($messages)
->save();
if (count($exceptions) === 1) {
throw head($exceptions);
}
throw new PhutilAggregateException(
pht('Encountered multiple exceptions while transmitting mail.'),
$exceptions);
}
public static function shouldMailEachRecipient() {
return PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient');
}
/* -( Managing Recipients )------------------------------------------------ */
/**
* Get all of the recipients for this mail, after preference filters are
* applied. This list has all objects to whom delivery will be attempted.
*
* Note that this expands recipients into their members, because delivery
* is never directly attempted to aggregate actors like projects.
*
* @return list<phid> A list of all recipients to whom delivery will be
* attempted.
* @task recipients
*/
public function buildRecipientList() {
$actors = $this->loadAllActors();
$actors = $this->filterDeliverableActors($actors);
return mpull($actors, 'getPHID');
}
public function loadAllActors() {
$actor_phids = $this->getExpandedRecipientPHIDs();
return $this->loadActors($actor_phids);
}
public function getExpandedRecipientPHIDs() {
$actor_phids = $this->getAllActorPHIDs();
return $this->expandRecipients($actor_phids);
}
private function getAllActorPHIDs() {
return array_merge(
array($this->getParam('from')),
$this->getToPHIDs(),
$this->getCcPHIDs());
}
/**
* Expand a list of recipient PHIDs (possibly including aggregate recipients
* like projects) into a deaggregated list of individual recipient PHIDs.
* For example, this will expand project PHIDs into a list of the project's
* members.
*
* @param list<phid> $phids List of recipient PHIDs, possibly including
* aggregate recipients.
* @return list<phid> Deaggregated list of mailable recipients.
*/
public function expandRecipients(array $phids) {
if ($this->recipientExpansionMap === null) {
$all_phids = $this->getAllActorPHIDs();
$this->recipientExpansionMap = id(new PhabricatorMetaMTAMemberQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($all_phids)
->execute();
}
$results = array();
foreach ($phids as $phid) {
foreach ($this->recipientExpansionMap[$phid] as $recipient_phid) {
$results[$recipient_phid] = $recipient_phid;
}
}
return array_keys($results);
}
private function filterDeliverableActors(array $actors) {
assert_instances_of($actors, 'PhabricatorMetaMTAActor');
$deliverable_actors = array();
foreach ($actors as $phid => $actor) {
if ($actor->isDeliverable()) {
$deliverable_actors[$phid] = $actor;
}
}
return $deliverable_actors;
}
private function loadActors(array $actor_phids) {
$actor_phids = array_filter($actor_phids);
$viewer = PhabricatorUser::getOmnipotentUser();
$actors = id(new PhabricatorMetaMTAActorQuery())
->setViewer($viewer)
->withPHIDs($actor_phids)
->execute();
if (!$actors) {
return array();
}
if ($this->getForceDelivery()) {
// If we're forcing delivery, skip all the opt-out checks. We don't
// bother annotating reasoning on the mail in this case because it should
// always be obvious why the mail hit this rule (e.g., it is a password
// reset mail).
foreach ($actors as $actor) {
$actor->setDeliverable(PhabricatorMetaMTAActor::REASON_FORCE);
}
return $actors;
}
// Exclude explicit recipients.
foreach ($this->getExcludeMailRecipientPHIDs() as $phid) {
$actor = idx($actors, $phid);
if (!$actor) {
continue;
}
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_RESPONSE);
}
// Before running more rules, save a list of the actors who were
// deliverable before we started running preference-based rules. This stops
// us from trying to send mail to disabled users just because a Herald rule
// added them, for example.
$deliverable = array();
foreach ($actors as $phid => $actor) {
if ($actor->isDeliverable()) {
$deliverable[] = $phid;
}
}
// Exclude muted recipients. We're doing this after saving deliverability
// so that Herald "Send me an email" actions can still punch through a
// mute.
foreach ($this->getMutedPHIDs() as $muted_phid) {
$muted_actor = idx($actors, $muted_phid);
if (!$muted_actor) {
continue;
}
$muted_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_MUTED);
}
// For the rest of the rules, order matters. We're going to run all the
// possible rules in order from weakest to strongest, and let the strongest
// matching rule win. The weaker rules leave annotations behind which help
// users understand why the mail was routed the way it was.
// Exclude the actor if their preferences are set.
$from_phid = $this->getParam('from');
$from_actor = idx($actors, $from_phid);
if ($from_actor) {
$from_user = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withPHIDs(array($from_phid))
->needUserSettings(true)
->execute();
$from_user = head($from_user);
if ($from_user) {
$pref_key = PhabricatorEmailSelfActionsSetting::SETTINGKEY;
$exclude_self = $from_user->getUserSetting($pref_key);
if ($exclude_self) {
$from_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_SELF);
}
}
}
$all_prefs = id(new PhabricatorUserPreferencesQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withUserPHIDs($actor_phids)
->needSyntheticPreferences(true)
->execute();
$all_prefs = mpull($all_prefs, null, 'getUserPHID');
$value_email = PhabricatorEmailTagsSetting::VALUE_EMAIL;
// Exclude all recipients who have set preferences to not receive this type
// of email (for example, a user who says they don't want emails about task
// CC changes).
$tags = $this->getParam('mailtags');
if ($tags) {
foreach ($all_prefs as $phid => $prefs) {
$user_mailtags = $prefs->getSettingValue(
PhabricatorEmailTagsSetting::SETTINGKEY);
// The user must have elected to receive mail for at least one
// of the mailtags.
$send = false;
foreach ($tags as $tag) {
if (((int)idx($user_mailtags, $tag, $value_email)) == $value_email) {
$send = true;
break;
}
}
if (!$send) {
$actors[$phid]->setUndeliverable(
PhabricatorMetaMTAActor::REASON_MAILTAGS);
}
}
}
foreach ($deliverable as $phid) {
switch ($this->getRoutingRule($phid)) {
case PhabricatorMailRoutingRule::ROUTE_AS_NOTIFICATION:
$actors[$phid]->setUndeliverable(
PhabricatorMetaMTAActor::REASON_ROUTE_AS_NOTIFICATION);
break;
case PhabricatorMailRoutingRule::ROUTE_AS_MAIL:
$actors[$phid]->setDeliverable(
PhabricatorMetaMTAActor::REASON_ROUTE_AS_MAIL);
break;
default:
// No change.
break;
}
}
// If recipients were initially deliverable and were added by "Send me an
// email" Herald rules, annotate them as such and make them deliverable
// again, overriding any changes made by the "self mail" and "mail tags"
// settings.
$force_recipients = $this->getForceHeraldMailRecipientPHIDs();
$force_recipients = array_fuse($force_recipients);
if ($force_recipients) {
foreach ($deliverable as $phid) {
if (isset($force_recipients[$phid])) {
$actors[$phid]->setDeliverable(
PhabricatorMetaMTAActor::REASON_FORCE_HERALD);
}
}
}
// Exclude recipients who don't want any mail. This rule is very strong
// and runs last.
foreach ($all_prefs as $phid => $prefs) {
$exclude = $prefs->getSettingValue(
PhabricatorEmailNotificationsSetting::SETTINGKEY);
if ($exclude) {
$actors[$phid]->setUndeliverable(
PhabricatorMetaMTAActor::REASON_MAIL_DISABLED);
}
}
// Unless delivery was forced earlier (password resets, confirmation mail),
// never send mail to unverified addresses.
foreach ($actors as $phid => $actor) {
if ($actor->getIsVerified()) {
continue;
}
$actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNVERIFIED);
}
return $actors;
}
public function getDeliveredHeaders() {
return $this->getParam('headers.sent');
}
public function setDeliveredHeaders(array $headers) {
$headers = $this->flattenHeaders($headers);
return $this->setParam('headers.sent', $headers);
}
public function getUnfilteredHeaders() {
$unfiltered = $this->getParam('headers.unfiltered');
if ($unfiltered === null) {
// Older versions of Phabricator did not filter headers, and thus did
// not record unfiltered headers. If we don't have unfiltered header
// data just return the delivered headers for compatibility.
return $this->getDeliveredHeaders();
}
return $unfiltered;
}
public function setUnfilteredHeaders(array $headers) {
$headers = $this->flattenHeaders($headers);
return $this->setParam('headers.unfiltered', $headers);
}
private function flattenHeaders(array $headers) {
assert_instances_of($headers, 'PhabricatorMailHeader');
$list = array();
foreach ($list as $header) {
$list[] = array(
$header->getName(),
$header->getValue(),
);
}
return $list;
}
public function getDeliveredActors() {
return $this->getParam('actors.sent');
}
public function getDeliveredRoutingRules() {
return $this->getParam('routing.sent');
}
public function getDeliveredRoutingMap() {
return $this->getParam('routingmap.sent');
}
public function getDeliveredBody() {
return $this->getParam('body.sent');
}
public function setDeliveredBody($body) {
return $this->setParam('body.sent', $body);
}
public function getURI() {
return '/mail/detail/'.$this->getID().'/';
}
/* -( Routing )------------------------------------------------------------ */
public function addRoutingRule($routing_rule, $phids, $reason_phid) {
$routing = $this->getParam('routing', array());
$routing[] = array(
'routingRule' => $routing_rule,
'phids' => $phids,
'reasonPHID' => $reason_phid,
);
$this->setParam('routing', $routing);
// Throw the routing map away so we rebuild it.
$this->routingMap = null;
return $this;
}
private function getRoutingRule($phid) {
$map = $this->getRoutingRuleMap();
$info = idx($map, $phid, idx($map, 'default'));
if ($info) {
return idx($info, 'rule');
}
return null;
}
private function getRoutingRuleMap() {
if ($this->routingMap === null) {
$map = array();
$routing = $this->getParam('routing', array());
foreach ($routing as $route) {
$phids = $route['phids'];
if ($phids === null) {
$phids = array('default');
}
foreach ($phids as $phid) {
$new_rule = $route['routingRule'];
$current_rule = idx($map, $phid);
if ($current_rule === null) {
$is_stronger = true;
} else {
$is_stronger = PhabricatorMailRoutingRule::isStrongerThan(
$new_rule,
$current_rule);
}
if ($is_stronger) {
$map[$phid] = array(
'rule' => $new_rule,
'reason' => $route['reasonPHID'],
);
}
}
}
$this->routingMap = $map;
}
return $this->routingMap;
}
/* -( Preferences )-------------------------------------------------------- */
private function loadPreferences($target_phid) {
$viewer = PhabricatorUser::getOmnipotentUser();
if (self::shouldMailEachRecipient()) {
$preferences = id(new PhabricatorUserPreferencesQuery())
->setViewer($viewer)
->withUserPHIDs(array($target_phid))
->needSyntheticPreferences(true)
->executeOne();
if ($preferences) {
return $preferences;
}
}
return PhabricatorUserPreferences::loadGlobalPreferences($viewer);
}
public function shouldRenderMailStampsInBody($viewer) {
$preferences = $this->loadPreferences($viewer->getPHID());
$value = $preferences->getSettingValue(
PhabricatorEmailStampsSetting::SETTINGKEY);
return ($value == PhabricatorEmailStampsSetting::VALUE_BODY_STAMPS);
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
);
}
public function getPolicy($capability) {
return PhabricatorPolicies::POLICY_NOONE;
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
$actor_phids = $this->getExpandedRecipientPHIDs();
return in_array($viewer->getPHID(), $actor_phids);
}
public function describeAutomaticCapability($capability) {
return pht(
'The mail sender and message recipients can always see the mail.');
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$files = $this->loadAttachedFiles($engine->getViewer());
foreach ($files as $file) {
$engine->destroyObject($file);
}
$this->delete();
}
}
diff --git a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php
index 2178bfc130..8d73a60977 100644
--- a/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php
+++ b/src/applications/metamta/view/PhabricatorMetaMTAMailBody.php
@@ -1,223 +1,223 @@
<?php
/**
* Render the body of an application email by building it up section-by-section.
*
* @task compose Composition
* @task render Rendering
*/
final class PhabricatorMetaMTAMailBody extends Phobject {
private $sections = array();
private $htmlSections = array();
private $attachments = array();
private $viewer;
private $contextObject;
public function getViewer() {
return $this->viewer;
}
public function setViewer($viewer) {
$this->viewer = $viewer;
return $this;
}
public function setContextObject($context_object) {
$this->contextObject = $context_object;
return $this;
}
public function getContextObject() {
return $this->contextObject;
}
/* -( Composition )-------------------------------------------------------- */
/**
* Add a raw block of text to the email. This will be rendered as-is.
*
* @param string $text Block of text.
- * @return this
+ * @return $this
* @task compose
*/
public function addRawSection($text) {
if (strlen($text)) {
$text = rtrim($text);
$this->sections[] = $text;
$this->htmlSections[] = phutil_escape_html_newlines(
phutil_tag('div', array(), $text));
}
return $this;
}
public function addRemarkupSection($header, $text) {
try {
$engine = $this->newMarkupEngine()
->setMode(PhutilRemarkupEngine::MODE_TEXT);
$styled_text = $engine->markupText($text);
$this->addPlaintextSection($header, $styled_text);
} catch (Exception $ex) {
phlog($ex);
$this->addTextSection($header, $text);
}
try {
$mail_engine = $this->newMarkupEngine()
->setMode(PhutilRemarkupEngine::MODE_HTML_MAIL);
$html = $mail_engine->markupText($text);
$this->addHTMLSection($header, $html);
} catch (Exception $ex) {
phlog($ex);
$this->addHTMLSection($header, $text);
}
return $this;
}
public function addRawPlaintextSection($text) {
if (strlen($text)) {
$text = rtrim($text);
$this->sections[] = $text;
}
return $this;
}
public function addRawHTMLSection($html) {
$this->htmlSections[] = phutil_safe_html($html);
return $this;
}
/**
* Add a block of text with a section header. This is rendered like this:
*
* HEADER
* Text is indented.
*
* @param string $header Header text.
* @param string $section Section text.
- * @return this
+ * @return $this
* @task compose
*/
public function addTextSection($header, $section) {
if ($section instanceof PhabricatorMetaMTAMailSection) {
$plaintext = $section->getPlaintext();
$html = $section->getHTML();
} else {
$plaintext = $section;
$html = phutil_escape_html_newlines(phutil_tag('div', array(), $section));
}
$this->addPlaintextSection($header, $plaintext);
$this->addHTMLSection($header, $html);
return $this;
}
public function addPlaintextSection($header, $text, $indent = true) {
if ($indent) {
$text = $this->indent($text);
}
$this->sections[] = $header."\n".$text;
return $this;
}
public function addHTMLSection($header, $html_fragment) {
if ($header !== null) {
$header = phutil_tag('strong', array(), $header);
}
$this->htmlSections[] = array(
phutil_tag(
'div',
array(),
array(
$header,
phutil_tag('div', array(), $html_fragment),
)),
);
return $this;
}
public function addLinkSection($header, $link) {
$html = phutil_tag('a', array('href' => $link), $link);
$this->addPlaintextSection($header, $link);
$this->addHTMLSection($header, $html);
return $this;
}
/**
* Add an attachment.
*
* @param PhabricatorMailAttachment $attachment Attachment.
- * @return this
+ * @return $this
* @task compose
*/
public function addAttachment(PhabricatorMailAttachment $attachment) {
$this->attachments[] = $attachment;
return $this;
}
/* -( Rendering )---------------------------------------------------------- */
/**
* Render the email body.
*
* @return string Rendered body.
* @task render
*/
public function render() {
return implode("\n\n", $this->sections)."\n";
}
public function renderHTML() {
$br = phutil_tag('br');
$body = phutil_implode_html($br, $this->htmlSections);
return (string)hsprintf('%s', array($body, $br));
}
/**
* Retrieve attachments.
*
* @return list<PhabricatorMailAttachment> Attachments.
* @task render
*/
public function getAttachments() {
return $this->attachments;
}
/**
* Indent a block of text for rendering under a section heading.
*
* @param string $text Text to indent.
* @return string Indented text.
* @task render
*/
private function indent($text) {
return rtrim(" ".str_replace("\n", "\n ", $text));
}
private function newMarkupEngine() {
$engine = PhabricatorMarkupEngine::newMarkupEngine(array())
->setConfig('viewer', $this->getViewer())
->setConfig('uri.base', PhabricatorEnv::getProductionURI('/'));
$context = $this->getContextObject();
if ($context) {
$engine->setConfig('contextObject', $context);
}
return $engine;
}
}
diff --git a/src/applications/notification/query/PhabricatorNotificationQuery.php b/src/applications/notification/query/PhabricatorNotificationQuery.php
index 3c2a26a217..aba49d19e8 100644
--- a/src/applications/notification/query/PhabricatorNotificationQuery.php
+++ b/src/applications/notification/query/PhabricatorNotificationQuery.php
@@ -1,196 +1,196 @@
<?php
/**
* @task config Configuring the Query
* @task exec Query Execution
*/
final class PhabricatorNotificationQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $userPHIDs;
private $keys;
private $unread;
/* -( Configuring the Query )---------------------------------------------- */
public function withUserPHIDs(array $user_phids) {
$this->userPHIDs = $user_phids;
return $this;
}
public function withKeys(array $keys) {
$this->keys = $keys;
return $this;
}
/**
* Filter results by read/unread status. Note that `true` means to return
* only unread notifications, while `false` means to return only //read//
* notifications. The default is `null`, which returns both.
*
* @param mixed $unread True or false to filter results by read status. Null
* to remove the filter.
- * @return this
+ * @return $this
* @task config
*/
public function withUnread($unread) {
$this->unread = $unread;
return $this;
}
/* -( Query Execution )---------------------------------------------------- */
protected function loadPage() {
$story_table = new PhabricatorFeedStoryData();
$notification_table = new PhabricatorFeedStoryNotification();
$conn = $story_table->establishConnection('r');
$data = queryfx_all(
$conn,
'SELECT story.*, notification.hasViewed FROM %R notification
JOIN %R story ON notification.chronologicalKey = story.chronologicalKey
%Q
ORDER BY notification.chronologicalKey DESC
%Q',
$notification_table,
$story_table,
$this->buildWhereClause($conn),
$this->buildLimitClause($conn));
return $data;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->userPHIDs !== null) {
$where[] = qsprintf(
$conn,
'notification.userPHID IN (%Ls)',
$this->userPHIDs);
}
if ($this->unread !== null) {
$where[] = qsprintf(
$conn,
'notification.hasViewed = %d',
(int)!$this->unread);
}
if ($this->keys !== null) {
$where[] = qsprintf(
$conn,
'notification.chronologicalKey IN (%Ls)',
$this->keys);
}
return $where;
}
protected function willFilterPage(array $rows) {
// See T13623. The policy model here is outdated and awkward.
// Users may have notifications about objects they can no longer see.
// Two ways this can arise: destroy an object; or change an object's
// view policy to exclude a user.
// "PhabricatorFeedStory::loadAllFromRows()" does its own policy filtering.
// This doesn't align well with modern query sequencing, but we should be
// able to get away with it by loading here.
// See T13623. Although most queries for notifications return unique
// stories, this isn't a guarantee.
$story_map = ipull($rows, null, 'chronologicalKey');
$viewer = $this->getViewer();
$stories = PhabricatorFeedStory::loadAllFromRows($story_map, $viewer);
$stories = mpull($stories, null, 'getChronologicalKey');
$results = array();
foreach ($rows as $row) {
$story_key = $row['chronologicalKey'];
$has_viewed = $row['hasViewed'];
if (!isset($stories[$story_key])) {
// NOTE: We can't call "didRejectResult()" here because we don't have
// a policy object to pass.
continue;
}
$story = id(clone $stories[$story_key])
->setHasViewed($has_viewed);
if (!$story->isVisibleInNotifications()) {
continue;
}
$results[] = $story;
}
return $results;
}
protected function getDefaultOrderVector() {
return array('key');
}
public function getBuiltinOrders() {
return array(
'newest' => array(
'vector' => array('key'),
'name' => pht('Creation (Newest First)'),
'aliases' => array('created'),
),
'oldest' => array(
'vector' => array('-key'),
'name' => pht('Creation (Oldest First)'),
),
);
}
public function getOrderableColumns() {
return array(
'key' => array(
'table' => 'notification',
'column' => 'chronologicalKey',
'type' => 'string',
'unique' => true,
),
);
}
protected function applyExternalCursorConstraintsToQuery(
PhabricatorCursorPagedPolicyAwareQuery $subquery,
$cursor) {
$subquery
->withKeys(array($cursor))
->setLimit(1);
}
protected function newExternalCursorStringForResult($object) {
return $object->getChronologicalKey();
}
protected function newPagingMapFromPartialObject($object) {
return array(
'key' => $object['chronologicalKey'],
);
}
protected function getPrimaryTableAlias() {
return 'notification';
}
public function getQueryApplicationClass() {
return PhabricatorNotificationsApplication::class;
}
}
diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php
index 0e1e2560ac..dc39868923 100644
--- a/src/applications/people/storage/PhabricatorUser.php
+++ b/src/applications/people/storage/PhabricatorUser.php
@@ -1,1483 +1,1483 @@
<?php
/**
* @task availability Availability
* @task image-cache Profile Image Cache
* @task factors Multi-Factor Authentication
* @task handles Managing Handles
* @task settings Settings
* @task cache User Cache
*/
final class PhabricatorUser
extends PhabricatorUserDAO
implements
PhutilPerson,
PhabricatorPolicyInterface,
PhabricatorCustomFieldInterface,
PhabricatorDestructibleInterface,
PhabricatorSSHPublicKeyInterface,
PhabricatorFlaggableInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorFulltextInterface,
PhabricatorFerretInterface,
PhabricatorConduitResultInterface,
PhabricatorAuthPasswordHashInterface {
const SESSION_TABLE = 'phabricator_session';
const NAMETOKEN_TABLE = 'user_nametoken';
const MAXIMUM_USERNAME_LENGTH = 64;
protected $userName;
protected $realName;
protected $profileImagePHID;
protected $defaultProfileImagePHID;
protected $defaultProfileImageVersion;
protected $availabilityCache;
protected $availabilityCacheTTL;
protected $conduitCertificate;
protected $isSystemAgent = 0;
protected $isMailingList = 0;
protected $isAdmin = 0;
protected $isDisabled = 0;
protected $isEmailVerified = 0;
protected $isApproved = 0;
protected $isEnrolledInMultiFactor = 0;
protected $accountSecret;
private $profile = null;
private $availability = self::ATTACHABLE;
private $preferences = null;
private $omnipotent = false;
private $customFields = self::ATTACHABLE;
private $badgePHIDs = self::ATTACHABLE;
private $alternateCSRFString = self::ATTACHABLE;
private $session = self::ATTACHABLE;
private $rawCacheData = array();
private $usableCacheData = array();
private $handlePool;
private $csrfSalt;
private $settingCacheKeys = array();
private $settingCache = array();
private $allowInlineCacheGeneration;
private $conduitClusterToken = self::ATTACHABLE;
protected function readField($field) {
switch ($field) {
// Make sure these return booleans.
case 'isAdmin':
return (bool)$this->isAdmin;
case 'isDisabled':
return (bool)$this->isDisabled;
case 'isSystemAgent':
return (bool)$this->isSystemAgent;
case 'isMailingList':
return (bool)$this->isMailingList;
case 'isEmailVerified':
return (bool)$this->isEmailVerified;
case 'isApproved':
return (bool)$this->isApproved;
default:
return parent::readField($field);
}
}
/**
* Is this a live account which has passed required approvals? Returns true
* if this is an enabled, verified (if required), approved (if required)
* account, and false otherwise.
*
* @return bool True if this is a standard, usable account.
*/
public function isUserActivated() {
if (!$this->isLoggedIn()) {
return false;
}
if ($this->isOmnipotent()) {
return true;
}
if ($this->getIsDisabled()) {
return false;
}
if (!$this->getIsApproved()) {
return false;
}
if (PhabricatorUserEmail::isEmailVerificationRequired()) {
if (!$this->getIsEmailVerified()) {
return false;
}
}
return true;
}
/**
* Is this a user who we can reasonably expect to respond to requests?
*
* This is used to provide a grey "disabled/unresponsive" dot cue when
* rendering handles and tags, so it isn't a surprise if you get ignored
* when you ask things of users who will not receive notifications or could
* not respond to them (because they are disabled, unapproved, do not have
* verified email addresses, etc).
*
* @return bool True if this user can receive and respond to requests from
* other humans.
*/
public function isResponsive() {
if (!$this->isUserActivated()) {
return false;
}
if (!$this->getIsEmailVerified()) {
return false;
}
return true;
}
public function canEstablishWebSessions() {
if ($this->getIsMailingList()) {
return false;
}
if ($this->getIsSystemAgent()) {
return false;
}
return true;
}
public function canEstablishAPISessions() {
if ($this->getIsDisabled()) {
return false;
}
// Intracluster requests are permitted even if the user is logged out:
// in particular, public users are allowed to issue intracluster requests
// when browsing Diffusion.
if (PhabricatorEnv::isClusterRemoteAddress()) {
if (!$this->isLoggedIn()) {
return true;
}
}
if (!$this->isUserActivated()) {
return false;
}
if ($this->getIsMailingList()) {
return false;
}
return true;
}
public function canEstablishSSHSessions() {
if (!$this->isUserActivated()) {
return false;
}
if ($this->getIsMailingList()) {
return false;
}
return true;
}
/**
* Returns `true` if this is a standard user who is logged in. Returns `false`
* for logged out, anonymous, or external users.
*
* @return bool `true` if the user is a standard user who is logged in with
* a normal session.
*/
public function getIsStandardUser() {
$type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
return $this->getPHID() && (phid_get_type($this->getPHID()) == $type_user);
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'userName' => 'sort64',
'realName' => 'text128',
'profileImagePHID' => 'phid?',
'conduitCertificate' => 'text255',
'isSystemAgent' => 'bool',
'isMailingList' => 'bool',
'isDisabled' => 'bool',
'isAdmin' => 'bool',
'isEmailVerified' => 'uint32',
'isApproved' => 'uint32',
'accountSecret' => 'bytes64',
'isEnrolledInMultiFactor' => 'bool',
'availabilityCache' => 'text255?',
'availabilityCacheTTL' => 'uint32?',
'defaultProfileImagePHID' => 'phid?',
'defaultProfileImageVersion' => 'text64?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'userName' => array(
'columns' => array('userName'),
'unique' => true,
),
'realName' => array(
'columns' => array('realName'),
),
'key_approved' => array(
'columns' => array('isApproved'),
),
),
self::CONFIG_NO_MUTATE => array(
'availabilityCache' => true,
'availabilityCacheTTL' => true,
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorPeopleUserPHIDType::TYPECONST);
}
public function getMonogram() {
return '@'.$this->getUsername();
}
public function isLoggedIn() {
return !($this->getPHID() === null);
}
public function saveWithoutIndex() {
return parent::save();
}
public function save() {
if (!$this->getConduitCertificate()) {
$this->setConduitCertificate($this->generateConduitCertificate());
}
$secret = $this->getAccountSecret();
if (($secret === null) || !strlen($secret)) {
$this->setAccountSecret(Filesystem::readRandomCharacters(64));
}
$result = $this->saveWithoutIndex();
if ($this->profile) {
$this->profile->save();
}
$this->updateNameTokens();
PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
return $result;
}
public function attachSession(PhabricatorAuthSession $session) {
$this->session = $session;
return $this;
}
public function getSession() {
return $this->assertAttached($this->session);
}
public function hasSession() {
return ($this->session !== self::ATTACHABLE);
}
public function hasHighSecuritySession() {
if (!$this->hasSession()) {
return false;
}
return $this->getSession()->isHighSecuritySession();
}
private function generateConduitCertificate() {
return Filesystem::readRandomCharacters(255);
}
const EMAIL_CYCLE_FREQUENCY = 86400;
const EMAIL_TOKEN_LENGTH = 24;
/**
* This function removes the blurb from a profile.
* This is an incredibly broad hammer to handle some spam on the upstream,
* which will be refined later.
*
* @return void
*/
private function cleanUpProfile() {
$this->profile->setBlurb('');
}
public function getUserProfile() {
return $this->assertAttached($this->profile);
}
public function attachUserProfile(PhabricatorUserProfile $profile) {
$this->profile = $profile;
if ($this->isDisabled) {
$this->cleanUpProfile();
}
return $this;
}
public function loadUserProfile() {
if ($this->profile) {
return $this->profile;
}
$profile_dao = new PhabricatorUserProfile();
$this->profile = $profile_dao->loadOneWhere('userPHID = %s',
$this->getPHID());
if (!$this->profile) {
$this->profile = PhabricatorUserProfile::initializeNewProfile($this);
}
if ($this->isDisabled) {
$this->cleanUpProfile();
}
return $this->profile;
}
public function loadPrimaryEmailAddress() {
$email = $this->loadPrimaryEmail();
if (!$email) {
throw new Exception(pht('User has no primary email address!'));
}
return $email->getAddress();
}
public function loadPrimaryEmail() {
return id(new PhabricatorUserEmail())->loadOneWhere(
'userPHID = %s AND isPrimary = 1',
$this->getPHID());
}
/* -( Settings )----------------------------------------------------------- */
public function getUserSetting($key) {
// NOTE: We store available keys and cached values separately to make it
// faster to check for `null` in the cache, which is common.
if (isset($this->settingCacheKeys[$key])) {
return $this->settingCache[$key];
}
$settings_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES;
if ($this->getPHID()) {
$settings = $this->requireCacheData($settings_key);
} else {
$settings = $this->loadGlobalSettings();
}
if (array_key_exists($key, $settings)) {
$value = $settings[$key];
return $this->writeUserSettingCache($key, $value);
}
$cache = PhabricatorCaches::getRuntimeCache();
$cache_key = "settings.defaults({$key})";
$cache_map = $cache->getKeys(array($cache_key));
if ($cache_map) {
$value = $cache_map[$cache_key];
} else {
$defaults = PhabricatorSetting::getAllSettings();
if (isset($defaults[$key])) {
$value = id(clone $defaults[$key])
->setViewer($this)
->getSettingDefaultValue();
} else {
$value = null;
}
$cache->setKey($cache_key, $value);
}
return $this->writeUserSettingCache($key, $value);
}
/**
* Test if a given setting is set to a particular value.
*
* @param const $key Setting key.
* @param wild $value Value to compare.
* @return bool True if the setting has the specified value.
* @task settings
*/
public function compareUserSetting($key, $value) {
$actual = $this->getUserSetting($key);
return ($actual == $value);
}
private function writeUserSettingCache($key, $value) {
$this->settingCacheKeys[$key] = true;
$this->settingCache[$key] = $value;
return $value;
}
public function getTranslation() {
return $this->getUserSetting(PhabricatorTranslationSetting::SETTINGKEY);
}
public function getTimezoneIdentifier() {
return $this->getUserSetting(PhabricatorTimezoneSetting::SETTINGKEY);
}
public static function getGlobalSettingsCacheKey() {
return 'user.settings.globals.v1';
}
private function loadGlobalSettings() {
$cache_key = self::getGlobalSettingsCacheKey();
$cache = PhabricatorCaches::getMutableStructureCache();
$settings = $cache->getKey($cache_key);
if (!$settings) {
$preferences = PhabricatorUserPreferences::loadGlobalPreferences($this);
$settings = $preferences->getPreferences();
$cache->setKey($cache_key, $settings);
}
return $settings;
}
/**
* Override the user's timezone identifier.
*
* This is primarily useful for unit tests.
*
* @param string $identifier New timezone identifier.
- * @return this
+ * @return $this
* @task settings
*/
public function overrideTimezoneIdentifier($identifier) {
$timezone_key = PhabricatorTimezoneSetting::SETTINGKEY;
$this->settingCacheKeys[$timezone_key] = true;
$this->settingCache[$timezone_key] = $identifier;
return $this;
}
public function getGender() {
return $this->getUserSetting(PhabricatorPronounSetting::SETTINGKEY);
}
/**
* Populate the nametoken table, which used to fetch typeahead results. When
* a user types "linc", we want to match "Abraham Lincoln" from on-demand
* typeahead sources. To do this, we need a separate table of name fragments.
*/
public function updateNameTokens() {
$table = self::NAMETOKEN_TABLE;
$conn_w = $this->establishConnection('w');
$tokens = PhabricatorTypeaheadDatasource::tokenizeString(
$this->getUserName().' '.$this->getRealName());
$sql = array();
foreach ($tokens as $token) {
$sql[] = qsprintf(
$conn_w,
'(%d, %s)',
$this->getID(),
$token);
}
queryfx(
$conn_w,
'DELETE FROM %T WHERE userID = %d',
$table,
$this->getID());
if ($sql) {
queryfx(
$conn_w,
'INSERT INTO %T (userID, token) VALUES %LQ',
$table,
$sql);
}
}
public static function describeValidUsername() {
return pht(
'Usernames must contain only numbers, letters, period, underscore, and '.
'hyphen, and can not end with a period. They must have no more than %d '.
'characters.',
new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH));
}
public static function validateUsername($username) {
// NOTE: If you update this, make sure to update:
//
// - Remarkup rule for @mentions.
// - Routing rule for "/p/username/".
// - Unit tests, obviously.
// - describeValidUsername() method, above.
if (strlen($username) > self::MAXIMUM_USERNAME_LENGTH) {
return false;
}
return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]\z/', $username);
}
public static function getDefaultProfileImageURI() {
return celerity_get_resource_uri('/rsrc/image/avatar.png');
}
public function getProfileImageURI() {
$uri_key = PhabricatorUserProfileImageCacheType::KEY_URI;
return $this->requireCacheData($uri_key);
}
public function getUnreadNotificationCount() {
$notification_key = PhabricatorUserNotificationCountCacheType::KEY_COUNT;
return $this->requireCacheData($notification_key);
}
public function getUnreadMessageCount() {
$message_key = PhabricatorUserMessageCountCacheType::KEY_COUNT;
return $this->requireCacheData($message_key);
}
public function getRecentBadgeAwards() {
$badges_key = PhabricatorUserBadgesCacheType::KEY_BADGES;
return $this->requireCacheData($badges_key);
}
public function getFullName() {
if (strlen($this->getRealName())) {
return $this->getUsername().' ('.$this->getRealName().')';
} else {
return $this->getUsername();
}
}
public function getTimeZone() {
return new DateTimeZone($this->getTimezoneIdentifier());
}
public function getTimeZoneOffset() {
$timezone = $this->getTimeZone();
$now = new DateTime('@'.PhabricatorTime::getNow());
$offset = $timezone->getOffset($now);
// Javascript offsets are in minutes and have the opposite sign.
$offset = -(int)($offset / 60);
return $offset;
}
public function getTimeZoneOffsetInHours() {
$offset = $this->getTimeZoneOffset();
$offset = (int)round($offset / 60);
$offset = -$offset;
return $offset;
}
public function formatShortDateTime($when, $now = null) {
if ($now === null) {
$now = PhabricatorTime::getNow();
}
try {
$when = new DateTime('@'.$when);
$now = new DateTime('@'.$now);
} catch (Exception $ex) {
return null;
}
$zone = $this->getTimeZone();
$when->setTimeZone($zone);
$now->setTimeZone($zone);
if ($when->format('Y') !== $now->format('Y')) {
// Different year, so show "Feb 31 2075".
$format = 'M j Y';
} else if ($when->format('Ymd') !== $now->format('Ymd')) {
// Same year but different month and day, so show "Feb 31".
$format = 'M j';
} else {
// Same year, month and day so show a time of day.
$pref_time = PhabricatorTimeFormatSetting::SETTINGKEY;
$format = $this->getUserSetting($pref_time);
}
return $when->format($format);
}
public function __toString() {
return $this->getUsername();
}
public static function loadOneWithEmailAddress($address) {
$email = id(new PhabricatorUserEmail())->loadOneWhere(
'address = %s',
$address);
if (!$email) {
return null;
}
return id(new PhabricatorUser())->loadOneWhere(
'phid = %s',
$email->getUserPHID());
}
public function getDefaultSpacePHID() {
// TODO: We might let the user switch which space they're "in" later on;
// for now just use the global space if one exists.
// If the viewer has access to the default space, use that.
$spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces($this);
foreach ($spaces as $space) {
if ($space->getIsDefaultNamespace()) {
return $space->getPHID();
}
}
// Otherwise, use the space with the lowest ID that they have access to.
// This just tends to keep the default stable and predictable over time,
// so adding a new space won't change behavior for users.
if ($spaces) {
$spaces = msort($spaces, 'getID');
return head($spaces)->getPHID();
}
return null;
}
public function hasConduitClusterToken() {
return ($this->conduitClusterToken !== self::ATTACHABLE);
}
public function attachConduitClusterToken(PhabricatorConduitToken $token) {
$this->conduitClusterToken = $token;
return $this;
}
public function getConduitClusterToken() {
return $this->assertAttached($this->conduitClusterToken);
}
/* -( Availability )------------------------------------------------------- */
/**
* @task availability
*/
public function attachAvailability(array $availability) {
$this->availability = $availability;
return $this;
}
/**
* Get the timestamp the user is away until, if they are currently away.
*
* @return int|null Epoch timestamp, or `null` if the user is not away.
* @task availability
*/
public function getAwayUntil() {
$availability = $this->availability;
$this->assertAttached($availability);
if (!$availability) {
return null;
}
return idx($availability, 'until');
}
public function getDisplayAvailability() {
$availability = $this->availability;
$this->assertAttached($availability);
if (!$availability) {
return null;
}
$busy = PhabricatorCalendarEventInvitee::AVAILABILITY_BUSY;
return idx($availability, 'availability', $busy);
}
public function getAvailabilityEventPHID() {
$availability = $this->availability;
$this->assertAttached($availability);
if (!$availability) {
return null;
}
return idx($availability, 'eventPHID');
}
/**
* Get cached availability, if present.
*
* @return wild|null Cache data, or null if no cache is available.
* @task availability
*/
public function getAvailabilityCache() {
$now = PhabricatorTime::getNow();
if ($this->availabilityCacheTTL <= $now) {
return null;
}
try {
return phutil_json_decode($this->availabilityCache);
} catch (Exception $ex) {
return null;
}
}
/**
* Write to the availability cache.
*
* @param wild $availability Availability cache data.
* @param int|null $ttl Cache TTL.
- * @return this
+ * @return $this
* @task availability
*/
public function writeAvailabilityCache(array $availability, $ttl) {
if (PhabricatorEnv::isReadOnly()) {
return $this;
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$this->establishConnection('w'),
'UPDATE %T SET availabilityCache = %s, availabilityCacheTTL = %nd
WHERE id = %d',
$this->getTableName(),
phutil_json_encode($availability),
$ttl,
$this->getID());
unset($unguarded);
return $this;
}
/* -( Multi-Factor Authentication )---------------------------------------- */
/**
* Update the flag storing this user's enrollment in multi-factor auth.
*
* With certain settings, we need to check if a user has MFA on every page,
* so we cache MFA enrollment on the user object for performance. Calling this
* method synchronizes the cache by examining enrollment records. After
* updating the cache, use @{method:getIsEnrolledInMultiFactor} to check if
* the user is enrolled.
*
* This method should be called after any changes are made to a given user's
* multi-factor configuration.
*
* @return void
* @task factors
*/
public function updateMultiFactorEnrollment() {
$factors = id(new PhabricatorAuthFactorConfigQuery())
->setViewer($this)
->withUserPHIDs(array($this->getPHID()))
->withFactorProviderStatuses(
array(
PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
))
->execute();
$enrolled = count($factors) ? 1 : 0;
if ($enrolled !== $this->isEnrolledInMultiFactor) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$this->establishConnection('w'),
'UPDATE %T SET isEnrolledInMultiFactor = %d WHERE id = %d',
$this->getTableName(),
$enrolled,
$this->getID());
unset($unguarded);
$this->isEnrolledInMultiFactor = $enrolled;
}
}
/**
* Check if the user is enrolled in multi-factor authentication.
*
* Enrolled users have one or more multi-factor authentication sources
* attached to their account. For performance, this value is cached. You
* can use @{method:updateMultiFactorEnrollment} to update the cache.
*
* @return bool True if the user is enrolled.
* @task factors
*/
public function getIsEnrolledInMultiFactor() {
return $this->isEnrolledInMultiFactor;
}
/* -( Omnipotence )-------------------------------------------------------- */
/**
* Returns true if this user is omnipotent. Omnipotent users bypass all policy
* checks.
*
* @return bool True if the user bypasses policy checks.
*/
public function isOmnipotent() {
return $this->omnipotent;
}
/**
* Get an omnipotent user object for use in contexts where there is no acting
* user, notably daemons.
*
* @return PhabricatorUser An omnipotent user.
*/
public static function getOmnipotentUser() {
static $user = null;
if (!$user) {
$user = new PhabricatorUser();
$user->omnipotent = true;
$user->makeEphemeral();
}
return $user;
}
/**
* Get a scalar string identifying this user.
*
* This is similar to using the PHID, but distinguishes between omnipotent
* and public users explicitly. This allows safe construction of cache keys
* or cache buckets which do not conflate public and omnipotent users.
*
* @return string Scalar identifier.
*/
public function getCacheFragment() {
if ($this->isOmnipotent()) {
return 'u.omnipotent';
}
$phid = $this->getPHID();
if ($phid) {
return 'u.'.$phid;
}
return 'u.public';
}
/* -( Managing Handles )--------------------------------------------------- */
/**
* Get a @{class:PhabricatorHandleList} which benefits from this viewer's
* internal handle pool.
*
* @param list<phid> $phids List of PHIDs to load.
* @return PhabricatorHandleList Handle list object.
* @task handle
*/
public function loadHandles(array $phids) {
if ($this->handlePool === null) {
$this->handlePool = id(new PhabricatorHandlePool())
->setViewer($this);
}
return $this->handlePool->newHandleList($phids);
}
/**
* Get a @{class:PHUIHandleView} for a single handle.
*
* This benefits from the viewer's internal handle pool.
*
* @param phid $phid PHID to render a handle for.
* @return PHUIHandleView View of the handle.
* @task handle
*/
public function renderHandle($phid) {
return $this->loadHandles(array($phid))->renderHandle($phid);
}
/**
* Get a @{class:PHUIHandleListView} for a list of handles.
*
* This benefits from the viewer's internal handle pool.
*
* @param list<phid> $phids List of PHIDs to render.
* @return PHUIHandleListView View of the handles.
* @task handle
*/
public function renderHandleList(array $phids) {
return $this->loadHandles($phids)->renderList();
}
public function attachBadgePHIDs(array $phids) {
$this->badgePHIDs = $phids;
return $this;
}
public function getBadgePHIDs() {
return $this->assertAttached($this->badgePHIDs);
}
/* -( CSRF )--------------------------------------------------------------- */
public function getCSRFToken() {
if ($this->isOmnipotent()) {
// We may end up here when called from the daemons. The omnipotent user
// has no meaningful CSRF token, so just return `null`.
return null;
}
return $this->newCSRFEngine()
->newToken();
}
public function validateCSRFToken($token) {
return $this->newCSRFengine()
->isValidToken($token);
}
public function getAlternateCSRFString() {
return $this->assertAttached($this->alternateCSRFString);
}
public function attachAlternateCSRFString($string) {
$this->alternateCSRFString = $string;
return $this;
}
private function newCSRFEngine() {
if ($this->getPHID()) {
$vec = $this->getPHID().$this->getAccountSecret();
} else {
$vec = $this->getAlternateCSRFString();
}
if ($this->hasSession()) {
$vec = $vec.$this->getSession()->getSessionKey();
}
$engine = new PhabricatorAuthCSRFEngine();
if ($this->csrfSalt === null) {
$this->csrfSalt = $engine->newSalt();
}
$engine
->setSalt($this->csrfSalt)
->setSecret(new PhutilOpaqueEnvelope($vec));
return $engine;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::POLICY_PUBLIC;
case PhabricatorPolicyCapability::CAN_EDIT:
if ($this->getIsSystemAgent() || $this->getIsMailingList()) {
return PhabricatorPolicies::POLICY_ADMIN;
} else {
return PhabricatorPolicies::POLICY_NOONE;
}
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return $this->getPHID() && ($viewer->getPHID() === $this->getPHID());
}
public function describeAutomaticCapability($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_EDIT:
return pht('Only you can edit your information.');
default:
return null;
}
}
/* -( PhabricatorCustomFieldInterface )------------------------------------ */
public function getCustomFieldSpecificationForRole($role) {
return PhabricatorEnv::getEnvConfig('user.fields');
}
public function getCustomFieldBaseClass() {
return 'PhabricatorUserCustomField';
}
public function getCustomFields() {
return $this->assertAttached($this->customFields);
}
public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) {
$this->customFields = $fields;
return $this;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$viewer = $engine->getViewer();
$this->openTransaction();
$this->delete();
$externals = id(new PhabricatorExternalAccountQuery())
->setViewer($viewer)
->withUserPHIDs(array($this->getPHID()))
->newIterator();
foreach ($externals as $external) {
$engine->destroyObject($external);
}
$prefs = id(new PhabricatorUserPreferencesQuery())
->setViewer($viewer)
->withUsers(array($this))
->execute();
foreach ($prefs as $pref) {
$engine->destroyObject($pref);
}
$profiles = id(new PhabricatorUserProfile())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($profiles as $profile) {
$profile->delete();
}
$keys = id(new PhabricatorAuthSSHKeyQuery())
->setViewer($viewer)
->withObjectPHIDs(array($this->getPHID()))
->execute();
foreach ($keys as $key) {
$engine->destroyObject($key);
}
$emails = id(new PhabricatorUserEmail())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($emails as $email) {
$engine->destroyObject($email);
}
$sessions = id(new PhabricatorAuthSession())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($sessions as $session) {
$session->delete();
}
$factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($factors as $factor) {
$factor->delete();
}
$this->saveTransaction();
}
/* -( PhabricatorSSHPublicKeyInterface )----------------------------------- */
public function getSSHPublicKeyManagementURI(PhabricatorUser $viewer) {
if ($viewer->getPHID() == $this->getPHID()) {
// If the viewer is managing their own keys, take them to the normal
// panel.
return '/settings/panel/ssh/';
} else {
// Otherwise, take them to the administrative panel for this user.
return '/settings/user/'.$this->getUsername().'/page/ssh/';
}
}
public function getSSHKeyDefaultName() {
return 'id_rsa_phorge';
}
public function getSSHKeyNotifyPHIDs() {
return array(
$this->getPHID(),
);
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorUserTransactionEditor();
}
public function getApplicationTransactionTemplate() {
return new PhabricatorUserTransaction();
}
/* -( PhabricatorFulltextInterface )--------------------------------------- */
public function newFulltextEngine() {
return new PhabricatorUserFulltextEngine();
}
/* -( PhabricatorFerretInterface )----------------------------------------- */
public function newFerretEngine() {
return new PhabricatorUserFerretEngine();
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('username')
->setType('string')
->setDescription(pht("The user's username.")),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('realName')
->setType('string')
->setDescription(pht("The user's real name.")),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('roles')
->setType('list<string>')
->setDescription(pht('List of account roles.')),
);
}
public function getFieldValuesForConduit() {
$roles = array();
if ($this->getIsDisabled()) {
$roles[] = 'disabled';
}
if ($this->getIsSystemAgent()) {
$roles[] = 'bot';
}
if ($this->getIsMailingList()) {
$roles[] = 'list';
}
if ($this->getIsAdmin()) {
$roles[] = 'admin';
}
if ($this->getIsEmailVerified()) {
$roles[] = 'verified';
}
if ($this->getIsApproved()) {
$roles[] = 'approved';
}
if ($this->isUserActivated()) {
$roles[] = 'activated';
}
return array(
'username' => $this->getUsername(),
'realName' => $this->getRealName(),
'roles' => $roles,
);
}
public function getConduitSearchAttachments() {
return array(
id(new PhabricatorPeopleAvailabilitySearchEngineAttachment())
->setAttachmentKey('availability'),
);
}
/* -( User Cache )--------------------------------------------------------- */
/**
* @task cache
*/
public function attachRawCacheData(array $data) {
$this->rawCacheData = $data + $this->rawCacheData;
return $this;
}
public function setAllowInlineCacheGeneration($allow_cache_generation) {
$this->allowInlineCacheGeneration = $allow_cache_generation;
return $this;
}
/**
* @task cache
*/
protected function requireCacheData($key) {
if (isset($this->usableCacheData[$key])) {
return $this->usableCacheData[$key];
}
$type = PhabricatorUserCacheType::requireCacheTypeForKey($key);
if (isset($this->rawCacheData[$key])) {
$raw_value = $this->rawCacheData[$key];
$usable_value = $type->getValueFromStorage($raw_value);
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
// By default, we throw if a cache isn't available. This is consistent
// with the standard `needX()` + `attachX()` + `getX()` interaction.
if (!$this->allowInlineCacheGeneration) {
throw new PhabricatorDataNotAttachedException($this);
}
$user_phid = $this->getPHID();
// Try to read the actual cache before we generate a new value. We can
// end up here via Conduit, which does not use normal sessions and can
// not pick up a free cache load during session identification.
if ($user_phid) {
$raw_data = PhabricatorUserCache::readCaches(
$type,
$key,
array($user_phid));
if (array_key_exists($user_phid, $raw_data)) {
$raw_value = $raw_data[$user_phid];
$usable_value = $type->getValueFromStorage($raw_value);
$this->rawCacheData[$key] = $raw_value;
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
}
$usable_value = $type->getDefaultValue();
if ($user_phid) {
$map = $type->newValueForUsers($key, array($this));
if (array_key_exists($user_phid, $map)) {
$raw_value = $map[$user_phid];
$usable_value = $type->getValueFromStorage($raw_value);
$this->rawCacheData[$key] = $raw_value;
PhabricatorUserCache::writeCache(
$type,
$key,
$user_phid,
$raw_value);
}
}
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
/**
* @task cache
*/
public function clearCacheData($key) {
unset($this->rawCacheData[$key]);
unset($this->usableCacheData[$key]);
return $this;
}
public function getCSSValue($variable_key) {
$preference = PhabricatorAccessibilitySetting::SETTINGKEY;
$key = $this->getUserSetting($preference);
$postprocessor = CelerityPostprocessor::getPostprocessor($key);
$variables = $postprocessor->getVariables();
if (!isset($variables[$variable_key])) {
throw new Exception(
pht(
'Unknown CSS variable "%s"!',
$variable_key));
}
return $variables[$variable_key];
}
/* -( PhabricatorAuthPasswordHashInterface )------------------------------- */
public function newPasswordDigest(
PhutilOpaqueEnvelope $envelope,
PhabricatorAuthPassword $password) {
// Before passwords are hashed, they are digested. The goal of digestion
// is twofold: to reduce the length of very long passwords to something
// reasonable; and to salt the password in case the best available hasher
// does not include salt automatically.
// Users may choose arbitrarily long passwords, and attackers may try to
// attack the system by probing it with very long passwords. When large
// inputs are passed to hashers -- which are intentionally slow -- it
// can result in unacceptably long runtimes. The classic attack here is
// to try to log in with a 64MB password and see if that locks up the
// machine for the next century. By digesting passwords to a standard
// length first, the length of the raw input does not impact the runtime
// of the hashing algorithm.
// Some hashers like bcrypt are self-salting, while other hashers are not.
// Applying salt while digesting passwords ensures that hashes are salted
// whether we ultimately select a self-salting hasher or not.
// For legacy compatibility reasons, old VCS and Account password digest
// algorithms are significantly more complicated than necessary to achieve
// these goals. This is because they once used a different hashing and
// salting process. When we upgraded to the modern modular hasher
// infrastructure, we just bolted it onto the end of the existing pipelines
// so that upgrading didn't break all users' credentials.
// New implementations can (and, generally, should) safely select the
// simple HMAC SHA256 digest at the bottom of the function, which does
// everything that a digest callback should without any needless legacy
// baggage on top.
if ($password->getLegacyDigestFormat() == 'v1') {
switch ($password->getPasswordType()) {
case PhabricatorAuthPassword::PASSWORD_TYPE_VCS:
// Old VCS passwords use an iterated HMAC SHA1 as a digest algorithm.
// They originally used this as a hasher, but it became a digest
// algorithm once hashing was upgraded to include bcrypt.
$digest = $envelope->openEnvelope();
$salt = $this->getPHID();
for ($ii = 0; $ii < 1000; $ii++) {
$digest = PhabricatorHash::weakDigest($digest, $salt);
}
return new PhutilOpaqueEnvelope($digest);
case PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT:
// Account passwords previously used this weird mess of salt and did
// not digest the input to a standard length.
// Beyond this being a weird special case, there are two actual
// problems with this, although neither are particularly severe:
// First, because we do not normalize the length of passwords, this
// algorithm may make us vulnerable to DOS attacks where an attacker
// attempts to use a very long input to slow down hashers.
// Second, because the username is part of the hash algorithm,
// renaming a user breaks their password. This isn't a huge deal but
// it's pretty silly. There's no security justification for this
// behavior, I just didn't think about the implication when I wrote
// it originally.
$parts = array(
$this->getUsername(),
$envelope->openEnvelope(),
$this->getPHID(),
$password->getPasswordSalt(),
);
return new PhutilOpaqueEnvelope(implode('', $parts));
}
}
// For passwords which do not have some crazy legacy reason to use some
// other digest algorithm, HMAC SHA256 is an excellent choice. It satisfies
// the digest requirements and is simple.
$digest = PhabricatorHash::digestHMACSHA256(
$envelope->openEnvelope(),
$password->getPasswordSalt());
return new PhutilOpaqueEnvelope($digest);
}
public function newPasswordBlocklist(
PhabricatorUser $viewer,
PhabricatorAuthPasswordEngine $engine) {
$list = array();
$list[] = $this->getUsername();
$list[] = $this->getRealName();
$emails = id(new PhabricatorUserEmail())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($emails as $email) {
$list[] = $email->getAddress();
}
return $list;
}
}
diff --git a/src/applications/people/storage/PhabricatorUserEmail.php b/src/applications/people/storage/PhabricatorUserEmail.php
index bf6b487d88..edfc864b9b 100644
--- a/src/applications/people/storage/PhabricatorUserEmail.php
+++ b/src/applications/people/storage/PhabricatorUserEmail.php
@@ -1,337 +1,337 @@
<?php
/**
* @task restrictions Domain Restrictions
* @task email Email About Email
*/
final class PhabricatorUserEmail
extends PhabricatorUserDAO
implements
PhabricatorDestructibleInterface,
PhabricatorPolicyInterface {
protected $userPHID;
protected $address;
protected $isVerified;
protected $isPrimary;
protected $verificationCode;
private $user = self::ATTACHABLE;
const MAX_ADDRESS_LENGTH = 128;
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'address' => 'sort128',
'isVerified' => 'bool',
'isPrimary' => 'bool',
'verificationCode' => 'text64?',
),
self::CONFIG_KEY_SCHEMA => array(
'address' => array(
'columns' => array('address'),
'unique' => true,
),
'userPHID' => array(
'columns' => array('userPHID', 'isPrimary'),
),
),
) + parent::getConfiguration();
}
public function getPHIDType() {
return PhabricatorPeopleUserEmailPHIDType::TYPECONST;
}
public function getVerificationURI() {
return '/emailverify/'.$this->getVerificationCode().'/';
}
public function save() {
if (!$this->verificationCode) {
$this->setVerificationCode(Filesystem::readRandomCharacters(24));
}
return parent::save();
}
public function attachUser(PhabricatorUser $user) {
$this->user = $user;
return $this;
}
public function getUser() {
return $this->assertAttached($this->user);
}
/* -( Domain Restrictions )------------------------------------------------ */
/**
* @task restrictions
*/
public static function isValidAddress($address) {
if (strlen($address) > self::MAX_ADDRESS_LENGTH) {
return false;
}
// Very roughly validate that this address isn't so mangled that a
// reasonable piece of code might completely misparse it. In particular,
// the major risks are:
//
// - `PhutilEmailAddress` needs to be able to extract the domain portion
// from it.
// - Reasonable mail adapters should be hard-pressed to interpret one
// address as several addresses.
//
// To this end, we're roughly verifying that there's some normal text, an
// "@" symbol, and then some more normal text.
$email_regex = '(^[a-z0-9_+.!-]+@[a-z0-9_+:.-]+\z)i';
if (!preg_match($email_regex, $address)) {
return false;
}
return true;
}
/**
* @task restrictions
*/
public static function describeValidAddresses() {
return pht(
'Email addresses should be in the form "user@domain.com". The maximum '.
'length of an email address is %s characters.',
new PhutilNumber(self::MAX_ADDRESS_LENGTH));
}
/**
* @task restrictions
*/
public static function isAllowedAddress($address) {
if (!self::isValidAddress($address)) {
return false;
}
$allowed_domains = PhabricatorEnv::getEnvConfig('auth.email-domains');
if (!$allowed_domains) {
return true;
}
$addr_obj = new PhutilEmailAddress($address);
$domain = $addr_obj->getDomainName();
if (!$domain) {
return false;
}
$lower_domain = phutil_utf8_strtolower($domain);
foreach ($allowed_domains as $allowed_domain) {
$lower_allowed = phutil_utf8_strtolower($allowed_domain);
if ($lower_allowed === $lower_domain) {
return true;
}
}
return false;
}
/**
* @task restrictions
*/
public static function describeAllowedAddresses() {
$domains = PhabricatorEnv::getEnvConfig('auth.email-domains');
if (!$domains) {
return null;
}
if (count($domains) == 1) {
return pht('Email address must be @%s', head($domains));
} else {
return pht(
'Email address must be at one of: %s',
implode(', ', $domains));
}
}
/**
* Check if this install requires email verification.
*
* @return bool True if email addresses must be verified.
*
* @task restrictions
*/
public static function isEmailVerificationRequired() {
// NOTE: Configuring required email domains implies required verification.
return PhabricatorEnv::getEnvConfig('auth.require-email-verification') ||
PhabricatorEnv::getEnvConfig('auth.email-domains');
}
/* -( Email About Email )-------------------------------------------------- */
/**
* Send a verification email from $user to this address.
*
* @param PhabricatorUser $user The user sending the verification.
- * @return this
+ * @return $this
* @task email
*/
public function sendVerificationEmail(PhabricatorUser $user) {
$username = $user->getUsername();
$address = $this->getAddress();
$link = PhabricatorEnv::getProductionURI($this->getVerificationURI());
$is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
$signature = null;
if (!$is_serious) {
$signature = pht(
"Get Well Soon,\n%s",
PlatformSymbols::getPlatformServerName());
}
$body = sprintf(
"%s\n\n%s\n\n %s\n\n%s",
pht('Hi %s', $username),
pht(
'Please verify that you own this email address (%s) by '.
'clicking this link:',
$address),
$link,
$signature);
id(new PhabricatorMetaMTAMail())
->addRawTos(array($address))
->setForceDelivery(true)
->setSubject(
pht(
'[%s] Email Verification',
PlatformSymbols::getPlatformServerName()))
->setBody($body)
->setRelatedPHID($user->getPHID())
->saveAndSend();
return $this;
}
/**
* Send a notification email from $user to this address, informing the
* recipient that this is no longer their account's primary address.
*
* @param PhabricatorUser $user The user sending the notification.
* @param PhabricatorUserEmail $new New primary email address.
* @task email
*/
public function sendOldPrimaryEmail(
PhabricatorUser $user,
PhabricatorUserEmail $new) {
$username = $user->getUsername();
$old_address = $this->getAddress();
$new_address = $new->getAddress();
$body = sprintf(
"%s\n\n%s\n",
pht('Hi %s', $username),
pht(
'This email address (%s) is no longer your primary email address. '.
'Going forward, all email will be sent to your new primary email '.
'address (%s).',
$old_address,
$new_address));
id(new PhabricatorMetaMTAMail())
->addRawTos(array($old_address))
->setForceDelivery(true)
->setSubject(
pht(
'[%s] Primary Address Changed',
PlatformSymbols::getPlatformServerName()))
->setBody($body)
->setFrom($user->getPHID())
->setRelatedPHID($user->getPHID())
->saveAndSend();
}
/**
* Send a notification email from $user to this address, informing the
* recipient that this is now their account's new primary email address.
*
* @param PhabricatorUser $user The user sending the verification.
- * @return this
+ * @return $this
* @task email
*/
public function sendNewPrimaryEmail(PhabricatorUser $user) {
$username = $user->getUsername();
$new_address = $this->getAddress();
$body = sprintf(
"%s\n\n%s\n",
pht('Hi %s', $username),
pht(
'This is now your primary email address (%s). Going forward, '.
'all email will be sent here.',
$new_address));
id(new PhabricatorMetaMTAMail())
->addRawTos(array($new_address))
->setForceDelivery(true)
->setSubject(
pht(
'[%s] Primary Address Changed',
PlatformSymbols::getPlatformServerName()))
->setBody($body)
->setFrom($user->getPHID())
->setRelatedPHID($user->getPHID())
->saveAndSend();
return $this;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->delete();
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
$user = $this->getUser();
if ($this->getIsSystemAgent() || $this->getIsMailingList()) {
return PhabricatorPolicies::POLICY_ADMIN;
}
return $user->getPHID();
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
}
diff --git a/src/applications/phid/PhabricatorObjectHandle.php b/src/applications/phid/PhabricatorObjectHandle.php
index 6a6c1f19b8..b42dfdf863 100644
--- a/src/applications/phid/PhabricatorObjectHandle.php
+++ b/src/applications/phid/PhabricatorObjectHandle.php
@@ -1,494 +1,494 @@
<?php
final class PhabricatorObjectHandle
extends Phobject
implements PhabricatorPolicyInterface {
const AVAILABILITY_FULL = 'full';
const AVAILABILITY_NONE = 'none';
const AVAILABILITY_NOEMAIL = 'no-email';
const AVAILABILITY_PARTIAL = 'partial';
const AVAILABILITY_DISABLED = 'disabled';
const STATUS_OPEN = 'open';
const STATUS_CLOSED = 'closed';
private $uri;
private $phid;
private $type;
private $name;
private $fullName;
private $title;
private $imageURI;
private $icon;
private $tagColor;
private $timestamp;
private $status = self::STATUS_OPEN;
private $availability = self::AVAILABILITY_FULL;
private $complete;
private $objectName;
private $policyFiltered;
private $subtitle;
private $tokenIcon;
private $commandLineObjectName;
private $mailStampName;
private $capabilities = array();
public function setIcon($icon) {
$this->icon = $icon;
return $this;
}
public function getIcon() {
if ($this->getPolicyFiltered()) {
return 'fa-lock';
}
if ($this->icon) {
return $this->icon;
}
return $this->getTypeIcon();
}
public function setSubtitle($subtitle) {
$this->subtitle = $subtitle;
return $this;
}
public function getSubtitle() {
return $this->subtitle;
}
public function setTagColor($color) {
static $colors;
if (!$colors) {
$colors = array_fuse(array_keys(PHUITagView::getShadeMap()));
}
if (isset($colors[$color])) {
$this->tagColor = $color;
}
return $this;
}
public function getTagColor() {
if ($this->getPolicyFiltered()) {
return 'disabled';
}
if ($this->tagColor) {
return $this->tagColor;
}
return 'blue';
}
public function getIconColor() {
if ($this->tagColor) {
return $this->tagColor;
}
return null;
}
public function setTokenIcon($icon) {
$this->tokenIcon = $icon;
return $this;
}
public function getTokenIcon() {
if ($this->tokenIcon !== null) {
return $this->tokenIcon;
}
return $this->getIcon();
}
public function getTypeIcon() {
if ($this->getPHIDType()) {
return $this->getPHIDType()->getTypeIcon();
}
return null;
}
public function setPolicyFiltered($policy_filered) {
$this->policyFiltered = $policy_filered;
return $this;
}
public function getPolicyFiltered() {
return $this->policyFiltered;
}
public function setObjectName($object_name) {
$this->objectName = $object_name;
return $this;
}
public function getObjectName() {
if (!$this->objectName) {
return $this->getName();
}
return $this->objectName;
}
public function setMailStampName($mail_stamp_name) {
$this->mailStampName = $mail_stamp_name;
return $this;
}
public function getMailStampName() {
return $this->mailStampName;
}
public function setURI($uri) {
$this->uri = $uri;
return $this;
}
public function getURI() {
return $this->uri;
}
public function setPHID($phid) {
$this->phid = $phid;
return $this;
}
public function getPHID() {
return $this->phid;
}
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
if ($this->name === null) {
if ($this->getPolicyFiltered()) {
return pht('Restricted %s', $this->getTypeName());
} else {
return pht('Unknown Object (%s)', $this->getTypeName());
}
}
return $this->name;
}
public function setAvailability($availability) {
$this->availability = $availability;
return $this;
}
public function getAvailability() {
return $this->availability;
}
public function isDisabled() {
return ($this->getAvailability() == self::AVAILABILITY_DISABLED);
}
public function setStatus($status) {
$this->status = $status;
return $this;
}
public function getStatus() {
return $this->status;
}
public function isClosed() {
return ($this->status === self::STATUS_CLOSED);
}
public function setFullName($full_name) {
$this->fullName = $full_name;
return $this;
}
public function getFullName() {
if ($this->fullName !== null) {
return $this->fullName;
}
return $this->getName();
}
public function setCommandLineObjectName($command_line_object_name) {
$this->commandLineObjectName = $command_line_object_name;
return $this;
}
public function getCommandLineObjectName() {
if ($this->commandLineObjectName !== null) {
return $this->commandLineObjectName;
}
return $this->getObjectName();
}
public function setTitle($title) {
$this->title = $title;
return $this;
}
public function getTitle() {
return $this->title;
}
public function setType($type) {
$this->type = $type;
return $this;
}
public function getType() {
return $this->type;
}
public function setImageURI($uri) {
$this->imageURI = $uri;
return $this;
}
public function getImageURI() {
return $this->imageURI;
}
public function setTimestamp($timestamp) {
$this->timestamp = $timestamp;
return $this;
}
public function getTimestamp() {
return $this->timestamp;
}
public function getTypeName() {
if ($this->getPHIDType()) {
return $this->getPHIDType()->getTypeName();
}
return $this->getType();
}
/**
* Set whether or not the underlying object is complete. See
* @{method:isComplete} for an explanation of what it means to be complete.
*
* @param bool $complete True if the handle represents a complete object.
- * @return this
+ * @return $this
*/
public function setComplete($complete) {
$this->complete = $complete;
return $this;
}
/**
* Determine if the handle represents an object which was completely loaded
* (i.e., the underlying object exists) vs an object which could not be
* completely loaded (e.g., the type or data for the PHID could not be
* identified or located).
*
* Basically, @{class:PhabricatorHandleQuery} gives you back a handle for
* any PHID you give it, but it gives you a complete handle only for valid
* PHIDs.
*
* @return bool True if the handle represents a complete object.
*/
public function isComplete() {
return $this->complete;
}
public function renderLink($name = null) {
return $this->renderLinkWithAttributes($name, array());
}
public function renderHovercardLink($name = null, $context_phid = null) {
Javelin::initBehavior('phui-hovercards');
$hovercard_spec = array(
'objectPHID' => $this->getPHID(),
);
if ($context_phid) {
$hovercard_spec['contextPHID'] = $context_phid;
}
$attributes = array(
'sigil' => 'hovercard',
'meta' => array(
'hovercardSpec' => $hovercard_spec,
),
);
return $this->renderLinkWithAttributes($name, $attributes);
}
private function renderLinkWithAttributes($name, array $attributes) {
if ($name === null) {
$name = $this->getLinkName();
}
$classes = array();
$classes[] = 'phui-handle';
$title = $this->title;
if ($this->status != self::STATUS_OPEN) {
$classes[] = 'handle-status-'.$this->status;
}
$circle = null;
if ($this->availability != self::AVAILABILITY_FULL) {
$classes[] = 'handle-availability-'.$this->availability;
$circle = array(
phutil_tag(
'span',
array(
'class' => 'perfect-circle',
),
"\xE2\x80\xA2"),
' ',
);
}
if ($this->getType() == PhabricatorPeopleUserPHIDType::TYPECONST) {
$classes[] = 'phui-link-person';
}
$uri = $this->getURI();
$icon = null;
if ($this->getPolicyFiltered()) {
$icon = id(new PHUIIconView())
->setIcon('fa-lock lightgreytext');
}
$attributes = $attributes + array(
'href' => $uri,
'class' => implode(' ', $classes),
'title' => $title,
);
return javelin_tag(
$uri ? 'a' : 'span',
$attributes,
array($circle, $icon, $name));
}
public function renderTag() {
return id(new PHUITagView())
->setType(PHUITagView::TYPE_SHADE)
->setColor($this->getTagColor())
->setIcon($this->getIcon())
->setHref($this->getURI())
->setName($this->getLinkName());
}
public function getLinkName() {
switch ($this->getType()) {
case PhabricatorPeopleUserPHIDType::TYPECONST:
$name = $this->getName();
break;
default:
$name = $this->getFullName();
break;
}
return $name;
}
protected function getPHIDType() {
$types = PhabricatorPHIDType::getAllTypes();
return idx($types, $this->getType());
}
public function hasCapabilities() {
if (!$this->isComplete()) {
return false;
}
return ($this->getType() === PhabricatorPeopleUserPHIDType::TYPECONST);
}
public function attachCapability(
PhabricatorPolicyInterface $object,
$capability,
$has_capability) {
if (!$this->hasCapabilities()) {
throw new Exception(
pht(
'Attempting to attach capability ("%s") for object ("%s") to '.
'handle, but this handle (of type "%s") can not have '.
'capabilities.',
$capability,
get_class($object),
$this->getType()));
}
$object_key = $this->getObjectCapabilityKey($object);
$this->capabilities[$object_key][$capability] = $has_capability;
return $this;
}
public function hasViewCapability(PhabricatorPolicyInterface $object) {
return $this->hasCapability($object, PhabricatorPolicyCapability::CAN_VIEW);
}
private function hasCapability(
PhabricatorPolicyInterface $object,
$capability) {
$object_key = $this->getObjectCapabilityKey($object);
if (!isset($this->capabilities[$object_key][$capability])) {
throw new Exception(
pht(
'Attempting to test capability "%s" for handle of type "%s", but '.
'this capability has not been attached.',
$capability,
$this->getType()));
}
return $this->capabilities[$object_key][$capability];
}
private function getObjectCapabilityKey(PhabricatorPolicyInterface $object) {
$object_phid = $object->getPHID();
if (!$object_phid) {
throw new Exception(
pht(
'Object (of class "%s") has no PHID, so handles can not interact '.
'with capabilities for it.',
get_class($object)));
}
return $object_phid;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
);
}
public function getPolicy($capability) {
return PhabricatorPolicies::POLICY_PUBLIC;
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
// NOTE: Handles are always visible, they just don't get populated with
// data if the user can't see the underlying object.
return true;
}
public function describeAutomaticCapability($capability) {
return null;
}
}
diff --git a/src/applications/phortune/currency/PhortuneCurrency.php b/src/applications/phortune/currency/PhortuneCurrency.php
index f69dd362fe..25023554dc 100644
--- a/src/applications/phortune/currency/PhortuneCurrency.php
+++ b/src/applications/phortune/currency/PhortuneCurrency.php
@@ -1,239 +1,239 @@
<?php
final class PhortuneCurrency extends Phobject {
private $value;
private $currency;
private function __construct() {
// Intentionally private.
}
public static function getDefaultCurrency() {
return 'USD';
}
public static function newEmptyCurrency() {
return self::newFromString('0.00 USD');
}
public static function newFromUserInput(PhabricatorUser $user, $string) {
// Eventually, this might select a default currency based on user settings.
return self::newFromString($string, self::getDefaultCurrency());
}
public static function newFromString($string, $default = null) {
$matches = null;
$ok = preg_match(
'/^([-$]*(?:\d+)?(?:[.]\d{0,2})?)(?:\s+([A-Z]+))?$/',
trim($string),
$matches);
if (!$ok) {
self::throwFormatException($string);
}
$value = $matches[1];
if (substr_count($value, '-') > 1) {
self::throwFormatException($string);
}
if (substr_count($value, '$') > 1) {
self::throwFormatException($string);
}
$value = str_replace('$', '', $value);
$value = (float)$value;
$value = (int)round(100 * $value);
$currency = idx($matches, 2, $default);
switch ($currency) {
case 'USD':
break;
default:
throw new Exception(pht("Unsupported currency '%s'!", $currency));
}
return self::newFromValueAndCurrency($value, $currency);
}
public static function newFromValueAndCurrency($value, $currency) {
$obj = new PhortuneCurrency();
$obj->value = $value;
$obj->currency = $currency;
return $obj;
}
public static function newFromList(array $list) {
assert_instances_of($list, __CLASS__);
if (!$list) {
return self::newEmptyCurrency();
}
$total = null;
foreach ($list as $item) {
if ($total === null) {
$total = $item;
} else {
$total = $total->add($item);
}
}
return $total;
}
public function formatForDisplay() {
$bare = $this->formatBareValue();
return '$'.$bare.' '.$this->currency;
}
public function serializeForStorage() {
return $this->formatBareValue().' '.$this->currency;
}
public function formatBareValue() {
switch ($this->currency) {
case 'USD':
return sprintf('%.02f', $this->value / 100);
default:
throw new Exception(
pht('Unsupported currency ("%s")!', $this->currency));
}
}
public function getValue() {
return $this->value;
}
public function getCurrency() {
return $this->currency;
}
public function getValueInUSDCents() {
if ($this->currency !== 'USD') {
throw new Exception(pht('Unexpected currency!'));
}
return $this->value;
}
private static function throwFormatException($string) {
throw new Exception(pht("Invalid currency format ('%s').", $string));
}
private function throwUnlikeCurrenciesException(PhortuneCurrency $other) {
throw new Exception(
pht(
'Trying to operate on unlike currencies ("%s" and "%s")!',
$this->currency,
$other->currency));
}
public function add(PhortuneCurrency $other) {
if ($this->currency !== $other->currency) {
$this->throwUnlikeCurrenciesException($other);
}
$currency = new PhortuneCurrency();
// TODO: This should check for integer overflows, etc.
$currency->value = $this->value + $other->value;
$currency->currency = $this->currency;
return $currency;
}
public function subtract(PhortuneCurrency $other) {
if ($this->currency !== $other->currency) {
$this->throwUnlikeCurrenciesException($other);
}
$currency = new PhortuneCurrency();
// TODO: This should check for integer overflows, etc.
$currency->value = $this->value - $other->value;
$currency->currency = $this->currency;
return $currency;
}
public function isEqualTo(PhortuneCurrency $other) {
if ($this->currency !== $other->currency) {
$this->throwUnlikeCurrenciesException($other);
}
return ($this->value === $other->value);
}
public function negate() {
$currency = new PhortuneCurrency();
$currency->value = -$this->value;
$currency->currency = $this->currency;
return $currency;
}
public function isPositive() {
return ($this->value > 0);
}
public function isGreaterThan(PhortuneCurrency $other) {
if ($this->currency !== $other->currency) {
$this->throwUnlikeCurrenciesException($other);
}
return $this->value > $other->value;
}
/**
* Assert that a currency value lies within a range.
*
* Throws if the value is not between the minimum and maximum, inclusive.
*
* In particular, currency values can be negative (to represent a debt or
* credit), so checking against zero may be useful to make sure a value
* has the expected sign.
*
* @param string|null Currency string, or null to skip check.
* @param string|null Currency string, or null to skip check.
- * @return this
+ * @return $this
*/
public function assertInRange($minimum, $maximum) {
if ($minimum !== null && $maximum !== null) {
$min = self::newFromString($minimum);
$max = self::newFromString($maximum);
if ($min->value > $max->value) {
throw new Exception(
pht(
'Range (%s - %s) is not valid!',
$min->formatForDisplay(),
$max->formatForDisplay()));
}
}
if ($minimum !== null) {
$min = self::newFromString($minimum);
if ($min->value > $this->value) {
throw new Exception(
pht(
'Minimum allowed amount is %s.',
$min->formatForDisplay()));
}
}
if ($maximum !== null) {
$max = self::newFromString($maximum);
if ($max->value < $this->value) {
throw new Exception(
pht(
'Maximum allowed amount is %s.',
$max->formatForDisplay()));
}
}
return $this;
}
}
diff --git a/src/applications/phortune/query/PhortuneCartQuery.php b/src/applications/phortune/query/PhortuneCartQuery.php
index e54578731f..e8325d6aae 100644
--- a/src/applications/phortune/query/PhortuneCartQuery.php
+++ b/src/applications/phortune/query/PhortuneCartQuery.php
@@ -1,223 +1,223 @@
<?php
final class PhortuneCartQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $accountPHIDs;
private $merchantPHIDs;
private $subscriptionPHIDs;
private $statuses;
private $invoices;
private $needPurchases;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withAccountPHIDs(array $account_phids) {
$this->accountPHIDs = $account_phids;
return $this;
}
public function withMerchantPHIDs(array $merchant_phids) {
$this->merchantPHIDs = $merchant_phids;
return $this;
}
public function withSubscriptionPHIDs(array $subscription_phids) {
$this->subscriptionPHIDs = $subscription_phids;
return $this;
}
public function withStatuses(array $statuses) {
$this->statuses = $statuses;
return $this;
}
/**
* Include or exclude carts which represent invoices with payments due.
*
* @param bool `true` to select invoices; `false` to exclude invoices.
- * @return this
+ * @return $this
*/
public function withInvoices($invoices) {
$this->invoices = $invoices;
return $this;
}
public function needPurchases($need_purchases) {
$this->needPurchases = $need_purchases;
return $this;
}
protected function loadPage() {
$table = new PhortuneCart();
$conn = $table->establishConnection('r');
$rows = queryfx_all(
$conn,
'SELECT cart.* FROM %T cart %Q %Q %Q',
$table->getTableName(),
$this->buildWhereClause($conn),
$this->buildOrderClause($conn),
$this->buildLimitClause($conn));
return $table->loadAllFromArray($rows);
}
protected function willFilterPage(array $carts) {
$accounts = id(new PhortuneAccountQuery())
->setViewer($this->getViewer())
->withPHIDs(mpull($carts, 'getAccountPHID'))
->execute();
$accounts = mpull($accounts, null, 'getPHID');
foreach ($carts as $key => $cart) {
$account = idx($accounts, $cart->getAccountPHID());
if (!$account) {
unset($carts[$key]);
continue;
}
$cart->attachAccount($account);
}
if (!$carts) {
return array();
}
$merchants = id(new PhortuneMerchantQuery())
->setViewer($this->getViewer())
->withPHIDs(mpull($carts, 'getMerchantPHID'))
->execute();
$merchants = mpull($merchants, null, 'getPHID');
foreach ($carts as $key => $cart) {
$merchant = idx($merchants, $cart->getMerchantPHID());
if (!$merchant) {
unset($carts[$key]);
continue;
}
$cart->attachMerchant($merchant);
}
if (!$carts) {
return array();
}
$implementations = array();
$cart_map = mgroup($carts, 'getCartClass');
foreach ($cart_map as $class => $class_carts) {
$implementations += newv($class, array())->loadImplementationsForCarts(
$this->getViewer(),
$class_carts);
}
foreach ($carts as $key => $cart) {
$implementation = idx($implementations, $key);
if (!$implementation) {
unset($carts[$key]);
continue;
}
$cart->attachImplementation($implementation);
}
return $carts;
}
protected function didFilterPage(array $carts) {
if ($this->needPurchases) {
$purchases = id(new PhortunePurchaseQuery())
->setViewer($this->getViewer())
->setParentQuery($this)
->withCartPHIDs(mpull($carts, 'getPHID'))
->execute();
$purchases = mgroup($purchases, 'getCartPHID');
foreach ($carts as $cart) {
$cart->attachPurchases(idx($purchases, $cart->getPHID(), array()));
}
}
return $carts;
}
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
$where[] = $this->buildPagingClause($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'cart.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'cart.phid IN (%Ls)',
$this->phids);
}
if ($this->accountPHIDs !== null) {
$where[] = qsprintf(
$conn,
'cart.accountPHID IN (%Ls)',
$this->accountPHIDs);
}
if ($this->merchantPHIDs !== null) {
$where[] = qsprintf(
$conn,
'cart.merchantPHID IN (%Ls)',
$this->merchantPHIDs);
}
if ($this->subscriptionPHIDs !== null) {
$where[] = qsprintf(
$conn,
'cart.subscriptionPHID IN (%Ls)',
$this->subscriptionPHIDs);
}
if ($this->statuses !== null) {
$where[] = qsprintf(
$conn,
'cart.status IN (%Ls)',
$this->statuses);
}
if ($this->invoices !== null) {
if ($this->invoices) {
$where[] = qsprintf(
$conn,
'cart.status = %s AND cart.isInvoice = 1',
PhortuneCart::STATUS_READY);
} else {
$where[] = qsprintf(
$conn,
'cart.status != %s OR cart.isInvoice = 0',
PhortuneCart::STATUS_READY);
}
}
return $this->formatWhereClause($conn, $where);
}
public function getQueryApplicationClass() {
return PhabricatorPhortuneApplication::class;
}
}
diff --git a/src/applications/search/field/PhabricatorSearchField.php b/src/applications/search/field/PhabricatorSearchField.php
index 7b420ac8e6..5c224ca6a8 100644
--- a/src/applications/search/field/PhabricatorSearchField.php
+++ b/src/applications/search/field/PhabricatorSearchField.php
@@ -1,426 +1,426 @@
<?php
/**
* @task config Configuring Fields
* @task error Handling Errors
* @task io Reading and Writing Field Values
* @task conduit Integration with Conduit
* @task util Utility Methods
*/
abstract class PhabricatorSearchField extends Phobject {
private $key;
private $conduitKey;
private $viewer;
private $value;
private $label;
private $aliases = array();
private $errors = array();
private $description;
private $isHidden;
private $enableForConduit = true;
/* -( Configuring Fields )------------------------------------------------- */
/**
* Set the primary key for the field, like `projectPHIDs`.
*
* You can set human-readable aliases with @{method:setAliases}.
*
* The key should be a short, unique (within a search engine) string which
* does not contain any special characters.
*
* @param string $key Unique key which identifies the field.
- * @return this
+ * @return $this
* @task config
*/
public function setKey($key) {
$this->key = $key;
return $this;
}
/**
* Get the field's key.
*
* @return string Unique key for this field.
* @task config
*/
public function getKey() {
return $this->key;
}
/**
* Set a human-readable label for the field.
*
* This should be a short text string, like "Reviewers" or "Colors".
*
* @param string $label Short, human-readable field label.
- * @return this
+ * @return $this
* task config
*/
public function setLabel($label) {
$this->label = $label;
return $this;
}
/**
* Get the field's human-readable label.
*
* @return string Short, human-readable field label.
* @task config
*/
public function getLabel() {
return $this->label;
}
/**
* Set the acting viewer.
*
* Engines do not need to do this explicitly; it will be done on their
* behalf by the caller.
*
* @param PhabricatorUser $viewer Viewer.
- * @return this
+ * @return $this
* @task config
*/
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
/**
* Get the acting viewer.
*
* @return PhabricatorUser Viewer.
* @task config
*/
public function getViewer() {
return $this->viewer;
}
/**
* Provide alternate field aliases, usually more human-readable versions
* of the key.
*
* These aliases can be used when building GET requests, so you can provide
* an alias like `authors` to let users write `&authors=alincoln` instead of
* `&authorPHIDs=alincoln`. This is a little easier to use.
*
* @param list<string> $aliases List of aliases for this field.
- * @return this
+ * @return $this
* @task config
*/
public function setAliases(array $aliases) {
$this->aliases = $aliases;
return $this;
}
/**
* Get aliases for this field.
*
* @return list<string> List of aliases for this field.
* @task config
*/
public function getAliases() {
return $this->aliases;
}
/**
* Provide an alternate field key for Conduit.
*
* This can allow you to choose a more usable key for API endpoints.
* If no key is provided, the main key is used.
*
* @param string $conduit_key Alternate key for Conduit.
- * @return this
+ * @return $this
* @task config
*/
public function setConduitKey($conduit_key) {
$this->conduitKey = $conduit_key;
return $this;
}
/**
* Get the field key for use in Conduit.
*
* @return string Conduit key for this field.
* @task config
*/
public function getConduitKey() {
if ($this->conduitKey !== null) {
return $this->conduitKey;
}
return $this->getKey();
}
/**
* Set a human-readable description for this field.
*
* @param string $description Human-readable description.
- * @return this
+ * @return $this
* @task config
*/
public function setDescription($description) {
$this->description = $description;
return $this;
}
/**
* Get this field's human-readable description.
*
* @return string|null Human-readable description.
* @task config
*/
public function getDescription() {
return $this->description;
}
/**
* Hide this field from the web UI.
*
* @param bool $is_hidden True to hide the field from the web UI.
- * @return this
+ * @return $this
* @task config
*/
public function setIsHidden($is_hidden) {
$this->isHidden = $is_hidden;
return $this;
}
/**
* Should this field be hidden from the web UI?
*
* @return bool True to hide the field in the web UI.
* @task config
*/
public function getIsHidden() {
return $this->isHidden;
}
/* -( Handling Errors )---------------------------------------------------- */
protected function addError($short, $long) {
$this->errors[] = array($short, $long);
return $this;
}
public function getErrors() {
return $this->errors;
}
protected function validateControlValue($value) {
return;
}
protected function getShortError() {
$error = head($this->getErrors());
if ($error) {
return head($error);
}
return null;
}
/* -( Reading and Writing Field Values )----------------------------------- */
public function readValueFromRequest(AphrontRequest $request) {
$check = array_merge(array($this->getKey()), $this->getAliases());
foreach ($check as $key) {
if ($this->getValueExistsInRequest($request, $key)) {
return $this->getValueFromRequest($request, $key);
}
}
return $this->getDefaultValue();
}
protected function getValueExistsInRequest(AphrontRequest $request, $key) {
return $request->getExists($key);
}
abstract protected function getValueFromRequest(
AphrontRequest $request,
$key);
public function readValueFromSavedQuery(PhabricatorSavedQuery $saved) {
$value = $saved->getParameter(
$this->getKey(),
$this->getDefaultValue());
$this->value = $this->didReadValueFromSavedQuery($value);
$this->validateControlValue($value);
return $this;
}
protected function didReadValueFromSavedQuery($value) {
return $value;
}
public function getValue() {
return $this->value;
}
protected function getValueForControl() {
return $this->value;
}
protected function getDefaultValue() {
return null;
}
public function getValueForQuery($value) {
return $value;
}
/* -( Rendering Controls )------------------------------------------------- */
protected function newControl() {
throw new PhutilMethodNotImplementedException();
}
protected function renderControl() {
if ($this->getIsHidden()) {
return null;
}
$control = $this->newControl();
if (!$control) {
return null;
}
// TODO: We should `setError($this->getShortError())` here, but it looks
// terrible in the form layout.
return $control
->setValue($this->getValueForControl())
->setName($this->getKey())
->setLabel($this->getLabel());
}
public function appendToForm(AphrontFormView $form) {
$control = $this->renderControl();
if ($control !== null) {
$form->appendControl($this->renderControl());
}
return $this;
}
/* -( Integration with Conduit )------------------------------------------- */
/**
* @task conduit
*/
final public function getConduitParameterType() {
if (!$this->getEnableForConduit()) {
return false;
}
$type = $this->newConduitParameterType();
if ($type) {
$type->setViewer($this->getViewer());
}
return $type;
}
protected function newConduitParameterType() {
return null;
}
public function getValueExistsInConduitRequest(array $constraints) {
return $this->getConduitParameterType()->getExists(
$constraints,
$this->getConduitKey());
}
public function readValueFromConduitRequest(
array $constraints,
$strict = true) {
return $this->getConduitParameterType()->getValue(
$constraints,
$this->getConduitKey(),
$strict);
}
public function getValidConstraintKeys() {
return $this->getConduitParameterType()->getKeys(
$this->getConduitKey());
}
final public function setEnableForConduit($enable) {
$this->enableForConduit = $enable;
return $this;
}
final public function getEnableForConduit() {
return $this->enableForConduit;
}
public function newConduitConstants() {
return array();
}
/* -( Utility Methods )----------------------------------------------------- */
/**
* Read a list of items from the request, in either array format or string
* format:
*
* list[]=item1&list[]=item2
* list=item1,item2
*
* This provides flexibility when constructing URIs, especially from external
* sources.
*
* @param AphrontRequest $request Request to read strings from.
* @param string $key Key to read in the request.
* @return list<string> List of values.
* @task utility
*/
protected function getListFromRequest(
AphrontRequest $request,
$key) {
$list = $request->getArr($key, null);
if ($list === null) {
$list = $request->getStrList($key);
}
if (!$list) {
return array();
}
return $list;
}
}
diff --git a/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php
index a36fed1c5d..22e1105f3f 100644
--- a/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php
+++ b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php
@@ -1,102 +1,102 @@
<?php
final class PhabricatorSubscriptionsEditor extends PhabricatorEditor {
private $object;
private $explicitSubscribePHIDs = array();
private $implicitSubscribePHIDs = array();
private $unsubscribePHIDs = array();
public function setObject(PhabricatorSubscribableInterface $object) {
$this->object = $object;
return $this;
}
/**
* Add explicit subscribers. These subscribers have explicitly subscribed
* (or been subscribed) to the object, and will be added even if they
* had previously unsubscribed.
*
* @param list<phid> $phids List of PHIDs to explicitly subscribe.
- * @return this
+ * @return $this
*/
public function subscribeExplicit(array $phids) {
$this->explicitSubscribePHIDs += array_fill_keys($phids, true);
return $this;
}
/**
* Add implicit subscribers. These subscribers have taken some action which
* implicitly subscribes them (e.g., adding a comment) but it will be
* suppressed if they've previously unsubscribed from the object.
*
* @param list<phid> $phids List of PHIDs to implicitly subscribe.
- * @return this
+ * @return $this
*/
public function subscribeImplicit(array $phids) {
$this->implicitSubscribePHIDs += array_fill_keys($phids, true);
return $this;
}
/**
* Unsubscribe PHIDs and mark them as unsubscribed, so implicit subscriptions
* will not resubscribe them.
*
* @param list<phid> $phids List of PHIDs to unsubscribe.
- * @return this
+ * @return $this
*/
public function unsubscribe(array $phids) {
$this->unsubscribePHIDs += array_fill_keys($phids, true);
return $this;
}
public function save() {
if (!$this->object) {
throw new PhutilInvalidStateException('setObject');
}
$actor = $this->requireActor();
$src = $this->object->getPHID();
if ($this->implicitSubscribePHIDs) {
$unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
$src,
PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
$unsub = array_fill_keys($unsub, true);
$this->implicitSubscribePHIDs = array_diff_key(
$this->implicitSubscribePHIDs,
$unsub);
}
$add = $this->implicitSubscribePHIDs + $this->explicitSubscribePHIDs;
$del = $this->unsubscribePHIDs;
// If a PHID is marked for both subscription and unsubscription, treat
// unsubscription as the stronger action.
$add = array_diff_key($add, $del);
if ($add || $del) {
$u_type = PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST;
$s_type = PhabricatorObjectHasSubscriberEdgeType::EDGECONST;
$editor = new PhabricatorEdgeEditor();
foreach ($add as $phid => $ignored) {
$editor->removeEdge($src, $u_type, $phid);
$editor->addEdge($src, $s_type, $phid);
}
foreach ($del as $phid => $ignored) {
$editor->removeEdge($src, $s_type, $phid);
$editor->addEdge($src, $u_type, $phid);
}
$editor->save();
}
}
}
diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php
index f86b4773bf..0ab53751b5 100644
--- a/src/applications/transactions/editengine/PhabricatorEditEngine.php
+++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php
@@ -1,2765 +1,2765 @@
<?php
/**
* @task fields Managing Fields
* @task text Display Text
* @task config Edit Engine Configuration
* @task uri Managing URIs
* @task load Creating and Loading Objects
* @task web Responding to Web Requests
* @task edit Responding to Edit Requests
* @task http Responding to HTTP Parameter Requests
* @task conduit Responding to Conduit Requests
*/
abstract class PhabricatorEditEngine
extends Phobject
implements PhabricatorPolicyInterface {
const EDITENGINECONFIG_DEFAULT = 'default';
const SUBTYPE_DEFAULT = 'default';
private $viewer;
private $controller;
private $isCreate;
private $editEngineConfiguration;
private $contextParameters = array();
private $targetObject;
private $page;
private $pages;
private $navigation;
final public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
final public function getViewer() {
return $this->viewer;
}
final public function setController(PhabricatorController $controller) {
$this->controller = $controller;
$this->setViewer($controller->getViewer());
return $this;
}
final public function getController() {
return $this->controller;
}
final public function getEngineKey() {
$key = $this->getPhobjectClassConstant('ENGINECONST', 64);
if (strpos($key, '/') !== false) {
throw new Exception(
pht(
'EditEngine ("%s") contains an invalid key character "/".',
get_class($this)));
}
return $key;
}
final public function getApplication() {
$app_class = $this->getEngineApplicationClass();
return PhabricatorApplication::getByClass($app_class);
}
final public function addContextParameter($key) {
$this->contextParameters[] = $key;
return $this;
}
public function isEngineConfigurable() {
return true;
}
public function isEngineExtensible() {
return true;
}
public function isDefaultQuickCreateEngine() {
return false;
}
public function getDefaultQuickCreateFormKeys() {
$keys = array();
if ($this->isDefaultQuickCreateEngine()) {
$keys[] = self::EDITENGINECONFIG_DEFAULT;
}
foreach ($keys as $idx => $key) {
$keys[$idx] = $this->getEngineKey().'/'.$key;
}
return $keys;
}
public static function splitFullKey($full_key) {
return explode('/', $full_key, 2);
}
public function getQuickCreateOrderVector() {
return id(new PhutilSortVector())
->addString($this->getObjectCreateShortText());
}
/**
* Force the engine to edit a particular object.
*/
public function setTargetObject($target_object) {
$this->targetObject = $target_object;
return $this;
}
public function getTargetObject() {
return $this->targetObject;
}
public function setNavigation(AphrontSideNavFilterView $navigation) {
$this->navigation = $navigation;
return $this;
}
public function getNavigation() {
return $this->navigation;
}
/* -( Managing Fields )---------------------------------------------------- */
abstract public function getEngineApplicationClass();
abstract protected function buildCustomEditFields($object);
public function getFieldsForConfig(
PhabricatorEditEngineConfiguration $config) {
$object = $this->newEditableObject();
$this->editEngineConfiguration = $config;
// This is mostly making sure that we fill in default values.
$this->setIsCreate(true);
return $this->buildEditFields($object);
}
final protected function buildEditFields($object) {
$viewer = $this->getViewer();
$fields = $this->buildCustomEditFields($object);
foreach ($fields as $field) {
$field
->setViewer($viewer)
->setObject($object);
}
$fields = mpull($fields, null, 'getKey');
if ($this->isEngineExtensible()) {
$extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions();
} else {
$extensions = array();
}
// See T13248. Create a template object to provide to extensions. We
// adjust the template to have the intended subtype, so that extensions
// may change behavior based on the form subtype.
$template_object = clone $object;
if ($this->getIsCreate()) {
if ($this->supportsSubtypes()) {
$config = $this->getEditEngineConfiguration();
$subtype = $config->getSubtype();
$template_object->setSubtype($subtype);
}
}
foreach ($extensions as $extension) {
$extension->setViewer($viewer);
if (!$extension->supportsObject($this, $template_object)) {
continue;
}
$extension_fields = $extension->buildCustomEditFields(
$this,
$template_object);
// TODO: Validate this in more detail with a more tailored error.
assert_instances_of($extension_fields, 'PhabricatorEditField');
foreach ($extension_fields as $field) {
$field
->setViewer($viewer)
->setObject($object);
$group_key = $field->getBulkEditGroupKey();
if ($group_key === null) {
$field->setBulkEditGroupKey('extension');
}
}
$extension_fields = mpull($extension_fields, null, 'getKey');
foreach ($extension_fields as $key => $field) {
$fields[$key] = $field;
}
}
$config = $this->getEditEngineConfiguration();
$fields = $this->willConfigureFields($object, $fields);
$fields = $config->applyConfigurationToFields($this, $object, $fields);
$fields = $this->applyPageToFields($object, $fields);
return $fields;
}
protected function willConfigureFields($object, array $fields) {
return $fields;
}
final public function supportsSubtypes() {
try {
$object = $this->newEditableObject();
} catch (Exception $ex) {
return false;
}
return ($object instanceof PhabricatorEditEngineSubtypeInterface);
}
final public function newSubtypeMap() {
return $this->newEditableObject()->newEditEngineSubtypeMap();
}
/* -( Display Text )------------------------------------------------------- */
/**
* @task text
*/
abstract public function getEngineName();
/**
* @task text
*/
abstract protected function getObjectCreateTitleText($object);
/**
* @task text
*/
protected function getFormHeaderText($object) {
$config = $this->getEditEngineConfiguration();
return $config->getName();
}
/**
* @task text
*/
abstract protected function getObjectEditTitleText($object);
/**
* @task text
*/
abstract protected function getObjectCreateShortText();
/**
* @task text
*/
abstract protected function getObjectName();
/**
* @task text
*/
abstract protected function getObjectEditShortText($object);
/**
* @task text
*/
protected function getObjectCreateButtonText($object) {
return $this->getObjectCreateTitleText($object);
}
/**
* @task text
*/
protected function getObjectEditButtonText($object) {
return pht('Save Changes');
}
/**
* @task text
*/
protected function getCommentViewSeriousHeaderText($object) {
return pht('Take Action');
}
/**
* @task text
*/
protected function getCommentViewSeriousButtonText($object) {
return pht('Submit');
}
/**
* @task text
*/
protected function getCommentViewHeaderText($object) {
return $this->getCommentViewSeriousHeaderText($object);
}
/**
* @task text
*/
protected function getCommentViewButtonText($object) {
return $this->getCommentViewSeriousButtonText($object);
}
/**
* @task text
*/
protected function getPageHeader($object) {
return null;
}
/**
* Set default placeholder plain text in the comment textarea of the engine.
* To be overwritten by conditions defined in the child EditEngine class.
*
* @param object $object Object in which the comment textarea is displayed.
* @return string Placeholder text to display in the comment textarea.
* @task text
*/
public function getCommentFieldPlaceholderText($object) {
return '';
}
/**
* Return a human-readable header describing what this engine is used to do,
* like "Configure Maniphest Task Forms".
*
* @return string Human-readable description of the engine.
* @task text
*/
abstract public function getSummaryHeader();
/**
* Return a human-readable summary of what this engine is used to do.
*
* @return string Human-readable description of the engine.
* @task text
*/
abstract public function getSummaryText();
/* -( Edit Engine Configuration )------------------------------------------ */
protected function supportsEditEngineConfiguration() {
return true;
}
final protected function getEditEngineConfiguration() {
return $this->editEngineConfiguration;
}
public function newConfigurationQuery() {
return id(new PhabricatorEditEngineConfigurationQuery())
->setViewer($this->getViewer())
->withEngineKeys(array($this->getEngineKey()));
}
private function loadEditEngineConfigurationWithQuery(
PhabricatorEditEngineConfigurationQuery $query,
$sort_method) {
if ($sort_method) {
$results = $query->execute();
$results = msort($results, $sort_method);
$result = head($results);
} else {
$result = $query->executeOne();
}
if (!$result) {
return null;
}
$this->editEngineConfiguration = $result;
return $result;
}
private function loadEditEngineConfigurationWithIdentifier($identifier) {
$query = $this->newConfigurationQuery()
->withIdentifiers(array($identifier));
return $this->loadEditEngineConfigurationWithQuery($query, null);
}
private function loadDefaultConfiguration() {
$query = $this->newConfigurationQuery()
->withIdentifiers(
array(
self::EDITENGINECONFIG_DEFAULT,
))
->withIgnoreDatabaseConfigurations(true);
return $this->loadEditEngineConfigurationWithQuery($query, null);
}
private function loadDefaultCreateConfiguration() {
$query = $this->newConfigurationQuery()
->withIsDefault(true)
->withIsDisabled(false);
return $this->loadEditEngineConfigurationWithQuery(
$query,
'getCreateSortKey');
}
public function loadDefaultEditConfiguration($object) {
$query = $this->newConfigurationQuery()
->withIsEdit(true)
->withIsDisabled(false);
// If this object supports subtyping, we edit it with a form of the same
// subtype: so "bug" tasks get edited with "bug" forms.
if ($object instanceof PhabricatorEditEngineSubtypeInterface) {
$query->withSubtypes(
array(
$object->getEditEngineSubtype(),
));
}
return $this->loadEditEngineConfigurationWithQuery(
$query,
'getEditSortKey');
}
final public function getBuiltinEngineConfigurations() {
$configurations = $this->newBuiltinEngineConfigurations();
if (!$configurations) {
throw new Exception(
pht(
'EditEngine ("%s") returned no builtin engine configurations, but '.
'an edit engine must have at least one configuration.',
get_class($this)));
}
assert_instances_of($configurations, 'PhabricatorEditEngineConfiguration');
$has_default = false;
foreach ($configurations as $config) {
if ($config->getBuiltinKey() == self::EDITENGINECONFIG_DEFAULT) {
$has_default = true;
}
}
if (!$has_default) {
$first = head($configurations);
if (!$first->getBuiltinKey()) {
$first
->setBuiltinKey(self::EDITENGINECONFIG_DEFAULT)
->setIsDefault(true)
->setIsEdit(true);
$first_name = $first->getName();
if ($first_name === null || $first_name === '') {
$first->setName($this->getObjectCreateShortText());
}
} else {
throw new Exception(
pht(
'EditEngine ("%s") returned builtin engine configurations, '.
'but none are marked as default and the first configuration has '.
'a different builtin key already. Mark a builtin as default or '.
'omit the key from the first configuration',
get_class($this)));
}
}
$builtins = array();
foreach ($configurations as $key => $config) {
$builtin_key = $config->getBuiltinKey();
if ($builtin_key === null) {
throw new Exception(
pht(
'EditEngine ("%s") returned builtin engine configurations, '.
'but one (with key "%s") is missing a builtin key. Provide a '.
'builtin key for each configuration (you can omit it from the '.
'first configuration in the list to automatically assign the '.
'default key).',
get_class($this),
$key));
}
if (isset($builtins[$builtin_key])) {
throw new Exception(
pht(
'EditEngine ("%s") returned builtin engine configurations, '.
'but at least two specify the same builtin key ("%s"). Engines '.
'must have unique builtin keys.',
get_class($this),
$builtin_key));
}
$builtins[$builtin_key] = $config;
}
return $builtins;
}
protected function newBuiltinEngineConfigurations() {
return array(
$this->newConfiguration(),
);
}
final protected function newConfiguration() {
return PhabricatorEditEngineConfiguration::initializeNewConfiguration(
$this->getViewer(),
$this);
}
/* -( Managing URIs )------------------------------------------------------ */
/**
* @task uri
*/
abstract protected function getObjectViewURI($object);
/**
* @task uri
*/
protected function getObjectCreateCancelURI($object) {
return $this->getApplication()->getApplicationURI();
}
/**
* @task uri
*/
protected function getEditorURI() {
return $this->getApplication()->getApplicationURI('edit/');
}
/**
* @task uri
*/
protected function getObjectEditCancelURI($object) {
return $this->getObjectViewURI($object);
}
/**
* @task uri
*/
public function getCreateURI($form_key) {
try {
$create_uri = $this->getEditURI(null, "form/{$form_key}/");
} catch (Exception $ex) {
$create_uri = null;
}
return $create_uri;
}
/**
* @task uri
*/
public function getEditURI($object = null, $path = null) {
$parts = array();
$parts[] = $this->getEditorURI();
if ($object && $object->getID()) {
$parts[] = $object->getID().'/';
}
if ($path !== null) {
$parts[] = $path;
}
return implode('', $parts);
}
public function getEffectiveObjectViewURI($object) {
if ($this->getIsCreate()) {
return $this->getObjectViewURI($object);
}
$page = $this->getSelectedPage();
if ($page) {
$view_uri = $page->getViewURI();
if ($view_uri !== null) {
return $view_uri;
}
}
return $this->getObjectViewURI($object);
}
public function getEffectiveObjectEditDoneURI($object) {
return $this->getEffectiveObjectViewURI($object);
}
public function getEffectiveObjectEditCancelURI($object) {
$page = $this->getSelectedPage();
if ($page) {
$view_uri = $page->getViewURI();
if ($view_uri !== null) {
return $view_uri;
}
}
return $this->getObjectEditCancelURI($object);
}
/* -( Creating and Loading Objects )--------------------------------------- */
/**
* Initialize a new object for creation.
*
* @return object Newly initialized object.
* @task load
*/
abstract protected function newEditableObject();
/**
* Build an empty query for objects.
*
* @return PhabricatorPolicyAwareQuery Query.
* @task load
*/
abstract protected function newObjectQuery();
/**
* Test if this workflow is creating a new object or editing an existing one.
*
* @return bool True if a new object is being created.
* @task load
*/
final public function getIsCreate() {
return $this->isCreate;
}
/**
* Initialize a new object for object creation via Conduit.
*
* @return object Newly initialized object.
* @param list<wild> $raw_xactions Raw transactions.
* @task load
*/
protected function newEditableObjectFromConduit(array $raw_xactions) {
return $this->newEditableObject();
}
/**
* Initialize a new object for documentation creation.
*
* @return object Newly initialized object.
* @task load
*/
protected function newEditableObjectForDocumentation() {
return $this->newEditableObject();
}
/**
* Flag this workflow as a create or edit.
*
* @param bool $is_create True if this is a create workflow.
- * @return this
+ * @return $this
* @task load
*/
private function setIsCreate($is_create) {
$this->isCreate = $is_create;
return $this;
}
/**
* Try to load an object by ID, PHID, or monogram. This is done primarily
* to make Conduit a little easier to use.
*
* @param wild $identifier ID, PHID, or monogram.
* @param list<const> $capabilities (optional) List of required capability
* constants, or omit for defaults.
* @return object Corresponding editable object.
* @task load
*/
private function newObjectFromIdentifier(
$identifier,
array $capabilities = array()) {
if (is_int($identifier) || ctype_digit($identifier)) {
$object = $this->newObjectFromID($identifier, $capabilities);
if (!$object) {
throw new Exception(
pht(
'No object exists with ID "%s".',
$identifier));
}
return $object;
}
$type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN;
if (phid_get_type($identifier) != $type_unknown) {
$object = $this->newObjectFromPHID($identifier, $capabilities);
if (!$object) {
throw new Exception(
pht(
'No object exists with PHID "%s".',
$identifier));
}
return $object;
}
$target = id(new PhabricatorObjectQuery())
->setViewer($this->getViewer())
->withNames(array($identifier))
->executeOne();
if (!$target) {
throw new Exception(
pht(
'Monogram "%s" does not identify a valid object.',
$identifier));
}
$expect = $this->newEditableObject();
$expect_class = get_class($expect);
$target_class = get_class($target);
if ($expect_class !== $target_class) {
throw new Exception(
pht(
'Monogram "%s" identifies an object of the wrong type. Loaded '.
'object has class "%s", but this editor operates on objects of '.
'type "%s".',
$identifier,
$target_class,
$expect_class));
}
// Load the object by PHID using this engine's standard query. This makes
// sure it's really valid, goes through standard policy check logic, and
// picks up any `need...()` clauses we want it to load with.
$object = $this->newObjectFromPHID($target->getPHID(), $capabilities);
if (!$object) {
throw new Exception(
pht(
'Failed to reload object identified by monogram "%s" when '.
'querying by PHID.',
$identifier));
}
return $object;
}
/**
* Load an object by ID.
*
* @param int $id Object ID.
* @param list<const> $capabilities (optional) List of required capability
* constants, or omit for defaults.
* @return object|null Object, or null if no such object exists.
* @task load
*/
private function newObjectFromID($id, array $capabilities = array()) {
$query = $this->newObjectQuery()
->withIDs(array($id));
return $this->newObjectFromQuery($query, $capabilities);
}
/**
* Load an object by PHID.
*
* @param phid $phid Object PHID.
* @param list<const> $capabilities (optional) List of required capability
* constants, or omit for defaults.
* @return object|null Object, or null if no such object exists.
* @task load
*/
private function newObjectFromPHID($phid, array $capabilities = array()) {
$query = $this->newObjectQuery()
->withPHIDs(array($phid));
return $this->newObjectFromQuery($query, $capabilities);
}
/**
* Load an object given a configured query.
*
* @param PhabricatorPolicyAwareQuery $query Configured query.
* @param list<const> $capabilities (optional) List of required capability
* constants, or omit for defaults.
* @return object|null Object, or null if no such object exists.
* @task load
*/
private function newObjectFromQuery(
PhabricatorPolicyAwareQuery $query,
array $capabilities = array()) {
$viewer = $this->getViewer();
if (!$capabilities) {
$capabilities = array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
$object = $query
->setViewer($viewer)
->requireCapabilities($capabilities)
->executeOne();
if (!$object) {
return null;
}
return $object;
}
/**
* Verify that an object is appropriate for editing.
*
* @param wild $object Loaded value.
* @return void
* @task load
*/
private function validateObject($object) {
if (!$object || !is_object($object)) {
throw new Exception(
pht(
'EditEngine "%s" created or loaded an invalid object: object must '.
'actually be an object, but is of some other type ("%s").',
get_class($this),
gettype($object)));
}
if (!($object instanceof PhabricatorApplicationTransactionInterface)) {
throw new Exception(
pht(
'EditEngine "%s" created or loaded an invalid object: object (of '.
'class "%s") must implement "%s", but does not.',
get_class($this),
get_class($object),
'PhabricatorApplicationTransactionInterface'));
}
}
/* -( Responding to Web Requests )----------------------------------------- */
final public function buildResponse() {
$viewer = $this->getViewer();
$controller = $this->getController();
$request = $controller->getRequest();
$action = $this->getEditAction();
$capabilities = array();
$use_default = false;
$require_create = true;
switch ($action) {
case 'comment':
$capabilities = array(
PhabricatorPolicyCapability::CAN_VIEW,
);
$use_default = true;
break;
case 'parameters':
$use_default = true;
break;
case 'nodefault':
case 'nocreate':
case 'nomanage':
$require_create = false;
break;
default:
break;
}
$object = $this->getTargetObject();
if (!$object) {
$id = $request->getURIData('id');
if ($id) {
$this->setIsCreate(false);
$object = $this->newObjectFromID($id, $capabilities);
if (!$object) {
return new Aphront404Response();
}
} else {
// Make sure the viewer has permission to create new objects of
// this type if we're going to create a new object.
if ($require_create) {
$this->requireCreateCapability();
}
$this->setIsCreate(true);
$object = $this->newEditableObject();
}
} else {
$id = $object->getID();
}
$this->validateObject($object);
if ($use_default) {
$config = $this->loadDefaultConfiguration();
if (!$config) {
return new Aphront404Response();
}
} else {
$form_key = $request->getURIData('formKey');
if (phutil_nonempty_string($form_key)) {
$config = $this->loadEditEngineConfigurationWithIdentifier($form_key);
if (!$config) {
return new Aphront404Response();
}
if ($id && !$config->getIsEdit()) {
return $this->buildNotEditFormRespose($object, $config);
}
} else {
if ($id) {
$config = $this->loadDefaultEditConfiguration($object);
if (!$config) {
return $this->buildNoEditResponse($object);
}
} else {
$config = $this->loadDefaultCreateConfiguration();
if (!$config) {
return $this->buildNoCreateResponse($object);
}
}
}
}
if ($config->getIsDisabled()) {
return $this->buildDisabledFormResponse($object, $config);
}
$page_key = $request->getURIData('pageKey');
if (!phutil_nonempty_string($page_key)) {
$pages = $this->getPages($object);
if ($pages) {
$page_key = head_key($pages);
}
}
if (phutil_nonempty_string($page_key)) {
$page = $this->selectPage($object, $page_key);
if (!$page) {
return new Aphront404Response();
}
}
switch ($action) {
case 'parameters':
return $this->buildParametersResponse($object);
case 'nodefault':
return $this->buildNoDefaultResponse($object);
case 'nocreate':
return $this->buildNoCreateResponse($object);
case 'nomanage':
return $this->buildNoManageResponse($object);
case 'comment':
return $this->buildCommentResponse($object);
default:
return $this->buildEditResponse($object);
}
}
private function buildCrumbs($object, $final = false) {
$controller = $this->getController();
$crumbs = $controller->buildApplicationCrumbsForEditEngine();
if ($this->getIsCreate()) {
$create_text = $this->getObjectCreateShortText();
if ($final) {
$crumbs->addTextCrumb($create_text);
} else {
$edit_uri = $this->getEditURI($object);
$crumbs->addTextCrumb($create_text, $edit_uri);
}
} else {
$crumbs->addTextCrumb(
$this->getObjectEditShortText($object),
$this->getEffectiveObjectViewURI($object));
$edit_text = pht('Edit');
if ($final) {
$crumbs->addTextCrumb($edit_text);
} else {
$edit_uri = $this->getEditURI($object);
$crumbs->addTextCrumb($edit_text, $edit_uri);
}
}
return $crumbs;
}
private function buildEditResponse($object) {
$viewer = $this->getViewer();
$controller = $this->getController();
$request = $controller->getRequest();
$fields = $this->buildEditFields($object);
$template = $object->getApplicationTransactionTemplate();
$page_state = new PhabricatorEditEnginePageState();
if ($this->getIsCreate()) {
$cancel_uri = $this->getObjectCreateCancelURI($object);
$submit_button = $this->getObjectCreateButtonText($object);
$page_state->setIsCreate(true);
} else {
$cancel_uri = $this->getEffectiveObjectEditCancelURI($object);
$submit_button = $this->getObjectEditButtonText($object);
}
$config = $this->getEditEngineConfiguration()
->attachEngine($this);
// NOTE: Don't prompt users to override locks when creating objects,
// even if the default settings would create a locked object.
$can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object);
if (!$can_interact &&
!$this->getIsCreate() &&
!$request->getBool('editEngine') &&
!$request->getBool('overrideLock')) {
$lock = PhabricatorEditEngineLock::newForObject($viewer, $object);
$dialog = $this->getController()
->newDialog()
->addHiddenInput('overrideLock', true)
->setDisableWorkflowOnSubmit(true)
->addCancelButton($cancel_uri);
return $lock->willPromptUserForLockOverrideWithDialog($dialog);
}
$validation_exception = null;
if ($request->isFormOrHisecPost() && $request->getBool('editEngine')) {
$page_state->setIsSubmit(true);
$submit_fields = $fields;
foreach ($submit_fields as $key => $field) {
if (!$field->shouldGenerateTransactionsFromSubmit()) {
unset($submit_fields[$key]);
continue;
}
}
// Before we read the submitted values, store a copy of what we would
// use if the form was empty so we can figure out which transactions are
// just setting things to their default values for the current form.
$defaults = array();
foreach ($submit_fields as $key => $field) {
$defaults[$key] = $field->getValueForTransaction();
}
foreach ($submit_fields as $key => $field) {
$field->setIsSubmittedForm(true);
if (!$field->shouldReadValueFromSubmit()) {
continue;
}
$field->readValueFromSubmit($request);
}
$xactions = array();
if ($this->getIsCreate()) {
$xactions[] = id(clone $template)
->setTransactionType(PhabricatorTransactions::TYPE_CREATE);
if ($this->supportsSubtypes()) {
$xactions[] = id(clone $template)
->setTransactionType(PhabricatorTransactions::TYPE_SUBTYPE)
->setNewValue($config->getSubtype());
}
}
foreach ($submit_fields as $key => $field) {
$field_value = $field->getValueForTransaction();
$type_xactions = $field->generateTransactions(
clone $template,
array(
'value' => $field_value,
));
foreach ($type_xactions as $type_xaction) {
$default = $defaults[$key];
if ($default === $field->getValueForTransaction()) {
$type_xaction->setIsDefaultTransaction(true);
}
$xactions[] = $type_xaction;
}
}
$editor = $object->getApplicationTransactionEditor()
->setActor($viewer)
->setContentSourceFromRequest($request)
->setCancelURI($cancel_uri)
->setContinueOnNoEffect(true);
try {
$xactions = $this->willApplyTransactions($object, $xactions);
$editor->applyTransactions($object, $xactions);
$this->didApplyTransactions($object, $xactions);
return $this->newEditResponse($request, $object, $xactions);
} catch (PhabricatorApplicationTransactionValidationException $ex) {
$validation_exception = $ex;
foreach ($fields as $field) {
$message = $this->getValidationExceptionShortMessage($ex, $field);
if ($message === null) {
continue;
}
$field->setControlError($message);
}
$page_state->setIsError(true);
}
} else {
if ($this->getIsCreate()) {
$template = $request->getStr('template');
if (phutil_nonempty_string($template)) {
$template_object = $this->newObjectFromIdentifier(
$template,
array(
PhabricatorPolicyCapability::CAN_VIEW,
));
if (!$template_object) {
return new Aphront404Response();
}
} else {
$template_object = null;
}
if ($template_object) {
$copy_fields = $this->buildEditFields($template_object);
$copy_fields = mpull($copy_fields, null, 'getKey');
foreach ($copy_fields as $copy_key => $copy_field) {
if (!$copy_field->getIsCopyable()) {
unset($copy_fields[$copy_key]);
}
}
} else {
$copy_fields = array();
}
foreach ($fields as $field) {
if (!$field->shouldReadValueFromRequest()) {
continue;
}
$field_key = $field->getKey();
if (isset($copy_fields[$field_key])) {
$field->readValueFromField($copy_fields[$field_key]);
}
$field->readValueFromRequest($request);
}
}
}
$action_button = $this->buildEditFormActionButton($object);
if ($this->getIsCreate()) {
$header_text = $this->getFormHeaderText($object);
} else {
$header_text = $this->getObjectEditTitleText($object);
}
$show_preview = !$request->isAjax();
if ($show_preview) {
$previews = array();
foreach ($fields as $field) {
$preview = $field->getPreviewPanel();
if (!$preview) {
continue;
}
$control_id = $field->getControlID();
$preview
->setControlID($control_id)
->setPreviewURI('/transactions/remarkuppreview/');
$previews[] = $preview;
}
} else {
$previews = array();
}
$form = $this->buildEditForm($object, $fields);
$crumbs = $this->buildCrumbs($object, $final = true);
$crumbs->setBorder(true);
if ($request->isAjax()) {
return $this->getController()
->newDialog()
->setWidth(AphrontDialogView::WIDTH_FULL)
->setTitle($header_text)
->setValidationException($validation_exception)
->appendForm($form)
->addCancelButton($cancel_uri)
->addSubmitButton($submit_button);
}
$box_header = id(new PHUIHeaderView())
->setHeader($header_text);
if ($action_button) {
$box_header->addActionLink($action_button);
}
$request_submit_key = $request->getSubmitKey();
$engine_submit_key = $this->getEditEngineSubmitKey();
if ($request_submit_key === $engine_submit_key) {
$page_state->setIsSubmit(true);
$page_state->setIsSave(true);
}
$head = $this->newEditFormHeadContent($page_state);
$tail = $this->newEditFormTailContent($page_state);
$box = id(new PHUIObjectBoxView())
->setUser($viewer)
->setHeader($box_header)
->setValidationException($validation_exception)
->setBackground(PHUIObjectBoxView::WHITE_CONFIG)
->appendChild($form);
$content = array(
$head,
$box,
$previews,
$tail,
);
$view = new PHUITwoColumnView();
$page_header = $this->getPageHeader($object);
if ($page_header) {
$view->setHeader($page_header);
}
$view->setFooter($content);
$page = $controller->newPage()
->setTitle($header_text)
->setCrumbs($crumbs)
->appendChild($view);
$navigation = $this->getNavigation();
if ($navigation) {
$page->setNavigation($navigation);
}
return $page;
}
protected function newEditFormHeadContent(
PhabricatorEditEnginePageState $state) {
return null;
}
protected function newEditFormTailContent(
PhabricatorEditEnginePageState $state) {
return null;
}
protected function newEditResponse(
AphrontRequest $request,
$object,
array $xactions) {
$submit_cookie = PhabricatorCookies::COOKIE_SUBMIT;
$submit_key = $this->getEditEngineSubmitKey();
$request->setTemporaryCookie($submit_cookie, $submit_key);
return id(new AphrontRedirectResponse())
->setURI($this->getEffectiveObjectEditDoneURI($object));
}
private function getEditEngineSubmitKey() {
return 'edit-engine/'.$this->getEngineKey();
}
private function buildEditForm($object, array $fields) {
$viewer = $this->getViewer();
$controller = $this->getController();
$request = $controller->getRequest();
$fields = $this->willBuildEditForm($object, $fields);
$request_path = $request->getPath();
$form = id(new AphrontFormView())
->setUser($viewer)
->setAction($request_path)
->addHiddenInput('editEngine', 'true');
foreach ($this->contextParameters as $param) {
$form->addHiddenInput($param, $request->getStr($param));
}
$requires_mfa = false;
if ($object instanceof PhabricatorEditEngineMFAInterface) {
$mfa_engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
->setViewer($viewer);
$requires_mfa = $mfa_engine->shouldRequireMFA();
}
if ($requires_mfa) {
$message = pht(
'You will be required to provide multi-factor credentials to make '.
'changes.');
$form->appendChild(
id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_MFA)
->setErrors(array($message)));
// TODO: This should also set workflow on the form, so the user doesn't
// lose any form data if they "Cancel". However, Maniphest currently
// overrides "newEditResponse()" if the request is Ajax and returns a
// bag of view data. This can reasonably be cleaned up when workboards
// get their next iteration.
}
foreach ($fields as $field) {
if (!$field->getIsFormField()) {
continue;
}
$field->appendToForm($form);
}
if ($this->getIsCreate()) {
$cancel_uri = $this->getObjectCreateCancelURI($object);
$submit_button = $this->getObjectCreateButtonText($object);
} else {
$cancel_uri = $this->getEffectiveObjectEditCancelURI($object);
$submit_button = $this->getObjectEditButtonText($object);
}
if (!$request->isAjax()) {
$buttons = id(new AphrontFormSubmitControl())
->setValue($submit_button);
if ($cancel_uri) {
$buttons->addCancelButton($cancel_uri);
}
$form->appendControl($buttons);
}
return $form;
}
protected function willBuildEditForm($object, array $fields) {
return $fields;
}
private function buildEditFormActionButton($object) {
if (!$this->isEngineConfigurable()) {
return null;
}
$viewer = $this->getViewer();
$action_view = id(new PhabricatorActionListView())
->setUser($viewer);
foreach ($this->buildEditFormActions($object) as $action) {
$action_view->addAction($action);
}
$action_button = id(new PHUIButtonView())
->setTag('a')
->setText(pht('Configure Form'))
->setHref('#')
->setIcon('fa-gear')
->setDropdownMenu($action_view);
return $action_button;
}
private function buildEditFormActions($object) {
$actions = array();
if ($this->supportsEditEngineConfiguration()) {
$engine_key = $this->getEngineKey();
$config = $this->getEditEngineConfiguration();
$can_manage = PhabricatorPolicyFilter::hasCapability(
$this->getViewer(),
$config,
PhabricatorPolicyCapability::CAN_EDIT);
if ($can_manage) {
$manage_uri = $config->getURI();
} else {
$manage_uri = $this->getEditURI(null, 'nomanage/');
}
$view_uri = "/transactions/editengine/{$engine_key}/";
$actions[] = id(new PhabricatorActionView())
->setLabel(true)
->setName(pht('Configuration'));
$actions[] = id(new PhabricatorActionView())
->setName(pht('View Form Configurations'))
->setIcon('fa-list-ul')
->setHref($view_uri);
$actions[] = id(new PhabricatorActionView())
->setName(pht('Edit Form Configuration'))
->setIcon('fa-pencil')
->setHref($manage_uri)
->setDisabled(!$can_manage)
->setWorkflow(!$can_manage);
}
$actions[] = id(new PhabricatorActionView())
->setLabel(true)
->setName(pht('Documentation'));
$actions[] = id(new PhabricatorActionView())
->setName(pht('Using HTTP Parameters'))
->setIcon('fa-book')
->setHref($this->getEditURI($object, 'parameters/'));
$doc_href = PhabricatorEnv::getDoclink('User Guide: Customizing Forms');
$actions[] = id(new PhabricatorActionView())
->setName(pht('User Guide: Customizing Forms'))
->setIcon('fa-book')
->setHref($doc_href);
return $actions;
}
public function newNUXButton($text) {
$specs = $this->newCreateActionSpecifications(array());
$head = head($specs);
return id(new PHUIButtonView())
->setTag('a')
->setText($text)
->setHref($head['uri'])
->setDisabled($head['disabled'])
->setWorkflow($head['workflow'])
->setColor(PHUIButtonView::GREEN);
}
final public function addActionToCrumbs(
PHUICrumbsView $crumbs,
array $parameters = array()) {
$viewer = $this->getViewer();
$specs = $this->newCreateActionSpecifications($parameters);
$head = head($specs);
$menu_uri = $head['uri'];
$dropdown = null;
if (count($specs) > 1) {
$menu_icon = 'fa-caret-square-o-down';
$menu_name = $this->getObjectCreateShortText();
$workflow = false;
$disabled = false;
$dropdown = id(new PhabricatorActionListView())
->setUser($viewer);
foreach ($specs as $spec) {
$dropdown->addAction(
id(new PhabricatorActionView())
->setName($spec['name'])
->setIcon($spec['icon'])
->setHref($spec['uri'])
->setDisabled($head['disabled'])
->setWorkflow($head['workflow']));
}
} else {
$menu_icon = $head['icon'];
$menu_name = $head['name'];
$workflow = $head['workflow'];
$disabled = $head['disabled'];
}
$action = id(new PHUIListItemView())
->setName($menu_name)
->setHref($menu_uri)
->setIcon($menu_icon)
->setWorkflow($workflow)
->setDisabled($disabled);
if ($dropdown) {
$action->setDropdownMenu($dropdown);
}
$crumbs->addAction($action);
}
/**
* Build a raw description of available "Create New Object" UI options so
* other methods can build menus or buttons.
*/
public function newCreateActionSpecifications(array $parameters) {
$viewer = $this->getViewer();
$can_create = $this->hasCreateCapability();
if ($can_create) {
$configs = $this->loadUsableConfigurationsForCreate();
} else {
$configs = array();
}
$disabled = false;
$workflow = false;
$menu_icon = 'fa-plus-square';
$specs = array();
if (!$configs) {
if ($viewer->isLoggedIn()) {
$disabled = true;
} else {
// If the viewer isn't logged in, assume they'll get hit with a login
// dialog and are likely able to create objects after they log in.
$disabled = false;
}
$workflow = true;
if ($can_create) {
$create_uri = $this->getEditURI(null, 'nodefault/');
} else {
$create_uri = $this->getEditURI(null, 'nocreate/');
}
$specs[] = array(
'name' => $this->getObjectCreateShortText(),
'uri' => $create_uri,
'icon' => $menu_icon,
'disabled' => $disabled,
'workflow' => $workflow,
);
} else {
foreach ($configs as $config) {
$config_uri = $config->getCreateURI();
if ($parameters) {
$config_uri = (string)new PhutilURI($config_uri, $parameters);
}
$specs[] = array(
'name' => $config->getDisplayName(),
'uri' => $config_uri,
'icon' => 'fa-plus',
'disabled' => false,
'workflow' => false,
);
}
}
return $specs;
}
final public function buildEditEngineCommentView($object) {
$config = $this->loadDefaultEditConfiguration($object);
if (!$config) {
// TODO: This just nukes the entire comment form if you don't have access
// to any edit forms. We might want to tailor this UX a bit.
return id(new PhabricatorApplicationTransactionCommentView())
->setNoPermission(true);
}
$viewer = $this->getViewer();
$can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object);
if (!$can_interact) {
$lock = PhabricatorEditEngineLock::newForObject($viewer, $object);
return id(new PhabricatorApplicationTransactionCommentView())
->setEditEngineLock($lock);
}
$object_phid = $object->getPHID();
$is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
if ($is_serious) {
$header_text = $this->getCommentViewSeriousHeaderText($object);
$button_text = $this->getCommentViewSeriousButtonText($object);
} else {
$header_text = $this->getCommentViewHeaderText($object);
$button_text = $this->getCommentViewButtonText($object);
}
$comment_uri = $this->getEditURI($object, 'comment/');
$requires_mfa = false;
if ($object instanceof PhabricatorEditEngineMFAInterface) {
$mfa_engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
->setViewer($viewer);
$requires_mfa = $mfa_engine->shouldRequireMFA();
}
$view = id(new PhabricatorApplicationTransactionCommentView())
->setUser($viewer)
->setHeaderText($header_text)
->setAction($comment_uri)
->setRequestURI(new PhutilURI($this->getObjectViewURI($object)))
->setRequiresMFA($requires_mfa)
->setObject($object)
->setEditEngine($this)
->setSubmitButtonName($button_text);
$draft = PhabricatorVersionedDraft::loadDraft(
$object_phid,
$viewer->getPHID());
if ($draft) {
$view->setVersionedDraft($draft);
}
$view->setCurrentVersion($this->loadDraftVersion($object));
$fields = $this->buildEditFields($object);
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$object,
PhabricatorPolicyCapability::CAN_EDIT);
$comment_actions = array();
foreach ($fields as $field) {
if (!$field->shouldGenerateTransactionsFromComment()) {
continue;
}
if (!$can_edit) {
if (!$field->getCanApplyWithoutEditCapability()) {
continue;
}
}
$comment_action = $field->getCommentAction();
if (!$comment_action) {
continue;
}
$key = $comment_action->getKey();
// TODO: Validate these better.
$comment_actions[$key] = $comment_action;
}
$comment_actions = msortv($comment_actions, 'getSortVector');
$view->setCommentActions($comment_actions);
$comment_groups = $this->newCommentActionGroups();
$view->setCommentActionGroups($comment_groups);
return $view;
}
protected function loadDraftVersion($object) {
$viewer = $this->getViewer();
if (!$viewer->isLoggedIn()) {
return null;
}
$template = $object->getApplicationTransactionTemplate();
$conn_r = $template->establishConnection('r');
// Find the most recent transaction the user has written. We'll use this
// as a version number to make sure that out-of-date drafts get discarded.
$result = queryfx_one(
$conn_r,
'SELECT id AS version FROM %T
WHERE objectPHID = %s AND authorPHID = %s
ORDER BY id DESC LIMIT 1',
$template->getTableName(),
$object->getPHID(),
$viewer->getPHID());
if ($result) {
return (int)$result['version'];
} else {
return null;
}
}
/* -( Responding to HTTP Parameter Requests )------------------------------ */
/**
* Respond to a request for documentation on HTTP parameters.
*
* @param object $object Editable object.
* @return AphrontResponse Response object.
* @task http
*/
private function buildParametersResponse($object) {
$controller = $this->getController();
$viewer = $this->getViewer();
$request = $controller->getRequest();
$fields = $this->buildEditFields($object);
$crumbs = $this->buildCrumbs($object);
$crumbs->addTextCrumb(pht('HTTP Parameters'));
$crumbs->setBorder(true);
$header_text = pht(
'HTTP Parameters: %s',
$this->getObjectCreateShortText());
$header = id(new PHUIHeaderView())
->setHeader($header_text);
$help_view = id(new PhabricatorApplicationEditHTTPParameterHelpView())
->setUser($viewer)
->setFields($fields);
$document = id(new PHUIDocumentView())
->setUser($viewer)
->setHeader($header)
->appendChild($help_view);
return $controller->newPage()
->setTitle(pht('HTTP Parameters'))
->setCrumbs($crumbs)
->appendChild($document);
}
private function buildError($object, $title, $body) {
$cancel_uri = $this->getObjectCreateCancelURI($object);
$dialog = $this->getController()
->newDialog()
->addCancelButton($cancel_uri);
if ($title !== null) {
$dialog->setTitle($title);
}
if ($body !== null) {
$dialog->appendParagraph($body);
}
return $dialog;
}
private function buildNoDefaultResponse($object) {
return $this->buildError(
$object,
pht('No Default Create Forms'),
pht(
'This application is not configured with any forms for creating '.
'objects that are visible to you and enabled.'));
}
private function buildNoCreateResponse($object) {
return $this->buildError(
$object,
pht('No Create Permission'),
pht('You do not have permission to create these objects.'));
}
private function buildNoManageResponse($object) {
return $this->buildError(
$object,
pht('No Manage Permission'),
pht(
'You do not have permission to configure forms for this '.
'application.'));
}
private function buildNoEditResponse($object) {
return $this->buildError(
$object,
pht('No Edit Forms'),
pht(
'You do not have access to any forms which are enabled and marked '.
'as edit forms.'));
}
private function buildNotEditFormRespose($object, $config) {
return $this->buildError(
$object,
pht('Not an Edit Form'),
pht(
'This form ("%s") is not marked as an edit form, so '.
'it can not be used to edit objects.',
$config->getName()));
}
private function buildDisabledFormResponse($object, $config) {
return $this->buildError(
$object,
pht('Form Disabled'),
pht(
'This form ("%s") has been disabled, so it can not be used.',
$config->getName()));
}
private function buildLockedObjectResponse($object) {
$dialog = $this->buildError($object, null, null);
$viewer = $this->getViewer();
$lock = PhabricatorEditEngineLock::newForObject($viewer, $object);
return $lock->willBlockUserInteractionWithDialog($dialog);
}
private function buildCommentResponse($object) {
$viewer = $this->getViewer();
if ($this->getIsCreate()) {
return new Aphront404Response();
}
$controller = $this->getController();
$request = $controller->getRequest();
// NOTE: We handle hisec inside the transaction editor with "Sign With MFA"
// comment actions.
if (!$request->isFormOrHisecPost()) {
return new Aphront400Response();
}
$can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object);
if (!$can_interact) {
return $this->buildLockedObjectResponse($object);
}
$config = $this->loadDefaultEditConfiguration($object);
if (!$config) {
return new Aphront404Response();
}
$fields = $this->buildEditFields($object);
$is_preview = $request->isPreviewRequest();
$view_uri = $this->getEffectiveObjectViewURI($object);
$template = $object->getApplicationTransactionTemplate();
$comment_template = $template->getApplicationTransactionCommentObject();
$comment_text = $request->getStr('comment');
$comment_metadata = $request->getStr('comment_metadata');
if (phutil_nonempty_string($comment_metadata)) {
$comment_metadata = phutil_json_decode($comment_metadata);
}
$actions = $request->getStr('editengine.actions');
if ($actions) {
$actions = phutil_json_decode($actions);
}
if ($is_preview) {
$version_key = PhabricatorVersionedDraft::KEY_VERSION;
$request_version = $request->getInt($version_key);
$current_version = $this->loadDraftVersion($object);
if ($request_version >= $current_version) {
$draft = PhabricatorVersionedDraft::loadOrCreateDraft(
$object->getPHID(),
$viewer->getPHID(),
$current_version);
$draft
->setProperty('comment', $comment_text)
->setProperty('metadata', $comment_metadata)
->setProperty('actions', $actions)
->save();
$draft_engine = $this->newDraftEngine($object);
if ($draft_engine) {
$draft_engine
->setVersionedDraft($draft)
->synchronize();
}
}
}
$xactions = array();
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$object,
PhabricatorPolicyCapability::CAN_EDIT);
if ($actions) {
$action_map = array();
foreach ($actions as $action) {
$type = idx($action, 'type');
if (!$type) {
continue;
}
if (empty($fields[$type])) {
continue;
}
$action_map[$type] = $action;
}
foreach ($action_map as $type => $action) {
$field = $fields[$type];
if (!$field->shouldGenerateTransactionsFromComment()) {
continue;
}
// If you don't have edit permission on the object, you're limited in
// which actions you can take via the comment form. Most actions
// need edit permission, but some actions (like "Accept Revision")
// can be applied by anyone with view permission.
if (!$can_edit) {
if (!$field->getCanApplyWithoutEditCapability()) {
// We know the user doesn't have the capability, so this will
// raise a policy exception.
PhabricatorPolicyFilter::requireCapability(
$viewer,
$object,
PhabricatorPolicyCapability::CAN_EDIT);
}
}
if (array_key_exists('initialValue', $action)) {
$field->setInitialValue($action['initialValue']);
}
$field->readValueFromComment(idx($action, 'value'));
$type_xactions = $field->generateTransactions(
clone $template,
array(
'value' => $field->getValueForTransaction(),
));
foreach ($type_xactions as $type_xaction) {
$xactions[] = $type_xaction;
}
}
}
$auto_xactions = $this->newAutomaticCommentTransactions($object);
foreach ($auto_xactions as $xaction) {
$xactions[] = $xaction;
}
if (phutil_nonempty_string($comment_text) || !$xactions) {
$xactions[] = id(clone $template)
->setTransactionType(PhabricatorTransactions::TYPE_COMMENT)
->setMetadataValue('remarkup.control', $comment_metadata)
->attachComment(
id(clone $comment_template)
->setContent($comment_text));
}
$editor = $object->getApplicationTransactionEditor()
->setActor($viewer)
->setContinueOnNoEffect($request->isContinueRequest())
->setContinueOnMissingFields(true)
->setContentSourceFromRequest($request)
->setCancelURI($view_uri)
->setRaiseWarnings(!$request->getBool('editEngine.warnings'))
->setIsPreview($is_preview);
try {
$xactions = $editor->applyTransactions($object, $xactions);
} catch (PhabricatorApplicationTransactionValidationException $ex) {
return id(new PhabricatorApplicationTransactionValidationResponse())
->setCancelURI($view_uri)
->setException($ex);
} catch (PhabricatorApplicationTransactionNoEffectException $ex) {
return id(new PhabricatorApplicationTransactionNoEffectResponse())
->setCancelURI($view_uri)
->setException($ex);
} catch (PhabricatorApplicationTransactionWarningException $ex) {
return id(new PhabricatorApplicationTransactionWarningResponse())
->setObject($object)
->setCancelURI($view_uri)
->setException($ex);
}
if (!$is_preview) {
PhabricatorVersionedDraft::purgeDrafts(
$object->getPHID(),
$viewer->getPHID());
$draft_engine = $this->newDraftEngine($object);
if ($draft_engine) {
$draft_engine
->setVersionedDraft(null)
->synchronize();
}
}
if ($request->isAjax() && $is_preview) {
$preview_content = $this->newCommentPreviewContent($object, $xactions);
$raw_view_data = $request->getStr('viewData');
try {
$view_data = phutil_json_decode($raw_view_data);
} catch (Exception $ex) {
$view_data = array();
}
return id(new PhabricatorApplicationTransactionResponse())
->setObject($object)
->setViewer($viewer)
->setTransactions($xactions)
->setIsPreview($is_preview)
->setViewData($view_data)
->setPreviewContent($preview_content);
} else {
return id(new AphrontRedirectResponse())
->setURI($view_uri);
}
}
protected function newDraftEngine($object) {
$viewer = $this->getViewer();
if ($object instanceof PhabricatorDraftInterface) {
$engine = $object->newDraftEngine();
} else {
$engine = new PhabricatorBuiltinDraftEngine();
}
return $engine
->setObject($object)
->setViewer($viewer);
}
/* -( Conduit )------------------------------------------------------------ */
/**
* Respond to a Conduit edit request.
*
* This method accepts a list of transactions to apply to an object, and
* either edits an existing object or creates a new one.
*
* @task conduit
*/
final public function buildConduitResponse(ConduitAPIRequest $request) {
$viewer = $this->getViewer();
$config = $this->loadDefaultConfiguration();
if (!$config) {
throw new Exception(
pht(
'Unable to load configuration for this EditEngine ("%s").',
get_class($this)));
}
$raw_xactions = $this->getRawConduitTransactions($request);
$identifier = $request->getValue('objectIdentifier');
if ($identifier) {
$this->setIsCreate(false);
// After T13186, each transaction can individually weaken or replace the
// capabilities required to apply it, so we no longer need CAN_EDIT to
// attempt to apply transactions to objects. In practice, almost all
// transactions require CAN_EDIT so we won't get very far if we don't
// have it.
$capabilities = array(
PhabricatorPolicyCapability::CAN_VIEW,
);
$object = $this->newObjectFromIdentifier(
$identifier,
$capabilities);
} else {
$this->requireCreateCapability();
$this->setIsCreate(true);
$object = $this->newEditableObjectFromConduit($raw_xactions);
}
$this->validateObject($object);
$fields = $this->buildEditFields($object);
$types = $this->getConduitEditTypesFromFields($fields);
$template = $object->getApplicationTransactionTemplate();
$xactions = $this->getConduitTransactions(
$request,
$raw_xactions,
$types,
$template);
$editor = $object->getApplicationTransactionEditor()
->setActor($viewer)
->setContentSource($request->newContentSource())
->setContinueOnNoEffect(true);
if (!$this->getIsCreate()) {
$editor->setContinueOnMissingFields(true);
}
$xactions = $editor->applyTransactions($object, $xactions);
$xactions_struct = array();
foreach ($xactions as $xaction) {
$xactions_struct[] = array(
'phid' => $xaction->getPHID(),
);
}
return array(
'object' => array(
'id' => (int)$object->getID(),
'phid' => $object->getPHID(),
),
'transactions' => $xactions_struct,
);
}
private function getRawConduitTransactions(ConduitAPIRequest $request) {
$transactions_key = 'transactions';
$xactions = $request->getValue($transactions_key);
if (!is_array($xactions)) {
throw new Exception(
pht(
'Parameter "%s" is not a list of transactions.',
$transactions_key));
}
foreach ($xactions as $key => $xaction) {
if (!is_array($xaction)) {
throw new Exception(
pht(
'Parameter "%s" must contain a list of transaction descriptions, '.
'but item with key "%s" is not a dictionary.',
$transactions_key,
$key));
}
if (!array_key_exists('type', $xaction)) {
throw new Exception(
pht(
'Parameter "%s" must contain a list of transaction descriptions, '.
'but item with key "%s" is missing a "type" field. Each '.
'transaction must have a type field.',
$transactions_key,
$key));
}
if (!array_key_exists('value', $xaction)) {
throw new Exception(
pht(
'Parameter "%s" must contain a list of transaction descriptions, '.
'but item with key "%s" is missing a "value" field. Each '.
'transaction must have a value field.',
$transactions_key,
$key));
}
}
return $xactions;
}
/**
* Generate transactions which can be applied from edit actions in a Conduit
* request.
*
* @param ConduitAPIRequest $request The request.
* @param list<wild> $xactions Raw conduit transactions.
* @param list<PhabricatorEditType> $types Supported edit types.
* @param PhabricatorApplicationTransaction $template Template transaction.
* @return list<PhabricatorApplicationTransaction> Generated transactions.
* @task conduit
*/
private function getConduitTransactions(
ConduitAPIRequest $request,
array $xactions,
array $types,
PhabricatorApplicationTransaction $template) {
$viewer = $request->getUser();
$results = array();
foreach ($xactions as $key => $xaction) {
$type = $xaction['type'];
if (empty($types[$type])) {
throw new Exception(
pht(
'Transaction with key "%s" has invalid type "%s". This type is '.
'not recognized. Valid types are: %s.',
$key,
$type,
implode(', ', array_keys($types))));
}
}
if ($this->getIsCreate()) {
$results[] = id(clone $template)
->setTransactionType(PhabricatorTransactions::TYPE_CREATE);
}
$is_strict = $request->getIsStrictlyTyped();
foreach ($xactions as $xaction) {
$type = $types[$xaction['type']];
// Let the parameter type interpret the value. This allows you to
// use usernames in list<user> fields, for example.
$parameter_type = $type->getConduitParameterType();
$parameter_type->setViewer($viewer);
try {
$value = $xaction['value'];
$value = $parameter_type->getValue($xaction, 'value', $is_strict);
$value = $type->getTransactionValueFromConduit($value);
$xaction['value'] = $value;
} catch (Exception $ex) {
throw new PhutilProxyException(
pht(
'Exception when processing transaction of type "%s": %s',
$xaction['type'],
$ex->getMessage()),
$ex);
}
$type_xactions = $type->generateTransactions(
clone $template,
$xaction);
foreach ($type_xactions as $type_xaction) {
$results[] = $type_xaction;
}
}
return $results;
}
/**
* @return map<string, PhabricatorEditType>
* @task conduit
*/
private function getConduitEditTypesFromFields(array $fields) {
$types = array();
foreach ($fields as $field) {
$field_types = $field->getConduitEditTypes();
if ($field_types === null) {
continue;
}
foreach ($field_types as $field_type) {
$types[$field_type->getEditType()] = $field_type;
}
}
return $types;
}
public function getConduitEditTypes() {
$config = $this->loadDefaultConfiguration();
if (!$config) {
return array();
}
$object = $this->newEditableObjectForDocumentation();
$fields = $this->buildEditFields($object);
return $this->getConduitEditTypesFromFields($fields);
}
final public static function getAllEditEngines() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getEngineKey')
->execute();
}
final public static function getByKey(PhabricatorUser $viewer, $key) {
return id(new PhabricatorEditEngineQuery())
->setViewer($viewer)
->withEngineKeys(array($key))
->executeOne();
}
public function getIcon() {
$application = $this->getApplication();
return $application->getIcon();
}
private function loadUsableConfigurationsForCreate() {
$viewer = $this->getViewer();
$configs = id(new PhabricatorEditEngineConfigurationQuery())
->setViewer($viewer)
->withEngineKeys(array($this->getEngineKey()))
->withIsDefault(true)
->withIsDisabled(false)
->execute();
$configs = msort($configs, 'getCreateSortKey');
// Attach this specific engine to configurations we load so they can access
// any runtime configuration. For example, this allows us to generate the
// correct "Create Form" buttons when editing forms, see T12301.
foreach ($configs as $config) {
$config->attachEngine($this);
}
return $configs;
}
protected function getValidationExceptionShortMessage(
PhabricatorApplicationTransactionValidationException $ex,
PhabricatorEditField $field) {
$xaction_type = $field->getTransactionType();
if ($xaction_type === null) {
return null;
}
return $ex->getShortMessage($xaction_type);
}
protected function getCreateNewObjectPolicy() {
return PhabricatorPolicies::POLICY_USER;
}
private function requireCreateCapability() {
PhabricatorPolicyFilter::requireCapability(
$this->getViewer(),
$this,
PhabricatorPolicyCapability::CAN_EDIT);
}
private function hasCreateCapability() {
return PhabricatorPolicyFilter::hasCapability(
$this->getViewer(),
$this,
PhabricatorPolicyCapability::CAN_EDIT);
}
public function isCommentAction() {
return ($this->getEditAction() == 'comment');
}
public function getEditAction() {
$controller = $this->getController();
$request = $controller->getRequest();
return $request->getURIData('editAction');
}
protected function newCommentActionGroups() {
return array();
}
protected function newAutomaticCommentTransactions($object) {
return array();
}
protected function newCommentPreviewContent($object, array $xactions) {
return null;
}
/* -( Form Pages )--------------------------------------------------------- */
public function getSelectedPage() {
return $this->page;
}
private function selectPage($object, $page_key) {
$pages = $this->getPages($object);
if (empty($pages[$page_key])) {
return null;
}
$this->page = $pages[$page_key];
return $this->page;
}
protected function newPages($object) {
return array();
}
protected function getPages($object) {
if ($this->pages === null) {
$pages = $this->newPages($object);
assert_instances_of($pages, 'PhabricatorEditPage');
$pages = mpull($pages, null, 'getKey');
$this->pages = $pages;
}
return $this->pages;
}
private function applyPageToFields($object, array $fields) {
$pages = $this->getPages($object);
if (!$pages) {
return $fields;
}
if (!$this->getSelectedPage()) {
return $fields;
}
$page_picks = array();
$default_key = head($pages)->getKey();
foreach ($pages as $page_key => $page) {
foreach ($page->getFieldKeys() as $field_key) {
$page_picks[$field_key] = $page_key;
}
if ($page->getIsDefault()) {
$default_key = $page_key;
}
}
$page_map = array_fill_keys(array_keys($pages), array());
foreach ($fields as $field_key => $field) {
if (isset($page_picks[$field_key])) {
$page_map[$page_picks[$field_key]][$field_key] = $field;
continue;
}
// TODO: Maybe let the field pick a page to associate itself with so
// extensions can force themselves onto a particular page?
$page_map[$default_key][$field_key] = $field;
}
$page = $this->getSelectedPage();
if (!$page) {
$page = head($pages);
}
$selected_key = $page->getKey();
return $page_map[$selected_key];
}
protected function willApplyTransactions($object, array $xactions) {
return $xactions;
}
protected function didApplyTransactions($object, array $xactions) {
return;
}
/* -( Bulk Edits )--------------------------------------------------------- */
final public function newBulkEditGroupMap() {
$groups = $this->newBulkEditGroups();
$map = array();
foreach ($groups as $group) {
$key = $group->getKey();
if (isset($map[$key])) {
throw new Exception(
pht(
'Two bulk edit groups have the same key ("%s"). Each bulk edit '.
'group must have a unique key.',
$key));
}
$map[$key] = $group;
}
if ($this->isEngineExtensible()) {
$extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions();
} else {
$extensions = array();
}
foreach ($extensions as $extension) {
$extension_groups = $extension->newBulkEditGroups($this);
foreach ($extension_groups as $group) {
$key = $group->getKey();
if (isset($map[$key])) {
throw new Exception(
pht(
'Extension "%s" defines a bulk edit group with the same key '.
'("%s") as the main editor or another extension. Each bulk '.
'edit group must have a unique key.',
get_class($extension),
$key));
}
$map[$key] = $group;
}
}
return $map;
}
protected function newBulkEditGroups() {
return array(
id(new PhabricatorBulkEditGroup())
->setKey('default')
->setLabel(pht('Primary Fields')),
id(new PhabricatorBulkEditGroup())
->setKey('extension')
->setLabel(pht('Support Applications')),
);
}
final public function newBulkEditMap() {
$viewer = $this->getViewer();
$config = $this->loadDefaultConfiguration();
if (!$config) {
throw new Exception(
pht('No default edit engine configuration for bulk edit.'));
}
$object = $this->newEditableObject();
$fields = $this->buildEditFields($object);
$groups = $this->newBulkEditGroupMap();
$edit_types = $this->getBulkEditTypesFromFields($fields);
$map = array();
foreach ($edit_types as $key => $type) {
$bulk_type = $type->getBulkParameterType();
if ($bulk_type === null) {
continue;
}
$bulk_type->setViewer($viewer);
$bulk_label = $type->getBulkEditLabel();
if ($bulk_label === null) {
continue;
}
$group_key = $type->getBulkEditGroupKey();
if (!$group_key) {
$group_key = 'default';
}
if (!isset($groups[$group_key])) {
throw new Exception(
pht(
'Field "%s" has a bulk edit group key ("%s") with no '.
'corresponding bulk edit group.',
$key,
$group_key));
}
$map[] = array(
'label' => $bulk_label,
'xaction' => $key,
'group' => $group_key,
'control' => array(
'type' => $bulk_type->getPHUIXControlType(),
'spec' => (object)$bulk_type->getPHUIXControlSpecification(),
),
);
}
return $map;
}
final public function newRawBulkTransactions(array $xactions) {
$config = $this->loadDefaultConfiguration();
if (!$config) {
throw new Exception(
pht('No default edit engine configuration for bulk edit.'));
}
$object = $this->newEditableObject();
$fields = $this->buildEditFields($object);
$edit_types = $this->getBulkEditTypesFromFields($fields);
$template = $object->getApplicationTransactionTemplate();
$raw_xactions = array();
foreach ($xactions as $key => $xaction) {
PhutilTypeSpec::checkMap(
$xaction,
array(
'type' => 'string',
'value' => 'optional wild',
));
$type = $xaction['type'];
if (!isset($edit_types[$type])) {
throw new Exception(
pht(
'Unsupported bulk edit type "%s".',
$type));
}
$edit_type = $edit_types[$type];
// Replace the edit type with the underlying transaction type. Usually
// these are 1:1 and the transaction type just has more internal noise,
// but it's possible that this isn't the case.
$xaction['type'] = $edit_type->getTransactionType();
$value = $xaction['value'];
$value = $edit_type->getTransactionValueFromBulkEdit($value);
$xaction['value'] = $value;
$xaction_objects = $edit_type->generateTransactions(
clone $template,
$xaction);
foreach ($xaction_objects as $xaction_object) {
$raw_xaction = array(
'type' => $xaction_object->getTransactionType(),
'metadata' => $xaction_object->getMetadata(),
'new' => $xaction_object->getNewValue(),
);
if ($xaction_object->hasOldValue()) {
$raw_xaction['old'] = $xaction_object->getOldValue();
}
if ($xaction_object->hasComment()) {
$comment = $xaction_object->getComment();
$raw_xaction['comment'] = $comment->getContent();
}
$raw_xactions[] = $raw_xaction;
}
}
return $raw_xactions;
}
private function getBulkEditTypesFromFields(array $fields) {
$types = array();
foreach ($fields as $field) {
$field_types = $field->getBulkEditTypes();
if ($field_types === null) {
continue;
}
foreach ($field_types as $field_type) {
$types[$field_type->getEditType()] = $field_type;
}
}
return $types;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getPHID() {
return get_class($this);
}
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::getMostOpenPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return $this->getCreateNewObjectPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
}
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
index b759e58c1d..d5fd81a65b 100644
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -1,5759 +1,5759 @@
<?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 $continue True to drop transactions without effect and
* continue.
- * @return this
+ * @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 $continue_on_missing_fields True to continue when transactions
* don't completely satisfy all required fields.
- * @return this
+ * @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" 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;
break;
}
}
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()
->setIgnoreOnNoEffect(true)
->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 Object being edited.
* @param string $type Transaction type to validate.
* @param list<PhabricatorApplicationTransaction> $xactions 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 $field_value Current field value.
* @param list<PhabricatorApplicationTransaction> $xactions 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 )------------------------------------------------------- */
/**
* Adds the actor as a subscriber to the object with which they interact
* @param PhabricatorLiskDAO $object on which the action is performed
* @param array $xactions Transactions to apply
* @return array Transactions to apply
*/
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;
}
/**
* Whether the action implies the actor should be subscribed on the object
* @param PhabricatorLiskDAO $object on which the action is performed
* @param PhabricatorApplicationTransaction $xaction Transaction to apply
* @return bool True if the actor should be subscribed on the object
*/
protected function shouldImplyCC(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return ($xaction->isCommentTransaction() &&
!($xaction->getComment()->getIsRemoved()));
}
/* -( 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> $state Editor state, from
@{method:getWorkerState}.
- * @return this
+ * @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> $state Custom state,
* from @{method:getCustomWorkerState}.
- * @return this
+ * @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> $state Map of values to encode.
* @param map<string, string> $encodings 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> $state Map of encoded values.
* @param map<string, string> $encodings 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> $phids 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/infrastructure/cache/PhutilInRequestKeyValueCache.php b/src/infrastructure/cache/PhutilInRequestKeyValueCache.php
index 19edc81e2a..1960c464c0 100644
--- a/src/infrastructure/cache/PhutilInRequestKeyValueCache.php
+++ b/src/infrastructure/cache/PhutilInRequestKeyValueCache.php
@@ -1,118 +1,118 @@
<?php
/**
* Key-value cache implemented in the current request. All storage is local
* to this request (i.e., the current page) and destroyed after the request
* exits. This means the first request to this cache for a given key on a page
* will ALWAYS miss.
*
* This cache exists mostly to support unit tests. In a well-designed
* applications, you generally should not be fetching the same data over and
* over again in one request, so this cache should be of limited utility.
* If using this cache improves application performance, especially if it
* improves it significantly, it may indicate an architectural problem in your
* application.
*/
final class PhutilInRequestKeyValueCache extends PhutilKeyValueCache {
private $cache = array();
private $ttl = array();
private $limit = 0;
/**
* Set a limit on the number of keys this cache may contain.
*
* When too many keys are inserted, the oldest keys are removed from the
* cache. Setting a limit of `0` disables the cache.
*
* @param int $limit Maximum number of items to store in the cache.
- * @return this
+ * @return $this
*/
public function setLimit($limit) {
$this->limit = $limit;
return $this;
}
/* -( Key-Value Cache Implementation )------------------------------------- */
public function isAvailable() {
return true;
}
public function getKeys(array $keys) {
$results = array();
$now = time();
foreach ($keys as $key) {
if (!isset($this->cache[$key]) && !array_key_exists($key, $this->cache)) {
continue;
}
if (isset($this->ttl[$key]) && ($this->ttl[$key] < $now)) {
continue;
}
$results[$key] = $this->cache[$key];
}
return $results;
}
public function setKeys(array $keys, $ttl = null) {
foreach ($keys as $key => $value) {
$this->cache[$key] = $value;
}
if ($ttl) {
$end = time() + $ttl;
foreach ($keys as $key => $value) {
$this->ttl[$key] = $end;
}
} else {
foreach ($keys as $key => $value) {
unset($this->ttl[$key]);
}
}
if ($this->limit) {
$count = count($this->cache);
if ($count > $this->limit) {
$remove = array();
foreach ($this->cache as $key => $value) {
$remove[] = $key;
$count--;
if ($count <= $this->limit) {
break;
}
}
$this->deleteKeys($remove);
}
}
return $this;
}
public function deleteKeys(array $keys) {
foreach ($keys as $key) {
unset($this->cache[$key]);
unset($this->ttl[$key]);
}
return $this;
}
public function getAllKeys() {
return $this->cache;
}
public function destroyCache() {
$this->cache = array();
$this->ttl = array();
return $this;
}
}
diff --git a/src/infrastructure/cache/PhutilKeyValueCache.php b/src/infrastructure/cache/PhutilKeyValueCache.php
index 45f7a3048e..64596ddb6e 100644
--- a/src/infrastructure/cache/PhutilKeyValueCache.php
+++ b/src/infrastructure/cache/PhutilKeyValueCache.php
@@ -1,121 +1,121 @@
<?php
/**
* Interface to a key-value cache like Memcache or APC. This class provides a
* uniform interface to multiple different key-value caches and integration
* with PhutilServiceProfiler.
*
* @task kvimpl Key-Value Cache Implementation
*/
abstract class PhutilKeyValueCache extends Phobject {
/* -( Key-Value Cache Implementation )------------------------------------- */
/**
* Determine if the cache is available. For example, the APC cache tests if
* APC is installed. If this method returns false, the cache is not
* operational and can not be used.
*
* @return bool True if the cache can be used.
* @task kvimpl
*/
public function isAvailable() {
return false;
}
/**
* Get a single key from cache. See @{method:getKeys} to get multiple keys at
* once.
*
* @param string $key Key to retrieve.
* @param wild $default (optional) Value to return if the key is not
* found. By default, returns null.
* @return wild Cache value (on cache hit) or default value (on cache
* miss).
* @task kvimpl
*/
final public function getKey($key, $default = null) {
$map = $this->getKeys(array($key));
return idx($map, $key, $default);
}
/**
* Set a single key in cache. See @{method:setKeys} to set multiple keys at
* once.
*
* See @{method:setKeys} for a description of TTLs.
*
* @param string $key Key to set.
* @param wild $value Value to set.
* @param int|null $ttl (optional) TTL.
- * @return this
+ * @return $this
* @task kvimpl
*/
final public function setKey($key, $value, $ttl = null) {
return $this->setKeys(array($key => $value), $ttl);
}
/**
* Delete a key from the cache. See @{method:deleteKeys} to delete multiple
* keys at once.
*
* @param string $key Key to delete.
- * @return this
+ * @return $this
* @task kvimpl
*/
final public function deleteKey($key) {
return $this->deleteKeys(array($key));
}
/**
* Get data from the cache.
*
* @param list<string> $keys List of cache keys to retrieve.
* @return dict<string, wild> Dictionary of keys that were found in the
* cache. Keys not present in the cache are
* omitted, so you can detect a cache miss.
* @task kvimpl
*/
abstract public function getKeys(array $keys);
/**
* Put data into the key-value cache.
*
* With a TTL ("time to live"), the cache will automatically delete the key
* after a specified number of seconds. By default, there is no expiration
* policy and data will persist in cache indefinitely.
*
* @param dict<string, wild> $keys Map of cache keys to values.
* @param int|null $ttl (optional) TTL for cache keys, in seconds.
- * @return this
+ * @return $this
* @task kvimpl
*/
abstract public function setKeys(array $keys, $ttl = null);
/**
* Delete a list of keys from the cache.
*
* @param list<string> $keys List of keys to delete.
- * @return this
+ * @return $this
* @task kvimpl
*/
abstract public function deleteKeys(array $keys);
/**
* Completely destroy all data in the cache.
*
- * @return this
+ * @return $this
* @task kvimpl
*/
abstract public function destroyCache();
}
diff --git a/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php b/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php
index f5d6979c92..b572844060 100644
--- a/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php
+++ b/src/infrastructure/cache/PhutilKeyValueCacheProfiler.php
@@ -1,108 +1,108 @@
<?php
final class PhutilKeyValueCacheProfiler extends PhutilKeyValueCacheProxy {
private $profiler;
private $name;
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
/**
* Set a profiler for cache operations.
*
* @param PhutilServiceProfiler $profiler Service profiler.
- * @return this
+ * @return $this
* @task kvimpl
*/
public function setProfiler(PhutilServiceProfiler $profiler) {
$this->profiler = $profiler;
return $this;
}
/**
* Get the current profiler.
*
* @return PhutilServiceProfiler|null Profiler, or null if none is set.
* @task kvimpl
*/
public function getProfiler() {
return $this->profiler;
}
public function getKeys(array $keys) {
$call_id = null;
if ($this->getProfiler()) {
$call_id = $this->getProfiler()->beginServiceCall(
array(
'type' => 'kvcache-get',
'name' => $this->getName(),
'keys' => $keys,
));
}
$results = parent::getKeys($keys);
if ($call_id !== null) {
$this->getProfiler()->endServiceCall(
$call_id,
array(
'hits' => array_keys($results),
));
}
return $results;
}
public function setKeys(array $keys, $ttl = null) {
$call_id = null;
if ($this->getProfiler()) {
$call_id = $this->getProfiler()->beginServiceCall(
array(
'type' => 'kvcache-set',
'name' => $this->getName(),
'keys' => array_keys($keys),
'ttl' => $ttl,
));
}
$result = parent::setKeys($keys, $ttl);
if ($call_id !== null) {
$this->getProfiler()->endServiceCall($call_id, array());
}
return $result;
}
public function deleteKeys(array $keys) {
$call_id = null;
if ($this->getProfiler()) {
$call_id = $this->getProfiler()->beginServiceCall(
array(
'type' => 'kvcache-del',
'name' => $this->getName(),
'keys' => $keys,
));
}
$result = parent::deleteKeys($keys);
if ($call_id !== null) {
$this->getProfiler()->endServiceCall($call_id, array());
}
return $result;
}
}
diff --git a/src/infrastructure/cache/PhutilKeyValueCacheStack.php b/src/infrastructure/cache/PhutilKeyValueCacheStack.php
index eab1ff86c5..6d4d4864a0 100644
--- a/src/infrastructure/cache/PhutilKeyValueCacheStack.php
+++ b/src/infrastructure/cache/PhutilKeyValueCacheStack.php
@@ -1,132 +1,132 @@
<?php
/**
* Stacks multiple caches on top of each other, with readthrough semantics:
*
* - For reads, we try each cache in order until we find all the keys.
* - For writes, we set the keys in each cache.
*
* @task config Configuring the Stack
*/
final class PhutilKeyValueCacheStack extends PhutilKeyValueCache {
/**
* Forward list of caches in the stack (from the nearest cache to the farthest
* cache).
*/
private $cachesForward;
/**
* Backward list of caches in the stack (from the farthest cache to the
* nearest cache).
*/
private $cachesBackward;
/**
* TTL to use for any writes which are side effects of the next read
* operation.
*/
private $nextTTL;
/* -( Configuring the Stack )---------------------------------------------- */
/**
* Set the caches which comprise this stack.
*
* @param list<PhutilKeyValueCache> $caches Ordered list of key-value
* caches.
- * @return this
+ * @return $this
* @task config
*/
public function setCaches(array $caches) {
assert_instances_of($caches, 'PhutilKeyValueCache');
$this->cachesForward = $caches;
$this->cachesBackward = array_reverse($caches);
return $this;
}
/**
* Set the readthrough TTL for the next cache operation. The TTL applies to
* any keys set by the next call to @{method:getKey} or @{method:getKeys},
* and is reset after the call finishes.
*
* // If this causes any caches to fill, they'll fill with a 15-second TTL.
* $stack->setNextTTL(15)->getKey('porcupine');
*
* // TTL does not persist; this will use no TTL.
* $stack->getKey('hedgehog');
*
* @param int $ttl TTL in seconds.
- * @return this
+ * @return $this
*
* @task config
*/
public function setNextTTL($ttl) {
$this->nextTTL = $ttl;
return $this;
}
/* -( Key-Value Cache Implementation )------------------------------------- */
public function getKeys(array $keys) {
$remaining = array_fuse($keys);
$results = array();
$missed = array();
try {
foreach ($this->cachesForward as $cache) {
$result = $cache->getKeys($remaining);
$remaining = array_diff_key($remaining, $result);
$results += $result;
if (!$remaining) {
while ($cache = array_pop($missed)) {
// TODO: This sets too many results in the closer caches, although
// it probably isn't a big deal in most cases; normally we're just
// filling the request cache.
$cache->setKeys($result, $this->nextTTL);
}
break;
}
$missed[] = $cache;
}
$this->nextTTL = null;
} catch (Exception $ex) {
$this->nextTTL = null;
throw $ex;
}
return $results;
}
public function setKeys(array $keys, $ttl = null) {
foreach ($this->cachesBackward as $cache) {
$cache->setKeys($keys, $ttl);
}
}
public function deleteKeys(array $keys) {
foreach ($this->cachesBackward as $cache) {
$cache->deleteKeys($keys);
}
}
public function destroyCache() {
foreach ($this->cachesBackward as $cache) {
$cache->destroyCache();
}
}
}
diff --git a/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php b/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php
index 918971ac28..b8fdd4b3dc 100644
--- a/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php
+++ b/src/infrastructure/cache/PhutilMemcacheKeyValueCache.php
@@ -1,153 +1,153 @@
<?php
/**
* @task memcache Managing Memcache
*/
final class PhutilMemcacheKeyValueCache extends PhutilKeyValueCache {
private $servers = array();
private $connections = array();
/* -( Key-Value Cache Implementation )------------------------------------- */
public function isAvailable() {
return function_exists('memcache_pconnect');
}
public function getKeys(array $keys) {
$buckets = $this->bucketKeys($keys);
$results = array();
foreach ($buckets as $bucket => $bucket_keys) {
$conn = $this->getConnection($bucket);
$result = $conn->get($bucket_keys);
if (!$result) {
// If the call fails, treat it as a miss on all keys.
$result = array();
}
$results += $result;
}
return $results;
}
public function setKeys(array $keys, $ttl = null) {
$buckets = $this->bucketKeys(array_keys($keys));
// Memcache interprets TTLs as:
//
// - Seconds from now, for values from 1 to 2592000 (30 days).
// - Epoch timestamp, for values larger than 2592000.
//
// We support only relative TTLs, so convert excessively large relative
// TTLs into epoch TTLs.
if ($ttl > 2592000) {
$effective_ttl = time() + $ttl;
} else {
$effective_ttl = $ttl;
}
foreach ($buckets as $bucket => $bucket_keys) {
$conn = $this->getConnection($bucket);
foreach ($bucket_keys as $key) {
$conn->set($key, $keys[$key], 0, $effective_ttl);
}
}
return $this;
}
public function deleteKeys(array $keys) {
$buckets = $this->bucketKeys($keys);
foreach ($buckets as $bucket => $bucket_keys) {
$conn = $this->getConnection($bucket);
foreach ($bucket_keys as $key) {
$conn->delete($key);
}
}
return $this;
}
public function destroyCache() {
foreach ($this->servers as $key => $spec) {
$this->getConnection($key)->flush();
}
return $this;
}
/* -( Managing Memcache )-------------------------------------------------- */
/**
* Set available memcache servers. For example:
*
* $cache->setServers(
* array(
* array(
* 'host' => '10.0.0.20',
* 'port' => 11211,
* ),
* array(
* 'host' => '10.0.0.21',
* 'port' => 11211,
* ),
* ));
*
* @param list<dict> $servers List of server specifications.
- * @return this
+ * @return $this
* @task memcache
*/
public function setServers(array $servers) {
$this->servers = array_values($servers);
return $this;
}
private function bucketKeys(array $keys) {
$buckets = array();
$n = count($this->servers);
if (!$n) {
throw new PhutilInvalidStateException('setServers');
}
foreach ($keys as $key) {
$bucket = (int)((crc32($key) & 0x7FFFFFFF) % $n);
$buckets[$bucket][] = $key;
}
return $buckets;
}
/**
* @phutil-external-symbol function memcache_pconnect
*/
private function getConnection($server) {
if (empty($this->connections[$server])) {
$spec = $this->servers[$server];
$host = $spec['host'];
$port = $spec['port'];
$conn = memcache_pconnect($host, $spec['port']);
if (!$conn) {
throw new Exception(
pht(
'Unable to connect to memcache server (%s:%d)!',
$host,
$port));
}
$this->connections[$server] = $conn;
}
return $this->connections[$server];
}
}
diff --git a/src/infrastructure/customfield/field/PhabricatorCustomField.php b/src/infrastructure/customfield/field/PhabricatorCustomField.php
index c513b59c82..4b95586dcb 100644
--- a/src/infrastructure/customfield/field/PhabricatorCustomField.php
+++ b/src/infrastructure/customfield/field/PhabricatorCustomField.php
@@ -1,1727 +1,1727 @@
<?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 $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 $proxy Field implementation.
- * @return this
+ * @return $this
* @task proxy
*/
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.
* @task proxy
*/
final public function getProxy() {
return $this->proxy;
}
/**
* @task proxy
*/
public function __clone() {
if ($this->proxy) {
$this->proxy = clone $this->proxy;
}
}
/* -( Contextual Data )---------------------------------------------------- */
/**
* Sets the object this field belongs to.
*
* @param PhabricatorCustomFieldInterface $object The object this field
* belongs to.
- * @return this
+ * @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 $object The object this field
* belongs to.
- * @return this
+ * @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 $value Serialized field representation (from
* @{method:getValueForStorage}) or null if no value has
* ever been stored.
- * @return this
+ * @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 $value 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 $value 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 Engine building the
* query.
* @param AphrontRequest $request 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 Engine executing the
* query.
* @param PhabricatorCursorPagedPolicyAwareQuery $query Query to constrain.
* @param wild $value 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 Engine constructing the
* form.
* @param AphrontFormView $form The form to update.
* @param wild $value 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 ApplicationTransactions 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 Editor applying the transactions.
* @param string $type Transaction type. This type is always
* `PhabricatorTransactions::TYPE_CUSTOMFIELD`, it is provided for
* convenience when constructing exceptions.
* @param list<PhabricatorApplicationTransaction> $xactions 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 $condition 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 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/daemon/PhutilDaemon.php b/src/infrastructure/daemon/PhutilDaemon.php
index ccc1a02fec..bdd29a9ade 100644
--- a/src/infrastructure/daemon/PhutilDaemon.php
+++ b/src/infrastructure/daemon/PhutilDaemon.php
@@ -1,390 +1,390 @@
<?php
/**
* Scaffolding for implementing robust background processing scripts.
*
*
* Autoscaling
* ===========
*
* Autoscaling automatically launches copies of a daemon when it is busy
* (scaling the pool up) and stops them when they're idle (scaling the pool
* down). This is appropriate for daemons which perform highly parallelizable
* work.
*
* To make a daemon support autoscaling, the implementation should look
* something like this:
*
* while (!$this->shouldExit()) {
* if (work_available()) {
* $this->willBeginWork();
* do_work();
* $this->sleep(0);
* } else {
* $this->willBeginIdle();
* $this->sleep(1);
* }
* }
*
* In particular, call @{method:willBeginWork} before becoming busy, and
* @{method:willBeginIdle} when no work is available. If the daemon is launched
* into an autoscale pool, this will cause the pool to automatically scale up
* when busy and down when idle.
*
* Launching a daemon which does not make these callbacks into an autoscale
* pool will have no effect.
*
* @task overseer Communicating With the Overseer
* @task autoscale Autoscaling Daemon Pools
*/
abstract class PhutilDaemon extends Phobject {
const MESSAGETYPE_STDOUT = 'stdout';
const MESSAGETYPE_HEARTBEAT = 'heartbeat';
const MESSAGETYPE_BUSY = 'busy';
const MESSAGETYPE_IDLE = 'idle';
const MESSAGETYPE_DOWN = 'down';
const MESSAGETYPE_HIBERNATE = 'hibernate';
const WORKSTATE_BUSY = 'busy';
const WORKSTATE_IDLE = 'idle';
private $argv;
private $traceMode;
private $traceMemory;
private $verbose;
private $notifyReceived;
private $inGracefulShutdown;
private $workState = null;
private $idleSince = null;
private $scaledownDuration;
final public function setVerbose($verbose) {
$this->verbose = $verbose;
return $this;
}
final public function getVerbose() {
return $this->verbose;
}
final public function setScaledownDuration($scaledown_duration) {
$this->scaledownDuration = $scaledown_duration;
return $this;
}
final public function getScaledownDuration() {
return $this->scaledownDuration;
}
final public function __construct(array $argv) {
$this->argv = $argv;
$router = PhutilSignalRouter::getRouter();
$handler_key = 'daemon.term';
if (!$router->getHandler($handler_key)) {
$handler = new PhutilCallbackSignalHandler(
SIGTERM,
__CLASS__.'::onTermSignal');
$router->installHandler($handler_key, $handler);
}
pcntl_signal(SIGINT, array($this, 'onGracefulSignal'));
pcntl_signal(SIGUSR2, array($this, 'onNotifySignal'));
// Without discard mode, this consumes unbounded amounts of memory. Keep
// memory bounded.
PhutilServiceProfiler::getInstance()->enableDiscardMode();
$this->beginStdoutCapture();
}
final public function __destruct() {
$this->endStdoutCapture();
}
final public function stillWorking() {
$this->emitOverseerMessage(self::MESSAGETYPE_HEARTBEAT, null);
if ($this->traceMemory) {
$daemon = get_class($this);
fprintf(
STDERR,
"%s %s %s\n",
'<RAMS>',
$daemon,
pht(
'Memory Usage: %s KB',
new PhutilNumber(memory_get_usage() / 1024, 1)));
}
}
final public function shouldExit() {
return $this->inGracefulShutdown;
}
final protected function shouldHibernate($duration) {
// Don't hibernate if we don't have very long to sleep.
if ($duration < 30) {
return false;
}
// Never hibernate if we're part of a pool and could scale down instead.
// We only hibernate the last process to drop the pool size to zero.
if ($this->getScaledownDuration()) {
return false;
}
// Don't hibernate for too long.
$duration = min($duration, phutil_units('3 minutes in seconds'));
$this->emitOverseerMessage(
self::MESSAGETYPE_HIBERNATE,
array(
'duration' => $duration,
));
$this->log(
pht(
'Preparing to hibernate for %s second(s).',
new PhutilNumber($duration)));
return true;
}
final protected function sleep($duration) {
$this->notifyReceived = false;
$this->willSleep($duration);
$this->stillWorking();
$scale_down = $this->getScaledownDuration();
$max_sleep = 60;
if ($scale_down) {
$max_sleep = min($max_sleep, $scale_down);
}
if ($scale_down) {
if ($this->workState == self::WORKSTATE_IDLE) {
$dur = $this->getIdleDuration();
$this->log(pht('Idle for %s seconds.', $dur));
}
}
while ($duration > 0 &&
!$this->notifyReceived &&
!$this->shouldExit()) {
// If this is an autoscaling clone and we've been idle for too long,
// we're going to scale the pool down by exiting and not restarting. The
// DOWN message tells the overseer that we don't want to be restarted.
if ($scale_down) {
if ($this->workState == self::WORKSTATE_IDLE) {
if ($this->idleSince && ($this->idleSince + $scale_down < time())) {
$this->inGracefulShutdown = true;
$this->emitOverseerMessage(self::MESSAGETYPE_DOWN, null);
$this->log(
pht(
'Daemon was idle for more than %s second(s), '.
'scaling pool down.',
new PhutilNumber($scale_down)));
break;
}
}
}
sleep(min($duration, $max_sleep));
$duration -= $max_sleep;
$this->stillWorking();
}
}
protected function willSleep($duration) {
return;
}
public static function onTermSignal($signo) {
self::didCatchSignal($signo);
}
final protected function getArgv() {
return $this->argv;
}
final public function execute() {
$this->willRun();
$this->run();
}
abstract protected function run();
final public function setTraceMemory() {
$this->traceMemory = true;
return $this;
}
final public function getTraceMemory() {
return $this->traceMemory;
}
final public function setTraceMode() {
$this->traceMode = true;
PhutilServiceProfiler::installEchoListener();
PhutilConsole::getConsole()->getServer()->setEnableLog(true);
$this->didSetTraceMode();
return $this;
}
final public function getTraceMode() {
return $this->traceMode;
}
final public function onGracefulSignal($signo) {
self::didCatchSignal($signo);
$this->inGracefulShutdown = true;
}
final public function onNotifySignal($signo) {
self::didCatchSignal($signo);
$this->notifyReceived = true;
$this->onNotify($signo);
}
protected function onNotify($signo) {
// This is a hook for subclasses.
}
protected function willRun() {
// This is a hook for subclasses.
}
protected function didSetTraceMode() {
// This is a hook for subclasses.
}
final protected function log($message) {
if ($this->verbose) {
$daemon = get_class($this);
fprintf(STDERR, "%s %s %s\n", '<VERB>', $daemon, $message);
}
}
private static function didCatchSignal($signo) {
$signame = phutil_get_signal_name($signo);
fprintf(
STDERR,
"%s Caught signal %s (%s).\n",
'<SGNL>',
$signo,
$signame);
}
/* -( Communicating With the Overseer )------------------------------------ */
private function beginStdoutCapture() {
ob_start(array($this, 'didReceiveStdout'), 2);
}
private function endStdoutCapture() {
ob_end_flush();
}
public function didReceiveStdout($data) {
if (!strlen($data)) {
return '';
}
return $this->encodeOverseerMessage(self::MESSAGETYPE_STDOUT, $data);
}
private function encodeOverseerMessage($type, $data) {
$structure = array($type);
if ($data !== null) {
$structure[] = $data;
}
return json_encode($structure)."\n";
}
private function emitOverseerMessage($type, $data) {
$this->endStdoutCapture();
echo $this->encodeOverseerMessage($type, $data);
$this->beginStdoutCapture();
}
public static function errorListener($event, $value, array $metadata) {
// If the caller has redirected the error log to a file, PHP won't output
// messages to stderr, so the overseer can't capture them. Install a
// listener which just echoes errors to stderr, so the overseer is always
// aware of errors.
$console = PhutilConsole::getConsole();
$message = idx($metadata, 'default_message');
if ($message) {
$console->writeErr("%s\n", $message);
}
if (idx($metadata, 'trace')) {
$trace = PhutilErrorHandler::formatStacktrace($metadata['trace']);
$console->writeErr("%s\n", $trace);
}
}
/* -( Autoscaling )-------------------------------------------------------- */
/**
* Prepare to become busy. This may autoscale the pool up.
*
* This notifies the overseer that the daemon has become busy. If daemons
* that are part of an autoscale pool are continuously busy for a prolonged
* period of time, the overseer may scale up the pool.
*
- * @return this
+ * @return $this
* @task autoscale
*/
protected function willBeginWork() {
if ($this->workState != self::WORKSTATE_BUSY) {
$this->workState = self::WORKSTATE_BUSY;
$this->idleSince = null;
$this->emitOverseerMessage(self::MESSAGETYPE_BUSY, null);
}
return $this;
}
/**
* Prepare to idle. This may autoscale the pool down.
*
* This notifies the overseer that the daemon is no longer busy. If daemons
* that are part of an autoscale pool are idle for a prolonged period of
* time, they may exit to scale the pool down.
*
- * @return this
+ * @return $this
* @task autoscale
*/
protected function willBeginIdle() {
if ($this->workState != self::WORKSTATE_IDLE) {
$this->workState = self::WORKSTATE_IDLE;
$this->idleSince = time();
$this->emitOverseerMessage(self::MESSAGETYPE_IDLE, null);
}
return $this;
}
protected function getIdleDuration() {
if (!$this->idleSince) {
return null;
}
$now = time();
return ($now - $this->idleSince);
}
}
diff --git a/src/infrastructure/daemon/workers/PhabricatorWorker.php b/src/infrastructure/daemon/workers/PhabricatorWorker.php
index c5c8271862..fd215fc21f 100644
--- a/src/infrastructure/daemon/workers/PhabricatorWorker.php
+++ b/src/infrastructure/daemon/workers/PhabricatorWorker.php
@@ -1,328 +1,328 @@
<?php
/**
* @task config Configuring Retries and Failures
*/
abstract class PhabricatorWorker extends Phobject {
private $data;
private static $runAllTasksInProcess = false;
private $queuedTasks = array();
private $currentWorkerTask;
// NOTE: Lower priority numbers execute first. The priority numbers have to
// have the same ordering that IDs do (lowest first) so MySQL can use a
// multipart key across both of them efficiently.
const PRIORITY_ALERTS = 1000;
const PRIORITY_DEFAULT = 2000;
const PRIORITY_COMMIT = 2500;
const PRIORITY_BULK = 3000;
const PRIORITY_INDEX = 3500;
const PRIORITY_IMPORT = 4000;
/**
* Special owner indicating that the task has yielded.
*/
const YIELD_OWNER = '(yield)';
/* -( Configuring Retries and Failures )----------------------------------- */
/**
* Return the number of seconds this worker needs hold a lease on the task for
* while it performs work. For most tasks you can leave this at `null`, which
* will give you a default lease (currently 2 hours).
*
* For tasks which may take a very long time to complete, you should return
* an upper bound on the amount of time the task may require.
*
* @return int|null Number of seconds this task needs to remain leased for,
* or null for a default lease.
*
* @task config
*/
public function getRequiredLeaseTime() {
return null;
}
/**
* Return the maximum number of times this task may be retried before it is
* considered permanently failed. By default, tasks retry indefinitely. You
* can throw a @{class:PhabricatorWorkerPermanentFailureException} to cause an
* immediate permanent failure.
*
* @return int|null Number of times the task will retry before permanent
* failure. Return `null` to retry indefinitely.
*
* @task config
*/
public function getMaximumRetryCount() {
return null;
}
/**
* Return the number of seconds a task should wait after a failure before
* retrying. For most tasks you can leave this at `null`, which will give you
* a short default retry period (currently 60 seconds).
*
* @param PhabricatorWorkerTask $task The task itself. This object is
* probably useful mostly to examine the
* failure count if you want to implement
* staggered retries, or to examine the
* execution exception if you want to react to
* different failures in different ways.
* @return int|null Number of seconds to wait between retries,
* or null for a default retry period
* (currently 60 seconds).
*
* @task config
*/
public function getWaitBeforeRetry(PhabricatorWorkerTask $task) {
return null;
}
public function setCurrentWorkerTask(PhabricatorWorkerTask $task) {
$this->currentWorkerTask = $task;
return $this;
}
public function getCurrentWorkerTask() {
return $this->currentWorkerTask;
}
public function getCurrentWorkerTaskID() {
$task = $this->getCurrentWorkerTask();
if (!$task) {
return null;
}
return $task->getID();
}
abstract protected function doWork();
final public function __construct($data) {
$this->data = $data;
}
final protected function getTaskData() {
return $this->data;
}
final protected function getTaskDataValue($key, $default = null) {
$data = $this->getTaskData();
if (!is_array($data)) {
throw new PhabricatorWorkerPermanentFailureException(
pht('Expected task data to be a dictionary.'));
}
return idx($data, $key, $default);
}
final public function executeTask() {
$this->doWork();
}
final public static function scheduleTask(
$task_class,
$data,
$options = array()) {
PhutilTypeSpec::checkMap(
$options,
array(
'priority' => 'optional int|null',
'objectPHID' => 'optional string|null',
'containerPHID' => 'optional string|null',
'delayUntil' => 'optional int|null',
));
$priority = idx($options, 'priority');
if ($priority === null) {
$priority = self::PRIORITY_DEFAULT;
}
$object_phid = idx($options, 'objectPHID');
$container_phid = idx($options, 'containerPHID');
$task = id(new PhabricatorWorkerActiveTask())
->setTaskClass($task_class)
->setData($data)
->setPriority($priority)
->setObjectPHID($object_phid)
->setContainerPHID($container_phid);
$delay = idx($options, 'delayUntil');
if ($delay) {
$task->setLeaseExpires($delay);
}
if (self::$runAllTasksInProcess) {
// Do the work in-process.
$worker = newv($task_class, array($data));
while (true) {
try {
$worker->executeTask();
$worker->flushTaskQueue();
$task_result = PhabricatorWorkerArchiveTask::RESULT_SUCCESS;
break;
} catch (PhabricatorWorkerPermanentFailureException $ex) {
$proxy = new PhutilProxyException(
pht(
'In-process task ("%s") failed permanently.',
$task_class),
$ex);
phlog($proxy);
$task_result = PhabricatorWorkerArchiveTask::RESULT_FAILURE;
break;
} catch (PhabricatorWorkerYieldException $ex) {
phlog(
pht(
'In-process task "%s" yielded for %s seconds, sleeping...',
$task_class,
$ex->getDuration()));
sleep($ex->getDuration());
}
}
// Now, save a task row and immediately archive it so we can return an
// object with a valid ID.
$task->openTransaction();
$task->save();
$archived = $task->archiveTask($task_result, 0);
$task->saveTransaction();
return $archived;
} else {
$task->save();
return $task;
}
}
public function renderForDisplay(PhabricatorUser $viewer) {
return null;
}
/**
* Set this flag to execute scheduled tasks synchronously, in the same
* process. This is useful for debugging, and otherwise dramatically worse
* in every way imaginable.
*/
public static function setRunAllTasksInProcess($all) {
self::$runAllTasksInProcess = $all;
}
final protected function log($pattern /* , ... */) {
$console = PhutilConsole::getConsole();
$argv = func_get_args();
call_user_func_array(array($console, 'writeLog'), $argv);
return $this;
}
/**
* Queue a task to be executed after this one succeeds.
*
* The followup task will be queued only if this task completes cleanly.
*
* @param string $class Task class to queue.
* @param array $data Data for the followup task.
* @param array $options (optional) Options for the followup task.
- * @return this
+ * @return $this
*/
final protected function queueTask(
$class,
array $data,
array $options = array()) {
$this->queuedTasks[] = array($class, $data, $options);
return $this;
}
/**
* Get tasks queued as followups by @{method:queueTask}.
*
* @return list<tuple<string, wild, int|null>> Queued task specifications.
*/
final protected function getQueuedTasks() {
return $this->queuedTasks;
}
/**
* Schedule any queued tasks, then empty the task queue.
*
* By default, the queue is flushed only if a task succeeds. You can call
* this method to force the queue to flush before failing (for example, if
* you are using queues to improve locking behavior).
*
* @param map<string, wild> $defaults (optional) Default options.
*/
final public function flushTaskQueue($defaults = array()) {
foreach ($this->getQueuedTasks() as $task) {
list($class, $data, $options) = $task;
$options = $options + $defaults;
self::scheduleTask($class, $data, $options);
}
$this->queuedTasks = array();
}
/**
* Awaken tasks that have yielded.
*
* Reschedules the specified tasks if they are currently queued in a yielded,
* unleased, unretried state so they'll execute sooner. This can let the
* queue avoid unnecessary waits.
*
* This method does not provide any assurances about when these tasks will
* execute, or even guarantee that it will have any effect at all.
*
* @param list<id> $ids List of task IDs to try to awaken.
* @return void
*/
final public static function awakenTaskIDs(array $ids) {
if (!$ids) {
return;
}
$table = new PhabricatorWorkerActiveTask();
$conn_w = $table->establishConnection('w');
// NOTE: At least for now, we're keeping these tasks yielded, just
// pretending that they threw a shorter yield than they really did.
// Overlap the windows here to handle minor client/server time differences
// and because it's likely correct to push these tasks to the head of their
// respective priorities. There is a good chance they are ready to execute.
$window = phutil_units('1 hour in seconds');
$epoch_ago = (PhabricatorTime::getNow() - $window);
queryfx(
$conn_w,
'UPDATE %T SET leaseExpires = %d
WHERE id IN (%Ld)
AND leaseOwner = %s
AND leaseExpires > %d
AND failureCount = 0',
$table->getTableName(),
$epoch_ago,
$ids,
self::YIELD_OWNER,
$epoch_ago);
}
protected function newContentSource() {
return PhabricatorContentSource::newForSource(
PhabricatorDaemonContentSource::SOURCECONST);
}
}
diff --git a/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php b/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php
index 697d89a168..b2bf7d0503 100644
--- a/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php
+++ b/src/infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php
@@ -1,345 +1,345 @@
<?php
/**
* Select and lease tasks from the worker task queue.
*/
final class PhabricatorWorkerLeaseQuery extends PhabricatorQuery {
const PHASE_LEASED = 'leased';
const PHASE_UNLEASED = 'unleased';
const PHASE_EXPIRED = 'expired';
private $ids;
private $objectPHIDs;
private $limit;
private $skipLease;
private $leased = false;
public static function getDefaultWaitBeforeRetry() {
return phutil_units('5 minutes in seconds');
}
public static function getDefaultLeaseDuration() {
return phutil_units('2 hours in seconds');
}
/**
* Set this flag to select tasks from the top of the queue without leasing
* them.
*
* This can be used to show which tasks are coming up next without altering
* the queue's behavior.
*
* @param bool $skip True to skip the lease acquisition step.
*/
public function setSkipLease($skip) {
$this->skipLease = $skip;
return $this;
}
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withObjectPHIDs(array $phids) {
$this->objectPHIDs = $phids;
return $this;
}
/**
* Select only leased tasks, only unleased tasks, or both types of task.
*
* By default, queries select only unleased tasks (equivalent to passing
* `false` to this method). You can pass `true` to select only leased tasks,
* or `null` to ignore the lease status of tasks.
*
* If your result set potentially includes leased tasks, you must disable
* leasing using @{method:setSkipLease}. These options are intended for use
* when displaying task status information.
*
* @param mixed $leased `true` to select only leased tasks, `false` to select
* only unleased tasks (default), or `null` to select both.
- * @return this
+ * @return $this
*/
public function withLeasedTasks($leased) {
$this->leased = $leased;
return $this;
}
public function setLimit($limit) {
$this->limit = $limit;
return $this;
}
public function execute() {
if (!$this->limit) {
throw new Exception(
pht('You must %s when leasing tasks.', 'setLimit()'));
}
if ($this->leased !== false) {
if (!$this->skipLease) {
throw new Exception(
pht(
'If you potentially select leased tasks using %s, '.
'you MUST disable lease acquisition by calling %s.',
'withLeasedTasks()',
'setSkipLease()'));
}
}
$task_table = new PhabricatorWorkerActiveTask();
$taskdata_table = new PhabricatorWorkerTaskData();
$lease_ownership_name = $this->getLeaseOwnershipName();
$conn_w = $task_table->establishConnection('w');
// Try to satisfy the request from new, unleased tasks first. If we don't
// find enough tasks, try tasks with expired leases (i.e., tasks which have
// previously failed).
// If we're selecting leased tasks, look for them first.
$phases = array();
if ($this->leased !== false) {
$phases[] = self::PHASE_LEASED;
}
if ($this->leased !== true) {
$phases[] = self::PHASE_UNLEASED;
$phases[] = self::PHASE_EXPIRED;
}
$limit = $this->limit;
$leased = 0;
$task_ids = array();
foreach ($phases as $phase) {
// NOTE: If we issue `UPDATE ... WHERE ... ORDER BY id ASC`, the query
// goes very, very slowly. The `ORDER BY` triggers this, although we get
// the same apparent results without it. Without the ORDER BY, binary
// read slaves complain that the query isn't repeatable. To avoid both
// problems, do a SELECT and then an UPDATE.
$rows = queryfx_all(
$conn_w,
'SELECT id, leaseOwner FROM %T %Q %Q %Q',
$task_table->getTableName(),
$this->buildCustomWhereClause($conn_w, $phase),
$this->buildOrderClause($conn_w, $phase),
$this->buildLimitClause($conn_w, $limit - $leased));
// NOTE: Sometimes, we'll race with another worker and they'll grab
// this task before we do. We could reduce how often this happens by
// selecting more tasks than we need, then shuffling them and trying
// to lock only the number we're actually after. However, the amount
// of time workers spend here should be very small relative to their
// total runtime, so keep it simple for the moment.
if ($rows) {
if ($this->skipLease) {
$leased += count($rows);
$task_ids += array_fuse(ipull($rows, 'id'));
} else {
queryfx(
$conn_w,
'UPDATE %T task
SET leaseOwner = %s, leaseExpires = UNIX_TIMESTAMP() + %d
%Q',
$task_table->getTableName(),
$lease_ownership_name,
self::getDefaultLeaseDuration(),
$this->buildUpdateWhereClause($conn_w, $phase, $rows));
$leased += $conn_w->getAffectedRows();
}
if ($leased == $limit) {
break;
}
}
}
if (!$leased) {
return array();
}
if ($this->skipLease) {
$selection_condition = qsprintf(
$conn_w,
'task.id IN (%Ld)',
$task_ids);
} else {
$selection_condition = qsprintf(
$conn_w,
'task.leaseOwner = %s AND leaseExpires > UNIX_TIMESTAMP()',
$lease_ownership_name);
}
$data = queryfx_all(
$conn_w,
'SELECT task.*, taskdata.data _taskData, UNIX_TIMESTAMP() _serverTime
FROM %T task LEFT JOIN %T taskdata
ON taskdata.id = task.dataID
WHERE %Q %Q %Q',
$task_table->getTableName(),
$taskdata_table->getTableName(),
$selection_condition,
$this->buildOrderClause($conn_w, $phase),
$this->buildLimitClause($conn_w, $limit));
$tasks = $task_table->loadAllFromArray($data);
$tasks = mpull($tasks, null, 'getID');
foreach ($data as $row) {
$tasks[$row['id']]->setServerTime($row['_serverTime']);
if ($row['_taskData']) {
$task_data = json_decode($row['_taskData'], true);
} else {
$task_data = null;
}
$tasks[$row['id']]->setData($task_data);
}
if ($this->skipLease) {
// Reorder rows into the original phase order if this is a status query.
$tasks = array_select_keys($tasks, $task_ids);
}
return $tasks;
}
protected function buildCustomWhereClause(
AphrontDatabaseConnection $conn,
$phase) {
$where = array();
switch ($phase) {
case self::PHASE_LEASED:
$where[] = qsprintf(
$conn,
'leaseOwner IS NOT NULL');
$where[] = qsprintf(
$conn,
'leaseExpires >= UNIX_TIMESTAMP()');
break;
case self::PHASE_UNLEASED:
$where[] = qsprintf(
$conn,
'leaseOwner IS NULL');
break;
case self::PHASE_EXPIRED:
$where[] = qsprintf(
$conn,
'leaseExpires < UNIX_TIMESTAMP()');
break;
default:
throw new Exception(pht("Unknown phase '%s'!", $phase));
}
if ($this->ids !== null) {
$where[] = qsprintf($conn, 'id IN (%Ld)', $this->ids);
}
if ($this->objectPHIDs !== null) {
$where[] = qsprintf($conn, 'objectPHID IN (%Ls)', $this->objectPHIDs);
}
return $this->formatWhereClause($conn, $where);
}
private function buildUpdateWhereClause(
AphrontDatabaseConnection $conn,
$phase,
array $rows) {
$where = array();
// NOTE: This is basically working around the MySQL behavior that
// `IN (NULL)` doesn't match NULL.
switch ($phase) {
case self::PHASE_LEASED:
throw new Exception(
pht(
'Trying to lease tasks selected in the leased phase! This is '.
'intended to be impossible.'));
case self::PHASE_UNLEASED:
$where[] = qsprintf($conn, 'leaseOwner IS NULL');
$where[] = qsprintf($conn, 'id IN (%Ld)', ipull($rows, 'id'));
break;
case self::PHASE_EXPIRED:
$in = array();
foreach ($rows as $row) {
$in[] = qsprintf(
$conn,
'(id = %d AND leaseOwner = %s)',
$row['id'],
$row['leaseOwner']);
}
$where[] = qsprintf($conn, '%LO', $in);
break;
default:
throw new Exception(pht('Unknown phase "%s"!', $phase));
}
return $this->formatWhereClause($conn, $where);
}
private function buildOrderClause(AphrontDatabaseConnection $conn_w, $phase) {
switch ($phase) {
case self::PHASE_LEASED:
// Ideally we'd probably order these by lease acquisition time, but
// we don't have that handy and this is a good approximation.
return qsprintf($conn_w, 'ORDER BY priority ASC, id ASC');
case self::PHASE_UNLEASED:
// When selecting new tasks, we want to consume them in order of
// increasing priority (and then FIFO).
return qsprintf($conn_w, 'ORDER BY priority ASC, id ASC');
case self::PHASE_EXPIRED:
// When selecting failed tasks, we want to consume them in roughly
// FIFO order of their failures, which is not necessarily their original
// queue order.
// Particularly, this is important for tasks which use soft failures to
// indicate that they are waiting on other tasks to complete: we need to
// push them to the end of the queue after they fail, at least on
// average, so we don't deadlock retrying the same blocked task over
// and over again.
return qsprintf($conn_w, 'ORDER BY leaseExpires ASC');
default:
throw new Exception(pht('Unknown phase "%s"!', $phase));
}
}
private function buildLimitClause(AphrontDatabaseConnection $conn_w, $limit) {
return qsprintf($conn_w, 'LIMIT %d', $limit);
}
private function getLeaseOwnershipName() {
static $sequence = 0;
// TODO: If the host name is very long, this can overflow the 64-character
// column, so we pick just the first part of the host name. It might be
// useful to just use a random hash as the identifier instead and put the
// pid / time / host (which are somewhat useful diagnostically) elsewhere.
// Likely, we could store a daemon ID instead and use that to identify
// when and where code executed. See T6742.
$host = php_uname('n');
$host = id(new PhutilUTF8StringTruncator())
->setMaximumBytes(32)
->setTerminator('...')
->truncateString($host);
$parts = array(
getmypid(),
time(),
$host,
++$sequence,
);
return implode(':', $parts);
}
}
diff --git a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
index fa2f04f5a1..e0f749af32 100644
--- a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
+++ b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
@@ -1,241 +1,241 @@
<?php
final class PhabricatorWorkerTriggerQuery
extends PhabricatorPolicyAwareQuery {
// NOTE: This is a PolicyAware query so it can work with other infrastructure
// like handles; triggers themselves are low-level and do not have
// meaningful policies.
const ORDER_ID = 'id';
const ORDER_EXECUTION = 'execution';
const ORDER_VERSION = 'version';
private $ids;
private $phids;
private $versionMin;
private $versionMax;
private $nextEpochMin;
private $nextEpochMax;
private $needEvents;
private $order = self::ORDER_ID;
public function getQueryApplicationClass() {
return null;
}
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withVersionBetween($min, $max) {
$this->versionMin = $min;
$this->versionMax = $max;
return $this;
}
public function withNextEventBetween($min, $max) {
$this->nextEpochMin = $min;
$this->nextEpochMax = $max;
return $this;
}
public function needEvents($need_events) {
$this->needEvents = $need_events;
return $this;
}
/**
* Set the result order.
*
* Note that using `ORDER_EXECUTION` will also filter results to include only
* triggers which have been scheduled to execute. You should not use this
* ordering when querying for specific triggers, e.g. by ID or PHID.
*
* @param const $order Result order.
- * @return this
+ * @return $this
*/
public function setOrder($order) {
$this->order = $order;
return $this;
}
protected function nextPage(array $page) {
// NOTE: We don't implement paging because we don't currently ever need
// it and paging ORDER_EXECUTION is a hassle.
// (Before T13266, we raised an exception here, but since "nextPage()" is
// now called even if we don't page we can't do that anymore. Just do
// nothing instead.)
return null;
}
protected function loadPage() {
$task_table = new PhabricatorWorkerTrigger();
$conn_r = $task_table->establishConnection('r');
$rows = queryfx_all(
$conn_r,
'SELECT t.* FROM %T t %Q %Q %Q %Q',
$task_table->getTableName(),
$this->buildJoinClause($conn_r),
$this->buildWhereClause($conn_r),
$this->buildOrderClause($conn_r),
$this->buildLimitClause($conn_r));
$triggers = $task_table->loadAllFromArray($rows);
if ($triggers) {
if ($this->needEvents) {
$ids = mpull($triggers, 'getID');
$events = id(new PhabricatorWorkerTriggerEvent())->loadAllWhere(
'triggerID IN (%Ld)',
$ids);
$events = mpull($events, null, 'getTriggerID');
foreach ($triggers as $key => $trigger) {
$event = idx($events, $trigger->getID());
$trigger->attachEvent($event);
}
}
foreach ($triggers as $key => $trigger) {
$clock_class = $trigger->getClockClass();
if (!is_subclass_of($clock_class, 'PhabricatorTriggerClock')) {
unset($triggers[$key]);
continue;
}
try {
$argv = array($trigger->getClockProperties());
$clock = newv($clock_class, $argv);
} catch (Exception $ex) {
unset($triggers[$key]);
continue;
}
$trigger->attachClock($clock);
}
foreach ($triggers as $key => $trigger) {
$action_class = $trigger->getActionClass();
if (!is_subclass_of($action_class, 'PhabricatorTriggerAction')) {
unset($triggers[$key]);
continue;
}
try {
$argv = array($trigger->getActionProperties());
$action = newv($action_class, $argv);
} catch (Exception $ex) {
unset($triggers[$key]);
continue;
}
$trigger->attachAction($action);
}
}
return $triggers;
}
protected function buildJoinClause(AphrontDatabaseConnection $conn) {
$joins = array();
if (($this->nextEpochMin !== null) ||
($this->nextEpochMax !== null) ||
($this->order == self::ORDER_EXECUTION)) {
$joins[] = qsprintf(
$conn,
'JOIN %T e ON e.triggerID = t.id',
id(new PhabricatorWorkerTriggerEvent())->getTableName());
}
if ($joins) {
return qsprintf($conn, '%LJ', $joins);
} else {
return qsprintf($conn, '');
}
}
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
't.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
't.phid IN (%Ls)',
$this->phids);
}
if ($this->versionMin !== null) {
$where[] = qsprintf(
$conn,
't.triggerVersion >= %d',
$this->versionMin);
}
if ($this->versionMax !== null) {
$where[] = qsprintf(
$conn,
't.triggerVersion <= %d',
$this->versionMax);
}
if ($this->nextEpochMin !== null) {
$where[] = qsprintf(
$conn,
'e.nextEventEpoch >= %d',
$this->nextEpochMin);
}
if ($this->nextEpochMax !== null) {
$where[] = qsprintf(
$conn,
'e.nextEventEpoch <= %d',
$this->nextEpochMax);
}
return $this->formatWhereClause($conn, $where);
}
private function buildOrderClause(AphrontDatabaseConnection $conn_r) {
switch ($this->order) {
case self::ORDER_ID:
return qsprintf(
$conn_r,
'ORDER BY id DESC');
case self::ORDER_EXECUTION:
return qsprintf(
$conn_r,
'ORDER BY e.nextEventEpoch ASC, e.id ASC');
case self::ORDER_VERSION:
return qsprintf(
$conn_r,
'ORDER BY t.triggerVersion ASC');
default:
throw new Exception(
pht(
'Unsupported order "%s".',
$this->order));
}
}
}
diff --git a/src/infrastructure/diff/PhabricatorDifferenceEngine.php b/src/infrastructure/diff/PhabricatorDifferenceEngine.php
index 3326a939d1..804a6604f8 100644
--- a/src/infrastructure/diff/PhabricatorDifferenceEngine.php
+++ b/src/infrastructure/diff/PhabricatorDifferenceEngine.php
@@ -1,261 +1,261 @@
<?php
/**
* Utility class which encapsulates some shared behavior between different
* applications which render diffs.
*
* @task config Configuring the Engine
* @task diff Generating Diffs
*/
final class PhabricatorDifferenceEngine extends Phobject {
private $oldName;
private $newName;
private $normalize;
/* -( Configuring the Engine )--------------------------------------------- */
/**
* Set the name to identify the old file with. Primarily cosmetic.
*
* @param string $old_name Old file name.
- * @return this
+ * @return $this
* @task config
*/
public function setOldName($old_name) {
$this->oldName = $old_name;
return $this;
}
/**
* Set the name to identify the new file with. Primarily cosmetic.
*
* @param string $new_name New file name.
- * @return this
+ * @return $this
* @task config
*/
public function setNewName($new_name) {
$this->newName = $new_name;
return $this;
}
public function setNormalize($normalize) {
$this->normalize = $normalize;
return $this;
}
public function getNormalize() {
return $this->normalize;
}
/* -( Generating Diffs )--------------------------------------------------- */
/**
* Generate a raw diff from two raw files. This is a lower-level API than
* @{method:generateChangesetFromFileContent}, but may be useful if you need
* to use a custom parser configuration, as with Diffusion.
*
* @param string $old Entire previous file content.
* @param string $new Entire current file content.
* @return string Raw diff between the two files.
* @task diff
*/
public function generateRawDiffFromFileContent($old, $new) {
$options = array();
// Generate diffs with full context.
$options[] = '-U65535';
$old_name = nonempty($this->oldName, '/dev/universe').' 9999-99-99';
$new_name = nonempty($this->newName, '/dev/universe').' 9999-99-99';
$options[] = '-L';
$options[] = $old_name;
$options[] = '-L';
$options[] = $new_name;
$normalize = $this->getNormalize();
if ($normalize) {
$old = $this->normalizeFile($old);
$new = $this->normalizeFile($new);
}
$old_tmp = new TempFile();
$new_tmp = new TempFile();
Filesystem::writeFile($old_tmp, $old);
Filesystem::writeFile($new_tmp, $new);
list($err, $diff) = exec_manual(
'diff %Ls %s %s',
$options,
$old_tmp,
$new_tmp);
if (!$err) {
// This indicates that the two files are the same. Build a synthetic,
// changeless diff so that we can still render the raw, unchanged file
// instead of being forced to just say "this file didn't change" since we
// don't have the content.
$entire_file = explode("\n", $old);
foreach ($entire_file as $k => $line) {
$entire_file[$k] = ' '.$line;
}
$len = count($entire_file);
$entire_file = implode("\n", $entire_file);
// TODO: If both files were identical but missing newlines, we probably
// get this wrong. Unclear if it ever matters.
// This is a bit hacky but the diff parser can handle it.
$diff = "--- {$old_name}\n".
"+++ {$new_name}\n".
"@@ -1,{$len} +1,{$len} @@\n".
$entire_file."\n";
}
return $diff;
}
/**
* Generate an @{class:DifferentialChangeset} from two raw files. This is
* principally useful because you can feed the output to
* @{class:DifferentialChangesetParser} in order to render it.
*
* @param string $old Entire previous file content.
* @param string $new Entire current file content.
* @return @{class:DifferentialChangeset} Synthetic changeset.
* @task diff
*/
public function generateChangesetFromFileContent($old, $new) {
$diff = $this->generateRawDiffFromFileContent($old, $new);
$changes = id(new ArcanistDiffParser())->parseDiff($diff);
$diff = DifferentialDiff::newEphemeralFromRawChanges(
$changes);
return head($diff->getChangesets());
}
private function normalizeFile($corpus) {
// We can freely apply any other transformations we want to here: we have
// no constraints on what we need to preserve. If we normalize every line
// to "cat", the diff will still work, the alignment of the "-" / "+"
// lines will just be very hard to read.
// In general, we'll make the diff better if we normalize two lines that
// humans think are the same.
// We'll make the diff worse if we normalize two lines that humans think
// are different.
// Strip all whitespace present anywhere in the diff, since humans never
// consider whitespace changes to alter the line into a "different line"
// even when they're semantic (e.g., in a string constant). This covers
// indentation changes, trailing whitepspace, and formatting changes
// like "+/-".
$corpus = preg_replace('/[ \t]/', '', $corpus);
return $corpus;
}
public static function applyIntralineDiff($str, $intra_stack) {
$buf = '';
$p = $s = $e = 0; // position, start, end
$highlight = $tag = $ent = false;
$highlight_o = '<span class="bright">';
$highlight_c = '</span>';
$depth_in = '<span class="depth-in">';
$depth_out = '<span class="depth-out">';
$is_html = false;
if ($str instanceof PhutilSafeHTML) {
$is_html = true;
$str = $str->getHTMLContent();
}
$n = strlen($str);
for ($i = 0; $i < $n; $i++) {
if ($p == $e) {
do {
if (empty($intra_stack)) {
$buf .= substr($str, $i);
break 2;
}
$stack = array_shift($intra_stack);
$s = $e;
$e += $stack[1];
} while ($stack[0] === 0);
switch ($stack[0]) {
case '>':
$open_tag = $depth_in;
break;
case '<':
$open_tag = $depth_out;
break;
default:
$open_tag = $highlight_o;
break;
}
}
if (!$highlight && !$tag && !$ent && $p == $s) {
$buf .= $open_tag;
$highlight = true;
}
if ($str[$i] == '<') {
$tag = true;
if ($highlight) {
$buf .= $highlight_c;
}
}
if (!$tag) {
if ($str[$i] == '&') {
$ent = true;
}
if ($ent && $str[$i] == ';') {
$ent = false;
}
if (!$ent) {
$p++;
}
}
$buf .= $str[$i];
if ($tag && $str[$i] == '>') {
$tag = false;
if ($highlight) {
$buf .= $open_tag;
}
}
if ($highlight && ($p == $e || $i == $n - 1)) {
$buf .= $highlight_c;
$highlight = false;
}
}
if ($is_html) {
return phutil_safe_html($buf);
}
return $buf;
}
}
diff --git a/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php b/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php
index e1410cfd13..28b199e657 100644
--- a/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php
+++ b/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php
@@ -1,405 +1,405 @@
<?php
/**
* Add and remove edges between objects. You can use
* @{class:PhabricatorEdgeQuery} to load object edges. For more information
* on edges, see @{article:Using Edges}.
*
* Edges are not directly policy aware, and this editor makes low-level changes
* below the policy layer.
*
* name=Adding Edges
* $src = $earth_phid;
* $type = PhabricatorEdgeConfig::TYPE_BODY_HAS_SATELLITE;
* $dst = $moon_phid;
*
* id(new PhabricatorEdgeEditor())
* ->addEdge($src, $type, $dst)
* ->save();
*
* @task edit Editing Edges
* @task cycles Cycle Prevention
* @task internal Internals
*/
final class PhabricatorEdgeEditor extends Phobject {
private $addEdges = array();
private $remEdges = array();
private $openTransactions = array();
/* -( Editing Edges )------------------------------------------------------ */
/**
* Add a new edge (possibly also adding its inverse). Changes take effect when
* you call @{method:save}. If the edge already exists, it will not be
* overwritten, but if data is attached to the edge it will be updated.
* Removals queued with @{method:removeEdge} are executed before
* adds, so the effect of removing and adding the same edge is to overwrite
* any existing edge.
*
* The `$options` parameter accepts these values:
*
* - `data` Optional, data to write onto the edge.
* - `inverse_data` Optional, data to write on the inverse edge. If not
* provided, `data` will be written.
*
* @param phid $src Source object PHID.
* @param const $type Edge type constant.
* @param phid $dst Destination object PHID.
* @param map $options (optional) Options map (see documentation).
- * @return this
+ * @return $this
*
* @task edit
*/
public function addEdge($src, $type, $dst, array $options = array()) {
foreach ($this->buildEdgeSpecs($src, $type, $dst, $options) as $spec) {
$this->addEdges[] = $spec;
}
return $this;
}
/**
* Remove an edge (possibly also removing its inverse). Changes take effect
* when you call @{method:save}. If an edge does not exist, the removal
* will be ignored. Edges are added after edges are removed, so the effect of
* a remove plus an add is to overwrite.
*
* @param phid $src Source object PHID.
* @param const $type Edge type constant.
* @param phid $dst Destination object PHID.
- * @return this
+ * @return $this
*
* @task edit
*/
public function removeEdge($src, $type, $dst) {
foreach ($this->buildEdgeSpecs($src, $type, $dst) as $spec) {
$this->remEdges[] = $spec;
}
return $this;
}
/**
* Apply edge additions and removals queued by @{method:addEdge} and
* @{method:removeEdge}. Note that transactions are opened, all additions and
* removals are executed, and then transactions are saved. Thus, in some cases
* it may be slightly more efficient to perform multiple edit operations
* (e.g., adds followed by removals) if their outcomes are not dependent,
* since transactions will not be held open as long.
*
* @task edit
*/
public function save() {
$cycle_types = $this->getPreventCyclesEdgeTypes();
$locks = array();
$caught = null;
try {
// NOTE: We write edge data first, before doing any transactions, since
// it's OK if we just leave it hanging out in space unattached to
// anything.
$this->writeEdgeData();
// If we're going to perform cycle detection, lock the edge type before
// doing edits.
if ($cycle_types) {
$src_phids = ipull($this->addEdges, 'src');
foreach ($cycle_types as $cycle_type) {
$key = 'edge.cycle:'.$cycle_type;
$locks[] = PhabricatorGlobalLock::newLock($key)->lock(15);
}
}
static $id = 0;
$id++;
// NOTE: Removes first, then adds, so that "remove + add" is a useful
// operation meaning "overwrite".
$this->executeRemoves();
$this->executeAdds();
foreach ($cycle_types as $cycle_type) {
$this->detectCycles($src_phids, $cycle_type);
}
$this->saveTransactions();
} catch (Exception $ex) {
$caught = $ex;
}
if ($caught) {
$this->killTransactions();
}
foreach ($locks as $lock) {
$lock->unlock();
}
if ($caught) {
throw $caught;
}
}
/* -( Internals )---------------------------------------------------------- */
/**
* Build the specification for an edge operation, and possibly build its
* inverse as well.
*
* @task internal
*/
private function buildEdgeSpecs($src, $type, $dst, array $options = array()) {
$data = array();
if (!empty($options['data'])) {
$data['data'] = $options['data'];
}
$src_type = phid_get_type($src);
$dst_type = phid_get_type($dst);
$specs = array();
$specs[] = array(
'src' => $src,
'src_type' => $src_type,
'dst' => $dst,
'dst_type' => $dst_type,
'type' => $type,
'data' => $data,
);
$type_obj = PhabricatorEdgeType::getByConstant($type);
$inverse = $type_obj->getInverseEdgeConstant();
if ($inverse !== null) {
// If `inverse_data` is set, overwrite the edge data. Normally, just
// write the same data to the inverse edge.
if (array_key_exists('inverse_data', $options)) {
$data['data'] = $options['inverse_data'];
}
$specs[] = array(
'src' => $dst,
'src_type' => $dst_type,
'dst' => $src,
'dst_type' => $src_type,
'type' => $inverse,
'data' => $data,
);
}
return $specs;
}
/**
* Write edge data.
*
* @task internal
*/
private function writeEdgeData() {
$adds = $this->addEdges;
$writes = array();
foreach ($adds as $key => $edge) {
if ($edge['data']) {
$writes[] = array($key, $edge['src_type'], json_encode($edge['data']));
}
}
foreach ($writes as $write) {
list($key, $src_type, $data) = $write;
$conn_w = PhabricatorEdgeConfig::establishConnection($src_type, 'w');
queryfx(
$conn_w,
'INSERT INTO %T (data) VALUES (%s)',
PhabricatorEdgeConfig::TABLE_NAME_EDGEDATA,
$data);
$this->addEdges[$key]['data_id'] = $conn_w->getInsertID();
}
}
/**
* Add queued edges.
*
* @task internal
*/
private function executeAdds() {
$adds = $this->addEdges;
$adds = igroup($adds, 'src_type');
// Assign stable sequence numbers to each edge, so we have a consistent
// ordering across edges by source and type.
foreach ($adds as $src_type => $edges) {
$edges_by_src = igroup($edges, 'src');
foreach ($edges_by_src as $src => $src_edges) {
$seq = 0;
foreach ($src_edges as $key => $edge) {
$src_edges[$key]['seq'] = $seq++;
$src_edges[$key]['dateCreated'] = time();
}
$edges_by_src[$src] = $src_edges;
}
$adds[$src_type] = array_mergev($edges_by_src);
}
$inserts = array();
foreach ($adds as $src_type => $edges) {
$conn_w = PhabricatorEdgeConfig::establishConnection($src_type, 'w');
$sql = array();
foreach ($edges as $edge) {
$sql[] = qsprintf(
$conn_w,
'(%s, %d, %s, %d, %d, %nd)',
$edge['src'],
$edge['type'],
$edge['dst'],
$edge['dateCreated'],
$edge['seq'],
idx($edge, 'data_id'));
}
$inserts[] = array($conn_w, $sql);
}
foreach ($inserts as $insert) {
list($conn_w, $sql) = $insert;
$conn_w->openTransaction();
$this->openTransactions[] = $conn_w;
foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) {
queryfx(
$conn_w,
'INSERT INTO %T (src, type, dst, dateCreated, seq, dataID)
VALUES %LQ ON DUPLICATE KEY UPDATE dataID = VALUES(dataID)',
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
$chunk);
}
}
}
/**
* Remove queued edges.
*
* @task internal
*/
private function executeRemoves() {
$rems = $this->remEdges;
$rems = igroup($rems, 'src_type');
$deletes = array();
foreach ($rems as $src_type => $edges) {
$conn_w = PhabricatorEdgeConfig::establishConnection($src_type, 'w');
$sql = array();
foreach ($edges as $edge) {
$sql[] = qsprintf(
$conn_w,
'(src = %s AND type = %d AND dst = %s)',
$edge['src'],
$edge['type'],
$edge['dst']);
}
$deletes[] = array($conn_w, $sql);
}
foreach ($deletes as $delete) {
list($conn_w, $sql) = $delete;
$conn_w->openTransaction();
$this->openTransactions[] = $conn_w;
foreach (array_chunk($sql, 256) as $chunk) {
queryfx(
$conn_w,
'DELETE FROM %T WHERE %LO',
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
$chunk);
}
}
}
/**
* Save open transactions.
*
* @task internal
*/
private function saveTransactions() {
foreach ($this->openTransactions as $key => $conn_w) {
$conn_w->saveTransaction();
unset($this->openTransactions[$key]);
}
}
private function killTransactions() {
foreach ($this->openTransactions as $key => $conn_w) {
$conn_w->killTransaction();
unset($this->openTransactions[$key]);
}
}
/* -( Cycle Prevention )--------------------------------------------------- */
/**
* Get a list of all edge types which are being added, and which we should
* prevent cycles on.
*
* @return list<const> List of edge types which should have cycles prevented.
* @task cycle
*/
private function getPreventCyclesEdgeTypes() {
$edge_types = array();
foreach ($this->addEdges as $edge) {
$edge_types[$edge['type']] = true;
}
foreach ($edge_types as $type => $ignored) {
$type_obj = PhabricatorEdgeType::getByConstant($type);
if (!$type_obj->shouldPreventCycles()) {
unset($edge_types[$type]);
}
}
return array_keys($edge_types);
}
/**
* Detect graph cycles of a given edge type. If the edit introduces a cycle,
* a @{class:PhabricatorEdgeCycleException} is thrown with details.
*
* @return void
* @task cycle
*/
private function detectCycles(array $phids, $edge_type) {
// For simplicity, we just seed the graph with the affected nodes rather
// than seeding it with their edges. To do this, we just add synthetic
// edges from an imaginary '<seed>' node to the known edges.
$graph = id(new PhabricatorEdgeGraph())
->setEdgeType($edge_type)
->addNodes(
array(
'<seed>' => $phids,
))
->loadGraph();
foreach ($phids as $phid) {
$cycle = $graph->detectCycles($phid);
if ($cycle) {
throw new PhabricatorEdgeCycleException($edge_type, $cycle);
}
}
}
}
diff --git a/src/infrastructure/edges/query/PhabricatorEdgeQuery.php b/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
index a33d75cfcc..930c033042 100644
--- a/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
+++ b/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
@@ -1,341 +1,341 @@
<?php
/**
* Load object edges created by @{class:PhabricatorEdgeEditor}.
*
* name=Querying Edges
* $src = $earth_phid;
* $type = PhabricatorEdgeConfig::TYPE_BODY_HAS_SATELLITE;
*
* // Load the earth's satellites.
* $satellite_edges = id(new PhabricatorEdgeQuery())
* ->withSourcePHIDs(array($src))
* ->withEdgeTypes(array($type))
* ->execute();
*
* For more information on edges, see @{article:Using Edges}.
*
* @task config Configuring the Query
* @task exec Executing the Query
* @task internal Internal
*/
final class PhabricatorEdgeQuery extends PhabricatorQuery {
private $sourcePHIDs;
private $destPHIDs;
private $edgeTypes;
private $resultSet;
const ORDER_OLDEST_FIRST = 'order:oldest';
const ORDER_NEWEST_FIRST = 'order:newest';
private $order = self::ORDER_NEWEST_FIRST;
private $needEdgeData;
/* -( Configuring the Query )---------------------------------------------- */
/**
* Find edges originating at one or more source PHIDs. You MUST provide this
* to execute an edge query.
*
* @param list $source_phids List of source PHIDs.
- * @return this
+ * @return $this
*
* @task config
*/
public function withSourcePHIDs(array $source_phids) {
if (!$source_phids) {
throw new Exception(
pht(
'Edge list passed to "withSourcePHIDs(...)" is empty, but it must '.
'be nonempty.'));
}
$this->sourcePHIDs = $source_phids;
return $this;
}
/**
* Find edges terminating at one or more destination PHIDs.
*
* @param list $dest_phids List of destination PHIDs.
- * @return this
+ * @return $this
*
*/
public function withDestinationPHIDs(array $dest_phids) {
$this->destPHIDs = $dest_phids;
return $this;
}
/**
* Find edges of specific types.
*
* @param list $types List of PhabricatorEdgeConfig type constants.
- * @return this
+ * @return $this
*
* @task config
*/
public function withEdgeTypes(array $types) {
$this->edgeTypes = $types;
return $this;
}
/**
* Configure the order edge results are returned in.
*
* @param const $order Order constant.
- * @return this
+ * @return $this
*
* @task config
*/
public function setOrder($order) {
$this->order = $order;
return $this;
}
/**
* When loading edges, also load edge data.
*
* @param bool $need True to load edge data.
- * @return this
+ * @return $this
*
* @task config
*/
public function needEdgeData($need) {
$this->needEdgeData = $need;
return $this;
}
/* -( Executing the Query )------------------------------------------------ */
/**
* Convenience method for loading destination PHIDs with one source and one
* edge type. Equivalent to building a full query, but simplifies a common
* use case.
*
* @param phid $src_phid Source PHID.
* @param const $edge_type Edge type.
* @return list<phid> List of destination PHIDs.
*/
public static function loadDestinationPHIDs($src_phid, $edge_type) {
$edges = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($src_phid))
->withEdgeTypes(array($edge_type))
->execute();
return array_keys($edges[$src_phid][$edge_type]);
}
/**
* Convenience method for loading a single edge's metadata for
* a given source, destination, and edge type. Returns null
* if the edge does not exist or does not have metadata. Builds
* and immediately executes a full query.
*
* @param phid $src_phid Source PHID.
* @param const $edge_type Edge type.
* @param phid $dest_phid Destination PHID.
* @return wild Edge annotation (or null).
*/
public static function loadSingleEdgeData($src_phid, $edge_type, $dest_phid) {
$edges = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($src_phid))
->withEdgeTypes(array($edge_type))
->withDestinationPHIDs(array($dest_phid))
->needEdgeData(true)
->execute();
if (isset($edges[$src_phid][$edge_type][$dest_phid]['data'])) {
return $edges[$src_phid][$edge_type][$dest_phid]['data'];
}
return null;
}
/**
* Load specified edges.
*
* @task exec
*/
public function execute() {
if ($this->sourcePHIDs === null) {
throw new Exception(
pht(
'You must use "withSourcePHIDs()" to query edges.'));
}
$sources = phid_group_by_type($this->sourcePHIDs);
$result = array();
// When a query specifies types, make sure we return data for all queried
// types.
if ($this->edgeTypes) {
foreach ($this->sourcePHIDs as $phid) {
foreach ($this->edgeTypes as $type) {
$result[$phid][$type] = array();
}
}
}
foreach ($sources as $type => $phids) {
$conn_r = PhabricatorEdgeConfig::establishConnection($type, 'r');
$where = $this->buildWhereClause($conn_r);
$order = $this->buildOrderClause($conn_r);
$edges = queryfx_all(
$conn_r,
'SELECT edge.* FROM %T edge %Q %Q',
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
$where,
$order);
if ($this->needEdgeData) {
$data_ids = array_filter(ipull($edges, 'dataID'));
$data_map = array();
if ($data_ids) {
$data_rows = queryfx_all(
$conn_r,
'SELECT edgedata.* FROM %T edgedata WHERE id IN (%Ld)',
PhabricatorEdgeConfig::TABLE_NAME_EDGEDATA,
$data_ids);
foreach ($data_rows as $row) {
$data_map[$row['id']] = idx(
phutil_json_decode($row['data']),
'data');
}
}
foreach ($edges as $key => $edge) {
$edges[$key]['data'] = idx($data_map, $edge['dataID'], array());
}
}
foreach ($edges as $edge) {
$result[$edge['src']][$edge['type']][$edge['dst']] = $edge;
}
}
$this->resultSet = $result;
return $result;
}
/**
* Convenience function for selecting edge destination PHIDs after calling
* execute().
*
* Returns a flat list of PHIDs matching the provided source PHID and type
* filters. By default, the filters are empty so all PHIDs will be returned.
* For example, if you're doing a batch query from several sources, you might
* write code like this:
*
* $query = new PhabricatorEdgeQuery();
* $query->setViewer($viewer);
* $query->withSourcePHIDs(mpull($objects, 'getPHID'));
* $query->withEdgeTypes(array($some_type));
* $query->execute();
*
* // Gets all of the destinations.
* $all_phids = $query->getDestinationPHIDs();
* $handles = id(new PhabricatorHandleQuery())
* ->setViewer($viewer)
* ->withPHIDs($all_phids)
* ->execute();
*
* foreach ($objects as $object) {
* // Get all of the destinations for the given object.
* $dst_phids = $query->getDestinationPHIDs(array($object->getPHID()));
* $object->attachHandles(array_select_keys($handles, $dst_phids));
* }
*
* @param list $src_phids (optional) List of PHIDs to select, or empty to
* select all.
* @param list $types (optional) List of edge types to select, or empty to
* select all.
* @return list<phid> List of matching destination PHIDs.
*/
public function getDestinationPHIDs(
array $src_phids = array(),
array $types = array()) {
if ($this->resultSet === null) {
throw new PhutilInvalidStateException('execute');
}
$result_phids = array();
$set = $this->resultSet;
if ($src_phids) {
$set = array_select_keys($set, $src_phids);
}
foreach ($set as $src => $edges_by_type) {
if ($types) {
$edges_by_type = array_select_keys($edges_by_type, $types);
}
foreach ($edges_by_type as $edges) {
foreach ($edges as $edge_phid => $edge) {
$result_phids[$edge_phid] = true;
}
}
}
return array_keys($result_phids);
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
if ($this->sourcePHIDs) {
$where[] = qsprintf(
$conn,
'edge.src IN (%Ls)',
$this->sourcePHIDs);
}
if ($this->edgeTypes) {
$where[] = qsprintf(
$conn,
'edge.type IN (%Ls)',
$this->edgeTypes);
}
if ($this->destPHIDs) {
// potentially complain if $this->edgeType was not set
$where[] = qsprintf(
$conn,
'edge.dst IN (%Ls)',
$this->destPHIDs);
}
return $this->formatWhereClause($conn, $where);
}
/**
* @task internal
*/
private function buildOrderClause(AphrontDatabaseConnection $conn) {
if ($this->order == self::ORDER_NEWEST_FIRST) {
return qsprintf($conn, 'ORDER BY edge.dateCreated DESC, edge.seq DESC');
} else {
return qsprintf($conn, 'ORDER BY edge.dateCreated ASC, edge.seq ASC');
}
}
}
diff --git a/src/infrastructure/env/PhabricatorScopedEnv.php b/src/infrastructure/env/PhabricatorScopedEnv.php
index 80a9e5893a..e7dd234a1f 100644
--- a/src/infrastructure/env/PhabricatorScopedEnv.php
+++ b/src/infrastructure/env/PhabricatorScopedEnv.php
@@ -1,59 +1,59 @@
<?php
/**
* Scope guard to hold a temporary environment. See @{class:PhabricatorEnv} for
* instructions on use.
*
* @task internal Internals
* @task override Overriding Environment Configuration
*/
final class PhabricatorScopedEnv extends Phobject {
private $key;
private $isPopped = false;
/* -( Overriding Environment Configuration )------------------------------- */
/**
* Override a configuration key in this scope, setting it to a new value.
*
* @param string $key Key to override.
* @param wild $value New value.
- * @return this
+ * @return $this
*
* @task override
*/
public function overrideEnvConfig($key, $value) {
PhabricatorEnv::overrideTestEnvConfig(
$this->key,
$key,
$value);
return $this;
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
public function __construct($stack_key) {
$this->key = $stack_key;
}
/**
* Release the scoped environment.
*
* @return void
* @task internal
*/
public function __destruct() {
if (!$this->isPopped) {
PhabricatorEnv::popTestEnvironment($this->key);
$this->isPopped = true;
}
}
}
diff --git a/src/infrastructure/markup/PhabricatorMarkupEngine.php b/src/infrastructure/markup/PhabricatorMarkupEngine.php
index 60ea048458..c4fea015cd 100644
--- a/src/infrastructure/markup/PhabricatorMarkupEngine.php
+++ b/src/infrastructure/markup/PhabricatorMarkupEngine.php
@@ -1,745 +1,745 @@
<?php
/**
* Manages markup engine selection, configuration, application, caching and
* pipelining.
*
* @{class:PhabricatorMarkupEngine} can be used to render objects which
* implement @{interface:PhabricatorMarkupInterface} in a batched, cache-aware
* way. For example, if you have a list of comments written in remarkup (and
* the objects implement the correct interface) you can render them by first
* building an engine and adding the fields with @{method:addObject}.
*
* $field = 'field:body'; // Field you want to render. Each object exposes
* // one or more fields of markup.
*
* $engine = new PhabricatorMarkupEngine();
* foreach ($comments as $comment) {
* $engine->addObject($comment, $field);
* }
*
* Now, call @{method:process} to perform the actual cache/rendering
* step. This is a heavyweight call which does batched data access and
* transforms the markup into output.
*
* $engine->process();
*
* Finally, do something with the results:
*
* $results = array();
* foreach ($comments as $comment) {
* $results[] = $engine->getOutput($comment, $field);
* }
*
* If you have a single object to render, you can use the convenience method
* @{method:renderOneObject}.
*
* @task markup Markup Pipeline
* @task engine Engine Construction
*/
final class PhabricatorMarkupEngine extends Phobject {
private $objects = array();
private $viewer;
private $contextObject;
private $version = 21;
private $engineCaches = array();
private $auxiliaryConfig = array();
private static $engineStack = array();
/* -( Markup Pipeline )---------------------------------------------------- */
/**
* Convenience method for pushing a single object through the markup
* pipeline.
*
* @param PhabricatorMarkupInterface $object The object to render.
* @param string $field The field to render.
* @param PhabricatorUser $viewer User viewing the markup.
* @param object $context_object (optional) A context
* object for policy checks.
* @return string Marked up output.
* @task markup
*/
public static function renderOneObject(
PhabricatorMarkupInterface $object,
$field,
PhabricatorUser $viewer,
$context_object = null) {
return id(new PhabricatorMarkupEngine())
->setViewer($viewer)
->setContextObject($context_object)
->addObject($object, $field)
->process()
->getOutput($object, $field);
}
/**
* Queue an object for markup generation when @{method:process} is
* called. You can retrieve the output later with @{method:getOutput}.
*
* @param PhabricatorMarkupInterface $object The object to render.
* @param string $field The field to render.
- * @return this
+ * @return $this
* @task markup
*/
public function addObject(PhabricatorMarkupInterface $object, $field) {
$key = $this->getMarkupFieldKey($object, $field);
$this->objects[$key] = array(
'object' => $object,
'field' => $field,
);
return $this;
}
/**
* Process objects queued with @{method:addObject}. You can then retrieve
* the output with @{method:getOutput}.
*
- * @return this
+ * @return $this
* @task markup
*/
public function process() {
self::$engineStack[] = $this;
try {
$result = $this->execute();
} finally {
array_pop(self::$engineStack);
}
return $result;
}
public static function isRenderingEmbeddedContent() {
// See T13678. This prevents cycles when rendering embedded content that
// itself has remarkup fields.
return (count(self::$engineStack) > 1);
}
private function execute() {
$keys = array();
foreach ($this->objects as $key => $info) {
if (!isset($info['markup'])) {
$keys[] = $key;
}
}
if (!$keys) {
return $this;
}
$objects = array_select_keys($this->objects, $keys);
// Build all the markup engines. We need an engine for each field whether
// we have a cache or not, since we still need to postprocess the cache.
$engines = array();
foreach ($objects as $key => $info) {
$engines[$key] = $info['object']->newMarkupEngine($info['field']);
$engines[$key]->setConfig('viewer', $this->viewer);
$engines[$key]->setConfig('contextObject', $this->contextObject);
foreach ($this->auxiliaryConfig as $aux_key => $aux_value) {
$engines[$key]->setConfig($aux_key, $aux_value);
}
}
// Load or build the preprocessor caches.
$blocks = $this->loadPreprocessorCaches($engines, $objects);
$blocks = mpull($blocks, 'getCacheData');
$this->engineCaches = $blocks;
// Finalize the output.
foreach ($objects as $key => $info) {
$engine = $engines[$key];
$field = $info['field'];
$object = $info['object'];
$output = $engine->postprocessText($blocks[$key]);
$output = $object->didMarkupText($field, $output, $engine);
$this->objects[$key]['output'] = $output;
}
return $this;
}
/**
* Get the output of markup processing for a field queued with
* @{method:addObject}. Before you can call this method, you must call
* @{method:process}.
*
* @param PhabricatorMarkupInterface $object The object to retrieve.
* @param string $field The field to retrieve.
* @return string Processed output.
* @task markup
*/
public function getOutput(PhabricatorMarkupInterface $object, $field) {
$key = $this->getMarkupFieldKey($object, $field);
$this->requireKeyProcessed($key);
return $this->objects[$key]['output'];
}
/**
* Retrieve engine metadata for a given field.
*
* @param PhabricatorMarkupInterface $object The object to retrieve.
* @param string $field The field to retrieve.
* @param string $metadata_key The engine metadata field
* to retrieve.
* @param wild $default (optional) Default value.
* @task markup
*/
public function getEngineMetadata(
PhabricatorMarkupInterface $object,
$field,
$metadata_key,
$default = null) {
$key = $this->getMarkupFieldKey($object, $field);
$this->requireKeyProcessed($key);
return idx($this->engineCaches[$key]['metadata'], $metadata_key, $default);
}
/**
* @task markup
*/
private function requireKeyProcessed($key) {
if (empty($this->objects[$key])) {
throw new Exception(
pht(
"Call %s before using results (key = '%s').",
'addObject()',
$key));
}
if (!isset($this->objects[$key]['output'])) {
throw new PhutilInvalidStateException('process');
}
}
/**
* @task markup
*/
private function getMarkupFieldKey(
PhabricatorMarkupInterface $object,
$field) {
static $custom;
if ($custom === null) {
$custom = array_merge(
self::loadCustomInlineRules(),
self::loadCustomBlockRules());
$custom = mpull($custom, 'getRuleVersion', null);
ksort($custom);
$custom = PhabricatorHash::digestForIndex(serialize($custom));
}
return $object->getMarkupFieldKey($field).'@'.$this->version.'@'.$custom;
}
/**
* @task markup
*/
private function loadPreprocessorCaches(array $engines, array $objects) {
$blocks = array();
$use_cache = array();
foreach ($objects as $key => $info) {
if ($info['object']->shouldUseMarkupCache($info['field'])) {
$use_cache[$key] = true;
}
}
if ($use_cache) {
try {
$blocks = id(new PhabricatorMarkupCache())->loadAllWhere(
'cacheKey IN (%Ls)',
array_keys($use_cache));
$blocks = mpull($blocks, null, 'getCacheKey');
} catch (Exception $ex) {
phlog($ex);
}
}
$is_readonly = PhabricatorEnv::isReadOnly();
foreach ($objects as $key => $info) {
// False check in case MySQL doesn't support unicode characters
// in the string (T1191), resulting in unserialize returning false.
if (isset($blocks[$key]) && $blocks[$key]->getCacheData() !== false) {
// If we already have a preprocessing cache, we don't need to rebuild
// it.
continue;
}
$text = $info['object']->getMarkupText($info['field']);
$data = $engines[$key]->preprocessText($text);
// NOTE: This is just debugging information to help sort out cache issues.
// If one machine is misconfigured and poisoning caches you can use this
// field to hunt it down.
$metadata = array(
'host' => php_uname('n'),
);
$blocks[$key] = id(new PhabricatorMarkupCache())
->setCacheKey($key)
->setCacheData($data)
->setMetadata($metadata);
if (isset($use_cache[$key]) && !$is_readonly) {
// This is just filling a cache and always safe, even on a read pathway.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$blocks[$key]->replace();
unset($unguarded);
}
}
return $blocks;
}
/**
* Set the viewing user. Used to implement object permissions.
*
* @param PhabricatorUser $viewer The viewing user.
- * @return this
+ * @return $this
* @task markup
*/
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
/**
* Set the context object. Used to implement object permissions.
*
* @param $object The object in which context this remarkup is used.
- * @return this
+ * @return $this
* @task markup
*/
public function setContextObject($object) {
$this->contextObject = $object;
return $this;
}
public function setAuxiliaryConfig($key, $value) {
// TODO: This is gross and should be removed. Avoid use.
$this->auxiliaryConfig[$key] = $value;
return $this;
}
/* -( Engine Construction )------------------------------------------------ */
/**
* @task engine
*/
public static function newManiphestMarkupEngine() {
return self::newMarkupEngine(array(
));
}
/**
* @task engine
*/
public static function newPhrictionMarkupEngine() {
return self::newMarkupEngine(array(
'header.generate-toc' => true,
));
}
/**
* @task engine
*/
public static function newPhameMarkupEngine() {
return self::newMarkupEngine(
array(
'macros' => false,
'uri.full' => true,
'uri.same-window' => true,
'uri.base' => PhabricatorEnv::getURI('/'),
));
}
/**
* @task engine
*/
public static function newFeedMarkupEngine() {
return self::newMarkupEngine(
array(
'macros' => false,
'youtube' => false,
));
}
/**
* @task engine
*/
public static function newCalendarMarkupEngine() {
return self::newMarkupEngine(array(
));
}
/**
* @task engine
*/
public static function newDifferentialMarkupEngine(array $options = array()) {
return self::newMarkupEngine(array(
'differential.diff' => idx($options, 'differential.diff'),
));
}
/**
* @task engine
*/
public static function newDiffusionMarkupEngine(array $options = array()) {
return self::newMarkupEngine(array(
'header.generate-toc' => true,
));
}
/**
* @task engine
*/
public static function getEngine($ruleset = 'default') {
static $engines = array();
if (isset($engines[$ruleset])) {
return $engines[$ruleset];
}
$engine = null;
switch ($ruleset) {
case 'default':
$engine = self::newMarkupEngine(array());
break;
case 'feed':
$engine = self::newMarkupEngine(array());
$engine->setConfig('autoplay.disable', true);
break;
case 'nolinebreaks':
$engine = self::newMarkupEngine(array());
$engine->setConfig('preserve-linebreaks', false);
break;
case 'diffusion-readme':
$engine = self::newMarkupEngine(array());
$engine->setConfig('preserve-linebreaks', false);
$engine->setConfig('header.generate-toc', true);
break;
case 'diviner':
$engine = self::newMarkupEngine(array());
$engine->setConfig('preserve-linebreaks', false);
// $engine->setConfig('diviner.renderer', new DivinerDefaultRenderer());
$engine->setConfig('header.generate-toc', true);
break;
case 'extract':
// Engine used for reference/edge extraction. Turn off anything which
// is slow and doesn't change reference extraction.
$engine = self::newMarkupEngine(array());
$engine->setConfig('pygments.enabled', false);
break;
default:
throw new Exception(pht('Unknown engine ruleset: %s!', $ruleset));
}
$engines[$ruleset] = $engine;
return $engine;
}
/**
* @task engine
*/
private static function getMarkupEngineDefaultConfiguration() {
return array(
'pygments' => PhabricatorEnv::getEnvConfig('pygments.enabled'),
'youtube' => PhabricatorEnv::getEnvConfig(
'remarkup.enable-embedded-youtube'),
'differential.diff' => null,
'header.generate-toc' => false,
'macros' => true,
'uri.allowed-protocols' => PhabricatorEnv::getEnvConfig(
'uri.allowed-protocols'),
'uri.full' => false,
'syntax-highlighter.engine' => PhabricatorEnv::getEnvConfig(
'syntax-highlighter.engine'),
'preserve-linebreaks' => true,
);
}
/**
* @task engine
*/
public static function newMarkupEngine(array $options) {
$options += self::getMarkupEngineDefaultConfiguration();
$engine = new PhutilRemarkupEngine();
$engine->setConfig('preserve-linebreaks', $options['preserve-linebreaks']);
$engine->setConfig('pygments.enabled', $options['pygments']);
$engine->setConfig(
'uri.allowed-protocols',
$options['uri.allowed-protocols']);
$engine->setConfig('differential.diff', $options['differential.diff']);
$engine->setConfig('header.generate-toc', $options['header.generate-toc']);
$engine->setConfig(
'syntax-highlighter.engine',
$options['syntax-highlighter.engine']);
$style_map = id(new PhabricatorDefaultSyntaxStyle())
->getRemarkupStyleMap();
$engine->setConfig('phutil.codeblock.style-map', $style_map);
$engine->setConfig('uri.full', $options['uri.full']);
if (isset($options['uri.base'])) {
$engine->setConfig('uri.base', $options['uri.base']);
}
if (isset($options['uri.same-window'])) {
$engine->setConfig('uri.same-window', $options['uri.same-window']);
}
$rules = array();
$rules[] = new PhutilRemarkupEscapeRemarkupRule();
$rules[] = new PhutilRemarkupEvalRule();
$rules[] = new PhutilRemarkupMonospaceRule();
$rules[] = new PhutilRemarkupHexColorCodeRule();
$rules[] = new PhutilRemarkupDocumentLinkRule();
$rules[] = new PhabricatorNavigationRemarkupRule();
$rules[] = new PhabricatorKeyboardRemarkupRule();
$rules[] = new PhabricatorConfigRemarkupRule();
if ($options['youtube']) {
$rules[] = new PhabricatorYoutubeRemarkupRule();
}
$rules[] = new PhabricatorIconRemarkupRule();
$rules[] = new PhabricatorEmojiRemarkupRule();
$rules[] = new PhabricatorHandleRemarkupRule();
$applications = PhabricatorApplication::getAllInstalledApplications();
foreach ($applications as $application) {
foreach ($application->getRemarkupRules() as $rule) {
$rules[] = $rule;
}
}
$rules[] = new PhutilRemarkupHyperlinkRule();
if ($options['macros']) {
$rules[] = new PhabricatorImageMacroRemarkupRule();
$rules[] = new PhabricatorMemeRemarkupRule();
}
$rules[] = new PhutilRemarkupBoldRule();
$rules[] = new PhutilRemarkupItalicRule();
$rules[] = new PhutilRemarkupDelRule();
$rules[] = new PhutilRemarkupUnderlineRule();
$rules[] = new PhutilRemarkupHighlightRule();
$rules[] = new PhutilRemarkupAnchorRule();
foreach (self::loadCustomInlineRules() as $rule) {
$rules[] = clone $rule;
}
$blocks = array();
$blocks[] = new PhutilRemarkupQuotesBlockRule();
$blocks[] = new PhutilRemarkupReplyBlockRule();
$blocks[] = new PhutilRemarkupLiteralBlockRule();
$blocks[] = new PhutilRemarkupHeaderBlockRule();
$blocks[] = new PhutilRemarkupHorizontalRuleBlockRule();
$blocks[] = new PhutilRemarkupListBlockRule();
$blocks[] = new PhutilRemarkupCodeBlockRule();
$blocks[] = new PhutilRemarkupNoteBlockRule();
$blocks[] = new PhutilRemarkupTableBlockRule();
$blocks[] = new PhutilRemarkupSimpleTableBlockRule();
$blocks[] = new PhutilRemarkupInterpreterBlockRule();
$blocks[] = new PhutilRemarkupDefaultBlockRule();
foreach (self::loadCustomBlockRules() as $rule) {
$blocks[] = $rule;
}
foreach ($blocks as $block) {
$block->setMarkupRules($rules);
}
$engine->setBlockRules($blocks);
return $engine;
}
public static function extractPHIDsFromMentions(
PhabricatorUser $viewer,
array $content_blocks) {
$mentions = array();
$engine = self::newDifferentialMarkupEngine();
$engine->setConfig('viewer', $viewer);
foreach ($content_blocks as $content_block) {
if ($content_block === null) {
continue;
}
if (!strlen($content_block)) {
continue;
}
$engine->markupText($content_block);
$phids = $engine->getTextMetadata(
PhabricatorMentionRemarkupRule::KEY_MENTIONED,
array());
$mentions += $phids;
}
return $mentions;
}
public static function extractFilePHIDsFromEmbeddedFiles(
PhabricatorUser $viewer,
array $content_blocks) {
$files = array();
$engine = self::newDifferentialMarkupEngine();
$engine->setConfig('viewer', $viewer);
foreach ($content_blocks as $content_block) {
$engine->markupText($content_block);
$phids = $engine->getTextMetadata(
PhabricatorEmbedFileRemarkupRule::KEY_ATTACH_INTENT_FILE_PHIDS,
array());
foreach ($phids as $phid) {
$files[$phid] = $phid;
}
}
return array_values($files);
}
public static function summarizeSentence($corpus) {
$corpus = trim($corpus);
$blocks = preg_split('/\n+/', $corpus, 2);
$block = head($blocks);
$sentences = preg_split(
'/\b([.?!]+)\B/u',
$block,
2,
PREG_SPLIT_DELIM_CAPTURE);
if (count($sentences) > 1) {
$result = $sentences[0].$sentences[1];
} else {
$result = head($sentences);
}
return id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(128)
->truncateString($result);
}
/**
* Produce a corpus summary, in a way that shortens the underlying text
* without truncating it somewhere awkward.
*
* TODO: We could do a better job of this.
*
* @param string $corpus Remarkup corpus to summarize.
* @return string Summarized corpus.
*/
public static function summarize($corpus) {
// Major goals here are:
// - Don't split in the middle of a character (utf-8).
// - Don't split in the middle of, e.g., **bold** text, since
// we end up with hanging '**' in the summary.
// - Try not to pick an image macro, header, embedded file, etc.
// - Hopefully don't return too much text. We don't explicitly limit
// this right now.
$blocks = preg_split("/\n *\n\s*/", $corpus);
$best = null;
foreach ($blocks as $block) {
// This is a test for normal spaces in the block, i.e. a heuristic to
// distinguish standard paragraphs from things like image macros. It may
// not work well for non-latin text. We prefer to summarize with a
// paragraph of normal words over an image macro, if possible.
$has_space = preg_match('/\w\s\w/', $block);
// This is a test to find embedded images and headers. We prefer to
// summarize with a normal paragraph over a header or an embedded object,
// if possible.
$has_embed = preg_match('/^[{=]/', $block);
if ($has_space && !$has_embed) {
// This seems like a good summary, so return it.
return $block;
}
if (!$best) {
// This is the first block we found; if everything is garbage just
// use the first block.
$best = $block;
}
}
return $best;
}
private static function loadCustomInlineRules() {
return id(new PhutilClassMapQuery())
->setAncestorClass('PhabricatorRemarkupCustomInlineRule')
->execute();
}
private static function loadCustomBlockRules() {
return id(new PhutilClassMapQuery())
->setAncestorClass('PhabricatorRemarkupCustomBlockRule')
->execute();
}
public static function digestRemarkupContent($object, $content) {
$parts = array();
$parts[] = get_class($object);
if ($object instanceof PhabricatorLiskDAO) {
$parts[] = $object->getID();
}
$parts[] = $content;
$message = implode("\n", $parts);
return PhabricatorHash::digestWithNamedKey($message, 'remarkup');
}
}
diff --git a/src/infrastructure/markup/PhutilMarkupEngine.php b/src/infrastructure/markup/PhutilMarkupEngine.php
index aadb891116..56cb91ff6a 100644
--- a/src/infrastructure/markup/PhutilMarkupEngine.php
+++ b/src/infrastructure/markup/PhutilMarkupEngine.php
@@ -1,33 +1,33 @@
<?php
abstract class PhutilMarkupEngine extends Phobject {
/**
* Set a configuration parameter which the engine can read to customize how
* the text is marked up. This is a generic interface; consult the
* documentation for specific rules and blocks for what options are available
* for configuration.
*
* @param string $key Key to set in the configuration dictionary.
* @param string $value Value to set.
- * @return this
+ * @return $this
*/
abstract public function setConfig($key, $value);
/**
* After text has been marked up with @{method:markupText}, you can retrieve
* any metadata the markup process generated by calling this method. This is
* a generic interface that allows rules to export extra information about
* text; consult the documentation for specific rules and blocks to see what
* metadata may be available in your configuration.
*
* @param string $key Key to retrieve from metadata.
* @param mixed $default (optional) Default value to return if the key is
* not available.
* @return mixed Metadata property, or default value.
*/
abstract public function getTextMetadata($key, $default = null);
abstract public function markupText($text);
}
diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
index 7d57df7c35..b292ce6473 100644
--- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
+++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
@@ -1,3290 +1,3290 @@
<?php
/**
* A query class which uses cursor-based paging. This paging is much more
* performant than offset-based paging in the presence of policy filtering.
*
* @task cursors Query Cursors
* @task clauses Building Query Clauses
* @task appsearch Integration with ApplicationSearch
* @task customfield Integration with CustomField
* @task paging Paging
* @task order Result Ordering
* @task edgelogic Working with Edge Logic
* @task spaces Working with Spaces
*/
abstract class PhabricatorCursorPagedPolicyAwareQuery
extends PhabricatorPolicyAwareQuery {
private $externalCursorString;
private $internalCursorObject;
private $isQueryOrderReversed = false;
private $rawCursorRow;
private $applicationSearchConstraints = array();
private $internalPaging;
private $orderVector;
private $groupVector;
private $builtinOrder;
private $edgeLogicConstraints = array();
private $edgeLogicConstraintsAreValid = false;
private $spacePHIDs;
private $spaceIsArchived;
private $ngrams = array();
private $ferretEngine;
private $ferretTokens = array();
private $ferretTables = array();
private $ferretQuery;
private $ferretMetadata = array();
private $ngramEngine;
const FULLTEXT_RANK = '_ft_rank';
const FULLTEXT_MODIFIED = '_ft_epochModified';
const FULLTEXT_CREATED = '_ft_epochCreated';
/* -( Cursors )------------------------------------------------------------ */
protected function newExternalCursorStringForResult($object) {
if (!($object instanceof LiskDAO)) {
throw new Exception(
pht(
'Expected to be passed a result object of class "LiskDAO" in '.
'"newExternalCursorStringForResult()", actually passed "%s". '.
'Return storage objects from "loadPage()" or override '.
'"newExternalCursorStringForResult()".',
phutil_describe_type($object)));
}
return (string)$object->getID();
}
protected function newInternalCursorFromExternalCursor($cursor) {
$viewer = $this->getViewer();
$query = newv(get_class($this), array());
$query
->setParentQuery($this)
->setViewer($viewer);
// We're copying our order vector to the subquery so that the subquery
// knows it should generate any supplemental information required by the
// ordering.
// For example, Phriction documents may be ordered by title, but the title
// isn't a column in the "document" table: the query must JOIN the
// "content" table to perform the ordering. Passing the ordering to the
// subquery tells it that we need it to do that JOIN and attach relevant
// paging information to the internal cursor object.
// We only expect to load a single result, so the actual result order does
// not matter. We only want the internal cursor for that result to look
// like a cursor this parent query would generate.
$query->setOrderVector($this->getOrderVector());
$this->applyExternalCursorConstraintsToQuery($query, $cursor);
// If we have a Ferret fulltext query, copy it to the subquery so that we
// generate ranking columns appropriately, and compute the correct object
// ranking score for the current query.
if ($this->ferretEngine) {
$query->withFerretConstraint($this->ferretEngine, $this->ferretTokens);
}
// We're executing the subquery normally to make sure the viewer can
// actually see the object, and that it's a completely valid object which
// passes all filtering and policy checks. You aren't allowed to use an
// object you can't see as a cursor, since this can leak information.
$result = $query->executeOne();
if (!$result) {
$this->throwCursorException(
pht(
'Cursor "%s" does not identify a valid object in query "%s".',
$cursor,
get_class($this)));
}
// Now that we made sure the viewer can actually see the object the
// external cursor identifies, return the internal cursor the query
// generated as a side effect while loading the object.
return $query->getInternalCursorObject();
}
final protected function throwCursorException($message) {
throw new PhabricatorInvalidQueryCursorException($message);
}
protected function applyExternalCursorConstraintsToQuery(
PhabricatorCursorPagedPolicyAwareQuery $subquery,
$cursor) {
$subquery->withIDs(array($cursor));
}
protected function newPagingMapFromCursorObject(
PhabricatorQueryCursor $cursor,
array $keys) {
$object = $cursor->getObject();
return $this->newPagingMapFromPartialObject($object);
}
protected function newPagingMapFromPartialObject($object) {
return array(
'id' => (int)$object->getID(),
);
}
private function getExternalCursorStringForResult($object) {
$cursor = $this->newExternalCursorStringForResult($object);
if (!is_string($cursor)) {
throw new Exception(
pht(
'Expected "newExternalCursorStringForResult()" in class "%s" to '.
'return a string, but got "%s".',
get_class($this),
phutil_describe_type($cursor)));
}
return $cursor;
}
final protected function getExternalCursorString() {
return $this->externalCursorString;
}
private function setExternalCursorString($external_cursor) {
$this->externalCursorString = $external_cursor;
return $this;
}
final protected function getIsQueryOrderReversed() {
return $this->isQueryOrderReversed;
}
final protected function setIsQueryOrderReversed($is_reversed) {
$this->isQueryOrderReversed = $is_reversed;
return $this;
}
private function getInternalCursorObject() {
return $this->internalCursorObject;
}
private function setInternalCursorObject(
PhabricatorQueryCursor $cursor) {
$this->internalCursorObject = $cursor;
return $this;
}
private function getInternalCursorFromExternalCursor(
$cursor_string) {
$cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string);
if (!($cursor_object instanceof PhabricatorQueryCursor)) {
throw new Exception(
pht(
'Expected "newInternalCursorFromExternalCursor()" to return an '.
'object of class "PhabricatorQueryCursor", but got "%s" (in '.
'class "%s").',
phutil_describe_type($cursor_object),
get_class($this)));
}
return $cursor_object;
}
private function getPagingMapFromCursorObject(
PhabricatorQueryCursor $cursor,
array $keys) {
$map = $this->newPagingMapFromCursorObject($cursor, $keys);
if (!is_array($map)) {
throw new Exception(
pht(
'Expected "newPagingMapFromCursorObject()" to return a map of '.
'paging values, but got "%s" (in class "%s").',
phutil_describe_type($map),
get_class($this)));
}
if ($this->supportsFerretEngine()) {
if ($this->hasFerretOrder()) {
$map += array(
'rank' =>
$cursor->getRawRowProperty(self::FULLTEXT_RANK),
'fulltext-modified' =>
$cursor->getRawRowProperty(self::FULLTEXT_MODIFIED),
'fulltext-created' =>
$cursor->getRawRowProperty(self::FULLTEXT_CREATED),
);
}
}
foreach ($keys as $key) {
if (!array_key_exists($key, $map)) {
throw new Exception(
pht(
'Map returned by "newPagingMapFromCursorObject()" in class "%s" '.
'omits required key "%s".',
get_class($this),
$key));
}
}
return $map;
}
final protected function nextPage(array $page) {
if (!$page) {
return;
}
$cursor = id(new PhabricatorQueryCursor())
->setObject(last($page));
if ($this->rawCursorRow) {
$cursor->setRawRow($this->rawCursorRow);
}
$this->setInternalCursorObject($cursor);
}
final public function getFerretMetadata() {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Unable to retrieve Ferret engine metadata, this class ("%s") does '.
'not support the Ferret engine.',
get_class($this)));
}
return $this->ferretMetadata;
}
protected function loadPage() {
$object = $this->newResultObject();
if (!$object instanceof PhabricatorLiskDAO) {
throw new Exception(
pht(
'Query class ("%s") did not return the correct type of object '.
'from "newResultObject()" (expected a subclass of '.
'"PhabricatorLiskDAO", found "%s"). Return an object of the '.
'expected type (this is common), or implement a custom '.
'"loadPage()" method (this is unusual in modern code).',
get_class($this),
phutil_describe_type($object)));
}
return $this->loadStandardPage($object);
}
protected function loadStandardPage(PhabricatorLiskDAO $table) {
$rows = $this->loadStandardPageRows($table);
return $table->loadAllFromArray($rows);
}
protected function loadStandardPageRows(PhabricatorLiskDAO $table) {
$conn = $table->establishConnection('r');
return $this->loadStandardPageRowsWithConnection(
$conn,
$table->getTableName());
}
protected function loadStandardPageRowsWithConnection(
AphrontDatabaseConnection $conn,
$table_name) {
$query = $this->buildStandardPageQuery($conn, $table_name);
$rows = queryfx_all($conn, '%Q', $query);
$rows = $this->didLoadRawRows($rows);
return $rows;
}
protected function buildStandardPageQuery(
AphrontDatabaseConnection $conn,
$table_name) {
$table_alias = $this->getPrimaryTableAlias();
if ($table_alias === null) {
$table_alias = qsprintf($conn, '');
} else {
$table_alias = qsprintf($conn, '%T', $table_alias);
}
return qsprintf(
$conn,
'%Q FROM %T %Q %Q %Q %Q %Q %Q %Q',
$this->buildSelectClause($conn),
$table_name,
$table_alias,
$this->buildJoinClause($conn),
$this->buildWhereClause($conn),
$this->buildGroupClause($conn),
$this->buildHavingClause($conn),
$this->buildOrderClause($conn),
$this->buildLimitClause($conn));
}
protected function didLoadRawRows(array $rows) {
$this->rawCursorRow = last($rows);
if ($this->ferretEngine) {
foreach ($rows as $row) {
$phid = $row['phid'];
$metadata = id(new PhabricatorFerretMetadata())
->setPHID($phid)
->setEngine($this->ferretEngine)
->setRelevance(idx($row, self::FULLTEXT_RANK));
$this->ferretMetadata[$phid] = $metadata;
unset($row[self::FULLTEXT_RANK]);
unset($row[self::FULLTEXT_MODIFIED]);
unset($row[self::FULLTEXT_CREATED]);
}
}
return $rows;
}
final protected function buildLimitClause(AphrontDatabaseConnection $conn) {
if ($this->shouldLimitResults()) {
$limit = $this->getRawResultLimit();
if ($limit) {
return qsprintf($conn, 'LIMIT %d', $limit);
}
}
return qsprintf($conn, '');
}
protected function shouldLimitResults() {
return true;
}
final protected function didLoadResults(array $results) {
if ($this->getIsQueryOrderReversed()) {
$results = array_reverse($results, $preserve_keys = true);
}
return $results;
}
final public function newIterator() {
return new PhabricatorQueryIterator($this);
}
final public function executeWithCursorPager(AphrontCursorPagerView $pager) {
$limit = $pager->getPageSize();
$this->setLimit($limit + 1);
$after_id = phutil_string_cast($pager->getAfterID());
$before_id = phutil_string_cast($pager->getBeforeID());
if (phutil_nonempty_string($after_id)) {
$this->setExternalCursorString($after_id);
} else if (phutil_nonempty_string($before_id)) {
$this->setExternalCursorString($before_id);
$this->setIsQueryOrderReversed(true);
}
$results = $this->execute();
$count = count($results);
$sliced_results = $pager->sliceResults($results);
if ($sliced_results) {
// If we have results, generate external-facing cursors from the visible
// results. This stops us from leaking any internal details about objects
// which we loaded but which were not visible to the viewer.
if ($pager->getBeforeID() || ($count > $limit)) {
$last_object = last($sliced_results);
$cursor = $this->getExternalCursorStringForResult($last_object);
$pager->setNextPageID($cursor);
}
if ($pager->getAfterID() ||
($pager->getBeforeID() && ($count > $limit))) {
$head_object = head($sliced_results);
$cursor = $this->getExternalCursorStringForResult($head_object);
$pager->setPrevPageID($cursor);
}
}
return $sliced_results;
}
/**
* Return the alias this query uses to identify the primary table.
*
* Some automatic query constructions may need to be qualified with a table
* alias if the query performs joins which make column names ambiguous. If
* this is the case, return the alias for the primary table the query
* uses; generally the object table which has `id` and `phid` columns.
*
* @return string Alias for the primary table.
*/
protected function getPrimaryTableAlias() {
return null;
}
public function newResultObject() {
return null;
}
/* -( Building Query Clauses )--------------------------------------------- */
/**
* @task clauses
*/
protected function buildSelectClause(AphrontDatabaseConnection $conn) {
$parts = $this->buildSelectClauseParts($conn);
return $this->formatSelectClause($conn, $parts);
}
/**
* @task clauses
*/
protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) {
$select = array();
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$select[] = qsprintf($conn, '%T.*', $alias);
} else {
$select[] = qsprintf($conn, '*');
}
$select[] = $this->buildEdgeLogicSelectClause($conn);
$select[] = $this->buildFerretSelectClause($conn);
return $select;
}
/**
* @task clauses
*/
protected function buildJoinClause(AphrontDatabaseConnection $conn) {
$joins = $this->buildJoinClauseParts($conn);
return $this->formatJoinClause($conn, $joins);
}
/**
* @task clauses
*/
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
$joins = array();
$joins[] = $this->buildEdgeLogicJoinClause($conn);
$joins[] = $this->buildApplicationSearchJoinClause($conn);
$joins[] = $this->buildNgramsJoinClause($conn);
$joins[] = $this->buildFerretJoinClause($conn);
return $joins;
}
/**
* @task clauses
*/
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = $this->buildWhereClauseParts($conn);
return $this->formatWhereClause($conn, $where);
}
/**
* @task clauses
*/
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = array();
$where[] = $this->buildPagingWhereClause($conn);
$where[] = $this->buildEdgeLogicWhereClause($conn);
$where[] = $this->buildSpacesWhereClause($conn);
$where[] = $this->buildNgramsWhereClause($conn);
$where[] = $this->buildFerretWhereClause($conn);
$where[] = $this->buildApplicationSearchWhereClause($conn);
return $where;
}
/**
* @task clauses
*/
protected function buildHavingClause(AphrontDatabaseConnection $conn) {
$having = $this->buildHavingClauseParts($conn);
$having[] = $this->buildPagingHavingClause($conn);
return $this->formatHavingClause($conn, $having);
}
/**
* @task clauses
*/
protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) {
$having = array();
$having[] = $this->buildEdgeLogicHavingClause($conn);
return $having;
}
/**
* @task clauses
*/
protected function buildGroupClause(AphrontDatabaseConnection $conn) {
if (!$this->shouldGroupQueryResultRows()) {
return qsprintf($conn, '');
}
return qsprintf(
$conn,
'GROUP BY %Q',
$this->getApplicationSearchObjectPHIDColumn($conn));
}
/**
* @task clauses
*/
protected function shouldGroupQueryResultRows() {
if ($this->shouldGroupEdgeLogicResultRows()) {
return true;
}
if ($this->getApplicationSearchMayJoinMultipleRows()) {
return true;
}
if ($this->shouldGroupNgramResultRows()) {
return true;
}
if ($this->shouldGroupFerretResultRows()) {
return true;
}
return false;
}
/* -( Paging )------------------------------------------------------------- */
private function buildPagingWhereClause(AphrontDatabaseConnection $conn) {
if ($this->shouldPageWithHavingClause()) {
return null;
}
return $this->buildPagingClause($conn);
}
private function buildPagingHavingClause(AphrontDatabaseConnection $conn) {
if (!$this->shouldPageWithHavingClause()) {
return null;
}
return $this->buildPagingClause($conn);
}
private function shouldPageWithHavingClause() {
// If any of the paging conditions reference dynamic columns, we need to
// put the paging conditions in a "HAVING" clause instead of a "WHERE"
// clause.
// For example, this happens when paging on the Ferret "rank" column,
// since the "rank" value is computed dynamically in the SELECT statement.
$orderable = $this->getOrderableColumns();
$vector = $this->getOrderVector();
foreach ($vector as $order) {
$key = $order->getOrderKey();
$column = $orderable[$key];
if (!empty($column['having'])) {
return true;
}
}
return false;
}
/**
* @task paging
*/
protected function buildPagingClause(AphrontDatabaseConnection $conn) {
$orderable = $this->getOrderableColumns();
$vector = $this->getQueryableOrderVector();
// If we don't have a cursor object yet, it means we're trying to load
// the first result page. We may need to build a cursor object from the
// external string, or we may not need a paging clause yet.
$cursor_object = $this->getInternalCursorObject();
if (!$cursor_object) {
$external_cursor = $this->getExternalCursorString();
if ($external_cursor !== null) {
$cursor_object = $this->getInternalCursorFromExternalCursor(
$external_cursor);
}
}
// If we still don't have a cursor object, this is the first result page
// and we aren't paging it. We don't need to build a paging clause.
if (!$cursor_object) {
return qsprintf($conn, '');
}
$reversed = $this->getIsQueryOrderReversed();
$keys = array();
foreach ($vector as $order) {
$keys[] = $order->getOrderKey();
}
$keys = array_fuse($keys);
$value_map = $this->getPagingMapFromCursorObject(
$cursor_object,
$keys);
$columns = array();
foreach ($vector as $order) {
$key = $order->getOrderKey();
$column = $orderable[$key];
$column['value'] = $value_map[$key];
// If the vector component is reversed, we need to reverse whatever the
// order of the column is.
if ($order->getIsReversed()) {
$column['reverse'] = !idx($column, 'reverse', false);
}
$columns[] = $column;
}
return $this->buildPagingClauseFromMultipleColumns(
$conn,
$columns,
array(
'reversed' => $reversed,
));
}
/**
* Simplifies the task of constructing a paging clause across multiple
* columns. In the general case, this looks like:
*
* A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)
*
* To build a clause, specify the name, type, and value of each column
* to include:
*
* $this->buildPagingClauseFromMultipleColumns(
* $conn_r,
* array(
* array(
* 'table' => 't',
* 'column' => 'title',
* 'type' => 'string',
* 'value' => $cursor->getTitle(),
* 'reverse' => true,
* ),
* array(
* 'table' => 't',
* 'column' => 'id',
* 'type' => 'int',
* 'value' => $cursor->getID(),
* ),
* ),
* array(
* 'reversed' => $is_reversed,
* ));
*
* This method will then return a composable clause for inclusion in WHERE.
*
* @param AphrontDatabaseConnection $conn Connection query will execute on.
* @param list<map> $columns Column description dictionaries.
* @param map $options Additional construction options.
* @return string Query clause.
* @task paging
*/
final protected function buildPagingClauseFromMultipleColumns(
AphrontDatabaseConnection $conn,
array $columns,
array $options) {
foreach ($columns as $column) {
PhutilTypeSpec::checkMap(
$column,
array(
'table' => 'optional string|null',
'column' => 'string',
'customfield' => 'optional bool',
'customfield.index.key' => 'optional string',
'customfield.index.table' => 'optional string',
'value' => 'wild',
'type' => 'string',
'reverse' => 'optional bool',
'unique' => 'optional bool',
'null' => 'optional string|null',
'requires-ferret' => 'optional bool',
'having' => 'optional bool',
));
}
PhutilTypeSpec::checkMap(
$options,
array(
'reversed' => 'optional bool',
));
$is_query_reversed = idx($options, 'reversed', false);
$clauses = array();
$accumulated = array();
$last_key = last_key($columns);
foreach ($columns as $key => $column) {
$type = $column['type'];
$null = idx($column, 'null');
if ($column['value'] === null) {
if ($null) {
$value = null;
} else {
throw new Exception(
pht(
'Column "%s" has null value, but does not specify a null '.
'behavior.',
$key));
}
} else {
switch ($type) {
case 'int':
$value = qsprintf($conn, '%d', $column['value']);
break;
case 'float':
$value = qsprintf($conn, '%f', $column['value']);
break;
case 'string':
$value = qsprintf($conn, '%s', $column['value']);
break;
default:
throw new Exception(
pht(
'Column "%s" has unknown column type "%s".',
$column['column'],
$type));
}
}
$is_column_reversed = idx($column, 'reverse', false);
$reverse = ($is_query_reversed xor $is_column_reversed);
$clause = $accumulated;
$table_name = idx($column, 'table');
$column_name = $column['column'];
if ($table_name !== null) {
$field = qsprintf($conn, '%T.%T', $table_name, $column_name);
} else {
$field = qsprintf($conn, '%T', $column_name);
}
$parts = array();
if ($null) {
$can_page_if_null = ($null === 'head');
$can_page_if_nonnull = ($null === 'tail');
if ($reverse) {
$can_page_if_null = !$can_page_if_null;
$can_page_if_nonnull = !$can_page_if_nonnull;
}
$subclause = null;
if ($can_page_if_null && $value === null) {
$parts[] = qsprintf(
$conn,
'(%Q IS NOT NULL)',
$field);
} else if ($can_page_if_nonnull && $value !== null) {
$parts[] = qsprintf(
$conn,
'(%Q IS NULL)',
$field);
}
}
if ($value !== null) {
$parts[] = qsprintf(
$conn,
'%Q %Q %Q',
$field,
$reverse ? qsprintf($conn, '>') : qsprintf($conn, '<'),
$value);
}
if ($parts) {
$clause[] = qsprintf($conn, '%LO', $parts);
}
if ($clause) {
$clauses[] = qsprintf($conn, '%LA', $clause);
}
if ($value === null) {
$accumulated[] = qsprintf(
$conn,
'%Q IS NULL',
$field);
} else {
$accumulated[] = qsprintf(
$conn,
'%Q = %Q',
$field,
$value);
}
}
if ($clauses) {
return qsprintf($conn, '%LO', $clauses);
}
return qsprintf($conn, '');
}
/* -( Result Ordering )---------------------------------------------------- */
/**
* Select a result ordering.
*
* This is a high-level method which selects an ordering from a predefined
* list of builtin orders, as provided by @{method:getBuiltinOrders}. These
* options are user-facing and not exhaustive, but are generally convenient
* and meaningful.
*
* You can also use @{method:setOrderVector} to specify a low-level ordering
* across individual orderable columns. This offers greater control but is
* also more involved.
*
* @param string $order Key of a builtin order supported by this query.
- * @return this
+ * @return $this
* @task order
*/
public function setOrder($order) {
$aliases = $this->getBuiltinOrderAliasMap();
if (empty($aliases[$order])) {
throw new Exception(
pht(
'Query "%s" does not support a builtin order "%s". Supported orders '.
'are: %s.',
get_class($this),
$order,
implode(', ', array_keys($aliases))));
}
$this->builtinOrder = $aliases[$order];
$this->orderVector = null;
return $this;
}
/**
* Set a grouping order to apply before primary result ordering.
*
* This allows you to preface the query order vector with additional orders,
* so you can effect "group by" queries while still respecting "order by".
*
* This is a high-level method which works alongside @{method:setOrder}. For
* lower-level control over order vectors, use @{method:setOrderVector}.
*
* @param PhabricatorQueryOrderVector|list<string> $vector List of order
* keys.
- * @return this
+ * @return $this
* @task order
*/
public function setGroupVector($vector) {
$this->groupVector = $vector;
$this->orderVector = null;
return $this;
}
/**
* Get builtin orders for this class.
*
* In application UIs, we want to be able to present users with a small
* selection of meaningful order options (like "Order by Title") rather than
* an exhaustive set of column ordering options.
*
* Meaningful user-facing orders are often really orders across multiple
* columns: for example, a "title" ordering is usually implemented as a
* "title, id" ordering under the hood.
*
* Builtin orders provide a mapping from convenient, understandable
* user-facing orders to implementations.
*
* A builtin order should provide these keys:
*
* - `vector` (`list<string>`): The actual order vector to use.
* - `name` (`string`): Human-readable order name.
*
* @return map<string, wild> Map from builtin order keys to specification.
* @task order
*/
public function getBuiltinOrders() {
$orders = array(
'newest' => array(
'vector' => array('id'),
'name' => pht('Creation (Newest First)'),
'aliases' => array('created'),
),
'oldest' => array(
'vector' => array('-id'),
'name' => pht('Creation (Oldest First)'),
),
);
$object = $this->newResultObject();
if ($object instanceof PhabricatorCustomFieldInterface) {
$list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
foreach ($list->getFields() as $field) {
$index = $field->buildOrderIndex();
if (!$index) {
continue;
}
$legacy_key = 'custom:'.$field->getFieldKey();
$modern_key = $field->getModernFieldKey();
$orders[$modern_key] = array(
'vector' => array($modern_key, 'id'),
'name' => $field->getFieldName(),
'aliases' => array($legacy_key),
);
$orders['-'.$modern_key] = array(
'vector' => array('-'.$modern_key, '-id'),
'name' => pht('%s (Reversed)', $field->getFieldName()),
);
}
}
if ($this->supportsFerretEngine()) {
$orders['relevance'] = array(
'vector' => array('rank', 'fulltext-modified', 'id'),
'name' => pht('Relevance'),
);
}
return $orders;
}
public function getBuiltinOrderAliasMap() {
$orders = $this->getBuiltinOrders();
$map = array();
foreach ($orders as $key => $order) {
$keys = array();
$keys[] = $key;
foreach (idx($order, 'aliases', array()) as $alias) {
$keys[] = $alias;
}
foreach ($keys as $alias) {
if (isset($map[$alias])) {
throw new Exception(
pht(
'Two builtin orders ("%s" and "%s") define the same key or '.
'alias ("%s"). Each order alias and key must be unique and '.
'identify a single order.',
$key,
$map[$alias],
$alias));
}
$map[$alias] = $key;
}
}
return $map;
}
/**
* Set a low-level column ordering.
*
* This is a low-level method which offers granular control over column
* ordering. In most cases, applications can more easily use
* @{method:setOrder} to choose a high-level builtin order.
*
* To set an order vector, specify a list of order keys as provided by
* @{method:getOrderableColumns}.
*
* @param PhabricatorQueryOrderVector|list<string> $vector List of order
* keys.
- * @return this
+ * @return $this
* @task order
*/
public function setOrderVector($vector) {
$vector = PhabricatorQueryOrderVector::newFromVector($vector);
$orderable = $this->getOrderableColumns();
// Make sure that all the components identify valid columns.
$unique = array();
foreach ($vector as $order) {
$key = $order->getOrderKey();
if (empty($orderable[$key])) {
$valid = implode(', ', array_keys($orderable));
throw new Exception(
pht(
'This query ("%s") does not support sorting by order key "%s". '.
'Supported orders are: %s.',
get_class($this),
$key,
$valid));
}
$unique[$key] = idx($orderable[$key], 'unique', false);
}
// Make sure that the last column is unique so that this is a strong
// ordering which can be used for paging.
$last = last($unique);
if ($last !== true) {
throw new Exception(
pht(
'Order vector "%s" is invalid: the last column in an order must '.
'be a column with unique values, but "%s" is not unique.',
$vector->getAsString(),
last_key($unique)));
}
// Make sure that other columns are not unique; an ordering like "id, name"
// does not make sense because only "id" can ever have an effect.
array_pop($unique);
foreach ($unique as $key => $is_unique) {
if ($is_unique) {
throw new Exception(
pht(
'Order vector "%s" is invalid: only the last column in an order '.
'may be unique, but "%s" is a unique column and not the last '.
'column in the order.',
$vector->getAsString(),
$key));
}
}
$this->orderVector = $vector;
return $this;
}
/**
* Get the effective order vector.
*
* @return PhabricatorQueryOrderVector Effective vector.
* @task order
*/
protected function getOrderVector() {
if (!$this->orderVector) {
if ($this->builtinOrder !== null) {
$builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder);
$vector = $builtin_order['vector'];
} else {
$vector = $this->getDefaultOrderVector();
}
if ($this->groupVector) {
$group = PhabricatorQueryOrderVector::newFromVector($this->groupVector);
$group->appendVector($vector);
$vector = $group;
}
$vector = PhabricatorQueryOrderVector::newFromVector($vector);
// We call setOrderVector() here to apply checks to the default vector.
// This catches any errors in the implementation.
$this->setOrderVector($vector);
}
return $this->orderVector;
}
/**
* @task order
*/
protected function getDefaultOrderVector() {
return array('id');
}
/**
* @task order
*/
public function getOrderableColumns() {
$cache = PhabricatorCaches::getRequestCache();
$class = get_class($this);
$cache_key = 'query.orderablecolumns.'.$class;
$columns = $cache->getKey($cache_key);
if ($columns !== null) {
return $columns;
}
$columns = array(
'id' => array(
'table' => $this->getPrimaryTableAlias(),
'column' => 'id',
'reverse' => false,
'type' => 'int',
'unique' => true,
),
);
$object = $this->newResultObject();
if ($object instanceof PhabricatorCustomFieldInterface) {
$list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
foreach ($list->getFields() as $field) {
$index = $field->buildOrderIndex();
if (!$index) {
continue;
}
$digest = $field->getFieldIndex();
$key = $field->getModernFieldKey();
$columns[$key] = array(
'table' => 'appsearch_order_'.$digest,
'column' => 'indexValue',
'type' => $index->getIndexValueType(),
'null' => 'tail',
'customfield' => true,
'customfield.index.table' => $index->getTableName(),
'customfield.index.key' => $digest,
);
}
}
if ($this->supportsFerretEngine()) {
$columns['rank'] = array(
'table' => null,
'column' => self::FULLTEXT_RANK,
'type' => 'int',
'requires-ferret' => true,
'having' => true,
);
$columns['fulltext-created'] = array(
'table' => null,
'column' => self::FULLTEXT_CREATED,
'type' => 'int',
'requires-ferret' => true,
);
$columns['fulltext-modified'] = array(
'table' => null,
'column' => self::FULLTEXT_MODIFIED,
'type' => 'int',
'requires-ferret' => true,
);
}
$cache->setKey($cache_key, $columns);
return $columns;
}
/**
* @task order
*/
final protected function buildOrderClause(
AphrontDatabaseConnection $conn,
$for_union = false) {
$orderable = $this->getOrderableColumns();
$vector = $this->getQueryableOrderVector();
$parts = array();
foreach ($vector as $order) {
$part = $orderable[$order->getOrderKey()];
if ($order->getIsReversed()) {
$part['reverse'] = !idx($part, 'reverse', false);
}
$parts[] = $part;
}
return $this->formatOrderClause($conn, $parts, $for_union);
}
/**
* @task order
*/
private function getQueryableOrderVector() {
$vector = $this->getOrderVector();
$orderable = $this->getOrderableColumns();
$keep = array();
foreach ($vector as $order) {
$column = $orderable[$order->getOrderKey()];
// If this is a Ferret fulltext column but the query doesn't actually
// have a fulltext query, we'll skip most of the Ferret stuff and won't
// actually have the columns in the result set. Just skip them.
if (!empty($column['requires-ferret'])) {
if (!$this->getFerretTokens()) {
continue;
}
}
$keep[] = $order->getAsScalar();
}
return PhabricatorQueryOrderVector::newFromVector($keep);
}
/**
* @task order
*/
protected function formatOrderClause(
AphrontDatabaseConnection $conn,
array $parts,
$for_union = false) {
$is_query_reversed = $this->getIsQueryOrderReversed();
$sql = array();
foreach ($parts as $key => $part) {
$is_column_reversed = !empty($part['reverse']);
$descending = true;
if ($is_query_reversed) {
$descending = !$descending;
}
if ($is_column_reversed) {
$descending = !$descending;
}
$table = idx($part, 'table');
// When we're building an ORDER BY clause for a sequence of UNION
// statements, we can't refer to tables from the subqueries.
if ($for_union) {
$table = null;
}
$column = $part['column'];
if ($table !== null) {
$field = qsprintf($conn, '%T.%T', $table, $column);
} else {
$field = qsprintf($conn, '%T', $column);
}
$null = idx($part, 'null');
if ($null) {
switch ($null) {
case 'head':
$null_field = qsprintf($conn, '(%Q IS NULL)', $field);
break;
case 'tail':
$null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);
break;
default:
throw new Exception(
pht(
'NULL value "%s" is invalid. Valid values are "head" and '.
'"tail".',
$null));
}
if ($descending) {
$sql[] = qsprintf($conn, '%Q DESC', $null_field);
} else {
$sql[] = qsprintf($conn, '%Q ASC', $null_field);
}
}
if ($descending) {
$sql[] = qsprintf($conn, '%Q DESC', $field);
} else {
$sql[] = qsprintf($conn, '%Q ASC', $field);
}
}
return qsprintf($conn, 'ORDER BY %LQ', $sql);
}
/* -( Application Search )------------------------------------------------- */
/**
* Constrain the query with an ApplicationSearch index, requiring field values
* contain at least one of the values in a set.
*
* This constraint can build the most common types of queries, like:
*
* - Find users with shirt sizes "X" or "XL".
* - Find shoes with size "13".
*
* @param PhabricatorCustomFieldIndexStorage $index Table where the index is
* stored.
* @param string|list<string> $value One or more values to filter by.
- * @return this
+ * @return $this
* @task appsearch
*/
public function withApplicationSearchContainsConstraint(
PhabricatorCustomFieldIndexStorage $index,
$value) {
$values = (array)$value;
$data_values = array();
$constraint_values = array();
foreach ($values as $value) {
if ($value instanceof PhabricatorQueryConstraint) {
$constraint_values[] = $value;
} else {
$data_values[] = $value;
}
}
$alias = 'appsearch_'.count($this->applicationSearchConstraints);
$this->applicationSearchConstraints[] = array(
'type' => $index->getIndexValueType(),
'cond' => '=',
'table' => $index->getTableName(),
'index' => $index->getIndexKey(),
'alias' => $alias,
'value' => $values,
'data' => $data_values,
'constraints' => $constraint_values,
);
return $this;
}
/**
* Constrain the query with an ApplicationSearch index, requiring values
* exist in a given range.
*
* This constraint is useful for expressing date ranges:
*
* - Find events between July 1st and July 7th.
*
* The ends of the range are inclusive, so a `$min` of `3` and a `$max` of
* `5` will match fields with values `3`, `4`, or `5`. Providing `null` for
* either end of the range will leave that end of the constraint open.
*
* @param PhabricatorCustomFieldIndexStorage $index Table where the index is
* stored.
* @param int|null $min Minimum permissible value, inclusive.
* @param int|null $max Maximum permissible value, inclusive.
- * @return this
+ * @return $this
* @task appsearch
*/
public function withApplicationSearchRangeConstraint(
PhabricatorCustomFieldIndexStorage $index,
$min,
$max) {
$index_type = $index->getIndexValueType();
if ($index_type != 'int') {
throw new Exception(
pht(
'Attempting to apply a range constraint to a field with index type '.
'"%s", expected type "%s".',
$index_type,
'int'));
}
$alias = 'appsearch_'.count($this->applicationSearchConstraints);
$this->applicationSearchConstraints[] = array(
'type' => $index->getIndexValueType(),
'cond' => 'range',
'table' => $index->getTableName(),
'index' => $index->getIndexKey(),
'alias' => $alias,
'value' => array($min, $max),
'data' => null,
'constraints' => null,
);
return $this;
}
/**
* Get the name of the query's primary object PHID column, for constructing
* JOIN clauses. Normally (and by default) this is just `"phid"`, but it may
* be something more exotic.
*
* See @{method:getPrimaryTableAlias} if the column needs to be qualified with
* a table alias.
*
* @param AphrontDatabaseConnection $conn Connection executing queries.
* @return PhutilQueryString Column name.
* @task appsearch
*/
protected function getApplicationSearchObjectPHIDColumn(
AphrontDatabaseConnection $conn) {
if ($this->getPrimaryTableAlias()) {
return qsprintf($conn, '%T.phid', $this->getPrimaryTableAlias());
} else {
return qsprintf($conn, 'phid');
}
}
/**
* Determine if the JOINs built by ApplicationSearch might cause each primary
* object to return multiple result rows. Generally, this means the query
* needs an extra GROUP BY clause.
*
* @return bool True if the query may return multiple rows for each object.
* @task appsearch
*/
protected function getApplicationSearchMayJoinMultipleRows() {
foreach ($this->applicationSearchConstraints as $constraint) {
$type = $constraint['type'];
$value = $constraint['value'];
$cond = $constraint['cond'];
switch ($cond) {
case '=':
switch ($type) {
case 'string':
case 'int':
if (count($value) > 1) {
return true;
}
break;
default:
throw new Exception(pht('Unknown index type "%s"!', $type));
}
break;
case 'range':
// NOTE: It's possible to write a custom field where multiple rows
// match a range constraint, but we don't currently ship any in the
// upstream and I can't immediately come up with cases where this
// would make sense.
break;
default:
throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
}
}
return false;
}
/**
* Construct a GROUP BY clause appropriate for ApplicationSearch constraints.
*
* @param AphrontDatabaseConnection $conn Connection executing the query.
* @return string Group clause.
* @task appsearch
*/
protected function buildApplicationSearchGroupClause(
AphrontDatabaseConnection $conn) {
if ($this->getApplicationSearchMayJoinMultipleRows()) {
return qsprintf(
$conn,
'GROUP BY %Q',
$this->getApplicationSearchObjectPHIDColumn($conn));
} else {
return qsprintf($conn, '');
}
}
/**
* Construct a JOIN clause appropriate for applying ApplicationSearch
* constraints.
*
* @param AphrontDatabaseConnection $conn Connection executing the query.
* @return string Join clause.
* @task appsearch
*/
protected function buildApplicationSearchJoinClause(
AphrontDatabaseConnection $conn) {
$joins = array();
foreach ($this->applicationSearchConstraints as $key => $constraint) {
$table = $constraint['table'];
$alias = $constraint['alias'];
$index = $constraint['index'];
$cond = $constraint['cond'];
$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
switch ($cond) {
case '=':
// Figure out whether we need to do a LEFT JOIN or not. We need to
// LEFT JOIN if we're going to select "IS NULL" rows.
$join_type = qsprintf($conn, 'JOIN');
foreach ($constraint['constraints'] as $query_constraint) {
$op = $query_constraint->getOperator();
if ($op === PhabricatorQueryConstraint::OPERATOR_NULL) {
$join_type = qsprintf($conn, 'LEFT JOIN');
break;
}
}
$joins[] = qsprintf(
$conn,
'%Q %T %T ON %T.objectPHID = %Q
AND %T.indexKey = %s',
$join_type,
$table,
$alias,
$alias,
$phid_column,
$alias,
$index);
break;
case 'range':
list($min, $max) = $constraint['value'];
if (($min === null) && ($max === null)) {
// If there's no actual range constraint, just move on.
break;
}
if ($min === null) {
$constraint_clause = qsprintf(
$conn,
'%T.indexValue <= %d',
$alias,
$max);
} else if ($max === null) {
$constraint_clause = qsprintf(
$conn,
'%T.indexValue >= %d',
$alias,
$min);
} else {
$constraint_clause = qsprintf(
$conn,
'%T.indexValue BETWEEN %d AND %d',
$alias,
$min,
$max);
}
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.objectPHID = %Q
AND %T.indexKey = %s
AND (%Q)',
$table,
$alias,
$alias,
$phid_column,
$alias,
$index,
$constraint_clause);
break;
default:
throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
}
}
$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
$orderable = $this->getOrderableColumns();
$vector = $this->getOrderVector();
foreach ($vector as $order) {
$spec = $orderable[$order->getOrderKey()];
if (empty($spec['customfield'])) {
continue;
}
$table = $spec['customfield.index.table'];
$alias = $spec['table'];
$key = $spec['customfield.index.key'];
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T %T ON %T.objectPHID = %Q
AND %T.indexKey = %s',
$table,
$alias,
$alias,
$phid_column,
$alias,
$key);
}
if ($joins) {
return qsprintf($conn, '%LJ', $joins);
} else {
return qsprintf($conn, '');
}
}
/**
* Construct a WHERE clause appropriate for applying ApplicationSearch
* constraints.
*
* @param AphrontDatabaseConnection $conn Connection executing the query.
* @return list<string> Where clause parts.
* @task appsearch
*/
protected function buildApplicationSearchWhereClause(
AphrontDatabaseConnection $conn) {
$where = array();
foreach ($this->applicationSearchConstraints as $key => $constraint) {
$alias = $constraint['alias'];
$cond = $constraint['cond'];
$type = $constraint['type'];
$data_values = $constraint['data'];
$constraint_values = $constraint['constraints'];
$constraint_parts = array();
switch ($cond) {
case '=':
if ($data_values) {
switch ($type) {
case 'string':
$constraint_parts[] = qsprintf(
$conn,
'%T.indexValue IN (%Ls)',
$alias,
$data_values);
break;
case 'int':
$constraint_parts[] = qsprintf(
$conn,
'%T.indexValue IN (%Ld)',
$alias,
$data_values);
break;
default:
throw new Exception(pht('Unknown index type "%s"!', $type));
}
}
if ($constraint_values) {
foreach ($constraint_values as $value) {
$op = $value->getOperator();
switch ($op) {
case PhabricatorQueryConstraint::OPERATOR_NULL:
$constraint_parts[] = qsprintf(
$conn,
'%T.indexValue IS NULL',
$alias);
break;
case PhabricatorQueryConstraint::OPERATOR_ANY:
$constraint_parts[] = qsprintf(
$conn,
'%T.indexValue IS NOT NULL',
$alias);
break;
default:
throw new Exception(
pht(
'No support for applying operator "%s" against '.
'index of type "%s".',
$op,
$type));
}
}
}
if ($constraint_parts) {
$where[] = qsprintf($conn, '%LO', $constraint_parts);
}
break;
}
}
return $where;
}
/* -( Integration with CustomField )--------------------------------------- */
/**
* @task customfield
*/
protected function getPagingValueMapForCustomFields(
PhabricatorCustomFieldInterface $object) {
// We have to get the current field values on the cursor object.
$fields = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
$fields->setViewer($this->getViewer());
$fields->readFieldsFromStorage($object);
$map = array();
foreach ($fields->getFields() as $field) {
$map[$field->getModernFieldKey()] = $field->getValueForStorage();
}
return $map;
}
/**
* @task customfield
*/
protected function isCustomFieldOrderKey($key) {
$prefix = 'custom.';
return !strncmp($key, $prefix, strlen($prefix));
}
/* -( Ferret )------------------------------------------------------------- */
public function supportsFerretEngine() {
$object = $this->newResultObject();
return ($object instanceof PhabricatorFerretInterface);
}
public function withFerretQuery(
PhabricatorFerretEngine $engine,
PhabricatorSavedQuery $query) {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Query ("%s") does not support the Ferret fulltext engine.',
get_class($this)));
}
$this->ferretEngine = $engine;
$this->ferretQuery = $query;
return $this;
}
public function getFerretTokens() {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Query ("%s") does not support the Ferret fulltext engine.',
get_class($this)));
}
return $this->ferretTokens;
}
public function withFerretConstraint(
PhabricatorFerretEngine $engine,
array $fulltext_tokens) {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Query ("%s") does not support the Ferret fulltext engine.',
get_class($this)));
}
if ($this->ferretEngine) {
throw new Exception(
pht(
'Query may not have multiple fulltext constraints.'));
}
if (!$fulltext_tokens) {
return $this;
}
$this->ferretEngine = $engine;
$this->ferretTokens = $fulltext_tokens;
$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
$default_function = $engine->getDefaultFunctionKey();
$table_map = array();
$idx = 1;
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
$function = $raw_token->getFunction();
if ($function === null) {
$function = $default_function;
}
$function_def = $engine->getFunctionForName($function);
// NOTE: The query compiler guarantees that a query can not make a
// field both "present" and "absent", so it's safe to just use the
// first operator we encounter to determine whether the table is
// optional or not.
$operator = $raw_token->getOperator();
$is_optional = ($operator === $op_absent);
if (!isset($table_map[$function])) {
$alias = 'ftfield_'.$idx++;
$table_map[$function] = array(
'alias' => $alias,
'function' => $function_def,
'optional' => $is_optional,
);
}
}
// Join the title field separately so we can rank results.
$table_map['rank'] = array(
'alias' => 'ft_rank',
'function' => $engine->getFunctionForName('title'),
// See T13345. Not every document has a title, so we want to LEFT JOIN
// this table to avoid excluding documents with no title that match
// the query in other fields.
'optional' => true,
);
$this->ferretTables = $table_map;
return $this;
}
protected function buildFerretSelectClause(AphrontDatabaseConnection $conn) {
$select = array();
if (!$this->supportsFerretEngine()) {
return $select;
}
if (!$this->hasFerretOrder()) {
// We only need to SELECT the virtual rank/relevance columns if we're
// actually sorting the results by rank.
return $select;
}
if (!$this->ferretEngine) {
$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_RANK);
$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_CREATED);
$select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_MODIFIED);
return $select;
}
$engine = $this->ferretEngine;
$stemmer = $engine->newStemmer();
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
$table_alias = 'ft_rank';
$parts = array();
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
$value = $raw_token->getValue();
if ($raw_token->getOperator() == $op_not) {
// Ignore "not" terms when ranking, since they aren't useful.
continue;
}
if ($raw_token->getOperator() == $op_sub) {
$is_substring = true;
} else {
$is_substring = false;
}
if ($is_substring) {
$parts[] = qsprintf(
$conn,
'IF(%T.rawCorpus LIKE %~, 2, 0)',
$table_alias,
$value);
continue;
}
if ($raw_token->isQuoted()) {
$is_quoted = true;
$is_stemmed = false;
} else {
$is_quoted = false;
$is_stemmed = true;
}
$term_constraints = array();
$term_value = $engine->newTermsCorpus($value);
$parts[] = qsprintf(
$conn,
'IF(%T.termCorpus LIKE %~, 2, 0)',
$table_alias,
$term_value);
if ($is_stemmed) {
$stem_value = $stemmer->stemToken($value);
$stem_value = $engine->newTermsCorpus($stem_value);
$parts[] = qsprintf(
$conn,
'IF(%T.normalCorpus LIKE %~, 1, 0)',
$table_alias,
$stem_value);
}
}
$parts[] = qsprintf($conn, '%d', 0);
$sum = array_shift($parts);
foreach ($parts as $part) {
$sum = qsprintf(
$conn,
'%Q + %Q',
$sum,
$part);
}
$select[] = qsprintf(
$conn,
'%Q AS %T',
$sum,
self::FULLTEXT_RANK);
// See D20297. We select these as real columns in the result set so that
// constructions like this will work:
//
// ((SELECT ...) UNION (SELECT ...)) ORDER BY ...
//
// If the columns aren't part of the result set, the final "ORDER BY" can
// not act on them.
$select[] = qsprintf(
$conn,
'ft_doc.epochCreated AS %T',
self::FULLTEXT_CREATED);
$select[] = qsprintf(
$conn,
'ft_doc.epochModified AS %T',
self::FULLTEXT_MODIFIED);
return $select;
}
protected function buildFerretJoinClause(AphrontDatabaseConnection $conn) {
if (!$this->ferretEngine) {
return array();
}
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
$op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
$engine = $this->ferretEngine;
$stemmer = $engine->newStemmer();
$ngram_table = $engine->getNgramsTableName();
$ngram_engine = $this->getNgramEngine();
$flat = array();
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
$operator = $raw_token->getOperator();
// If this is a negated term like "-pomegranate", don't join the ngram
// table since we aren't looking for documents with this term. (We could
// LEFT JOIN the table and require a NULL row, but this is probably more
// trouble than it's worth.)
if ($operator === $op_not) {
continue;
}
// Neither the "present" or "absent" operators benefit from joining
// the ngram table.
if ($operator === $op_absent || $operator === $op_present) {
continue;
}
$value = $raw_token->getValue();
$length = count(phutil_utf8v($value));
if ($raw_token->getOperator() == $op_sub) {
$is_substring = true;
} else {
$is_substring = false;
}
// If the user specified a substring query for a substring which is
// shorter than the ngram length, we can't use the ngram index, so
// don't do a join. We'll fall back to just doing LIKE on the full
// corpus.
if ($is_substring) {
if ($length < 3) {
continue;
}
}
if ($raw_token->isQuoted()) {
$is_stemmed = false;
} else {
$is_stemmed = true;
}
if ($is_substring) {
$ngrams = $ngram_engine->getSubstringNgramsFromString($value);
} else {
$terms_value = $engine->newTermsCorpus($value);
$ngrams = $ngram_engine->getTermNgramsFromString($terms_value);
// If this is a stemmed term, only look for ngrams present in both the
// unstemmed and stemmed variations.
if ($is_stemmed) {
// Trim the boundary space characters so the stemmer recognizes this
// is (or, at least, may be) a normal word and activates.
$terms_value = trim($terms_value, ' ');
$stem_value = $stemmer->stemToken($terms_value);
$stem_ngrams = $ngram_engine->getTermNgramsFromString($stem_value);
$ngrams = array_intersect($ngrams, $stem_ngrams);
}
}
foreach ($ngrams as $ngram) {
$flat[] = array(
'table' => $ngram_table,
'ngram' => $ngram,
);
}
}
// Remove common ngrams, like "the", which occur too frequently in
// documents to be useful in constraining the query. The best ngrams
// are obscure sequences which occur in very few documents.
if ($flat) {
$common_ngrams = queryfx_all(
$conn,
'SELECT ngram FROM %T WHERE ngram IN (%Ls)',
$engine->getCommonNgramsTableName(),
ipull($flat, 'ngram'));
$common_ngrams = ipull($common_ngrams, 'ngram', 'ngram');
foreach ($flat as $key => $spec) {
$ngram = $spec['ngram'];
if (isset($common_ngrams[$ngram])) {
unset($flat[$key]);
continue;
}
// NOTE: MySQL discards trailing whitespace in CHAR(X) columns.
$trim_ngram = rtrim($ngram, ' ');
if (isset($common_ngrams[$trim_ngram])) {
unset($flat[$key]);
continue;
}
}
}
// MySQL only allows us to join a maximum of 61 tables per query. Each
// ngram is going to cost us a join toward that limit, so if the user
// specified a very long query string, just pick 16 of the ngrams
// at random.
if (count($flat) > 16) {
shuffle($flat);
$flat = array_slice($flat, 0, 16);
}
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$phid_column = qsprintf($conn, '%T.%T', $alias, 'phid');
} else {
$phid_column = qsprintf($conn, '%T', 'phid');
}
$document_table = $engine->getDocumentTableName();
$field_table = $engine->getFieldTableName();
$joins = array();
$joins[] = qsprintf(
$conn,
'JOIN %T ft_doc ON ft_doc.objectPHID = %Q',
$document_table,
$phid_column);
$idx = 1;
foreach ($flat as $spec) {
$table = $spec['table'];
$ngram = $spec['ngram'];
$alias = 'ftngram_'.$idx++;
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s',
$table,
$alias,
$alias,
$alias,
$ngram);
}
$object = $this->newResultObject();
if (!$object) {
throw new Exception(
pht(
'Query class ("%s") must define "newResultObject()" to use '.
'Ferret constraints.',
get_class($this)));
}
// See T13511. If we have a fulltext query which uses valid field
// functions, but at least one of the functions applies to a field which
// the object can never have, the query can never match anything. Detect
// this and return an empty result set.
// (Even if the query is "field is absent" or "field does not contain
// such-and-such", the interpretation is that these constraints are
// not meaningful when applied to an object which can never have the
// field.)
$functions = ipull($this->ferretTables, 'function');
$functions = mpull($functions, null, 'getFerretFunctionName');
foreach ($functions as $function) {
if (!$function->supportsObject($object)) {
throw new PhabricatorEmptyQueryException(
pht(
'This query uses a fulltext function which this document '.
'type does not support.'));
}
}
foreach ($this->ferretTables as $table) {
$alias = $table['alias'];
if (empty($table['optional'])) {
$join_type = qsprintf($conn, 'JOIN');
} else {
$join_type = qsprintf($conn, 'LEFT JOIN');
}
$joins[] = qsprintf(
$conn,
'%Q %T %T ON ft_doc.id = %T.documentID
AND %T.fieldKey = %s',
$join_type,
$field_table,
$alias,
$alias,
$alias,
$table['function']->getFerretFieldKey());
}
return $joins;
}
protected function buildFerretWhereClause(AphrontDatabaseConnection $conn) {
if (!$this->ferretEngine) {
return array();
}
$engine = $this->ferretEngine;
$stemmer = $engine->newStemmer();
$table_map = $this->ferretTables;
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
$op_exact = PhutilSearchQueryCompiler::OPERATOR_EXACT;
$op_absent = PhutilSearchQueryCompiler::OPERATOR_ABSENT;
$op_present = PhutilSearchQueryCompiler::OPERATOR_PRESENT;
$where = array();
$default_function = $engine->getDefaultFunctionKey();
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
$value = $raw_token->getValue();
$function = $raw_token->getFunction();
if ($function === null) {
$function = $default_function;
}
$operator = $raw_token->getOperator();
$table_alias = $table_map[$function]['alias'];
// If this is a "field is present" operator, we've already implicitly
// guaranteed this by JOINing the table. We don't need to do any
// more work.
$is_present = ($operator === $op_present);
if ($is_present) {
continue;
}
// If this is a "field is absent" operator, we just want documents
// which failed to match to a row when we LEFT JOINed the table. This
// means there's no index for the field.
$is_absent = ($operator === $op_absent);
if ($is_absent) {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus IS NULL)',
$table_alias);
continue;
}
$is_not = ($operator === $op_not);
if ($operator == $op_sub) {
$is_substring = true;
} else {
$is_substring = false;
}
// If we're doing exact search, just test the raw corpus.
$is_exact = ($operator === $op_exact);
if ($is_exact) {
if ($is_not) {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus != %s)',
$table_alias,
$value);
} else {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus = %s)',
$table_alias,
$value);
}
continue;
}
// If we're doing substring search, we just match against the raw corpus
// and we're done.
if ($is_substring) {
if ($is_not) {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus NOT LIKE %~)',
$table_alias,
$value);
} else {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus LIKE %~)',
$table_alias,
$value);
}
continue;
}
// Otherwise, we need to match against the term corpus and the normal
// corpus, so that searching for "raw" does not find "strawberry".
if ($raw_token->isQuoted()) {
$is_quoted = true;
$is_stemmed = false;
} else {
$is_quoted = false;
$is_stemmed = true;
}
// Never stem negated queries, since this can exclude results users
// did not mean to exclude and generally confuse things.
if ($is_not) {
$is_stemmed = false;
}
$term_constraints = array();
$term_value = $engine->newTermsCorpus($value);
if ($is_not) {
$term_constraints[] = qsprintf(
$conn,
'(%T.termCorpus NOT LIKE %~)',
$table_alias,
$term_value);
} else {
$term_constraints[] = qsprintf(
$conn,
'(%T.termCorpus LIKE %~)',
$table_alias,
$term_value);
}
if ($is_stemmed) {
$stem_value = $stemmer->stemToken($value);
$stem_value = $engine->newTermsCorpus($stem_value);
$term_constraints[] = qsprintf(
$conn,
'(%T.normalCorpus LIKE %~)',
$table_alias,
$stem_value);
}
if ($is_not) {
$where[] = qsprintf(
$conn,
'%LA',
$term_constraints);
} else if ($is_quoted) {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus LIKE %~ AND %LO)',
$table_alias,
$value,
$term_constraints);
} else {
$where[] = qsprintf(
$conn,
'%LO',
$term_constraints);
}
}
if ($this->ferretQuery) {
$query = $this->ferretQuery;
$author_phids = $query->getParameter('authorPHIDs');
if ($author_phids) {
$where[] = qsprintf(
$conn,
'ft_doc.authorPHID IN (%Ls)',
$author_phids);
}
$with_unowned = $query->getParameter('withUnowned');
$with_any = $query->getParameter('withAnyOwner');
if ($with_any && $with_unowned) {
throw new PhabricatorEmptyQueryException(
pht(
'This query matches only unowned documents owned by anyone, '.
'which is impossible.'));
}
$owner_phids = $query->getParameter('ownerPHIDs');
if ($owner_phids && !$with_any) {
if ($with_unowned) {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL',
$owner_phids);
} else {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IN (%Ls)',
$owner_phids);
}
} else if ($with_unowned) {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IS NULL');
}
if ($with_any) {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IS NOT NULL');
}
$rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN;
$statuses = $query->getParameter('statuses');
$is_closed = null;
if ($statuses) {
$statuses = array_fuse($statuses);
if (count($statuses) == 1) {
if (isset($statuses[$rel_open])) {
$is_closed = 0;
} else {
$is_closed = 1;
}
}
}
if ($is_closed !== null) {
$where[] = qsprintf(
$conn,
'ft_doc.isClosed = %d',
$is_closed);
}
}
return $where;
}
protected function shouldGroupFerretResultRows() {
return (bool)$this->ferretTokens;
}
/* -( Ngrams )------------------------------------------------------------- */
protected function withNgramsConstraint(
PhabricatorSearchNgrams $index,
$value) {
if (phutil_nonempty_string($value)) {
$this->ngrams[] = array(
'index' => $index,
'value' => $value,
'length' => count(phutil_utf8v($value)),
);
}
return $this;
}
protected function buildNgramsJoinClause(AphrontDatabaseConnection $conn) {
$ngram_engine = $this->getNgramEngine();
$flat = array();
foreach ($this->ngrams as $spec) {
$length = $spec['length'];
if ($length < 3) {
continue;
}
$index = $spec['index'];
$value = $spec['value'];
$ngrams = $ngram_engine->getSubstringNgramsFromString($value);
foreach ($ngrams as $ngram) {
$flat[] = array(
'table' => $index->getTableName(),
'ngram' => $ngram,
);
}
}
if (!$flat) {
return array();
}
// MySQL only allows us to join a maximum of 61 tables per query. Each
// ngram is going to cost us a join toward that limit, so if the user
// specified a very long query string, just pick 16 of the ngrams
// at random.
if (count($flat) > 16) {
shuffle($flat);
$flat = array_slice($flat, 0, 16);
}
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$id_column = qsprintf($conn, '%T.%T', $alias, 'id');
} else {
$id_column = qsprintf($conn, '%T', 'id');
}
$idx = 1;
$joins = array();
foreach ($flat as $spec) {
$table = $spec['table'];
$ngram = $spec['ngram'];
$alias = 'ngm'.$idx++;
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s',
$table,
$alias,
$alias,
$id_column,
$alias,
$ngram);
}
return $joins;
}
protected function buildNgramsWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
$ngram_engine = $this->getNgramEngine();
foreach ($this->ngrams as $ngram) {
$index = $ngram['index'];
$value = $ngram['value'];
$column = $index->getColumnName();
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$column = qsprintf($conn, '%T.%T', $alias, $column);
} else {
$column = qsprintf($conn, '%T', $column);
}
$tokens = $ngram_engine->tokenizeNgramString($value);
foreach ($tokens as $token) {
$where[] = qsprintf(
$conn,
'%Q LIKE %~',
$column,
$token);
}
}
return $where;
}
protected function shouldGroupNgramResultRows() {
return (bool)$this->ngrams;
}
private function getNgramEngine() {
if (!$this->ngramEngine) {
$this->ngramEngine = new PhabricatorSearchNgramEngine();
}
return $this->ngramEngine;
}
/* -( Edge Logic )--------------------------------------------------------- */
/**
* Convenience method for specifying edge logic constraints with a list of
* PHIDs.
*
* @param const $edge_type Edge constant.
* @param const $operator Constraint operator.
* @param list<phid> $phids List of PHIDs.
- * @return this
+ * @return $this
* @task edgelogic
*/
public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) {
$constraints = array();
foreach ($phids as $phid) {
$constraints[] = new PhabricatorQueryConstraint($operator, $phid);
}
return $this->withEdgeLogicConstraints($edge_type, $constraints);
}
/**
- * @return this
+ * @return $this
* @task edgelogic
*/
public function withEdgeLogicConstraints($edge_type, array $constraints) {
assert_instances_of($constraints, 'PhabricatorQueryConstraint');
$constraints = mgroup($constraints, 'getOperator');
foreach ($constraints as $operator => $list) {
foreach ($list as $item) {
$this->edgeLogicConstraints[$edge_type][$operator][] = $item;
}
}
$this->edgeLogicConstraintsAreValid = false;
return $this;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) {
$select = array();
$this->validateEdgeLogicConstraints();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_AND:
if (count($list) > 1) {
$select[] = qsprintf(
$conn,
'COUNT(DISTINCT(%T.dst)) %T',
$alias,
$this->buildEdgeLogicTableAliasCount($alias));
}
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
// This is tricky. We have a query which specifies multiple
// projects, each of which may have an arbitrarily large number
// of descendants.
// Suppose the projects are "Engineering" and "Operations", and
// "Engineering" has subprojects X, Y and Z.
// We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row
// is not part of Engineering at all, or some number other than
// 0 if it is.
// Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and
// any other value to an index (say, 1) for the ancestor.
// We build these up for every ancestor, then use `COALESCE(...)`
// to select the non-null one, giving us an ancestor which this
// row is a member of.
// From there, we use `COUNT(DISTINCT(...))` to make sure that
// each result row is a member of all ancestors.
if (count($list) > 1) {
$idx = 1;
$parts = array();
foreach ($list as $constraint) {
$parts[] = qsprintf(
$conn,
'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)',
$alias,
(array)$constraint->getValue(),
$idx++);
}
$parts = qsprintf($conn, '%LQ', $parts);
$select[] = qsprintf(
$conn,
'COUNT(DISTINCT(COALESCE(%Q))) %T',
$parts,
$this->buildEdgeLogicTableAliasAncestor($alias));
}
break;
default:
break;
}
}
}
return $select;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) {
$edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;
$phid_column = $this->getApplicationSearchObjectPHIDColumn($conn);
$joins = array();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
$has_null = isset($constraints[$op_null]);
// If we're going to process an only() operator, build a list of the
// acceptable set of PHIDs first. We'll only match results which have
// no edges to any other PHIDs.
$all_phids = array();
if (isset($constraints[PhabricatorQueryConstraint::OPERATOR_ONLY])) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
foreach ($list as $constraint) {
$value = (array)$constraint->getValue();
foreach ($value as $v) {
$all_phids[$v] = $v;
}
}
break;
}
}
}
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
$phids = array();
foreach ($list as $constraint) {
$value = (array)$constraint->getValue();
foreach ($value as $v) {
$phids[$v] = $v;
}
}
$phids = array_keys($phids);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_NOT:
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
AND %T.dst IN (%Ls)',
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type,
$alias,
$phids);
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
// If we're including results with no matches, we have to degrade
// this to a LEFT join. We'll use WHERE to select matching rows
// later.
if ($has_null) {
$join_type = qsprintf($conn, 'LEFT');
} else {
$join_type = qsprintf($conn, '');
}
$joins[] = qsprintf(
$conn,
'%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d
AND %T.dst IN (%Ls)',
$join_type,
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type,
$alias,
$phids);
break;
case PhabricatorQueryConstraint::OPERATOR_NULL:
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d',
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type);
break;
case PhabricatorQueryConstraint::OPERATOR_ONLY:
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
AND %T.dst NOT IN (%Ls)',
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type,
$alias,
$all_phids);
break;
}
}
}
return $joins;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
$full = array();
$null = array();
$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
$has_null = isset($constraints[$op_null]);
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_NOT:
case PhabricatorQueryConstraint::OPERATOR_ONLY:
$full[] = qsprintf(
$conn,
'%T.dst IS NULL',
$alias);
break;
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
if ($has_null) {
$full[] = qsprintf(
$conn,
'%T.dst IS NOT NULL',
$alias);
}
break;
case PhabricatorQueryConstraint::OPERATOR_NULL:
$null[] = qsprintf(
$conn,
'%T.dst IS NULL',
$alias);
break;
}
}
if ($full && $null) {
$where[] = qsprintf($conn, '(%LA OR %LA)', $full, $null);
} else if ($full) {
foreach ($full as $condition) {
$where[] = $condition;
}
} else if ($null) {
foreach ($null as $condition) {
$where[] = $condition;
}
}
}
return $where;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) {
$having = array();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_AND:
if (count($list) > 1) {
$having[] = qsprintf(
$conn,
'%T = %d',
$this->buildEdgeLogicTableAliasCount($alias),
count($list));
}
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
if (count($list) > 1) {
$having[] = qsprintf(
$conn,
'%T = %d',
$this->buildEdgeLogicTableAliasAncestor($alias),
count($list));
}
break;
}
}
}
return $having;
}
/**
* @task edgelogic
*/
public function shouldGroupEdgeLogicResultRows() {
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_NOT:
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
if (count($list) > 1) {
return true;
}
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
// NOTE: We must always group query results rows when using an
// "ANCESTOR" operator because a single task may be related to
// two different descendants of a particular ancestor. For
// discussion, see T12753.
return true;
case PhabricatorQueryConstraint::OPERATOR_NULL:
case PhabricatorQueryConstraint::OPERATOR_ONLY:
return true;
}
}
}
return false;
}
/**
* @task edgelogic
*/
private function getEdgeLogicTableAlias($operator, $type) {
return 'edgelogic_'.$operator.'_'.$type;
}
/**
* @task edgelogic
*/
private function buildEdgeLogicTableAliasCount($alias) {
return $alias.'_count';
}
/**
* @task edgelogic
*/
private function buildEdgeLogicTableAliasAncestor($alias) {
return $alias.'_ancestor';
}
/**
* Select certain edge logic constraint values.
*
* @task edgelogic
*/
protected function getEdgeLogicValues(
array $edge_types,
array $operators) {
$values = array();
$constraint_lists = $this->edgeLogicConstraints;
if ($edge_types) {
$constraint_lists = array_select_keys($constraint_lists, $edge_types);
}
foreach ($constraint_lists as $type => $constraints) {
if ($operators) {
$constraints = array_select_keys($constraints, $operators);
}
foreach ($constraints as $operator => $list) {
foreach ($list as $constraint) {
$value = (array)$constraint->getValue();
foreach ($value as $v) {
$values[] = $v;
}
}
}
}
return $values;
}
/**
* Validate edge logic constraints for the query.
*
- * @return this
+ * @return $this
* @task edgelogic
*/
private function validateEdgeLogicConstraints() {
if ($this->edgeLogicConstraintsAreValid) {
return $this;
}
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_EMPTY:
throw new PhabricatorEmptyQueryException(
pht('This query specifies an empty constraint.'));
}
}
}
// This should probably be more modular, eventually, but we only do
// project-based edge logic today.
$project_phids = $this->getEdgeLogicValues(
array(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
),
array(
PhabricatorQueryConstraint::OPERATOR_AND,
PhabricatorQueryConstraint::OPERATOR_OR,
PhabricatorQueryConstraint::OPERATOR_NOT,
PhabricatorQueryConstraint::OPERATOR_ANCESTOR,
));
if ($project_phids) {
$projects = id(new PhabricatorProjectQuery())
->setViewer($this->getViewer())
->setParentQuery($this)
->withPHIDs($project_phids)
->execute();
$projects = mpull($projects, null, 'getPHID');
foreach ($project_phids as $phid) {
if (empty($projects[$phid])) {
throw new PhabricatorEmptyQueryException(
pht(
'This query is constrained by a project you do not have '.
'permission to see.'));
}
}
}
$op_and = PhabricatorQueryConstraint::OPERATOR_AND;
$op_or = PhabricatorQueryConstraint::OPERATOR_OR;
$op_ancestor = PhabricatorQueryConstraint::OPERATOR_ANCESTOR;
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_ONLY:
if (count($list) > 1) {
throw new PhabricatorEmptyQueryException(
pht(
'This query specifies only() more than once.'));
}
$have_and = idx($constraints, $op_and);
$have_or = idx($constraints, $op_or);
$have_ancestor = idx($constraints, $op_ancestor);
if (!$have_and && !$have_or && !$have_ancestor) {
throw new PhabricatorEmptyQueryException(
pht(
'This query specifies only(), but no other constraints '.
'which it can apply to.'));
}
break;
}
}
}
$this->edgeLogicConstraintsAreValid = true;
return $this;
}
/* -( Spaces )------------------------------------------------------------- */
/**
* Constrain the query to return results from only specific Spaces.
*
* Pass a list of Space PHIDs, or `null` to represent the default space. Only
* results in those Spaces will be returned.
*
* Queries are always constrained to include only results from spaces the
* viewer has access to.
*
* @param list<phid|null> $space_phids
* @task spaces
*/
public function withSpacePHIDs(array $space_phids) {
$object = $this->newResultObject();
if (!$object) {
throw new Exception(
pht(
'This query (of class "%s") does not implement newResultObject(), '.
'but must implement this method to enable support for Spaces.',
get_class($this)));
}
if (!($object instanceof PhabricatorSpacesInterface)) {
throw new Exception(
pht(
'This query (of class "%s") returned an object of class "%s" from '.
'getNewResultObject(), but it does not implement the required '.
'interface ("%s"). Objects must implement this interface to enable '.
'Spaces support.',
get_class($this),
get_class($object),
'PhabricatorSpacesInterface'));
}
$this->spacePHIDs = $space_phids;
return $this;
}
public function withSpaceIsArchived($archived) {
$this->spaceIsArchived = $archived;
return $this;
}
/**
* Constrain the query to include only results in valid Spaces.
*
* This method builds part of a WHERE clause which considers the spaces the
* viewer has access to see with any explicit constraint on spaces added by
* @{method:withSpacePHIDs}.
*
* @param AphrontDatabaseConnection $conn Database connection.
* @return string Part of a WHERE clause.
* @task spaces
*/
private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) {
$object = $this->newResultObject();
if (!$object) {
return null;
}
if (!($object instanceof PhabricatorSpacesInterface)) {
return null;
}
$viewer = $this->getViewer();
// If we have an omnipotent viewer and no formal space constraints, don't
// emit a clause. This primarily enables older migrations to run cleanly,
// without fataling because they try to match a `spacePHID` column which
// does not exist yet. See T8743, T8746.
if ($viewer->isOmnipotent()) {
if ($this->spaceIsArchived === null && $this->spacePHIDs === null) {
return null;
}
}
// See T13240. If this query raises policy exceptions, don't filter objects
// in the MySQL layer. We want them to reach the application layer so we
// can reject them and raise an exception.
if ($this->shouldRaisePolicyExceptions()) {
return null;
}
$space_phids = array();
$include_null = false;
$all = PhabricatorSpacesNamespaceQuery::getAllSpaces();
if (!$all) {
// If there are no spaces at all, implicitly give the viewer access to
// the default space.
$include_null = true;
} else {
// Otherwise, give them access to the spaces they have permission to
// see.
$viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(
$viewer);
foreach ($viewer_spaces as $viewer_space) {
if ($this->spaceIsArchived !== null) {
if ($viewer_space->getIsArchived() != $this->spaceIsArchived) {
continue;
}
}
$phid = $viewer_space->getPHID();
$space_phids[$phid] = $phid;
if ($viewer_space->getIsDefaultNamespace()) {
$include_null = true;
}
}
}
// If we have additional explicit constraints, evaluate them now.
if ($this->spacePHIDs !== null) {
$explicit = array();
$explicit_null = false;
foreach ($this->spacePHIDs as $phid) {
if ($phid === null) {
$space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
} else {
$space = idx($all, $phid);
}
if ($space) {
$phid = $space->getPHID();
$explicit[$phid] = $phid;
if ($space->getIsDefaultNamespace()) {
$explicit_null = true;
}
}
}
// If the viewer can see the default space but it isn't on the explicit
// list of spaces to query, don't match it.
if ($include_null && !$explicit_null) {
$include_null = false;
}
// Include only the spaces common to the viewer and the constraints.
$space_phids = array_intersect_key($space_phids, $explicit);
}
if (!$space_phids && !$include_null) {
if ($this->spacePHIDs === null) {
throw new PhabricatorEmptyQueryException(
pht('You do not have access to any spaces.'));
} else {
throw new PhabricatorEmptyQueryException(
pht(
'You do not have access to any of the spaces this query '.
'is constrained to.'));
}
}
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$col = qsprintf($conn, '%T.spacePHID', $alias);
} else {
$col = qsprintf($conn, 'spacePHID');
}
if ($space_phids && $include_null) {
return qsprintf(
$conn,
'(%Q IN (%Ls) OR %Q IS NULL)',
$col,
$space_phids,
$col);
} else if ($space_phids) {
return qsprintf(
$conn,
'%Q IN (%Ls)',
$col,
$space_phids);
} else {
return qsprintf(
$conn,
'%Q IS NULL',
$col);
}
}
private function hasFerretOrder() {
$vector = $this->getOrderVector();
if ($vector->containsKey('rank')) {
return true;
}
if ($vector->containsKey('fulltext-created')) {
return true;
}
if ($vector->containsKey('fulltext-modified')) {
return true;
}
return false;
}
}
diff --git a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
index 1faf45d63d..a48d64b1ae 100644
--- a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
+++ b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php
@@ -1,784 +1,784 @@
<?php
/**
* A @{class:PhabricatorQuery} which filters results according to visibility
* policies for the querying user. Broadly, this class allows you to implement
* a query that returns only objects the user is allowed to see.
*
* $results = id(new ExampleQuery())
* ->setViewer($user)
* ->withConstraint($example)
* ->execute();
*
* Normally, you should extend @{class:PhabricatorCursorPagedPolicyAwareQuery},
* not this class. @{class:PhabricatorCursorPagedPolicyAwareQuery} provides a
* more practical interface for building usable queries against most object
* types.
*
* NOTE: Although this class extends @{class:PhabricatorOffsetPagedQuery},
* offset paging with policy filtering is not efficient. All results must be
* loaded into the application and filtered here: skipping `N` rows via offset
* is an `O(N)` operation with a large constant. Prefer cursor-based paging
* with @{class:PhabricatorCursorPagedPolicyAwareQuery}, which can filter far
* more efficiently in MySQL.
*
* @task config Query Configuration
* @task exec Executing Queries
* @task policyimpl Policy Query Implementation
*/
abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery {
private $viewer;
private $parentQuery;
private $rawResultLimit;
private $capabilities;
private $workspace = array();
private $inFlightPHIDs = array();
private $policyFilteredPHIDs = array();
/**
* Should we continue or throw an exception when a query result is filtered
* by policy rules?
*
* Values are `true` (raise exceptions), `false` (do not raise exceptions)
* and `null` (inherit from parent query, with no exceptions by default).
*/
private $raisePolicyExceptions;
private $isOverheated;
private $returnPartialResultsOnOverheat;
private $disableOverheating;
/* -( Query Configuration )------------------------------------------------ */
/**
* Set the viewer who is executing the query. Results will be filtered
* according to the viewer's capabilities. You must set a viewer to execute
* a policy query.
*
* @param PhabricatorUser $viewer The viewing user.
- * @return this
+ * @return $this
* @task config
*/
final public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
/**
* Get the query's viewer.
*
* @return PhabricatorUser The viewing user.
* @task config
*/
final public function getViewer() {
return $this->viewer;
}
/**
* Set the parent query of this query. This is useful for nested queries so
* that configuration like whether or not to raise policy exceptions is
* seamlessly passed along to child queries.
*
- * @return this
+ * @return $this
* @task config
*/
final public function setParentQuery(PhabricatorPolicyAwareQuery $query) {
$this->parentQuery = $query;
return $this;
}
/**
* Get the parent query. See @{method:setParentQuery} for discussion.
*
* @return PhabricatorPolicyAwareQuery The parent query.
* @task config
*/
final public function getParentQuery() {
return $this->parentQuery;
}
/**
* Hook to configure whether this query should raise policy exceptions.
*
- * @return this
+ * @return $this
* @task config
*/
final public function setRaisePolicyExceptions($bool) {
$this->raisePolicyExceptions = $bool;
return $this;
}
/**
* @return bool
* @task config
*/
final public function shouldRaisePolicyExceptions() {
return (bool)$this->raisePolicyExceptions;
}
/**
* @task config
*/
final public function requireCapabilities(array $capabilities) {
$this->capabilities = $capabilities;
return $this;
}
final public function setReturnPartialResultsOnOverheat($bool) {
$this->returnPartialResultsOnOverheat = $bool;
return $this;
}
final public function setDisableOverheating($disable_overheating) {
$this->disableOverheating = $disable_overheating;
return $this;
}
/* -( Query Execution )---------------------------------------------------- */
/**
* Execute the query, expecting a single result. This method simplifies
* loading objects for detail pages or edit views.
*
* // Load one result by ID.
* $obj = id(new ExampleQuery())
* ->setViewer($user)
* ->withIDs(array($id))
* ->executeOne();
* if (!$obj) {
* return new Aphront404Response();
* }
*
* If zero results match the query, this method returns `null`.
* If one result matches the query, this method returns that result.
*
* If two or more results match the query, this method throws an exception.
* You should use this method only when the query constraints guarantee at
* most one match (e.g., selecting a specific ID or PHID).
*
* If one result matches the query but it is caught by the policy filter (for
* example, the user is trying to view or edit an object which exists but
* which they do not have permission to see) a policy exception is thrown.
*
* @return mixed Single result, or null.
* @task exec
*/
final public function executeOne() {
$this->setRaisePolicyExceptions(true);
try {
$results = $this->execute();
} catch (Exception $ex) {
$this->setRaisePolicyExceptions(false);
throw $ex;
}
if (count($results) > 1) {
throw new Exception(pht('Expected a single result!'));
}
if (!$results) {
return null;
}
return head($results);
}
/**
* Execute the query, loading all visible results.
*
* @return list<PhabricatorPolicyInterface> Result objects.
* @task exec
*/
final public function execute() {
if (!$this->viewer) {
throw new PhutilInvalidStateException('setViewer');
}
$parent_query = $this->getParentQuery();
if ($parent_query && ($this->raisePolicyExceptions === null)) {
$this->setRaisePolicyExceptions(
$parent_query->shouldRaisePolicyExceptions());
}
$results = array();
$filter = $this->getPolicyFilter();
$offset = (int)$this->getOffset();
$limit = (int)$this->getLimit();
$count = 0;
if ($limit) {
$need = $offset + $limit;
} else {
$need = 0;
}
$this->willExecute();
// If we examine and filter significantly more objects than the query
// limit, we stop early. This prevents us from looping through a huge
// number of records when the viewer can see few or none of them. See
// T11773 for some discussion.
$this->isOverheated = false;
// See T13386. If we are on an old offset-based paging workflow, we need
// to base the overheating limit on both the offset and limit.
$overheat_limit = $need * 10;
$total_seen = 0;
do {
if ($need) {
$this->rawResultLimit = min($need - $count, 1024);
} else {
$this->rawResultLimit = 0;
}
if ($this->canViewerUseQueryApplication()) {
try {
$page = $this->loadPage();
} catch (PhabricatorEmptyQueryException $ex) {
$page = array();
}
} else {
$page = array();
}
$total_seen += count($page);
if ($page) {
$maybe_visible = $this->willFilterPage($page);
if ($maybe_visible) {
$maybe_visible = $this->applyWillFilterPageExtensions($maybe_visible);
}
} else {
$maybe_visible = array();
}
if ($this->shouldDisablePolicyFiltering()) {
$visible = $maybe_visible;
} else {
$visible = $filter->apply($maybe_visible);
$policy_filtered = array();
foreach ($maybe_visible as $key => $object) {
if (empty($visible[$key])) {
$phid = $object->getPHID();
if ($phid) {
$policy_filtered[$phid] = $phid;
}
}
}
$this->addPolicyFilteredPHIDs($policy_filtered);
}
if ($visible) {
$visible = $this->didFilterPage($visible);
}
$removed = array();
foreach ($maybe_visible as $key => $object) {
if (empty($visible[$key])) {
$removed[$key] = $object;
}
}
$this->didFilterResults($removed);
// NOTE: We call "nextPage()" before checking if we've found enough
// results because we want to build the internal cursor object even
// if we don't need to execute another query: the internal cursor may
// be used by a parent query that is using this query to translate an
// external cursor into an internal cursor.
$this->nextPage($page);
foreach ($visible as $key => $result) {
++$count;
// If we have an offset, we just ignore that many results and start
// storing them only once we've hit the offset. This reduces memory
// requirements for large offsets, compared to storing them all and
// slicing them away later.
if ($count > $offset) {
$results[$key] = $result;
}
if ($need && ($count >= $need)) {
// If we have all the rows we need, break out of the paging query.
break 2;
}
}
if (!$this->rawResultLimit) {
// If we don't have a load count, we loaded all the results. We do
// not need to load another page.
break;
}
if (count($page) < $this->rawResultLimit) {
// If we have a load count but the unfiltered results contained fewer
// objects, we know this was the last page of objects; we do not need
// to load another page because we can deduce it would be empty.
break;
}
if (!$this->disableOverheating) {
if ($overheat_limit && ($total_seen >= $overheat_limit)) {
$this->isOverheated = true;
if (!$this->returnPartialResultsOnOverheat) {
throw new Exception(
pht(
'Query (of class "%s") overheated: examined more than %s '.
'raw rows without finding %s visible objects.',
get_class($this),
new PhutilNumber($overheat_limit),
new PhutilNumber($need)));
}
break;
}
}
} while (true);
$results = $this->didLoadResults($results);
return $results;
}
private function getPolicyFilter() {
$filter = new PhabricatorPolicyFilter();
$filter->setViewer($this->viewer);
$capabilities = $this->getRequiredCapabilities();
$filter->requireCapabilities($capabilities);
$filter->raisePolicyExceptions($this->shouldRaisePolicyExceptions());
return $filter;
}
protected function getRequiredCapabilities() {
if ($this->capabilities) {
return $this->capabilities;
}
return array(
PhabricatorPolicyCapability::CAN_VIEW,
);
}
protected function applyPolicyFilter(array $objects, array $capabilities) {
if ($this->shouldDisablePolicyFiltering()) {
return $objects;
}
$filter = $this->getPolicyFilter();
$filter->requireCapabilities($capabilities);
return $filter->apply($objects);
}
protected function didRejectResult(PhabricatorPolicyInterface $object) {
// Some objects (like commits) may be rejected because related objects
// (like repositories) can not be loaded. In some cases, we may need these
// related objects to determine the object policy, so it's expected that
// we may occasionally be unable to determine the policy.
try {
$policy = $object->getPolicy(PhabricatorPolicyCapability::CAN_VIEW);
} catch (Exception $ex) {
$policy = null;
}
// Mark this object as filtered so handles can render "Restricted" instead
// of "Unknown".
$phid = $object->getPHID();
$this->addPolicyFilteredPHIDs(array($phid => $phid));
$this->getPolicyFilter()->rejectObject(
$object,
$policy,
PhabricatorPolicyCapability::CAN_VIEW);
}
public function addPolicyFilteredPHIDs(array $phids) {
$this->policyFilteredPHIDs += $phids;
if ($this->getParentQuery()) {
$this->getParentQuery()->addPolicyFilteredPHIDs($phids);
}
return $this;
}
public function getIsOverheated() {
if ($this->isOverheated === null) {
throw new PhutilInvalidStateException('execute');
}
return $this->isOverheated;
}
/**
* Return a map of all object PHIDs which were loaded in the query but
* filtered out by policy constraints. This allows a caller to distinguish
* between objects which do not exist (or, at least, were filtered at the
* content level) and objects which exist but aren't visible.
*
* @return map<phid, phid> Map of object PHIDs which were filtered
* by policies.
* @task exec
*/
public function getPolicyFilteredPHIDs() {
return $this->policyFilteredPHIDs;
}
/* -( Query Workspace )---------------------------------------------------- */
/**
* Put a map of objects into the query workspace. Many queries perform
* subqueries, which can eventually end up loading the same objects more than
* once (often to perform policy checks).
*
* For example, loading a user may load the user's profile image, which might
* load the user object again in order to verify that the viewer has
* permission to see the file.
*
* The "query workspace" allows queries to load objects from elsewhere in a
* query block instead of refetching them.
*
* When using the query workspace, it's important to obey two rules:
*
* **Never put objects into the workspace which the viewer may not be able
* to see**. You need to apply all policy filtering //before// putting
* objects in the workspace. Otherwise, subqueries may read the objects and
* use them to permit access to content the user shouldn't be able to view.
*
* **Fully enrich objects pulled from the workspace.** After pulling objects
* from the workspace, you still need to load and attach any additional
* content the query requests. Otherwise, a query might return objects
* without requested content.
*
* Generally, you do not need to update the workspace yourself: it is
* automatically populated as a side effect of objects surviving policy
* filtering.
*
* @param map<phid, PhabricatorPolicyInterface> $objects Objects to add to
* the query workspace.
- * @return this
+ * @return $this
* @task workspace
*/
public function putObjectsInWorkspace(array $objects) {
$parent = $this->getParentQuery();
if ($parent) {
$parent->putObjectsInWorkspace($objects);
return $this;
}
assert_instances_of($objects, 'PhabricatorPolicyInterface');
$viewer_fragment = $this->getViewer()->getCacheFragment();
// The workspace is scoped per viewer to prevent accidental contamination.
if (empty($this->workspace[$viewer_fragment])) {
$this->workspace[$viewer_fragment] = array();
}
$this->workspace[$viewer_fragment] += $objects;
return $this;
}
/**
* Retrieve objects from the query workspace. For more discussion about the
* workspace mechanism, see @{method:putObjectsInWorkspace}. This method
* searches both the current query's workspace and the workspaces of parent
* queries.
*
* @param list<phid> $phids List of PHIDs to retrieve.
- * @return this
+ * @return $this
* @task workspace
*/
public function getObjectsFromWorkspace(array $phids) {
$parent = $this->getParentQuery();
if ($parent) {
return $parent->getObjectsFromWorkspace($phids);
}
$viewer_fragment = $this->getViewer()->getCacheFragment();
$results = array();
foreach ($phids as $key => $phid) {
if (isset($this->workspace[$viewer_fragment][$phid])) {
$results[$phid] = $this->workspace[$viewer_fragment][$phid];
unset($phids[$key]);
}
}
return $results;
}
/**
* Mark PHIDs as in flight.
*
* PHIDs which are "in flight" are actively being queried for. Using this
* list can prevent infinite query loops by aborting queries which cycle.
*
* @param list<phid> $phids List of PHIDs which are now in flight.
- * @return this
+ * @return $this
*/
public function putPHIDsInFlight(array $phids) {
foreach ($phids as $phid) {
$this->inFlightPHIDs[$phid] = $phid;
}
return $this;
}
/**
* Get PHIDs which are currently in flight.
*
* PHIDs which are "in flight" are actively being queried for.
*
* @return map<phid, phid> PHIDs currently in flight.
*/
public function getPHIDsInFlight() {
$results = $this->inFlightPHIDs;
if ($this->getParentQuery()) {
$results += $this->getParentQuery()->getPHIDsInFlight();
}
return $results;
}
/* -( Policy Query Implementation )---------------------------------------- */
/**
* Get the number of results @{method:loadPage} should load. If the value is
* 0, @{method:loadPage} should load all available results.
*
* @return int The number of results to load, or 0 for all results.
* @task policyimpl
*/
final protected function getRawResultLimit() {
return $this->rawResultLimit;
}
/**
* Hook invoked before query execution. Generally, implementations should
* reset any internal cursors.
*
* @return void
* @task policyimpl
*/
protected function willExecute() {
return;
}
/**
* Load a raw page of results. Generally, implementations should load objects
* from the database. They should attempt to return the number of results
* hinted by @{method:getRawResultLimit}.
*
* @return list<PhabricatorPolicyInterface> List of filterable policy objects.
* @task policyimpl
*/
abstract protected function loadPage();
/**
* Update internal state so that the next call to @{method:loadPage} will
* return new results. Generally, you should adjust a cursor position based
* on the provided result page.
*
* @param list<PhabricatorPolicyInterface> $page The current page of results.
* @return void
* @task policyimpl
*/
abstract protected function nextPage(array $page);
/**
* Hook for applying a page filter prior to the privacy filter. This allows
* you to drop some items from the result set without creating problems with
* pagination or cursor updates. You can also load and attach data which is
* required to perform policy filtering.
*
* Generally, you should load non-policy data and perform non-policy filtering
* later, in @{method:didFilterPage}. Strictly fewer objects will make it that
* far (so the program will load less data) and subqueries from that context
* can use the query workspace to further reduce query load.
*
* This method will only be called if data is available. Implementations
* do not need to handle the case of no results specially.
*
* @param list<wild> $page Results from `loadPage()`.
* @return list<PhabricatorPolicyInterface> Objects for policy filtering.
* @task policyimpl
*/
protected function willFilterPage(array $page) {
return $page;
}
/**
* Hook for performing additional non-policy loading or filtering after an
* object has satisfied all policy checks. Generally, this means loading and
* attaching related data.
*
* Subqueries executed during this phase can use the query workspace, which
* may improve performance or make circular policies resolvable. Data which
* is not necessary for policy filtering should generally be loaded here.
*
* This callback can still filter objects (for example, if attachable data
* is discovered to not exist), but should not do so for policy reasons.
*
* This method will only be called if data is available. Implementations do
* not need to handle the case of no results specially.
*
* @param list<wild> $page Results from @{method:willFilterPage()}.
* @return list<PhabricatorPolicyInterface> Objects after additional
* non-policy processing.
*/
protected function didFilterPage(array $page) {
return $page;
}
/**
* Hook for removing filtered results from alternate result sets. This
* hook will be called with any objects which were returned by the query but
* filtered for policy reasons. The query should remove them from any cached
* or partial result sets.
*
* @param list<wild> $results List of objects that should not be returned by
* alternate result mechanisms.
* @return void
* @task policyimpl
*/
protected function didFilterResults(array $results) {
return;
}
/**
* Hook for applying final adjustments before results are returned. This is
* used by @{class:PhabricatorCursorPagedPolicyAwareQuery} to reverse results
* that are queried during reverse paging.
*
* @param list<PhabricatorPolicyInterface> $results Query results.
* @return list<PhabricatorPolicyInterface> Final results.
* @task policyimpl
*/
protected function didLoadResults(array $results) {
return $results;
}
/**
* Allows a subclass to disable policy filtering. This method is dangerous.
* It should be used only if the query loads data which has already been
* filtered (for example, because it wraps some other query which uses
* normal policy filtering).
*
* @return bool True to disable all policy filtering.
* @task policyimpl
*/
protected function shouldDisablePolicyFiltering() {
return false;
}
/**
* If this query belongs to an application, return the application class name
* here. This will prevent the query from returning results if the viewer can
* not access the application.
*
* If this query does not belong to an application, return `null`.
*
* @return string|null Application class name.
*/
abstract public function getQueryApplicationClass();
/**
* Determine if the viewer has permission to use this query's application.
* For queries which aren't part of an application, this method always returns
* true.
*
* @return bool True if the viewer has application-level permission to
* execute the query.
*/
public function canViewerUseQueryApplication() {
$class = $this->getQueryApplicationClass();
if (!$class) {
return true;
}
$viewer = $this->getViewer();
return PhabricatorApplication::isClassInstalledForViewer($class, $viewer);
}
private function applyWillFilterPageExtensions(array $page) {
$bridges = array();
foreach ($page as $key => $object) {
if ($object instanceof DoorkeeperBridgedObjectInterface) {
$bridges[$key] = $object;
}
}
if ($bridges) {
$external_phids = array();
foreach ($bridges as $bridge) {
$external_phid = $bridge->getBridgedObjectPHID();
if ($external_phid) {
$external_phids[$key] = $external_phid;
}
}
if ($external_phids) {
$external_objects = id(new DoorkeeperExternalObjectQuery())
->setViewer($this->getViewer())
->withPHIDs($external_phids)
->execute();
$external_objects = mpull($external_objects, null, 'getPHID');
} else {
$external_objects = array();
}
foreach ($bridges as $key => $bridge) {
$external_phid = idx($external_phids, $key);
if (!$external_phid) {
$bridge->attachBridgedObject(null);
continue;
}
$external_object = idx($external_objects, $external_phid);
if (!$external_object) {
$this->didRejectResult($bridge);
unset($page[$key]);
continue;
}
$bridge->attachBridgedObject($external_object);
}
}
return $page;
}
}
diff --git a/src/infrastructure/storage/connection/AphrontDatabaseConnection.php b/src/infrastructure/storage/connection/AphrontDatabaseConnection.php
index b3bd2c8299..0d4597b34f 100644
--- a/src/infrastructure/storage/connection/AphrontDatabaseConnection.php
+++ b/src/infrastructure/storage/connection/AphrontDatabaseConnection.php
@@ -1,305 +1,305 @@
<?php
/**
* @task xaction Transaction Management
*/
abstract class AphrontDatabaseConnection
extends Phobject
implements PhutilQsprintfInterface {
private $transactionState;
private $readOnly;
private $queryTimeout;
private $locks = array();
private $lastActiveEpoch;
private $persistent;
abstract public function getInsertID();
abstract public function getAffectedRows();
abstract public function selectAllResults();
abstract public function executeQuery(PhutilQueryString $query);
abstract public function executeRawQueries(array $raw_queries);
abstract public function close();
abstract public function openConnection();
public function __destruct() {
// NOTE: This does not actually close persistent connections: PHP maintains
// them in the connection pool.
$this->close();
}
final public function setLastActiveEpoch($epoch) {
$this->lastActiveEpoch = $epoch;
return $this;
}
final public function getLastActiveEpoch() {
return $this->lastActiveEpoch;
}
final public function setPersistent($persistent) {
$this->persistent = $persistent;
return $this;
}
final public function getPersistent() {
return $this->persistent;
}
public function queryData($pattern/* , $arg, $arg, ... */) {
$args = func_get_args();
array_unshift($args, $this);
return call_user_func_array('queryfx_all', $args);
}
public function query($pattern/* , $arg, $arg, ... */) {
$args = func_get_args();
array_unshift($args, $this);
return call_user_func_array('queryfx', $args);
}
public function supportsAsyncQueries() {
return false;
}
public function supportsParallelQueries() {
return false;
}
public function setReadOnly($read_only) {
$this->readOnly = $read_only;
return $this;
}
public function getReadOnly() {
return $this->readOnly;
}
public function setQueryTimeout($query_timeout) {
$this->queryTimeout = $query_timeout;
return $this;
}
public function getQueryTimeout() {
return $this->queryTimeout;
}
public function asyncQuery($raw_query) {
throw new Exception(pht('Async queries are not supported.'));
}
public static function resolveAsyncQueries(array $conns, array $asyncs) {
throw new Exception(pht('Async queries are not supported.'));
}
/**
* Is this connection idle and safe to close?
*
* A connection is "idle" if it can be safely closed without loss of state.
* Connections inside a transaction or holding locks are not idle, even
* though they may not actively be executing queries.
*
* @return bool True if the connection is idle and can be safely closed.
*/
public function isIdle() {
if ($this->isInsideTransaction()) {
return false;
}
if ($this->isHoldingAnyLock()) {
return false;
}
return true;
}
/* -( Global Locks )------------------------------------------------------- */
public function rememberLock($lock) {
if (isset($this->locks[$lock])) {
throw new Exception(
pht(
'Trying to remember lock "%s", but this lock has already been '.
'remembered.',
$lock));
}
$this->locks[$lock] = true;
return $this;
}
public function forgetLock($lock) {
if (empty($this->locks[$lock])) {
throw new Exception(
pht(
'Trying to forget lock "%s", but this connection does not remember '.
'that lock.',
$lock));
}
unset($this->locks[$lock]);
return $this;
}
public function forgetAllLocks() {
$this->locks = array();
return $this;
}
public function isHoldingAnyLock() {
return (bool)$this->locks;
}
/* -( Transaction Management )--------------------------------------------- */
/**
* Begin a transaction, or set a savepoint if the connection is already
* transactional.
*
- * @return this
+ * @return $this
* @task xaction
*/
public function openTransaction() {
$state = $this->getTransactionState();
$point = $state->getSavepointName();
$depth = $state->getDepth();
$new_transaction = ($depth == 0);
if ($new_transaction) {
$this->query('START TRANSACTION');
} else {
$this->query('SAVEPOINT '.$point);
}
$state->increaseDepth();
return $this;
}
/**
* Commit a transaction, or stage a savepoint for commit once the entire
* transaction completes if inside a transaction stack.
*
- * @return this
+ * @return $this
* @task xaction
*/
public function saveTransaction() {
$state = $this->getTransactionState();
$depth = $state->decreaseDepth();
if ($depth == 0) {
$this->query('COMMIT');
}
return $this;
}
/**
* Rollback a transaction, or unstage the last savepoint if inside a
* transaction stack.
*
- * @return this
+ * @return $this
*/
public function killTransaction() {
$state = $this->getTransactionState();
$depth = $state->decreaseDepth();
if ($depth == 0) {
$this->query('ROLLBACK');
} else {
$this->query('ROLLBACK TO SAVEPOINT '.$state->getSavepointName());
}
return $this;
}
/**
* Returns true if the connection is transactional.
*
* @return bool True if the connection is currently transactional.
* @task xaction
*/
public function isInsideTransaction() {
$state = $this->getTransactionState();
return ($state->getDepth() > 0);
}
/**
* Get the current @{class:AphrontDatabaseTransactionState} object, or create
* one if none exists.
*
* @return AphrontDatabaseTransactionState Current transaction state.
* @task xaction
*/
protected function getTransactionState() {
if (!$this->transactionState) {
$this->transactionState = new AphrontDatabaseTransactionState();
}
return $this->transactionState;
}
/**
* @task xaction
*/
public function beginReadLocking() {
$this->getTransactionState()->beginReadLocking();
return $this;
}
/**
* @task xaction
*/
public function endReadLocking() {
$this->getTransactionState()->endReadLocking();
return $this;
}
/**
* @task xaction
*/
public function isReadLocking() {
return $this->getTransactionState()->isReadLocking();
}
/**
* @task xaction
*/
public function beginWriteLocking() {
$this->getTransactionState()->beginWriteLocking();
return $this;
}
/**
* @task xaction
*/
public function endWriteLocking() {
$this->getTransactionState()->endWriteLocking();
return $this;
}
/**
* @task xaction
*/
public function isWriteLocking() {
return $this->getTransactionState()->isWriteLocking();
}
}
diff --git a/src/infrastructure/storage/lisk/LiskDAO.php b/src/infrastructure/storage/lisk/LiskDAO.php
index db6554bacb..f222772daf 100644
--- a/src/infrastructure/storage/lisk/LiskDAO.php
+++ b/src/infrastructure/storage/lisk/LiskDAO.php
@@ -1,1923 +1,1923 @@
<?php
/**
* Simple object-authoritative data access object that makes it easy to build
* stuff that you need to save to a database. Basically, it means that the
* amount of boilerplate code (and, particularly, boilerplate SQL) you need
* to write is greatly reduced.
*
* Lisk makes it fairly easy to build something quickly and end up with
* reasonably high-quality code when you're done (e.g., getters and setters,
* objects, transactions, reasonably structured OO code). It's also very thin:
* you can break past it and use MySQL and other lower-level tools when you
* need to in those couple of cases where it doesn't handle your workflow
* gracefully.
*
* However, Lisk won't scale past one database and lacks many of the features
* of modern DAOs like Hibernate: for instance, it does not support joins or
* polymorphic storage.
*
* This means that Lisk is well-suited for tools like Differential, but often a
* poor choice elsewhere. And it is strictly unsuitable for many projects.
*
* Lisk's model is object-authoritative: the PHP class definition is the
* master authority for what the object looks like.
*
* =Building New Objects=
*
* To create new Lisk objects, extend @{class:LiskDAO} and implement
* @{method:establishLiveConnection}. It should return an
* @{class:AphrontDatabaseConnection}; this will tell Lisk where to save your
* objects.
*
* class Dog extends LiskDAO {
*
* protected $name;
* protected $breed;
*
* public function establishLiveConnection() {
* return $some_connection_object;
* }
* }
*
* Now, you should create your table:
*
* lang=sql
* CREATE TABLE dog (
* id int unsigned not null auto_increment primary key,
* name varchar(32) not null,
* breed varchar(32) not null,
* dateCreated int unsigned not null,
* dateModified int unsigned not null
* );
*
* For each property in your class, add a column with the same name to the table
* (see @{method:getConfiguration} for information about changing this mapping).
* Additionally, you should create the three columns `id`, `dateCreated` and
* `dateModified`. Lisk will automatically manage these, using them to implement
* autoincrement IDs and timestamps. If you do not want to use these features,
* see @{method:getConfiguration} for information on disabling them. At a bare
* minimum, you must normally have an `id` column which is a primary or unique
* key with a numeric type, although you can change its name by overriding
* @{method:getIDKey} or disable it entirely by overriding @{method:getIDKey} to
* return null. Note that many methods rely on a single-part primary key and
* will no longer work (they will throw) if you disable it.
*
* As you add more properties to your class in the future, remember to add them
* to the database table as well.
*
* Lisk will now automatically handle these operations: getting and setting
* properties, saving objects, loading individual objects, loading groups
* of objects, updating objects, managing IDs, updating timestamps whenever
* an object is created or modified, and some additional specialized
* operations.
*
* = Creating, Retrieving, Updating, and Deleting =
*
* To create and persist a Lisk object, use @{method:save}:
*
* $dog = id(new Dog())
* ->setName('Sawyer')
* ->setBreed('Pug')
* ->save();
*
* Note that **Lisk automatically builds getters and setters for all of your
* object's protected properties** via @{method:__call}. If you want to add
* custom behavior to your getters or setters, you can do so by overriding the
* @{method:readField} and @{method:writeField} methods.
*
* Calling @{method:save} will persist the object to the database. After calling
* @{method:save}, you can call @{method:getID} to retrieve the object's ID.
*
* To load objects by ID, use the @{method:load} method:
*
* $dog = id(new Dog())->load($id);
*
* This will load the Dog record with ID $id into $dog, or `null` if no such
* record exists (@{method:load} is an instance method rather than a static
* method because PHP does not support late static binding, at least until PHP
* 5.3).
*
* To update an object, change its properties and save it:
*
* $dog->setBreed('Lab')->save();
*
* To delete an object, call @{method:delete}:
*
* $dog->delete();
*
* That's Lisk CRUD in a nutshell.
*
* = Queries =
*
* Often, you want to load a bunch of objects, or execute a more specialized
* query. Use @{method:loadAllWhere} or @{method:loadOneWhere} to do this:
*
* $pugs = $dog->loadAllWhere('breed = %s', 'Pug');
* $sawyer = $dog->loadOneWhere('name = %s', 'Sawyer');
*
* These methods work like @{function@arcanist:queryfx}, but only take half of
* a query (the part after the WHERE keyword). Lisk will handle the connection,
* columns, and object construction; you are responsible for the rest of it.
* @{method:loadAllWhere} returns a list of objects, while
* @{method:loadOneWhere} returns a single object (or `null`).
*
* There's also a @{method:loadRelatives} method which helps to prevent the 1+N
* queries problem.
*
* = Managing Transactions =
*
* Lisk uses a transaction stack, so code does not generally need to be aware
* of the transactional state of objects to implement correct transaction
* semantics:
*
* $obj->openTransaction();
* $obj->save();
* $other->save();
* // ...
* $other->openTransaction();
* $other->save();
* $another->save();
* if ($some_condition) {
* $other->saveTransaction();
* } else {
* $other->killTransaction();
* }
* // ...
* $obj->saveTransaction();
*
* Assuming ##$obj##, ##$other## and ##$another## live on the same database,
* this code will work correctly by establishing savepoints.
*
* Selects whose data are used later in the transaction should be included in
* @{method:beginReadLocking} or @{method:beginWriteLocking} block.
*
* @task conn Managing Connections
* @task config Configuring Lisk
* @task load Loading Objects
* @task info Examining Objects
* @task save Writing Objects
* @task hook Hooks and Callbacks
* @task util Utilities
* @task xaction Managing Transactions
* @task isolate Isolation for Unit Testing
*/
abstract class LiskDAO extends Phobject
implements AphrontDatabaseTableRefInterface {
const CONFIG_IDS = 'id-mechanism';
const CONFIG_TIMESTAMPS = 'timestamps';
const CONFIG_AUX_PHID = 'auxiliary-phid';
const CONFIG_SERIALIZATION = 'col-serialization';
const CONFIG_BINARY = 'binary';
const CONFIG_COLUMN_SCHEMA = 'col-schema';
const CONFIG_KEY_SCHEMA = 'key-schema';
const CONFIG_NO_TABLE = 'no-table';
const CONFIG_NO_MUTATE = 'no-mutate';
const SERIALIZATION_NONE = 'id';
const SERIALIZATION_JSON = 'json';
const SERIALIZATION_PHP = 'php';
const IDS_AUTOINCREMENT = 'ids-auto';
const IDS_COUNTER = 'ids-counter';
const IDS_MANUAL = 'ids-manual';
const COUNTER_TABLE_NAME = 'lisk_counter';
private static $processIsolationLevel = 0;
private static $transactionIsolationLevel = 0;
private $ephemeral = false;
private $forcedConnection;
private static $connections = array();
private static $liskMetadata = array();
protected $id;
protected $phid;
protected $dateCreated;
protected $dateModified;
/**
* Build an empty object.
*
* @return obj Empty object.
*/
public function __construct() {
$id_key = $this->getIDKey();
if ($id_key) {
$this->$id_key = null;
}
}
/* -( Managing Connections )----------------------------------------------- */
/**
* Establish a live connection to a database service. This method should
* return a new connection. Lisk handles connection caching and management;
* do not perform caching deeper in the stack.
*
* @param string $mode Mode, either 'r' (reading) or 'w' (reading and
* writing).
* @return AphrontDatabaseConnection New database connection.
* @task conn
*/
abstract protected function establishLiveConnection($mode);
/**
* Return a namespace for this object's connections in the connection cache.
* Generally, the database name is appropriate. Two connections are considered
* equivalent if they have the same connection namespace and mode.
*
* @return string Connection namespace for cache
* @task conn
*/
protected function getConnectionNamespace() {
return $this->getDatabaseName();
}
abstract protected function getDatabaseName();
/**
* Get an existing, cached connection for this object.
*
* @param mode $mode Connection mode.
* @return AphrontDatabaseConnection|null Connection, if it exists in cache.
* @task conn
*/
protected function getEstablishedConnection($mode) {
$key = $this->getConnectionNamespace().':'.$mode;
if (isset(self::$connections[$key])) {
return self::$connections[$key];
}
return null;
}
/**
* Store a connection in the connection cache.
*
* @param mode $mode Connection mode.
* @param AphrontDatabaseConnection $connection Connection to cache.
* @param bool $force_unique (optional)
- * @return this
+ * @return $this
* @task conn
*/
protected function setEstablishedConnection(
$mode,
AphrontDatabaseConnection $connection,
$force_unique = false) {
$key = $this->getConnectionNamespace().':'.$mode;
if ($force_unique) {
$key .= ':unique';
while (isset(self::$connections[$key])) {
$key .= '!';
}
}
self::$connections[$key] = $connection;
return $this;
}
/**
* Force an object to use a specific connection.
*
* This overrides all connection management and forces the object to use
* a specific connection when interacting with the database.
*
* @param AphrontDatabaseConnection $connection Connection to force this
* object to use.
* @task conn
*/
public function setForcedConnection(AphrontDatabaseConnection $connection) {
$this->forcedConnection = $connection;
return $this;
}
/* -( Configuring Lisk )--------------------------------------------------- */
/**
* Change Lisk behaviors, like ID configuration and timestamps. If you want
* to change these behaviors, you should override this method in your child
* class and change the options you're interested in. For example:
*
* protected function getConfiguration() {
* return array(
* Lisk_DataAccessObject::CONFIG_EXAMPLE => true,
* ) + parent::getConfiguration();
* }
*
* The available options are:
*
* CONFIG_IDS
* Lisk objects need to have a unique identifying ID. The three mechanisms
* available for generating this ID are IDS_AUTOINCREMENT (default, assumes
* the ID column is an autoincrement primary key), IDS_MANUAL (you are taking
* full responsibility for ID management), or IDS_COUNTER (see below).
*
* InnoDB does not persist the value of `auto_increment` across restarts,
* and instead initializes it to `MAX(id) + 1` during startup. This means it
* may reissue the same autoincrement ID more than once, if the row is deleted
* and then the database is restarted. To avoid this, you can set an object to
* use a counter table with IDS_COUNTER. This will generally behave like
* IDS_AUTOINCREMENT, except that the counter value will persist across
* restarts and inserts will be slightly slower. If a database stores any
* DAOs which use this mechanism, you must create a table there with this
* schema:
*
* CREATE TABLE lisk_counter (
* counterName VARCHAR(64) COLLATE utf8_bin PRIMARY KEY,
* counterValue BIGINT UNSIGNED NOT NULL
* ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
*
* CONFIG_TIMESTAMPS
* Lisk can automatically handle keeping track of a `dateCreated' and
* `dateModified' column, which it will update when it creates or modifies
* an object. If you don't want to do this, you may disable this option.
* By default, this option is ON.
*
* CONFIG_AUX_PHID
* This option can be enabled by being set to some truthy value. The meaning
* of this value is defined by your PHID generation mechanism. If this option
* is enabled, a `phid' property will be populated with a unique PHID when an
* object is created (or if it is saved and does not currently have one). You
* need to override generatePHID() and hook it into your PHID generation
* mechanism for this to work. By default, this option is OFF.
*
* CONFIG_SERIALIZATION
* You can optionally provide a column serialization map that will be applied
* to values when they are written to the database. For example:
*
* self::CONFIG_SERIALIZATION => array(
* 'complex' => self::SERIALIZATION_JSON,
* )
*
* This will cause Lisk to JSON-serialize the 'complex' field before it is
* written, and unserialize it when it is read.
*
* CONFIG_BINARY
* You can optionally provide a map of columns to a flag indicating that
* they store binary data. These columns will not raise an error when
* handling binary writes.
*
* CONFIG_COLUMN_SCHEMA
* Provide a map of columns to schema column types.
*
* CONFIG_KEY_SCHEMA
* Provide a map of key names to key specifications.
*
* CONFIG_NO_TABLE
* Allows you to specify that this object does not actually have a table in
* the database.
*
* CONFIG_NO_MUTATE
* Provide a map of columns which should not be included in UPDATE statements.
* If you have some columns which are always written to explicitly and should
* never be overwritten by a save(), you can specify them here. This is an
* advanced, specialized feature and there are usually better approaches for
* most locking/contention problems.
*
* @return dictionary Map of configuration options to values.
*
* @task config
*/
protected function getConfiguration() {
return array(
self::CONFIG_IDS => self::IDS_AUTOINCREMENT,
self::CONFIG_TIMESTAMPS => true,
);
}
/**
* Determine the setting of a configuration option for this class of objects.
*
* @param const $option_name Option name, one of the CONFIG_* constants.
* @return mixed Option value, if configured (null if unavailable).
*
* @task config
*/
public function getConfigOption($option_name) {
$options = $this->getLiskMetadata('config');
if ($options === null) {
$options = $this->getConfiguration();
$this->setLiskMetadata('config', $options);
}
return idx($options, $option_name);
}
/* -( Loading Objects )---------------------------------------------------- */
/**
* Load an object by ID. You need to invoke this as an instance method, not
* a class method, because PHP doesn't have late static binding (until
* PHP 5.3.0). For example:
*
* $dog = id(new Dog())->load($dog_id);
*
* @param int $id Numeric ID identifying the object to load.
* @return obj|null Identified object, or null if it does not exist.
*
* @task load
*/
public function load($id) {
if (is_object($id)) {
$id = (string)$id;
}
if (!$id || (!is_int($id) && !ctype_digit($id))) {
return null;
}
return $this->loadOneWhere(
'%C = %d',
$this->getIDKey(),
$id);
}
/**
* Loads all of the objects, unconditionally.
*
* @return dict Dictionary of all persisted objects of this type, keyed
* on object ID.
*
* @task load
*/
public function loadAll() {
return $this->loadAllWhere('1 = 1');
}
/**
* Load all objects which match a WHERE clause. You provide everything after
* the 'WHERE'; Lisk handles everything up to it. For example:
*
* $old_dogs = id(new Dog())->loadAllWhere('age > %d', 7);
*
* The pattern and arguments are as per queryfx().
*
* @param string $pattern queryfx()-style SQL WHERE clause.
* @param ... Zero or more conversions.
* @return dict Dictionary of matching objects, keyed on ID.
*
* @task load
*/
public function loadAllWhere($pattern /* , $arg, $arg, $arg ... */) {
$args = func_get_args();
$data = call_user_func_array(
array($this, 'loadRawDataWhere'),
$args);
return $this->loadAllFromArray($data);
}
/**
* Load a single object identified by a 'WHERE' clause. You provide
* everything after the 'WHERE', and Lisk builds the first half of the
* query. See loadAllWhere(). This method is similar, but returns a single
* result instead of a list.
*
* @param string $pattern queryfx()-style SQL WHERE clause.
* @param ... Zero or more conversions.
* @return obj|null Matching object, or null if no object matches.
*
* @task load
*/
public function loadOneWhere($pattern /* , $arg, $arg, $arg ... */) {
$args = func_get_args();
$data = call_user_func_array(
array($this, 'loadRawDataWhere'),
$args);
if (count($data) > 1) {
throw new AphrontCountQueryException(
pht(
'More than one result from %s!',
__FUNCTION__.'()'));
}
$data = reset($data);
if (!$data) {
return null;
}
return $this->loadFromArray($data);
}
protected function loadRawDataWhere($pattern /* , $args... */) {
$conn = $this->establishConnection('r');
if ($conn->isReadLocking()) {
$lock_clause = qsprintf($conn, 'FOR UPDATE');
} else if ($conn->isWriteLocking()) {
$lock_clause = qsprintf($conn, 'LOCK IN SHARE MODE');
} else {
$lock_clause = qsprintf($conn, '');
}
$args = func_get_args();
$args = array_slice($args, 1);
$pattern = 'SELECT * FROM %R WHERE '.$pattern.' %Q';
array_unshift($args, $this);
array_push($args, $lock_clause);
array_unshift($args, $pattern);
return call_user_func_array(array($conn, 'queryData'), $args);
}
/**
* Reload an object from the database, discarding any changes to persistent
* properties. This is primarily useful after entering a transaction but
* before applying changes to an object.
*
- * @return this
+ * @return $this
*
* @task load
*/
public function reload() {
if (!$this->getID()) {
throw new Exception(
pht("Unable to reload object that hasn't been loaded!"));
}
$result = $this->loadOneWhere(
'%C = %d',
$this->getIDKey(),
$this->getID());
if (!$result) {
throw new AphrontObjectMissingQueryException();
}
return $this;
}
/**
* Initialize this object's properties from a dictionary. Generally, you
* load single objects with loadOneWhere(), but sometimes it may be more
* convenient to pull data from elsewhere directly (e.g., a complicated
* join via @{method:queryData}) and then load from an array representation.
*
* @param dict $row Dictionary of properties, which should be equivalent
* to selecting a row from the table or calling
* @{method:getProperties}.
- * @return this
+ * @return $this
*
* @task load
*/
public function loadFromArray(array $row) {
$valid_map = $this->getLiskMetadata('validMap', array());
$map = array();
$updated = false;
foreach ($row as $k => $v) {
// We permit (but ignore) extra properties in the array because a
// common approach to building the array is to issue a raw SELECT query
// which may include extra explicit columns or joins.
// This pathway is very hot on some pages, so we're inlining a cache
// and doing some microoptimization to avoid a strtolower() call for each
// assignment. The common path (assigning a valid property which we've
// already seen) always incurs only one empty(). The second most common
// path (assigning an invalid property which we've already seen) costs
// an empty() plus an isset().
if (empty($valid_map[$k])) {
if (isset($valid_map[$k])) {
// The value is set but empty, which means it's false, so we've
// already determined it's not valid. We don't need to check again.
continue;
}
$valid_map[$k] = $this->hasProperty($k);
$updated = true;
if (!$valid_map[$k]) {
continue;
}
}
$map[$k] = $v;
}
if ($updated) {
$this->setLiskMetadata('validMap', $valid_map);
}
$this->willReadData($map);
foreach ($map as $prop => $value) {
$this->$prop = $value;
}
$this->didReadData();
return $this;
}
/**
* Initialize a list of objects from a list of dictionaries. Usually you
* load lists of objects with @{method:loadAllWhere}, but sometimes that
* isn't flexible enough. One case is if you need to do joins to select the
* right objects:
*
* function loadAllWithOwner($owner) {
* $data = $this->queryData(
* 'SELECT d.*
* FROM owner o
* JOIN owner_has_dog od ON o.id = od.ownerID
* JOIN dog d ON od.dogID = d.id
* WHERE o.id = %d',
* $owner);
* return $this->loadAllFromArray($data);
* }
*
* This is a lot messier than @{method:loadAllWhere}, but more flexible.
*
* @param list $rows List of property dictionaries.
* @return dict List of constructed objects, keyed on ID.
*
* @task load
*/
public function loadAllFromArray(array $rows) {
$result = array();
$id_key = $this->getIDKey();
foreach ($rows as $row) {
$obj = clone $this;
if ($id_key && isset($row[$id_key])) {
$row_id = $row[$id_key];
if (isset($result[$row_id])) {
throw new Exception(
pht(
'Rows passed to "loadAllFromArray(...)" include two or more '.
'rows with the same ID ("%s"). Rows must have unique IDs. '.
'An underlying query may be missing a GROUP BY.',
$row_id));
}
$result[$row_id] = $obj->loadFromArray($row);
} else {
$result[] = $obj->loadFromArray($row);
}
}
return $result;
}
/* -( Examining Objects )-------------------------------------------------- */
/**
* Set unique ID identifying this object. You normally don't need to call this
* method unless with `IDS_MANUAL`.
*
* @param mixed $id Unique ID.
- * @return this
+ * @return $this
* @task save
*/
public function setID($id) {
$id_key = $this->getIDKey();
$this->$id_key = $id;
return $this;
}
/**
* Retrieve the unique ID identifying this object. This value will be null if
* the object hasn't been persisted and you didn't set it manually.
*
* @return mixed Unique ID.
*
* @task info
*/
public function getID() {
$id_key = $this->getIDKey();
return $this->$id_key;
}
public function getPHID() {
return $this->phid;
}
/**
* Test if a property exists.
*
* @param string $property Property name.
* @return bool True if the property exists.
* @task info
*/
public function hasProperty($property) {
return (bool)$this->checkProperty($property);
}
/**
* Retrieve a list of all object properties. This list only includes
* properties that are declared as protected, and it is expected that
* all properties returned by this function should be persisted to the
* database.
* Properties that should not be persisted must be declared as private.
*
* @return dict Dictionary of normalized (lowercase) to canonical (original
* case) property names.
*
* @task info
*/
protected function getAllLiskProperties() {
$properties = $this->getLiskMetadata('properties');
if ($properties === null) {
$class = new ReflectionClass(static::class);
$properties = array();
foreach ($class->getProperties(ReflectionProperty::IS_PROTECTED) as $p) {
$properties[strtolower($p->getName())] = $p->getName();
}
$id_key = $this->getIDKey();
if ($id_key != 'id') {
unset($properties['id']);
}
if (!$this->getConfigOption(self::CONFIG_TIMESTAMPS)) {
unset($properties['datecreated']);
unset($properties['datemodified']);
}
if ($id_key != 'phid' && !$this->getConfigOption(self::CONFIG_AUX_PHID)) {
unset($properties['phid']);
}
$this->setLiskMetadata('properties', $properties);
}
return $properties;
}
/**
* Check if a property exists on this object.
*
* @return string|null Canonical property name, or null if the property
* does not exist.
*
* @task info
*/
protected function checkProperty($property) {
$properties = $this->getAllLiskProperties();
$property = strtolower($property);
if (empty($properties[$property])) {
return null;
}
return $properties[$property];
}
/**
* Get or build the database connection for this object.
*
* @param string $mode 'r' for read, 'w' for read/write.
* @param bool $force_new (optional) True to force a new connection. The
* connection will not be retrieved from or saved into the connection
* cache.
* @return AphrontDatabaseConnection Lisk connection object.
*
* @task info
*/
public function establishConnection($mode, $force_new = false) {
if ($mode != 'r' && $mode != 'w') {
throw new Exception(
pht(
"Unknown mode '%s', should be 'r' or 'w'.",
$mode));
}
if ($this->forcedConnection) {
return $this->forcedConnection;
}
if (self::shouldIsolateAllLiskEffectsToCurrentProcess()) {
$mode = 'isolate-'.$mode;
$connection = $this->getEstablishedConnection($mode);
if (!$connection) {
$connection = $this->establishIsolatedConnection($mode);
$this->setEstablishedConnection($mode, $connection);
}
return $connection;
}
if (self::shouldIsolateAllLiskEffectsToTransactions()) {
// If we're doing fixture transaction isolation, force the mode to 'w'
// so we always get the same connection for reads and writes, and thus
// can see the writes inside the transaction.
$mode = 'w';
}
// TODO: There is currently no protection on 'r' queries against writing.
$connection = null;
if (!$force_new) {
if ($mode == 'r') {
// If we're requesting a read connection but already have a write
// connection, reuse the write connection so that reads can take place
// inside transactions.
$connection = $this->getEstablishedConnection('w');
}
if (!$connection) {
$connection = $this->getEstablishedConnection($mode);
}
}
if (!$connection) {
$connection = $this->establishLiveConnection($mode);
if (self::shouldIsolateAllLiskEffectsToTransactions()) {
$connection->openTransaction();
}
$this->setEstablishedConnection(
$mode,
$connection,
$force_unique = $force_new);
}
return $connection;
}
/**
* Convert this object into a property dictionary. This dictionary can be
* restored into an object by using @{method:loadFromArray} (unless you're
* using legacy features with CONFIG_CONVERT_CAMELCASE, but in that case you
* should just go ahead and die in a fire).
*
* @return dict Dictionary of object properties.
*
* @task info
*/
protected function getAllLiskPropertyValues() {
$map = array();
foreach ($this->getAllLiskProperties() as $p) {
// We may receive a warning here for properties we've implicitly added
// through configuration; squelch it.
$map[$p] = @$this->$p;
}
return $map;
}
/* -( Writing Objects )---------------------------------------------------- */
/**
* Make an object read-only.
*
* Making an object ephemeral indicates that you will be changing state in
* such a way that you would never ever want it to be written back to the
* storage.
*/
public function makeEphemeral() {
$this->ephemeral = true;
return $this;
}
private function isEphemeralCheck() {
if ($this->ephemeral) {
throw new LiskEphemeralObjectException();
}
}
/**
* Persist this object to the database. In most cases, this is the only
* method you need to call to do writes. If the object has not yet been
* inserted this will do an insert; if it has, it will do an update.
*
- * @return this
+ * @return $this
*
* @task save
*/
public function save() {
if ($this->shouldInsertWhenSaved()) {
return $this->insert();
} else {
return $this->update();
}
}
/**
* Save this object, forcing the query to use REPLACE regardless of object
* state.
*
- * @return this
+ * @return $this
*
* @task save
*/
public function replace() {
$this->isEphemeralCheck();
return $this->insertRecordIntoDatabase('REPLACE');
}
/**
* Save this object, forcing the query to use INSERT regardless of object
* state.
*
- * @return this
+ * @return $this
*
* @task save
*/
public function insert() {
$this->isEphemeralCheck();
return $this->insertRecordIntoDatabase('INSERT');
}
/**
* Save this object, forcing the query to use UPDATE regardless of object
* state.
*
- * @return this
+ * @return $this
*
* @task save
*/
public function update() {
$this->isEphemeralCheck();
$this->willSaveObject();
$data = $this->getAllLiskPropertyValues();
// Remove columns flagged as nonmutable from the update statement.
$no_mutate = $this->getConfigOption(self::CONFIG_NO_MUTATE);
if ($no_mutate) {
foreach ($no_mutate as $column) {
unset($data[$column]);
}
}
$this->willWriteData($data);
$map = array();
foreach ($data as $k => $v) {
$map[$k] = $v;
}
$conn = $this->establishConnection('w');
$binary = $this->getBinaryColumns();
foreach ($map as $key => $value) {
if (!empty($binary[$key])) {
$map[$key] = qsprintf($conn, '%C = %nB', $key, $value);
} else {
$map[$key] = qsprintf($conn, '%C = %ns', $key, $value);
}
}
$id = $this->getID();
$conn->query(
'UPDATE %R SET %LQ WHERE %C = '.(is_int($id) ? '%d' : '%s'),
$this,
$map,
$this->getIDKey(),
$id);
// We can't detect a missing object because updating an object without
// changing any values doesn't affect rows. We could jiggle timestamps
// to catch this for objects which track them if we wanted.
$this->didWriteData();
return $this;
}
/**
* Delete this object, permanently.
*
- * @return this
+ * @return $this
*
* @task save
*/
public function delete() {
$this->isEphemeralCheck();
$this->willDelete();
$conn = $this->establishConnection('w');
$conn->query(
'DELETE FROM %R WHERE %C = %d',
$this,
$this->getIDKey(),
$this->getID());
$this->didDelete();
return $this;
}
/**
* Internal implementation of INSERT and REPLACE.
*
* @param const $mode Either "INSERT" or "REPLACE", to force the desired
* mode.
- * @return this
+ * @return $this
*
* @task save
*/
protected function insertRecordIntoDatabase($mode) {
$this->willSaveObject();
$data = $this->getAllLiskPropertyValues();
$conn = $this->establishConnection('w');
$id_mechanism = $this->getConfigOption(self::CONFIG_IDS);
switch ($id_mechanism) {
case self::IDS_AUTOINCREMENT:
// If we are using autoincrement IDs, let MySQL assign the value for the
// ID column, if it is empty. If the caller has explicitly provided a
// value, use it.
$id_key = $this->getIDKey();
if (empty($data[$id_key])) {
unset($data[$id_key]);
}
break;
case self::IDS_COUNTER:
// If we are using counter IDs, assign a new ID if we don't already have
// one.
$id_key = $this->getIDKey();
if (empty($data[$id_key])) {
$counter_name = $this->getTableName();
$id = self::loadNextCounterValue($conn, $counter_name);
$this->setID($id);
$data[$id_key] = $id;
}
break;
case self::IDS_MANUAL:
break;
default:
throw new Exception(pht('Unknown %s mechanism!', 'CONFIG_IDs'));
}
$this->willWriteData($data);
$columns = array_keys($data);
$binary = $this->getBinaryColumns();
foreach ($data as $key => $value) {
try {
if (!empty($binary[$key])) {
$data[$key] = qsprintf($conn, '%nB', $value);
} else {
$data[$key] = qsprintf($conn, '%ns', $value);
}
} catch (AphrontParameterQueryException $parameter_exception) {
throw new PhutilProxyException(
pht(
"Unable to insert or update object of class %s, field '%s' ".
"has a non-scalar value.",
get_class($this),
$key),
$parameter_exception);
}
}
switch ($mode) {
case 'INSERT':
$verb = qsprintf($conn, 'INSERT');
break;
case 'REPLACE':
$verb = qsprintf($conn, 'REPLACE');
break;
default:
throw new Exception(
pht(
'Insert mode verb "%s" is not recognized, use INSERT or REPLACE.',
$mode));
}
$conn->query(
'%Q INTO %R (%LC) VALUES (%LQ)',
$verb,
$this,
$columns,
$data);
// Only use the insert id if this table is using auto-increment ids
if ($id_mechanism === self::IDS_AUTOINCREMENT) {
$this->setID($conn->getInsertID());
}
$this->didWriteData();
return $this;
}
/**
* Method used to determine whether to insert or update when saving.
*
* @return bool true if the record should be inserted
*/
protected function shouldInsertWhenSaved() {
$key_type = $this->getConfigOption(self::CONFIG_IDS);
if ($key_type == self::IDS_MANUAL) {
throw new Exception(
pht(
'You are using manual IDs. You must override the %s method '.
'to properly detect when to insert a new record.',
__FUNCTION__.'()'));
} else {
return !$this->getID();
}
}
/* -( Hooks and Callbacks )------------------------------------------------ */
/**
* Retrieve the database table name. By default, this is the class name.
*
* @return string Table name for object storage.
*
* @task hook
*/
public function getTableName() {
return get_class($this);
}
/**
* Retrieve the primary key column, "id" by default. If you can not
* reasonably name your ID column "id", override this method.
*
* @return string Name of the ID column.
*
* @task hook
*/
public function getIDKey() {
return 'id';
}
/**
* Generate a new PHID, used by CONFIG_AUX_PHID.
*
* @return phid Unique, newly allocated PHID.
*
* @task hook
*/
public function generatePHID() {
$type = $this->getPHIDType();
return PhabricatorPHID::generateNewPHID($type);
}
public function getPHIDType() {
throw new PhutilMethodNotImplementedException();
}
/**
* Hook to apply serialization or validation to data before it is written to
* the database. See also @{method:willReadData}.
*
* @task hook
*/
protected function willWriteData(array &$data) {
$this->applyLiskDataSerialization($data, false);
}
/**
* Hook to perform actions after data has been written to the database.
*
* @task hook
*/
protected function didWriteData() {}
/**
* Hook to make internal object state changes prior to INSERT, REPLACE or
* UPDATE.
*
* @task hook
*/
protected function willSaveObject() {
$use_timestamps = $this->getConfigOption(self::CONFIG_TIMESTAMPS);
if ($use_timestamps) {
if (!$this->getDateCreated()) {
$this->setDateCreated(time());
}
$this->setDateModified(time());
}
if ($this->getConfigOption(self::CONFIG_AUX_PHID) && !$this->getPHID()) {
$this->setPHID($this->generatePHID());
}
}
/**
* Hook to apply serialization or validation to data as it is read from the
* database. See also @{method:willWriteData}.
*
* @task hook
*/
protected function willReadData(array &$data) {
$this->applyLiskDataSerialization($data, $deserialize = true);
}
/**
* Hook to perform an action on data after it is read from the database.
*
* @task hook
*/
protected function didReadData() {}
/**
* Hook to perform an action before the deletion of an object.
*
* @task hook
*/
protected function willDelete() {}
/**
* Hook to perform an action after the deletion of an object.
*
* @task hook
*/
protected function didDelete() {}
/**
* Reads the value from a field. Override this method for custom behavior
* of @{method:getField} instead of overriding getField directly.
*
* @param string $field Canonical field name
* @return mixed Value of the field
*
* @task hook
*/
protected function readField($field) {
if (isset($this->$field)) {
return $this->$field;
}
return null;
}
/**
* Writes a value to a field. Override this method for custom behavior of
* setField($value) instead of overriding setField directly.
*
* @param string $field Canonical field name
* @param mixed $value Value to write
*
* @task hook
*/
protected function writeField($field, $value) {
$this->$field = $value;
}
/* -( Manging Transactions )----------------------------------------------- */
/**
* Increase transaction stack depth.
*
- * @return this
+ * @return $this
*/
public function openTransaction() {
$this->establishConnection('w')->openTransaction();
return $this;
}
/**
* Decrease transaction stack depth, saving work.
*
- * @return this
+ * @return $this
*/
public function saveTransaction() {
$this->establishConnection('w')->saveTransaction();
return $this;
}
/**
* Decrease transaction stack depth, discarding work.
*
- * @return this
+ * @return $this
*/
public function killTransaction() {
$this->establishConnection('w')->killTransaction();
return $this;
}
/**
* Begins read-locking selected rows with SELECT ... FOR UPDATE, so that
* other connections can not read them (this is an enormous oversimplification
* of FOR UPDATE semantics; consult the MySQL documentation for details). To
* end read locking, call @{method:endReadLocking}. For example:
*
* $beach->openTransaction();
* $beach->beginReadLocking();
*
* $beach->reload();
* $beach->setGrainsOfSand($beach->getGrainsOfSand() + 1);
* $beach->save();
*
* $beach->endReadLocking();
* $beach->saveTransaction();
*
- * @return this
+ * @return $this
* @task xaction
*/
public function beginReadLocking() {
$this->establishConnection('w')->beginReadLocking();
return $this;
}
/**
* Ends read-locking that began at an earlier @{method:beginReadLocking} call.
*
- * @return this
+ * @return $this
* @task xaction
*/
public function endReadLocking() {
$this->establishConnection('w')->endReadLocking();
return $this;
}
/**
* Begins write-locking selected rows with SELECT ... LOCK IN SHARE MODE, so
* that other connections can not update or delete them (this is an
* oversimplification of LOCK IN SHARE MODE semantics; consult the
* MySQL documentation for details). To end write locking, call
* @{method:endWriteLocking}.
*
- * @return this
+ * @return $this
* @task xaction
*/
public function beginWriteLocking() {
$this->establishConnection('w')->beginWriteLocking();
return $this;
}
/**
* Ends write-locking that began at an earlier @{method:beginWriteLocking}
* call.
*
- * @return this
+ * @return $this
* @task xaction
*/
public function endWriteLocking() {
$this->establishConnection('w')->endWriteLocking();
return $this;
}
/* -( Isolation )---------------------------------------------------------- */
/**
* @task isolate
*/
public static function beginIsolateAllLiskEffectsToCurrentProcess() {
self::$processIsolationLevel++;
}
/**
* @task isolate
*/
public static function endIsolateAllLiskEffectsToCurrentProcess() {
self::$processIsolationLevel--;
if (self::$processIsolationLevel < 0) {
throw new Exception(
pht('Lisk process isolation level was reduced below 0.'));
}
}
/**
* @task isolate
*/
public static function shouldIsolateAllLiskEffectsToCurrentProcess() {
return (bool)self::$processIsolationLevel;
}
/**
* @task isolate
*/
private function establishIsolatedConnection($mode) {
$config = array();
return new AphrontIsolatedDatabaseConnection($config);
}
/**
* @task isolate
*/
public static function beginIsolateAllLiskEffectsToTransactions() {
if (self::$transactionIsolationLevel === 0) {
self::closeAllConnections();
}
self::$transactionIsolationLevel++;
}
/**
* @task isolate
*/
public static function endIsolateAllLiskEffectsToTransactions() {
self::$transactionIsolationLevel--;
if (self::$transactionIsolationLevel < 0) {
throw new Exception(
pht('Lisk transaction isolation level was reduced below 0.'));
} else if (self::$transactionIsolationLevel == 0) {
foreach (self::$connections as $key => $conn) {
if ($conn) {
$conn->killTransaction();
}
}
self::closeAllConnections();
}
}
/**
* @task isolate
*/
public static function shouldIsolateAllLiskEffectsToTransactions() {
return (bool)self::$transactionIsolationLevel;
}
/**
* Close any connections with no recent activity.
*
* Long-running processes can use this method to clean up connections which
* have not been used recently.
*
* @param int $idle_window Close connections with no activity for this many
* seconds.
* @return void
*/
public static function closeInactiveConnections($idle_window) {
$connections = self::$connections;
$now = PhabricatorTime::getNow();
foreach ($connections as $key => $connection) {
// If the connection is not idle, never consider it inactive.
if (!$connection->isIdle()) {
continue;
}
$last_active = $connection->getLastActiveEpoch();
$idle_duration = ($now - $last_active);
if ($idle_duration <= $idle_window) {
continue;
}
self::closeConnection($key);
}
}
public static function closeAllConnections() {
$connections = self::$connections;
foreach ($connections as $key => $connection) {
self::closeConnection($key);
}
}
public static function closeIdleConnections() {
$connections = self::$connections;
foreach ($connections as $key => $connection) {
if (!$connection->isIdle()) {
continue;
}
self::closeConnection($key);
}
}
private static function closeConnection($key) {
if (empty(self::$connections[$key])) {
throw new Exception(
pht(
'No database connection with connection key "%s" exists!',
$key));
}
$connection = self::$connections[$key];
unset(self::$connections[$key]);
$connection->close();
}
/* -( Utilities )---------------------------------------------------------- */
/**
* Applies configured serialization to a dictionary of values.
*
* @task util
*/
protected function applyLiskDataSerialization(array &$data, $deserialize) {
$serialization = $this->getConfigOption(self::CONFIG_SERIALIZATION);
if ($serialization) {
foreach (array_intersect_key($serialization, $data) as $col => $format) {
switch ($format) {
case self::SERIALIZATION_NONE:
break;
case self::SERIALIZATION_PHP:
if ($deserialize) {
$data[$col] = unserialize($data[$col]);
} else {
$data[$col] = serialize($data[$col]);
}
break;
case self::SERIALIZATION_JSON:
if ($deserialize) {
$data[$col] = json_decode($data[$col], true);
} else {
$data[$col] = phutil_json_encode($data[$col]);
}
break;
default:
throw new Exception(
pht("Unknown serialization format '%s'.", $format));
}
}
}
}
/**
* Black magic. Builds implied get*() and set*() for all properties.
*
* @param string $method Method name.
* @param list $args Argument vector.
* @return mixed get*() methods return the property value. set*() methods
* return $this.
* @task util
*/
public function __call($method, $args) {
$dispatch_map = $this->getLiskMetadata('dispatchMap', array());
// NOTE: This method is very performance-sensitive (many thousands of calls
// per page on some pages), and thus has some silliness in the name of
// optimizations.
if ($method[0] === 'g') {
if (isset($dispatch_map[$method])) {
$property = $dispatch_map[$method];
} else {
if (substr($method, 0, 3) !== 'get') {
throw new Exception(pht("Unable to resolve method '%s'!", $method));
}
$property = substr($method, 3);
if (!($property = $this->checkProperty($property))) {
throw new Exception(pht('Bad getter call: %s', $method));
}
$dispatch_map[$method] = $property;
$this->setLiskMetadata('dispatchMap', $dispatch_map);
}
return $this->readField($property);
}
if ($method[0] === 's') {
if (isset($dispatch_map[$method])) {
$property = $dispatch_map[$method];
} else {
if (substr($method, 0, 3) !== 'set') {
throw new Exception(pht("Unable to resolve method '%s'!", $method));
}
$property = substr($method, 3);
$property = $this->checkProperty($property);
if (!$property) {
throw new Exception(pht('Bad setter call: %s', $method));
}
$dispatch_map[$method] = $property;
$this->setLiskMetadata('dispatchMap', $dispatch_map);
}
$this->writeField($property, $args[0]);
return $this;
}
throw new Exception(pht("Unable to resolve method '%s'.", $method));
}
/**
* Warns against writing to undeclared property.
*
* @task util
*/
public function __set($name, $value) {
// Hack for policy system hints, see PhabricatorPolicyRule for notes.
if ($name != '_hashKey') {
phlog(
pht(
'Wrote to undeclared property %s.',
get_class($this).'::$'.$name));
}
$this->$name = $value;
}
/**
* Increments a named counter and returns the next value.
*
* @param AphrontDatabaseConnection $conn_w Database where the counter
* resides.
* @param string $counter_name Counter name to create
* or increment.
* @return int Next counter value.
*
* @task util
*/
public static function loadNextCounterValue(
AphrontDatabaseConnection $conn_w,
$counter_name) {
// NOTE: If an insert does not touch an autoincrement row or call
// LAST_INSERT_ID(), MySQL normally does not change the value of
// LAST_INSERT_ID(). This can cause a counter's value to leak to a
// new counter if the second counter is created after the first one is
// updated. To avoid this, we insert LAST_INSERT_ID(1), to ensure the
// LAST_INSERT_ID() is always updated and always set correctly after the
// query completes.
queryfx(
$conn_w,
'INSERT INTO %T (counterName, counterValue) VALUES
(%s, LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE
counterValue = LAST_INSERT_ID(counterValue + 1)',
self::COUNTER_TABLE_NAME,
$counter_name);
return $conn_w->getInsertID();
}
/**
* Returns the current value of a named counter.
*
* @param AphrontDatabaseConnection $conn_r Database where the counter
* resides.
* @param string $counter_name Counter name to read.
* @return int|null Current value, or `null` if the counter does not exist.
*
* @task util
*/
public static function loadCurrentCounterValue(
AphrontDatabaseConnection $conn_r,
$counter_name) {
$row = queryfx_one(
$conn_r,
'SELECT counterValue FROM %T WHERE counterName = %s',
self::COUNTER_TABLE_NAME,
$counter_name);
if (!$row) {
return null;
}
return (int)$row['counterValue'];
}
/**
* Overwrite a named counter, forcing it to a specific value.
*
* If the counter does not exist, it is created.
*
* @param AphrontDatabaseConnection $conn_w Database where the counter
* resides.
* @param string $counter_name Counter name to create or overwrite.
* @param int $counter_value
* @return void
*
* @task util
*/
public static function overwriteCounterValue(
AphrontDatabaseConnection $conn_w,
$counter_name,
$counter_value) {
queryfx(
$conn_w,
'INSERT INTO %T (counterName, counterValue) VALUES (%s, %d)
ON DUPLICATE KEY UPDATE counterValue = VALUES(counterValue)',
self::COUNTER_TABLE_NAME,
$counter_name,
$counter_value);
}
private function getBinaryColumns() {
return $this->getConfigOption(self::CONFIG_BINARY);
}
public function getSchemaColumns() {
$custom_map = $this->getConfigOption(self::CONFIG_COLUMN_SCHEMA);
if (!$custom_map) {
$custom_map = array();
}
$serialization = $this->getConfigOption(self::CONFIG_SERIALIZATION);
if (!$serialization) {
$serialization = array();
}
$serialization_map = array(
self::SERIALIZATION_JSON => 'text',
self::SERIALIZATION_PHP => 'bytes',
);
$binary_map = $this->getBinaryColumns();
$id_mechanism = $this->getConfigOption(self::CONFIG_IDS);
if ($id_mechanism == self::IDS_AUTOINCREMENT) {
$id_type = 'auto';
} else {
$id_type = 'id';
}
$builtin = array(
'id' => $id_type,
'phid' => 'phid',
'viewPolicy' => 'policy',
'editPolicy' => 'policy',
'epoch' => 'epoch',
'dateCreated' => 'epoch',
'dateModified' => 'epoch',
);
$map = array();
foreach ($this->getAllLiskProperties() as $property) {
// First, use types specified explicitly in the table configuration.
if (array_key_exists($property, $custom_map)) {
$map[$property] = $custom_map[$property];
continue;
}
// If we don't have an explicit type, try a builtin type for the
// column.
$type = idx($builtin, $property);
if ($type) {
$map[$property] = $type;
continue;
}
// If the column has serialization, we can infer the column type.
if (isset($serialization[$property])) {
$type = idx($serialization_map, $serialization[$property]);
if ($type) {
$map[$property] = $type;
continue;
}
}
if (isset($binary_map[$property])) {
$map[$property] = 'bytes';
continue;
}
if ($property === 'spacePHID') {
$map[$property] = 'phid?';
continue;
}
// If the column is named `somethingPHID`, infer it is a PHID.
if (preg_match('/[a-z]PHID$/', $property)) {
$map[$property] = 'phid';
continue;
}
// If the column is named `somethingID`, infer it is an ID.
if (preg_match('/[a-z]ID$/', $property)) {
$map[$property] = 'id';
continue;
}
// We don't know the type of this column.
$map[$property] = PhabricatorConfigSchemaSpec::DATATYPE_UNKNOWN;
}
return $map;
}
public function getSchemaKeys() {
$custom_map = $this->getConfigOption(self::CONFIG_KEY_SCHEMA);
if (!$custom_map) {
$custom_map = array();
}
$default_map = array();
foreach ($this->getAllLiskProperties() as $property) {
switch ($property) {
case 'id':
$default_map['PRIMARY'] = array(
'columns' => array('id'),
'unique' => true,
);
break;
case 'phid':
$default_map['key_phid'] = array(
'columns' => array('phid'),
'unique' => true,
);
break;
case 'spacePHID':
$default_map['key_space'] = array(
'columns' => array('spacePHID'),
);
break;
}
}
return $custom_map + $default_map;
}
public function getColumnMaximumByteLength($column) {
$map = $this->getSchemaColumns();
if (!isset($map[$column])) {
throw new Exception(
pht(
'Object (of class "%s") does not have a column "%s".',
get_class($this),
$column));
}
$data_type = $map[$column];
return id(new PhabricatorStorageSchemaSpec())
->getMaximumByteLengthForDataType($data_type);
}
public function getSchemaPersistence() {
return null;
}
/* -( AphrontDatabaseTableRefInterface )----------------------------------- */
public function getAphrontRefDatabaseName() {
return $this->getDatabaseName();
}
public function getAphrontRefTableName() {
return $this->getTableName();
}
private function getLiskMetadata($key, $default = null) {
if (isset(self::$liskMetadata[static::class][$key])) {
return self::$liskMetadata[static::class][$key];
}
if (!isset(self::$liskMetadata[static::class])) {
self::$liskMetadata[static::class] = array();
}
return idx(self::$liskMetadata[static::class], $key, $default);
}
private function setLiskMetadata($key, $value) {
self::$liskMetadata[static::class][$key] = $value;
}
}
diff --git a/src/infrastructure/util/PhabricatorGlobalLock.php b/src/infrastructure/util/PhabricatorGlobalLock.php
index c82c2f0604..67cdaab151 100644
--- a/src/infrastructure/util/PhabricatorGlobalLock.php
+++ b/src/infrastructure/util/PhabricatorGlobalLock.php
@@ -1,437 +1,437 @@
<?php
/**
* Global, MySQL-backed lock. This is a high-reliability, low-performance
* global lock.
*
* The lock is maintained by using GET_LOCK() in MySQL, and automatically
* released when the connection terminates. Thus, this lock can safely be used
* to control access to shared resources without implementing any sort of
* timeout or override logic: the lock can't normally be stuck in a locked state
* with no process actually holding the lock.
*
* However, acquiring the lock is moderately expensive (several network
* roundtrips). This makes it unsuitable for tasks where lock performance is
* important.
*
* $lock = PhabricatorGlobalLock::newLock('example');
* $lock->lock();
* do_contentious_things();
* $lock->unlock();
*
* NOTE: This lock is not completely global; it is namespaced to the active
* storage namespace so that unit tests running in separate table namespaces
* are isolated from one another.
*
* @task construct Constructing Locks
* @task impl Implementation
*/
final class PhabricatorGlobalLock extends PhutilLock {
private $parameters;
private $conn;
private $externalConnection;
private $log;
private $disableLogging;
private static $pool = array();
/* -( Constructing Locks )------------------------------------------------- */
public static function newLock($name, $parameters = array()) {
$namespace = PhabricatorLiskDAO::getStorageNamespace();
$namespace = PhabricatorHash::digestToLength($namespace, 20);
$parts = array();
ksort($parameters);
foreach ($parameters as $key => $parameter) {
if (!preg_match('/^[a-zA-Z0-9]+\z/', $key)) {
throw new Exception(
pht(
'Lock parameter key "%s" must be alphanumeric.',
$key));
}
if (!is_scalar($parameter) && !is_null($parameter)) {
throw new Exception(
pht(
'Lock parameter for key "%s" must be a scalar.',
$key));
}
$value = phutil_json_encode($parameter);
$parts[] = "{$key}={$value}";
}
$parts = implode(', ', $parts);
$local = "{$name}({$parts})";
$local = PhabricatorHash::digestToLength($local, 20);
$full_name = "ph:{$namespace}:{$local}";
$lock = self::getLock($full_name);
if (!$lock) {
$lock = new PhabricatorGlobalLock($full_name);
self::registerLock($lock);
$lock->parameters = $parameters;
}
return $lock;
}
/**
* Use a specific database connection for locking.
*
* By default, `PhabricatorGlobalLock` will lock on the "repository" database
* (somewhat arbitrarily). In most cases this is fine, but this method can
* be used to lock on a specific connection.
*
* @param AphrontDatabaseConnection $conn
- * @return this
+ * @return $this
*/
public function setExternalConnection(AphrontDatabaseConnection $conn) {
if ($this->conn) {
throw new Exception(
pht(
'Lock is already held, and must be released before the '.
'connection may be changed.'));
}
$this->externalConnection = $conn;
return $this;
}
public function setDisableLogging($disable) {
$this->disableLogging = $disable;
return $this;
}
/* -( Connection Pool )---------------------------------------------------- */
public static function getConnectionPoolSize() {
return count(self::$pool);
}
public static function clearConnectionPool() {
self::$pool = array();
}
public static function newConnection() {
// NOTE: Use of the "repository" database is somewhat arbitrary, mostly
// because the first client of locks was the repository daemons.
// We must always use the same database for all locks, because different
// databases may be on different hosts if the database is partitioned.
// However, we don't access any tables so we could use any valid database.
// We could build a database-free connection instead, but that's kind of
// messy and unusual.
$dao = new PhabricatorRepository();
// NOTE: Using "force_new" to make sure each lock is on its own connection.
// See T13627. This is critically important in versions of MySQL older
// than MySQL 5.7, because they can not hold more than one lock per
// connection simultaneously.
return $dao->establishConnection('w', $force_new = true);
}
/* -( Implementation )----------------------------------------------------- */
protected function doLock($wait) {
$conn = $this->conn;
if (!$conn) {
if ($this->externalConnection) {
$conn = $this->externalConnection;
}
}
if (!$conn) {
// Try to reuse a connection from the connection pool.
$conn = array_pop(self::$pool);
}
if (!$conn) {
$conn = self::newConnection();
}
// See T13627. We must never hold more than one lock per connection, so
// make sure this connection has no existing locks. (Normally, we should
// only be able to get here if callers explicitly provide the same external
// connection to multiple locks.)
if ($conn->isHoldingAnyLock()) {
throw new Exception(
pht(
'Unable to establish lock on connection: this connection is '.
'already holding a lock. Acquiring a second lock on the same '.
'connection would release the first lock in MySQL versions '.
'older than 5.7.'));
}
// NOTE: Since MySQL will disconnect us if we're idle for too long, we set
// the wait_timeout to an enormous value, to allow us to hold the
// connection open indefinitely (or, at least, for 24 days).
$max_allowed_timeout = 2147483;
queryfx($conn, 'SET wait_timeout = %d', $max_allowed_timeout);
$lock_name = $this->getName();
$result = queryfx_one(
$conn,
'SELECT GET_LOCK(%s, %f)',
$lock_name,
$wait);
$ok = head($result);
if (!$ok) {
// See PHI1794. We failed to acquire the lock, but the connection itself
// is still good. We're done with it, so add it to the pool, just as we
// would if we were releasing the lock.
// If we don't do this, we may establish a huge number of connections
// very rapidly if many workers try to acquire a lock at once. For
// example, this can happen if there are a large number of webhook tasks
// in the queue.
// See T13627. If this is an external connection, don't put it into
// the shared connection pool.
if (!$this->externalConnection) {
self::$pool[] = $conn;
}
throw id(new PhutilLockException($lock_name))
->setHint($this->newHint($lock_name, $wait));
}
$conn->rememberLock($lock_name);
$this->conn = $conn;
if ($this->shouldLogLock()) {
$lock_context = $this->newLockContext();
$log = id(new PhabricatorDaemonLockLog())
->setLockName($lock_name)
->setLockParameters($this->parameters)
->setLockContext($lock_context)
->save();
$this->log = $log;
}
}
protected function doUnlock() {
$lock_name = $this->getName();
$conn = $this->conn;
try {
$result = queryfx_one(
$conn,
'SELECT RELEASE_LOCK(%s)',
$lock_name);
$conn->forgetLock($lock_name);
} catch (Exception $ex) {
$result = array(null);
}
$ok = head($result);
if (!$ok) {
// TODO: We could throw here, but then this lock doesn't get marked
// unlocked and we throw again later when exiting. It also doesn't
// particularly matter for any current applications. For now, just
// swallow the error.
}
$this->conn = null;
if (!$this->externalConnection) {
$conn->close();
self::$pool[] = $conn;
}
if ($this->log) {
$log = $this->log;
$this->log = null;
$conn = $log->establishConnection('w');
queryfx(
$conn,
'UPDATE %T SET lockReleased = UNIX_TIMESTAMP() WHERE id = %d',
$log->getTableName(),
$log->getID());
}
}
private function shouldLogLock() {
if ($this->disableLogging) {
return false;
}
$policy = id(new PhabricatorDaemonLockLogGarbageCollector())
->getRetentionPolicy();
if (!$policy) {
return false;
}
return true;
}
private function newLockContext() {
$context = array(
'pid' => getmypid(),
'host' => php_uname('n'),
'sapi' => php_sapi_name(),
);
global $argv;
if ($argv) {
$context['argv'] = $argv;
}
$access_log = null;
// TODO: There's currently no cohesive way to get the parameterized access
// log for the current request across different request types. Web requests
// have an "AccessLog", SSH requests have an "SSHLog", and other processes
// (like scripts) have no log. But there's no method to say "give me any
// log you've got". For now, just test if we have a web request and use the
// "AccessLog" if we do, since that's the only one we actually read any
// parameters from.
// NOTE: "PhabricatorStartup" is only available from web requests, not
// from CLI scripts.
if (class_exists('PhabricatorStartup', false)) {
$access_log = PhabricatorAccessLog::getLog();
}
if ($access_log) {
$controller = $access_log->getData('C');
if ($controller) {
$context['controller'] = $controller;
}
$method = $access_log->getData('m');
if ($method) {
$context['method'] = $method;
}
}
return $context;
}
private function newHint($lock_name, $wait) {
if (!$this->shouldLogLock()) {
return pht(
'Enable the lock log for more detailed information about '.
'which process is holding this lock.');
}
$now = PhabricatorTime::getNow();
// First, look for recent logs. If other processes have been acquiring and
// releasing this lock while we've been waiting, this is more likely to be
// a contention/throughput issue than an issue with something hung while
// holding the lock.
$limit = 100;
$logs = id(new PhabricatorDaemonLockLog())->loadAllWhere(
'lockName = %s AND dateCreated >= %d ORDER BY id ASC LIMIT %d',
$lock_name,
($now - $wait),
$limit);
if ($logs) {
if (count($logs) === $limit) {
return pht(
'During the last %s second(s) spent waiting for the lock, more '.
'than %s other process(es) acquired it, so this is likely a '.
'bottleneck. Use "bin/lock log --name %s" to review log activity.',
new PhutilNumber($wait),
new PhutilNumber($limit),
$lock_name);
} else {
return pht(
'During the last %s second(s) spent waiting for the lock, %s '.
'other process(es) acquired it, so this is likely a '.
'bottleneck. Use "bin/lock log --name %s" to review log activity.',
new PhutilNumber($wait),
phutil_count($logs),
$lock_name);
}
}
$last_log = id(new PhabricatorDaemonLockLog())->loadOneWhere(
'lockName = %s ORDER BY id DESC LIMIT 1',
$lock_name);
if ($last_log) {
$info = array();
$acquired = $last_log->getDateCreated();
$context = $last_log->getLockContext();
$process_info = array();
$pid = idx($context, 'pid');
if ($pid) {
$process_info[] = 'pid='.$pid;
}
$host = idx($context, 'host');
if ($host) {
$process_info[] = 'host='.$host;
}
$sapi = idx($context, 'sapi');
if ($sapi) {
$process_info[] = 'sapi='.$sapi;
}
$argv = idx($context, 'argv');
if ($argv) {
$process_info[] = 'argv='.(string)csprintf('%LR', $argv);
}
$controller = idx($context, 'controller');
if ($controller) {
$process_info[] = 'controller='.$controller;
}
$method = idx($context, 'method');
if ($method) {
$process_info[] = 'method='.$method;
}
$process_info = implode(', ', $process_info);
$info[] = pht(
'This lock was most recently acquired by a process (%s) '.
'%s second(s) ago.',
$process_info,
new PhutilNumber($now - $acquired));
$released = $last_log->getLockReleased();
if ($released) {
$info[] = pht(
'This lock was released %s second(s) ago.',
new PhutilNumber($now - $released));
} else {
$info[] = pht('There is no record of this lock being released.');
}
return implode(' ', $info);
}
return pht(
'Found no records of processes acquiring or releasing this lock.');
}
}
diff --git a/src/view/AphrontView.php b/src/view/AphrontView.php
index f1773c7579..045ffe0fef 100644
--- a/src/view/AphrontView.php
+++ b/src/view/AphrontView.php
@@ -1,225 +1,225 @@
<?php
/**
* @task children Managing Children
*/
abstract class AphrontView extends Phobject
implements PhutilSafeHTMLProducerInterface {
private $viewer;
protected $children = array();
/* -( Configuration )------------------------------------------------------ */
/**
* Set the user viewing this element.
*
* @param PhabricatorUser $viewer Viewing user.
- * @return this
+ * @return $this
*/
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
/**
* Get the user viewing this element.
*
* Throws an exception if no viewer has been set.
*
* @return PhabricatorUser Viewing user.
*/
public function getViewer() {
if (!$this->viewer) {
throw new PhutilInvalidStateException('setViewer');
}
return $this->viewer;
}
/**
* Test if a viewer has been set on this element.
*
* @return bool True if a viewer is available.
*/
public function hasViewer() {
return (bool)$this->viewer;
}
/**
* Deprecated, use @{method:setViewer}.
*
* @task config
* @deprecated
*/
public function setUser(PhabricatorUser $user) {
return $this->setViewer($user);
}
/**
* Deprecated, use @{method:getViewer}.
*
* @task config
* @deprecated
*/
protected function getUser() {
if (!$this->hasViewer()) {
return null;
}
return $this->getViewer();
}
/* -( Managing Children )-------------------------------------------------- */
/**
* Test if this View accepts children.
*
* By default, views accept children, but subclases may override this method
* to prevent children from being appended. Doing so will cause
* @{method:appendChild} to throw exceptions instead of appending children.
*
* @return bool True if the View should accept children.
* @task children
*/
protected function canAppendChild() {
return true;
}
/**
* Append a child to the list of children.
*
* This method will only work if the view supports children, which is
* determined by @{method:canAppendChild}.
*
* @param wild $child Something renderable.
- * @return this
+ * @return $this
*/
final public function appendChild($child) {
if (!$this->canAppendChild()) {
$class = get_class($this);
throw new Exception(
pht("View '%s' does not support children.", $class));
}
$this->children[] = $child;
return $this;
}
/**
* Produce children for rendering.
*
* Historically, this method reduced children to a string representation,
* but it no longer does.
*
* @return wild Renderable children.
* @task
*/
final protected function renderChildren() {
return $this->children;
}
/**
* Test if an element has no children.
*
* @return bool True if this element has children.
* @task children
*/
final public function hasChildren() {
if ($this->children) {
$this->children = $this->reduceChildren($this->children);
}
return (bool)$this->children;
}
/**
* Reduce effectively-empty lists of children to be actually empty. This
* recursively removes `null`, `''`, and `array()` from the list of children
* so that @{method:hasChildren} can more effectively align with expectations.
*
* NOTE: Because View children are not rendered, a View which renders down
* to nothing will not be reduced by this method.
*
* @param list<wild> $children Renderable children.
* @return list<wild> Reduced list of children.
* @task children
*/
private function reduceChildren(array $children) {
foreach ($children as $key => $child) {
if ($child === null) {
unset($children[$key]);
} else if ($child === '') {
unset($children[$key]);
} else if (is_array($child)) {
$child = $this->reduceChildren($child);
if ($child) {
$children[$key] = $child;
} else {
unset($children[$key]);
}
}
}
return $children;
}
public function getDefaultResourceSource() {
return 'phabricator';
}
public function requireResource($symbol) {
$response = CelerityAPI::getStaticResourceResponse();
$response->requireResource($symbol, $this->getDefaultResourceSource());
return $this;
}
public function initBehavior($name, $config = array()) {
Javelin::initBehavior(
$name,
$config,
$this->getDefaultResourceSource());
return $this;
}
/* -( Rendering )---------------------------------------------------------- */
/**
* Inconsistent, unreliable pre-rendering hook.
*
* This hook //may// fire before views render. It is not fired reliably, and
* may fire multiple times.
*
* If it does fire, views might use it to register data for later loads, but
* almost no datasources support this now; this is currently only useful for
* tokenizers. This mechanism might eventually see wider support or might be
* removed.
*/
public function willRender() {
return;
}
abstract public function render();
/* -( PhutilSafeHTMLProducerInterface )------------------------------------ */
public function producePhutilSafeHTML() {
return $this->render();
}
}
diff --git a/src/view/form/AphrontFormView.php b/src/view/form/AphrontFormView.php
index de1143ab85..ef5cb8e864 100644
--- a/src/view/form/AphrontFormView.php
+++ b/src/view/form/AphrontFormView.php
@@ -1,180 +1,180 @@
<?php
final class AphrontFormView extends AphrontView {
private $action;
private $method = 'POST';
private $header;
private $data = array();
private $encType;
private $workflow;
private $id;
private $sigils = array();
private $metadata;
private $controls = array();
private $fullWidth = false;
private $classes = array();
public function setMetadata($metadata) {
$this->metadata = $metadata;
return $this;
}
public function getMetadata() {
return $this->metadata;
}
public function setID($id) {
$this->id = $id;
return $this;
}
public function setAction($action) {
$this->action = $action;
return $this;
}
public function setMethod($method) {
$this->method = $method;
return $this;
}
public function setEncType($enc_type) {
$this->encType = $enc_type;
return $this;
}
public function addHiddenInput($key, $value) {
$this->data[$key] = $value;
return $this;
}
public function setWorkflow($workflow) {
$this->workflow = $workflow;
return $this;
}
public function addSigil($sigil) {
$this->sigils[] = $sigil;
return $this;
}
public function addClass($class) {
$this->classes[] = $class;
return $this;
}
public function setFullWidth($full_width) {
$this->fullWidth = $full_width;
return $this;
}
public function getFullWidth() {
return $this->fullWidth;
}
public function appendInstructions($text) {
return $this->appendChild(
phutil_tag(
'div',
array(
'class' => 'aphront-form-instructions',
),
$text));
}
public function appendRemarkupInstructions($remarkup) {
$view = $this->newInstructionsRemarkupView($remarkup);
return $this->appendInstructions($view);
}
public function newInstructionsRemarkupView($remarkup) {
$viewer = $this->getViewer();
$view = new PHUIRemarkupView($viewer, $remarkup);
$view->setRemarkupOptions(
array(
PHUIRemarkupView::OPTION_PRESERVE_LINEBREAKS => false,
));
return $view;
}
public function buildLayoutView() {
foreach ($this->controls as $control) {
$control->setViewer($this->getViewer());
$control->willRender();
}
return id(new PHUIFormLayoutView())
->setFullWidth($this->getFullWidth())
->appendChild($this->renderDataInputs())
->appendChild($this->renderChildren());
}
/**
* Append a control to the form.
*
* This method behaves like @{method:appendChild}, but it only takes
* controls. It will propagate some information from the form to the
* control to simplify rendering.
*
* @param AphrontFormControl $control Control to append.
- * @return this
+ * @return $this
*/
public function appendControl(AphrontFormControl $control) {
$this->controls[] = $control;
return $this->appendChild($control);
}
public function render() {
require_celerity_resource('phui-form-view-css');
$layout = $this->buildLayoutView();
if (!$this->hasViewer()) {
throw new Exception(
pht(
'You must pass the user to %s.',
__CLASS__));
}
$sigils = $this->sigils;
if ($this->workflow) {
$sigils[] = 'workflow';
}
return phabricator_form(
$this->getViewer(),
array(
'class' => implode(' ', $this->classes),
'action' => $this->action,
'method' => $this->method,
'enctype' => $this->encType,
'sigil' => $sigils ? implode(' ', $sigils) : null,
'meta' => $this->metadata,
'id' => $this->id,
),
$layout->render());
}
private function renderDataInputs() {
$inputs = array();
foreach ($this->data as $key => $value) {
if ($value === null) {
continue;
}
$inputs[] = phutil_tag(
'input',
array(
'type' => 'hidden',
'name' => $key,
'value' => $value,
));
}
return $inputs;
}
}
diff --git a/src/view/phui/PHUICrumbView.php b/src/view/phui/PHUICrumbView.php
index a8c0a7e57a..5174de59cd 100644
--- a/src/view/phui/PHUICrumbView.php
+++ b/src/view/phui/PHUICrumbView.php
@@ -1,133 +1,133 @@
<?php
final class PHUICrumbView extends AphrontView {
private $name;
private $href;
private $icon;
private $isLastCrumb;
private $workflow;
private $aural;
private $alwaysVisible;
public function setAural($aural) {
$this->aural = $aural;
return $this;
}
public function getAural() {
return $this->aural;
}
/**
* Make this crumb always visible, even on devices where it would normally
* be hidden.
*
* @param bool $always_visible True to make the crumb always visible.
- * @return this
+ * @return $this
*/
public function setAlwaysVisible($always_visible) {
$this->alwaysVisible = $always_visible;
return $this;
}
public function getAlwaysVisible() {
return $this->alwaysVisible;
}
public function setWorkflow($workflow) {
$this->workflow = $workflow;
return $this;
}
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setHref($href) {
$this->href = $href;
return $this;
}
public function setIcon($icon) {
$this->icon = $icon;
return $this;
}
protected function canAppendChild() {
return false;
}
public function setIsLastCrumb($is_last_crumb) {
$this->isLastCrumb = $is_last_crumb;
return $this;
}
public function render() {
$classes = array(
'phui-crumb-view',
);
$aural = null;
if ($this->aural !== null) {
$aural = javelin_tag(
'span',
array(
'aural' => true,
),
$this->aural);
}
$icon = null;
if ($this->icon) {
$classes[] = 'phui-crumb-has-icon';
$icon = id(new PHUIIconView())
->setIcon($this->icon);
}
// Surround the crumb name with spaces so that double clicking it only
// selects the crumb itself.
$name = array(' ', $this->name);
$name = phutil_tag(
'span',
array(
'class' => 'phui-crumb-name',
),
$name);
// Because of text-overflow and safari, put the second space on the
// outside of the element.
$name = array($name, ' ');
$divider = null;
if (!$this->isLastCrumb) {
$divider = id(new PHUIIconView())
->setIcon('fa-angle-right')
->addClass('phui-crumb-divider')
->addClass('phui-crumb-view');
} else {
$classes[] = 'phabricator-last-crumb';
}
if ($this->getAlwaysVisible()) {
$classes[] = 'phui-crumb-always-visible';
}
$tag = javelin_tag(
$this->href ? 'a' : 'span',
array(
'sigil' => $this->workflow ? 'workflow' : null,
'href' => $this->href,
'class' => implode(' ', $classes),
),
array($aural, $icon, $name));
return array($tag, $divider);
}
}
diff --git a/src/view/phui/PHUICrumbsView.php b/src/view/phui/PHUICrumbsView.php
index 80613cb61e..416566a98a 100644
--- a/src/view/phui/PHUICrumbsView.php
+++ b/src/view/phui/PHUICrumbsView.php
@@ -1,150 +1,150 @@
<?php
final class PHUICrumbsView extends AphrontView {
private $crumbs = array();
private $actions = array();
private $border;
protected function canAppendChild() {
return false;
}
/**
* Convenience method for adding a simple crumb with just text, or text and
* a link.
*
* @param string $text Text of the crumb.
* @param string $href (optional) href for the crumb.
- * @return this
+ * @return $this
*/
public function addTextCrumb($text, $href = null) {
return $this->addCrumb(
id(new PHUICrumbView())
->setName($text)
->setHref($href));
}
public function addCrumb(PHUICrumbView $crumb) {
$this->crumbs[] = $crumb;
return $this;
}
public function addAction(PHUIListItemView $action) {
$this->actions[] = $action;
return $this;
}
public function setBorder($border) {
$this->border = $border;
return $this;
}
public function getActions() {
return $this->actions;
}
public function render() {
require_celerity_resource('phui-crumbs-view-css');
$action_view = null;
if ($this->actions) {
// TODO: This block of code takes "PHUIListItemView" objects and turns
// them into some weird abomination by reading most of their properties
// out. Some day, this workflow should render the items and CSS should
// resytle them in place without needing a wholly separate set of
// DOM nodes.
$actions = array();
foreach ($this->actions as $action) {
if ($action->getType() == PHUIListItemView::TYPE_DIVIDER) {
$actions[] = phutil_tag(
'span',
array(
'class' => 'phui-crumb-action-divider',
));
continue;
}
$icon = null;
if ($action->getIcon()) {
$icon_name = $action->getIcon();
if ($action->getDisabled()) {
$icon_name .= ' lightgreytext';
}
$icon = id(new PHUIIconView())
->setIcon($icon_name);
}
$action_classes = $action->getClasses();
$action_classes[] = 'phui-crumbs-action';
$name = null;
if ($action->getName()) {
$name = phutil_tag(
'span',
array(
'class' => 'phui-crumbs-action-name',
),
$action->getName());
} else {
$action_classes[] = 'phui-crumbs-action-icon';
}
$action_sigils = $action->getSigils();
if ($action->getWorkflow()) {
$action_sigils[] = 'workflow';
}
if ($action->getDisabled()) {
$action_classes[] = 'phui-crumbs-action-disabled';
}
$actions[] = javelin_tag(
'a',
array(
'href' => $action->getHref(),
'class' => implode(' ', $action_classes),
'sigil' => implode(' ', $action_sigils),
'style' => $action->getStyle(),
'meta' => $action->getMetadata(),
),
array(
$icon,
$name,
));
}
$action_view = phutil_tag(
'div',
array(
'class' => 'phui-crumbs-actions',
),
$actions);
}
if ($this->crumbs) {
last($this->crumbs)->setIsLastCrumb(true);
}
$classes = array();
$classes[] = 'phui-crumbs-view';
if ($this->border) {
$classes[] = 'phui-crumbs-border';
}
return phutil_tag(
'div',
array(
'class' => implode(' ', $classes),
),
array(
$action_view,
$this->crumbs,
));
}
}
diff --git a/support/startup/PhabricatorClientLimit.php b/support/startup/PhabricatorClientLimit.php
index ea2f39d217..75436d1f24 100644
--- a/support/startup/PhabricatorClientLimit.php
+++ b/support/startup/PhabricatorClientLimit.php
@@ -1,290 +1,290 @@
<?php
abstract class PhabricatorClientLimit {
private $limitKey;
private $clientKey;
private $limit;
final public function setLimitKey($limit_key) {
$this->limitKey = $limit_key;
return $this;
}
final public function getLimitKey() {
return $this->limitKey;
}
final public function setClientKey($client_key) {
$this->clientKey = $client_key;
return $this;
}
final public function getClientKey() {
return $this->clientKey;
}
final public function setLimit($limit) {
$this->limit = $limit;
return $this;
}
final public function getLimit() {
return $this->limit;
}
final public function didConnect() {
// NOTE: We can not use pht() here because this runs before libraries
// load.
if (!function_exists('apc_fetch') && !function_exists('apcu_fetch')) {
throw new Exception(
'You can not configure connection rate limits unless APC/APCu are '.
'available. Rate limits rely on APC/APCu to track clients and '.
'connections.');
}
if ($this->getClientKey() === null) {
throw new Exception(
'You must configure a client key when defining a rate limit.');
}
if ($this->getLimitKey() === null) {
throw new Exception(
'You must configure a limit key when defining a rate limit.');
}
if ($this->getLimit() === null) {
throw new Exception(
'You must configure a limit when defining a rate limit.');
}
$points = $this->getConnectScore();
if ($points) {
$this->addScore($points);
}
$score = $this->getScore();
if (!$this->shouldRejectConnection($score)) {
// Client has not hit the limit, so continue processing the request.
return null;
}
$penalty = $this->getPenaltyScore();
if ($penalty) {
$this->addScore($penalty);
$score += $penalty;
}
return $this->getRateLimitReason($score);
}
final public function didDisconnect(array $request_state) {
$score = $this->getDisconnectScore($request_state);
if ($score) {
$this->addScore($score);
}
}
/**
* Get the number of seconds for each rate bucket.
*
* For example, a value of 60 will create one-minute buckets.
*
* @return int Number of seconds per bucket.
*/
abstract protected function getBucketDuration();
/**
* Get the total number of rate limit buckets to retain.
*
* @return int Total number of rate limit buckets to retain.
*/
abstract protected function getBucketCount();
/**
* Get the score to add when a client connects.
*
* @return double Connection score.
*/
abstract protected function getConnectScore();
/**
* Get the number of penalty points to add when a client hits a rate limit.
*
* @return double Penalty score.
*/
abstract protected function getPenaltyScore();
/**
* Get the score to add when a client disconnects.
*
* @return double Connection score.
*/
abstract protected function getDisconnectScore(array $request_state);
/**
* Get a human-readable explanation of why the client is being rejected.
*
* @return string Brief rejection message.
*/
abstract protected function getRateLimitReason($score);
/**
* Determine whether to reject a connection.
*
* @return bool True to reject the connection.
*/
abstract protected function shouldRejectConnection($score);
/**
* Get the APC key for the smallest stored bucket.
*
* @return string APC key for the smallest stored bucket.
* @task ratelimit
*/
private function getMinimumBucketCacheKey() {
$limit_key = $this->getLimitKey();
return "limit:min:{$limit_key}";
}
/**
* Get the current bucket ID for storing rate limit scores.
*
* @return int The current bucket ID.
*/
private function getCurrentBucketID() {
return (int)(time() / $this->getBucketDuration());
}
/**
* Get the APC key for a given bucket.
*
* @param int $bucket_id Bucket to get the key for.
* @return string APC key for the bucket.
*/
private function getBucketCacheKey($bucket_id) {
$limit_key = $this->getLimitKey();
return "limit:bucket:{$limit_key}:{$bucket_id}";
}
/**
* Add points to the rate limit score for some client.
*
* @param float $score The cost for this request; more points pushes them
* toward the limit faster.
- * @return this
+ * @return $this
*/
private function addScore($score) {
$is_apcu = (bool)function_exists('apcu_fetch');
$current = $this->getCurrentBucketID();
$bucket_key = $this->getBucketCacheKey($current);
// There's a bit of a race here, if a second process reads the bucket
// before this one writes it, but it's fine if we occasionally fail to
// record a client's score. If they're making requests fast enough to hit
// rate limiting, we'll get them soon enough.
if ($is_apcu) {
$bucket = apcu_fetch($bucket_key);
} else {
$bucket = apc_fetch($bucket_key);
}
if (!is_array($bucket)) {
$bucket = array();
}
$client_key = $this->getClientKey();
if (empty($bucket[$client_key])) {
$bucket[$client_key] = 0;
}
$bucket[$client_key] += $score;
if ($is_apcu) {
@apcu_store($bucket_key, $bucket);
} else {
@apc_store($bucket_key, $bucket);
}
return $this;
}
/**
* Get the current rate limit score for a given client.
*
* @return float The client's current score.
* @task ratelimit
*/
private function getScore() {
$is_apcu = (bool)function_exists('apcu_fetch');
// Identify the oldest bucket stored in APC.
$min_key = $this->getMinimumBucketCacheKey();
if ($is_apcu) {
$min = apcu_fetch($min_key);
} else {
$min = apc_fetch($min_key);
}
// If we don't have any buckets stored yet, store the current bucket as
// the oldest bucket.
$cur = $this->getCurrentBucketID();
if (!$min) {
if ($is_apcu) {
@apcu_store($min_key, $cur);
} else {
@apc_store($min_key, $cur);
}
$min = $cur;
}
// Destroy any buckets that are older than the minimum bucket we're keeping
// track of. Under load this normally shouldn't do anything, but will clean
// up an old bucket once per minute.
$count = $this->getBucketCount();
for ($cursor = $min; $cursor < ($cur - $count); $cursor++) {
$bucket_key = $this->getBucketCacheKey($cursor);
if ($is_apcu) {
apcu_delete($bucket_key);
@apcu_store($min_key, $cursor + 1);
} else {
apc_delete($bucket_key);
@apc_store($min_key, $cursor + 1);
}
}
$client_key = $this->getClientKey();
// Now, sum up the client's scores in all of the active buckets.
$score = 0;
for (; $cursor <= $cur; $cursor++) {
$bucket_key = $this->getBucketCacheKey($cursor);
if ($is_apcu) {
$bucket = apcu_fetch($bucket_key);
} else {
$bucket = apc_fetch($bucket_key);
}
if (isset($bucket[$client_key])) {
$score += $bucket[$client_key];
}
}
return $score;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Mar 16, 2:19 AM (1 d, 13 h)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
b8/bb/520ddc3f4b09628bb447c78d10da
Default Alt Text
(1 MB)

Event Timeline