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 8ce2f3e4af..d88c02af6d 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
* @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
* @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
* @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 Default value to return if header is not
- present.
- * @param array? $data Read this instead of `$_SERVER`.
+ * @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/sink/AphrontHTTPSink.php b/src/aphront/sink/AphrontHTTPSink.php
index f2a2f50860..946f94d8d6 100644
--- a/src/aphront/sink/AphrontHTTPSink.php
+++ b/src/aphront/sink/AphrontHTTPSink.php
@@ -1,173 +1,173 @@
<?php
/**
* Abstract class which wraps some sort of output mechanism for HTTP responses.
* Normally this is just @{class:AphrontPHPHTTPSink}, which uses "echo" and
* "header()" to emit responses.
*
* @task write Writing Response Components
* @task emit Emitting the Response
*/
abstract class AphrontHTTPSink extends Phobject {
private $showStackTraces = false;
final public function setShowStackTraces($show_stack_traces) {
$this->showStackTraces = $show_stack_traces;
return $this;
}
final public function getShowStackTraces() {
return $this->showStackTraces;
}
/* -( Writing Response Components )---------------------------------------- */
/**
* Write an HTTP status code to the output.
*
* @param int $code Numeric HTTP status code.
- * @param string? $message
+ * @param string $message (optional)
* @return void
*/
final public function writeHTTPStatus($code, $message = '') {
if (!preg_match('/^\d{3}$/', $code)) {
throw new Exception(pht("Malformed HTTP status code '%s'!", $code));
}
$code = (int)$code;
$this->emitHTTPStatus($code, $message);
}
/**
* Write HTTP headers to the output.
*
* @param list<pair> $headers List of <name, value> pairs.
* @return void
*/
final public function writeHeaders(array $headers) {
foreach ($headers as $header) {
if (!is_array($header) || count($header) !== 2) {
throw new Exception(pht('Malformed header.'));
}
list($name, $value) = $header;
if (strpos($name, ':') !== false) {
throw new Exception(
pht(
'Declining to emit response with malformed HTTP header name: %s',
$name));
}
// Attackers may perform an "HTTP response splitting" attack by making
// the application emit certain types of headers containing newlines:
//
// http://en.wikipedia.org/wiki/HTTP_response_splitting
//
// PHP has built-in protections against HTTP response-splitting, but they
// are of dubious trustworthiness:
//
// http://news.php.net/php.internals/57655
if (preg_match('/[\r\n\0]/', $name.$value)) {
throw new Exception(
pht(
'Declining to emit response with unsafe HTTP header: %s',
"<'".$name."', '".$value."'>."));
}
}
foreach ($headers as $header) {
list($name, $value) = $header;
$this->emitHeader($name, $value);
}
}
/**
* Write HTTP body data to the output.
*
* @param string $data Body data.
* @return void
*/
final public function writeData($data) {
$this->emitData($data);
}
/**
* Write an entire @{class:AphrontResponse} to the output.
*
* @param AphrontResponse $response The response object to write.
* @return void
*/
final public function writeResponse(AphrontResponse $response) {
$response->willBeginWrite();
// Build the content iterator first, in case it throws. Ideally, we'd
// prefer to handle exceptions before we emit the response status or any
// HTTP headers.
$data = $response->getContentIterator();
// This isn't an exceptionally clean separation of concerns, but we need
// to add CSP headers for all response types (including both web pages
// and dialogs) and can't determine the correct CSP until after we render
// the page (because page elements like Recaptcha may add CSP rules).
$static = CelerityAPI::getStaticResourceResponse();
foreach ($static->getContentSecurityPolicyURIMap() as $kind => $uris) {
foreach ($uris as $uri) {
$response->addContentSecurityPolicyURI($kind, $uri);
}
}
$all_headers = array_merge(
$response->getHeaders(),
$response->getCacheHeaders());
$this->writeHTTPStatus(
$response->getHTTPResponseCode(),
$response->getHTTPResponseMessage());
$this->writeHeaders($all_headers);
// Allow clients an unlimited amount of time to download the response.
// This allows clients to perform a "slow loris" attack, where they
// download a large response very slowly to tie up process slots. However,
// concurrent connection limits and "RequestReadTimeout" already prevent
// this attack. We could add our own minimum download rate here if we want
// to make this easier to configure eventually.
// For normal page responses, we've fully rendered the page into a string
// already so all that's left is writing it to the client.
// For unusual responses (like large file downloads) we may still be doing
// some meaningful work, but in theory that work is intrinsic to streaming
// the response.
set_time_limit(0);
$abort = false;
foreach ($data as $block) {
if (!$this->isWritable()) {
$abort = true;
break;
}
$this->writeData($block);
}
$response->didCompleteWrite($abort);
}
/* -( Emitting the Response )---------------------------------------------- */
abstract protected function emitHTTPStatus($code, $message = '');
abstract protected function emitHeader($name, $value);
abstract protected function emitData($data);
abstract protected function isWritable();
}
diff --git a/src/applications/auth/constants/PhabricatorCookies.php b/src/applications/auth/constants/PhabricatorCookies.php
index 607240c97b..7a54eb3dc0 100644
--- a/src/applications/auth/constants/PhabricatorCookies.php
+++ b/src/applications/auth/constants/PhabricatorCookies.php
@@ -1,179 +1,179 @@
<?php
/**
* Consolidates Phabricator application cookies, including registration
* and session management.
*
* @task clientid Client ID Cookie
* @task next Next URI Cookie
*/
final class PhabricatorCookies extends Phobject {
/**
* Stores the login username for password authentication. This is just a
* display value for convenience, used to prefill the login form. It is not
* authoritative.
*/
const COOKIE_USERNAME = 'phusr';
/**
* Stores the user's current session ID. This is authoritative and establishes
* the user's identity.
*/
const COOKIE_SESSION = 'phsid';
/**
* Stores a secret used during new account registration to prevent an attacker
* from tricking a victim into registering an account which is linked to
* credentials the attacker controls.
*/
const COOKIE_REGISTRATION = 'phreg';
/**
* Stores a secret used during OAuth2 handshakes to prevent various attacks
* where an attacker hands a victim a URI corresponding to the middle of an
* OAuth2 workflow and we might otherwise do something sketchy. Particularly,
* this corresponds to the OAuth2 "code".
*/
const COOKIE_CLIENTID = 'phcid';
/**
* Stores the URI to redirect the user to after login. This allows users to
* visit a path like `/feed/`, be prompted to login, and then be redirected
* back to `/feed/` after the workflow completes.
*/
const COOKIE_NEXTURI = 'next_uri';
/**
* Stores a hint that the user should be moved directly into high security
* after upgrading a partial login session. This is used during password
* recovery to avoid a double-prompt.
*/
const COOKIE_HISEC = 'jump_to_hisec';
/**
* Stores an invite code.
*/
const COOKIE_INVITE = 'invite';
/**
* Stores a workflow completion across a redirect-after-POST following a
* form submission. This can be used to show "Changes Saved" messages.
*/
const COOKIE_SUBMIT = 'phfrm';
/* -( Client ID Cookie )--------------------------------------------------- */
/**
* Set the client ID cookie. This is a random cookie used like a CSRF value
* during authentication workflows.
*
* @param AphrontRequest $request Request to modify.
* @return void
* @task clientid
*/
public static function setClientIDCookie(AphrontRequest $request) {
// NOTE: See T3471 for some discussion. Some browsers and browser extensions
// can make duplicate requests, so we overwrite this cookie only if it is
// not present in the request. The cookie lifetime is limited by making it
// temporary and clearing it when users log out.
$value = $request->getCookie(self::COOKIE_CLIENTID);
if (!phutil_nonempty_string($value)) {
$request->setTemporaryCookie(
self::COOKIE_CLIENTID,
Filesystem::readRandomCharacters(16));
}
}
/* -( Next URI Cookie )---------------------------------------------------- */
/**
* Set the Next URI cookie. We only write the cookie if it wasn't recently
* written, to avoid writing over a real URI with a bunch of "humans.txt"
* stuff. See T3793 for discussion.
*
* @param AphrontRequest $request Request to write to.
* @param string $next_uri URI to write.
- * @param bool? $force Write this cookie even if we have a
- * fresh cookie already.
+ * @param bool $force (optional) Write this cookie even if we
+ * have a fresh cookie already.
* @return void
*
* @task next
*/
public static function setNextURICookie(
AphrontRequest $request,
$next_uri,
$force = false) {
if (!$force) {
$cookie_value = $request->getCookie(self::COOKIE_NEXTURI);
list($set_at, $current_uri) = self::parseNextURICookie($cookie_value);
// If the cookie was set within the last 2 minutes, don't overwrite it.
// Primarily, this prevents browser requests for resources which do not
// exist (like "humans.txt" and various icons) from overwriting a normal
// URI like "/feed/".
if ($set_at > (time() - 120)) {
return;
}
}
$new_value = time().','.$next_uri;
$request->setTemporaryCookie(self::COOKIE_NEXTURI, $new_value);
}
/**
* Read the URI out of the Next URI cookie.
*
* @param AphrontRequest $request Request to examine.
* @return string|null Next URI cookie's URI value.
*
* @task next
*/
public static function getNextURICookie(AphrontRequest $request) {
$cookie_value = $request->getCookie(self::COOKIE_NEXTURI);
list($set_at, $next_uri) = self::parseNextURICookie($cookie_value);
return $next_uri;
}
/**
* Parse a Next URI cookie into its components.
*
* @param string $cookie Raw cookie value.
* @return list<string> List of timestamp and URI.
*
* @task next
*/
private static function parseNextURICookie($cookie) {
// Old cookies look like: /uri
// New cookies look like: timestamp,/uri
if (!phutil_nonempty_string($cookie)) {
return null;
}
if (strpos($cookie, ',') !== false) {
list($timestamp, $uri) = explode(',', $cookie, 2);
return array((int)$timestamp, $uri);
}
return array(0, $cookie);
}
}
diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php
index bb95c2ec72..7d0156dc18 100644
--- a/src/applications/auth/controller/PhabricatorAuthController.php
+++ b/src/applications/auth/controller/PhabricatorAuthController.php
@@ -1,300 +1,300 @@
<?php
abstract class PhabricatorAuthController extends PhabricatorController {
protected function renderErrorPage($title, array $messages) {
$view = new PHUIInfoView();
$view->setTitle($title);
$view->setErrors($messages);
return $this->newPage()
->setTitle($title)
->appendChild($view);
}
/**
* Returns true if this install is newly setup (i.e., there are no user
* accounts yet). In this case, we enter a special mode to permit creation
* of the first account form the web UI.
*/
protected function isFirstTimeSetup() {
// If there are any auth providers, this isn't first time setup, even if
// we don't have accounts.
if (PhabricatorAuthProvider::getAllEnabledProviders()) {
return false;
}
// Otherwise, check if there are any user accounts. If not, we're in first
// time setup.
$any_users = id(new PhabricatorPeopleQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->setLimit(1)
->execute();
return !$any_users;
}
/**
* Log a user into a web session and return an @{class:AphrontResponse} which
* corresponds to continuing the login process.
*
* Normally, this is a redirect to the validation controller which makes sure
* the user's cookies are set. However, event listeners can intercept this
* event and do something else if they prefer.
*
* @param PhabricatorUser $user User to log the viewer in as.
- * @param bool? $force_full_session True to issue a full session immediately,
- * bypassing MFA.
+ * @param bool $force_full_session (optional) True to issue a full session
+ * immediately, bypassing MFA.
* @return AphrontResponse Response which continues the login process.
*/
protected function loginUser(
PhabricatorUser $user,
$force_full_session = false) {
$response = $this->buildLoginValidateResponse($user);
$session_type = PhabricatorAuthSession::TYPE_WEB;
if ($force_full_session) {
$partial_session = false;
} else {
$partial_session = true;
}
$session_key = id(new PhabricatorAuthSessionEngine())
->establishSession($session_type, $user->getPHID(), $partial_session);
// NOTE: We allow disabled users to login and roadblock them later, so
// there's no check for users being disabled here.
$request = $this->getRequest();
$request->setCookie(
PhabricatorCookies::COOKIE_USERNAME,
$user->getUsername());
$request->setCookie(
PhabricatorCookies::COOKIE_SESSION,
$session_key);
$this->clearRegistrationCookies();
return $response;
}
protected function clearRegistrationCookies() {
$request = $this->getRequest();
// Clear the registration key.
$request->clearCookie(PhabricatorCookies::COOKIE_REGISTRATION);
// Clear the client ID / OAuth state key.
$request->clearCookie(PhabricatorCookies::COOKIE_CLIENTID);
// Clear the invite cookie.
$request->clearCookie(PhabricatorCookies::COOKIE_INVITE);
}
private function buildLoginValidateResponse(PhabricatorUser $user) {
$validate_uri = new PhutilURI($this->getApplicationURI('validate/'));
$validate_uri->replaceQueryParam('expect', $user->getUsername());
return id(new AphrontRedirectResponse())->setURI((string)$validate_uri);
}
protected function renderError($message) {
return $this->renderErrorPage(
pht('Authentication Error'),
array(
$message,
));
}
protected function loadAccountForRegistrationOrLinking($account_key) {
$request = $this->getRequest();
$viewer = $request->getUser();
$account = null;
$provider = null;
$response = null;
if (!$account_key) {
$response = $this->renderError(
pht('Request did not include account key.'));
return array($account, $provider, $response);
}
// NOTE: We're using the omnipotent user because the actual user may not
// be logged in yet, and because we want to tailor an error message to
// distinguish between "not usable" and "does not exist". We do explicit
// checks later on to make sure this account is valid for the intended
// operation. This requires edit permission for completeness and consistency
// but it won't actually be meaningfully checked because we're using the
// omnipotent user.
$account = id(new PhabricatorExternalAccountQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withAccountSecrets(array($account_key))
->needImages(true)
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->executeOne();
if (!$account) {
$response = $this->renderError(pht('No valid linkable account.'));
return array($account, $provider, $response);
}
if ($account->getUserPHID()) {
if ($account->getUserPHID() != $viewer->getPHID()) {
$response = $this->renderError(
pht(
'The account you are attempting to register or link is already '.
'linked to another user.'));
} else {
$response = $this->renderError(
pht(
'The account you are attempting to link is already linked '.
'to your account.'));
}
return array($account, $provider, $response);
}
$registration_key = $request->getCookie(
PhabricatorCookies::COOKIE_REGISTRATION);
// NOTE: This registration key check is not strictly necessary, because
// we're only creating new accounts, not linking existing accounts. It
// might be more hassle than it is worth, especially for email.
//
// The attack this prevents is getting to the registration screen, then
// copy/pasting the URL and getting someone else to click it and complete
// the process. They end up with an account bound to credentials you
// control. This doesn't really let you do anything meaningful, though,
// since you could have simply completed the process yourself.
if (!$registration_key) {
$response = $this->renderError(
pht(
'Your browser did not submit a registration key with the request. '.
'You must use the same browser to begin and complete registration. '.
'Check that cookies are enabled and try again.'));
return array($account, $provider, $response);
}
// We store the digest of the key rather than the key itself to prevent a
// theoretical attacker with read-only access to the database from
// hijacking registration sessions.
$actual = $account->getProperty('registrationKey');
$expect = PhabricatorHash::weakDigest($registration_key);
if (!phutil_hashes_are_identical($actual, $expect)) {
$response = $this->renderError(
pht(
'Your browser submitted a different registration key than the one '.
'associated with this account. You may need to clear your cookies.'));
return array($account, $provider, $response);
}
$config = $account->getProviderConfig();
if (!$config->getIsEnabled()) {
$response = $this->renderError(
pht(
'The account you are attempting to register with uses a disabled '.
'authentication provider ("%s"). An administrator may have '.
'recently disabled this provider.',
$config->getDisplayName()));
return array($account, $provider, $response);
}
$provider = $config->getProvider();
return array($account, $provider, null);
}
protected function loadInvite() {
$invite_cookie = PhabricatorCookies::COOKIE_INVITE;
$invite_code = $this->getRequest()->getCookie($invite_cookie);
if (!$invite_code) {
return null;
}
$engine = id(new PhabricatorAuthInviteEngine())
->setViewer($this->getViewer())
->setUserHasConfirmedVerify(true);
try {
return $engine->processInviteCode($invite_code);
} catch (Exception $ex) {
// If this fails for any reason, just drop the invite. In normal
// circumstances, we gave them a detailed explanation of any error
// before they jumped into this workflow.
return null;
}
}
protected function renderInviteHeader(PhabricatorAuthInvite $invite) {
$viewer = $this->getViewer();
// Since the user hasn't registered yet, they may not be able to see other
// user accounts. Load the inviting user with the omnipotent viewer.
$omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
$invite_author = id(new PhabricatorPeopleQuery())
->setViewer($omnipotent_viewer)
->withPHIDs(array($invite->getAuthorPHID()))
->needProfileImage(true)
->executeOne();
// If we can't load the author for some reason, just drop this message.
// We lose the value of contextualizing things without author details.
if (!$invite_author) {
return null;
}
$invite_item = id(new PHUIObjectItemView())
->setHeader(
pht(
'Welcome to %s!',
PlatformSymbols::getPlatformServerName()))
->setImageURI($invite_author->getProfileImageURI())
->addAttribute(
pht(
'%s has invited you to join %s.',
$invite_author->getFullName(),
PlatformSymbols::getPlatformServerName()));
$invite_list = id(new PHUIObjectItemListView())
->addItem($invite_item)
->setFlush(true);
return id(new PHUIBoxView())
->addMargin(PHUI::MARGIN_LARGE)
->appendChild($invite_list);
}
final protected function newCustomStartMessage() {
$viewer = $this->getViewer();
$text = PhabricatorAuthMessage::loadMessageText(
$viewer,
PhabricatorAuthLoginMessageType::MESSAGEKEY);
if (!phutil_nonempty_string($text)) {
return null;
}
$remarkup_view = new PHUIRemarkupView($viewer, $text);
return phutil_tag(
'div',
array(
'class' => 'auth-custom-message',
),
$remarkup_view);
}
}
diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
index 30d85b6fac..2074e71f8b 100644
--- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
+++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php
@@ -1,1180 +1,1181 @@
<?php
/**
*
* @task use Using Sessions
* @task new Creating Sessions
* @task hisec High Security
* @task partial Partial Sessions
* @task onetime One Time Login URIs
* @task cache User Cache
*/
final class PhabricatorAuthSessionEngine extends Phobject {
/**
* Session issued to normal users after they login through a standard channel.
* Associates the client with a standard user identity.
*/
const KIND_USER = 'U';
/**
* Session issued to users who login with some sort of credentials but do not
* have full accounts. These are sometimes called "grey users".
*
* TODO: We do not currently issue these sessions, see T4310.
*/
const KIND_EXTERNAL = 'X';
/**
* Session issued to logged-out users which has no real identity information.
* Its purpose is to protect logged-out users from CSRF.
*/
const KIND_ANONYMOUS = 'A';
/**
* Session kind isn't known.
*/
const KIND_UNKNOWN = '?';
const ONETIME_RECOVER = 'recover';
const ONETIME_RESET = 'reset';
const ONETIME_WELCOME = 'welcome';
const ONETIME_USERNAME = 'rename';
private $workflowKey;
private $request;
public function setWorkflowKey($workflow_key) {
$this->workflowKey = $workflow_key;
return $this;
}
public function getWorkflowKey() {
// TODO: A workflow key should become required in order to issue an MFA
// challenge, but allow things to keep working for now until we can update
// callsites.
if ($this->workflowKey === null) {
return 'legacy';
}
return $this->workflowKey;
}
public function getRequest() {
return $this->request;
}
/**
* Get the session kind (e.g., anonymous, user, external account) from a
* session token. Returns a `KIND_` constant.
*
* @param string $session_token Session token.
* @return const Session kind constant.
*/
public static function getSessionKindFromToken($session_token) {
if (strpos($session_token, '/') === false) {
// Old-style session, these are all user sessions.
return self::KIND_USER;
}
list($kind, $key) = explode('/', $session_token, 2);
switch ($kind) {
case self::KIND_ANONYMOUS:
case self::KIND_USER:
case self::KIND_EXTERNAL:
return $kind;
default:
return self::KIND_UNKNOWN;
}
}
/**
* Load the user identity associated with a session of a given type,
* identified by token.
*
* When the user presents a session token to an API, this method verifies
* it is of the correct type and loads the corresponding identity if the
* session exists and is valid.
*
* NOTE: `$session_type` is the type of session that is required by the
* loading context. This prevents use of a Conduit sesssion as a Web
* session, for example.
*
* @param const $session_type The type of session to load.
* @param string $session_token The session token.
* @return PhabricatorUser|null
* @task use
*/
public function loadUserForSession($session_type, $session_token) {
$session_kind = self::getSessionKindFromToken($session_token);
switch ($session_kind) {
case self::KIND_ANONYMOUS:
// Don't bother trying to load a user for an anonymous session, since
// neither the session nor the user exist.
return null;
case self::KIND_UNKNOWN:
// If we don't know what kind of session this is, don't go looking for
// it.
return null;
case self::KIND_USER:
break;
case self::KIND_EXTERNAL:
// TODO: Implement these (T4310).
return null;
}
$session_table = new PhabricatorAuthSession();
$user_table = new PhabricatorUser();
$conn = $session_table->establishConnection('r');
// TODO: See T13225. We're moving sessions to a more modern digest
// algorithm, but still accept older cookies for compatibility.
$session_key = PhabricatorAuthSession::newSessionDigest(
new PhutilOpaqueEnvelope($session_token));
$weak_key = PhabricatorHash::weakDigest($session_token);
$cache_parts = $this->getUserCacheQueryParts($conn);
list($cache_selects, $cache_joins, $cache_map, $types_map) = $cache_parts;
$info = queryfx_one(
$conn,
'SELECT
s.id AS s_id,
s.phid AS s_phid,
s.sessionExpires AS s_sessionExpires,
s.sessionStart AS s_sessionStart,
s.highSecurityUntil AS s_highSecurityUntil,
s.isPartial AS s_isPartial,
s.signedLegalpadDocuments as s_signedLegalpadDocuments,
IF(s.sessionKey = %P, 1, 0) as s_weak,
u.*
%Q
FROM %R u JOIN %R s ON u.phid = s.userPHID
AND s.type = %s AND s.sessionKey IN (%P, %P) %Q',
new PhutilOpaqueEnvelope($weak_key),
$cache_selects,
$user_table,
$session_table,
$session_type,
new PhutilOpaqueEnvelope($session_key),
new PhutilOpaqueEnvelope($weak_key),
$cache_joins);
if (!$info) {
return null;
}
// TODO: Remove this, see T13225.
$is_weak = (bool)$info['s_weak'];
unset($info['s_weak']);
$session_dict = array(
'userPHID' => $info['phid'],
'sessionKey' => $session_key,
'type' => $session_type,
);
$cache_raw = array_fill_keys($cache_map, null);
foreach ($info as $key => $value) {
if (strncmp($key, 's_', 2) === 0) {
unset($info[$key]);
$session_dict[substr($key, 2)] = $value;
continue;
}
if (isset($cache_map[$key])) {
unset($info[$key]);
$cache_raw[$cache_map[$key]] = $value;
continue;
}
}
$user = $user_table->loadFromArray($info);
$cache_raw = $this->filterRawCacheData($user, $types_map, $cache_raw);
$user->attachRawCacheData($cache_raw);
switch ($session_type) {
case PhabricatorAuthSession::TYPE_WEB:
// Explicitly prevent bots and mailing lists from establishing web
// sessions. It's normally impossible to attach authentication to these
// accounts, and likewise impossible to generate sessions, but it's
// technically possible that a session could exist in the database. If
// one does somehow, refuse to load it.
if (!$user->canEstablishWebSessions()) {
return null;
}
break;
}
$session = id(new PhabricatorAuthSession())->loadFromArray($session_dict);
$this->extendSession($session);
// TODO: Remove this, see T13225.
if ($is_weak) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$conn_w = $session_table->establishConnection('w');
queryfx(
$conn_w,
'UPDATE %T SET sessionKey = %P WHERE id = %d',
$session->getTableName(),
new PhutilOpaqueEnvelope($session_key),
$session->getID());
unset($unguarded);
}
$user->attachSession($session);
return $user;
}
/**
* Issue a new session key for a given identity. Phabricator supports
* different types of sessions (like "web" and "conduit") and each session
* type may have multiple concurrent sessions (this allows a user to be
* logged in on multiple browsers at the same time, for instance).
*
* Note that this method is transport-agnostic and does not set cookies or
* issue other types of tokens, it ONLY generates a new session key.
*
* You can configure the maximum number of concurrent sessions for various
* session types in the Phabricator configuration.
*
* @param const $session_type Session type constant (see
* @{class:PhabricatorAuthSession}).
* @param phid|null $identity_phid Identity to establish a session for,
* usually a user PHID. With `null`, generates an
* anonymous session.
* @param bool $partial True to issue a partial session.
* @return string Newly generated session key.
*/
public function establishSession($session_type, $identity_phid, $partial) {
// Consume entropy to generate a new session key, forestalling the eventual
// heat death of the universe.
$session_key = Filesystem::readRandomCharacters(40);
if ($identity_phid === null) {
return self::KIND_ANONYMOUS.'/'.$session_key;
}
$session_table = new PhabricatorAuthSession();
$conn_w = $session_table->establishConnection('w');
// This has a side effect of validating the session type.
$session_ttl = PhabricatorAuthSession::getSessionTypeTTL(
$session_type,
$partial);
$digest_key = PhabricatorAuthSession::newSessionDigest(
new PhutilOpaqueEnvelope($session_key));
// Logging-in users don't have CSRF stuff yet, so we have to unguard this
// write.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
id(new PhabricatorAuthSession())
->setUserPHID($identity_phid)
->setType($session_type)
->setSessionKey($digest_key)
->setSessionStart(time())
->setSessionExpires(time() + $session_ttl)
->setIsPartial($partial ? 1 : 0)
->setSignedLegalpadDocuments(0)
->save();
$log = PhabricatorUserLog::initializeNewLog(
null,
$identity_phid,
($partial
? PhabricatorPartialLoginUserLogType::LOGTYPE
: PhabricatorLoginUserLogType::LOGTYPE));
$log->setDetails(
array(
'session_type' => $session_type,
));
$log->setSession($digest_key);
$log->save();
unset($unguarded);
$info = id(new PhabricatorAuthSessionInfo())
->setSessionType($session_type)
->setIdentityPHID($identity_phid)
->setIsPartial($partial);
$extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
foreach ($extensions as $extension) {
$extension->didEstablishSession($info);
}
return $session_key;
}
/**
* Terminate all of a user's login sessions.
*
* This is used when users change passwords, linked accounts, or add
* multifactor authentication.
*
* @param PhabricatorUser $user User whose sessions should be terminated.
- * @param string|null? $except_session Optionally, one session to keep.
+ * @param string|null $except_session (optional) One session to keep.
* Normally, the current login session.
*
* @return void
*/
public function terminateLoginSessions(
PhabricatorUser $user,
PhutilOpaqueEnvelope $except_session = null) {
$sessions = id(new PhabricatorAuthSessionQuery())
->setViewer($user)
->withIdentityPHIDs(array($user->getPHID()))
->execute();
if ($except_session !== null) {
$except_session = PhabricatorAuthSession::newSessionDigest(
$except_session);
}
foreach ($sessions as $key => $session) {
if ($except_session !== null) {
$is_except = phutil_hashes_are_identical(
$session->getSessionKey(),
$except_session);
if ($is_except) {
continue;
}
}
$session->delete();
}
}
public function logoutSession(
PhabricatorUser $user,
PhabricatorAuthSession $session) {
$log = PhabricatorUserLog::initializeNewLog(
$user,
$user->getPHID(),
PhabricatorLogoutUserLogType::LOGTYPE);
$log->save();
$extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
foreach ($extensions as $extension) {
$extension->didLogout($user, array($session));
}
$session->delete();
}
/* -( High Security )------------------------------------------------------ */
/**
* Require the user respond to a high security (MFA) check.
*
* This method differs from @{method:requireHighSecuritySession} in that it
* does not upgrade the user's session as a side effect. This method is
* appropriate for one-time checks.
*
* @param PhabricatorUser $viewer User whose session needs to be in high
* security.
* @param AphrontRequest $request Current request.
* @param string $cancel_uri URI to return the user to if they
* cancel.
* @return PhabricatorAuthHighSecurityToken Security token.
* @task hisec
*/
public function requireHighSecurityToken(
PhabricatorUser $viewer,
AphrontRequest $request,
$cancel_uri) {
return $this->newHighSecurityToken(
$viewer,
$request,
$cancel_uri,
false,
false);
}
/**
* Require high security, or prompt the user to enter high security.
*
* If the user's session is in high security, this method will return a
* token. Otherwise, it will throw an exception which will eventually
* be converted into a multi-factor authentication workflow.
*
* This method upgrades the user's session to high security for a short
* period of time, and is appropriate if you anticipate they may need to
* take multiple high security actions. To perform a one-time check instead,
* use @{method:requireHighSecurityToken}.
*
* @param PhabricatorUser $viewer User whose session needs to be in high
* security.
* @param AphrontRequest $request Current request.
* @param string $cancel_uri URI to return the user to if they
* cancel.
- * @param bool? $jump_into_hisec True to jump partial sessions
- * directly into high security instead of just
- * upgrading them to full sessions.
+ * @param bool $jump_into_hisec (optional) True to jump partial
+ * sessions directly into high security instead of
+ * just upgrading them to full sessions.
* @return PhabricatorAuthHighSecurityToken Security token.
* @task hisec
*/
public function requireHighSecuritySession(
PhabricatorUser $viewer,
AphrontRequest $request,
$cancel_uri,
$jump_into_hisec = false) {
return $this->newHighSecurityToken(
$viewer,
$request,
$cancel_uri,
$jump_into_hisec,
true);
}
private function newHighSecurityToken(
PhabricatorUser $viewer,
AphrontRequest $request,
$cancel_uri,
$jump_into_hisec,
$upgrade_session) {
if (!$viewer->hasSession()) {
throw new Exception(
pht('Requiring a high-security session from a user with no session!'));
}
// TODO: If a user answers a "requireHighSecurityToken()" prompt and hits
// a "requireHighSecuritySession()" prompt a short time later, the one-shot
// token should be good enough to upgrade the session.
$session = $viewer->getSession();
// Check if the session is already in high security mode.
$token = $this->issueHighSecurityToken($session);
if ($token) {
return $token;
}
// Load the multi-factor auth sources attached to this account. Note that
// we order factors from oldest to newest, which is not the default query
// ordering but makes the greatest sense in context.
$factors = id(new PhabricatorAuthFactorConfigQuery())
->setViewer($viewer)
->withUserPHIDs(array($viewer->getPHID()))
->withFactorProviderStatuses(
array(
PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
))
->execute();
// Sort factors in the same order that they appear in on the Settings
// panel. This means that administrators changing provider statuses may
// change the order of prompts for users, but the alternative is that the
// Settings panel order disagrees with the prompt order, which seems more
// disruptive.
$factors = msortv($factors, 'newSortVector');
// If the account has no associated multi-factor auth, just issue a token
// without putting the session into high security mode. This is generally
// easier for users. A minor but desirable side effect is that when a user
// adds an auth factor, existing sessions won't get a free pass into hisec,
// since they never actually got marked as hisec.
if (!$factors) {
return $this->issueHighSecurityToken($session, true)
->setIsUnchallengedToken(true);
}
$this->request = $request;
foreach ($factors as $factor) {
$factor->setSessionEngine($this);
}
// Check for a rate limit without awarding points, so the user doesn't
// get partway through the workflow only to get blocked.
PhabricatorSystemActionEngine::willTakeAction(
array($viewer->getPHID()),
new PhabricatorAuthTryFactorAction(),
0);
$now = PhabricatorTime::getNow();
// We need to do challenge validation first, since this happens whether you
// submitted responses or not. You can't get a "bad response" error before
// you actually submit a response, but you can get a "wait, we can't
// issue a challenge yet" response. Load all issued challenges which are
// currently valid.
$challenges = id(new PhabricatorAuthChallengeQuery())
->setViewer($viewer)
->withFactorPHIDs(mpull($factors, 'getPHID'))
->withUserPHIDs(array($viewer->getPHID()))
->withChallengeTTLBetween($now, null)
->execute();
PhabricatorAuthChallenge::newChallengeResponsesFromRequest(
$challenges,
$request);
$challenge_map = mgroup($challenges, 'getFactorPHID');
$validation_results = array();
$ok = true;
// Validate each factor against issued challenges. For example, this
// prevents you from receiving or responding to a TOTP challenge if another
// challenge was recently issued to a different session.
foreach ($factors as $factor) {
$factor_phid = $factor->getPHID();
$issued_challenges = idx($challenge_map, $factor_phid, array());
$provider = $factor->getFactorProvider();
$impl = $provider->getFactor();
$new_challenges = $impl->getNewIssuedChallenges(
$factor,
$viewer,
$issued_challenges);
// NOTE: We may get a list of challenges back, or may just get an early
// result. For example, this can happen on an SMS factor if all SMS
// mailers have been disabled.
if ($new_challenges instanceof PhabricatorAuthFactorResult) {
$result = $new_challenges;
if (!$result->getIsValid()) {
$ok = false;
}
$validation_results[$factor_phid] = $result;
$challenge_map[$factor_phid] = $issued_challenges;
continue;
}
foreach ($new_challenges as $new_challenge) {
$issued_challenges[] = $new_challenge;
}
$challenge_map[$factor_phid] = $issued_challenges;
if (!$issued_challenges) {
continue;
}
$result = $impl->getResultFromIssuedChallenges(
$factor,
$viewer,
$issued_challenges);
if (!$result) {
continue;
}
if (!$result->getIsValid()) {
$ok = false;
}
$validation_results[$factor_phid] = $result;
}
if ($request->isHTTPPost()) {
$request->validateCSRF();
if ($request->getExists(AphrontRequest::TYPE_HISEC)) {
// Limit factor verification rates to prevent brute force attacks.
$any_attempt = false;
foreach ($factors as $factor) {
$factor_phid = $factor->getPHID();
$provider = $factor->getFactorProvider();
$impl = $provider->getFactor();
// If we already have a result (normally "wait..."), we won't try
// to validate whatever the user submitted, so this doesn't count as
// an attempt for rate limiting purposes.
if (isset($validation_results[$factor_phid])) {
continue;
}
if ($impl->getRequestHasChallengeResponse($factor, $request)) {
$any_attempt = true;
break;
}
}
if ($any_attempt) {
PhabricatorSystemActionEngine::willTakeAction(
array($viewer->getPHID()),
new PhabricatorAuthTryFactorAction(),
1);
}
foreach ($factors as $factor) {
$factor_phid = $factor->getPHID();
// If we already have a validation result from previously issued
// challenges, skip validating this factor.
if (isset($validation_results[$factor_phid])) {
continue;
}
$issued_challenges = idx($challenge_map, $factor_phid, array());
$provider = $factor->getFactorProvider();
$impl = $provider->getFactor();
$validation_result = $impl->getResultFromChallengeResponse(
$factor,
$viewer,
$request,
$issued_challenges);
if (!$validation_result->getIsValid()) {
$ok = false;
}
$validation_results[$factor_phid] = $validation_result;
}
if ($ok) {
// We're letting you through, so mark all the challenges you
// responded to as completed. These challenges can never be used
// again, even by the same session and workflow: you can't use the
// same response to take two different actions, even if those actions
// are of the same type.
foreach ($validation_results as $validation_result) {
$challenge = $validation_result->getAnsweredChallenge()
->markChallengeAsCompleted();
}
// Give the user a credit back for a successful factor verification.
if ($any_attempt) {
PhabricatorSystemActionEngine::willTakeAction(
array($viewer->getPHID()),
new PhabricatorAuthTryFactorAction(),
-1);
}
if ($session->getIsPartial() && !$jump_into_hisec) {
// If we have a partial session and are not jumping directly into
// hisec, just issue a token without putting it in high security
// mode.
return $this->issueHighSecurityToken($session, true);
}
// If we aren't upgrading the session itself, just issue a token.
if (!$upgrade_session) {
return $this->issueHighSecurityToken($session, true);
}
$until = time() + phutil_units('15 minutes in seconds');
$session->setHighSecurityUntil($until);
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET highSecurityUntil = %d WHERE id = %d',
$session->getTableName(),
$until,
$session->getID());
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorEnterHisecUserLogType::LOGTYPE);
$log->save();
} else {
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorFailHisecUserLogType::LOGTYPE);
$log->save();
}
}
}
$token = $this->issueHighSecurityToken($session);
if ($token) {
return $token;
}
// If we don't have a validation result for some factors yet, fill them
// in with an empty result so form rendering doesn't have to care if the
// results exist or not. This happens when you first load the form and have
// not submitted any responses yet.
foreach ($factors as $factor) {
$factor_phid = $factor->getPHID();
if (isset($validation_results[$factor_phid])) {
continue;
}
$issued_challenges = idx($challenge_map, $factor_phid, array());
$validation_results[$factor_phid] = $impl->getResultForPrompt(
$factor,
$viewer,
$request,
$issued_challenges);
}
throw id(new PhabricatorAuthHighSecurityRequiredException())
->setCancelURI($cancel_uri)
->setIsSessionUpgrade($upgrade_session)
->setFactors($factors)
->setFactorValidationResults($validation_results);
}
/**
* Issue a high security token for a session, if authorized.
*
* @param PhabricatorAuthSession $session Session to issue a token for.
- * @param bool? $force Force token issue.
+ * @param bool $force (optional) Force token issue.
* @return PhabricatorAuthHighSecurityToken|null Token, if authorized.
* @task hisec
*/
private function issueHighSecurityToken(
PhabricatorAuthSession $session,
$force = false) {
if ($session->isHighSecuritySession() || $force) {
return new PhabricatorAuthHighSecurityToken();
}
return null;
}
/**
* Render a form for providing relevant multi-factor credentials.
*
* @param array $factors
* @param array $validation_results
* @param PhabricatorUser $viewer Viewing user.
* @param AphrontRequest $request Current request.
* @return AphrontFormView Renderable form.
* @task hisec
*/
public function renderHighSecurityForm(
array $factors,
array $validation_results,
PhabricatorUser $viewer,
AphrontRequest $request) {
assert_instances_of($validation_results, 'PhabricatorAuthFactorResult');
$form = id(new AphrontFormView())
->setUser($viewer)
->appendRemarkupInstructions('');
$answered = array();
foreach ($factors as $factor) {
$result = $validation_results[$factor->getPHID()];
$provider = $factor->getFactorProvider();
$impl = $provider->getFactor();
$impl->renderValidateFactorForm(
$factor,
$form,
$viewer,
$result);
$answered_challenge = $result->getAnsweredChallenge();
if ($answered_challenge) {
$answered[] = $answered_challenge;
}
}
$form->appendRemarkupInstructions('');
if ($answered) {
$http_params = PhabricatorAuthChallenge::newHTTPParametersFromChallenges(
$answered);
foreach ($http_params as $key => $value) {
$form->addHiddenInput($key, $value);
}
}
return $form;
}
/**
* Strip the high security flag from a session.
*
* Kicks a session out of high security and logs the exit.
*
* @param PhabricatorUser $viewer Acting user.
* @param PhabricatorAuthSession $session Session to return to normal
* security.
* @return void
* @task hisec
*/
public function exitHighSecurity(
PhabricatorUser $viewer,
PhabricatorAuthSession $session) {
if (!$session->getHighSecurityUntil()) {
return;
}
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET highSecurityUntil = NULL WHERE id = %d',
$session->getTableName(),
$session->getID());
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorExitHisecUserLogType::LOGTYPE);
$log->save();
}
/* -( Partial Sessions )--------------------------------------------------- */
/**
* Upgrade a partial session to a full session.
*
* @param PhabricatorUser $viewer Viewer whose session should upgrade.
* @return void
* @task partial
*/
public function upgradePartialSession(PhabricatorUser $viewer) {
if (!$viewer->hasSession()) {
throw new Exception(
pht('Upgrading partial session of user with no session!'));
}
$session = $viewer->getSession();
if (!$session->getIsPartial()) {
throw new Exception(pht('Session is not partial!'));
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$session->setIsPartial(0);
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET isPartial = %d WHERE id = %d',
$session->getTableName(),
0,
$session->getID());
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorFullLoginUserLogType::LOGTYPE);
$log->save();
unset($unguarded);
}
/* -( Legalpad Documents )-------------------------------------------------- */
/**
* Upgrade a session to have all legalpad documents signed.
*
* @param PhabricatorUser $viewer User whose session should upgrade.
* @param array $docs LegalpadDocument objects
* @return void
* @task partial
*/
public function signLegalpadDocuments(PhabricatorUser $viewer, array $docs) {
if (!$viewer->hasSession()) {
throw new Exception(
pht('Signing session legalpad documents of user with no session!'));
}
$session = $viewer->getSession();
if ($session->getSignedLegalpadDocuments()) {
throw new Exception(pht(
'Session has already signed required legalpad documents!'));
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$session->setSignedLegalpadDocuments(1);
queryfx(
$session->establishConnection('w'),
'UPDATE %T SET signedLegalpadDocuments = %d WHERE id = %d',
$session->getTableName(),
1,
$session->getID());
if (!empty($docs)) {
$log = PhabricatorUserLog::initializeNewLog(
$viewer,
$viewer->getPHID(),
PhabricatorSignDocumentsUserLogType::LOGTYPE);
$log->save();
}
unset($unguarded);
}
/* -( One Time Login URIs )------------------------------------------------ */
/**
* Retrieve a temporary, one-time URI which can log in to an account.
*
* These URIs are used for password recovery and to regain access to accounts
* which users have been locked out of.
*
* @param PhabricatorUser $user User to generate a URI for.
* @param PhabricatorUserEmail? $email Optionally, email to verify when
* link is used.
- * @param string? $type Optional context string for the URI. This is purely
+ * @param string $type (optional) Context string for the URI. This is purely
* cosmetic and used only to customize workflow and error messages.
- * @param bool? $force_full_session True to generate a URI which forces an
- * immediate upgrade to a full session, bypassing MFA and other login
- * checks.
+ * @param bool $force_full_session (optional) True to generate a URI which
+ * forces an immediate upgrade to a full session, bypassing MFA and other
+ * login checks.
* @return string Login URI.
* @task onetime
*/
public function getOneTimeLoginURI(
PhabricatorUser $user,
PhabricatorUserEmail $email = null,
$type = self::ONETIME_RESET,
$force_full_session = false) {
$key = Filesystem::readRandomCharacters(32);
$key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
$onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$token = id(new PhabricatorAuthTemporaryToken())
->setTokenResource($user->getPHID())
->setTokenType($onetime_type)
->setTokenExpires(time() + phutil_units('1 day in seconds'))
->setTokenCode($key_hash)
->setShouldForceFullSession($force_full_session)
->save();
unset($unguarded);
$uri = '/login/once/'.$type.'/'.$user->getID().'/'.$key.'/';
if ($email) {
$uri = $uri.$email->getID().'/';
}
try {
$uri = PhabricatorEnv::getProductionURI($uri);
} catch (Exception $ex) {
// If a user runs `bin/auth recover` before configuring the base URI,
// just show the path. We don't have any way to figure out the domain.
// See T4132.
}
return $uri;
}
/**
* Load the temporary token associated with a given one-time login key.
*
* @param PhabricatorUser $user User to load the token for.
- * @param PhabricatorUserEmail? $email Optionally, email to verify when
- * link is used.
- * @param string? $key Key user is presenting as a valid one-time login key.
+ * @param PhabricatorUserEmail $email (optional) Email to verify when link is
+ * used.
+ * @param string $key (optional) Key user is presenting as a valid one-time
+ * login key.
* @return PhabricatorAuthTemporaryToken|null Token, if one exists.
* @task onetime
*/
public function loadOneTimeLoginKey(
PhabricatorUser $user,
PhabricatorUserEmail $email = null,
$key = null) {
$key_hash = $this->getOneTimeLoginKeyHash($user, $email, $key);
$onetime_type = PhabricatorAuthOneTimeLoginTemporaryTokenType::TOKENTYPE;
return id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer($user)
->withTokenResources(array($user->getPHID()))
->withTokenTypes(array($onetime_type))
->withTokenCodes(array($key_hash))
->withExpired(false)
->executeOne();
}
/**
* Hash a one-time login key for storage as a temporary token.
*
* @param PhabricatorUser $user User this key is for.
- * @param PhabricatorUserEmail? $email Optionally, email to verify when
- * link is used.
- * @param string? $key The one time login key.
+ * @param PhabricatorUserEmail $email (optional) Email to verify when link is
+ * used.
+ * @param string $key (optional) The one time login key.
* @return string Hash of the key.
* task onetime
*/
private function getOneTimeLoginKeyHash(
PhabricatorUser $user,
PhabricatorUserEmail $email = null,
$key = null) {
$parts = array(
$key,
$user->getAccountSecret(),
);
if ($email) {
$parts[] = $email->getVerificationCode();
}
return PhabricatorHash::weakDigest(implode(':', $parts));
}
/* -( User Cache )--------------------------------------------------------- */
/**
* @task cache
*/
private function getUserCacheQueryParts(AphrontDatabaseConnection $conn) {
$cache_selects = array();
$cache_joins = array();
$cache_map = array();
$keys = array();
$types_map = array();
$cache_types = PhabricatorUserCacheType::getAllCacheTypes();
foreach ($cache_types as $cache_type) {
foreach ($cache_type->getAutoloadKeys() as $autoload_key) {
$keys[] = $autoload_key;
$types_map[$autoload_key] = $cache_type;
}
}
$cache_table = id(new PhabricatorUserCache())->getTableName();
$cache_idx = 1;
foreach ($keys as $key) {
$join_as = 'ucache_'.$cache_idx;
$select_as = 'ucache_'.$cache_idx.'_v';
$cache_selects[] = qsprintf(
$conn,
'%T.cacheData %T',
$join_as,
$select_as);
$cache_joins[] = qsprintf(
$conn,
'LEFT JOIN %T AS %T ON u.phid = %T.userPHID
AND %T.cacheIndex = %s',
$cache_table,
$join_as,
$join_as,
$join_as,
PhabricatorHash::digestForIndex($key));
$cache_map[$select_as] = $key;
$cache_idx++;
}
if ($cache_selects) {
$cache_selects = qsprintf($conn, ', %LQ', $cache_selects);
} else {
$cache_selects = qsprintf($conn, '');
}
if ($cache_joins) {
$cache_joins = qsprintf($conn, '%LJ', $cache_joins);
} else {
$cache_joins = qsprintf($conn, '');
}
return array($cache_selects, $cache_joins, $cache_map, $types_map);
}
private function filterRawCacheData(
PhabricatorUser $user,
array $types_map,
array $cache_raw) {
foreach ($cache_raw as $cache_key => $cache_data) {
$type = $types_map[$cache_key];
if ($type->shouldValidateRawCacheData()) {
if (!$type->isRawCacheDataValid($user, $cache_key, $cache_data)) {
unset($cache_raw[$cache_key]);
}
}
}
return $cache_raw;
}
public function willServeRequestForUser(PhabricatorUser $user) {
// We allow the login user to generate any missing cache data inline.
$user->setAllowInlineCacheGeneration(true);
// Switch to the user's translation.
PhabricatorEnv::setLocaleCode($user->getTranslation());
$extensions = PhabricatorAuthSessionEngineExtension::getAllExtensions();
foreach ($extensions as $extension) {
$extension->willServeRequestForUser($user);
}
}
private function extendSession(PhabricatorAuthSession $session) {
$is_partial = $session->getIsPartial();
// Don't extend partial sessions. You have a relatively short window to
// upgrade into a full session, and your session expires otherwise.
if ($is_partial) {
return;
}
$session_type = $session->getType();
$ttl = PhabricatorAuthSession::getSessionTypeTTL(
$session_type,
$session->getIsPartial());
// If more than 20% of the time on this session has been used, refresh the
// TTL back up to the full duration. The idea here is that sessions are
// good forever if used regularly, but get GC'd when they fall out of use.
$now = PhabricatorTime::getNow();
if ($now + (0.80 * $ttl) <= $session->getSessionExpires()) {
return;
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$session->establishConnection('w'),
'UPDATE %R SET sessionExpires = UNIX_TIMESTAMP() + %d
WHERE id = %d',
$session,
$ttl,
$session->getID());
unset($unguarded);
}
}
diff --git a/src/applications/auth/provider/PhabricatorAuthProvider.php b/src/applications/auth/provider/PhabricatorAuthProvider.php
index 4b90daa2aa..4a56ef0a3a 100644
--- a/src/applications/auth/provider/PhabricatorAuthProvider.php
+++ b/src/applications/auth/provider/PhabricatorAuthProvider.php
@@ -1,594 +1,595 @@
<?php
abstract class PhabricatorAuthProvider extends Phobject {
private $providerConfig;
public function attachProviderConfig(PhabricatorAuthProviderConfig $config) {
$this->providerConfig = $config;
return $this;
}
public function hasProviderConfig() {
return (bool)$this->providerConfig;
}
public function getProviderConfig() {
if ($this->providerConfig === null) {
throw new PhutilInvalidStateException('attachProviderConfig');
}
return $this->providerConfig;
}
public function getProviderConfigPHID() {
return $this->getProviderConfig()->getPHID();
}
public function getConfigurationHelp() {
return null;
}
public function getDefaultProviderConfig() {
return id(new PhabricatorAuthProviderConfig())
->setProviderClass(get_class($this))
->setIsEnabled(1)
->setShouldAllowLogin(1)
->setShouldAllowRegistration(1)
->setShouldAllowLink(1)
->setShouldAllowUnlink(1);
}
public function getNameForCreate() {
return $this->getProviderName();
}
public function getDescriptionForCreate() {
return null;
}
public function getProviderKey() {
return $this->getAdapter()->getAdapterKey();
}
public function getProviderType() {
return $this->getAdapter()->getAdapterType();
}
public function getProviderDomain() {
return $this->getAdapter()->getAdapterDomain();
}
public static function getAllBaseProviders() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->execute();
}
public static function getAllProviders() {
static $providers;
if ($providers === null) {
$objects = self::getAllBaseProviders();
$configs = id(new PhabricatorAuthProviderConfigQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->execute();
$providers = array();
foreach ($configs as $config) {
if (!isset($objects[$config->getProviderClass()])) {
// This configuration is for a provider which is not installed.
continue;
}
$object = clone $objects[$config->getProviderClass()];
$object->attachProviderConfig($config);
$key = $object->getProviderKey();
if (isset($providers[$key])) {
throw new Exception(
pht(
"Two authentication providers use the same provider key ".
"('%s'). Each provider must be identified by a unique key.",
$key));
}
$providers[$key] = $object;
}
}
return $providers;
}
public static function getAllEnabledProviders() {
$providers = self::getAllProviders();
foreach ($providers as $key => $provider) {
if (!$provider->isEnabled()) {
unset($providers[$key]);
}
}
return $providers;
}
public static function getEnabledProviderByKey($provider_key) {
return idx(self::getAllEnabledProviders(), $provider_key);
}
abstract public function getProviderName();
abstract public function getAdapter();
public function isEnabled() {
return $this->getProviderConfig()->getIsEnabled();
}
public function shouldAllowLogin() {
return $this->getProviderConfig()->getShouldAllowLogin();
}
public function shouldAllowRegistration() {
if (!$this->shouldAllowLogin()) {
return false;
}
return $this->getProviderConfig()->getShouldAllowRegistration();
}
public function shouldAllowAccountLink() {
return $this->getProviderConfig()->getShouldAllowLink();
}
public function shouldAllowAccountUnlink() {
return $this->getProviderConfig()->getShouldAllowUnlink();
}
public function shouldTrustEmails() {
return $this->shouldAllowEmailTrustConfiguration() &&
$this->getProviderConfig()->getShouldTrustEmails();
}
/**
* Should we allow the adapter to be marked as "trusted". This is true for
* all adapters except those that allow the user to type in emails (see
* @{class:PhabricatorPasswordAuthProvider}).
*/
public function shouldAllowEmailTrustConfiguration() {
return true;
}
public function buildLoginForm(PhabricatorAuthStartController $controller) {
return $this->renderLoginForm($controller->getRequest(), $mode = 'start');
}
public function buildInviteForm(PhabricatorAuthStartController $controller) {
return $this->renderLoginForm($controller->getRequest(), $mode = 'invite');
}
abstract public function processLoginRequest(
PhabricatorAuthLoginController $controller);
public function buildLinkForm($controller) {
return $this->renderLoginForm($controller->getRequest(), $mode = 'link');
}
public function shouldAllowAccountRefresh() {
return true;
}
public function buildRefreshForm(
PhabricatorAuthLinkController $controller) {
return $this->renderLoginForm($controller->getRequest(), $mode = 'refresh');
}
protected function renderLoginForm(AphrontRequest $request, $mode) {
throw new PhutilMethodNotImplementedException();
}
public function createProviders() {
return array($this);
}
protected function willSaveAccount(PhabricatorExternalAccount $account) {
return;
}
final protected function newExternalAccountForIdentifiers(
array $identifiers) {
assert_instances_of($identifiers, 'PhabricatorExternalAccountIdentifier');
if (!$identifiers) {
throw new Exception(
pht(
'Authentication provider (of class "%s") is attempting to '.
'load or create an external account, but provided no account '.
'identifiers.',
get_class($this)));
}
$config = $this->getProviderConfig();
$viewer = PhabricatorUser::getOmnipotentUser();
$raw_identifiers = mpull($identifiers, 'getIdentifierRaw');
$accounts = id(new PhabricatorExternalAccountQuery())
->setViewer($viewer)
->withProviderConfigPHIDs(array($config->getPHID()))
->withRawAccountIdentifiers($raw_identifiers)
->needAccountIdentifiers(true)
->execute();
if (!$accounts) {
$account = $this->newExternalAccount();
} else if (count($accounts) === 1) {
$account = head($accounts);
} else {
throw new Exception(
pht(
'Authentication provider (of class "%s") is attempting to load '.
'or create an external account, but provided a list of '.
'account identifiers which map to more than one account: %s.',
get_class($this),
implode(', ', $raw_identifiers)));
}
// See T13493. Add all the identifiers to the account. In the case where
// an account initially has a lower-quality identifier (like an email
// address) and later adds a higher-quality identifier (like a GUID), this
// allows us to automatically upgrade toward the higher-quality identifier
// and survive API changes which remove the lower-quality identifier more
// gracefully.
foreach ($identifiers as $identifier) {
$account->appendIdentifier($identifier);
}
return $this->didUpdateAccount($account);
}
final protected function newExternalAccountForUser(PhabricatorUser $user) {
$config = $this->getProviderConfig();
// When a user logs in with a provider like username/password, they
// always already have a Phabricator account (since there's no way they
// could have a username otherwise).
// These users should never go to registration, so we're building a
// dummy "external account" which just links directly back to their
// internal account.
$account = id(new PhabricatorExternalAccountQuery())
->setViewer($user)
->withProviderConfigPHIDs(array($config->getPHID()))
->withUserPHIDs(array($user->getPHID()))
->executeOne();
if (!$account) {
$account = $this->newExternalAccount()
->setUserPHID($user->getPHID());
}
return $this->didUpdateAccount($account);
}
private function didUpdateAccount(PhabricatorExternalAccount $account) {
$adapter = $this->getAdapter();
$account->setUsername($adapter->getAccountName());
$account->setRealName($adapter->getAccountRealName());
$account->setEmail($adapter->getAccountEmail());
$account->setAccountURI($adapter->getAccountURI());
$account->setProfileImagePHID(null);
$image_uri = $adapter->getAccountImageURI();
if ($image_uri) {
try {
$name = PhabricatorSlug::normalize($this->getProviderName());
$name = $name.'-profile.jpg';
// TODO: If the image has not changed, we do not need to make a new
// file entry for it, but there's no convenient way to do this with
// PhabricatorFile right now. The storage will get shared, so the impact
// here is negligible.
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$image_file = PhabricatorFile::newFromFileDownload(
$image_uri,
array(
'name' => $name,
'viewPolicy' => PhabricatorPolicies::POLICY_NOONE,
));
if ($image_file->isViewableImage()) {
$image_file
->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy())
->setCanCDN(true)
->save();
$account->setProfileImagePHID($image_file->getPHID());
} else {
$image_file->delete();
}
unset($unguarded);
} catch (Exception $ex) {
// Log this but proceed, it's not especially important that we
// be able to pull profile images.
phlog($ex);
}
}
$this->willSaveAccount($account);
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
$account->save();
unset($unguarded);
return $account;
}
public function getLoginURI() {
$app = PhabricatorApplication::getByClass('PhabricatorAuthApplication');
return $app->getApplicationURI('/login/'.$this->getProviderKey().'/');
}
public function getSettingsURI() {
return '/settings/panel/external/';
}
public function getStartURI() {
$app = PhabricatorApplication::getByClass('PhabricatorAuthApplication');
$uri = $app->getApplicationURI('/start/');
return $uri;
}
public function isDefaultRegistrationProvider() {
return false;
}
public function shouldRequireRegistrationPassword() {
return false;
}
public function newDefaultExternalAccount() {
return $this->newExternalAccount();
}
protected function newExternalAccount() {
$config = $this->getProviderConfig();
$adapter = $this->getAdapter();
$account = id(new PhabricatorExternalAccount())
->setProviderConfigPHID($config->getPHID())
->attachAccountIdentifiers(array());
// TODO: Remove this when these columns are removed. They no longer have
// readers or writers (other than this callsite).
$account
->setAccountType($adapter->getAdapterType())
->setAccountDomain($adapter->getAdapterDomain());
// TODO: Remove this when "accountID" is removed; the column is not
// nullable.
$account->setAccountID('');
return $account;
}
public function getLoginOrder() {
return '500-'.$this->getProviderName();
}
protected function getLoginIcon() {
return 'Generic';
}
public function newIconView() {
return id(new PHUIIconView())
->setSpriteSheet(PHUIIconView::SPRITE_LOGIN)
->setSpriteIcon($this->getLoginIcon());
}
public function isLoginFormAButton() {
return false;
}
public function renderConfigPropertyTransactionTitle(
PhabricatorAuthProviderConfigTransaction $xaction) {
return null;
}
public function readFormValuesFromProvider() {
return array();
}
public function readFormValuesFromRequest(AphrontRequest $request) {
return array();
}
public function processEditForm(
AphrontRequest $request,
array $values) {
$errors = array();
$issues = array();
return array($errors, $issues, $values);
}
public function extendEditForm(
AphrontRequest $request,
AphrontFormView $form,
array $values,
array $issues) {
return;
}
public function willRenderLinkedAccount(
PhabricatorUser $viewer,
PHUIObjectItemView $item,
PhabricatorExternalAccount $account) {
$account_view = id(new PhabricatorAuthAccountView())
->setExternalAccount($account)
->setAuthProvider($this);
$item->appendChild(
phutil_tag(
'div',
array(
'class' => 'mmr mml mst mmb',
),
$account_view));
}
/**
* Return true to use a two-step configuration (setup, configure) instead of
* the default single-step configuration. In practice, this means that
* creating a new provider instance will redirect back to the edit page
* instead of the provider list.
*
* @return bool True if this provider uses two-step configuration.
*/
public function hasSetupStep() {
return false;
}
/**
* Render a standard login/register button element.
*
* The `$attributes` parameter takes these keys:
*
* - `uri`: URI the button should take the user to when clicked.
* - `method`: Optional HTTP method the button should use, defaults to GET.
*
* @param AphrontRequest $request HTTP request.
* @param string $mode Request mode string.
- * @param map? $attributes Additional parameters, see above.
+ * @param map $attributes (optional) Additional parameters, see
+ * above.
* @return wild Log in button.
*/
protected function renderStandardLoginButton(
AphrontRequest $request,
$mode,
array $attributes = array()) {
PhutilTypeSpec::checkMap(
$attributes,
array(
'method' => 'optional string',
'uri' => 'string',
'sigil' => 'optional string',
));
$viewer = $request->getUser();
$adapter = $this->getAdapter();
if ($mode == 'link') {
$button_text = pht('Link External Account');
} else if ($mode == 'refresh') {
$button_text = pht('Refresh Account Link');
} else if ($mode == 'invite') {
$button_text = pht('Register Account');
} else if ($this->shouldAllowRegistration()) {
$button_text = pht('Log In or Register');
} else {
$button_text = pht('Log In');
}
$icon = id(new PHUIIconView())
->setSpriteSheet(PHUIIconView::SPRITE_LOGIN)
->setSpriteIcon($this->getLoginIcon());
$button = id(new PHUIButtonView())
->setSize(PHUIButtonView::BIG)
->setColor(PHUIButtonView::GREY)
->setIcon($icon)
->setText($button_text)
->setSubtext($this->getProviderName());
$uri = $attributes['uri'];
$uri = new PhutilURI($uri);
$params = $uri->getQueryParamsAsPairList();
$uri->removeAllQueryParams();
$content = array($button);
foreach ($params as $pair) {
list($key, $value) = $pair;
$content[] = phutil_tag(
'input',
array(
'type' => 'hidden',
'name' => $key,
'value' => $value,
));
}
$static_response = CelerityAPI::getStaticResourceResponse();
$static_response->addContentSecurityPolicyURI('form-action', (string)$uri);
foreach ($this->getContentSecurityPolicyFormActions() as $csp_uri) {
$static_response->addContentSecurityPolicyURI('form-action', $csp_uri);
}
return phabricator_form(
$viewer,
array(
'method' => idx($attributes, 'method', 'GET'),
'action' => (string)$uri,
'sigil' => idx($attributes, 'sigil'),
),
$content);
}
public function renderConfigurationFooter() {
return null;
}
public function getAuthCSRFCode(AphrontRequest $request) {
$phcid = $request->getCookie(PhabricatorCookies::COOKIE_CLIENTID);
if (!strlen($phcid)) {
throw new AphrontMalformedRequestException(
pht('Missing Client ID Cookie'),
pht(
'Your browser did not submit a "%s" cookie with client state '.
'information in the request. Check that cookies are enabled. '.
'If this problem persists, you may need to clear your cookies.',
PhabricatorCookies::COOKIE_CLIENTID),
true);
}
return PhabricatorHash::weakDigest($phcid);
}
protected function verifyAuthCSRFCode(AphrontRequest $request, $actual) {
$expect = $this->getAuthCSRFCode($request);
if (!strlen($actual)) {
throw new Exception(
pht(
'The authentication provider did not return a client state '.
'parameter in its response, but one was expected. If this '.
'problem persists, you may need to clear your cookies.'));
}
if (!phutil_hashes_are_identical($actual, $expect)) {
throw new Exception(
pht(
'The authentication provider did not return the correct client '.
'state parameter in its response. If this problem persists, you may '.
'need to clear your cookies.'));
}
}
public function supportsAutoLogin() {
return false;
}
public function getAutoLoginURI(AphrontRequest $request) {
throw new PhutilMethodNotImplementedException();
}
protected function getContentSecurityPolicyFormActions() {
return array();
}
}
diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php
index 4433306e78..58ec2aa189 100644
--- a/src/applications/base/PhabricatorApplication.php
+++ b/src/applications/base/PhabricatorApplication.php
@@ -1,675 +1,675 @@
<?php
/**
* @task info Application Information
* @task ui UI Integration
* @task uri URI Routing
* @task mail Email integration
* @task fact Fact Integration
* @task meta Application Management
*/
abstract class PhabricatorApplication
extends PhabricatorLiskDAO
implements
PhabricatorPolicyInterface,
PhabricatorApplicationTransactionInterface {
const GROUP_CORE = 'core';
const GROUP_UTILITIES = 'util';
const GROUP_ADMIN = 'admin';
const GROUP_DEVELOPER = 'developer';
final public static function getApplicationGroups() {
return array(
self::GROUP_CORE => pht('Core Applications'),
self::GROUP_UTILITIES => pht('Utilities'),
self::GROUP_ADMIN => pht('Administration'),
self::GROUP_DEVELOPER => pht('Developer Tools'),
);
}
final public function getApplicationName() {
return 'application';
}
final public function getTableName() {
return 'application_application';
}
final protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
) + parent::getConfiguration();
}
final public function generatePHID() {
return $this->getPHID();
}
final public function save() {
// When "save()" is called on applications, we just return without
// actually writing anything to the database.
return $this;
}
/* -( Application Information )-------------------------------------------- */
abstract public function getName();
public function getShortDescription() {
return pht('%s Application', $this->getName());
}
/**
* Extensions are allowed to register multi-character monograms.
* The name "Monogram" is actually a bit of a misnomer,
* but we're keeping it due to the history.
*
* @return array
*/
public function getMonograms() {
return array();
}
public function isDeprecated() {
return false;
}
final public function isInstalled() {
if (!$this->canUninstall()) {
return true;
}
$prototypes = PhabricatorEnv::getEnvConfig('phabricator.show-prototypes');
if (!$prototypes && $this->isPrototype()) {
return false;
}
$uninstalled = PhabricatorEnv::getEnvConfig(
'phabricator.uninstalled-applications');
return empty($uninstalled[get_class($this)]);
}
public function isPrototype() {
return false;
}
/**
* Return `true` if this application should never appear in application lists
* in the UI. Primarily intended for unit test applications or other
* pseudo-applications.
*
* Few applications should be unlisted. For most applications, use
* @{method:isLaunchable} to hide them from main launch views instead.
*
* @return bool True to remove application from UI lists.
*/
public function isUnlisted() {
return false;
}
/**
* Return `true` if this application is a normal application with a base
* URI and a web interface.
*
* Launchable applications can be pinned to the home page, and show up in the
* "Launcher" view of the Applications application. Making an application
* unlaunchable prevents pinning and hides it from this view.
*
* Usually, an application should be marked unlaunchable if:
*
* - it is available on every page anyway (like search); or
* - it does not have a web interface (like subscriptions); or
* - it is still pre-release and being intentionally buried.
*
* To hide applications more completely, use @{method:isUnlisted}.
*
* @return bool True if the application is launchable.
*/
public function isLaunchable() {
return true;
}
/**
* Return `true` if this application should be pinned by default.
*
* Users who have not yet set preferences see a default list of applications.
*
* @param PhabricatorUser $viewer User viewing the pinned application list.
* @return bool True if this application should be pinned by default.
*/
public function isPinnedByDefault(PhabricatorUser $viewer) {
return false;
}
/**
* Returns true if an application is first-party and false otherwise.
*
* @return bool True if this application is first-party.
*/
final public function isFirstParty() {
$where = id(new ReflectionClass($this))->getFileName();
$root = phutil_get_library_root('phabricator');
if (!Filesystem::isDescendant($where, $root)) {
return false;
}
if (Filesystem::isDescendant($where, $root.'/extensions')) {
return false;
}
return true;
}
public function canUninstall() {
return true;
}
final public function getPHID() {
return 'PHID-APPS-'.get_class($this);
}
public function getTypeaheadURI() {
return $this->isLaunchable() ? $this->getBaseURI() : null;
}
public function getBaseURI() {
return null;
}
final public function getApplicationURI($path = '') {
return $this->getBaseURI().ltrim($path, '/');
}
public function getIcon() {
return 'fa-puzzle-piece';
}
public function getApplicationOrder() {
return PHP_INT_MAX;
}
public function getApplicationGroup() {
return self::GROUP_CORE;
}
public function getTitleGlyph() {
return null;
}
final public function getHelpMenuItems(PhabricatorUser $viewer) {
$items = array();
$articles = $this->getHelpDocumentationArticles($viewer);
if ($articles) {
foreach ($articles as $article) {
$item = id(new PhabricatorActionView())
->setName($article['name'])
->setHref($article['href'])
->addSigil('help-item')
->setOpenInNewWindow(true);
$items[] = $item;
}
}
$command_specs = $this->getMailCommandObjects();
if ($command_specs) {
foreach ($command_specs as $key => $spec) {
$object = $spec['object'];
$class = get_class($this);
$href = '/applications/mailcommands/'.$class.'/'.$key.'/';
$item = id(new PhabricatorActionView())
->setName($spec['name'])
->setHref($href)
->addSigil('help-item')
->setOpenInNewWindow(true);
$items[] = $item;
}
}
if ($items) {
$divider = id(new PhabricatorActionView())
->addSigil('help-item')
->setType(PhabricatorActionView::TYPE_DIVIDER);
array_unshift($items, $divider);
}
return array_values($items);
}
public function getHelpDocumentationArticles(PhabricatorUser $viewer) {
return array();
}
/**
* Get the Application Overview in raw Remarkup
*
* @return string|null
*/
public function getOverview() {
return null;
}
public function getEventListeners() {
return array();
}
public function getRemarkupRules() {
return array();
}
public function getQuicksandURIPatternBlacklist() {
return array();
}
public function getMailCommandObjects() {
return array();
}
/* -( URI Routing )-------------------------------------------------------- */
public function getRoutes() {
return array();
}
public function getResourceRoutes() {
return array();
}
/* -( Email Integration )-------------------------------------------------- */
public function supportsEmailIntegration() {
return false;
}
final protected function getInboundEmailSupportLink() {
return PhabricatorEnv::getDoclink('Configuring Inbound Email');
}
public function getAppEmailBlurb() {
throw new PhutilMethodNotImplementedException();
}
/* -( Fact Integration )--------------------------------------------------- */
public function getFactObjectsForAnalysis() {
return array();
}
/* -( UI Integration )----------------------------------------------------- */
/**
* You can provide an optional piece of flavor text for the application. This
* is currently rendered in application launch views if the application has no
* status elements.
*
* @return string|null Flavor text.
* @task ui
*/
public function getFlavorText() {
return null;
}
/**
* Build items for the main menu.
*
* @param PhabricatorUser $user The viewing user.
- * @param AphrontController? $controller The current controller. May be null
- * for special pages like 404, exception handlers, etc.
+ * @param AphrontController $controller (optional) The current controller.
+ * May be null for special pages like 404, exception handlers, etc.
* @return list<PHUIListItemView> List of menu items.
* @task ui
*/
public function buildMainMenuItems(
PhabricatorUser $user,
PhabricatorController $controller = null) {
return array();
}
/* -( Application Management )--------------------------------------------- */
final public static function getByClass($class_name) {
$selected = null;
$applications = self::getAllApplications();
foreach ($applications as $application) {
if (get_class($application) == $class_name) {
$selected = $application;
break;
}
}
if (!$selected) {
throw new Exception(pht("No application '%s'!", $class_name));
}
return $selected;
}
final public static function getAllApplications() {
static $applications;
if ($applications === null) {
$apps = id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setSortMethod('getApplicationOrder')
->execute();
// Reorder the applications into "application order". Notably, this
// ensures their event handlers register in application order.
$apps = mgroup($apps, 'getApplicationGroup');
$group_order = array_keys(self::getApplicationGroups());
$apps = array_select_keys($apps, $group_order) + $apps;
$apps = array_mergev($apps);
$applications = $apps;
}
return $applications;
}
final public static function getAllInstalledApplications() {
$all_applications = self::getAllApplications();
$apps = array();
foreach ($all_applications as $app) {
if (!$app->isInstalled()) {
continue;
}
$apps[] = $app;
}
return $apps;
}
/**
* Determine if an application is installed, by application class name.
*
* To check if an application is installed //and// available to a particular
* viewer, user @{method:isClassInstalledForViewer}.
*
* @param string $class Application class name.
* @return bool True if the class is installed.
* @task meta
*/
final public static function isClassInstalled($class) {
return self::getByClass($class)->isInstalled();
}
/**
* Determine if an application is installed and available to a viewer, by
* application class name.
*
* To check if an application is installed at all, use
* @{method:isClassInstalled}.
*
* @param string $class Application class name.
* @param PhabricatorUser $viewer Viewing user.
* @return bool True if the class is installed for the viewer.
* @task meta
*/
final public static function isClassInstalledForViewer(
$class,
PhabricatorUser $viewer) {
if ($viewer->isOmnipotent()) {
return true;
}
$cache = PhabricatorCaches::getRequestCache();
$viewer_fragment = $viewer->getCacheFragment();
$key = 'app.'.$class.'.installed.'.$viewer_fragment;
$result = $cache->getKey($key);
if ($result === null) {
if (!self::isClassInstalled($class)) {
$result = false;
} else {
$application = self::getByClass($class);
if (!$application->canUninstall()) {
// If the application can not be uninstalled, always allow viewers
// to see it. In particular, this allows logged-out viewers to see
// Settings and load global default settings even if the install
// does not allow public viewers.
$result = true;
} else {
$result = PhabricatorPolicyFilter::hasCapability(
$viewer,
self::getByClass($class),
PhabricatorPolicyCapability::CAN_VIEW);
}
}
$cache->setKey($key, $result);
}
return $result;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array_merge(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
),
array_keys($this->getCustomCapabilities()));
}
public function getPolicy($capability) {
$default = $this->getCustomPolicySetting($capability);
if ($default) {
return $default;
}
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::getMostOpenPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return PhabricatorPolicies::POLICY_ADMIN;
default:
$spec = $this->getCustomCapabilitySpecification($capability);
return idx($spec, 'default', PhabricatorPolicies::POLICY_USER);
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
/* -( Policies )----------------------------------------------------------- */
protected function getCustomCapabilities() {
return array();
}
private function getCustomPolicySetting($capability) {
if (!$this->isCapabilityEditable($capability)) {
return null;
}
$policy_locked = PhabricatorEnv::getEnvConfig('policy.locked');
if (isset($policy_locked[$capability])) {
return $policy_locked[$capability];
}
$config = PhabricatorEnv::getEnvConfig('phabricator.application-settings');
$app = idx($config, $this->getPHID());
if (!$app) {
return null;
}
$policy = idx($app, 'policy');
if (!$policy) {
return null;
}
return idx($policy, $capability);
}
private function getCustomCapabilitySpecification($capability) {
$custom = $this->getCustomCapabilities();
if (!isset($custom[$capability])) {
throw new Exception(pht("Unknown capability '%s'!", $capability));
}
return $custom[$capability];
}
final public function getCapabilityLabel($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return pht('Can Use Application');
case PhabricatorPolicyCapability::CAN_EDIT:
return pht('Can Configure Application');
}
$capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
if ($capobj) {
return $capobj->getCapabilityName();
}
return null;
}
final public function isCapabilityEditable($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return $this->canUninstall();
case PhabricatorPolicyCapability::CAN_EDIT:
return true;
default:
$spec = $this->getCustomCapabilitySpecification($capability);
return idx($spec, 'edit', true);
}
}
final public function getCapabilityCaption($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
if (!$this->canUninstall()) {
return pht(
'This application is required, so all '.
'users must have access to it.');
} else {
return null;
}
case PhabricatorPolicyCapability::CAN_EDIT:
return null;
default:
$spec = $this->getCustomCapabilitySpecification($capability);
return idx($spec, 'caption');
}
}
final public function getCapabilityTemplatePHIDType($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
case PhabricatorPolicyCapability::CAN_EDIT:
return null;
}
$spec = $this->getCustomCapabilitySpecification($capability);
return idx($spec, 'template');
}
final public function getDefaultObjectTypePolicyMap() {
$map = array();
foreach ($this->getCustomCapabilities() as $capability => $spec) {
if (empty($spec['template'])) {
continue;
}
if (empty($spec['capability'])) {
continue;
}
$default = $this->getPolicy($capability);
$map[$spec['template']][$spec['capability']] = $default;
}
return $map;
}
public function getApplicationSearchDocumentTypes() {
return array();
}
protected function getEditRoutePattern($base = null) {
return $base.'(?:'.
'(?P<id>[0-9]\d*)/)?'.
'(?:'.
'(?:'.
'(?P<editAction>parameters|nodefault|nocreate|nomanage|comment)/'.
'|'.
'(?:form/(?P<formKey>[^/]+)/)?(?:page/(?P<pageKey>[^/]+)/)?'.
')'.
')?';
}
protected function getBulkRoutePattern($base = null) {
return $base.'(?:query/(?P<queryKey>[^/]+)/)?';
}
protected function getQueryRoutePattern($base = null) {
return $base.'(?:query/(?P<queryKey>[^/]+)/(?:(?P<queryAction>[^/]+)/)?)?';
}
protected function getProfileMenuRouting($controller) {
$edit_route = $this->getEditRoutePattern();
$mode_route = '(?P<itemEditMode>global|custom)/';
return array(
'(?P<itemAction>view)/(?P<itemID>[^/]+)/' => $controller,
'(?P<itemAction>hide)/(?P<itemID>[^/]+)/' => $controller,
'(?P<itemAction>default)/(?P<itemID>[^/]+)/' => $controller,
'(?P<itemAction>configure)/' => $controller,
'(?P<itemAction>configure)/'.$mode_route => $controller,
'(?P<itemAction>reorder)/'.$mode_route => $controller,
'(?P<itemAction>edit)/'.$edit_route => $controller,
'(?P<itemAction>new)/'.$mode_route.'(?<itemKey>[^/]+)/'.$edit_route
=> $controller,
'(?P<itemAction>builtin)/(?<itemID>[^/]+)/'.$edit_route
=> $controller,
);
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorApplicationEditor();
}
public function getApplicationTransactionTemplate() {
return new PhabricatorApplicationApplicationTransaction();
}
}
diff --git a/src/applications/celerity/api.php b/src/applications/celerity/api.php
index 98802b4598..c14f34d919 100644
--- a/src/applications/celerity/api.php
+++ b/src/applications/celerity/api.php
@@ -1,54 +1,54 @@
<?php
/**
* Include a CSS or JS static resource by name. This function records a
* dependency for the current page, so when a response is generated it can be
* included. You can call this method from any context, and it is recommended
* you invoke it as close to the actual dependency as possible so that page
* dependencies are minimized.
*
* For more information, see @{article:Adding New CSS and JS}.
*
* @param string $symbol Name of the celerity module to include. This is
* whatever you annotated as "@provides" in the file.
- * @param string? $source_name
+ * @param string $source_name (optional)
* @return void
*/
function require_celerity_resource($symbol, $source_name = 'phabricator') {
$response = CelerityAPI::getStaticResourceResponse();
$response->requireResource($symbol, $source_name);
}
/**
* Generate a node ID which is guaranteed to be unique for the current page,
* even across Ajax requests. You should use this method to generate IDs for
* nodes which require a uniqueness guarantee.
*
* @return string A string appropriate for use as an 'id' attribute on a DOM
* node. It is guaranteed to be unique for the current page, even
* if the current request is a subsequent Ajax request.
*/
function celerity_generate_unique_node_id() {
static $uniq = 0;
$response = CelerityAPI::getStaticResourceResponse();
$block = $response->getMetadataBlock();
return 'UQ'.$block.'_'.($uniq++);
}
/**
* Get the versioned URI for a raw resource, like an image.
*
* @param string $resource Path to the raw image.
- * @param string? $source
+ * @param string $source (optional) Defaults to 'phabricator'
* @return string Versioned path to the image, if one is available.
*/
function celerity_get_resource_uri($resource, $source = 'phabricator') {
$resource = ltrim($resource, '/');
$map = CelerityResourceMap::getNamedInstance($source);
$response = CelerityAPI::getStaticResourceResponse();
return $response->getURI($map, $resource);
}
diff --git a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php
index f6832c4fc9..eff9df9c83 100644
--- a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php
+++ b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php
@@ -1,650 +1,650 @@
<?php
abstract class DifferentialChangesetHTMLRenderer
extends DifferentialChangesetRenderer {
public static function getHTMLRendererByKey($key) {
switch ($key) {
case '1up':
return new DifferentialChangesetOneUpRenderer();
case '2up':
default:
return new DifferentialChangesetTwoUpRenderer();
}
throw new Exception(pht('Unknown HTML renderer "%s"!', $key));
}
abstract protected function getRendererTableClass();
abstract public function getRowScaffoldForInline(
PHUIDiffInlineCommentView $view);
protected function renderChangeTypeHeader($force) {
$changeset = $this->getChangeset();
$change = $changeset->getChangeType();
$file = $changeset->getFileType();
$messages = array();
switch ($change) {
case DifferentialChangeType::TYPE_ADD:
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
$messages[] = pht('This file was added.');
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht('This image was added.');
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht('This directory was added.');
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht('This binary file was added.');
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht('This symlink was added.');
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht('This submodule was added.');
break;
}
break;
case DifferentialChangeType::TYPE_DELETE:
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
$messages[] = pht('This file was deleted.');
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht('This image was deleted.');
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht('This directory was deleted.');
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht('This binary file was deleted.');
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht('This symlink was deleted.');
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht('This submodule was deleted.');
break;
}
break;
case DifferentialChangeType::TYPE_MOVE_HERE:
$from = phutil_tag('strong', array(), $changeset->getOldFile());
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
$messages[] = pht('This file was moved from %s.', $from);
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht('This image was moved from %s.', $from);
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht('This directory was moved from %s.', $from);
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht('This binary file was moved from %s.', $from);
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht('This symlink was moved from %s.', $from);
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht('This submodule was moved from %s.', $from);
break;
}
break;
case DifferentialChangeType::TYPE_COPY_HERE:
$from = phutil_tag('strong', array(), $changeset->getOldFile());
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
$messages[] = pht('This file was copied from %s.', $from);
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht('This image was copied from %s.', $from);
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht('This directory was copied from %s.', $from);
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht('This binary file was copied from %s.', $from);
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht('This symlink was copied from %s.', $from);
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht('This submodule was copied from %s.', $from);
break;
}
break;
case DifferentialChangeType::TYPE_MOVE_AWAY:
$paths = phutil_tag(
'strong',
array(),
implode(', ', $changeset->getAwayPaths()));
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
$messages[] = pht('This file was moved to %s.', $paths);
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht('This image was moved to %s.', $paths);
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht('This directory was moved to %s.', $paths);
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht('This binary file was moved to %s.', $paths);
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht('This symlink was moved to %s.', $paths);
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht('This submodule was moved to %s.', $paths);
break;
}
break;
case DifferentialChangeType::TYPE_COPY_AWAY:
$paths = phutil_tag(
'strong',
array(),
implode(', ', $changeset->getAwayPaths()));
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
$messages[] = pht('This file was copied to %s.', $paths);
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht('This image was copied to %s.', $paths);
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht('This directory was copied to %s.', $paths);
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht('This binary file was copied to %s.', $paths);
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht('This symlink was copied to %s.', $paths);
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht('This submodule was copied to %s.', $paths);
break;
}
break;
case DifferentialChangeType::TYPE_MULTICOPY:
$paths = phutil_tag(
'strong',
array(),
implode(', ', $changeset->getAwayPaths()));
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
$messages[] = pht(
'This file was deleted after being copied to %s.',
$paths);
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht(
'This image was deleted after being copied to %s.',
$paths);
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht(
'This directory was deleted after being copied to %s.',
$paths);
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht(
'This binary file was deleted after being copied to %s.',
$paths);
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht(
'This symlink was deleted after being copied to %s.',
$paths);
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht(
'This submodule was deleted after being copied to %s.',
$paths);
break;
}
break;
default:
switch ($file) {
case DifferentialChangeType::FILE_TEXT:
// This is the default case, so we only render this header if
// forced to since it's not very useful.
if ($force) {
$messages[] = pht('This file was not modified.');
}
break;
case DifferentialChangeType::FILE_IMAGE:
$messages[] = pht('This is an image.');
break;
case DifferentialChangeType::FILE_DIRECTORY:
$messages[] = pht('This is a directory.');
break;
case DifferentialChangeType::FILE_BINARY:
$messages[] = pht('This is a binary file.');
break;
case DifferentialChangeType::FILE_SYMLINK:
$messages[] = pht('This is a symlink.');
break;
case DifferentialChangeType::FILE_SUBMODULE:
$messages[] = pht('This is a submodule.');
break;
}
break;
}
return $this->formatHeaderMessages($messages);
}
protected function renderUndershieldHeader() {
$messages = array();
$changeset = $this->getChangeset();
$file = $changeset->getFileType();
// If this is a text file with at least one hunk, we may have converted
// the text encoding. In this case, show a note.
$show_encoding = ($file == DifferentialChangeType::FILE_TEXT) &&
($changeset->getHunks());
if ($show_encoding) {
$encoding = $this->getOriginalCharacterEncoding();
if ($encoding != 'utf8') {
if ($encoding) {
$messages[] = pht(
'This file was converted from %s for display.',
phutil_tag('strong', array(), $encoding));
} else {
$messages[] = pht('This file uses an unknown character encoding.');
}
}
}
$blocks = $this->getDocumentEngineBlocks();
if ($blocks) {
foreach ($blocks->getMessages() as $message) {
$messages[] = $message;
}
} else {
if ($this->getHighlightingDisabled()) {
$byte_limit = DifferentialChangesetParser::HIGHLIGHT_BYTE_LIMIT;
$byte_limit = phutil_format_bytes($byte_limit);
$messages[] = pht(
'This file is larger than %s, so syntax highlighting is '.
'disabled by default.',
$byte_limit);
}
}
return $this->formatHeaderMessages($messages);
}
private function formatHeaderMessages(array $messages) {
if (!$messages) {
return null;
}
foreach ($messages as $key => $message) {
$messages[$key] = phutil_tag('li', array(), $message);
}
return phutil_tag(
'ul',
array(
'class' => 'differential-meta-notice',
),
$messages);
}
protected function renderPropertyChangeHeader() {
$changeset = $this->getChangeset();
list($old, $new) = $this->getChangesetProperties($changeset);
// If we don't have any property changes, don't render this table.
if ($old === $new) {
return null;
}
$keys = array_keys($old + $new);
sort($keys);
$key_map = array(
'unix:filemode' => pht('File Mode'),
'file:dimensions' => pht('Image Dimensions'),
'file:mimetype' => pht('MIME Type'),
'file:size' => pht('File Size'),
);
$rows = array();
foreach ($keys as $key) {
$oval = idx($old, $key);
$nval = idx($new, $key);
if ($oval !== $nval) {
if ($oval === null) {
$oval = phutil_tag('em', array(), 'null');
} else {
$oval = phutil_escape_html_newlines($oval);
}
if ($nval === null) {
$nval = phutil_tag('em', array(), 'null');
} else {
$nval = phutil_escape_html_newlines($nval);
}
$readable_key = idx($key_map, $key, $key);
$row = array(
$readable_key,
$oval,
$nval,
);
$rows[] = $row;
}
}
$classes = array('', 'oval', 'nval');
$headers = array(
pht('Property'),
pht('Old Value'),
pht('New Value'),
);
$table = id(new AphrontTableView($rows))
->setHeaders($headers)
->setColumnClasses($classes);
return phutil_tag(
'div',
array(
'class' => 'differential-property-table',
),
$table);
}
public function renderShield($message, $force = 'default') {
$end = count($this->getOldLines());
$reference = $this->getRenderingReference();
if ($force !== 'text' &&
$force !== 'none' &&
$force !== 'default') {
throw new Exception(
pht(
"Invalid '%s' parameter '%s'!",
'force',
$force));
}
$range = "0-{$end}";
if ($force == 'text') {
// If we're forcing text, force the whole file to be rendered.
$range = "{$range}/0-{$end}";
}
$meta = array(
'ref' => $reference,
'range' => $range,
);
$content = array();
$content[] = $message;
if ($force !== 'none') {
$content[] = ' ';
$content[] = javelin_tag(
'a',
array(
'mustcapture' => true,
'sigil' => 'show-more',
'class' => 'complete',
'href' => '#',
'meta' => $meta,
),
pht('Show File Contents'));
}
return $this->wrapChangeInTable(
javelin_tag(
'tr',
array(
'sigil' => 'context-target',
),
phutil_tag(
'td',
array(
'class' => 'differential-shield',
'colspan' => 6,
),
$content)));
}
abstract protected function renderColgroup();
protected function wrapChangeInTable($content) {
if (!$content) {
return null;
}
$classes = array();
$classes[] = 'differential-diff';
$classes[] = 'remarkup-code';
$classes[] = 'PhabricatorMonospaced';
$classes[] = $this->getRendererTableClass();
$sigils = array();
$sigils[] = 'differential-diff';
foreach ($this->getTableSigils() as $sigil) {
$sigils[] = $sigil;
}
return javelin_tag(
'table',
array(
'class' => implode(' ', $classes),
'sigil' => implode(' ', $sigils),
),
array(
$this->renderColgroup(),
$content,
));
}
protected function getTableSigils() {
return array();
}
protected function buildInlineComment(
PhabricatorInlineComment $comment,
$on_right = false) {
$viewer = $this->getUser();
$edit = $viewer &&
($comment->getAuthorPHID() == $viewer->getPHID()) &&
($comment->isDraft())
&& $this->getShowEditAndReplyLinks();
$allow_reply = (bool)$viewer && $this->getShowEditAndReplyLinks();
$allow_done = !$comment->isDraft() && $this->getCanMarkDone();
return id(new PHUIDiffInlineCommentDetailView())
->setViewer($viewer)
->setInlineComment($comment)
->setIsOnRight($on_right)
->setHandles($this->getHandles())
->setMarkupEngine($this->getMarkupEngine())
->setEditable($edit)
->setAllowReply($allow_reply)
->setCanMarkDone($allow_done)
->setObjectOwnerPHID($this->getObjectOwnerPHID());
}
/**
* Build links which users can click to show more context in a changeset.
*
* @param int $top Beginning of the line range to build links for.
* @param int $len Length of the line range to build links for.
* @param int $changeset_length Total number of lines in the changeset.
- * @param bool? $is_blocks
+ * @param bool $is_blocks (optional)
* @return markup Rendered links.
*/
protected function renderShowContextLinks(
$top,
$len,
$changeset_length,
$is_blocks = false) {
$block_size = 20;
$end = ($top + $len) - $block_size;
// If this is a large block, such that the "top" and "bottom" ranges are
// non-overlapping, we'll provide options to show the top, bottom or entire
// block. For smaller blocks, we only provide an option to show the entire
// block, since it would be silly to show the bottom 20 lines of a 25-line
// block.
$is_large_block = ($len > ($block_size * 2));
$links = array();
$block_display = new PhutilNumber($block_size);
if ($is_large_block) {
$is_first_block = ($top == 0);
if ($is_first_block) {
if ($is_blocks) {
$text = pht('Show First %s Block(s)', $block_display);
} else {
$text = pht('Show First %s Line(s)', $block_display);
}
} else {
if ($is_blocks) {
$text = pht("\xE2\x96\xB2 Show %s Block(s)", $block_display);
} else {
$text = pht("\xE2\x96\xB2 Show %s Line(s)", $block_display);
}
}
$links[] = $this->renderShowContextLink(
false,
"{$top}-{$len}/{$top}-20",
$text);
}
if ($is_blocks) {
$text = pht('Show All %s Block(s)', new PhutilNumber($len));
} else {
$text = pht('Show All %s Line(s)', new PhutilNumber($len));
}
$links[] = $this->renderShowContextLink(
true,
"{$top}-{$len}/{$top}-{$len}",
$text);
if ($is_large_block) {
$is_last_block = (($top + $len) >= $changeset_length);
if ($is_last_block) {
if ($is_blocks) {
$text = pht('Show Last %s Block(s)', $block_display);
} else {
$text = pht('Show Last %s Line(s)', $block_display);
}
} else {
if ($is_blocks) {
$text = pht("\xE2\x96\xBC Show %s Block(s)", $block_display);
} else {
$text = pht("\xE2\x96\xBC Show %s Line(s)", $block_display);
}
}
$links[] = $this->renderShowContextLink(
false,
"{$top}-{$len}/{$end}-20",
$text);
}
return phutil_implode_html(" \xE2\x80\xA2 ", $links);
}
/**
* Build a link that shows more context in a changeset.
*
* See @{method:renderShowContextLinks}.
*
* @param bool $is_all Does this link show all context when clicked?
* @param string $range Range specification for lines to show.
* @param string $text Text of the link.
* @return markup Rendered link.
*/
private function renderShowContextLink($is_all, $range, $text) {
$reference = $this->getRenderingReference();
return javelin_tag(
'a',
array(
'href' => '#',
'mustcapture' => true,
'sigil' => 'show-more',
'meta' => array(
'type' => ($is_all ? 'all' : null),
'range' => $range,
),
),
$text);
}
/**
* Build the prefixes for line IDs used to track inline comments.
*
* @return pair<wild, wild> Left and right prefixes.
*/
protected function getLineIDPrefixes() {
// These look like "C123NL45", which means the line is line 45 on the
// "new" side of the file in changeset 123.
// The "C" stands for "changeset", and is followed by a changeset ID.
// "N" stands for "new" and means the comment should attach to the new file
// when stored. "O" stands for "old" and means the comment should attach to
// the old file. These are important because either the old or new part
// of a file may appear on the left or right side of the diff in the
// diff-of-diffs view.
// The "L" stands for "line" and is followed by the line number.
if ($this->getOldChangesetID()) {
$left_prefix = array();
$left_prefix[] = 'C';
$left_prefix[] = $this->getOldChangesetID();
$left_prefix[] = $this->getOldAttachesToNewFile() ? 'N' : 'O';
$left_prefix[] = 'L';
$left_prefix = implode('', $left_prefix);
} else {
$left_prefix = null;
}
if ($this->getNewChangesetID()) {
$right_prefix = array();
$right_prefix[] = 'C';
$right_prefix[] = $this->getNewChangesetID();
$right_prefix[] = $this->getNewAttachesToNewFile() ? 'N' : 'O';
$right_prefix[] = 'L';
$right_prefix = implode('', $right_prefix);
} else {
$right_prefix = null;
}
return array($left_prefix, $right_prefix);
}
}
diff --git a/src/applications/diffusion/data/DiffusionGitBranch.php b/src/applications/diffusion/data/DiffusionGitBranch.php
index 75c17b67c1..7914c69874 100644
--- a/src/applications/diffusion/data/DiffusionGitBranch.php
+++ b/src/applications/diffusion/data/DiffusionGitBranch.php
@@ -1,111 +1,111 @@
<?php
final class DiffusionGitBranch extends Phobject {
const DEFAULT_GIT_REMOTE = 'origin';
/**
* Parse the output of 'git branch -r --verbose --no-abbrev' or similar into
* a map. For instance:
*
* array(
* 'origin/master' => '99a9c082f9a1b68c7264e26b9e552484a5ae5f25',
* );
*
* If you specify $only_this_remote, branches will be filtered to only those
* on the given remote, **and the remote name will be stripped**. For example:
*
* array(
* 'master' => '99a9c082f9a1b68c7264e26b9e552484a5ae5f25',
* );
*
* @param string $stdout stdout of git branch command.
- * @param string? $only_this_remote Filter branches to those on a specific
- * remote.
+ * @param string $only_this_remote (optional) Filter branches to those on a
+ * specific remote.
* @return map Map of 'branch' or 'remote/branch' to hash at HEAD.
*/
public static function parseRemoteBranchOutput(
$stdout,
$only_this_remote = null) {
$map = array();
$lines = array_filter(explode("\n", $stdout));
foreach ($lines as $line) {
$matches = null;
if (preg_match('/^ (\S+)\s+-> (\S+)$/', $line, $matches)) {
// This is a line like:
//
// origin/HEAD -> origin/master
//
// ...which we don't currently do anything interesting with, although
// in theory we could use it to automatically choose the default
// branch.
continue;
}
if (!preg_match('/^ *(\S+)\s+([a-z0-9]{40})/', $line, $matches)) {
throw new Exception(
pht(
'Failed to parse %s!',
$line));
}
$remote_branch = $matches[1];
$branch_head = $matches[2];
if (strpos($remote_branch, 'HEAD') !== false) {
// let's assume that no one will call their remote or branch HEAD
continue;
}
if ($only_this_remote) {
$matches = null;
if (!preg_match('#^([^/]+)/(.*)$#', $remote_branch, $matches)) {
throw new Exception(
pht(
"Failed to parse remote branch '%s'!",
$remote_branch));
}
$remote_name = $matches[1];
$branch_name = $matches[2];
if ($remote_name != $only_this_remote) {
continue;
}
$map[$branch_name] = $branch_head;
} else {
$map[$remote_branch] = $branch_head;
}
}
return $map;
}
/**
* As above, but with no `-r`. Used for bare repositories.
*/
public static function parseLocalBranchOutput($stdout) {
$map = array();
$lines = array_filter(explode("\n", $stdout));
$regex = '/^[* ]*(\(no branch\)|\S+)\s+([a-z0-9]{40})/';
foreach ($lines as $line) {
$matches = null;
if (!preg_match($regex, $line, $matches)) {
throw new Exception(
pht(
'Failed to parse %s!',
$line));
}
$branch = $matches[1];
$branch_head = $matches[2];
if ($branch == '(no branch)') {
continue;
}
$map[$branch] = $branch_head;
}
return $map;
}
}
diff --git a/src/applications/diffusion/request/DiffusionRequest.php b/src/applications/diffusion/request/DiffusionRequest.php
index d9bd30afea..eb1eaf9722 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
+ * @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
*/
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/files/PhabricatorImageTransformer.php b/src/applications/files/PhabricatorImageTransformer.php
index 0ca475e90c..bcfc1745ed 100644
--- a/src/applications/files/PhabricatorImageTransformer.php
+++ b/src/applications/files/PhabricatorImageTransformer.php
@@ -1,138 +1,138 @@
<?php
/**
* @task enormous Detecting Enormous Images
* @task save Saving Image Data
*/
final class PhabricatorImageTransformer extends Phobject {
/* -( Saving Image Data )-------------------------------------------------- */
/**
* Save an image resource to a string representation suitable for storage or
* transmission as an image file.
*
* Optionally, you can specify a preferred MIME type like `"image/png"`.
* Generally, you should specify the MIME type of the original file if you're
* applying file transformations. The MIME type may not be honored if
* Phabricator can not encode images in the given format (based on available
* extensions), but can save images in another format.
*
* @param resource $data GD image resource.
- * @param string? $preferred_mime Optionally, preferred mime type.
+ * @param string $preferred_mime (optional) Preferred mime type.
* @return string Bytes of an image file.
* @task save
*/
public static function saveImageDataInAnyFormat($data, $preferred_mime = '') {
$preferred = null;
switch ($preferred_mime) {
case 'image/gif':
$preferred = self::saveImageDataAsGIF($data);
break;
case 'image/png':
$preferred = self::saveImageDataAsPNG($data);
break;
}
if ($preferred !== null) {
return $preferred;
}
$data = self::saveImageDataAsJPG($data);
if ($data !== null) {
return $data;
}
$data = self::saveImageDataAsPNG($data);
if ($data !== null) {
return $data;
}
$data = self::saveImageDataAsGIF($data);
if ($data !== null) {
return $data;
}
throw new Exception(pht('Failed to save image data into any format.'));
}
/**
* Save an image in PNG format, returning the file data as a string.
*
* @param resource $image GD image resource.
* @return string|null PNG file as a string, or null on failure.
* @task save
*/
private static function saveImageDataAsPNG($image) {
if (!function_exists('imagepng')) {
return null;
}
// NOTE: Empirically, the highest compression level (9) seems to take
// up to twice as long as the default compression level (6) but produce
// only slightly smaller files (10% on avatars, 3% on screenshots).
ob_start();
$result = imagepng($image, null, 6);
$output = ob_get_clean();
if (!$result) {
return null;
}
return $output;
}
/**
* Save an image in GIF format, returning the file data as a string.
*
* @param resource $image GD image resource.
* @return string|null GIF file as a string, or null on failure.
* @task save
*/
private static function saveImageDataAsGIF($image) {
if (!function_exists('imagegif')) {
return null;
}
ob_start();
$result = imagegif($image);
$output = ob_get_clean();
if (!$result) {
return null;
}
return $output;
}
/**
* Save an image in JPG format, returning the file data as a string.
*
* @param resource $image GD image resource.
* @return string|null JPG file as a string, or null on failure.
* @task save
*/
private static function saveImageDataAsJPG($image) {
if (!function_exists('imagejpeg')) {
return null;
}
ob_start();
$result = imagejpeg($image);
$output = ob_get_clean();
if (!$result) {
return null;
}
return $output;
}
}
diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php
index 3da430d95c..fbb6f788f0 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 Offset for the start of data.
- * @param int? $end Offset for the end of data.
+ * @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
*/
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
*/
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/meta/panel/PhabricatorApplicationConfigurationPanel.php b/src/applications/meta/panel/PhabricatorApplicationConfigurationPanel.php
index 944daa9e08..5d62fd1a05 100644
--- a/src/applications/meta/panel/PhabricatorApplicationConfigurationPanel.php
+++ b/src/applications/meta/panel/PhabricatorApplicationConfigurationPanel.php
@@ -1,77 +1,77 @@
<?php
abstract class PhabricatorApplicationConfigurationPanel
extends Phobject {
private $viewer;
private $application;
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function setApplication(PhabricatorApplication $application) {
$this->application = $application;
return $this;
}
public function getApplication() {
return $this->application;
}
/**
* Get the URI for this application configuration panel.
*
- * @param string? $path Optional path to append.
+ * @param string $path (optional) Path to append.
* @return string Relative URI for the panel.
*/
public function getPanelURI($path = '') {
$app_key = get_class($this->getApplication());
$panel_key = $this->getPanelKey();
$base = "/applications/panel/{$app_key}/{$panel_key}/";
return $base.ltrim($path, '/');
}
/**
* Return a short, unique string key which identifies this panel.
*
* This key is used in URIs. Good values might be "email" or "files".
*/
abstract public function getPanelKey();
abstract public function shouldShowForApplication(
PhabricatorApplication $application);
abstract public function buildConfigurationPagePanel();
abstract public function handlePanelRequest(
AphrontRequest $request,
PhabricatorController $controller);
public static function loadAllPanels() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getPanelKey')
->execute();
}
public static function loadAllPanelsForApplication(
PhabricatorApplication $application) {
$panels = self::loadAllPanels();
$application_panels = array();
foreach ($panels as $key => $panel) {
if (!$panel->shouldShowForApplication($application)) {
continue;
}
$application_panels[$key] = $panel;
}
return $application_panels;
}
}
diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php
index 2d1ec54bb9..826c950577 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
*/
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
*/
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
*/
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
*/
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 If true, indicates this is the first
- * message in the thread.
+ * @param bool $is_first_message (optional) If true, indicates this is the
+ * first message in the thread.
* @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
*/
public function saveAndSend() {
return $this->save();
}
/**
* @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/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php
index 8294f6832e..2ed1af8e00 100644
--- a/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php
+++ b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php
@@ -1,611 +1,611 @@
<?php
/**
* Run pull commands on local working copies to keep them up to date. This
* daemon handles all repository types.
*
* By default, the daemon pulls **every** repository. If you want it to be
* responsible for only some repositories, you can launch it with a list of
* repositories:
*
* ./phd launch repositorypulllocal -- X Q Z
*
* You can also launch a daemon which is responsible for all //but// one or
* more repositories:
*
* ./phd launch repositorypulllocal -- --not A --not B
*
* If you have a very large number of repositories and some aren't being pulled
* as frequently as you'd like, you can either change the pull frequency of
* the less-important repositories to a larger number (so the daemon will skip
* them more often) or launch one daemon for all the less-important repositories
* and one for the more important repositories (or one for each more important
* repository).
*
* @task pull Pulling Repositories
*/
final class PhabricatorRepositoryPullLocalDaemon
extends PhabricatorDaemon {
private $statusMessageCursor = 0;
/* -( Pulling Repositories )----------------------------------------------- */
/**
* @task pull
*/
protected function run() {
$argv = $this->getArgv();
array_unshift($argv, __CLASS__);
$args = new PhutilArgumentParser($argv);
$args->parse(
array(
array(
'name' => 'no-discovery',
'help' => pht('Pull only, without discovering commits.'),
),
array(
'name' => 'not',
'param' => 'repository',
'repeat' => true,
'help' => pht('Do not pull __repository__.'),
),
array(
'name' => 'repositories',
'wildcard' => true,
'help' => pht('Pull specific __repositories__ instead of all.'),
),
));
$no_discovery = $args->getArg('no-discovery');
$include = $args->getArg('repositories');
$exclude = $args->getArg('not');
// Each repository has an individual pull frequency; after we pull it,
// wait that long to pull it again. When we start up, try to pull everything
// serially.
$retry_after = array();
$min_sleep = 15;
$max_sleep = phutil_units('5 minutes in seconds');
$max_futures = 4;
$futures = array();
$queue = array();
$future_pool = new FuturePool();
$future_pool->getIteratorTemplate()
->setUpdateInterval($min_sleep);
$sync_wait = phutil_units('2 minutes in seconds');
$last_sync = array();
while (!$this->shouldExit()) {
PhabricatorCaches::destroyRequestCache();
$device = AlmanacKeys::getLiveDevice();
$pullable = $this->loadPullableRepositories($include, $exclude, $device);
// If any repositories have the NEEDS_UPDATE flag set, pull them
// as soon as possible.
$need_update_messages = $this->loadRepositoryUpdateMessages(true);
foreach ($need_update_messages as $message) {
$repo = idx($pullable, $message->getRepositoryID());
if (!$repo) {
continue;
}
$this->log(
pht(
'Got an update message for repository "%s"!',
$repo->getMonogram()));
$retry_after[$message->getRepositoryID()] = time();
}
if ($device) {
$unsynchronized = $this->loadUnsynchronizedRepositories($device);
$now = PhabricatorTime::getNow();
foreach ($unsynchronized as $repository) {
$id = $repository->getID();
$this->log(
pht(
'Cluster repository ("%s") is out of sync on this node ("%s").',
$repository->getDisplayName(),
$device->getName()));
// Don't let out-of-sync conditions trigger updates too frequently,
// since we don't want to get trapped in a death spiral if sync is
// failing.
$sync_at = idx($last_sync, $id, 0);
$wait_duration = ($now - $sync_at);
if ($wait_duration < $sync_wait) {
$this->log(
pht(
'Skipping forced out-of-sync update because the last update '.
'was too recent (%s seconds ago).',
$wait_duration));
continue;
}
$last_sync[$id] = $now;
$retry_after[$id] = $now;
}
}
// If any repositories were deleted, remove them from the retry timer map
// so we don't end up with a retry timer that never gets updated and
// causes us to sleep for the minimum amount of time.
$retry_after = array_select_keys(
$retry_after,
array_keys($pullable));
// Figure out which repositories we need to queue for an update.
foreach ($pullable as $id => $repository) {
$now = PhabricatorTime::getNow();
$display_name = $repository->getDisplayName();
if (isset($futures[$id])) {
$this->log(
pht(
'Repository "%s" is currently updating.',
$display_name));
continue;
}
if (isset($queue[$id])) {
$this->log(
pht(
'Repository "%s" is already queued.',
$display_name));
continue;
}
$after = idx($retry_after, $id);
if (!$after) {
$smart_wait = $repository->loadUpdateInterval($min_sleep);
$last_update = $this->loadLastUpdate($repository);
$after = $last_update + $smart_wait;
$retry_after[$id] = $after;
$this->log(
pht(
'Scheduling repository "%s" with an update window of %s '.
'second(s). Last update was %s second(s) ago.',
$display_name,
new PhutilNumber($smart_wait),
new PhutilNumber($now - $last_update)));
}
if ($after > time()) {
$this->log(
pht(
'Repository "%s" is not due for an update for %s second(s).',
$display_name,
new PhutilNumber($after - $now)));
continue;
}
$this->log(
pht(
'Scheduling repository "%s" for an update (%s seconds overdue).',
$display_name,
new PhutilNumber($now - $after)));
$queue[$id] = $after;
}
// Process repositories in the order they became candidates for updates.
asort($queue);
// Dequeue repositories until we hit maximum parallelism.
while ($queue && (count($futures) < $max_futures)) {
foreach ($queue as $id => $time) {
$repository = idx($pullable, $id);
if (!$repository) {
$this->log(
pht('Repository %s is no longer pullable; skipping.', $id));
unset($queue[$id]);
continue;
}
$display_name = $repository->getDisplayName();
$this->log(
pht(
'Starting update for repository "%s".',
$display_name));
unset($queue[$id]);
$future = $this->buildUpdateFuture(
$repository,
$no_discovery);
$futures[$id] = $future->getFutureKey();
$future_pool->addFuture($future);
break;
}
}
if ($queue) {
$this->log(
pht(
'Not enough process slots to schedule the other %s '.
'repository(s) for updates yet.',
phutil_count($queue)));
}
if ($future_pool->hasFutures()) {
while ($future_pool->hasFutures()) {
$future = $future_pool->resolve();
$this->stillWorking();
if ($future === null) {
$this->log(pht('Waiting for updates to complete...'));
if ($this->loadRepositoryUpdateMessages()) {
$this->log(pht('Interrupted by pending updates!'));
break;
}
continue;
}
$future_key = $future->getFutureKey();
$repository_id = null;
foreach ($futures as $id => $key) {
if ($key === $future_key) {
$repository_id = $id;
unset($futures[$id]);
break;
}
}
$retry_after[$repository_id] = $this->resolveUpdateFuture(
$pullable[$repository_id],
$future,
$min_sleep);
// We have a free slot now, so go try to fill it.
break;
}
// Jump back into prioritization if we had any futures to deal with.
continue;
}
$should_hibernate = $this->waitForUpdates($max_sleep, $retry_after);
if ($should_hibernate) {
break;
}
}
}
/**
* @task pull
*/
private function buildUpdateFuture(
PhabricatorRepository $repository,
$no_discovery) {
$bin = dirname(phutil_get_library_root('phabricator')).'/bin/repository';
$flags = array();
if ($no_discovery) {
$flags[] = '--no-discovery';
}
$monogram = $repository->getMonogram();
$future = new ExecFuture('%s update %Ls -- %s', $bin, $flags, $monogram);
// Sometimes, the underlying VCS commands will hang indefinitely. We've
// observed this occasionally with GitHub, and other users have observed
// it with other VCS servers.
// To limit the damage this can cause, kill the update out after a
// reasonable amount of time, under the assumption that it has hung.
// Since it's hard to know what a "reasonable" amount of time is given that
// users may be downloading a repository full of pirated movies over a
// potato, these limits are fairly generous. Repositories exceeding these
// limits can be manually pulled with `bin/repository update X`, which can
// just run for as long as it wants.
if ($repository->isImporting()) {
$timeout = phutil_units('4 hours in seconds');
} else {
$timeout = phutil_units('15 minutes in seconds');
}
$future->setTimeout($timeout);
// The default TERM inherited by this process is "unknown", which causes PHP
// to produce a warning upon startup. Override it to squash this output to
// STDERR.
$future->updateEnv('TERM', 'dumb');
return $future;
}
/**
* Check for repositories that should be updated immediately.
*
* With the `$consume` flag, an internal cursor will also be incremented so
* that these messages are not returned by subsequent calls.
*
- * @param bool? $consume Pass `true` to consume these messages, so the
- * process will not see them again.
+ * @param bool $consume (optional) Pass `true` to consume these messages, so
+ * the process will not see them again.
* @return list<wild> Pending update messages.
*
* @task pull
*/
private function loadRepositoryUpdateMessages($consume = false) {
$type_need_update = PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE;
$messages = id(new PhabricatorRepositoryStatusMessage())->loadAllWhere(
'statusType = %s AND id > %d',
$type_need_update,
$this->statusMessageCursor);
// Keep track of messages we've seen so that we don't load them again.
// If we reload messages, we can get stuck a loop if we have a failing
// repository: we update immediately in response to the message, but do
// not clear the message because the update does not succeed. We then
// immediately retry. Instead, messages are only permitted to trigger
// an immediate update once.
if ($consume) {
foreach ($messages as $message) {
$this->statusMessageCursor = max(
$this->statusMessageCursor,
$message->getID());
}
}
return $messages;
}
/**
* @task pull
*/
private function loadLastUpdate(PhabricatorRepository $repository) {
$table = new PhabricatorRepositoryStatusMessage();
$conn = $table->establishConnection('r');
$epoch = queryfx_one(
$conn,
'SELECT MAX(epoch) last_update FROM %T
WHERE repositoryID = %d
AND statusType IN (%Ls)',
$table->getTableName(),
$repository->getID(),
array(
PhabricatorRepositoryStatusMessage::TYPE_INIT,
PhabricatorRepositoryStatusMessage::TYPE_FETCH,
));
if ($epoch) {
return (int)$epoch['last_update'];
}
return PhabricatorTime::getNow();
}
/**
* @task pull
*/
private function loadPullableRepositories(
array $include,
array $exclude,
AlmanacDevice $device = null) {
$query = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer());
if ($include) {
$query->withIdentifiers($include);
}
$repositories = $query->execute();
$repositories = mpull($repositories, null, 'getPHID');
if ($include) {
$map = $query->getIdentifierMap();
foreach ($include as $identifier) {
if (empty($map[$identifier])) {
throw new Exception(
pht(
'No repository "%s" exists!',
$identifier));
}
}
}
if ($exclude) {
$xquery = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withIdentifiers($exclude);
$excluded_repos = $xquery->execute();
$xmap = $xquery->getIdentifierMap();
foreach ($exclude as $identifier) {
if (empty($xmap[$identifier])) {
throw new Exception(
pht(
'No repository "%s" exists!',
$identifier));
}
}
foreach ($excluded_repos as $excluded_repo) {
unset($repositories[$excluded_repo->getPHID()]);
}
}
foreach ($repositories as $key => $repository) {
if (!$repository->isTracked()) {
unset($repositories[$key]);
}
}
$viewer = $this->getViewer();
$filter = id(new DiffusionLocalRepositoryFilter())
->setViewer($viewer)
->setDevice($device)
->setRepositories($repositories);
$repositories = $filter->execute();
foreach ($filter->getRejectionReasons() as $reason) {
$this->log($reason);
}
// Shuffle the repositories, then re-key the array since shuffle()
// discards keys. This is mostly for startup, we'll use soft priorities
// later.
shuffle($repositories);
$repositories = mpull($repositories, null, 'getID');
return $repositories;
}
/**
* @task pull
*/
private function resolveUpdateFuture(
PhabricatorRepository $repository,
ExecFuture $future,
$min_sleep) {
$display_name = $repository->getDisplayName();
$this->log(pht('Resolving update for "%s".', $display_name));
try {
list($stdout, $stderr) = $future->resolvex();
} catch (Exception $ex) {
$proxy = new PhutilProxyException(
pht(
'Error while updating the "%s" repository.',
$display_name),
$ex);
phlog($proxy);
$smart_wait = $repository->loadUpdateInterval($min_sleep);
return PhabricatorTime::getNow() + $smart_wait;
}
if (strlen($stderr)) {
$stderr_msg = pht(
'Unexpected output while updating repository "%s": %s',
$display_name,
$stderr);
phlog($stderr_msg);
}
$smart_wait = $repository->loadUpdateInterval($min_sleep);
$this->log(
pht(
'Based on activity in repository "%s", considering a wait of %s '.
'seconds before update.',
$display_name,
new PhutilNumber($smart_wait)));
return PhabricatorTime::getNow() + $smart_wait;
}
/**
* Sleep for a short period of time, waiting for update messages from the
*
*
* @task pull
*/
private function waitForUpdates($min_sleep, array $retry_after) {
$this->log(
pht('No repositories need updates right now, sleeping...'));
$sleep_until = time() + $min_sleep;
if ($retry_after) {
$sleep_until = min($sleep_until, min($retry_after));
}
while (($sleep_until - time()) > 0) {
$sleep_duration = ($sleep_until - time());
if ($this->shouldHibernate($sleep_duration)) {
return true;
}
$this->log(
pht(
'Sleeping for %s more second(s)...',
new PhutilNumber($sleep_duration)));
$this->sleep(1);
if ($this->shouldExit()) {
$this->log(pht('Awakened from sleep by graceful shutdown!'));
return false;
}
if ($this->loadRepositoryUpdateMessages()) {
$this->log(pht('Awakened from sleep by pending updates!'));
break;
}
}
return false;
}
private function loadUnsynchronizedRepositories(AlmanacDevice $device) {
$viewer = $this->getViewer();
$table = new PhabricatorRepositoryWorkingCopyVersion();
$conn = $table->establishConnection('r');
$our_versions = queryfx_all(
$conn,
'SELECT repositoryPHID, repositoryVersion FROM %R WHERE devicePHID = %s',
$table,
$device->getPHID());
$our_versions = ipull($our_versions, 'repositoryVersion', 'repositoryPHID');
$max_versions = queryfx_all(
$conn,
'SELECT repositoryPHID, MAX(repositoryVersion) maxVersion FROM %R
GROUP BY repositoryPHID',
$table);
$max_versions = ipull($max_versions, 'maxVersion', 'repositoryPHID');
$unsynchronized_phids = array();
foreach ($max_versions as $repository_phid => $max_version) {
$our_version = idx($our_versions, $repository_phid);
if (($our_version === null) || ($our_version < $max_version)) {
$unsynchronized_phids[] = $repository_phid;
}
}
if (!$unsynchronized_phids) {
return array();
}
return id(new PhabricatorRepositoryQuery())
->setViewer($viewer)
->withPHIDs($unsynchronized_phids)
->execute();
}
}
diff --git a/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php b/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php
index 2d9c36fae7..f42ef9c6c1 100644
--- a/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php
+++ b/src/applications/repository/graphcache/PhabricatorRepositoryGraphCache.php
@@ -1,419 +1,419 @@
<?php
/**
* Given a commit and a path, efficiently determine the most recent ancestor
* commit where the path was touched.
*
* In Git and Mercurial, log operations with a path are relatively slow. For
* example:
*
* git log -n1 <commit> -- <path>
*
* ...routinely takes several hundred milliseconds, and equivalent requests
* often take longer in Mercurial.
*
* Unfortunately, this operation is fundamental to rendering a repository for
* the web, and essentially everything else that's slow can be reduced to this
* plus some trivial work afterward. Making this fast is desirable and powerful,
* and allows us to make other things fast by expressing them in terms of this
* query.
*
* Because the query is fundamentally a graph query, it isn't easy to express
* in a reasonable way in MySQL, and we can't do round trips to the server to
* walk the graph without incurring huge performance penalties.
*
* However, the total amount of data in the graph is relatively small. By
* caching it in chunks and keeping it in APC, we can reasonably load and walk
* the graph in PHP quickly.
*
* For more context, see T2683.
*
* Structure of the Cache
* ======================
*
* The cache divides commits into buckets (see @{method:getBucketSize}). To
* walk the graph, we pull a commit's bucket. The bucket is a map from commit
* IDs to a list of parents and changed paths, separated by `null`. For
* example, a bucket might look like this:
*
* array(
* 1 => array(0, null, 17, 18),
* 2 => array(1, null, 4),
* // ...
* )
*
* This means that commit ID 1 has parent commit 0 (a special value meaning
* no parents) and affected path IDs 17 and 18. Commit ID 2 has parent commit 1,
* and affected path 4.
*
* This data structure attempts to balance compactness, ease of construction,
* simplicity of cache semantics, and lookup performance. In the average case,
* it appears to do a reasonable job at this.
*
* @task query Querying the Graph Cache
* @task cache Cache Internals
*/
final class PhabricatorRepositoryGraphCache extends Phobject {
private $rebuiltKeys = array();
/* -( Querying the Graph Cache )------------------------------------------- */
/**
* Search the graph cache for the most modification to a path.
*
* @param int $commit_id The commit ID to search ancestors of.
* @param int $path_id The path ID to search for changes to.
* @param float $time Maximum number of seconds to spend trying to satisfy
* this query using the graph cache. By default `0.5` (500ms).
* @return mixed Commit ID, or `null` if no ancestors exist, or `false` if
* the graph cache was unable to determine the answer.
* @task query
*/
public function loadLastModifiedCommitID($commit_id, $path_id, $time = 0.5) {
$commit_id = (int)$commit_id;
$path_id = (int)$path_id;
$bucket_data = null;
$data_key = null;
$seen = array();
$t_start = microtime(true);
$iterations = 0;
while (true) {
$bucket_key = $this->getBucketKey($commit_id);
if (($data_key != $bucket_key) || $bucket_data === null) {
$bucket_data = $this->getBucketData($bucket_key);
$data_key = $bucket_key;
}
if (empty($bucket_data[$commit_id])) {
// Rebuild the cache bucket, since the commit might be a very recent
// one that we'll pick up by rebuilding.
$bucket_data = $this->getBucketData($bucket_key, $bucket_data);
if (empty($bucket_data[$commit_id])) {
// A rebuild didn't help. This can occur legitimately if the commit
// is new and hasn't parsed yet.
return false;
}
// Otherwise, the rebuild gave us the data, so we can keep going.
$did_fill = true;
} else {
$did_fill = false;
}
// Sanity check so we can survive and recover from bad data.
if (isset($seen[$commit_id])) {
phlog(pht('Unexpected infinite loop in %s!', __CLASS__));
return false;
} else {
$seen[$commit_id] = true;
}
// `$data` is a list: the commit's parent IDs, followed by `null`,
// followed by the modified paths in ascending order. We figure out the
// first parent first, then check if the path was touched. If the path
// was touched, this is the commit we're after. If not, walk backward
// in the tree.
$items = $bucket_data[$commit_id];
$size = count($items);
// Walk past the parent information.
$parent_id = null;
for ($ii = 0;; ++$ii) {
if ($items[$ii] === null) {
break;
}
if ($parent_id === null) {
$parent_id = $items[$ii];
}
}
// Look for a modification to the path.
for (; $ii < $size; ++$ii) {
$item = $items[$ii];
if ($item > $path_id) {
break;
}
if ($item === $path_id) {
return $commit_id;
}
}
if ($parent_id) {
$commit_id = $parent_id;
// Periodically check if we've spent too long looking for a result
// in the cache, and return so we can fall back to a VCS operation.
// This keeps us from having a degenerate worst case if, e.g., the
// cache is cold and we need to inspect a very large number of blocks
// to satisfy the query.
++$iterations;
// If we performed a cache fill in this cycle, always check the time
// limit, since cache fills may take a significant amount of time.
if ($did_fill || ($iterations % 64 === 0)) {
$t_end = microtime(true);
if (($t_end - $t_start) > $time) {
return false;
}
}
continue;
}
// If we have an explicit 0, that means this commit really has no parents.
// Usually, it is the first commit in the repository.
if ($parent_id === 0) {
return null;
}
// If we didn't find a parent, the parent data isn't available. We fail
// to find an answer in the cache and fall back to querying the VCS.
return false;
}
}
/* -( Cache Internals )---------------------------------------------------- */
/**
* Get the bucket key for a given commit ID.
*
* @param int $commit_id Commit ID.
* @return int Bucket key.
* @task cache
*/
private function getBucketKey($commit_id) {
return (int)floor($commit_id / $this->getBucketSize());
}
/**
* Get the cache key for a given bucket key (from @{method:getBucketKey}).
*
* @param int $bucket_key Bucket key.
* @return string Cache key.
* @task cache
*/
private function getBucketCacheKey($bucket_key) {
static $prefix;
if ($prefix === null) {
$self = get_class($this);
$size = $this->getBucketSize();
$prefix = "{$self}:{$size}:2:";
}
return $prefix.$bucket_key;
}
/**
* Get the number of items per bucket.
*
* @return int Number of items to store per bucket.
* @task cache
*/
private function getBucketSize() {
return 4096;
}
/**
* Retrieve or build a graph cache bucket from the cache.
*
* Normally, this operates as a readthrough cache call. It can also be used
* to force a cache update by passing the existing data to `$rebuild_data`.
*
* @param int $bucket_key Bucket key, from @{method:getBucketKey}.
- * @param mixed? $rebuild_data Current data, to force a cache rebuild of
- * this bucket.
+ * @param mixed $rebuild_data (optional) Current data, to force a cache
+ * rebuild of this bucket.
* @return array Data from the cache.
* @task cache
*/
private function getBucketData($bucket_key, $rebuild_data = null) {
$cache_key = $this->getBucketCacheKey($bucket_key);
// TODO: This cache stuff could be handled more gracefully, but the
// database cache currently requires values to be strings and needs
// some tweaking to support this as part of a stack. Our cache semantics
// here are also unusual (not purely readthrough) because this cache is
// appendable.
$cache_level1 = PhabricatorCaches::getRepositoryGraphL1Cache();
$cache_level2 = PhabricatorCaches::getRepositoryGraphL2Cache();
if ($rebuild_data === null) {
$bucket_data = $cache_level1->getKey($cache_key);
if ($bucket_data) {
return $bucket_data;
}
$bucket_data = $cache_level2->getKey($cache_key);
if ($bucket_data) {
$unserialized = @unserialize($bucket_data);
if ($unserialized) {
// Fill APC if we got a database hit but missed in APC.
$cache_level1->setKey($cache_key, $unserialized);
return $unserialized;
}
}
}
if (!is_array($rebuild_data)) {
$rebuild_data = array();
}
$bucket_data = $this->rebuildBucket($bucket_key, $rebuild_data);
// Don't bother writing the data if we didn't update anything.
if ($bucket_data !== $rebuild_data) {
$cache_level2->setKey($cache_key, serialize($bucket_data));
$cache_level1->setKey($cache_key, $bucket_data);
}
return $bucket_data;
}
/**
* Rebuild a cache bucket, amending existing data if available.
*
* @param int $bucket_key Bucket key, from @{method:getBucketKey}.
* @param array $current_data Existing bucket data.
* @return array Rebuilt bucket data.
* @task cache
*/
private function rebuildBucket($bucket_key, array $current_data) {
// First, check if we've already rebuilt this bucket. In some cases (like
// browsing a repository at some commit) it's common to issue many lookups
// against one commit. If that commit has been discovered but not yet
// fully imported, we'll repeatedly attempt to rebuild the bucket. If the
// first rebuild did not work, subsequent rebuilds are very unlikely to
// have any effect. We can just skip the rebuild in these cases.
if (isset($this->rebuiltKeys[$bucket_key])) {
return $current_data;
} else {
$this->rebuiltKeys[$bucket_key] = true;
}
$bucket_min = ($bucket_key * $this->getBucketSize());
$bucket_max = ($bucket_min + $this->getBucketSize()) - 1;
// We need to reload all of the commits in the bucket because there is
// no guarantee that they'll get parsed in order, so we can fill large
// commit IDs before small ones. Later on, we'll ignore the commits we
// already know about.
$table_commit = new PhabricatorRepositoryCommit();
$table_repository = new PhabricatorRepository();
$conn_r = $table_commit->establishConnection('r');
// Find all the Git and Mercurial commits in the block which have completed
// change import. We can't fill the cache accurately for commits which have
// not completed change import, so just pretend we don't know about them.
// In these cases, we will ultimately fall back to VCS queries.
$commit_rows = queryfx_all(
$conn_r,
'SELECT c.id FROM %T c
JOIN %T r ON c.repositoryID = r.id AND r.versionControlSystem IN (%Ls)
WHERE c.id BETWEEN %d AND %d
AND (c.importStatus & %d) = %d',
$table_commit->getTableName(),
$table_repository->getTableName(),
array(
PhabricatorRepositoryType::REPOSITORY_TYPE_GIT,
PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL,
),
$bucket_min,
$bucket_max,
PhabricatorRepositoryCommit::IMPORTED_CHANGE,
PhabricatorRepositoryCommit::IMPORTED_CHANGE);
// If we don't have any data, just return the existing data.
if (!$commit_rows) {
return $current_data;
}
// Remove the commits we already have data for. We don't need to rebuild
// these. If there's nothing left, return the existing data.
$commit_ids = ipull($commit_rows, 'id', 'id');
$commit_ids = array_diff_key($commit_ids, $current_data);
if (!$commit_ids) {
return $current_data;
}
// Find all the path changes for the new commits.
$path_changes = queryfx_all(
$conn_r,
'SELECT commitID, pathID FROM %T
WHERE commitID IN (%Ld)
AND (isDirect = 1 OR changeType = %d)',
PhabricatorRepository::TABLE_PATHCHANGE,
$commit_ids,
DifferentialChangeType::TYPE_CHILD);
$path_changes = igroup($path_changes, 'commitID');
// Find all the parents for the new commits.
$parents = queryfx_all(
$conn_r,
'SELECT childCommitID, parentCommitID FROM %T
WHERE childCommitID IN (%Ld)
ORDER BY id ASC',
PhabricatorRepository::TABLE_PARENTS,
$commit_ids);
$parents = igroup($parents, 'childCommitID');
// Build the actual data for the cache.
foreach ($commit_ids as $commit_id) {
$parent_ids = array();
if (!empty($parents[$commit_id])) {
foreach ($parents[$commit_id] as $row) {
$parent_ids[] = (int)$row['parentCommitID'];
}
} else {
// We expect all rows to have parents (commits with no parents get
// an explicit "0" placeholder). If we're in an older repository, the
// parent information might not have been populated yet. Decline to fill
// the cache if we don't have the parent information, since the fill
// will be incorrect.
continue;
}
if (isset($path_changes[$commit_id])) {
$path_ids = $path_changes[$commit_id];
foreach ($path_ids as $key => $path_id) {
$path_ids[$key] = (int)$path_id['pathID'];
}
sort($path_ids);
} else {
$path_ids = array();
}
$value = $parent_ids;
$value[] = null;
foreach ($path_ids as $path_id) {
$value[] = $path_id;
}
$current_data[$commit_id] = $value;
}
return $current_data;
}
}
diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php
index 5ffaad6282..07daf32e16 100644
--- a/src/applications/repository/storage/PhabricatorRepository.php
+++ b/src/applications/repository/storage/PhabricatorRepository.php
@@ -1,2829 +1,2831 @@
<?php
/**
* @task uri Repository URI Management
* @task publishing Publishing
* @task sync Cluster Synchronization
*/
final class PhabricatorRepository extends PhabricatorRepositoryDAO
implements
PhabricatorApplicationTransactionInterface,
PhabricatorPolicyInterface,
PhabricatorFlaggableInterface,
PhabricatorMarkupInterface,
PhabricatorDestructibleInterface,
PhabricatorDestructibleCodexInterface,
PhabricatorProjectInterface,
PhabricatorSpacesInterface,
PhabricatorConduitResultInterface,
PhabricatorFulltextInterface,
PhabricatorFerretInterface {
/**
* Shortest hash we'll recognize in raw "a829f32" form.
*/
const MINIMUM_UNQUALIFIED_HASH = 7;
/**
* Shortest hash we'll recognize in qualified "rXab7ef2f8" form.
*/
const MINIMUM_QUALIFIED_HASH = 5;
/**
* Minimum number of commits to an empty repository to trigger "import" mode.
*/
const IMPORT_THRESHOLD = 7;
const LOWPRI_THRESHOLD = 64;
const TABLE_PATH = 'repository_path';
const TABLE_PATHCHANGE = 'repository_pathchange';
const TABLE_FILESYSTEM = 'repository_filesystem';
const TABLE_SUMMARY = 'repository_summary';
const TABLE_LINTMESSAGE = 'repository_lintmessage';
const TABLE_PARENTS = 'repository_parents';
const TABLE_COVERAGE = 'repository_coverage';
const STATUS_ACTIVE = 'active';
const STATUS_INACTIVE = 'inactive';
protected $name;
protected $callsign;
protected $repositorySlug;
protected $uuid;
protected $viewPolicy;
protected $editPolicy;
protected $pushPolicy;
protected $profileImagePHID;
protected $versionControlSystem;
protected $details = array();
protected $credentialPHID;
protected $almanacServicePHID;
protected $spacePHID;
protected $localPath;
private $commitCount = self::ATTACHABLE;
private $mostRecentCommit = self::ATTACHABLE;
private $projectPHIDs = self::ATTACHABLE;
private $uris = self::ATTACHABLE;
private $profileImageFile = self::ATTACHABLE;
public static function initializeNewRepository(PhabricatorUser $actor) {
$app = id(new PhabricatorApplicationQuery())
->setViewer($actor)
->withClasses(array('PhabricatorDiffusionApplication'))
->executeOne();
$view_policy = $app->getPolicy(DiffusionDefaultViewCapability::CAPABILITY);
$edit_policy = $app->getPolicy(DiffusionDefaultEditCapability::CAPABILITY);
$push_policy = $app->getPolicy(DiffusionDefaultPushCapability::CAPABILITY);
$repository = id(new PhabricatorRepository())
->setViewPolicy($view_policy)
->setEditPolicy($edit_policy)
->setPushPolicy($push_policy)
->setSpacePHID($actor->getDefaultSpacePHID());
// Put the repository in "Importing" mode until we finish
// parsing it.
$repository->setDetail('importing', true);
return $repository;
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'details' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'name' => 'sort255',
'callsign' => 'sort32?',
'repositorySlug' => 'sort64?',
'versionControlSystem' => 'text32',
'uuid' => 'text64?',
'pushPolicy' => 'policy',
'credentialPHID' => 'phid?',
'almanacServicePHID' => 'phid?',
'localPath' => 'text128?',
'profileImagePHID' => 'phid?',
),
self::CONFIG_KEY_SCHEMA => array(
'callsign' => array(
'columns' => array('callsign'),
'unique' => true,
),
'key_name' => array(
'columns' => array('name(128)'),
),
'key_vcs' => array(
'columns' => array('versionControlSystem'),
),
'key_slug' => array(
'columns' => array('repositorySlug'),
'unique' => true,
),
'key_local' => array(
'columns' => array('localPath'),
'unique' => true,
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorRepositoryRepositoryPHIDType::TYPECONST);
}
public static function getStatusMap() {
return array(
self::STATUS_ACTIVE => array(
'name' => pht('Active'),
'isTracked' => 1,
),
self::STATUS_INACTIVE => array(
'name' => pht('Inactive'),
'isTracked' => 0,
),
);
}
public static function getStatusNameMap() {
return ipull(self::getStatusMap(), 'name');
}
public function getStatus() {
if ($this->isTracked()) {
return self::STATUS_ACTIVE;
} else {
return self::STATUS_INACTIVE;
}
}
public function toDictionary() {
return array(
'id' => $this->getID(),
'name' => $this->getName(),
'phid' => $this->getPHID(),
'callsign' => $this->getCallsign(),
'monogram' => $this->getMonogram(),
'vcs' => $this->getVersionControlSystem(),
'uri' => PhabricatorEnv::getProductionURI($this->getURI()),
'remoteURI' => (string)$this->getRemoteURI(),
'description' => $this->getDetail('description'),
'isActive' => $this->isTracked(),
'isHosted' => $this->isHosted(),
'isImporting' => $this->isImporting(),
'encoding' => $this->getDefaultTextEncoding(),
'staging' => array(
'supported' => $this->supportsStaging(),
'prefix' => 'phabricator',
'uri' => $this->getStagingURI(),
),
);
}
public function getDefaultTextEncoding() {
return $this->getDetail('encoding', 'UTF-8');
}
public function getMonogram() {
$callsign = $this->getCallsign();
if (phutil_nonempty_string($callsign)) {
return "r{$callsign}";
}
$id = $this->getID();
return "R{$id}";
}
public function getDisplayName() {
$slug = $this->getRepositorySlug();
if (phutil_nonempty_string($slug)) {
return $slug;
}
return $this->getMonogram();
}
public function getAllMonograms() {
$monograms = array();
$monograms[] = 'R'.$this->getID();
$callsign = $this->getCallsign();
if (phutil_nonempty_string($callsign)) {
$monograms[] = 'r'.$callsign;
}
return $monograms;
}
public function setLocalPath($path) {
// Convert any extra slashes ("//") in the path to a single slash ("/").
$path = preg_replace('(//+)', '/', $path);
return parent::setLocalPath($path);
}
public function getDetail($key, $default = null) {
return idx($this->details, $key, $default);
}
public function setDetail($key, $value) {
$this->details[$key] = $value;
return $this;
}
public function attachCommitCount($count) {
$this->commitCount = $count;
return $this;
}
public function getCommitCount() {
return $this->assertAttached($this->commitCount);
}
public function attachMostRecentCommit(
PhabricatorRepositoryCommit $commit = null) {
$this->mostRecentCommit = $commit;
return $this;
}
public function getMostRecentCommit() {
return $this->assertAttached($this->mostRecentCommit);
}
public function getDiffusionBrowseURIForPath(
PhabricatorUser $user,
$path,
$line = null,
$branch = null) {
$drequest = DiffusionRequest::newFromDictionary(
array(
'user' => $user,
'repository' => $this,
'path' => $path,
'branch' => $branch,
));
return $drequest->generateURI(
array(
'action' => 'browse',
'line' => $line,
));
}
public function getSubversionBaseURI($commit = null) {
$subpath = $this->getDetail('svn-subpath');
if (!phutil_nonempty_string($subpath)) {
$subpath = null;
}
return $this->getSubversionPathURI($subpath, $commit);
}
public function getSubversionPathURI($path = null, $commit = null) {
$vcs = $this->getVersionControlSystem();
if ($vcs != PhabricatorRepositoryType::REPOSITORY_TYPE_SVN) {
throw new Exception(pht('Not a subversion repository!'));
}
if ($this->isHosted()) {
$uri = 'file://'.$this->getLocalPath();
} else {
$uri = $this->getDetail('remote-uri');
}
$uri = rtrim($uri, '/');
if (phutil_nonempty_string($path)) {
$path = rawurlencode($path);
$path = str_replace('%2F', '/', $path);
$uri = $uri.'/'.ltrim($path, '/');
}
if ($path !== null || $commit !== null) {
$uri .= '@';
}
if ($commit !== null) {
$uri .= $commit;
}
return $uri;
}
public function attachProjectPHIDs(array $project_phids) {
$this->projectPHIDs = $project_phids;
return $this;
}
public function getProjectPHIDs() {
return $this->assertAttached($this->projectPHIDs);
}
/**
* Get the name of the directory this repository should clone or checkout
* into. For example, if the repository name is "Example Repository", a
* reasonable name might be "example-repository". This is used to help users
* get reasonable results when cloning repositories, since they generally do
* not want to clone into directories called "X/" or "Example Repository/".
*
* @return string
*/
public function getCloneName() {
$name = $this->getRepositorySlug();
// Make some reasonable effort to produce reasonable default directory
// names from repository names.
if (!phutil_nonempty_string($name)) {
$name = $this->getName();
$name = phutil_utf8_strtolower($name);
$name = preg_replace('@[ -/:->]+@', '-', $name);
$name = trim($name, '-');
if (!phutil_nonempty_string($name)) {
$name = $this->getCallsign();
}
}
return $name;
}
public static function isValidRepositorySlug($slug) {
try {
self::assertValidRepositorySlug($slug);
return true;
} catch (Exception $ex) {
return false;
}
}
public static function assertValidRepositorySlug($slug) {
if (!strlen($slug)) {
throw new Exception(
pht(
'The empty string is not a valid repository short name. '.
'Repository short names must be at least one character long.'));
}
if (strlen($slug) > 64) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names must not be longer than 64 characters.',
$slug));
}
if (preg_match('/[^a-zA-Z0-9._-]/', $slug)) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names may only contain letters, numbers, periods, hyphens '.
'and underscores.',
$slug));
}
if (!preg_match('/^[a-zA-Z0-9]/', $slug)) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names must begin with a letter or number.',
$slug));
}
if (!preg_match('/[a-zA-Z0-9]\z/', $slug)) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names must end with a letter or number.',
$slug));
}
if (preg_match('/__|--|\\.\\./', $slug)) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names must not contain multiple consecutive underscores, '.
'hyphens, or periods.',
$slug));
}
if (preg_match('/^[A-Z]+\z/', $slug)) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names may not contain only uppercase letters.',
$slug));
}
if (preg_match('/^\d+\z/', $slug)) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names may not contain only numbers.',
$slug));
}
if (preg_match('/\\.git/', $slug)) {
throw new Exception(
pht(
'The name "%s" is not a valid repository short name. Repository '.
'short names must not end in ".git". This suffix will be added '.
'automatically in appropriate contexts.',
$slug));
}
}
public static function assertValidCallsign($callsign) {
if (!strlen($callsign)) {
throw new Exception(
pht(
'A repository callsign must be at least one character long.'));
}
if (strlen($callsign) > 32) {
throw new Exception(
pht(
'The callsign "%s" is not a valid repository callsign. Callsigns '.
'must be no more than 32 bytes long.',
$callsign));
}
if (!preg_match('/^[A-Z]+\z/', $callsign)) {
throw new Exception(
pht(
'The callsign "%s" is not a valid repository callsign. Callsigns '.
'may only contain UPPERCASE letters.',
$callsign));
}
}
public function getProfileImageURI() {
return $this->getProfileImageFile()->getBestURI();
}
public function attachProfileImageFile(PhabricatorFile $file) {
$this->profileImageFile = $file;
return $this;
}
public function getProfileImageFile() {
return $this->assertAttached($this->profileImageFile);
}
/* -( Remote Command Execution )------------------------------------------- */
public function execRemoteCommand($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newRemoteCommandFuture($args)->resolve();
}
public function execxRemoteCommand($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newRemoteCommandFuture($args)->resolvex();
}
public function getRemoteCommandFuture($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newRemoteCommandFuture($args);
}
public function passthruRemoteCommand($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newRemoteCommandPassthru($args)->resolve();
}
private function newRemoteCommandFuture(array $argv) {
return $this->newRemoteCommandEngine($argv)
->newFuture();
}
private function newRemoteCommandPassthru(array $argv) {
return $this->newRemoteCommandEngine($argv)
->setPassthru(true)
->newFuture();
}
private function newRemoteCommandEngine(array $argv) {
return DiffusionCommandEngine::newCommandEngine($this)
->setArgv($argv)
->setCredentialPHID($this->getCredentialPHID())
->setURI($this->getRemoteURIObject());
}
/* -( Local Command Execution )-------------------------------------------- */
public function execLocalCommand($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newLocalCommandFuture($args)->resolve();
}
public function execxLocalCommand($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newLocalCommandFuture($args)->resolvex();
}
public function getLocalCommandFuture($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newLocalCommandFuture($args);
}
public function passthruLocalCommand($pattern /* , $arg, ... */) {
$args = func_get_args();
return $this->newLocalCommandPassthru($args)->resolve();
}
private function newLocalCommandFuture(array $argv) {
$this->assertLocalExists();
$future = DiffusionCommandEngine::newCommandEngine($this)
->setArgv($argv)
->newFuture();
if ($this->usesLocalWorkingCopy()) {
$future->setCWD($this->getLocalPath());
}
return $future;
}
private function newLocalCommandPassthru(array $argv) {
$this->assertLocalExists();
$future = DiffusionCommandEngine::newCommandEngine($this)
->setArgv($argv)
->setPassthru(true)
->newFuture();
if ($this->usesLocalWorkingCopy()) {
$future->setCWD($this->getLocalPath());
}
return $future;
}
public function getURI() {
$short_name = $this->getRepositorySlug();
if (phutil_nonempty_string($short_name)) {
return "/source/{$short_name}/";
}
$callsign = $this->getCallsign();
if (phutil_nonempty_string($callsign)) {
return "/diffusion/{$callsign}/";
}
$id = $this->getID();
return "/diffusion/{$id}/";
}
public function getPathURI($path) {
return $this->getURI().ltrim($path, '/');
}
public function getCommitURI($identifier) {
$callsign = $this->getCallsign();
if (phutil_nonempty_string($callsign)) {
return "/r{$callsign}{$identifier}";
}
$id = $this->getID();
return "/R{$id}:{$identifier}";
}
public static function parseRepositoryServicePath($request_path, $vcs) {
$is_git = ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT);
$patterns = array(
'(^'.
'(?P<base>/?(?:diffusion|source)/(?P<identifier>[^/]+))'.
'(?P<path>.*)'.
'\z)',
);
$identifier = null;
foreach ($patterns as $pattern) {
$matches = null;
if (!preg_match($pattern, $request_path, $matches)) {
continue;
}
$identifier = $matches['identifier'];
if ($is_git) {
$identifier = preg_replace('/\\.git\z/', '', $identifier);
}
$base = $matches['base'];
$path = $matches['path'];
break;
}
if ($identifier === null) {
return null;
}
return array(
'identifier' => $identifier,
'base' => $base,
'path' => $path,
);
}
public function getCanonicalPath($request_path) {
$standard_pattern =
'(^'.
'(?P<prefix>/(?:diffusion|source)/)'.
'(?P<identifier>[^/]+)'.
'(?P<suffix>(?:/.*)?)'.
'\z)';
$matches = null;
if (preg_match($standard_pattern, $request_path, $matches)) {
$suffix = $matches['suffix'];
return $this->getPathURI($suffix);
}
$commit_pattern =
'(^'.
'(?P<prefix>/)'.
'(?P<monogram>'.
'(?:'.
'r(?P<repositoryCallsign>[A-Z]+)'.
'|'.
'R(?P<repositoryID>[1-9]\d*):'.
')'.
'(?P<commit>[a-f0-9]+)'.
')'.
'\z)';
$matches = null;
if (preg_match($commit_pattern, $request_path, $matches)) {
$commit = $matches['commit'];
return $this->getCommitURI($commit);
}
return null;
}
public function generateURI(array $params) {
$req_branch = false;
$req_commit = false;
$action = idx($params, 'action');
switch ($action) {
case 'history':
case 'clone':
case 'blame':
case 'browse':
case 'document':
case 'change':
case 'lastmodified':
case 'tags':
case 'branches':
case 'lint':
case 'pathtree':
case 'refs':
case 'compare':
break;
case 'branch':
// NOTE: This does not actually require a branch, and won't have one
// in Subversion. Possibly this should be more clear.
break;
case 'commit':
case 'rendering-ref':
$req_commit = true;
break;
default:
throw new Exception(
pht(
'Action "%s" is not a valid repository URI action.',
$action));
}
$path = idx($params, 'path');
$branch = idx($params, 'branch');
$commit = idx($params, 'commit');
$line = idx($params, 'line');
$head = idx($params, 'head');
$against = idx($params, 'against');
if ($req_commit && !strlen($commit)) {
throw new Exception(
pht(
'Diffusion URI action "%s" requires commit!',
$action));
}
if ($req_branch && !strlen($branch)) {
throw new Exception(
pht(
'Diffusion URI action "%s" requires branch!',
$action));
}
if ($action === 'commit') {
return $this->getCommitURI($commit);
}
if (phutil_nonempty_string($path)) {
$path = ltrim($path, '/');
$path = str_replace(array(';', '$'), array(';;', '$$'), $path);
$path = phutil_escape_uri($path);
}
$raw_branch = $branch;
if (phutil_nonempty_string($branch)) {
$branch = phutil_escape_uri_path_component($branch);
$path = "{$branch}/{$path}";
}
$raw_commit = $commit;
if (phutil_nonempty_scalar($commit)) {
$commit = str_replace('$', '$$', $commit);
$commit = ';'.phutil_escape_uri($commit);
}
$line = phutil_string_cast($line);
if (phutil_nonempty_string($line)) {
$line = '$'.phutil_escape_uri($line);
}
$query = array();
switch ($action) {
case 'change':
case 'history':
case 'blame':
case 'browse':
case 'document':
case 'lastmodified':
case 'tags':
case 'branches':
case 'lint':
case 'pathtree':
case 'refs':
$uri = $this->getPathURI("/{$action}/{$path}{$commit}{$line}");
break;
case 'compare':
$uri = $this->getPathURI("/{$action}/");
if (phutil_nonempty_scalar($head)) {
$query['head'] = $head;
} else if (phutil_nonempty_scalar($raw_commit)) {
$query['commit'] = $raw_commit;
} else if (phutil_nonempty_scalar($raw_branch)) {
$query['head'] = $raw_branch;
}
if (phutil_nonempty_scalar($against)) {
$query['against'] = $against;
}
break;
case 'branch':
if (strlen($path)) {
$uri = $this->getPathURI("/repository/{$path}");
} else {
$uri = $this->getPathURI('/');
}
break;
case 'external':
$commit = ltrim($commit, ';');
$uri = "/diffusion/external/{$commit}/";
break;
case 'rendering-ref':
// This isn't a real URI per se, it's passed as a query parameter to
// the ajax changeset stuff but then we parse it back out as though
// it came from a URI.
$uri = rawurldecode("{$path}{$commit}");
break;
case 'clone':
$uri = $this->getPathURI("/{$action}/");
break;
}
if ($action == 'rendering-ref') {
return $uri;
}
if (isset($params['lint'])) {
$params['params'] = idx($params, 'params', array()) + array(
'lint' => $params['lint'],
);
}
$query = idx($params, 'params', array()) + $query;
return new PhutilURI($uri, $query);
}
public function updateURIIndex() {
$indexes = array();
$uris = $this->getURIs();
foreach ($uris as $uri) {
if ($uri->getIsDisabled()) {
continue;
}
$indexes[] = $uri->getNormalizedURI();
}
PhabricatorRepositoryURIIndex::updateRepositoryURIs(
$this->getPHID(),
$indexes);
return $this;
}
public function isTracked() {
$status = $this->getDetail('tracking-enabled');
$map = self::getStatusMap();
$spec = idx($map, $status);
if (!$spec) {
if ($status) {
$status = self::STATUS_ACTIVE;
} else {
$status = self::STATUS_INACTIVE;
}
$spec = idx($map, $status);
}
return (bool)idx($spec, 'isTracked', false);
}
public function getDefaultBranch() {
$default = $this->getDetail('default-branch');
if (phutil_nonempty_string($default)) {
return $default;
}
$default_branches = array(
PhabricatorRepositoryType::REPOSITORY_TYPE_GIT => 'master',
PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL => 'default',
);
return idx($default_branches, $this->getVersionControlSystem());
}
public function getDefaultArcanistBranch() {
return coalesce($this->getDefaultBranch(), 'svn');
}
private function isBranchInFilter($branch, $filter_key) {
$vcs = $this->getVersionControlSystem();
$is_git = ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT);
$use_filter = ($is_git);
if (!$use_filter) {
// If this VCS doesn't use filters, pass everything through.
return true;
}
$filter = $this->getDetail($filter_key, array());
// If there's no filter set, let everything through.
if (!$filter) {
return true;
}
// If this branch isn't literally named `regexp(...)`, and it's in the
// filter list, let it through.
if (isset($filter[$branch])) {
if (self::extractBranchRegexp($branch) === null) {
return true;
}
}
// If the branch matches a regexp, let it through.
foreach ($filter as $pattern => $ignored) {
$regexp = self::extractBranchRegexp($pattern);
if ($regexp !== null) {
if (preg_match($regexp, $branch)) {
return true;
}
}
}
// Nothing matched, so filter this branch out.
return false;
}
public static function extractBranchRegexp($pattern) {
$matches = null;
if (preg_match('/^regexp\\((.*)\\)\z/', $pattern, $matches)) {
return $matches[1];
}
return null;
}
public function shouldTrackRef(DiffusionRepositoryRef $ref) {
// At least for now, don't track the staging area tags.
if ($ref->isTag()) {
if (preg_match('(^phabricator/)', $ref->getShortName())) {
return false;
}
}
if (!$ref->isBranch()) {
return true;
}
return $this->shouldTrackBranch($ref->getShortName());
}
public function shouldTrackBranch($branch) {
return $this->isBranchInFilter($branch, 'branch-filter');
}
public function isBranchPermanentRef($branch) {
return $this->isBranchInFilter($branch, 'close-commits-filter');
}
public function formatCommitName($commit_identifier, $local = false) {
$vcs = $this->getVersionControlSystem();
$type_git = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT;
$type_hg = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL;
$is_git = ($vcs == $type_git);
$is_hg = ($vcs == $type_hg);
if ($is_git || $is_hg) {
$name = substr($commit_identifier, 0, 12);
$need_scope = false;
} else {
$name = $commit_identifier;
$need_scope = true;
}
if (!$local) {
$need_scope = true;
}
if ($need_scope) {
$callsign = $this->getCallsign();
if ($callsign) {
$scope = "r{$callsign}";
} else {
$id = $this->getID();
$scope = "R{$id}:";
}
$name = $scope.$name;
}
return $name;
}
public function isImporting() {
return (bool)$this->getDetail('importing', false);
}
public function isNewlyInitialized() {
return (bool)$this->getDetail('newly-initialized', false);
}
public function loadImportProgress() {
$progress = queryfx_all(
$this->establishConnection('r'),
'SELECT importStatus, count(*) N FROM %T WHERE repositoryID = %d
GROUP BY importStatus',
id(new PhabricatorRepositoryCommit())->getTableName(),
$this->getID());
$done = 0;
$total = 0;
foreach ($progress as $row) {
$total += $row['N'] * 3;
$status = $row['importStatus'];
if ($status & PhabricatorRepositoryCommit::IMPORTED_MESSAGE) {
$done += $row['N'];
}
if ($status & PhabricatorRepositoryCommit::IMPORTED_CHANGE) {
$done += $row['N'];
}
if ($status & PhabricatorRepositoryCommit::IMPORTED_PUBLISH) {
$done += $row['N'];
}
}
if ($total) {
$ratio = ($done / $total);
} else {
$ratio = 0;
}
// Cap this at "99.99%", because it's confusing to users when the actual
// fraction is "99.996%" and it rounds up to "100.00%".
if ($ratio > 0.9999) {
$ratio = 0.9999;
}
return $ratio;
}
/* -( Publishing )--------------------------------------------------------- */
public function newPublisher() {
return id(new PhabricatorRepositoryPublisher())
->setRepository($this);
}
public function isPublishingDisabled() {
return $this->getDetail('herald-disabled');
}
public function getPermanentRefRules() {
return array_keys($this->getDetail('close-commits-filter', array()));
}
public function setPermanentRefRules(array $rules) {
$rules = array_fill_keys($rules, true);
$this->setDetail('close-commits-filter', $rules);
return $this;
}
public function getTrackOnlyRules() {
return array_keys($this->getDetail('branch-filter', array()));
}
public function setTrackOnlyRules(array $rules) {
$rules = array_fill_keys($rules, true);
$this->setDetail('branch-filter', $rules);
return $this;
}
public function supportsFetchRules() {
if ($this->isGit()) {
return true;
}
return false;
}
public function getFetchRules() {
return $this->getDetail('fetch-rules', array());
}
public function setFetchRules(array $rules) {
return $this->setDetail('fetch-rules', $rules);
}
/* -( Repository URI Management )------------------------------------------ */
/**
* Get the remote URI for this repository.
*
* @return string
* @task uri
*/
public function getRemoteURI() {
return (string)$this->getRemoteURIObject();
}
/**
* Get the remote URI for this repository, including credentials if they're
* used by this repository.
*
* @return PhutilOpaqueEnvelope URI, possibly including credentials.
* @task uri
*/
public function getRemoteURIEnvelope() {
$uri = $this->getRemoteURIObject();
$remote_protocol = $this->getRemoteProtocol();
if ($remote_protocol == 'http' || $remote_protocol == 'https') {
// For SVN, we use `--username` and `--password` flags separately, so
// don't add any credentials here.
if (!$this->isSVN()) {
$credential_phid = $this->getCredentialPHID();
if ($credential_phid) {
$key = PassphrasePasswordKey::loadFromPHID(
$credential_phid,
PhabricatorUser::getOmnipotentUser());
$uri->setUser($key->getUsernameEnvelope()->openEnvelope());
$uri->setPass($key->getPasswordEnvelope()->openEnvelope());
}
}
}
return new PhutilOpaqueEnvelope((string)$uri);
}
/**
* Get the clone (or checkout) URI for this repository, without authentication
* information.
*
* @return string Repository URI.
* @task uri
*/
public function getPublicCloneURI() {
return (string)$this->getCloneURIObject();
}
/**
* Get the protocol for the repository's remote.
*
* @return string Protocol, like "ssh" or "git".
* @task uri
*/
public function getRemoteProtocol() {
$uri = $this->getRemoteURIObject();
return $uri->getProtocol();
}
/**
* Get a parsed object representation of the repository's remote URI..
*
* @return wild A @{class@arcanist:PhutilURI}.
* @task uri
*/
public function getRemoteURIObject() {
$raw_uri = $this->getDetail('remote-uri');
if (!phutil_nonempty_string($raw_uri)) {
return new PhutilURI('');
}
if (!strncmp($raw_uri, '/', 1)) {
return new PhutilURI('file://'.$raw_uri);
}
return new PhutilURI($raw_uri);
}
/**
* Get the "best" clone/checkout URI for this repository, on any protocol.
*/
public function getCloneURIObject() {
if (!$this->isHosted()) {
if ($this->isSVN()) {
// Make sure we pick up the "Import Only" path for Subversion, so
// the user clones the repository starting at the correct path, not
// from the root.
$base_uri = $this->getSubversionBaseURI();
$base_uri = new PhutilURI($base_uri);
$path = $base_uri->getPath();
if (!$path) {
$path = '/';
}
// If the trailing "@" is not required to escape the URI, strip it for
// readability.
if (!preg_match('/@.*@/', $path)) {
$path = rtrim($path, '@');
}
$base_uri->setPath($path);
return $base_uri;
} else {
return $this->getRemoteURIObject();
}
}
// TODO: This should be cleaned up to deal with all the new URI handling.
$another_copy = id(new PhabricatorRepositoryQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs(array($this->getPHID()))
->needURIs(true)
->executeOne();
$clone_uris = $another_copy->getCloneURIs();
if (!$clone_uris) {
return null;
}
return head($clone_uris)->getEffectiveURI();
}
private function getRawHTTPCloneURIObject() {
$uri = PhabricatorEnv::getProductionURI($this->getURI());
$uri = new PhutilURI($uri);
if ($this->isGit()) {
$uri->setPath($uri->getPath().$this->getCloneName().'.git');
} else if ($this->isHg()) {
$uri->setPath($uri->getPath().$this->getCloneName().'/');
}
return $uri;
}
/**
* Determine if a protocol is SSH or SSH-like.
*
* @param string $protocol A protocol string, like "http" or "ssh".
* @return bool True if the protocol is SSH-like.
* @task uri
*/
private function isSSHProtocol($protocol) {
return ($protocol == 'ssh' || $protocol == 'svn+ssh');
}
public function delete() {
$this->openTransaction();
$paths = id(new PhabricatorOwnersPath())
->loadAllWhere('repositoryPHID = %s', $this->getPHID());
foreach ($paths as $path) {
$path->delete();
}
queryfx(
$this->establishConnection('w'),
'DELETE FROM %T WHERE repositoryPHID = %s',
id(new PhabricatorRepositorySymbol())->getTableName(),
$this->getPHID());
$commits = id(new PhabricatorRepositoryCommit())
->loadAllWhere('repositoryID = %d', $this->getID());
foreach ($commits as $commit) {
// note PhabricatorRepositoryAuditRequests and
// PhabricatorRepositoryCommitData are deleted here too.
$commit->delete();
}
$uris = id(new PhabricatorRepositoryURI())
->loadAllWhere('repositoryPHID = %s', $this->getPHID());
foreach ($uris as $uri) {
$uri->delete();
}
$ref_cursors = id(new PhabricatorRepositoryRefCursor())
->loadAllWhere('repositoryPHID = %s', $this->getPHID());
foreach ($ref_cursors as $cursor) {
$cursor->delete();
}
$conn_w = $this->establishConnection('w');
queryfx(
$conn_w,
'DELETE FROM %T WHERE repositoryID = %d',
self::TABLE_FILESYSTEM,
$this->getID());
queryfx(
$conn_w,
'DELETE FROM %T WHERE repositoryID = %d',
self::TABLE_PATHCHANGE,
$this->getID());
queryfx(
$conn_w,
'DELETE FROM %T WHERE repositoryID = %d',
self::TABLE_SUMMARY,
$this->getID());
$result = parent::delete();
$this->saveTransaction();
return $result;
}
public function isGit() {
$vcs = $this->getVersionControlSystem();
return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_GIT);
}
public function isSVN() {
$vcs = $this->getVersionControlSystem();
return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_SVN);
}
public function isHg() {
$vcs = $this->getVersionControlSystem();
return ($vcs == PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL);
}
public function isHosted() {
return (bool)$this->getDetail('hosting-enabled', false);
}
public function setHosted($enabled) {
return $this->setDetail('hosting-enabled', $enabled);
}
public function canServeProtocol(
$protocol,
$write,
$is_intracluster = false) {
// See T13192. If a repository is inactive, don't serve it to users. We
// still synchronize it within the cluster and serve it to other repository
// nodes.
if (!$is_intracluster) {
if (!$this->isTracked()) {
return false;
}
}
$clone_uris = $this->getCloneURIs();
foreach ($clone_uris as $uri) {
if ($uri->getBuiltinProtocol() !== $protocol) {
continue;
}
$io_type = $uri->getEffectiveIoType();
if ($io_type == PhabricatorRepositoryURI::IO_READWRITE) {
return true;
}
if (!$write) {
if ($io_type == PhabricatorRepositoryURI::IO_READ) {
return true;
}
}
}
if ($write) {
if ($this->isReadOnly()) {
return false;
}
}
return false;
}
public function hasLocalWorkingCopy() {
try {
self::assertLocalExists();
return true;
} catch (Exception $ex) {
return false;
}
}
/**
* Raise more useful errors when there are basic filesystem problems.
*/
private function assertLocalExists() {
if (!$this->usesLocalWorkingCopy()) {
return;
}
$local = $this->getLocalPath();
Filesystem::assertExists($local);
Filesystem::assertIsDirectory($local);
Filesystem::assertReadable($local);
}
/**
* Determine if the working copy is bare or not. In Git, this corresponds
* to `--bare`. In Mercurial, `--noupdate`.
*/
public function isWorkingCopyBare() {
switch ($this->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
return false;
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
$local = $this->getLocalPath();
if (Filesystem::pathExists($local.'/.git')) {
return false;
} else {
return true;
}
}
}
public function usesLocalWorkingCopy() {
switch ($this->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
return $this->isHosted();
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
return true;
}
}
public function getHookDirectories() {
$directories = array();
if (!$this->isHosted()) {
return $directories;
}
$root = $this->getLocalPath();
switch ($this->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
if ($this->isWorkingCopyBare()) {
$directories[] = $root.'/hooks/pre-receive-phabricator.d/';
} else {
$directories[] = $root.'/.git/hooks/pre-receive-phabricator.d/';
}
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$directories[] = $root.'/hooks/pre-commit-phabricator.d/';
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
// NOTE: We don't support custom Mercurial hooks for now because they're
// messy and we can't easily just drop a `hooks.d/` directory next to
// the hooks.
break;
}
return $directories;
}
public function canDestroyWorkingCopy() {
if ($this->isHosted()) {
// Never destroy hosted working copies.
return false;
}
$default_path = PhabricatorEnv::getEnvConfig(
'repository.default-local-path');
return Filesystem::isDescendant($this->getLocalPath(), $default_path);
}
public function canUsePathTree() {
return !$this->isSVN();
}
public function canUseGitLFS() {
if (!$this->isGit()) {
return false;
}
if (!$this->isHosted()) {
return false;
}
if (!PhabricatorEnv::getEnvConfig('diffusion.allow-git-lfs')) {
return false;
}
return true;
}
public function getGitLFSURI($path = null) {
if (!$this->canUseGitLFS()) {
throw new Exception(
pht(
'This repository does not support Git LFS, so Git LFS URIs can '.
'not be generated for it.'));
}
$uri = $this->getRawHTTPCloneURIObject();
$uri = (string)$uri;
$uri = $uri.'/'.$path;
return $uri;
}
public function canMirror() {
if ($this->isGit() || $this->isHg()) {
return true;
}
return false;
}
public function canAllowDangerousChanges() {
if (!$this->isHosted()) {
return false;
}
// In Git and Mercurial, ref deletions and rewrites are dangerous.
// In Subversion, editing revprops is dangerous.
return true;
}
public function shouldAllowDangerousChanges() {
return (bool)$this->getDetail('allow-dangerous-changes');
}
public function canAllowEnormousChanges() {
if (!$this->isHosted()) {
return false;
}
return true;
}
public function shouldAllowEnormousChanges() {
return (bool)$this->getDetail('allow-enormous-changes');
}
public function writeStatusMessage(
$status_type,
$status_code,
array $parameters = array()) {
$table = new PhabricatorRepositoryStatusMessage();
$conn_w = $table->establishConnection('w');
$table_name = $table->getTableName();
if ($status_code === null) {
queryfx(
$conn_w,
'DELETE FROM %T WHERE repositoryID = %d AND statusType = %s',
$table_name,
$this->getID(),
$status_type);
} else {
// If the existing message has the same code (e.g., we just hit an
// error and also previously hit an error) we increment the message
// count. This allows us to determine how many times in a row we've
// run into an error.
// NOTE: The assignments in "ON DUPLICATE KEY UPDATE" are evaluated
// in order, so the "messageCount" assignment must occur before the
// "statusCode" assignment. See T11705.
queryfx(
$conn_w,
'INSERT INTO %T
(repositoryID, statusType, statusCode, parameters, epoch,
messageCount)
VALUES (%d, %s, %s, %s, %d, %d)
ON DUPLICATE KEY UPDATE
messageCount =
IF(
statusCode = VALUES(statusCode),
messageCount + VALUES(messageCount),
VALUES(messageCount)),
statusCode = VALUES(statusCode),
parameters = VALUES(parameters),
epoch = VALUES(epoch)',
$table_name,
$this->getID(),
$status_type,
$status_code,
json_encode($parameters),
time(),
1);
}
return $this;
}
public static function assertValidRemoteURI($uri) {
if (trim($uri) != $uri) {
throw new Exception(
pht('The remote URI has leading or trailing whitespace.'));
}
$uri_object = new PhutilURI($uri);
$protocol = $uri_object->getProtocol();
// Catch confusion between Git/SCP-style URIs and normal URIs. See T3619
// for discussion. This is usually a user adding "ssh://" to an implicit
// SSH Git URI.
if ($protocol == 'ssh') {
if (preg_match('(^[^:@]+://[^/:]+:[^\d])', $uri)) {
throw new Exception(
pht(
"The remote URI is not formatted correctly. Remote URIs ".
"with an explicit protocol should be in the form ".
"'%s', not '%s'. The '%s' syntax is only valid in SCP-style URIs.",
'proto://domain/path',
'proto://domain:/path',
':/path'));
}
}
switch ($protocol) {
case 'ssh':
case 'http':
case 'https':
case 'git':
case 'svn':
case 'svn+ssh':
break;
default:
// NOTE: We're explicitly rejecting 'file://' because it can be
// used to clone from the working copy of another repository on disk
// that you don't normally have permission to access.
throw new Exception(
pht(
'The URI protocol is unrecognized. It should begin with '.
'"%s", "%s", "%s", "%s", "%s", "%s", or be in the form "%s".',
'ssh://',
'http://',
'https://',
'git://',
'svn://',
'svn+ssh://',
'git@domain.com:path'));
}
return true;
}
/**
* Load the pull frequency for this repository, based on the time since the
* last activity.
*
* We pull rarely used repositories less frequently. This finds the most
* recent commit which is older than the current time (which prevents us from
* spinning on repositories with a silly commit post-dated to some time in
* 2037). We adjust the pull frequency based on when the most recent commit
* occurred.
*
- * @param int? $minimum The minimum update interval to use, in seconds.
+ * @param int $minimum (optional) The minimum update interval to use, in
+ * seconds.
* @return int Repository update interval, in seconds.
*/
public function loadUpdateInterval($minimum = 15) {
// First, check if we've hit errors recently. If we have, wait one period
// for each consecutive error. Normally, this corresponds to a backoff of
// 15s, 30s, 45s, etc.
$message_table = new PhabricatorRepositoryStatusMessage();
$conn = $message_table->establishConnection('r');
$error_count = queryfx_one(
$conn,
'SELECT MAX(messageCount) error_count FROM %T
WHERE repositoryID = %d
AND statusType IN (%Ls)
AND statusCode IN (%Ls)',
$message_table->getTableName(),
$this->getID(),
array(
PhabricatorRepositoryStatusMessage::TYPE_INIT,
PhabricatorRepositoryStatusMessage::TYPE_FETCH,
),
array(
PhabricatorRepositoryStatusMessage::CODE_ERROR,
));
$error_count = (int)$error_count['error_count'];
if ($error_count > 0) {
return (int)($minimum * $error_count);
}
// If a repository is still importing, always pull it as frequently as
// possible. This prevents us from hanging for a long time at 99.9% when
// importing an inactive repository.
if ($this->isImporting()) {
return $minimum;
}
$window_start = (PhabricatorTime::getNow() + $minimum);
$table = id(new PhabricatorRepositoryCommit());
$last_commit = queryfx_one(
$table->establishConnection('r'),
'SELECT epoch FROM %T
WHERE repositoryID = %d AND epoch <= %d
ORDER BY epoch DESC LIMIT 1',
$table->getTableName(),
$this->getID(),
$window_start);
if ($last_commit) {
$time_since_commit = ($window_start - $last_commit['epoch']);
} else {
// If the repository has no commits, treat the creation date as
// though it were the date of the last commit. This makes empty
// repositories update quickly at first but slow down over time
// if they don't see any activity.
$time_since_commit = ($window_start - $this->getDateCreated());
}
$last_few_days = phutil_units('3 days in seconds');
if ($time_since_commit <= $last_few_days) {
// For repositories with activity in the recent past, we wait one
// extra second for every 10 minutes since the last commit. This
// shorter backoff is intended to handle weekends and other short
// breaks from development.
$smart_wait = ($time_since_commit / 600);
} else {
// For repositories without recent activity, we wait one extra second
// for every 4 minutes since the last commit. This longer backoff
// handles rarely used repositories, up to the maximum.
$smart_wait = ($time_since_commit / 240);
}
// We'll never wait more than 6 hours to pull a repository.
$longest_wait = phutil_units('6 hours in seconds');
$smart_wait = min($smart_wait, $longest_wait);
$smart_wait = max($minimum, $smart_wait);
return (int)$smart_wait;
}
/**
* Time limit for cloning or copying this repository.
*
* This limit is used to timeout operations like `git clone` or `git fetch`
* when doing intracluster synchronization, building working copies, etc.
*
* @return int Maximum number of seconds to spend copying this repository.
*/
public function getCopyTimeLimit() {
return $this->getDetail('limit.copy');
}
public function setCopyTimeLimit($limit) {
return $this->setDetail('limit.copy', $limit);
}
public function getDefaultCopyTimeLimit() {
return phutil_units('15 minutes in seconds');
}
public function getEffectiveCopyTimeLimit() {
$limit = $this->getCopyTimeLimit();
if ($limit) {
return $limit;
}
return $this->getDefaultCopyTimeLimit();
}
public function getFilesizeLimit() {
return $this->getDetail('limit.filesize');
}
public function setFilesizeLimit($limit) {
return $this->setDetail('limit.filesize', $limit);
}
public function getTouchLimit() {
return $this->getDetail('limit.touch');
}
public function setTouchLimit($limit) {
return $this->setDetail('limit.touch', $limit);
}
/**
* Retrieve the service URI for the device hosting this repository.
*
* See @{method:newConduitClient} for a general discussion of interacting
* with repository services. This method provides lower-level resolution of
* services, returning raw URIs.
*
* @param PhabricatorUser $viewer Viewing user.
* @param map<string, wild> $options Constraints on selectable services.
* @return string|null URI, or `null` for local repositories.
*/
public function getAlmanacServiceURI(
PhabricatorUser $viewer,
array $options) {
$refs = $this->getAlmanacServiceRefs($viewer, $options);
if (!$refs) {
return null;
}
$ref = head($refs);
return $ref->getURI();
}
public function getAlmanacServiceRefs(
PhabricatorUser $viewer,
array $options) {
PhutilTypeSpec::checkMap(
$options,
array(
'neverProxy' => 'bool',
'protocols' => 'list<string>',
'writable' => 'optional bool',
));
$never_proxy = $options['neverProxy'];
$protocols = $options['protocols'];
$writable = idx($options, 'writable', false);
$cache_key = $this->getAlmanacServiceCacheKey();
if (!$cache_key) {
return array();
}
$cache = PhabricatorCaches::getMutableStructureCache();
$uris = $cache->getKey($cache_key, false);
// If we haven't built the cache yet, build it now.
if ($uris === false) {
$uris = $this->buildAlmanacServiceURIs();
$cache->setKey($cache_key, $uris);
}
if ($uris === null) {
return array();
}
$local_device = AlmanacKeys::getDeviceID();
if ($never_proxy && !$local_device) {
throw new Exception(
pht(
'Unable to handle proxied service request. This device is not '.
'registered, so it can not identify local services. Register '.
'this device before sending requests here.'));
}
$protocol_map = array_fuse($protocols);
$results = array();
foreach ($uris as $uri) {
// If we're never proxying this and it's locally satisfiable, return
// `null` to tell the caller to handle it locally. If we're allowed to
// proxy, we skip this check and may proxy the request to ourselves.
// (That proxied request will end up here with proxying forbidden,
// return `null`, and then the request will actually run.)
if ($local_device && $never_proxy) {
if ($uri['device'] == $local_device) {
return array();
}
}
if (isset($protocol_map[$uri['protocol']])) {
$results[] = $uri;
}
}
if (!$results) {
throw new Exception(
pht(
'The Almanac service for this repository is not bound to any '.
'interfaces which support the required protocols (%s).',
implode(', ', $protocols)));
}
if ($never_proxy) {
// See PHI1030. This error can arise from various device name/address
// mismatches which are hard to detect, so try to provide as much
// information as we can.
if ($writable) {
$request_type = pht('(This is a write request.)');
} else {
$request_type = pht('(This is a read request.)');
}
throw new Exception(
pht(
'This repository request (for repository "%s") has been '.
'incorrectly routed to a cluster host (with device name "%s", '.
'and hostname "%s") which can not serve the request.'.
"\n\n".
'The Almanac device address for the correct device may improperly '.
'point at this host, or the "device.id" configuration file on '.
'this host may be incorrect.'.
"\n\n".
'Requests routed within the cluster are always '.
'expected to be sent to a node which can serve the request. To '.
'prevent loops, this request will not be proxied again.'.
"\n\n".
"%s",
$this->getDisplayName(),
$local_device,
php_uname('n'),
$request_type));
}
if (count($results) > 1) {
if (!$this->supportsSynchronization()) {
throw new Exception(
pht(
'Repository "%s" is bound to multiple active repository hosts, '.
'but this repository does not support cluster synchronization. '.
'Declusterize this repository or move it to a service with only '.
'one host.',
$this->getDisplayName()));
}
}
$refs = array();
foreach ($results as $result) {
$refs[] = DiffusionServiceRef::newFromDictionary($result);
}
// If we require a writable device, remove URIs which aren't writable.
if ($writable) {
foreach ($refs as $key => $ref) {
if (!$ref->isWritable()) {
unset($refs[$key]);
}
}
if (!$refs) {
throw new Exception(
pht(
'This repository ("%s") is not writable with the given '.
'protocols (%s). The Almanac service for this repository has no '.
'writable bindings that support these protocols.',
$this->getDisplayName(),
implode(', ', $protocols)));
}
}
if ($writable) {
$refs = $this->sortWritableAlmanacServiceRefs($refs);
} else {
$refs = $this->sortReadableAlmanacServiceRefs($refs);
}
return array_values($refs);
}
private function sortReadableAlmanacServiceRefs(array $refs) {
assert_instances_of($refs, 'DiffusionServiceRef');
shuffle($refs);
return $refs;
}
private function sortWritableAlmanacServiceRefs(array $refs) {
assert_instances_of($refs, 'DiffusionServiceRef');
// See T13109 for discussion of how this method routes requests.
// In the absence of other rules, we'll send traffic to devices randomly.
// We also want to select randomly among nodes which are equally good
// candidates to receive the write, and accomplish that by shuffling the
// list up front.
shuffle($refs);
$order = array();
// If some device is currently holding the write lock, send all requests
// to that device. We're trying to queue writes on a single device so they
// do not need to wait for read synchronization after earlier writes
// complete.
$writer = PhabricatorRepositoryWorkingCopyVersion::loadWriter(
$this->getPHID());
if ($writer) {
$device_phid = $writer->getWriteProperty('devicePHID');
foreach ($refs as $key => $ref) {
if ($ref->getDevicePHID() === $device_phid) {
$order[] = $key;
}
}
}
// If no device is currently holding the write lock, try to send requests
// to a device which is already up to date and will not need to synchronize
// before it can accept the write.
$versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions(
$this->getPHID());
if ($versions) {
$max_version = (int)max(mpull($versions, 'getRepositoryVersion'));
$max_devices = array();
foreach ($versions as $version) {
if ($version->getRepositoryVersion() == $max_version) {
$max_devices[] = $version->getDevicePHID();
}
}
$max_devices = array_fuse($max_devices);
foreach ($refs as $key => $ref) {
if (isset($max_devices[$ref->getDevicePHID()])) {
$order[] = $key;
}
}
}
// Reorder the results, putting any we've selected as preferred targets for
// the write at the head of the list.
$refs = array_select_keys($refs, $order) + $refs;
return $refs;
}
public function supportsSynchronization() {
// TODO: For now, this is only supported for Git.
if (!$this->isGit()) {
return false;
}
return true;
}
public function supportsRefs() {
if ($this->isSVN()) {
return false;
}
return true;
}
public function getAlmanacServiceCacheKey() {
$service_phid = $this->getAlmanacServicePHID();
if (!$service_phid) {
return null;
}
$repository_phid = $this->getPHID();
$parts = array(
"repo({$repository_phid})",
"serv({$service_phid})",
'v4',
);
return implode('.', $parts);
}
private function buildAlmanacServiceURIs() {
$service = $this->loadAlmanacService();
if (!$service) {
return null;
}
$bindings = $service->getActiveBindings();
if (!$bindings) {
throw new Exception(
pht(
'The Almanac service for this repository is not bound to any '.
'active interfaces.'));
}
$uris = array();
foreach ($bindings as $binding) {
$iface = $binding->getInterface();
$uri = $this->getClusterRepositoryURIFromBinding($binding);
$protocol = $uri->getProtocol();
$device_name = $iface->getDevice()->getName();
$device_phid = $iface->getDevice()->getPHID();
$uris[] = array(
'protocol' => $protocol,
'uri' => (string)$uri,
'device' => $device_name,
'writable' => (bool)$binding->getAlmanacPropertyValue('writable'),
'devicePHID' => $device_phid,
);
}
return $uris;
}
/**
* Build a new Conduit client in order to make a service call to this
* repository.
*
* If the repository is hosted locally, this method may return `null`. The
* caller should use `ConduitCall` or other local logic to complete the
* request.
*
* By default, we will return a @{class:ConduitClient} for any repository with
* a service, even if that service is on the current device.
*
* We do this because this configuration does not make very much sense in a
* production context, but is very common in a test/development context
* (where the developer's machine is both the web host and the repository
* service). By proxying in development, we get more consistent behavior
* between development and production, and don't have a major untested
* codepath.
*
* The `$never_proxy` parameter can be used to prevent this local proxying.
* If the flag is passed:
*
* - The method will return `null` (implying a local service call)
* if the repository service is hosted on the current device.
* - The method will throw if it would need to return a client.
*
* This is used to prevent loops in Conduit: the first request will proxy,
* even in development, but the second request will be identified as a
* cluster request and forced not to proxy.
*
* For lower-level service resolution, see @{method:getAlmanacServiceURI}.
*
* @param PhabricatorUser $viewer Viewing user.
- * @param bool? $never_proxy `true` to throw if a client would be returned.
+ * @param bool $never_proxy (optional) `true` to throw if a client would be
+ * returned.
* @return ConduitClient|null Client, or `null` for local repositories.
*/
public function newConduitClient(
PhabricatorUser $viewer,
$never_proxy = false) {
$uri = $this->getAlmanacServiceURI(
$viewer,
array(
'neverProxy' => $never_proxy,
'protocols' => array(
'http',
'https',
),
// At least today, no Conduit call can ever write to a repository,
// so it's fine to send anything to a read-only node.
'writable' => false,
));
if ($uri === null) {
return null;
}
$domain = id(new PhutilURI(PhabricatorEnv::getURI('/')))->getDomain();
$client = id(new ConduitClient($uri))
->setHost($domain);
if ($viewer->isOmnipotent()) {
// If the caller is the omnipotent user (normally, a daemon), we will
// sign the request with this host's asymmetric keypair.
$public_path = AlmanacKeys::getKeyPath('device.pub');
try {
$public_key = Filesystem::readFile($public_path);
} catch (Exception $ex) {
throw new PhutilAggregateException(
pht(
'Unable to read device public key while attempting to make '.
'authenticated method call within the cluster. '.
'Use `%s` to register keys for this device. Exception: %s',
'bin/almanac register',
$ex->getMessage()),
array($ex));
}
$private_path = AlmanacKeys::getKeyPath('device.key');
try {
$private_key = Filesystem::readFile($private_path);
$private_key = new PhutilOpaqueEnvelope($private_key);
} catch (Exception $ex) {
throw new PhutilAggregateException(
pht(
'Unable to read device private key while attempting to make '.
'authenticated method call within the cluster. '.
'Use `%s` to register keys for this device. Exception: %s',
'bin/almanac register',
$ex->getMessage()),
array($ex));
}
$client->setSigningKeys($public_key, $private_key);
} else {
// If the caller is a normal user, we generate or retrieve a cluster
// API token.
$token = PhabricatorConduitToken::loadClusterTokenForUser($viewer);
if ($token) {
$client->setConduitToken($token->getToken());
}
}
return $client;
}
public function newConduitClientForRequest(ConduitAPIRequest $request) {
// Figure out whether we're going to handle this request on this device,
// or proxy it to another node in the cluster.
// If this is a cluster request and we need to proxy, we'll explode here
// to prevent infinite recursion.
$viewer = $request->getViewer();
$is_cluster_request = $request->getIsClusterRequest();
$client = $this->newConduitClient(
$viewer,
$is_cluster_request);
return $client;
}
public function newConduitFuture(
PhabricatorUser $viewer,
$method,
array $params,
$never_proxy = false) {
$client = $this->newConduitClient(
$viewer,
$never_proxy);
if (!$client) {
$conduit_call = id(new ConduitCall($method, $params))
->setUser($viewer);
$future = new MethodCallFuture($conduit_call, 'execute');
} else {
$future = $client->callMethod($method, $params);
}
return $future;
}
public function getPassthroughEnvironmentalVariables() {
$env = $_ENV;
if ($this->isGit()) {
// $_ENV does not populate in CLI contexts if "E" is missing from
// "variables_order" in PHP config. Currently, we do not require this
// to be configured. Since it may not be, explicitly bring expected Git
// environmental variables into scope. This list is not exhaustive, but
// only lists variables with a known impact on commit hook behavior.
// This can be removed if we later require "E" in "variables_order".
$git_env = array(
'GIT_OBJECT_DIRECTORY',
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
'GIT_QUARANTINE_PATH',
);
foreach ($git_env as $key) {
$value = getenv($key);
if (strlen($value)) {
$env[$key] = $value;
}
}
$key = 'GIT_PUSH_OPTION_COUNT';
$git_count = getenv($key);
if (strlen($git_count)) {
$git_count = (int)$git_count;
$env[$key] = $git_count;
for ($ii = 0; $ii < $git_count; $ii++) {
$key = 'GIT_PUSH_OPTION_'.$ii;
$env[$key] = getenv($key);
}
}
}
$result = array();
foreach ($env as $key => $value) {
// In Git, pass anything matching "GIT_*" though. Some of these variables
// need to be preserved to allow `git` operations to work properly when
// running from commit hooks.
if ($this->isGit()) {
if (preg_match('/^GIT_/', $key)) {
$result[$key] = $value;
}
}
}
return $result;
}
public function supportsBranchComparison() {
return $this->isGit();
}
public function isReadOnly() {
return (bool)$this->getDetail('read-only');
}
public function setReadOnly($read_only) {
return $this->setDetail('read-only', $read_only);
}
public function getReadOnlyMessage() {
return $this->getDetail('read-only-message');
}
public function setReadOnlyMessage($message) {
return $this->setDetail('read-only-message', $message);
}
public function getReadOnlyMessageForDisplay() {
$parts = array();
$parts[] = pht(
'This repository is currently in read-only maintenance mode.');
$message = $this->getReadOnlyMessage();
if ($message !== null) {
$parts[] = $message;
}
return implode("\n\n", $parts);
}
/* -( Repository URIs )---------------------------------------------------- */
public function attachURIs(array $uris) {
$custom_map = array();
foreach ($uris as $key => $uri) {
$builtin_key = $uri->getRepositoryURIBuiltinKey();
if ($builtin_key !== null) {
$custom_map[$builtin_key] = $key;
}
}
$builtin_uris = $this->newBuiltinURIs();
$seen_builtins = array();
foreach ($builtin_uris as $builtin_uri) {
$builtin_key = $builtin_uri->getRepositoryURIBuiltinKey();
$seen_builtins[$builtin_key] = true;
// If this builtin URI is disabled, don't attach it and remove the
// persisted version if it exists.
if ($builtin_uri->getIsDisabled()) {
if (isset($custom_map[$builtin_key])) {
unset($uris[$custom_map[$builtin_key]]);
}
continue;
}
// If the URI exists, make sure it's marked as not being disabled.
if (isset($custom_map[$builtin_key])) {
$uris[$custom_map[$builtin_key]]->setIsDisabled(false);
}
}
// Remove any builtins which no longer exist.
foreach ($custom_map as $builtin_key => $key) {
if (empty($seen_builtins[$builtin_key])) {
unset($uris[$key]);
}
}
$this->uris = $uris;
return $this;
}
public function getURIs() {
return $this->assertAttached($this->uris);
}
public function getCloneURIs() {
$uris = $this->getURIs();
$clone = array();
foreach ($uris as $uri) {
if (!$uri->isBuiltin()) {
continue;
}
if ($uri->getIsDisabled()) {
continue;
}
$io_type = $uri->getEffectiveIoType();
$is_clone =
($io_type == PhabricatorRepositoryURI::IO_READ) ||
($io_type == PhabricatorRepositoryURI::IO_READWRITE);
if (!$is_clone) {
continue;
}
$clone[] = $uri;
}
$clone = msort($clone, 'getURIScore');
$clone = array_reverse($clone);
return $clone;
}
public function newBuiltinURIs() {
$has_callsign = ($this->getCallsign() !== null);
$has_shortname = ($this->getRepositorySlug() !== null);
$identifier_map = array(
PhabricatorRepositoryURI::BUILTIN_IDENTIFIER_CALLSIGN => $has_callsign,
PhabricatorRepositoryURI::BUILTIN_IDENTIFIER_SHORTNAME => $has_shortname,
PhabricatorRepositoryURI::BUILTIN_IDENTIFIER_ID => true,
);
// If the view policy of the repository is public, support anonymous HTTP
// even if authenticated HTTP is not supported.
if ($this->getViewPolicy() === PhabricatorPolicies::POLICY_PUBLIC) {
$allow_http = true;
} else {
$allow_http = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth');
}
$base_uri = PhabricatorEnv::getURI('/');
$base_uri = new PhutilURI($base_uri);
$has_https = ($base_uri->getProtocol() == 'https');
$has_https = ($has_https && $allow_http);
$has_http = !PhabricatorEnv::getEnvConfig('security.require-https');
$has_http = ($has_http && $allow_http);
// HTTP is not supported for Subversion.
if ($this->isSVN()) {
$has_http = false;
$has_https = false;
}
$phd_user = PhabricatorEnv::getEnvConfig('phd.user');
$has_ssh = phutil_nonempty_string($phd_user);
$protocol_map = array(
PhabricatorRepositoryURI::BUILTIN_PROTOCOL_SSH => $has_ssh,
PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTPS => $has_https,
PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTP => $has_http,
);
$uris = array();
foreach ($protocol_map as $protocol => $proto_supported) {
foreach ($identifier_map as $identifier => $id_supported) {
// This is just a dummy value because it can't be empty; we'll force
// it to a proper value when using it in the UI.
$builtin_uri = "{$protocol}://{$identifier}";
$uris[] = PhabricatorRepositoryURI::initializeNewURI()
->setRepositoryPHID($this->getPHID())
->attachRepository($this)
->setBuiltinProtocol($protocol)
->setBuiltinIdentifier($identifier)
->setURI($builtin_uri)
->setIsDisabled((int)(!$proto_supported || !$id_supported));
}
}
return $uris;
}
public function getClusterRepositoryURIFromBinding(
AlmanacBinding $binding) {
$protocol = $binding->getAlmanacPropertyValue('protocol');
if ($protocol === null) {
$protocol = 'https';
}
$iface = $binding->getInterface();
$address = $iface->renderDisplayAddress();
$path = $this->getURI();
return id(new PhutilURI("{$protocol}://{$address}"))
->setPath($path);
}
public function loadAlmanacService() {
$service_phid = $this->getAlmanacServicePHID();
if (!$service_phid) {
// No service, so this is a local repository.
return null;
}
$service = id(new AlmanacServiceQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs(array($service_phid))
->needActiveBindings(true)
->needProperties(true)
->executeOne();
if (!$service) {
throw new Exception(
pht(
'The Almanac service for this repository is invalid or could not '.
'be loaded.'));
}
$service_type = $service->getServiceImplementation();
if (!($service_type instanceof AlmanacClusterRepositoryServiceType)) {
throw new Exception(
pht(
'The Almanac service for this repository does not have the correct '.
'service type.'));
}
return $service;
}
public function markImporting() {
$this->openTransaction();
$this->beginReadLocking();
$repository = $this->reload();
$repository->setDetail('importing', true);
$repository->save();
$this->endReadLocking();
$this->saveTransaction();
return $repository;
}
/* -( Symbols )-------------------------------------------------------------*/
public function getSymbolSources() {
return $this->getDetail('symbol-sources', array());
}
public function getSymbolLanguages() {
return $this->getDetail('symbol-languages', array());
}
/* -( Staging )------------------------------------------------------------ */
public function supportsStaging() {
return $this->isGit();
}
public function getStagingURI() {
if (!$this->supportsStaging()) {
return null;
}
return $this->getDetail('staging-uri', null);
}
/* -( Automation )--------------------------------------------------------- */
public function supportsAutomation() {
return $this->isGit();
}
public function canPerformAutomation() {
if (!$this->supportsAutomation()) {
return false;
}
if (!$this->getAutomationBlueprintPHIDs()) {
return false;
}
return true;
}
public function getAutomationBlueprintPHIDs() {
if (!$this->supportsAutomation()) {
return array();
}
return $this->getDetail('automation.blueprintPHIDs', array());
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorRepositoryEditor();
}
public function getApplicationTransactionTemplate() {
return new PhabricatorRepositoryTransaction();
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
DiffusionPushCapability::CAPABILITY,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return $this->getViewPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return $this->getEditPolicy();
case DiffusionPushCapability::CAPABILITY:
return $this->getPushPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $user) {
return false;
}
/* -( PhabricatorMarkupInterface )----------------------------------------- */
public function getMarkupFieldKey($field) {
$hash = PhabricatorHash::digestForIndex($this->getMarkupText($field));
return "repo:{$hash}";
}
public function newMarkupEngine($field) {
return PhabricatorMarkupEngine::newMarkupEngine(array());
}
public function getMarkupText($field) {
return $this->getDetail('description');
}
public function didMarkupText(
$field,
$output,
PhutilMarkupEngine $engine) {
require_celerity_resource('phabricator-remarkup-css');
return phutil_tag(
'div',
array(
'class' => 'phabricator-remarkup',
),
$output);
}
public function shouldUseMarkupCache($field) {
return true;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$phid = $this->getPHID();
$this->openTransaction();
$this->delete();
PhabricatorRepositoryURIIndex::updateRepositoryURIs($phid, array());
$books = id(new DivinerBookQuery())
->setViewer($engine->getViewer())
->withRepositoryPHIDs(array($phid))
->execute();
foreach ($books as $book) {
$engine->destroyObject($book);
}
$atoms = id(new DivinerAtomQuery())
->setViewer($engine->getViewer())
->withRepositoryPHIDs(array($phid))
->execute();
foreach ($atoms as $atom) {
$engine->destroyObject($atom);
}
$lfs_refs = id(new PhabricatorRepositoryGitLFSRefQuery())
->setViewer($engine->getViewer())
->withRepositoryPHIDs(array($phid))
->execute();
foreach ($lfs_refs as $ref) {
$engine->destroyObject($ref);
}
$this->saveTransaction();
}
/* -( PhabricatorDestructibleCodexInterface )------------------------------ */
public function newDestructibleCodex() {
return new PhabricatorRepositoryDestructibleCodex();
}
/* -( PhabricatorSpacesInterface )----------------------------------------- */
public function getSpacePHID() {
return $this->spacePHID;
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('name')
->setType('string')
->setDescription(pht('The repository name.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('vcs')
->setType('string')
->setDescription(
pht('The VCS this repository uses ("git", "hg" or "svn").')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('callsign')
->setType('string')
->setDescription(pht('The repository callsign, if it has one.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('shortName')
->setType('string')
->setDescription(pht('Unique short name, if the repository has one.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('status')
->setType('string')
->setDescription(pht('Active or inactive status.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('isImporting')
->setType('bool')
->setDescription(
pht(
'True if the repository is importing initial commits.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('almanacServicePHID')
->setType('phid?')
->setDescription(
pht(
'The Almanac Service that hosts this repository, if the '.
'repository is clustered.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('refRules')
->setType('map<string, list<string>>')
->setDescription(
pht(
'The "Fetch" and "Permanent Ref" rules for this repository.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('defaultBranch')
->setType('string?')
->setDescription(pht('Default branch name.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('description')
->setType('remarkup')
->setDescription(pht('Repository description.')),
);
}
public function getFieldValuesForConduit() {
$fetch_rules = $this->getFetchRules();
$track_rules = $this->getTrackOnlyRules();
$permanent_rules = $this->getPermanentRefRules();
$fetch_rules = $this->getStringListForConduit($fetch_rules);
$track_rules = $this->getStringListForConduit($track_rules);
$permanent_rules = $this->getStringListForConduit($permanent_rules);
$default_branch = $this->getDefaultBranch();
if (!strlen($default_branch)) {
$default_branch = null;
}
return array(
'name' => $this->getName(),
'vcs' => $this->getVersionControlSystem(),
'callsign' => $this->getCallsign(),
'shortName' => $this->getRepositorySlug(),
'status' => $this->getStatus(),
'isImporting' => (bool)$this->isImporting(),
'almanacServicePHID' => $this->getAlmanacServicePHID(),
'refRules' => array(
'fetchRules' => $fetch_rules,
'trackRules' => $track_rules,
'permanentRefRules' => $permanent_rules,
),
'defaultBranch' => $default_branch,
'description' => array(
'raw' => (string)$this->getDetail('description'),
),
);
}
private function getStringListForConduit($list) {
if (!is_array($list)) {
$list = array();
}
foreach ($list as $key => $value) {
$value = (string)$value;
if (!strlen($value)) {
unset($list[$key]);
}
}
return array_values($list);
}
public function getConduitSearchAttachments() {
return array(
id(new DiffusionRepositoryURIsSearchEngineAttachment())
->setAttachmentKey('uris'),
id(new DiffusionRepositoryMetricsSearchEngineAttachment())
->setAttachmentKey('metrics'),
);
}
/* -( PhabricatorFulltextInterface )--------------------------------------- */
public function newFulltextEngine() {
return new PhabricatorRepositoryFulltextEngine();
}
/* -( PhabricatorFerretInterface )----------------------------------------- */
public function newFerretEngine() {
return new PhabricatorRepositoryFerretEngine();
}
}
diff --git a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
index 1979ec092a..2458a1ccd6 100644
--- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
+++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
@@ -1,1672 +1,1672 @@
<?php
/**
* Represents an abstract search engine for an application. It supports
* creating and storing saved queries.
*
* @task construct Constructing Engines
* @task app Applications
* @task builtin Builtin Queries
* @task uri Query URIs
* @task dates Date Filters
* @task order Result Ordering
* @task read Reading Utilities
* @task exec Paging and Executing Queries
* @task render Rendering Results
* @task custom Custom Fields
*/
abstract class PhabricatorApplicationSearchEngine extends Phobject {
private $application;
private $viewer;
private $errors = array();
private $request;
private $context;
private $controller;
private $namedQueries;
private $navigationItems = array();
const CONTEXT_LIST = 'list';
const CONTEXT_PANEL = 'panel';
const BUCKET_NONE = 'none';
public function setController(PhabricatorController $controller) {
$this->controller = $controller;
return $this;
}
public function getController() {
return $this->controller;
}
public function buildResponse() {
$controller = $this->getController();
$request = $controller->getRequest();
$search = id(new PhabricatorApplicationSearchController())
->setQueryKey($request->getURIData('queryKey'))
->setSearchEngine($this);
return $controller->delegateToController($search);
}
public function newResultObject() {
// We may be able to get this automatically if newQuery() is implemented.
$query = $this->newQuery();
if ($query) {
$object = $query->newResultObject();
if ($object) {
return $object;
}
}
return null;
}
public function newQuery() {
return null;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
protected function requireViewer() {
if (!$this->viewer) {
throw new PhutilInvalidStateException('setViewer');
}
return $this->viewer;
}
public function setContext($context) {
$this->context = $context;
return $this;
}
public function isPanelContext() {
return ($this->context == self::CONTEXT_PANEL);
}
public function setNavigationItems(array $navigation_items) {
assert_instances_of($navigation_items, 'PHUIListItemView');
$this->navigationItems = $navigation_items;
return $this;
}
public function getNavigationItems() {
return $this->navigationItems;
}
public function canUseInPanelContext() {
return true;
}
public function saveQuery(PhabricatorSavedQuery $query) {
if ($query->getID()) {
throw new Exception(
pht(
'Query (with ID "%s") has already been saved. Queries are '.
'immutable once saved.',
$query->getID()));
}
$query->setEngineClassName(get_class($this));
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
try {
$query->save();
} catch (AphrontDuplicateKeyQueryException $ex) {
// Ignore, this is just a repeated search.
}
unset($unguarded);
}
/**
* Create a saved query object from the request.
*
* @param AphrontRequest $request The search request.
* @return PhabricatorSavedQuery
*/
public function buildSavedQueryFromRequest(AphrontRequest $request) {
$fields = $this->buildSearchFields();
$viewer = $this->requireViewer();
$saved = new PhabricatorSavedQuery();
foreach ($fields as $field) {
$field->setViewer($viewer);
$value = $field->readValueFromRequest($request);
$saved->setParameter($field->getKey(), $value);
}
return $saved;
}
/**
* Executes the saved query.
*
* @param PhabricatorSavedQuery $original The saved query to operate on.
* @return PhabricatorQuery The result of the query.
*/
public function buildQueryFromSavedQuery(PhabricatorSavedQuery $original) {
$saved = clone $original;
$this->willUseSavedQuery($saved);
$fields = $this->buildSearchFields();
$viewer = $this->requireViewer();
$map = array();
foreach ($fields as $field) {
$field->setViewer($viewer);
$field->readValueFromSavedQuery($saved);
$value = $field->getValueForQuery($field->getValue());
$map[$field->getKey()] = $value;
}
$original->attachParameterMap($map);
$query = $this->buildQueryFromParameters($map);
$object = $this->newResultObject();
if (!$object) {
return $query;
}
$extensions = $this->getEngineExtensions();
foreach ($extensions as $extension) {
$extension->applyConstraintsToQuery($object, $query, $saved, $map);
}
$order = $saved->getParameter('order');
$builtin = $query->getBuiltinOrderAliasMap();
if (phutil_nonempty_string($order) && isset($builtin[$order])) {
$query->setOrder($order);
} else {
// If the order is invalid or not available, we choose the first
// builtin order. This isn't always the default order for the query,
// but is the first value in the "Order" dropdown, and makes the query
// behavior more consistent with the UI. In queries where the two
// orders differ, this order is the preferred order for humans.
$query->setOrder(head_key($builtin));
}
return $query;
}
/**
* Hook for subclasses to adjust saved queries prior to use.
*
* If an application changes how queries are saved, it can implement this
* hook to keep old queries working the way users expect, by reading,
* adjusting, and overwriting parameters.
*
* @param PhabricatorSavedQuery $saved Saved query which will be executed.
* @return void
*/
protected function willUseSavedQuery(PhabricatorSavedQuery $saved) {
return;
}
protected function buildQueryFromParameters(array $parameters) {
throw new PhutilMethodNotImplementedException();
}
/**
* Builds the search form using the request.
*
* @param AphrontFormView $form Form to populate.
* @param PhabricatorSavedQuery $saved Query from which to build the form.
* @return void
*/
public function buildSearchForm(
AphrontFormView $form,
PhabricatorSavedQuery $saved) {
$saved = clone $saved;
$this->willUseSavedQuery($saved);
$fields = $this->buildSearchFields();
$fields = $this->adjustFieldsForDisplay($fields);
$viewer = $this->requireViewer();
foreach ($fields as $field) {
$field->setViewer($viewer);
$field->readValueFromSavedQuery($saved);
}
foreach ($fields as $field) {
foreach ($field->getErrors() as $error) {
$this->addError(last($error));
}
}
foreach ($fields as $field) {
$field->appendToForm($form);
}
}
protected function buildSearchFields() {
$fields = array();
foreach ($this->buildCustomSearchFields() as $field) {
$fields[] = $field;
}
$object = $this->newResultObject();
if ($object) {
$extensions = $this->getEngineExtensions();
foreach ($extensions as $extension) {
$extension_fields = $extension->getSearchFields($object);
foreach ($extension_fields as $extension_field) {
$fields[] = $extension_field;
}
}
}
$query = $this->newQuery();
if ($query && $this->shouldShowOrderField()) {
$orders = $query->getBuiltinOrders();
$orders = ipull($orders, 'name');
$fields[] = id(new PhabricatorSearchOrderField())
->setLabel(pht('Order By'))
->setKey('order')
->setOrderAliases($query->getBuiltinOrderAliasMap())
->setOptions($orders);
}
if (id(new PhabricatorAuditApplication())->isInstalled()) {
$buckets = $this->newResultBuckets();
if ($query && $buckets) {
$bucket_options = array(
self::BUCKET_NONE => pht('No Bucketing'),
) + mpull($buckets, 'getResultBucketName');
$fields[] = id(new PhabricatorSearchSelectField())
->setLabel(pht('Bucket'))
->setKey('bucket')
->setOptions($bucket_options);
}
}
$field_map = array();
foreach ($fields as $field) {
$key = $field->getKey();
if (isset($field_map[$key])) {
throw new Exception(
pht(
'Two fields in this SearchEngine use the same key ("%s"), but '.
'each field must use a unique key.',
$key));
}
$field_map[$key] = $field;
}
return $field_map;
}
protected function shouldShowOrderField() {
return true;
}
private function adjustFieldsForDisplay(array $field_map) {
$order = $this->getDefaultFieldOrder();
$head_keys = array();
$tail_keys = array();
$seen_tail = false;
foreach ($order as $order_key) {
if ($order_key === '...') {
$seen_tail = true;
continue;
}
if (!$seen_tail) {
$head_keys[] = $order_key;
} else {
$tail_keys[] = $order_key;
}
}
$head = array_select_keys($field_map, $head_keys);
$body = array_diff_key($field_map, array_fuse($tail_keys));
$tail = array_select_keys($field_map, $tail_keys);
$result = $head + $body + $tail;
// Force the fulltext "query" field to the top unconditionally.
$result = array_select_keys($result, array('query')) + $result;
foreach ($this->getHiddenFields() as $hidden_key) {
unset($result[$hidden_key]);
}
return $result;
}
protected function buildCustomSearchFields() {
throw new PhutilMethodNotImplementedException();
}
/**
* Define the default display order for fields by returning a list of
* field keys.
*
* You can use the special key `...` to mean "all unspecified fields go
* here". This lets you easily put important fields at the top of the form,
* standard fields in the middle of the form, and less important fields at
* the bottom.
*
* For example, you might return a list like this:
*
* return array(
* 'authorPHIDs',
* 'reviewerPHIDs',
* '...',
* 'createdAfter',
* 'createdBefore',
* );
*
* Any unspecified fields (including custom fields and fields added
* automatically by infrastructure) will be put in the middle.
*
* @return list<string> Default ordering for field keys.
*/
protected function getDefaultFieldOrder() {
return array();
}
/**
* Return a list of field keys which should be hidden from the viewer.
*
* @return list<string> Fields to hide.
*/
protected function getHiddenFields() {
return array();
}
public function getErrors() {
return $this->errors;
}
public function addError($error) {
$this->errors[] = $error;
return $this;
}
/**
* Return an application URI corresponding to the results page of a query.
* Normally, this is something like `/application/query/QUERYKEY/`.
*
* @param string $query_key The query key to build a URI for.
* @return string URI where the query can be executed.
* @task uri
*/
public function getQueryResultsPageURI($query_key) {
return $this->getURI('query/'.$query_key.'/');
}
/**
* Return an application URI for query management. This is used when, e.g.,
* a query deletion operation is cancelled.
*
* @return string URI where queries can be managed.
* @task uri
*/
public function getQueryManagementURI() {
return $this->getURI('query/edit/');
}
public function getQueryBaseURI() {
return $this->getURI('');
}
public function getExportURI($query_key) {
return $this->getURI('query/'.$query_key.'/export/');
}
public function getCustomizeURI($query_key, $object_phid, $context_phid) {
$params = array(
'search.objectPHID' => $object_phid,
'search.contextPHID' => $context_phid,
);
$uri = $this->getURI('query/'.$query_key.'/customize/');
$uri = new PhutilURI($uri, $params);
return phutil_string_cast($uri);
}
/**
* Return the URI to a path within the application. Used to construct default
* URIs for management and results.
*
* @return string URI to path.
* @task uri
*/
abstract protected function getURI($path);
/**
* Return a human readable description of the type of objects this query
* searches for.
*
* For example, "Tasks" or "Commits".
*
* @return string Human-readable description of what this engine is used to
* find.
*/
abstract public function getResultTypeDescription();
public function newSavedQuery() {
return id(new PhabricatorSavedQuery())
->setEngineClassName(get_class($this));
}
public function addNavigationItems(PHUIListView $menu) {
$viewer = $this->requireViewer();
$menu->newLabel(pht('Queries'));
$named_queries = $this->loadEnabledNamedQueries();
foreach ($named_queries as $query) {
$key = $query->getQueryKey();
$uri = $this->getQueryResultsPageURI($key);
$menu->newLink($query->getQueryName(), $uri, 'query/'.$key);
}
if ($viewer->isLoggedIn()) {
$manage_uri = $this->getQueryManagementURI();
$menu->newLink(pht('Edit Queries...'), $manage_uri, 'query/edit');
}
$menu->newLabel(pht('Search'));
$advanced_uri = $this->getQueryResultsPageURI('advanced');
$menu->newLink(pht('Advanced Search'), $advanced_uri, 'query/advanced');
foreach ($this->navigationItems as $extra_item) {
$menu->addMenuItem($extra_item);
}
return $this;
}
public function loadAllNamedQueries() {
$viewer = $this->requireViewer();
$builtin = $this->getBuiltinQueries();
if ($this->namedQueries === null) {
$named_queries = id(new PhabricatorNamedQueryQuery())
->setViewer($viewer)
->withEngineClassNames(array(get_class($this)))
->withUserPHIDs(
array(
$viewer->getPHID(),
PhabricatorNamedQuery::SCOPE_GLOBAL,
))
->execute();
$named_queries = mpull($named_queries, null, 'getQueryKey');
$builtin = mpull($builtin, null, 'getQueryKey');
foreach ($named_queries as $key => $named_query) {
if ($named_query->getIsBuiltin()) {
if (isset($builtin[$key])) {
$named_queries[$key]->setQueryName($builtin[$key]->getQueryName());
unset($builtin[$key]);
} else {
unset($named_queries[$key]);
}
}
unset($builtin[$key]);
}
$named_queries = msortv($named_queries, 'getNamedQuerySortVector');
$this->namedQueries = $named_queries;
}
return $this->namedQueries + $builtin;
}
public function loadEnabledNamedQueries() {
$named_queries = $this->loadAllNamedQueries();
foreach ($named_queries as $key => $named_query) {
if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) {
unset($named_queries[$key]);
}
}
return $named_queries;
}
public function getDefaultQueryKey() {
$viewer = $this->requireViewer();
$configs = id(new PhabricatorNamedQueryConfigQuery())
->setViewer($viewer)
->withEngineClassNames(array(get_class($this)))
->withScopePHIDs(
array(
$viewer->getPHID(),
PhabricatorNamedQueryConfig::SCOPE_GLOBAL,
))
->execute();
$configs = msortv($configs, 'getStrengthSortVector');
$key_pinned = PhabricatorNamedQueryConfig::PROPERTY_PINNED;
$map = $this->loadEnabledNamedQueries();
foreach ($configs as $config) {
$pinned = $config->getConfigProperty($key_pinned);
if (!isset($map[$pinned])) {
continue;
}
return $pinned;
}
return head_key($map);
}
protected function setQueryProjects(
PhabricatorCursorPagedPolicyAwareQuery $query,
PhabricatorSavedQuery $saved) {
$datasource = id(new PhabricatorProjectLogicalDatasource())
->setViewer($this->requireViewer());
$projects = $saved->getParameter('projects', array());
$constraints = $datasource->evaluateTokens($projects);
if ($constraints) {
$query->withEdgeLogicConstraints(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
$constraints);
}
return $this;
}
/* -( Applications )------------------------------------------------------- */
protected function getApplicationURI($path = '') {
return $this->getApplication()->getApplicationURI($path);
}
protected function getApplication() {
if (!$this->application) {
$class = $this->getApplicationClassName();
$this->application = id(new PhabricatorApplicationQuery())
->setViewer($this->requireViewer())
->withClasses(array($class))
->withInstalled(true)
->executeOne();
if (!$this->application) {
throw new Exception(
pht(
'Application "%s" is not installed!',
$class));
}
}
return $this->application;
}
abstract public function getApplicationClassName();
/* -( Constructing Engines )----------------------------------------------- */
/**
* Load all available application search engines.
*
* @return list<PhabricatorApplicationSearchEngine> All available engines.
* @task construct
*/
public static function getAllEngines() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->execute();
}
/**
* Get an engine by class name, if it exists.
*
* @return PhabricatorApplicationSearchEngine|null Engine, or null if it does
* not exist.
* @task construct
*/
public static function getEngineByClassName($class_name) {
return idx(self::getAllEngines(), $class_name);
}
/* -( Builtin Queries )---------------------------------------------------- */
/**
* @task builtin
*/
public function getBuiltinQueries() {
$names = $this->getBuiltinQueryNames();
$queries = array();
$sequence = 0;
foreach ($names as $key => $name) {
$queries[$key] = id(new PhabricatorNamedQuery())
->setUserPHID(PhabricatorNamedQuery::SCOPE_GLOBAL)
->setEngineClassName(get_class($this))
->setQueryName($name)
->setQueryKey($key)
->setSequence((1 << 24) + $sequence++)
->setIsBuiltin(true);
}
return $queries;
}
/**
* @task builtin
*/
public function getBuiltinQuery($query_key) {
if (!$this->isBuiltinQuery($query_key)) {
throw new Exception(pht("'%s' is not a builtin!", $query_key));
}
return idx($this->getBuiltinQueries(), $query_key);
}
/**
* @task builtin
*/
protected function getBuiltinQueryNames() {
return array();
}
/**
* @task builtin
*/
public function isBuiltinQuery($query_key) {
$builtins = $this->getBuiltinQueries();
return isset($builtins[$query_key]);
}
/**
* @task builtin
*/
public function buildSavedQueryFromBuiltin($query_key) {
throw new Exception(pht("Builtin '%s' is not supported!", $query_key));
}
/* -( Reading Utilities )--------------------------------------------------- */
/**
* Read a list of user PHIDs from a request in a flexible way. This method
* supports either of these forms:
*
* users[]=alincoln&users[]=htaft
* users=alincoln,htaft
*
* Additionally, users can be specified either by PHID or by name.
*
* The main goal of this flexibility is to allow external programs to generate
* links to pages (like "alincoln's open revisions") without needing to make
* API calls.
*
* @param AphrontRequest $request Request to read user PHIDs from.
* @param string $key Key to read in the request.
- * @param list<const>? $allow_types Other permitted PHID types.
+ * @param list<const> $allow_types (optional) Other permitted PHID types.
* @return list<phid> List of user PHIDs and selector functions.
* @task read
*/
protected function readUsersFromRequest(
AphrontRequest $request,
$key,
array $allow_types = array()) {
$list = $this->readListFromRequest($request, $key);
$phids = array();
$names = array();
$allow_types = array_fuse($allow_types);
$user_type = PhabricatorPeopleUserPHIDType::TYPECONST;
foreach ($list as $item) {
$type = phid_get_type($item);
if ($type == $user_type) {
$phids[] = $item;
} else if (isset($allow_types[$type])) {
$phids[] = $item;
} else {
if (PhabricatorTypeaheadDatasource::isFunctionToken($item)) {
// If this is a function, pass it through unchanged; we'll evaluate
// it later.
$phids[] = $item;
} else {
$names[] = $item;
}
}
}
if ($names) {
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->requireViewer())
->withUsernames($names)
->execute();
foreach ($users as $user) {
$phids[] = $user->getPHID();
}
$phids = array_unique($phids);
}
return $phids;
}
/**
* Read a list of subscribers from a request in a flexible way.
*
* @param AphrontRequest $request Request to read PHIDs from.
* @param string $key Key to read in the request.
* @return list<phid> List of object PHIDs.
* @task read
*/
protected function readSubscribersFromRequest(
AphrontRequest $request,
$key) {
return $this->readUsersFromRequest(
$request,
$key,
array(
PhabricatorProjectProjectPHIDType::TYPECONST,
));
}
/**
* Read a list of generic PHIDs from a request in a flexible way. Like
* @{method:readUsersFromRequest}, this method supports either array or
* comma-delimited forms. Objects can be specified either by PHID or by
* object name.
*
* @param AphrontRequest $request Request to read PHIDs from.
* @param string $key Key to read in the request.
- * @param list<const>? $allow_types Optional, list of permitted PHID
+ * @param list<const> $allow_types (optional) List of permitted PHID
* types.
* @return list<phid> List of object PHIDs.
*
* @task read
*/
protected function readPHIDsFromRequest(
AphrontRequest $request,
$key,
array $allow_types = array()) {
$list = $this->readListFromRequest($request, $key);
$objects = id(new PhabricatorObjectQuery())
->setViewer($this->requireViewer())
->withNames($list)
->execute();
$list = mpull($objects, 'getPHID');
if (!$list) {
return array();
}
// If only certain PHID types are allowed, filter out all the others.
if ($allow_types) {
$allow_types = array_fuse($allow_types);
foreach ($list as $key => $phid) {
if (empty($allow_types[phid_get_type($phid)])) {
unset($list[$key]);
}
}
}
return $list;
}
/**
* 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.
*/
protected function readListFromRequest(
AphrontRequest $request,
$key) {
$list = $request->getArr($key, null);
if ($list === null) {
$list = $request->getStrList($key);
}
if (!$list) {
return array();
}
return $list;
}
protected function readBoolFromRequest(
AphrontRequest $request,
$key) {
if (!phutil_nonempty_string($request->getStr($key))) {
return null;
}
return $request->getBool($key);
}
protected function getBoolFromQuery(PhabricatorSavedQuery $query, $key) {
$value = $query->getParameter($key);
if ($value === null) {
return $value;
}
return $value ? 'true' : 'false';
}
/* -( Dates )-------------------------------------------------------------- */
/**
* @task dates
*/
protected function parseDateTime($date_time) {
if (!strlen($date_time)) {
return null;
}
return PhabricatorTime::parseLocalTime($date_time, $this->requireViewer());
}
/**
* @task dates
*/
protected function buildDateRange(
AphrontFormView $form,
PhabricatorSavedQuery $saved_query,
$start_key,
$start_name,
$end_key,
$end_name) {
$start_str = $saved_query->getParameter($start_key);
$start = null;
if (strlen($start_str)) {
$start = $this->parseDateTime($start_str);
if (!$start) {
$this->addError(
pht(
'"%s" date can not be parsed.',
$start_name));
}
}
$end_str = $saved_query->getParameter($end_key);
$end = null;
if (strlen($end_str)) {
$end = $this->parseDateTime($end_str);
if (!$end) {
$this->addError(
pht(
'"%s" date can not be parsed.',
$end_name));
}
}
if ($start && $end && ($start >= $end)) {
$this->addError(
pht(
'"%s" must be a date before "%s".',
$start_name,
$end_name));
}
$form
->appendChild(
id(new PHUIFormFreeformDateControl())
->setName($start_key)
->setLabel($start_name)
->setValue($start_str))
->appendChild(
id(new AphrontFormTextControl())
->setName($end_key)
->setLabel($end_name)
->setValue($end_str));
}
/* -( Paging and Executing Queries )--------------------------------------- */
protected function newResultBuckets() {
return array();
}
public function getResultBucket(PhabricatorSavedQuery $saved) {
$key = $saved->getParameter('bucket');
if ($key == self::BUCKET_NONE) {
return null;
}
$buckets = $this->newResultBuckets();
return idx($buckets, $key);
}
public function getPageSize(PhabricatorSavedQuery $saved) {
$bucket = $this->getResultBucket($saved);
$limit = (int)$saved->getParameter('limit');
if ($limit > 0) {
if ($bucket) {
$bucket->setPageSize($limit);
}
return $limit;
}
if ($bucket) {
return $bucket->getPageSize();
}
return 100;
}
public function shouldUseOffsetPaging() {
return false;
}
public function newPagerForSavedQuery(PhabricatorSavedQuery $saved) {
if ($this->shouldUseOffsetPaging()) {
$pager = new PHUIPagerView();
} else {
$pager = new AphrontCursorPagerView();
}
$page_size = $this->getPageSize($saved);
if (is_finite($page_size)) {
$pager->setPageSize($page_size);
} else {
// Consider an INF pagesize to mean a large finite pagesize.
// TODO: It would be nice to handle this more gracefully, but math
// with INF seems to vary across PHP versions, systems, and runtimes.
$pager->setPageSize(0xFFFF);
}
return $pager;
}
public function executeQuery(
PhabricatorPolicyAwareQuery $query,
AphrontView $pager) {
$query->setViewer($this->requireViewer());
if ($this->shouldUseOffsetPaging()) {
$objects = $query->executeWithOffsetPager($pager);
} else {
$objects = $query->executeWithCursorPager($pager);
}
$this->didExecuteQuery($query);
return $objects;
}
protected function didExecuteQuery(PhabricatorPolicyAwareQuery $query) {
return;
}
/* -( Rendering )---------------------------------------------------------- */
public function setRequest(AphrontRequest $request) {
$this->request = $request;
return $this;
}
public function getRequest() {
return $this->request;
}
public function renderResults(
array $objects,
PhabricatorSavedQuery $query) {
$phids = $this->getRequiredHandlePHIDsForResultList($objects, $query);
if ($phids) {
$handles = id(new PhabricatorHandleQuery())
->setViewer($this->requireViewer())
->withPHIDs($phids)
->execute();
} else {
$handles = array();
}
return $this->renderResultList($objects, $query, $handles);
}
protected function getRequiredHandlePHIDsForResultList(
array $objects,
PhabricatorSavedQuery $query) {
return array();
}
abstract protected function renderResultList(
array $objects,
PhabricatorSavedQuery $query,
array $handles);
/* -( Application Search )------------------------------------------------- */
public function getSearchFieldsForConduit() {
$standard_fields = $this->buildSearchFields();
$fields = array();
foreach ($standard_fields as $field_key => $field) {
$conduit_key = $field->getConduitKey();
if (isset($fields[$conduit_key])) {
$other = $fields[$conduit_key];
$other_key = $other->getKey();
throw new Exception(
pht(
'SearchFields "%s" (of class "%s") and "%s" (of class "%s") both '.
'define the same Conduit key ("%s"). Keys must be unique.',
$field_key,
get_class($field),
$other_key,
get_class($other),
$conduit_key));
}
$fields[$conduit_key] = $field;
}
// These are handled separately for Conduit, so don't show them as
// supported.
unset($fields['order']);
unset($fields['limit']);
$viewer = $this->requireViewer();
foreach ($fields as $key => $field) {
$field->setViewer($viewer);
}
return $fields;
}
public function buildConduitResponse(
ConduitAPIRequest $request,
ConduitAPIMethod $method) {
$viewer = $this->requireViewer();
$query_key = $request->getValue('queryKey');
$is_empty_query_key = phutil_string_cast($query_key) === '';
if ($is_empty_query_key) {
$saved_query = new PhabricatorSavedQuery();
} else if ($this->isBuiltinQuery($query_key)) {
$saved_query = $this->buildSavedQueryFromBuiltin($query_key);
} else {
$saved_query = id(new PhabricatorSavedQueryQuery())
->setViewer($viewer)
->withQueryKeys(array($query_key))
->executeOne();
if (!$saved_query) {
throw new Exception(
pht(
'Query key "%s" does not correspond to a valid query.',
$query_key));
}
}
$constraints = $request->getValue('constraints', array());
if (!is_array($constraints)) {
throw new Exception(
pht(
'Parameter "constraints" must be a map of constraints, got "%s".',
phutil_describe_type($constraints)));
}
$fields = $this->getSearchFieldsForConduit();
foreach ($fields as $key => $field) {
if (!$field->getConduitParameterType()) {
unset($fields[$key]);
}
}
$valid_constraints = array();
foreach ($fields as $field) {
foreach ($field->getValidConstraintKeys() as $key) {
$valid_constraints[$key] = true;
}
}
foreach ($constraints as $key => $constraint) {
if (empty($valid_constraints[$key])) {
throw new Exception(
pht(
'Constraint "%s" is not a valid constraint for this query.',
$key));
}
}
foreach ($fields as $field) {
if (!$field->getValueExistsInConduitRequest($constraints)) {
continue;
}
$value = $field->readValueFromConduitRequest(
$constraints,
$request->getIsStrictlyTyped());
$saved_query->setParameter($field->getKey(), $value);
}
// NOTE: Currently, when running an ad-hoc query we never persist it into
// a saved query. We might want to add an option to do this in the future
// (for example, to enable a CLI-to-Web workflow where user can view more
// details about results by following a link), but have no use cases for
// it today. If we do identify a use case, we could save the query here.
$query = $this->buildQueryFromSavedQuery($saved_query);
$pager = $this->newPagerForSavedQuery($saved_query);
$attachments = $this->getConduitSearchAttachments();
// TODO: Validate this better.
$attachment_specs = $request->getValue('attachments', array());
$attachments = array_select_keys(
$attachments,
array_keys($attachment_specs));
foreach ($attachments as $key => $attachment) {
$attachment->setViewer($viewer);
}
foreach ($attachments as $key => $attachment) {
$attachment->willLoadAttachmentData($query, $attachment_specs[$key]);
}
$this->setQueryOrderForConduit($query, $request);
$this->setPagerLimitForConduit($pager, $request);
$this->setPagerOffsetsForConduit($pager, $request);
$objects = $this->executeQuery($query, $pager);
$data = array();
if ($objects) {
$field_extensions = $this->getConduitFieldExtensions();
$extension_data = array();
foreach ($field_extensions as $key => $extension) {
$extension_data[$key] = $extension->loadExtensionConduitData($objects);
}
$attachment_data = array();
foreach ($attachments as $key => $attachment) {
$attachment_data[$key] = $attachment->loadAttachmentData(
$objects,
$attachment_specs[$key]);
}
foreach ($objects as $object) {
$field_map = $this->getObjectWireFieldsForConduit(
$object,
$field_extensions,
$extension_data);
$attachment_map = array();
foreach ($attachments as $key => $attachment) {
$attachment_map[$key] = $attachment->getAttachmentForObject(
$object,
$attachment_data[$key],
$attachment_specs[$key]);
}
// If this is empty, we still want to emit a JSON object, not a
// JSON list.
if (!$attachment_map) {
$attachment_map = (object)$attachment_map;
}
$id = (int)$object->getID();
$phid = $object->getPHID();
$data[] = array(
'id' => $id,
'type' => phid_get_type($phid),
'phid' => $phid,
'fields' => $field_map,
'attachments' => $attachment_map,
);
}
}
return array(
'data' => $data,
'maps' => $method->getQueryMaps($query),
'query' => array(
// This may be `null` if we have not saved the query.
'queryKey' => $saved_query->getQueryKey(),
),
'cursor' => array(
'limit' => $pager->getPageSize(),
'after' => $pager->getNextPageID(),
'before' => $pager->getPrevPageID(),
'order' => $request->getValue('order'),
),
);
}
public function getAllConduitFieldSpecifications() {
$extensions = $this->getConduitFieldExtensions();
$object = $this->newQuery()->newResultObject();
$map = array();
foreach ($extensions as $extension) {
$specifications = $extension->getFieldSpecificationsForConduit($object);
foreach ($specifications as $specification) {
$key = $specification->getKey();
if (isset($map[$key])) {
throw new Exception(
pht(
'Two field specifications share the same key ("%s"). Each '.
'specification must have a unique key.',
$key));
}
$map[$key] = $specification;
}
}
return $map;
}
private function getEngineExtensions() {
$extensions = PhabricatorSearchEngineExtension::getAllEnabledExtensions();
foreach ($extensions as $key => $extension) {
$extension
->setViewer($this->requireViewer())
->setSearchEngine($this);
}
$object = $this->newResultObject();
foreach ($extensions as $key => $extension) {
if (!$extension->supportsObject($object)) {
unset($extensions[$key]);
}
}
return $extensions;
}
private function getConduitFieldExtensions() {
$extensions = $this->getEngineExtensions();
$object = $this->newResultObject();
foreach ($extensions as $key => $extension) {
if (!$extension->getFieldSpecificationsForConduit($object)) {
unset($extensions[$key]);
}
}
return $extensions;
}
private function setQueryOrderForConduit($query, ConduitAPIRequest $request) {
$order = $request->getValue('order');
if ($order === null) {
return;
}
if (is_scalar($order)) {
$query->setOrder($order);
} else {
$query->setOrderVector($order);
}
}
private function setPagerLimitForConduit($pager, ConduitAPIRequest $request) {
$limit = $request->getValue('limit');
// If there's no limit specified and the query uses a weird huge page
// size, just leave it at the default gigantic page size. Otherwise,
// make sure it's between 1 and 100, inclusive.
if ($limit === null) {
if ($pager->getPageSize() >= 0xFFFF) {
return;
} else {
$limit = 100;
}
}
if ($limit > 100) {
throw new Exception(
pht(
'Maximum page size for Conduit API method calls is 100, but '.
'this call specified %s.',
$limit));
}
if ($limit < 1) {
throw new Exception(
pht(
'Minimum page size for API searches is 1, but this call '.
'specified %s.',
$limit));
}
$pager->setPageSize($limit);
}
private function setPagerOffsetsForConduit(
$pager,
ConduitAPIRequest $request) {
$before_id = $request->getValue('before');
if ($before_id !== null) {
$pager->setBeforeID($before_id);
}
$after_id = $request->getValue('after');
if ($after_id !== null) {
$pager->setAfterID($after_id);
}
}
protected function getObjectWireFieldsForConduit(
$object,
array $field_extensions,
array $extension_data) {
$fields = array();
foreach ($field_extensions as $key => $extension) {
$data = idx($extension_data, $key, array());
$fields += $extension->getFieldValuesForConduit($object, $data);
}
return $fields;
}
public function getConduitSearchAttachments() {
$extensions = $this->getEngineExtensions();
$object = $this->newResultObject();
$attachments = array();
foreach ($extensions as $extension) {
$extension_attachments = $extension->getSearchAttachments($object);
foreach ($extension_attachments as $attachment) {
$attachment_key = $attachment->getAttachmentKey();
if (isset($attachments[$attachment_key])) {
$other = $attachments[$attachment_key];
throw new Exception(
pht(
'Two search engine attachments (of classes "%s" and "%s") '.
'specify the same attachment key ("%s"); keys must be unique.',
get_class($attachment),
get_class($other),
$attachment_key));
}
$attachments[$attachment_key] = $attachment;
}
}
return $attachments;
}
/**
* Render a content body (if available) to onboard new users.
* This body is usually visible when you have no elements in a list,
* or when you force the rendering on a list with the `?nux=1` URL.
* @return wild|PhutilSafeHTML|null
*/
final public function renderNewUserView() {
$body = $this->getNewUserBody();
if (!$body) {
return null;
}
return $body;
}
/**
* Get a content body to onboard new users.
* Traditionally this content is shown from an empty list, to explain
* what a certain entity does, and how to create a new one.
* @return wild|PhutilSafeHTML|null
*/
protected function getNewUserHeader() {
return null;
}
protected function getNewUserBody() {
return null;
}
public function newUseResultsActions(PhabricatorSavedQuery $saved) {
return array();
}
/* -( Export )------------------------------------------------------------- */
public function canExport() {
$fields = $this->newExportFields();
return (bool)$fields;
}
final public function newExportFieldList() {
$object = $this->newResultObject();
$builtin_fields = array(
id(new PhabricatorIDExportField())
->setKey('id')
->setLabel(pht('ID')),
);
if ($object->getConfigOption(LiskDAO::CONFIG_AUX_PHID)) {
$builtin_fields[] = id(new PhabricatorPHIDExportField())
->setKey('phid')
->setLabel(pht('PHID'));
}
$fields = mpull($builtin_fields, null, 'getKey');
$export_fields = $this->newExportFields();
foreach ($export_fields as $export_field) {
$key = $export_field->getKey();
if (isset($fields[$key])) {
throw new Exception(
pht(
'Search engine ("%s") defines an export field with a key ("%s") '.
'that collides with another field. Each field must have a '.
'unique key.',
get_class($this),
$key));
}
$fields[$key] = $export_field;
}
$extensions = $this->newExportExtensions();
foreach ($extensions as $extension) {
$extension_fields = $extension->newExportFields();
foreach ($extension_fields as $extension_field) {
$key = $extension_field->getKey();
if (isset($fields[$key])) {
throw new Exception(
pht(
'Export engine extension ("%s") defines an export field with '.
'a key ("%s") that collides with another field. Each field '.
'must have a unique key.',
get_class($extension_field),
$key));
}
$fields[$key] = $extension_field;
}
}
return $fields;
}
final public function newExport(array $objects) {
$object = $this->newResultObject();
$has_phid = $object->getConfigOption(LiskDAO::CONFIG_AUX_PHID);
$objects = array_values($objects);
$n = count($objects);
$maps = array();
foreach ($objects as $object) {
$map = array(
'id' => $object->getID(),
);
if ($has_phid) {
$map['phid'] = $object->getPHID();
}
$maps[] = $map;
}
$export_data = $this->newExportData($objects);
$export_data = array_values($export_data);
if (count($export_data) !== count($objects)) {
throw new Exception(
pht(
'Search engine ("%s") exported the wrong number of objects, '.
'expected %s but got %s.',
get_class($this),
phutil_count($objects),
phutil_count($export_data)));
}
for ($ii = 0; $ii < $n; $ii++) {
$maps[$ii] += $export_data[$ii];
}
$extensions = $this->newExportExtensions();
foreach ($extensions as $extension) {
$extension_data = $extension->newExportData($objects);
$extension_data = array_values($extension_data);
if (count($export_data) !== count($objects)) {
throw new Exception(
pht(
'Export engine extension ("%s") exported the wrong number of '.
'objects, expected %s but got %s.',
get_class($extension),
phutil_count($objects),
phutil_count($export_data)));
}
for ($ii = 0; $ii < $n; $ii++) {
$maps[$ii] += $extension_data[$ii];
}
}
return $maps;
}
protected function newExportFields() {
return array();
}
protected function newExportData(array $objects) {
throw new PhutilMethodNotImplementedException();
}
private function newExportExtensions() {
$object = $this->newResultObject();
$viewer = $this->requireViewer();
$extensions = PhabricatorExportEngineExtension::getAllExtensions();
$supported = array();
foreach ($extensions as $extension) {
$extension = clone $extension;
$extension->setViewer($viewer);
if ($extension->supportsObject($object)) {
$supported[] = $extension;
}
}
return $supported;
}
/**
* Load from object and from storage, and updates Custom Fields instances
* that are attached to each object.
*
* @return map<phid->PhabricatorCustomFieldList> of loaded fields.
* @task custom
*/
protected function loadCustomFields(array $objects, $role) {
assert_instances_of($objects, 'PhabricatorCustomFieldInterface');
$query = new PhabricatorCustomFieldStorageQuery();
$lists = array();
foreach ($objects as $object) {
$field_list = PhabricatorCustomField::getObjectFields($object, $role);
$field_list->readFieldsFromObject($object);
foreach ($field_list->getFields() as $field) {
// TODO move $viewer into PhabricatorCustomFieldStorageQuery
$field->setViewer($this->viewer);
}
$lists[$object->getPHID()] = $field_list;
$query->addFields($field_list->getFields());
}
// This updates the field_list objects.
$query->execute();
return $lists;
}
}
diff --git a/src/applications/settings/panel/PhabricatorSettingsPanel.php b/src/applications/settings/panel/PhabricatorSettingsPanel.php
index ac92a134c6..e5dc33fa78 100644
--- a/src/applications/settings/panel/PhabricatorSettingsPanel.php
+++ b/src/applications/settings/panel/PhabricatorSettingsPanel.php
@@ -1,322 +1,322 @@
<?php
/**
* Defines a settings panel. Settings panels appear in the Settings application,
* and behave like lightweight controllers -- generally, they render some sort
* of form with options in it, and then update preferences when the user
* submits the form. By extending this class, you can add new settings
* panels.
*
* @task config Panel Configuration
* @task panel Panel Implementation
* @task internal Internals
*/
abstract class PhabricatorSettingsPanel extends Phobject {
private $user;
private $viewer;
private $controller;
private $navigation;
private $overrideURI;
private $preferences;
public function setUser(PhabricatorUser $user) {
$this->user = $user;
return $this;
}
public function getUser() {
return $this->user;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function setOverrideURI($override_uri) {
$this->overrideURI = $override_uri;
return $this;
}
final public function setController(PhabricatorController $controller) {
$this->controller = $controller;
return $this;
}
final public function getController() {
return $this->controller;
}
final public function setNavigation(AphrontSideNavFilterView $navigation) {
$this->navigation = $navigation;
return $this;
}
final public function getNavigation() {
return $this->navigation;
}
public function setPreferences(PhabricatorUserPreferences $preferences) {
$this->preferences = $preferences;
return $this;
}
public function getPreferences() {
return $this->preferences;
}
final public static function getAllPanels() {
$panels = id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getPanelKey')
->execute();
return msortv($panels, 'getPanelOrderVector');
}
final public static function getAllDisplayPanels() {
$panels = array();
$groups = PhabricatorSettingsPanelGroup::getAllPanelGroupsWithPanels();
foreach ($groups as $group) {
foreach ($group->getPanels() as $key => $panel) {
$panels[$key] = $panel;
}
}
return $panels;
}
final public function getPanelGroup() {
$group_key = $this->getPanelGroupKey();
$groups = PhabricatorSettingsPanelGroup::getAllPanelGroupsWithPanels();
$group = idx($groups, $group_key);
if (!$group) {
throw new Exception(
pht(
'No settings panel group with key "%s" exists!',
$group_key));
}
return $group;
}
/* -( Panel Configuration )------------------------------------------------ */
/**
* Return a unique string used in the URI to identify this panel, like
* "example".
*
* @return string Unique panel identifier (used in URIs).
* @task config
*/
public function getPanelKey() {
return $this->getPhobjectClassConstant('PANELKEY');
}
/**
* Return a human-readable description of the panel's contents, like
* "Example Settings".
*
* @return string Human-readable panel name.
* @task config
*/
abstract public function getPanelName();
/**
* Return an icon for the panel in the menu.
*
* @return string Icon identifier.
* @task config
*/
public function getPanelMenuIcon() {
return 'fa-wrench';
}
/**
* Return a panel group key constant for this panel.
*
* @return const Panel group key.
* @task config
*/
abstract public function getPanelGroupKey();
/**
* Return false to prevent this panel from being displayed or used. You can
* do, e.g., configuration checks here, to determine if the feature your
* panel controls is unavailable in this install. By default, all panels are
* enabled.
*
* @return bool True if the panel should be shown.
* @task config
*/
public function isEnabled() {
return true;
}
/**
* Return true if this panel is available to users while editing their own
* settings.
*
* @return bool True to enable management on behalf of a user.
* @task config
*/
public function isUserPanel() {
return true;
}
/**
* Return true if this panel is available to administrators while managing
* bot and mailing list accounts.
*
* @return bool True to enable management on behalf of accounts.
* @task config
*/
public function isManagementPanel() {
return false;
}
/**
* Return true if this panel is available while editing settings templates.
*
* @return bool True to allow editing in templates.
* @task config
*/
public function isTemplatePanel() {
return false;
}
/**
* Return true if this panel should be available when enrolling in MFA on
* a new account with MFA requiredd.
*
* @return bool True to allow configuration during MFA enrollment.
* @task config
*/
public function isMultiFactorEnrollmentPanel() {
return false;
}
/* -( Panel Implementation )----------------------------------------------- */
/**
* Process a user request for this settings panel. Implement this method like
* a lightweight controller. If you return an @{class:AphrontResponse}, the
* response will be used in whole. If you return anything else, it will be
* treated as a view and composed into a normal settings page.
*
* Generally, render your settings panel by returning a form, then return
* a redirect when the user saves settings.
*
* @param AphrontRequest $request Incoming request.
* @return wild Response to request, either as an
* @{class:AphrontResponse} or something which can
* be composed into a @{class:AphrontView}.
* @task panel
*/
abstract public function processRequest(AphrontRequest $request);
/**
* Get the URI for this panel.
*
- * @param string? $path Optional path to append.
+ * @param string $path (optional) Path to append.
* @return string Relative URI for the panel.
* @task panel
*/
final public function getPanelURI($path = '') {
$path = ltrim($path, '/');
if ($this->overrideURI) {
return rtrim($this->overrideURI, '/').'/'.$path;
}
$key = $this->getPanelKey();
$key = phutil_escape_uri($key);
$user = $this->getUser();
if ($user) {
if ($user->isLoggedIn()) {
$username = $user->getUsername();
return "/settings/user/{$username}/page/{$key}/{$path}";
} else {
// For logged-out users, we can't put their username in the URI. This
// page will prompt them to login, then redirect them to the correct
// location.
return "/settings/panel/{$key}/";
}
} else {
$builtin = $this->getPreferences()->getBuiltinKey();
return "/settings/builtin/{$builtin}/page/{$key}/{$path}";
}
}
/* -( Internals )---------------------------------------------------------- */
/**
* Generates a key to sort the list of panels.
*
* @return string Sortable key.
* @task internal
*/
final public function getPanelOrderVector() {
return id(new PhutilSortVector())
->addString($this->getPanelName());
}
protected function newDialog() {
return $this->getController()->newDialog();
}
protected function writeSetting(
PhabricatorUserPreferences $preferences,
$key,
$value) {
$viewer = $this->getViewer();
$request = $this->getController()->getRequest();
$editor = id(new PhabricatorUserPreferencesEditor())
->setActor($viewer)
->setContentSourceFromRequest($request)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true);
$xactions = array();
$xactions[] = $preferences->newTransaction($key, $value);
$editor->applyTransactions($preferences, $xactions);
}
public function newBox($title, $content, $actions = array()) {
$header = id(new PHUIHeaderView())
->setHeader($title);
foreach ($actions as $action) {
$header->addActionLink($action);
}
$view = id(new PHUIObjectBoxView())
->setHeader($header)
->appendChild($content)
->setBackground(PHUIObjectBoxView::WHITE_CONFIG);
return $view;
}
}
diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php
index 6b3aae2790..f86b4773bf 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
* @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 List of required capability constants,
- * or omit for defaults.
+ * @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 List of required capability constants,
- * or omit for defaults.
+ * @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 List of required capability constants,
- * or omit for defaults.
+ * @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 List of required capability constants,
- * or omit for defaults.
+ * @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/infrastructure/cache/PhutilKeyValueCache.php b/src/infrastructure/cache/PhutilKeyValueCache.php
index e65eb9675c..45f7a3048e 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
+ * @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.
+ * @param int|null $ttl (optional) TTL.
* @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
* @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 TTL for cache keys, in seconds.
+ * @param int|null $ttl (optional) TTL for cache keys, in seconds.
* @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
* @task kvimpl
*/
abstract public function deleteKeys(array $keys);
/**
* Completely destroy all data in the cache.
*
* @return this
* @task kvimpl
*/
abstract public function destroyCache();
}
diff --git a/src/infrastructure/contentsource/PhabricatorContentSource.php b/src/infrastructure/contentsource/PhabricatorContentSource.php
index 19f954e0d8..600f3080bf 100644
--- a/src/infrastructure/contentsource/PhabricatorContentSource.php
+++ b/src/infrastructure/contentsource/PhabricatorContentSource.php
@@ -1,99 +1,99 @@
<?php
abstract class PhabricatorContentSource extends Phobject {
private $source;
private $params = array();
abstract public function getSourceName();
abstract public function getSourceDescription();
final public function getSourceTypeConstant() {
return $this->getPhobjectClassConstant('SOURCECONST', 32);
}
final public static function getAllContentSources() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getSourceTypeConstant')
->execute();
}
/**
* Construct a new content source object.
*
* @param const $source The source type constant to build a source for.
- * @param array? $params Source parameters.
- * @param bool? $force True to suppress errors and force construction of a
- * source even if the source type is not valid.
+ * @param array $params (optional) Source parameters.
+ * @param bool $force (optional) True to suppress errors and force
+ * construction of a source even if the source type is not valid.
* @return PhabricatorContentSource New source object.
*/
final public static function newForSource(
$source,
array $params = array(),
$force = false) {
$map = self::getAllContentSources();
if (isset($map[$source])) {
$obj = clone $map[$source];
} else {
if ($force) {
$obj = new PhabricatorUnknownContentSource();
} else {
throw new Exception(
pht(
'Content source type "%s" is unknown.',
$source));
}
}
$obj->source = $source;
$obj->params = $params;
return $obj;
}
public static function newFromSerialized($serialized) {
$dict = json_decode($serialized, true);
if (!is_array($dict)) {
$dict = array();
}
$source = idx($dict, 'source');
$params = idx($dict, 'params');
if (!is_array($params)) {
$params = array();
}
return self::newForSource($source, $params, true);
}
public static function newFromRequest(AphrontRequest $request) {
return self::newForSource(
PhabricatorWebContentSource::SOURCECONST);
}
final public function serialize() {
return phutil_json_encode(
array(
'source' => $this->getSource(),
'params' => $this->params,
));
}
/**
* Get the internal source name
*
* This is usually coming from a SOURCECONST constant.
*
* @return string|null
*/
final public function getSource() {
return $this->source;
}
final public function getContentSourceParameter($key, $default = null) {
return idx($this->params, $key, $default);
}
}
diff --git a/src/infrastructure/daemon/PhutilDaemonHandle.php b/src/infrastructure/daemon/PhutilDaemonHandle.php
index a44ba7b7cb..36b0ea9935 100644
--- a/src/infrastructure/daemon/PhutilDaemonHandle.php
+++ b/src/infrastructure/daemon/PhutilDaemonHandle.php
@@ -1,537 +1,537 @@
<?php
final class PhutilDaemonHandle extends Phobject {
const EVENT_DID_LAUNCH = 'daemon.didLaunch';
const EVENT_DID_LOG = 'daemon.didLogMessage';
const EVENT_DID_HEARTBEAT = 'daemon.didHeartbeat';
const EVENT_WILL_GRACEFUL = 'daemon.willGraceful';
const EVENT_WILL_EXIT = 'daemon.willExit';
private $pool;
private $properties;
private $future;
private $argv;
private $restartAt;
private $busyEpoch;
private $daemonID;
private $deadline;
private $heartbeat;
private $stdoutBuffer;
private $shouldRestart = true;
private $shouldShutdown;
private $hibernating = false;
private $shouldSendExitEvent = false;
private function __construct() {
// <empty>
}
public static function newFromConfig(array $config) {
PhutilTypeSpec::checkMap(
$config,
array(
'class' => 'string',
'argv' => 'optional list<string>',
'load' => 'optional list<string>',
'log' => 'optional string|null',
'down' => 'optional int',
));
$config = $config + array(
'argv' => array(),
'load' => array(),
'log' => null,
'down' => 15,
);
$daemon = new self();
$daemon->properties = $config;
$daemon->daemonID = $daemon->generateDaemonID();
return $daemon;
}
public function setDaemonPool(PhutilDaemonPool $daemon_pool) {
$this->pool = $daemon_pool;
return $this;
}
public function getDaemonPool() {
return $this->pool;
}
public function getBusyEpoch() {
return $this->busyEpoch;
}
public function getDaemonClass() {
return $this->getProperty('class');
}
private function getProperty($key) {
return idx($this->properties, $key);
}
public function setCommandLineArguments(array $arguments) {
$this->argv = $arguments;
return $this;
}
public function getCommandLineArguments() {
return $this->argv;
}
public function getDaemonArguments() {
return $this->getProperty('argv');
}
public function didLaunch() {
$this->restartAt = time();
$this->shouldSendExitEvent = true;
$this->dispatchEvent(
self::EVENT_DID_LAUNCH,
array(
'argv' => $this->getCommandLineArguments(),
'explicitArgv' => $this->getDaemonArguments(),
));
return $this;
}
public function isRunning() {
return (bool)$this->getFuture();
}
public function isHibernating() {
return
!$this->isRunning() &&
!$this->isDone() &&
$this->hibernating;
}
public function wakeFromHibernation() {
if (!$this->isHibernating()) {
return $this;
}
$this->logMessage(
'WAKE',
pht(
'Process is being awakened from hibernation.'));
$this->restartAt = time();
$this->update();
return $this;
}
public function isDone() {
return (!$this->shouldRestart && !$this->isRunning());
}
public function update() {
if (!$this->isRunning()) {
if (!$this->shouldRestart) {
return;
}
if (!$this->restartAt || (time() < $this->restartAt)) {
return;
}
if ($this->shouldShutdown) {
return;
}
$this->startDaemonProcess();
}
$future = $this->getFuture();
$result = null;
$caught = null;
if ($future->canResolve()) {
$this->future = null;
try {
$result = $future->resolve();
} catch (Exception $ex) {
$caught = $ex;
} catch (Throwable $ex) {
$caught = $ex;
}
}
list($stdout, $stderr) = $future->read();
$future->discardBuffers();
if (strlen($stdout)) {
$this->didReadStdout($stdout);
}
$stderr = trim($stderr);
if (strlen($stderr)) {
foreach (phutil_split_lines($stderr, false) as $line) {
$this->logMessage('STDE', $line);
}
}
if ($result !== null || $caught !== null) {
if ($caught) {
$message = pht(
'Process failed with exception: %s',
$caught->getMessage());
$this->logMessage('FAIL', $message);
} else {
list($err) = $result;
if ($err) {
$this->logMessage('FAIL', pht('Process exited with error %s.', $err));
} else {
$this->logMessage('DONE', pht('Process exited normally.'));
}
}
if ($this->shouldShutdown) {
$this->restartAt = null;
} else {
$this->scheduleRestart();
}
}
$this->updateHeartbeatEvent();
$this->updateHangDetection();
}
private function updateHeartbeatEvent() {
if ($this->heartbeat > time()) {
return;
}
$this->heartbeat = time() + $this->getHeartbeatEventFrequency();
$this->dispatchEvent(self::EVENT_DID_HEARTBEAT);
}
private function updateHangDetection() {
if (!$this->isRunning()) {
return;
}
if (time() > $this->deadline) {
$this->logMessage('HANG', pht('Hang detected. Restarting process.'));
$this->annihilateProcessGroup();
$this->scheduleRestart();
}
}
private function scheduleRestart() {
// Wait a minimum of a few sceconds before restarting, but we may wait
// longer if the daemon has initiated hibernation.
$default_restart = time() + self::getWaitBeforeRestart();
if ($default_restart >= $this->restartAt) {
$this->restartAt = $default_restart;
}
$this->logMessage(
'WAIT',
pht(
'Waiting %s second(s) to restart process.',
new PhutilNumber($this->restartAt - time())));
}
/**
* Generate a unique ID for this daemon.
*
* @return string A unique daemon ID.
*/
private function generateDaemonID() {
return substr(getmypid().':'.Filesystem::readRandomCharacters(12), 0, 12);
}
public function getDaemonID() {
return $this->daemonID;
}
private function getFuture() {
return $this->future;
}
private function getPID() {
$future = $this->getFuture();
if (!$future) {
return null;
}
if (!$future->hasPID()) {
return null;
}
return $future->getPID();
}
private function getCaptureBufferSize() {
return 65535;
}
private function getRequiredHeartbeatFrequency() {
return 86400;
}
public static function getWaitBeforeRestart() {
return 5;
}
public static function getHeartbeatEventFrequency() {
return 120;
}
private function getKillDelay() {
return 3;
}
private function getDaemonCWD() {
$root = dirname(phutil_get_library_root('phabricator'));
return $root.'/scripts/daemon/exec/';
}
private function newExecFuture() {
$class = $this->getDaemonClass();
$argv = $this->getCommandLineArguments();
$buffer_size = $this->getCaptureBufferSize();
// NOTE: PHP implements proc_open() by running 'sh -c'. On most systems this
// is bash, but on Ubuntu it's dash. When you proc_open() using bash, you
// get one new process (the command you ran). When you proc_open() using
// dash, you get two new processes: the command you ran and a parent
// "dash -c" (or "sh -c") process. This means that the child process's PID
// is actually the 'dash' PID, not the command's PID. To avoid this, use
// 'exec' to replace the shell process with the real process; without this,
// the child will call posix_getppid(), be given the pid of the 'sh -c'
// process, and send it SIGUSR1 to keepalive which will terminate it
// immediately. We also won't be able to do process group management because
// the shell process won't properly posix_setsid() so the pgid of the child
// won't be meaningful.
$config = $this->properties;
unset($config['class']);
$config = phutil_json_encode($config);
return id(new ExecFuture('exec ./exec_daemon.php %s %Ls', $class, $argv))
->setCWD($this->getDaemonCWD())
->setStdoutSizeLimit($buffer_size)
->setStderrSizeLimit($buffer_size)
->write($config);
}
/**
* Dispatch an event to event listeners.
*
* @param string $type Event type.
- * @param dict? $params Event parameters.
+ * @param dict $params (optional) Event parameters.
* @return void
*/
private function dispatchEvent($type, array $params = array()) {
$data = array(
'id' => $this->getDaemonID(),
'daemonClass' => $this->getDaemonClass(),
'childPID' => $this->getPID(),
) + $params;
$event = new PhutilEvent($type, $data);
try {
PhutilEventEngine::dispatchEvent($event);
} catch (Exception $ex) {
phlog($ex);
}
}
private function annihilateProcessGroup() {
$pid = $this->getPID();
if ($pid) {
$pgid = posix_getpgid($pid);
if ($pgid) {
posix_kill(-$pgid, SIGTERM);
sleep($this->getKillDelay());
posix_kill(-$pgid, SIGKILL);
}
}
}
private function startDaemonProcess() {
$this->logMessage('INIT', pht('Starting process.'));
$this->deadline = time() + $this->getRequiredHeartbeatFrequency();
$this->heartbeat = time() + self::getHeartbeatEventFrequency();
$this->stdoutBuffer = '';
$this->hibernating = false;
$future = $this->newExecFuture();
$this->future = $future;
$pool = $this->getDaemonPool();
$overseer = $pool->getOverseer();
$overseer->addFutureToPool($future);
}
private function didReadStdout($data) {
$this->stdoutBuffer .= $data;
while (true) {
$pos = strpos($this->stdoutBuffer, "\n");
if ($pos === false) {
break;
}
$message = substr($this->stdoutBuffer, 0, $pos);
$this->stdoutBuffer = substr($this->stdoutBuffer, $pos + 1);
try {
$structure = phutil_json_decode($message);
} catch (PhutilJSONParserException $ex) {
$structure = array();
}
switch (idx($structure, 0)) {
case PhutilDaemon::MESSAGETYPE_STDOUT:
$this->logMessage('STDO', idx($structure, 1));
break;
case PhutilDaemon::MESSAGETYPE_HEARTBEAT:
$this->deadline = time() + $this->getRequiredHeartbeatFrequency();
break;
case PhutilDaemon::MESSAGETYPE_BUSY:
if (!$this->busyEpoch) {
$this->busyEpoch = time();
}
break;
case PhutilDaemon::MESSAGETYPE_IDLE:
$this->busyEpoch = null;
break;
case PhutilDaemon::MESSAGETYPE_DOWN:
// The daemon is exiting because it doesn't have enough work and it
// is trying to scale the pool down. We should not restart it.
$this->shouldRestart = false;
$this->shouldShutdown = true;
break;
case PhutilDaemon::MESSAGETYPE_HIBERNATE:
$config = idx($structure, 1);
$duration = (int)idx($config, 'duration', 0);
$this->restartAt = time() + $duration;
$this->hibernating = true;
$this->busyEpoch = null;
$this->logMessage(
'ZZZZ',
pht(
'Process is preparing to hibernate for %s second(s).',
new PhutilNumber($duration)));
break;
default:
// If we can't parse this or it isn't a message we understand, just
// emit the raw message.
$this->logMessage('STDO', pht('<Malformed> %s', $message));
break;
}
}
}
public function didReceiveNotifySignal($signo) {
$pid = $this->getPID();
if ($pid) {
posix_kill($pid, $signo);
}
}
public function didReceiveReloadSignal($signo) {
$signame = phutil_get_signal_name($signo);
if ($signame) {
$sigmsg = pht(
'Reloading in response to signal %d (%s).',
$signo,
$signame);
} else {
$sigmsg = pht(
'Reloading in response to signal %d.',
$signo);
}
$this->logMessage('RELO', $sigmsg, $signo);
// This signal means "stop the current process gracefully, then launch
// a new identical process once it exits". This can be used to update
// daemons after code changes (the new processes will run the new code)
// without aborting any running tasks.
// We SIGINT the daemon but don't set the shutdown flag, so it will
// naturally be restarted after it exits, as though it had exited after an
// unhandled exception.
$pid = $this->getPID();
if ($pid) {
posix_kill($pid, SIGINT);
}
}
public function didReceiveGracefulSignal($signo) {
$this->shouldShutdown = true;
$this->shouldRestart = false;
$signame = phutil_get_signal_name($signo);
if ($signame) {
$sigmsg = pht(
'Graceful shutdown in response to signal %d (%s).',
$signo,
$signame);
} else {
$sigmsg = pht(
'Graceful shutdown in response to signal %d.',
$signo);
}
$this->logMessage('DONE', $sigmsg, $signo);
$pid = $this->getPID();
if ($pid) {
posix_kill($pid, SIGINT);
}
}
public function didReceiveTerminateSignal($signo) {
$this->shouldShutdown = true;
$this->shouldRestart = false;
$signame = phutil_get_signal_name($signo);
if ($signame) {
$sigmsg = pht(
'Shutting down in response to signal %s (%s).',
$signo,
$signame);
} else {
$sigmsg = pht('Shutting down in response to signal %s.', $signo);
}
$this->logMessage('EXIT', $sigmsg, $signo);
$this->annihilateProcessGroup();
}
private function logMessage($type, $message, $context = null) {
$this->getDaemonPool()->logMessage($type, $message, $context);
$this->dispatchEvent(
self::EVENT_DID_LOG,
array(
'type' => $type,
'message' => $message,
'context' => $context,
));
}
public function didExit() {
if ($this->shouldSendExitEvent) {
$this->dispatchEvent(self::EVENT_WILL_EXIT);
$this->shouldSendExitEvent = false;
}
return $this;
}
}
diff --git a/src/infrastructure/daemon/workers/PhabricatorWorker.php b/src/infrastructure/daemon/workers/PhabricatorWorker.php
index 69815557ec..c5c8271862 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 Options for the followup task.
+ * @param array $options (optional) Options for the followup task.
* @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.
+ * @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/edges/editor/PhabricatorEdgeEditor.php b/src/infrastructure/edges/editor/PhabricatorEdgeEditor.php
index 8ab8ddce58..e1410cfd13 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 Options map (see documentation).
+ * @param map $options (optional) Options map (see documentation).
* @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
*
* @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 eeb6123abf..a33d75cfcc 100644
--- a/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
+++ b/src/infrastructure/edges/query/PhabricatorEdgeQuery.php
@@ -1,339 +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
*
* @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
*
*/
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
*
* @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
*
* @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
*
* @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 List of PHIDs to select, or empty to select all.
- * @param list? $types List of edge types to select, or empty to select all.
+ * @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/markup/PhabricatorMarkupEngine.php b/src/infrastructure/markup/PhabricatorMarkupEngine.php
index 89f03977f8..60ea048458 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 A context object for
- * policy checks.
+ * @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
* @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
* @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.
+ * @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
* @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
* @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 7f650a4417..aadb891116 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
*/
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 Default value to return if the key is not
- * available.
+ * @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/markup/markuprule/PhutilRemarkupRule.php b/src/infrastructure/markup/markuprule/PhutilRemarkupRule.php
index 6513d0f66c..09f6ae9a5c 100644
--- a/src/infrastructure/markup/markuprule/PhutilRemarkupRule.php
+++ b/src/infrastructure/markup/markuprule/PhutilRemarkupRule.php
@@ -1,132 +1,132 @@
<?php
abstract class PhutilRemarkupRule extends Phobject {
private $engine;
private $replaceCallback;
public function setEngine(PhutilRemarkupEngine $engine) {
$this->engine = $engine;
return $this;
}
public function getEngine() {
return $this->engine;
}
public function getPriority() {
return 500.0;
}
/**
* Check input whether to apply RemarkupRule. If true, apply formatting.
* @param string|PhutilSafeHTML $text String to check and potentially
* format.
* @return string|PhutilSafeHTML Unchanged input if no match, or input after
* matching the formatting rule and applying the formatting.
*/
abstract public function apply($text);
public function getPostprocessKey() {
return spl_object_hash($this);
}
public function didMarkupText() {
return;
}
protected function replaceHTML($pattern, $callback, $text) {
$this->replaceCallback = $callback;
return phutil_safe_html(preg_replace_callback(
$pattern,
array($this, 'replaceHTMLCallback'),
phutil_escape_html($text)));
}
private function replaceHTMLCallback(array $match) {
return phutil_escape_html(call_user_func(
$this->replaceCallback,
array_map('phutil_safe_html', $match)));
}
/**
* Safely generate a tag.
*
* In Remarkup contexts, it's not safe to use arbitrary text in tag
* attributes: even though it will be escaped, it may contain replacement
* tokens which are then replaced with markup.
*
* This method acts as @{function:phutil_tag}, but checks attributes before
* using them.
*
* @param string $name Tag name.
* @param dict<string, wild> $attrs Tag attributes.
- * @param wild? $content Tag content.
+ * @param wild $content (optional) Tag content.
* @return PhutilSafeHTML Tag object.
*/
protected function newTag($name, array $attrs, $content = null) {
foreach ($attrs as $key => $attr) {
if ($attr !== null) {
$attrs[$key] = $this->assertFlatText($attr);
}
}
return phutil_tag($name, $attrs, $content);
}
/**
* Assert that a text token is flat (it contains no replacement tokens).
*
* Because tokens can be replaced with markup, it is dangerous to use
* arbitrary input text in tag attributes. Normally, rule precedence should
* prevent this. Asserting that text is flat before using it as an attribute
* provides an extra layer of security.
*
* Normally, you can call @{method:newTag} rather than calling this method
* directly. @{method:newTag} will check attributes for you.
*
* @param wild $text Ostensibly flat text.
* @return string Flat text.
*/
protected function assertFlatText($text) {
$text = (string)hsprintf('%s', phutil_safe_html($text));
$rich = (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) !== false);
if ($rich) {
throw new Exception(
pht(
'Remarkup rule precedence is dangerous: rendering text with tokens '.
'as flat text!'));
}
return $text;
}
/**
* Check whether text is flat (contains no replacement tokens) or not.
*
* @param wild $text Ostensibly flat text.
* @return bool True if the text is flat.
*/
protected function isFlatText($text) {
$text = (string)hsprintf('%s', phutil_safe_html($text));
return (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) === false);
}
/**
* Get the CSS class="" attribute for a Remarkup link.
* It's just "remarkup-link" for all cases, plus the possibility for
* designers to style external links differently.
* @param boolean $is_internal Whenever the link was internal or not.
* @return string
*/
protected function getRemarkupLinkClass($is_internal) {
// Allow developers to style esternal links differently
$classes = array('remarkup-link');
if (!$is_internal) {
$classes[] = 'remarkup-link-ext';
}
return implode(' ', $classes);
}
}
diff --git a/src/infrastructure/markup/render.php b/src/infrastructure/markup/render.php
index eaa4e067b2..fda35d72e8 100644
--- a/src/infrastructure/markup/render.php
+++ b/src/infrastructure/markup/render.php
@@ -1,187 +1,187 @@
<?php
/**
* Render an HTML tag in a way that treats user content as unsafe by default.
*
* Tag rendering has some special logic which implements security features:
*
* - When rendering `<a>` tags, if the `rel` attribute is not specified, it
* is interpreted as `rel="noreferrer"`.
* - When rendering `<a>` tags, the `href` attribute may not begin with
* `javascript:`.
*
* These special cases can not be disabled.
*
* IMPORTANT: The `$tag` attribute and the keys of the `$attributes` array are
* trusted blindly, and not escaped. You should not pass user data in these
* parameters.
*
* @param string $tag The name of the tag, like `a` or `div`.
- * @param map<string, string>? $attributes A map of tag attributes.
- * @param wild? $content Content to put in the tag.
+ * @param map<string, string> $attributes (optional) A map of tag attributes.
+ * @param wild $content (optional) Content to put in the tag.
* @return PhutilSafeHTML Tag object.
*/
function phutil_tag($tag, array $attributes = array(), $content = null) {
// If the `href` attribute is present, make sure it is not a "javascript:"
// URI. We never permit these.
if (!empty($attributes['href'])) {
// This might be a URI object, so cast it to a string.
$href = (string)$attributes['href'];
if (isset($href[0])) {
// Block 'javascript:' hrefs at the tag level: no well-designed
// application should ever use them, and they are a potent attack vector.
// This function is deep in the core and performance sensitive, so we're
// doing a cheap version of this test first to avoid calling preg_match()
// on URIs which begin with '/' or `#`. These cover essentially all URIs
// in Phabricator.
if (($href[0] !== '/') && ($href[0] !== '#')) {
// Chrome 33 and IE 11 both interpret "javascript\n:" as a Javascript
// URI, and all browsers interpret " javascript:" as a Javascript URI,
// so be aggressive about looking for "javascript:" in the initial
// section of the string.
$normalized_href = preg_replace('([^a-z0-9/:]+)i', '', $href);
if (preg_match('/^javascript:/i', $normalized_href)) {
throw new Exception(
pht(
"Attempting to render a tag with an '%s' attribute that begins ".
"with '%s'. This is either a serious security concern or a ".
"serious architecture concern. Seek urgent remedy.",
'href',
'javascript:'));
}
}
}
}
// For tags which can't self-close, treat null as the empty string -- for
// example, always render `<div></div>`, never `<div />`.
static $self_closing_tags = array(
'area' => true,
'base' => true,
'br' => true,
'col' => true,
'command' => true,
'embed' => true,
'frame' => true,
'hr' => true,
'img' => true,
'input' => true,
'keygen' => true,
'link' => true,
'meta' => true,
'param' => true,
'source' => true,
'track' => true,
'wbr' => true,
);
$attr_string = '';
foreach ($attributes as $k => $v) {
if ($v === null) {
continue;
}
$v = phutil_escape_html($v);
$attr_string .= ' '.$k.'="'.$v.'"';
}
if ($content === null) {
if (isset($self_closing_tags[$tag])) {
return new PhutilSafeHTML('<'.$tag.$attr_string.' />');
} else {
$content = '';
}
} else {
$content = phutil_escape_html($content);
}
return new PhutilSafeHTML('<'.$tag.$attr_string.'>'.$content.'</'.$tag.'>');
}
function phutil_tag_div($class, $content = null) {
return phutil_tag('div', array('class' => $class), $content);
}
function phutil_escape_html($string) {
if ($string === null) {
return '';
}
if ($string instanceof PhutilSafeHTML) {
return $string;
} else if ($string instanceof PhutilSafeHTMLProducerInterface) {
$result = $string->producePhutilSafeHTML();
if ($result instanceof PhutilSafeHTML) {
return phutil_escape_html($result);
} else if (is_array($result)) {
return phutil_escape_html($result);
} else if ($result instanceof PhutilSafeHTMLProducerInterface) {
return phutil_escape_html($result);
} else {
try {
assert_stringlike($result);
return phutil_escape_html((string)$result);
} catch (Exception $ex) {
throw new Exception(
pht(
"Object (of class '%s') implements %s but did not return anything ".
"renderable from %s.",
get_class($string),
'PhutilSafeHTMLProducerInterface',
'producePhutilSafeHTML()'));
}
}
} else if (is_array($string)) {
$result = '';
foreach ($string as $item) {
$result .= phutil_escape_html($item);
}
return $result;
}
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}
function phutil_escape_html_newlines($string) {
return PhutilSafeHTML::applyFunction('nl2br', $string);
}
/**
* Mark string as safe for use in HTML.
*/
function phutil_safe_html($string) {
if ($string == '') {
return $string;
} else if ($string instanceof PhutilSafeHTML) {
return $string;
} else {
return new PhutilSafeHTML($string);
}
}
/**
* HTML safe version of `implode()`.
*/
function phutil_implode_html($glue, array $pieces) {
$glue = phutil_escape_html($glue);
foreach ($pieces as $k => $piece) {
$pieces[$k] = phutil_escape_html($piece);
}
return phutil_safe_html(implode($glue, $pieces));
}
/**
* Format a HTML code. This function behaves like `sprintf()`, except that all
* the normal conversions (like %s) will be properly escaped.
*/
function hsprintf($html /* , ... */) {
$args = func_get_args();
array_shift($args);
return new PhutilSafeHTML(
vsprintf($html, array_map('phutil_escape_html', $args)));
}
diff --git a/src/infrastructure/storage/lisk/LiskDAO.php b/src/infrastructure/storage/lisk/LiskDAO.php
index 1915bd71ee..db6554bacb 100644
--- a/src/infrastructure/storage/lisk/LiskDAO.php
+++ b/src/infrastructure/storage/lisk/LiskDAO.php
@@ -1,1922 +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
+ * @param bool $force_unique (optional)
* @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
*
* @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
*
* @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
* @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 True to force a new connection. The connection
- * will not be retrieved from or saved into the connection cache.
+ * @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
*
* @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
*
* @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
*
* @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
*
* @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
*
* @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
*
* @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
*/
public function openTransaction() {
$this->establishConnection('w')->openTransaction();
return $this;
}
/**
* Decrease transaction stack depth, saving work.
*
* @return this
*/
public function saveTransaction() {
$this->establishConnection('w')->saveTransaction();
return $this;
}
/**
* Decrease transaction stack depth, discarding work.
*
* @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
* @task xaction
*/
public function beginReadLocking() {
$this->establishConnection('w')->beginReadLocking();
return $this;
}
/**
* Ends read-locking that began at an earlier @{method:beginReadLocking} call.
*
* @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
* @task xaction
*/
public function beginWriteLocking() {
$this->establishConnection('w')->beginWriteLocking();
return $this;
}
/**
* Ends write-locking that began at an earlier @{method:beginWriteLocking}
* call.
*
* @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/PhabricatorHash.php b/src/infrastructure/util/PhabricatorHash.php
index 2b3fe8ab4c..7c49703553 100644
--- a/src/infrastructure/util/PhabricatorHash.php
+++ b/src/infrastructure/util/PhabricatorHash.php
@@ -1,282 +1,282 @@
<?php
final class PhabricatorHash extends Phobject {
const INDEX_DIGEST_LENGTH = 12;
const ANCHOR_DIGEST_LENGTH = 12;
/**
* Digest a string using HMAC+SHA1.
*
* Because a SHA1 collision is now known, this method should be considered
* weak. Callers should prefer @{method:digestWithNamedKey}.
*
* @param string $string Input string.
- * @param string? $key
+ * @param string $key (optional)
* @return string 32-byte hexadecimal SHA1+HMAC hash.
*/
public static function weakDigest($string, $key = null) {
if ($key === null) {
$key = PhabricatorEnv::getEnvConfig('security.hmac-key');
}
if (!$key) {
throw new Exception(
pht(
"Set a '%s' in your configuration!",
'security.hmac-key'));
}
return hash_hmac('sha1', $string, $key);
}
/**
* Digest a string for use in, e.g., a MySQL index. This produces a short
* (12-byte), case-sensitive alphanumeric string with 72 bits of entropy,
* which is generally safe in most contexts (notably, URLs).
*
* This method emphasizes compactness, and should not be used for security
* related hashing (for general purpose hashing, see @{method:digest}).
*
* @param string $string Input string.
* @return string 12-byte, case-sensitive, mostly-alphanumeric hash of
* the string.
*/
public static function digestForIndex($string) {
$hash = sha1($string, $raw_output = true);
static $map;
if ($map === null) {
$map = '0123456789'.
'abcdefghij'.
'klmnopqrst'.
'uvwxyzABCD'.
'EFGHIJKLMN'.
'OPQRSTUVWX'.
'YZ._';
}
$result = '';
for ($ii = 0; $ii < self::INDEX_DIGEST_LENGTH; $ii++) {
$result .= $map[(ord($hash[$ii]) & 0x3F)];
}
return $result;
}
/**
* Digest a string for use in HTML page anchors. This is similar to
* @{method:digestForIndex} but produces purely alphanumeric output.
*
* This tries to be mostly compatible with the index digest to limit how
* much stuff we're breaking by switching to it. For additional discussion,
* see T13045.
*
* @param string $string Input string.
* @return string 12-byte, case-sensitive, purely-alphanumeric hash of
* the string.
*/
public static function digestForAnchor($string) {
$hash = sha1($string, $raw_output = true);
static $map;
if ($map === null) {
$map = '0123456789'.
'abcdefghij'.
'klmnopqrst'.
'uvwxyzABCD'.
'EFGHIJKLMN'.
'OPQRSTUVWX'.
'YZ';
}
$result = '';
$accum = 0;
$map_size = strlen($map);
for ($ii = 0; $ii < self::ANCHOR_DIGEST_LENGTH; $ii++) {
$byte = ord($hash[$ii]);
$low_bits = ($byte & 0x3F);
$accum = ($accum + $byte) % $map_size;
if ($low_bits < $map_size) {
// If an index digest would produce any alphanumeric character, just
// use that character. This means that these digests are the same as
// digests created with "digestForIndex()" in all positions where the
// output character is some character other than "." or "_".
$result .= $map[$low_bits];
} else {
// If an index digest would produce a non-alphumeric character ("." or
// "_"), pick an alphanumeric character instead. We accumulate an
// index into the alphanumeric character list to try to preserve
// entropy here. We could use this strategy for all bytes instead,
// but then these digests would differ from digests created with
// "digestForIndex()" in all positions, instead of just a small number
// of positions.
$result .= $map[$accum];
}
}
return $result;
}
public static function digestToRange($string, $min, $max) {
if ($min > $max) {
throw new Exception(pht('Maximum must be larger than minimum.'));
}
if ($min == $max) {
return $min;
}
$hash = sha1($string, $raw_output = true);
// Make sure this ends up positive, even on 32-bit machines.
$value = head(unpack('L', $hash)) & 0x7FFFFFFF;
return $min + ($value % (1 + $max - $min));
}
/**
* Shorten a string to a maximum byte length in a collision-resistant way
* while retaining some degree of human-readability.
*
* This function converts an input string into a prefix plus a hash. For
* example, a very long string beginning with "crabapplepie..." might be
* digested to something like "crabapp-N1wM1Nz3U84k".
*
* This allows the maximum length of identifiers to be fixed while
* maintaining a high degree of collision resistance and a moderate degree
* of human readability.
*
* @param string $string The string to shorten.
* @param int $length Maximum length of the result.
* @return string String shortened in a collision-resistant way.
*/
public static function digestToLength($string, $length) {
// We need at least two more characters than the hash length to fit in a
// a 1-character prefix and a separator.
$min_length = self::INDEX_DIGEST_LENGTH + 2;
if ($length < $min_length) {
throw new Exception(
pht(
'Length parameter in %s must be at least %s, '.
'but %s was provided.',
'digestToLength()',
new PhutilNumber($min_length),
new PhutilNumber($length)));
}
// We could conceivably return the string unmodified if it's shorter than
// the specified length. Instead, always hash it. This makes the output of
// the method more recognizable and consistent (no surprising new behavior
// once you hit a string longer than `$length`) and prevents an attacker
// who can control the inputs from intentionally using the hashed form
// of a string to cause a collision.
$hash = self::digestForIndex($string);
$prefix = substr($string, 0, ($length - ($min_length - 1)));
return $prefix.'-'.$hash;
}
public static function digestWithNamedKey($message, $key_name) {
$key_bytes = self::getNamedHMACKey($key_name);
return self::digestHMACSHA256($message, $key_bytes);
}
public static function digestHMACSHA256($message, $key) {
if (!is_string($message)) {
throw new Exception(
pht('HMAC-SHA256 can only digest strings.'));
}
if (!is_string($key)) {
throw new Exception(
pht('HMAC-SHA256 keys must be strings.'));
}
if (!strlen($key)) {
throw new Exception(
pht('HMAC-SHA256 requires a nonempty key.'));
}
$result = hash_hmac('sha256', $message, $key, $raw_output = false);
// Although "hash_hmac()" is documented as returning `false` when it fails,
// it can also return `null` if you pass an object as the "$message".
if ($result === false || $result === null) {
throw new Exception(
pht('Unable to compute HMAC-SHA256 digest of message.'));
}
return $result;
}
/* -( HMAC Key Management )------------------------------------------------ */
private static function getNamedHMACKey($hmac_name) {
$cache = PhabricatorCaches::getImmutableCache();
$cache_key = "hmac.key({$hmac_name})";
$hmac_key = $cache->getKey($cache_key);
if (($hmac_key === null) || !strlen($hmac_key)) {
$hmac_key = self::readHMACKey($hmac_name);
if ($hmac_key === null) {
$hmac_key = self::newHMACKey($hmac_name);
self::writeHMACKey($hmac_name, $hmac_key);
}
$cache->setKey($cache_key, $hmac_key);
}
// The "hex2bin()" function doesn't exist until PHP 5.4.0 so just
// implement it inline.
$result = '';
for ($ii = 0; $ii < strlen($hmac_key); $ii += 2) {
$result .= pack('H*', substr($hmac_key, $ii, 2));
}
return $result;
}
private static function newHMACKey($hmac_name) {
$hmac_key = Filesystem::readRandomBytes(64);
return bin2hex($hmac_key);
}
private static function writeHMACKey($hmac_name, $hmac_key) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
id(new PhabricatorAuthHMACKey())
->setKeyName($hmac_name)
->setKeyValue($hmac_key)
->save();
unset($unguarded);
}
private static function readHMACKey($hmac_name) {
$table = new PhabricatorAuthHMACKey();
$conn = $table->establishConnection('r');
$row = queryfx_one(
$conn,
'SELECT keyValue FROM %T WHERE keyName = %s',
$table->getTableName(),
$hmac_name);
if (!$row) {
return null;
}
return $row['keyValue'];
}
}
diff --git a/src/view/phui/PHUICrumbsView.php b/src/view/phui/PHUICrumbsView.php
index bd202ed5a7..80613cb61e 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.
+ * @param string $href (optional) href for the crumb.
* @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/PhabricatorStartup.php b/support/startup/PhabricatorStartup.php
index 38c8af13b1..0315c0759f 100644
--- a/support/startup/PhabricatorStartup.php
+++ b/support/startup/PhabricatorStartup.php
@@ -1,793 +1,793 @@
<?php
/**
* Handle request startup, before loading the environment or libraries. This
* class bootstraps the request state up to the point where we can enter
* Phabricator code.
*
* NOTE: This class MUST NOT have any dependencies. It runs before libraries
* load.
*
* Rate Limiting
* =============
*
* Phabricator limits the rate at which clients can request pages, and issues
* HTTP 429 "Too Many Requests" responses if clients request too many pages too
* quickly. Although this is not a complete defense against high-volume attacks,
* it can protect an install against aggressive crawlers, security scanners,
* and some types of malicious activity.
*
* To perform rate limiting, each page increments a score counter for the
* requesting user's IP. The page can give the IP more points for an expensive
* request, or fewer for an authetnicated request.
*
* Score counters are kept in buckets, and writes move to a new bucket every
* minute. After a few minutes (defined by @{method:getRateLimitBucketCount}),
* the oldest bucket is discarded. This provides a simple mechanism for keeping
* track of scores without needing to store, access, or read very much data.
*
* Users are allowed to accumulate up to 1000 points per minute, averaged across
* all of the tracked buckets.
*
* @task info Accessing Request Information
* @task hook Startup Hooks
* @task apocalypse In Case Of Apocalypse
* @task validation Validation
* @task ratelimit Rate Limiting
* @task phases Startup Phase Timers
* @task request-path Request Path
*/
final class PhabricatorStartup {
private static $startTime;
private static $debugTimeLimit;
private static $accessLog;
private static $capturingOutput;
private static $rawInput;
private static $oldMemoryLimit;
private static $phases;
private static $limits = array();
private static $requestPath;
/* -( Accessing Request Information )-------------------------------------- */
/**
* @task info
*/
public static function getStartTime() {
return self::$startTime;
}
/**
* @task info
*/
public static function getMicrosecondsSinceStart() {
// This is the same as "phutil_microseconds_since()", but we may not have
// loaded libraries yet.
return (int)(1000000 * (microtime(true) - self::getStartTime()));
}
/**
* @task info
*/
public static function setAccessLog($access_log) {
self::$accessLog = $access_log;
}
/**
* @task info
*/
public static function getRawInput() {
if (self::$rawInput === null) {
$stream = new AphrontRequestStream();
if (isset($_SERVER['HTTP_CONTENT_ENCODING'])) {
$encoding = trim($_SERVER['HTTP_CONTENT_ENCODING']);
$stream->setEncoding($encoding);
}
$input = '';
do {
$bytes = $stream->readData();
if ($bytes === null) {
break;
}
$input .= $bytes;
} while (true);
self::$rawInput = $input;
}
return self::$rawInput;
}
/* -( Startup Hooks )------------------------------------------------------ */
/**
* @param float $start_time Request start time, from `microtime(true)`.
* @task hook
*/
public static function didStartup($start_time) {
self::$startTime = $start_time;
self::$phases = array();
self::$accessLog = null;
self::$requestPath = null;
static $registered;
if (!$registered) {
// NOTE: This protects us against multiple calls to didStartup() in the
// same request, but also against repeated requests to the same
// interpreter state, which we may implement in the future.
register_shutdown_function(array(__CLASS__, 'didShutdown'));
$registered = true;
}
self::setupPHP();
self::verifyPHP();
// If we've made it this far, the environment isn't completely broken so
// we can switch over to relying on our own exception recovery mechanisms.
ini_set('display_errors', 0);
self::connectRateLimits();
self::normalizeInput();
self::readRequestPath();
self::beginOutputCapture();
}
/**
* @task hook
*/
public static function didShutdown() {
// Disconnect any active rate limits before we shut down. If we don't do
// this, requests which exit early will lock a slot in any active
// connection limits, and won't count for rate limits.
self::disconnectRateLimits(array());
$event = error_get_last();
if (!$event) {
return;
}
switch ($event['type']) {
case E_ERROR:
case E_PARSE:
case E_COMPILE_ERROR:
break;
default:
return;
}
$msg = ">>> UNRECOVERABLE FATAL ERROR <<<\n\n";
if ($event) {
// Even though we should be emitting this as text-plain, escape things
// just to be sure since we can't really be sure what the program state
// is when we get here.
$msg .= htmlspecialchars(
$event['message']."\n\n".$event['file'].':'.$event['line'],
ENT_QUOTES,
'UTF-8');
}
// flip dem tables
$msg .= "\n\n\n";
$msg .= "\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb\x20\xef\xb8\xb5\x20\xc2\xaf".
"\x5c\x5f\x28\xe3\x83\x84\x29\x5f\x2f\xc2\xaf\x20\xef\xb8\xb5\x20".
"\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb";
self::didFatal($msg);
}
public static function loadCoreLibraries() {
$phabricator_root = dirname(dirname(dirname(__FILE__)));
$libraries_root = dirname($phabricator_root);
$root = null;
if (!empty($_SERVER['PHUTIL_LIBRARY_ROOT'])) {
$root = $_SERVER['PHUTIL_LIBRARY_ROOT'];
}
ini_set(
'include_path',
$libraries_root.PATH_SEPARATOR.ini_get('include_path'));
$ok = @include_once $root.'arcanist/src/init/init-library.php';
if (!$ok) {
self::didFatal(
'Unable to load the "Arcanist" library. Put "arcanist/" next to '.
'"phorge/" on disk.');
}
// Load Phabricator itself using the absolute path, so we never end up doing
// anything surprising (loading index.php and libraries from different
// directories).
phutil_load_library($phabricator_root.'/src');
}
/* -( Output Capture )----------------------------------------------------- */
public static function beginOutputCapture() {
if (self::$capturingOutput) {
self::didFatal('Already capturing output!');
}
self::$capturingOutput = true;
ob_start();
}
public static function endOutputCapture() {
if (!self::$capturingOutput) {
return null;
}
self::$capturingOutput = false;
return ob_get_clean();
}
/* -( Debug Time Limit )--------------------------------------------------- */
/**
* Set a time limit (in seconds) for the current script. After time expires,
* the script fatals.
*
* This works like `max_execution_time`, but prints out a useful stack trace
* when the time limit expires. This is primarily intended to make it easier
* to debug pages which hang by allowing extraction of a stack trace: set a
* short debug limit, then use the trace to figure out what's happening.
*
* The limit is implemented with a tick function, so enabling it implies
* some accounting overhead.
*
* @param int $limit Time limit in seconds.
* @return void
*/
public static function setDebugTimeLimit($limit) {
self::$debugTimeLimit = $limit;
static $initialized = false;
if (!$initialized) {
declare(ticks=1);
register_tick_function(array(__CLASS__, 'onDebugTick'));
$initialized = true;
}
}
/**
* Callback tick function used by @{method:setDebugTimeLimit}.
*
* Fatals with a useful stack trace after the time limit expires.
*
* @return void
*/
public static function onDebugTick() {
$limit = self::$debugTimeLimit;
if (!$limit) {
return;
}
$elapsed = (microtime(true) - self::getStartTime());
if ($elapsed > $limit) {
$frames = array();
foreach (debug_backtrace() as $frame) {
$file = isset($frame['file']) ? $frame['file'] : '-';
$file = basename($file);
$line = isset($frame['line']) ? $frame['line'] : '-';
$class = isset($frame['class']) ? $frame['class'].'->' : null;
$func = isset($frame['function']) ? $frame['function'].'()' : '?';
$frames[] = "{$file}:{$line} {$class}{$func}";
}
self::didFatal(
"Request aborted by debug time limit after {$limit} seconds.\n\n".
"STACK TRACE\n".
implode("\n", $frames));
}
}
/* -( In Case of Apocalypse )---------------------------------------------- */
/**
* Fatal the request completely in response to an exception, sending a plain
* text message to the client. Calls @{method:didFatal} internally.
*
* @param string $note Brief description of the exception context, like
* `"Rendering Exception"`.
* @param Throwable $ex The exception itself.
* @param bool $show_trace True if it's okay to show the exception's
* stack trace to the user. The trace will always be
* logged.
*
* @task apocalypse
*/
public static function didEncounterFatalException(
$note,
$ex,
$show_trace) {
$message = '['.$note.'/'.get_class($ex).'] '.$ex->getMessage();
$full_message = $message;
$full_message .= "\n\n";
$full_message .= $ex->getTraceAsString();
if ($show_trace) {
$message = $full_message;
}
self::didFatal($message, $full_message);
}
/**
* Fatal the request completely, sending a plain text message to the client.
*
* @param string $message Plain text message to send to the client.
- * @param string? $log_message Plain text message to send to the error log.
- * If not provided, the client message is used. You can pass
- * a more detailed message here (e.g., with stack traces) to
- * avoid showing it to users.
+ * @param string $log_message (optional) Plain text message to send to the
+ * error log. If not provided, the client message is used.
+ * You can pass a more detailed message here (e.g., with
+ * stack traces) to avoid showing it to users.
* @return exit This method **does not return**.
*
* @task apocalypse
*/
public static function didFatal($message, $log_message = null) {
if ($log_message === null) {
$log_message = $message;
}
self::endOutputCapture();
$access_log = self::$accessLog;
if ($access_log) {
// We may end up here before the access log is initialized, e.g. from
// verifyPHP().
$access_log->setData(
array(
'c' => 500,
));
$access_log->write();
}
header(
'Content-Type: text/plain; charset=utf-8',
$replace = true,
$http_error = 500);
error_log($log_message);
echo $message."\n";
exit(1);
}
/* -( Validation )--------------------------------------------------------- */
/**
* @task validation
*/
private static function setupPHP() {
error_reporting(E_ALL | E_STRICT);
self::$oldMemoryLimit = ini_get('memory_limit');
ini_set('memory_limit', -1);
// If we have libxml, disable the incredibly dangerous entity loader.
// PHP 8 deprecates this function and disables this by default; remove once
// PHP 7 is no longer supported or a future version has removed the function
// entirely.
if (function_exists('libxml_disable_entity_loader')) {
@libxml_disable_entity_loader(true);
}
// See T13060. If the locale for this process (the parent process) is not
// a UTF-8 locale we can encounter problems when launching subprocesses
// which receive UTF-8 parameters in their command line argument list.
@setlocale(LC_ALL, 'en_US.UTF-8');
$config_map = array(
// See PHI1894. Keep "args" in exception backtraces.
'zend.exception_ignore_args' => 0,
// See T13100. We'd like the regex engine to fail, rather than segfault,
// if handed a pathological regular expression.
'pcre.backtrack_limit' => 10000,
'pcre.recusion_limit' => 10000,
// NOTE: Arcanist applies a similar set of startup options for CLI
// environments in "init-script.php". Changes here may also be
// appropriate to apply there.
);
foreach ($config_map as $config_key => $config_value) {
ini_set($config_key, $config_value);
}
}
/**
* @task validation
*/
public static function getOldMemoryLimit() {
return self::$oldMemoryLimit;
}
/**
* @task validation
*/
private static function normalizeInput() {
// Replace superglobals with unfiltered versions, disrespect php.ini (we
// filter ourselves).
// NOTE: We don't filter INPUT_SERVER because we don't want to overwrite
// changes made in "preamble.php".
// NOTE: WE don't filter INPUT_POST because we may be constructing it
// lazily if "enable_post_data_reading" is disabled.
$filter = array(
INPUT_GET,
INPUT_ENV,
INPUT_COOKIE,
);
foreach ($filter as $type) {
$filtered = filter_input_array($type, FILTER_UNSAFE_RAW);
if (!is_array($filtered)) {
continue;
}
switch ($type) {
case INPUT_GET:
$_GET = array_merge($_GET, $filtered);
break;
case INPUT_COOKIE:
$_COOKIE = array_merge($_COOKIE, $filtered);
break;
case INPUT_ENV;
$env = array_merge($_ENV, $filtered);
$_ENV = self::filterEnvSuperglobal($env);
break;
}
}
self::rebuildRequest();
}
/**
* @task validation
*/
public static function rebuildRequest() {
// Rebuild $_REQUEST, respecting order declared in ".ini" files.
$order = ini_get('request_order');
if (!$order) {
$order = ini_get('variables_order');
}
if (!$order) {
// $_REQUEST will be empty, so leave it alone.
return;
}
$_REQUEST = array();
for ($ii = 0; $ii < strlen($order); $ii++) {
switch ($order[$ii]) {
case 'G':
$_REQUEST = array_merge($_REQUEST, $_GET);
break;
case 'P':
$_REQUEST = array_merge($_REQUEST, $_POST);
break;
case 'C':
$_REQUEST = array_merge($_REQUEST, $_COOKIE);
break;
default:
// $_ENV and $_SERVER never go into $_REQUEST.
break;
}
}
}
/**
* Adjust `$_ENV` before execution.
*
* Adjustments here primarily impact the environment as seen by subprocesses.
* The environment is forwarded explicitly by @{class:ExecFuture}.
*
* @param map<string, wild> $env Input `$_ENV`.
* @return map<string, string> Suitable `$_ENV`.
* @task validation
*/
private static function filterEnvSuperglobal(array $env) {
// In some configurations, we may get "argc" and "argv" set in $_ENV.
// These are not real environmental variables, and "argv" may have an array
// value which can not be forwarded to subprocesses. Remove these from the
// environment if they are present.
unset($env['argc']);
unset($env['argv']);
return $env;
}
/**
* @task validation
*/
private static function verifyPHP() {
$required_version = '5.2.3';
if (version_compare(PHP_VERSION, $required_version) < 0) {
self::didFatal(
"You are running PHP version '".PHP_VERSION."', which is older than ".
"the minimum version, '{$required_version}'. Update to at least ".
"'{$required_version}'.");
}
if (function_exists('get_magic_quotes_gpc')) {
if (@get_magic_quotes_gpc()) {
self::didFatal(
'Your server is configured with the PHP language feature '.
'"magic_quotes_gpc" enabled.'.
"\n\n".
'This feature is "highly discouraged" by PHP\'s developers, and '.
'has been removed entirely in PHP8.'.
"\n\n".
'You must disable "magic_quotes_gpc" to run Phabricator. Consult '.
'the PHP manual for instructions.');
}
}
if (extension_loaded('apc')) {
$apc_version = phpversion('apc');
$known_bad = array(
'3.1.14' => true,
'3.1.15' => true,
'3.1.15-dev' => true,
);
if (isset($known_bad[$apc_version])) {
self::didFatal(
"You have APC {$apc_version} installed. This version of APC is ".
"known to be bad, and does not work with Phabricator (it will ".
"cause Phabricator to fatal unrecoverably with nonsense errors). ".
"Downgrade to version 3.1.13.");
}
}
if (isset($_SERVER['HTTP_PROXY'])) {
self::didFatal(
'This HTTP request included a "Proxy:" header, poisoning the '.
'environment (CVE-2016-5385 / httpoxy). Declining to process this '.
'request. For details, see: https://secure.phabricator.com/T11359');
}
}
/**
* @task request-path
*/
private static function readRequestPath() {
// See T13575. The request path may be provided in:
//
// - the "$_GET" parameter "__path__" (normal for Apache and nginx); or
// - the "$_SERVER" parameter "REQUEST_URI" (normal for the PHP builtin
// webserver).
//
// Locate it wherever it is, and store it for later use. Note that writing
// to "$_REQUEST" here won't always work, because later code may rebuild
// "$_REQUEST" from other sources.
if (isset($_REQUEST['__path__']) && strlen($_REQUEST['__path__'])) {
self::setRequestPath($_REQUEST['__path__']);
return;
}
// Compatibility with PHP 5.4+ built-in web server.
if (php_sapi_name() == 'cli-server') {
$path = parse_url($_SERVER['REQUEST_URI']);
self::setRequestPath($path['path']);
return;
}
if (!isset($_REQUEST['__path__'])) {
self::didFatal(
"Request parameter '__path__' is not set. Your rewrite rules ".
"are not configured correctly.");
}
if (!strlen($_REQUEST['__path__'])) {
self::didFatal(
"Request parameter '__path__' is set, but empty. Your rewrite rules ".
"are not configured correctly. The '__path__' should always ".
"begin with a '/'.");
}
}
/**
* @task request-path
*/
public static function getRequestPath() {
$path = self::$requestPath;
if ($path === null) {
self::didFatal(
'Request attempted to access request path, but no request path is '.
'available for this request. You may be calling web request code '.
'from a non-request context, or your webserver may not be passing '.
'a request path to Phabricator in a format that it understands.');
}
return $path;
}
/**
* @task request-path
*/
public static function setRequestPath($path) {
self::$requestPath = $path;
}
/* -( Rate Limiting )------------------------------------------------------ */
/**
* Add a new client limits.
*
* @param PhabricatorClientLimit $limit New limit.
* @return PhabricatorClientLimit The limit.
*/
public static function addRateLimit(PhabricatorClientLimit $limit) {
self::$limits[] = $limit;
return $limit;
}
/**
* Apply configured rate limits.
*
* If any limit is exceeded, this method terminates the request.
*
* @return void
* @task ratelimit
*/
private static function connectRateLimits() {
$limits = self::$limits;
$reason = null;
$connected = array();
foreach ($limits as $limit) {
$reason = $limit->didConnect();
$connected[] = $limit;
if ($reason !== null) {
break;
}
}
// If we're killing the request here, disconnect any limits that we
// connected to try to keep the accounting straight.
if ($reason !== null) {
foreach ($connected as $limit) {
$limit->didDisconnect(array());
}
self::didRateLimit($reason);
}
}
/**
* Tear down rate limiting and allow limits to score the request.
*
* @param map<string, wild> $request_state Additional, freeform request
* state.
* @return void
* @task ratelimit
*/
public static function disconnectRateLimits(array $request_state) {
$limits = self::$limits;
// Remove all limits before disconnecting them so this works properly if
// it runs twice. (We run this automatically as a shutdown handler.)
self::$limits = array();
foreach ($limits as $limit) {
$limit->didDisconnect($request_state);
}
}
/**
* Emit an HTTP 429 "Too Many Requests" response (indicating that the user
* has exceeded application rate limits) and exit.
*
* @return exit This method **does not return**.
* @task ratelimit
*/
private static function didRateLimit($reason) {
header(
'Content-Type: text/plain; charset=utf-8',
$replace = true,
$http_error = 429);
echo $reason;
exit(1);
}
/* -( Startup Timers )----------------------------------------------------- */
/**
* Record the beginning of a new startup phase.
*
* For phases which occur before @{class:PhabricatorStartup} loads, save the
* time and record it with @{method:recordStartupPhase} after the class is
* available.
*
* @param string $phase Phase name.
* @task phases
*/
public static function beginStartupPhase($phase) {
self::recordStartupPhase($phase, microtime(true));
}
/**
* Record the start time of a previously executed startup phase.
*
* For startup phases which occur after @{class:PhabricatorStartup} loads,
* use @{method:beginStartupPhase} instead. This method can be used to
* record a time before the class loads, then hand it over once the class
* becomes available.
*
* @param string $phase Phase name.
* @param float $time Phase start time, from `microtime(true)`.
* @task phases
*/
public static function recordStartupPhase($phase, $time) {
self::$phases[$phase] = $time;
}
/**
* Get information about startup phase timings.
*
* Sometimes, performance problems can occur before we start the profiler.
* Since the profiler can't examine these phases, it isn't useful in
* understanding their performance costs.
*
* Instead, the startup process marks when it enters various phases using
* @{method:beginStartupPhase}. A later call to this method can retrieve this
* information, which can be examined to gain greater insight into where
* time was spent. The output is still crude, but better than nothing.
*
* @task phases
*/
public static function getPhases() {
return self::$phases;
}
}
diff --git a/support/startup/preamble-utils.php b/support/startup/preamble-utils.php
index c04f06b900..d08f51b69e 100644
--- a/support/startup/preamble-utils.php
+++ b/support/startup/preamble-utils.php
@@ -1,77 +1,77 @@
<?php
/**
* Parse the "X_FORWARDED_FOR" HTTP header to determine the original client
* address.
*
- * @param int? $layers Number of devices to trust.
+ * @param int $layers (optional) Number of devices to trust.
* @return void
*/
function preamble_trust_x_forwarded_for_header($layers = 1) {
if (!is_int($layers) || ($layers < 1)) {
echo
'preamble_trust_x_forwarded_for_header(<layers>): '.
'"layers" parameter must an integer larger than 0.'."\n";
echo "\n";
exit(1);
}
if (!isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return;
}
$forwarded_for = $_SERVER['HTTP_X_FORWARDED_FOR'];
if (!strlen($forwarded_for)) {
return;
}
$address = preamble_get_x_forwarded_for_address($forwarded_for, $layers);
$_SERVER['REMOTE_ADDR'] = $address;
}
function preamble_get_x_forwarded_for_address($raw_header, $layers) {
// The raw header may be a list of IPs, like "1.2.3.4, 4.5.6.7", if the
// request the load balancer received also had this header. In particular,
// this happens routinely with requests received through a CDN, but can also
// happen illegitimately if the client just makes up an "X-Forwarded-For"
// header full of lies.
// We can only trust the N elements at the end of the list which correspond
// to network-adjacent devices we control. Usually, we're behind a single
// load balancer and "N" is 1, so we want to take the last element in the
// list.
// In some cases, "N" may be more than 1, if the network is configured so
// that that requests are routed through multiple layers of load balancers
// and proxies. In this case, we want to take the Nth-to-last element of
// the list.
$addresses = explode(',', $raw_header);
// If we have more than one trustworthy device on the network path, discard
// corresponding elements from the list. For example, if we have 7 devices,
// we want to discard the last 6 elements of the list.
// The final device address does not appear in the list, since devices do
// not append their own addresses to "X-Forwarded-For".
$discard_addresses = ($layers - 1);
// However, we don't want to throw away all of the addresses. Some requests
// may originate from within the network, and may thus not have as many
// addresses as we expect. If we have fewer addresses than trustworthy
// devices, discard all but one address.
$max_discard = (count($addresses) - 1);
$discard_count = min($discard_addresses, $max_discard);
if ($discard_count) {
$addresses = array_slice($addresses, 0, -$discard_count);
}
$original_address = end($addresses);
$original_address = trim($original_address);
return $original_address;
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 25, 1:05 AM (1 d, 14 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1131
Default Alt Text
(693 KB)

Event Timeline