Page MenuHomestyx hydra

No OneTemporary

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/scripts/setup/manage_garbage.php b/scripts/setup/manage_garbage.php
index ba727eab60..326730375e 100755
--- a/scripts/setup/manage_garbage.php
+++ b/scripts/setup/manage_garbage.php
@@ -1,21 +1,21 @@
#!/usr/bin/env php
<?php
$root = dirname(dirname(dirname(__FILE__)));
require_once $root.'/scripts/__init_script__.php';
$args = new PhutilArgumentParser($argv);
-$args->setTagline(pht('manage garbage colletors'));
+$args->setTagline(pht('manage garbage collectors'));
$args->setSynopsis(<<<EOSYNOPSIS
**garbage** __command__ [__options__]
Manage garbage collectors.
EOSYNOPSIS
);
$args->parseStandardArguments();
$workflows = id(new PhutilClassMapQuery())
->setAncestorClass('PhabricatorGarbageCollectorManagementWorkflow')
->execute();
$workflows[] = new PhutilHelpArgumentWorkflow();
$args->parseWorkflows($workflows);
diff --git a/src/aphront/httpparametertype/AphrontHTTPParameterType.php b/src/aphront/httpparametertype/AphrontHTTPParameterType.php
index 995556a6b9..78a62a663c 100644
--- a/src/aphront/httpparametertype/AphrontHTTPParameterType.php
+++ b/src/aphront/httpparametertype/AphrontHTTPParameterType.php
@@ -1,309 +1,309 @@
<?php
/**
* Defines how to read a complex value from an HTTP request.
*
* Most HTTP parameters are simple (like strings or integers) but some
* parameters accept more complex values (like lists of users or project names).
*
* This class handles reading simple and complex values from a request,
* performing any required parsing or lookups, and returning a result in a
* standard format.
*
* @task read Reading Values from a Request
* @task info Information About the Type
* @task util Parsing Utilities
* @task impl Implementation
*/
abstract class AphrontHTTPParameterType extends Phobject {
private $viewer;
/* -( Reading Values from a Request )-------------------------------------- */
/**
* Set the current viewer.
*
* Some parameter types perform complex parsing involving lookups. For
* example, a type might lookup usernames or project names. These types need
* to use the current viewer to execute queries.
*
* @param PhabricatorUser Current viewer.
* @return this
* @task read
*/
final public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
/**
* Get the current viewer.
*
* @return PhabricatorUser Current viewer.
* @task read
*/
final public function getViewer() {
if (!$this->viewer) {
throw new PhutilInvalidStateException('setViewer');
}
return $this->viewer;
}
/**
* Test if a value is present in a request.
*
* @param AphrontRequest The incoming request.
* @param string The key to examine.
* @return bool True if a readable value is present in the request.
* @task read
*/
final public function getExists(AphrontRequest $request, $key) {
return $this->getParameterExists($request, $key);
}
/**
* Read a value from a request.
*
* If the value is not present, a default value is returned (usually `null`).
* Use @{method:getExists} to test if a value is present.
*
* @param AphrontRequest The incoming request.
* @param string The key to examine.
* @return wild Value, or default if value is not present.
* @task read
*/
final public function getValue(AphrontRequest $request, $key) {
if (!$this->getExists($request, $key)) {
return $this->getParameterDefault();
}
return $this->getParameterValue($request, $key);
}
/**
* Get the default value for this parameter type.
*
* @return wild Default value for this type.
* @task read
*/
final public function getDefaultValue() {
return $this->getParameterDefault();
}
/* -( Information About the Type )----------------------------------------- */
/**
* Get a short name for this type, like `string` or `list<phid>`.
*
* @return string Short type name.
* @task info
*/
final public function getTypeName() {
return $this->getParameterTypeName();
}
/**
* Get a list of human-readable descriptions of acceptable formats for this
* type.
*
* For example, a type might return strings like these:
*
* > Any positive integer.
* > A comma-separated list of PHIDs.
*
* This is used to explain to users how to specify a type when generating
* documentation.
*
* @return list<string> Human-readable list of acceptable formats.
* @task info
*/
final public function getFormatDescriptions() {
return $this->getParameterFormatDescriptions();
}
/**
* Get a list of human-readable examples of how to format this type as an
* HTTP GET parameter.
*
* For example, a type might return strings like these:
*
* > v=123
* > v[]=1&v[]=2
*
* This is used to show users how to specify parameters of this type in
* generated documentation.
*
* @return list<string> Human-readable list of format examples.
* @task info
*/
final public function getExamples() {
return $this->getParameterExamples();
}
/* -( Utilities )---------------------------------------------------------- */
/**
* Call another type's existence check.
*
- * This method allows a type to reuse the exitence behavior of a different
+ * This method allows a type to reuse the existence behavior of a different
* type. For example, a "list of users" type may have the same basic
* existence check that a simpler "list of strings" type has, and can just
* call the simpler type to reuse its behavior.
*
* @param AphrontHTTPParameterType The other type.
* @param AphrontRequest Incoming request.
* @param string Key to examine.
* @return bool True if the parameter exists.
* @task util
*/
final protected function getExistsWithType(
AphrontHTTPParameterType $type,
AphrontRequest $request,
$key) {
$type->setViewer($this->getViewer());
return $type->getParameterExists($request, $key);
}
/**
* Call another type's value parser.
*
* This method allows a type to reuse the parsing behavior of a different
* type. For example, a "list of users" type may start by running the same
* basic parsing that a simpler "list of strings" type does.
*
* @param AphrontHTTPParameterType The other type.
* @param AphrontRequest Incoming request.
* @param string Key to examine.
* @return wild Parsed value.
* @task util
*/
final protected function getValueWithType(
AphrontHTTPParameterType $type,
AphrontRequest $request,
$key) {
$type->setViewer($this->getViewer());
return $type->getValue($request, $key);
}
/**
* Get a list of all available parameter types.
*
* @return list<AphrontHTTPParameterType> List of all available types.
* @task util
*/
final public static function getAllTypes() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getTypeName')
->setSortMethod('getTypeName')
->execute();
}
/* -( Implementation )----------------------------------------------------- */
/**
* Test if a parameter exists in a request.
*
* See @{method:getExists}. By default, this method tests if the key is
* present in the request.
*
* To call another type's behavior in order to perform this check, use
* @{method:getExistsWithType}.
*
* @param AphrontRequest The incoming request.
* @param string The key to examine.
* @return bool True if a readable value is present in the request.
* @task impl
*/
protected function getParameterExists(AphrontRequest $request, $key) {
return $request->getExists($key);
}
/**
* Parse a value from a request.
*
* See @{method:getValue}. This method will //only// be called if this type
* has already asserted that the value exists with
* @{method:getParameterExists}.
*
* To call another type's behavior in order to parse a value, use
* @{method:getValueWithType}.
*
* @param AphrontRequest The incoming request.
* @param string The key to examine.
* @return wild Parsed value.
* @task impl
*/
abstract protected function getParameterValue(AphrontRequest $request, $key);
/**
* Return a simple type name string, like "string" or "list<phid>".
*
* See @{method:getTypeName}.
*
* @return string Short type name.
* @task impl
*/
abstract protected function getParameterTypeName();
/**
* Return a human-readable list of format descriptions.
*
* See @{method:getFormatDescriptions}.
*
* @return list<string> Human-readable list of acceptable formats.
* @task impl
*/
abstract protected function getParameterFormatDescriptions();
/**
* Return a human-readable list of examples.
*
* See @{method:getExamples}.
*
* @return list<string> Human-readable list of format examples.
* @task impl
*/
abstract protected function getParameterExamples();
/**
* Return the default value for this parameter type.
*
* See @{method:getDefaultValue}. If unspecified, the default is `null`.
*
* @return wild Default value.
* @task impl
*/
protected function getParameterDefault() {
return null;
}
}
diff --git a/src/applications/almanac/editor/AlmanacDeviceEditor.php b/src/applications/almanac/editor/AlmanacDeviceEditor.php
index 64b8de7bad..8e110954a3 100644
--- a/src/applications/almanac/editor/AlmanacDeviceEditor.php
+++ b/src/applications/almanac/editor/AlmanacDeviceEditor.php
@@ -1,335 +1,335 @@
<?php
final class AlmanacDeviceEditor
extends AlmanacEditor {
public function getEditorObjectsDescription() {
return pht('Almanac Device');
}
public function getTransactionTypes() {
$types = parent::getTransactionTypes();
$types[] = AlmanacDeviceTransaction::TYPE_NAME;
$types[] = AlmanacDeviceTransaction::TYPE_INTERFACE;
$types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
$types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
return $types;
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case AlmanacDeviceTransaction::TYPE_NAME:
return $object->getName();
}
return parent::getCustomTransactionOldValue($object, $xaction);
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case AlmanacDeviceTransaction::TYPE_NAME:
case AlmanacDeviceTransaction::TYPE_INTERFACE:
return $xaction->getNewValue();
}
return parent::getCustomTransactionNewValue($object, $xaction);
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case AlmanacDeviceTransaction::TYPE_NAME:
$object->setName($xaction->getNewValue());
return;
case AlmanacDeviceTransaction::TYPE_INTERFACE:
return;
}
return parent::applyCustomInternalTransaction($object, $xaction);
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case AlmanacDeviceTransaction::TYPE_NAME:
return;
case AlmanacDeviceTransaction::TYPE_INTERFACE:
$old = $xaction->getOldValue();
if ($old) {
$interface = id(new AlmanacInterfaceQuery())
->setViewer($this->requireActor())
->withIDs(array($old['id']))
->executeOne();
if (!$interface) {
throw new Exception(pht('Unable to load interface!'));
}
} else {
$interface = AlmanacInterface::initializeNewInterface()
->setDevicePHID($object->getPHID());
}
$new = $xaction->getNewValue();
if ($new) {
$interface
->setNetworkPHID($new['networkPHID'])
->setAddress($new['address'])
->setPort((int)$new['port']);
if (idx($new, 'phid')) {
$interface->setPHID($new['phid']);
}
$interface->save();
} else {
$interface->delete();
}
return;
}
return parent::applyCustomExternalTransaction($object, $xaction);
}
protected function validateTransaction(
PhabricatorLiskDAO $object,
$type,
array $xactions) {
$errors = parent::validateTransaction($object, $type, $xactions);
switch ($type) {
case AlmanacDeviceTransaction::TYPE_NAME:
$missing = $this->validateIsEmptyTextField(
$object->getName(),
$xactions);
if ($missing) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Required'),
pht('Device name is required.'),
nonempty(last($xactions), null));
$error->setIsMissingFieldError(true);
$errors[] = $error;
} else {
foreach ($xactions as $xaction) {
$message = null;
$name = $xaction->getNewValue();
try {
AlmanacNames::validateName($name);
} catch (Exception $ex) {
$message = $ex->getMessage();
}
if ($message !== null) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
$message,
$xaction);
$errors[] = $error;
continue;
}
$other = id(new AlmanacDeviceQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withNames(array($name))
->executeOne();
if ($other && ($other->getID() != $object->getID())) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Not Unique'),
pht('Almanac devices must have unique names.'),
$xaction);
$errors[] = $error;
continue;
}
if ($name === $object->getName()) {
continue;
}
$namespace = AlmanacNamespace::loadRestrictedNamespace(
$this->getActor(),
$name);
if ($namespace) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Restricted'),
pht(
'You do not have permission to create Almanac devices '.
'within the "%s" namespace.',
$namespace->getName()),
$xaction);
$errors[] = $error;
continue;
}
}
}
break;
case AlmanacDeviceTransaction::TYPE_INTERFACE:
// We want to make sure that all the affected networks are visible to
// the actor, any edited interfaces exist, and that the actual address
// components are valid.
$network_phids = array();
foreach ($xactions as $xaction) {
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
if ($old) {
$network_phids[] = $old['networkPHID'];
}
if ($new) {
$network_phids[] = $new['networkPHID'];
$address = $new['address'];
if (!strlen($address)) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht('Interfaces must have an address.'),
$xaction);
$errors[] = $error;
} else {
- // TODO: Validate addresses, but IPv6 addresses are not trival
+ // TODO: Validate addresses, but IPv6 addresses are not trivial
// to validate.
}
$port = $new['port'];
if (!strlen($port)) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht('Interfaces must have a port.'),
$xaction);
$errors[] = $error;
} else if ((int)$port < 1 || (int)$port > 65535) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht(
'Port numbers must be between 1 and 65535, inclusive.'),
$xaction);
$errors[] = $error;
}
$phid = idx($new, 'phid');
if ($phid) {
$interface_phid_type = AlmanacInterfacePHIDType::TYPECONST;
if (phid_get_type($phid) !== $interface_phid_type) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht(
'Precomputed interface PHIDs must be of type '.
'AlmanacInterfacePHIDType.'),
$xaction);
$errors[] = $error;
}
}
}
}
if ($network_phids) {
$networks = id(new AlmanacNetworkQuery())
->setViewer($this->requireActor())
->withPHIDs($network_phids)
->execute();
$networks = mpull($networks, null, 'getPHID');
} else {
$networks = array();
}
$addresses = array();
foreach ($xactions as $xaction) {
$old = $xaction->getOldValue();
if ($old) {
$network = idx($networks, $old['networkPHID']);
if (!$network) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht(
'You can not edit an interface which belongs to a '.
'nonexistent or restricted network.'),
$xaction);
$errors[] = $error;
}
$addresses[] = $old['id'];
}
$new = $xaction->getNewValue();
if ($new) {
$network = idx($networks, $new['networkPHID']);
if (!$network) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht(
'You can not add an interface on a nonexistent or '.
'restricted network.'),
$xaction);
$errors[] = $error;
}
}
}
if ($addresses) {
$interfaces = id(new AlmanacInterfaceQuery())
->setViewer($this->requireActor())
->withDevicePHIDs(array($object->getPHID()))
->withIDs($addresses)
->execute();
$interfaces = mpull($interfaces, null, 'getID');
} else {
$interfaces = array();
}
foreach ($xactions as $xaction) {
$old = $xaction->getOldValue();
if ($old) {
$interface = idx($interfaces, $old['id']);
if (!$interface) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht('You can not edit an invalid or restricted interface.'),
$xaction);
$errors[] = $error;
continue;
}
$new = $xaction->getNewValue();
if (!$new) {
if ($interface->loadIsInUse()) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('In Use'),
pht('You can not delete an interface which is still in use.'),
$xaction);
$errors[] = $error;
}
}
}
}
break;
}
return $errors;
}
}
diff --git a/src/applications/audit/view/PhabricatorAuditListView.php b/src/applications/audit/view/PhabricatorAuditListView.php
index f9399e66ab..cb9fecac3a 100644
--- a/src/applications/audit/view/PhabricatorAuditListView.php
+++ b/src/applications/audit/view/PhabricatorAuditListView.php
@@ -1,182 +1,182 @@
<?php
final class PhabricatorAuditListView extends AphrontView {
private $commits = array();
private $header;
private $showDrafts;
private $noDataString;
private $highlightedAudits;
public function setNoDataString($no_data_string) {
$this->noDataString = $no_data_string;
return $this;
}
public function getNoDataString() {
return $this->noDataString;
}
public function setHeader($header) {
$this->header = $header;
return $this;
}
public function getHeader() {
return $this->header;
}
public function setShowDrafts($show_drafts) {
$this->showDrafts = $show_drafts;
return $this;
}
public function getShowDrafts() {
return $this->showDrafts;
}
/**
* These commits should have both commit data and audit requests attached.
*/
public function setCommits(array $commits) {
assert_instances_of($commits, 'PhabricatorRepositoryCommit');
$this->commits = mpull($commits, null, 'getPHID');
return $this;
}
public function getCommits() {
return $this->commits;
}
private function getCommitDescription($phid) {
if ($this->commits === null) {
return pht('(Unknown Commit)');
}
$commit = idx($this->commits, $phid);
if (!$commit) {
return pht('(Unknown Commit)');
}
$summary = $commit->getCommitData()->getSummary();
if (strlen($summary)) {
return $summary;
}
- // No summary, so either this is still impoting or just has an empty
+ // No summary, so either this is still importing or just has an empty
// commit message.
if (!$commit->isImported()) {
return pht('(Importing Commit...)');
} else {
return pht('(Untitled Commit)');
}
}
public function render() {
$list = $this->buildList();
$list->setFlush(true);
return $list->render();
}
public function buildList() {
$viewer = $this->getViewer();
$rowc = array();
$phids = array();
foreach ($this->getCommits() as $commit) {
$phids[] = $commit->getPHID();
foreach ($commit->getAudits() as $audit) {
$phids[] = $audit->getAuditorPHID();
}
$author_phid = $commit->getAuthorPHID();
if ($author_phid) {
$phids[] = $author_phid;
}
}
$handles = $viewer->loadHandles($phids);
$show_drafts = $this->getShowDrafts();
$draft_icon = id(new PHUIIconView())
->setIcon('fa-comment yellow')
->addSigil('has-tooltip')
->setMetadata(
array(
'tip' => pht('Unsubmitted Comments'),
));
$list = new PHUIObjectItemListView();
foreach ($this->commits as $commit) {
$commit_phid = $commit->getPHID();
$commit_handle = $handles[$commit_phid];
$committed = null;
$commit_name = $commit_handle->getName();
$commit_link = $commit_handle->getURI();
$commit_desc = $this->getCommitDescription($commit_phid);
$committed = phabricator_datetime($commit->getEpoch(), $viewer);
$status = $commit->getAuditStatus();
$status_text =
PhabricatorAuditCommitStatusConstants::getStatusName($status);
$status_color =
PhabricatorAuditCommitStatusConstants::getStatusColor($status);
$status_icon =
PhabricatorAuditCommitStatusConstants::getStatusIcon($status);
$author_phid = $commit->getAuthorPHID();
if ($author_phid) {
$author_name = $handles[$author_phid]->renderLink();
} else {
$author_name = $commit->getCommitData()->getAuthorName();
}
$item = id(new PHUIObjectItemView())
->setObjectName($commit_name)
->setHeader($commit_desc)
->setHref($commit_link)
->setDisabled($commit->isUnreachable())
->addByline(pht('Author: %s', $author_name))
->addIcon('none', $committed);
if ($show_drafts) {
if ($commit->getHasDraft($viewer)) {
$item->addAttribute($draft_icon);
}
}
$audits = $commit->getAudits();
$auditor_phids = mpull($audits, 'getAuditorPHID');
if ($auditor_phids) {
$auditor_list = $handles->newSublist($auditor_phids)
->renderList()
->setAsInline(true);
} else {
$auditor_list = phutil_tag('em', array(), pht('None'));
}
$item->addAttribute(pht('Auditors: %s', $auditor_list));
if ($status_color) {
$item->setStatusIcon($status_icon.' '.$status_color, $status_text);
}
$list->addItem($item);
}
if ($this->noDataString) {
$list->setNoDataString($this->noDataString);
}
if ($this->header) {
$list->setHeader($this->header);
}
return $list;
}
}
diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php
index c5b3c1ce99..0ed86e3056 100644
--- a/src/applications/auth/controller/PhabricatorAuthController.php
+++ b/src/applications/auth/controller/PhabricatorAuthController.php
@@ -1,295 +1,295 @@
<?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 to log the viewer in as.
* @return AphrontResponse Response which continues the login process.
*/
protected function loginUser(PhabricatorUser $user) {
$response = $this->buildLoginValidateResponse($user);
$session_type = PhabricatorAuthSession::TYPE_WEB;
$event_type = PhabricatorEventType::TYPE_AUTH_WILLLOGINUSER;
$event_data = array(
'user' => $user,
'type' => $session_type,
'response' => $response,
'shouldLogin' => true,
);
$event = id(new PhabricatorEvent($event_type, $event_data))
->setUser($user);
PhutilEventEngine::dispatchEvent($event);
$should_login = $event->getValue('shouldLogin');
if ($should_login) {
$session_key = id(new PhabricatorAuthSessionEngine())
->establishSession($session_type, $user->getPHID(), $partial = true);
// 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 $event->getValue('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->setQueryParam('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
- // ominpotent user.
+ // 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);
}
$other_account = id(new PhabricatorExternalAccount())->loadAllWhere(
'accountType = %s AND accountDomain = %s AND accountID = %s
AND id != %d',
$account->getAccountType(),
$account->getAccountDomain(),
$account->getAccountID(),
$account->getID());
if ($other_account) {
$response = $this->renderError(
pht(
'The account you are attempting to register with already belongs '.
'to another user.'));
return array($account, $provider, $response);
}
$provider = PhabricatorAuthProvider::getEnabledProviderByKey(
$account->getProviderKey());
if (!$provider) {
$response = $this->renderError(
pht(
'The account you are attempting to register with uses a nonexistent '.
'or disabled authentication provider (with key "%s"). An '.
'administrator may have recently disabled this provider.',
$account->getProviderKey()));
return array($account, $provider, $response);
}
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 Phabricator!'))
->setImageURI($invite_author->getProfileImageURI())
->addAttribute(
pht(
'%s has invited you to join Phabricator.',
$invite_author->getFullName()));
$invite_list = id(new PHUIObjectItemListView())
->addItem($invite_item)
->setFlush(true);
return id(new PHUIBoxView())
->addMargin(PHUI::MARGIN_LARGE)
->appendChild($invite_list);
}
}
diff --git a/src/applications/auth/controller/PhabricatorAuthRegisterController.php b/src/applications/auth/controller/PhabricatorAuthRegisterController.php
index a68e12c801..5a792dc864 100644
--- a/src/applications/auth/controller/PhabricatorAuthRegisterController.php
+++ b/src/applications/auth/controller/PhabricatorAuthRegisterController.php
@@ -1,721 +1,721 @@
<?php
final class PhabricatorAuthRegisterController
extends PhabricatorAuthController {
public function shouldRequireLogin() {
return false;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$account_key = $request->getURIData('akey');
if ($request->getUser()->isLoggedIn()) {
return id(new AphrontRedirectResponse())->setURI('/');
}
$is_setup = false;
if (strlen($account_key)) {
$result = $this->loadAccountForRegistrationOrLinking($account_key);
list($account, $provider, $response) = $result;
$is_default = false;
} else if ($this->isFirstTimeSetup()) {
list($account, $provider, $response) = $this->loadSetupAccount();
$is_default = true;
$is_setup = true;
} else {
list($account, $provider, $response) = $this->loadDefaultAccount();
$is_default = true;
}
if ($response) {
return $response;
}
$invite = $this->loadInvite();
if (!$provider->shouldAllowRegistration()) {
if ($invite) {
// If the user has an invite, we allow them to register with any
// provider, even a login-only provider.
} else {
// TODO: This is a routine error if you click "Login" on an external
// auth source which doesn't allow registration. The error should be
// more tailored.
return $this->renderError(
pht(
'The account you are attempting to register with uses an '.
'authentication provider ("%s") which does not allow '.
'registration. An administrator may have recently disabled '.
'registration with this provider.',
$provider->getProviderName()));
}
}
$errors = array();
$user = new PhabricatorUser();
$default_username = $account->getUsername();
$default_realname = $account->getRealName();
$default_email = $account->getEmail();
if ($invite) {
$default_email = $invite->getEmailAddress();
}
if ($default_email !== null) {
if (!PhabricatorUserEmail::isValidAddress($default_email)) {
$errors[] = pht(
'The email address associated with this external account ("%s") is '.
'not a valid email address and can not be used to register a '.
'Phabricator account. Choose a different, valid address.',
phutil_tag('strong', array(), $default_email));
$default_email = null;
}
}
if ($default_email !== null) {
- // We should bypass policy here becase e.g. limiting an application use
+ // We should bypass policy here because e.g. limiting an application use
// to a subset of users should not allow the others to overwrite
// configured application emails.
$application_email = id(new PhabricatorMetaMTAApplicationEmailQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withAddresses(array($default_email))
->executeOne();
if ($application_email) {
$errors[] = pht(
'The email address associated with this account ("%s") is '.
'already in use by an application and can not be used to '.
'register a new Phabricator account. Choose a different, valid '.
'address.',
phutil_tag('strong', array(), $default_email));
$default_email = null;
}
}
$show_existing = null;
if ($default_email !== null) {
// If the account source provided an email, but it's not allowed by
// the configuration, roadblock the user. Previously, we let the user
// pick a valid email address instead, but this does not align well with
// user expectation and it's not clear the cases it enables are valuable.
// See discussion in T3472.
if (!PhabricatorUserEmail::isAllowedAddress($default_email)) {
$debug_email = new PHUIInvisibleCharacterView($default_email);
return $this->renderError(
array(
pht(
'The account you are attempting to register with has an invalid '.
'email address (%s). This Phabricator install only allows '.
'registration with specific email addresses:',
$debug_email),
phutil_tag('br'),
phutil_tag('br'),
PhabricatorUserEmail::describeAllowedAddresses(),
));
}
// If the account source provided an email, but another account already
// has that email, just pretend we didn't get an email.
if ($default_email !== null) {
$same_email = id(new PhabricatorUserEmail())->loadOneWhere(
'address = %s',
$default_email);
if ($same_email) {
if ($invite) {
// We're allowing this to continue. The fact that we loaded the
// invite means that the address is nonprimary and unverified and
// we're OK to steal it.
} else {
$show_existing = $default_email;
$default_email = null;
}
}
}
}
if ($show_existing !== null) {
if (!$request->getInt('phase')) {
return $this->newDialog()
->setTitle(pht('Email Address Already in Use'))
->addHiddenInput('phase', 1)
->appendParagraph(
pht(
'You are creating a new Phabricator account linked to an '.
'existing external account from outside Phabricator.'))
->appendParagraph(
pht(
'The email address ("%s") associated with the external account '.
'is already in use by an existing Phabricator account. Multiple '.
'Phabricator accounts may not have the same email address, so '.
'you can not use this email address to register a new '.
'Phabricator account.',
phutil_tag('strong', array(), $show_existing)))
->appendParagraph(
pht(
'If you want to register a new account, continue with this '.
'registration workflow and choose a new, unique email address '.
'for the new account.'))
->appendParagraph(
pht(
'If you want to link an existing Phabricator account to this '.
'external account, do not continue. Instead: log in to your '.
'existing account, then go to "Settings" and link the account '.
'in the "External Accounts" panel.'))
->appendParagraph(
pht(
'If you continue, you will create a new account. You will not '.
'be able to link this external account to an existing account.'))
->addCancelButton('/auth/login/', pht('Cancel'))
->addSubmitButton(pht('Create New Account'));
} else {
$errors[] = pht(
'The external account you are registering with has an email address '.
'that is already in use ("%s") by an existing Phabricator account. '.
'Choose a new, valid email address to register a new Phabricator '.
'account.',
phutil_tag('strong', array(), $show_existing));
}
}
$profile = id(new PhabricatorRegistrationProfile())
->setDefaultUsername($default_username)
->setDefaultEmail($default_email)
->setDefaultRealName($default_realname)
->setCanEditUsername(true)
->setCanEditEmail(($default_email === null))
->setCanEditRealName(true)
->setShouldVerifyEmail(false);
$event_type = PhabricatorEventType::TYPE_AUTH_WILLREGISTERUSER;
$event_data = array(
'account' => $account,
'profile' => $profile,
);
$event = id(new PhabricatorEvent($event_type, $event_data))
->setUser($user);
PhutilEventEngine::dispatchEvent($event);
$default_username = $profile->getDefaultUsername();
$default_email = $profile->getDefaultEmail();
$default_realname = $profile->getDefaultRealName();
$can_edit_username = $profile->getCanEditUsername();
$can_edit_email = $profile->getCanEditEmail();
$can_edit_realname = $profile->getCanEditRealName();
$must_set_password = $provider->shouldRequireRegistrationPassword();
$can_edit_anything = $profile->getCanEditAnything() || $must_set_password;
$force_verify = $profile->getShouldVerifyEmail();
// Automatically verify the administrator's email address during first-time
// setup.
if ($is_setup) {
$force_verify = true;
}
$value_username = $default_username;
$value_realname = $default_realname;
$value_email = $default_email;
$value_password = null;
$require_real_name = PhabricatorEnv::getEnvConfig('user.require-real-name');
$e_username = strlen($value_username) ? null : true;
$e_realname = $require_real_name ? true : null;
$e_email = strlen($value_email) ? null : true;
$e_password = true;
$e_captcha = true;
$skip_captcha = false;
if ($invite) {
// If the user is accepting an invite, assume they're trustworthy enough
// that we don't need to CAPTCHA them.
$skip_captcha = true;
}
$min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length');
$min_len = (int)$min_len;
$from_invite = $request->getStr('invite');
if ($from_invite && $can_edit_username) {
$value_username = $request->getStr('username');
$e_username = null;
}
$try_register =
($request->isFormPost() || !$can_edit_anything) &&
!$from_invite &&
($request->getInt('phase') != 1);
if ($try_register) {
$errors = array();
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
if ($must_set_password && !$skip_captcha) {
$e_captcha = pht('Again');
$captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request);
if (!$captcha_ok) {
$errors[] = pht('Captcha response is incorrect, try again.');
$e_captcha = pht('Invalid');
}
}
if ($can_edit_username) {
$value_username = $request->getStr('username');
if (!strlen($value_username)) {
$e_username = pht('Required');
$errors[] = pht('Username is required.');
} else if (!PhabricatorUser::validateUsername($value_username)) {
$e_username = pht('Invalid');
$errors[] = PhabricatorUser::describeValidUsername();
} else {
$e_username = null;
}
}
if ($must_set_password) {
$value_password = $request->getStr('password');
$value_confirm = $request->getStr('confirm');
if (!strlen($value_password)) {
$e_password = pht('Required');
$errors[] = pht('You must choose a password.');
} else if ($value_password !== $value_confirm) {
$e_password = pht('No Match');
$errors[] = pht('Password and confirmation must match.');
} else if (strlen($value_password) < $min_len) {
$e_password = pht('Too Short');
$errors[] = pht(
'Password is too short (must be at least %d characters long).',
$min_len);
} else if (
PhabricatorCommonPasswords::isCommonPassword($value_password)) {
$e_password = pht('Very Weak');
$errors[] = pht(
'Password is pathologically weak. This password is one of the '.
'most common passwords in use, and is extremely easy for '.
'attackers to guess. You must choose a stronger password.');
} else {
$e_password = null;
}
}
if ($can_edit_email) {
$value_email = $request->getStr('email');
if (!strlen($value_email)) {
$e_email = pht('Required');
$errors[] = pht('Email is required.');
} else if (!PhabricatorUserEmail::isValidAddress($value_email)) {
$e_email = pht('Invalid');
$errors[] = PhabricatorUserEmail::describeValidAddresses();
} else if (!PhabricatorUserEmail::isAllowedAddress($value_email)) {
$e_email = pht('Disallowed');
$errors[] = PhabricatorUserEmail::describeAllowedAddresses();
} else {
$e_email = null;
}
}
if ($can_edit_realname) {
$value_realname = $request->getStr('realName');
if (!strlen($value_realname) && $require_real_name) {
$e_realname = pht('Required');
$errors[] = pht('Real name is required.');
} else {
$e_realname = null;
}
}
if (!$errors) {
$image = $this->loadProfilePicture($account);
if ($image) {
$user->setProfileImagePHID($image->getPHID());
}
try {
$verify_email = false;
if ($force_verify) {
$verify_email = true;
}
if ($value_email === $default_email) {
if ($account->getEmailVerified()) {
$verify_email = true;
}
if ($provider->shouldTrustEmails()) {
$verify_email = true;
}
if ($invite) {
$verify_email = true;
}
}
$email_obj = null;
if ($invite) {
// If we have a valid invite, this email may exist but be
// nonprimary and unverified, so we'll reassign it.
$email_obj = id(new PhabricatorUserEmail())->loadOneWhere(
'address = %s',
$value_email);
}
if (!$email_obj) {
$email_obj = id(new PhabricatorUserEmail())
->setAddress($value_email);
}
$email_obj->setIsVerified((int)$verify_email);
$user->setUsername($value_username);
$user->setRealname($value_realname);
if ($is_setup) {
$must_approve = false;
} else if ($invite) {
$must_approve = false;
} else {
$must_approve = PhabricatorEnv::getEnvConfig(
'auth.require-approval');
}
if ($must_approve) {
$user->setIsApproved(0);
} else {
$user->setIsApproved(1);
}
if ($invite) {
$allow_reassign_email = true;
} else {
$allow_reassign_email = false;
}
$user->openTransaction();
$editor = id(new PhabricatorUserEditor())
->setActor($user);
$editor->createNewUser($user, $email_obj, $allow_reassign_email);
if ($must_set_password) {
$envelope = new PhutilOpaqueEnvelope($value_password);
$editor->changePassword($user, $envelope);
}
if ($is_setup) {
$editor->makeAdminUser($user, true);
}
$account->setUserPHID($user->getPHID());
$provider->willRegisterAccount($account);
$account->save();
$user->saveTransaction();
if (!$email_obj->getIsVerified()) {
$email_obj->sendVerificationEmail($user);
}
if ($must_approve) {
$this->sendWaitingForApprovalEmail($user);
}
if ($invite) {
$invite->setAcceptedByPHID($user->getPHID())->save();
}
return $this->loginUser($user);
} catch (AphrontDuplicateKeyQueryException $exception) {
$same_username = id(new PhabricatorUser())->loadOneWhere(
'userName = %s',
$user->getUserName());
$same_email = id(new PhabricatorUserEmail())->loadOneWhere(
'address = %s',
$value_email);
if ($same_username) {
$e_username = pht('Duplicate');
$errors[] = pht('Another user already has that username.');
}
if ($same_email) {
// TODO: See T3340.
$e_email = pht('Duplicate');
$errors[] = pht('Another user already has that email.');
}
if (!$same_username && !$same_email) {
throw $exception;
}
}
}
unset($unguarded);
}
$form = id(new AphrontFormView())
->setUser($request->getUser())
->addHiddenInput('phase', 2);
if (!$is_default) {
$form->appendChild(
id(new AphrontFormMarkupControl())
->setLabel(pht('External Account'))
->setValue(
id(new PhabricatorAuthAccountView())
->setUser($request->getUser())
->setExternalAccount($account)
->setAuthProvider($provider)));
}
if ($can_edit_username) {
$form->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Username'))
->setName('username')
->setValue($value_username)
->setError($e_username));
} else {
$form->appendChild(
id(new AphrontFormMarkupControl())
->setLabel(pht('Username'))
->setValue($value_username)
->setError($e_username));
}
if ($can_edit_realname) {
$form->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Real Name'))
->setName('realName')
->setValue($value_realname)
->setError($e_realname));
}
if ($must_set_password) {
$form->appendChild(
id(new AphrontFormPasswordControl())
->setLabel(pht('Password'))
->setName('password')
->setError($e_password));
$form->appendChild(
id(new AphrontFormPasswordControl())
->setLabel(pht('Confirm Password'))
->setName('confirm')
->setError($e_password)
->setCaption(
$min_len
? pht('Minimum length of %d characters.', $min_len)
: null));
}
if ($can_edit_email) {
$form->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Email'))
->setName('email')
->setValue($value_email)
->setCaption(PhabricatorUserEmail::describeAllowedAddresses())
->setError($e_email));
}
if ($must_set_password && !$skip_captcha) {
$form->appendChild(
id(new AphrontFormRecaptchaControl())
->setLabel(pht('Captcha'))
->setError($e_captcha));
}
$submit = id(new AphrontFormSubmitControl());
if ($is_setup) {
$submit
->setValue(pht('Create Admin Account'));
} else {
$submit
->addCancelButton($this->getApplicationURI('start/'))
->setValue(pht('Register Account'));
}
$form->appendChild($submit);
$crumbs = $this->buildApplicationCrumbs();
if ($is_setup) {
$crumbs->addTextCrumb(pht('Setup Admin Account'));
$title = pht('Welcome to Phabricator');
} else {
$crumbs->addTextCrumb(pht('Register'));
$crumbs->addTextCrumb($provider->getProviderName());
$title = pht('Create a New Account');
}
$crumbs->setBorder(true);
$welcome_view = null;
if ($is_setup) {
$welcome_view = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_NOTICE)
->setTitle(pht('Welcome to Phabricator'))
->appendChild(
pht(
'Installation is complete. Register your administrator account '.
'below to log in. You will be able to configure options and add '.
'other authentication mechanisms (like LDAP or OAuth) later on.'));
}
$object_box = id(new PHUIObjectBoxView())
->setForm($form)
->setFormErrors($errors);
$invite_header = null;
if ($invite) {
$invite_header = $this->renderInviteHeader($invite);
}
$header = id(new PHUIHeaderView())
->setHeader($title);
$view = id(new PHUITwoColumnView())
->setHeader($header)
->setFooter(array(
$welcome_view,
$invite_header,
$object_box,
));
return $this->newPage()
->setTitle($title)
->setCrumbs($crumbs)
->appendChild($view);
}
private function loadDefaultAccount() {
$providers = PhabricatorAuthProvider::getAllEnabledProviders();
$account = null;
$provider = null;
$response = null;
foreach ($providers as $key => $candidate_provider) {
if (!$candidate_provider->shouldAllowRegistration()) {
unset($providers[$key]);
continue;
}
if (!$candidate_provider->isDefaultRegistrationProvider()) {
unset($providers[$key]);
}
}
if (!$providers) {
$response = $this->renderError(
pht(
'There are no configured default registration providers.'));
return array($account, $provider, $response);
} else if (count($providers) > 1) {
$response = $this->renderError(
pht('There are too many configured default registration providers.'));
return array($account, $provider, $response);
}
$provider = head($providers);
$account = $provider->getDefaultExternalAccount();
return array($account, $provider, $response);
}
private function loadSetupAccount() {
$provider = new PhabricatorPasswordAuthProvider();
$provider->attachProviderConfig(
id(new PhabricatorAuthProviderConfig())
->setShouldAllowRegistration(1)
->setShouldAllowLogin(1)
->setIsEnabled(true));
$account = $provider->getDefaultExternalAccount();
$response = null;
return array($account, $provider, $response);
}
private function loadProfilePicture(PhabricatorExternalAccount $account) {
$phid = $account->getProfileImagePHID();
if (!$phid) {
return null;
}
// NOTE: Use of omnipotent user is okay here because the registering user
// can not control the field value, and we can't use their user object to
// do meaningful policy checks anyway since they have not registered yet.
// Reaching this means the user holds the account secret key and the
// registration secret key, and thus has permission to view the image.
$file = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs(array($phid))
->executeOne();
if (!$file) {
return null;
}
$xform = PhabricatorFileTransform::getTransformByKey(
PhabricatorFileThumbnailTransform::TRANSFORM_PROFILE);
return $xform->executeTransform($file);
}
protected function renderError($message) {
return $this->renderErrorPage(
pht('Registration Failed'),
array($message));
}
private function sendWaitingForApprovalEmail(PhabricatorUser $user) {
$title = '[Phabricator] '.pht(
'New User "%s" Awaiting Approval',
$user->getUsername());
$body = new PhabricatorMetaMTAMailBody();
$body->addRawSection(
pht(
'Newly registered user "%s" is awaiting account approval by an '.
'administrator.',
$user->getUsername()));
$body->addLinkSection(
pht('APPROVAL QUEUE'),
PhabricatorEnv::getProductionURI(
'/people/query/approval/'));
$body->addLinkSection(
pht('DISABLE APPROVAL QUEUE'),
PhabricatorEnv::getProductionURI(
'/config/edit/auth.require-approval/'));
$admins = id(new PhabricatorPeopleQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withIsAdmin(true)
->execute();
if (!$admins) {
return;
}
$mail = id(new PhabricatorMetaMTAMail())
->addTos(mpull($admins, 'getPHID'))
->setSubject($title)
->setBody($body->render())
->saveAndSend();
}
}
diff --git a/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php b/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php
index 0962b7b56a..1c40af5795 100644
--- a/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php
+++ b/src/applications/auth/editor/PhabricatorAuthSSHKeyEditor.php
@@ -1,256 +1,256 @@
<?php
final class PhabricatorAuthSSHKeyEditor
extends PhabricatorApplicationTransactionEditor {
public function getEditorApplicationClass() {
return 'PhabricatorAuthApplication';
}
public function getEditorObjectsDescription() {
return pht('SSH Keys');
}
public function getTransactionTypes() {
$types = parent::getTransactionTypes();
$types[] = PhabricatorAuthSSHKeyTransaction::TYPE_NAME;
$types[] = PhabricatorAuthSSHKeyTransaction::TYPE_KEY;
$types[] = PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE;
return $types;
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorAuthSSHKeyTransaction::TYPE_NAME:
return $object->getName();
case PhabricatorAuthSSHKeyTransaction::TYPE_KEY:
return $object->getEntireKey();
case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE:
return !$object->getIsActive();
}
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorAuthSSHKeyTransaction::TYPE_NAME:
case PhabricatorAuthSSHKeyTransaction::TYPE_KEY:
return $xaction->getNewValue();
case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE:
return (bool)$xaction->getNewValue();
}
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$value = $xaction->getNewValue();
switch ($xaction->getTransactionType()) {
case PhabricatorAuthSSHKeyTransaction::TYPE_NAME:
$object->setName($value);
return;
case PhabricatorAuthSSHKeyTransaction::TYPE_KEY:
$public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($value);
$type = $public_key->getType();
$body = $public_key->getBody();
$comment = $public_key->getComment();
$object->setKeyType($type);
$object->setKeyBody($body);
$object->setKeyComment($comment);
return;
case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE:
if ($value) {
$new = null;
} else {
$new = 1;
}
$object->setIsActive($new);
return;
}
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return;
}
protected function validateTransaction(
PhabricatorLiskDAO $object,
$type,
array $xactions) {
$errors = parent::validateTransaction($object, $type, $xactions);
switch ($type) {
case PhabricatorAuthSSHKeyTransaction::TYPE_NAME:
$missing = $this->validateIsEmptyTextField(
$object->getName(),
$xactions);
if ($missing) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Required'),
pht('SSH key name is required.'),
nonempty(last($xactions), null));
$error->setIsMissingFieldError(true);
$errors[] = $error;
}
break;
case PhabricatorAuthSSHKeyTransaction::TYPE_KEY;
$missing = $this->validateIsEmptyTextField(
$object->getName(),
$xactions);
if ($missing) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Required'),
pht('SSH key material is required.'),
nonempty(last($xactions), null));
$error->setIsMissingFieldError(true);
$errors[] = $error;
} else {
foreach ($xactions as $xaction) {
$new = $xaction->getNewValue();
try {
$public_key = PhabricatorAuthSSHPublicKey::newFromRawKey($new);
} catch (Exception $ex) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
$ex->getMessage(),
$xaction);
}
}
}
break;
case PhabricatorAuthSSHKeyTransaction::TYPE_DEACTIVATE:
foreach ($xactions as $xaction) {
if (!$xaction->getNewValue()) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
pht('SSH keys can not be reactivated.'),
$xaction);
}
}
break;
}
return $errors;
}
protected function didCatchDuplicateKeyException(
PhabricatorLiskDAO $object,
array $xactions,
Exception $ex) {
$errors = array();
$errors[] = new PhabricatorApplicationTransactionValidationError(
PhabricatorAuthSSHKeyTransaction::TYPE_KEY,
pht('Duplicate'),
pht(
'This public key is already associated with another user or device. '.
'Each key must unambiguously identify a single unique owner.'),
null);
throw new PhabricatorApplicationTransactionValidationException($errors);
}
protected function shouldSendMail(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
protected function getMailSubjectPrefix() {
return pht('[SSH Key]');
}
protected function getMailThreadID(PhabricatorLiskDAO $object) {
return 'ssh-key-'.$object->getPHID();
}
protected function applyFinalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
// After making any change to an SSH key, drop the authfile cache so it
// is regenerated the next time anyone authenticates.
PhabricatorAuthSSHKeyQuery::deleteSSHKeyCache();
return $xactions;
}
protected function getMailTo(PhabricatorLiskDAO $object) {
return $object->getObject()->getSSHKeyNotifyPHIDs();
}
protected function getMailCC(PhabricatorLiskDAO $object) {
return array();
}
protected function buildReplyHandler(PhabricatorLiskDAO $object) {
return id(new PhabricatorAuthSSHKeyReplyHandler())
->setMailReceiver($object);
}
protected function buildMailTemplate(PhabricatorLiskDAO $object) {
$id = $object->getID();
$name = $object->getName();
$phid = $object->getPHID();
$mail = id(new PhabricatorMetaMTAMail())
->setSubject(pht('SSH Key %d: %s', $id, $name))
->addHeader('Thread-Topic', $phid);
// The primary value of this mail is alerting users to account compromises,
- // so force delivery. In particular, this mail should still be delievered
+ // so force delivery. In particular, this mail should still be delivered
// even if "self mail" is disabled.
$mail->setForceDelivery(true);
return $mail;
}
protected function buildMailBody(
PhabricatorLiskDAO $object,
array $xactions) {
$body = parent::buildMailBody($object, $xactions);
$body->addTextSection(
pht('SECURITY WARNING'),
pht(
'If you do not recognize this change, it may indicate your account '.
'has been compromised.'));
$detail_uri = $object->getURI();
$detail_uri = PhabricatorEnv::getProductionURI($detail_uri);
$body->addLinkSection(pht('SSH KEY DETAIL'), $detail_uri);
return $body;
}
}
diff --git a/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php b/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php
index 3906c5eabe..ce9151bcef 100644
--- a/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php
+++ b/src/applications/auth/factor/__tests__/PhabricatorAuthInviteTestCase.php
@@ -1,374 +1,374 @@
<?php
final class PhabricatorAuthInviteTestCase extends PhabricatorTestCase {
protected function getPhabricatorTestCaseConfiguration() {
return array(
self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES => true,
);
}
/**
* Test that invalid invites can not be accepted.
*/
public function testInvalidInvite() {
$viewer = $this->generateUser();
$engine = $this->generateEngine($viewer);
$caught = null;
try {
$engine->processInviteCode('asdf1234');
} catch (PhabricatorAuthInviteInvalidException $ex) {
$caught = $ex;
}
$this->assertTrue($caught instanceof Exception);
}
/**
* Test that invites can be accepted exactly once.
*/
public function testDuplicateInvite() {
$author = $this->generateUser();
$viewer = $this->generateUser();
$address = Filesystem::readRandomCharacters(16).'@example.com';
$invite = id(new PhabricatorAuthInvite())
->setAuthorPHID($author->getPHID())
->setEmailAddress($address)
->save();
$engine = $this->generateEngine($viewer);
$engine->setUserHasConfirmedVerify(true);
$caught = null;
try {
$result = $engine->processInviteCode($invite->getVerificationCode());
} catch (Exception $ex) {
$caught = $ex;
}
- // This first time should accept the invite and verify the addresss.
+ // This first time should accept the invite and verify the address.
$this->assertTrue(
($caught instanceof PhabricatorAuthInviteRegisteredException));
try {
$result = $engine->processInviteCode($invite->getVerificationCode());
} catch (Exception $ex) {
$caught = $ex;
}
// The second time through, the invite should not be acceptable.
$this->assertTrue(
($caught instanceof PhabricatorAuthInviteInvalidException));
}
/**
* Test easy invite cases, where the email is not anywhere in the system.
*/
public function testInviteWithNewEmail() {
$expect_map = array(
'out' => array(
null,
null,
),
'in' => array(
'PhabricatorAuthInviteVerifyException',
'PhabricatorAuthInviteRegisteredException',
),
);
$author = $this->generateUser();
$logged_in = $this->generateUser();
$logged_out = new PhabricatorUser();
foreach (array('out', 'in') as $is_logged_in) {
foreach (array(0, 1) as $should_verify) {
$address = Filesystem::readRandomCharacters(16).'@example.com';
$invite = id(new PhabricatorAuthInvite())
->setAuthorPHID($author->getPHID())
->setEmailAddress($address)
->save();
switch ($is_logged_in) {
case 'out':
$viewer = $logged_out;
break;
case 'in':
$viewer = $logged_in;
break;
}
$engine = $this->generateEngine($viewer);
$engine->setUserHasConfirmedVerify($should_verify);
$caught = null;
try {
$result = $engine->processInviteCode($invite->getVerificationCode());
} catch (Exception $ex) {
$caught = $ex;
}
$expect = $expect_map[$is_logged_in];
$expect = $expect[$should_verify];
$this->assertEqual(
($expect !== null),
($caught instanceof Exception),
pht(
'user=%s, should_verify=%s',
$is_logged_in,
$should_verify));
if ($expect === null) {
$this->assertEqual($invite->getPHID(), $result->getPHID());
} else {
$this->assertEqual(
$expect,
get_class($caught),
pht('Actual exception: %s', $caught->getMessage()));
}
}
}
}
/**
* Test hard invite cases, where the email is already known and attached
* to some user account.
*/
public function testInviteWithKnownEmail() {
// This tests all permutations of:
//
// - Is the user logged out, logged in with a different account, or
// logged in with the correct account?
// - Is the address verified, or unverified?
// - Is the address primary, or nonprimary?
// - Has the user confirmed that they want to verify the address?
$expect_map = array(
'out' => array(
array(
array(
// For example, this corresponds to a logged out user trying to
// follow an invite with an unverified, nonprimary address, and
// they haven't clicked the "Verify" button yet. We ask them to
// verify that they want to register a new account.
'PhabricatorAuthInviteVerifyException',
// In this case, they have clicked the verify button. The engine
// continues the workflow.
null,
),
array(
// And so on. All of the rest of these cases cover the other
// permutations.
'PhabricatorAuthInviteLoginException',
'PhabricatorAuthInviteLoginException',
),
),
array(
array(
'PhabricatorAuthInviteLoginException',
'PhabricatorAuthInviteLoginException',
),
array(
'PhabricatorAuthInviteLoginException',
'PhabricatorAuthInviteLoginException',
),
),
),
'in' => array(
array(
array(
'PhabricatorAuthInviteVerifyException',
array(true, 'PhabricatorAuthInviteRegisteredException'),
),
array(
'PhabricatorAuthInviteAccountException',
'PhabricatorAuthInviteAccountException',
),
),
array(
array(
'PhabricatorAuthInviteAccountException',
'PhabricatorAuthInviteAccountException',
),
array(
'PhabricatorAuthInviteAccountException',
'PhabricatorAuthInviteAccountException',
),
),
),
'same' => array(
array(
array(
'PhabricatorAuthInviteVerifyException',
array(true, 'PhabricatorAuthInviteRegisteredException'),
),
array(
'PhabricatorAuthInviteVerifyException',
array(true, 'PhabricatorAuthInviteRegisteredException'),
),
),
array(
array(
'PhabricatorAuthInviteRegisteredException',
'PhabricatorAuthInviteRegisteredException',
),
array(
'PhabricatorAuthInviteRegisteredException',
'PhabricatorAuthInviteRegisteredException',
),
),
),
);
$author = $this->generateUser();
$logged_in = $this->generateUser();
$logged_out = new PhabricatorUser();
foreach (array('out', 'in', 'same') as $is_logged_in) {
foreach (array(0, 1) as $is_verified) {
foreach (array(0, 1) as $is_primary) {
foreach (array(0, 1) as $should_verify) {
$other = $this->generateUser();
switch ($is_logged_in) {
case 'out':
$viewer = $logged_out;
break;
case 'in';
$viewer = $logged_in;
break;
case 'same':
$viewer = clone $other;
break;
}
$email = $this->generateEmail($other, $is_verified, $is_primary);
$invite = id(new PhabricatorAuthInvite())
->setAuthorPHID($author->getPHID())
->setEmailAddress($email->getAddress())
->save();
$code = $invite->getVerificationCode();
$engine = $this->generateEngine($viewer);
$engine->setUserHasConfirmedVerify($should_verify);
$caught = null;
try {
$result = $engine->processInviteCode($code);
} catch (Exception $ex) {
$caught = $ex;
}
$expect = $expect_map[$is_logged_in];
$expect = $expect[$is_verified];
$expect = $expect[$is_primary];
$expect = $expect[$should_verify];
if (is_array($expect)) {
list($expect_reassign, $expect_exception) = $expect;
} else {
$expect_reassign = false;
$expect_exception = $expect;
}
$case_info = pht(
'user=%s, verified=%s, primary=%s, should_verify=%s',
$is_logged_in,
$is_verified,
$is_primary,
$should_verify);
$this->assertEqual(
($expect_exception !== null),
($caught instanceof Exception),
$case_info);
if ($expect_exception === null) {
$this->assertEqual($invite->getPHID(), $result->getPHID());
} else {
$this->assertEqual(
$expect_exception,
get_class($caught),
pht('%s, exception=%s', $case_info, $caught->getMessage()));
}
if ($expect_reassign) {
$email->reload();
$this->assertEqual(
$viewer->getPHID(),
$email->getUserPHID(),
pht(
'Expected email address reassignment (%s).',
$case_info));
}
switch ($expect_exception) {
case 'PhabricatorAuthInviteRegisteredException':
$invite->reload();
$this->assertEqual(
$viewer->getPHID(),
$invite->getAcceptedByPHID(),
pht(
'Expected invite accepted (%s).',
$case_info));
break;
}
}
}
}
}
}
private function generateUser() {
return $this->generateNewTestUser();
}
private function generateEngine(PhabricatorUser $viewer) {
return id(new PhabricatorAuthInviteEngine())
->setViewer($viewer);
}
private function generateEmail(
PhabricatorUser $user,
$is_verified,
$is_primary) {
// NOTE: We're being a little bit sneaky here because UserEditor will not
// let you make an unverified address a primary account address, and
// the test user will already have a verified primary address.
$email = id(new PhabricatorUserEmail())
->setAddress(Filesystem::readRandomCharacters(16).'@example.com')
->setIsVerified((int)($is_verified || $is_primary))
->setIsPrimary(0);
$editor = id(new PhabricatorUserEditor())
->setActor($user);
$editor->addEmail($user, $email);
if ($is_primary) {
$editor->changePrimaryEmail($user, $email);
}
$email->setIsVerified((int)$is_verified);
$email->save();
return $email;
}
}
diff --git a/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php b/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php
index bb94cfca62..758761b2ca 100644
--- a/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php
+++ b/src/applications/auth/management/PhabricatorAuthManagementRevokeWorkflow.php
@@ -1,140 +1,140 @@
<?php
final class PhabricatorAuthManagementRevokeWorkflow
extends PhabricatorAuthManagementWorkflow {
protected function didConstruct() {
$this
->setName('revoke')
->setExamples(
"**revoke** --type __type__ --from __user__\n".
"**revoke** --everything --everywhere")
->setSynopsis(
pht(
'Revoke credentials which may have been leaked or disclosed.'))
->setArguments(
array(
array(
'name' => 'from',
'param' => 'user',
'help' => pht(
'Revoke credentials for the specified user.'),
),
array(
'name' => 'type',
'param' => 'type',
'help' => pht(
'Revoke credentials of the given type.'),
),
array(
'name' => 'everything',
'help' => pht('Revoke all credentials types.'),
),
array(
'name' => 'everywhere',
'help' => pht('Revoke from all credential owners.'),
),
));
}
public function execute(PhutilArgumentParser $args) {
$viewer = PhabricatorUser::getOmnipotentUser();
$all_types = PhabricatorAuthRevoker::getAllRevokers();
$type = $args->getArg('type');
$is_everything = $args->getArg('everything');
if (!strlen($type) && !$is_everything) {
throw new PhutilArgumentUsageException(
pht(
'Specify the credential type to revoke with "--type" or specify '.
'"--everything".'));
} else if (strlen($type) && $is_everything) {
throw new PhutilArgumentUsageException(
pht(
'Specify the credential type to revoke with "--type" or '.
'"--everything", but not both.'));
} else if ($is_everything) {
$types = $all_types;
} else {
if (empty($all_types[$type])) {
throw new PhutilArgumentUsageException(
pht(
'Credential type "%s" is not valid. Valid credential types '.
'are: %s.',
$type,
implode(', ', array_keys($all_types))));
}
$types = array($all_types[$type]);
}
$is_everywhere = $args->getArg('everywhere');
$from = $args->getArg('from');
$target = null;
if (!strlen($from) && !$is_everywhere) {
throw new PhutilArgumentUsageException(
pht(
- 'Specify the target to revoke credentals from with "--from" or '.
+ 'Specify the target to revoke credentials from with "--from" or '.
'specify "--everywhere".'));
} else if (strlen($from) && $is_everywhere) {
throw new PhutilArgumentUsageException(
pht(
'Specify the target to revoke credentials from with "--from" or '.
'specify "--everywhere", but not both.'));
} else if ($is_everywhere) {
// Just carry the flag through.
} else {
$target = id(new PhabricatorObjectQuery())
->setViewer($viewer)
->withNames(array($from))
->executeOne();
if (!$target) {
throw new PhutilArgumentUsageException(
pht(
'Target "%s" is not a valid target to revoke credentials from. '.
'Usually, revoke from "@username".',
$from));
}
}
if ($is_everywhere) {
echo id(new PhutilConsoleBlock())
->addParagraph(
pht(
'You are destroying an entire class of credentials. This may be '.
'very disruptive to users. You should normally do this only if '.
'you suspect there has been a widespread compromise which may '.
'have impacted everyone.'))
->drawConsoleString();
$prompt = pht('Really destroy credentials everywhere?');
if (!phutil_console_confirm($prompt)) {
throw new PhutilArgumentUsageException(
pht('Aborted workflow.'));
}
}
foreach ($types as $type) {
$type->setViewer($viewer);
if ($is_everywhere) {
$count = $type->revokeAllCredentials();
} else {
$count = $type->revokeCredentialsFrom($target);
}
echo tsprintf(
"%s\n",
pht(
'Destroyed %s credential(s) of type "%s".',
new PhutilNumber($count),
$type->getRevokerKey()));
}
echo tsprintf(
"%s\n",
pht('Done.'));
return 0;
}
}
diff --git a/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php b/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php
index 530bf30583..c62983de2f 100644
--- a/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php
+++ b/src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php
@@ -1,280 +1,280 @@
<?php
abstract class PhabricatorOAuth1AuthProvider
extends PhabricatorOAuthAuthProvider {
protected $adapter;
const PROPERTY_CONSUMER_KEY = 'oauth1:consumer:key';
const PROPERTY_CONSUMER_SECRET = 'oauth1:consumer:secret';
const PROPERTY_PRIVATE_KEY = 'oauth1:private:key';
protected function getIDKey() {
return self::PROPERTY_CONSUMER_KEY;
}
protected function getSecretKey() {
return self::PROPERTY_CONSUMER_SECRET;
}
protected function configureAdapter(PhutilOAuth1AuthAdapter $adapter) {
$config = $this->getProviderConfig();
$adapter->setConsumerKey($config->getProperty(self::PROPERTY_CONSUMER_KEY));
$secret = $config->getProperty(self::PROPERTY_CONSUMER_SECRET);
if (strlen($secret)) {
$adapter->setConsumerSecret(new PhutilOpaqueEnvelope($secret));
}
$adapter->setCallbackURI(PhabricatorEnv::getURI($this->getLoginURI()));
return $adapter;
}
protected function renderLoginForm(AphrontRequest $request, $mode) {
$attributes = array(
'method' => 'POST',
'uri' => $this->getLoginURI(),
);
return $this->renderStandardLoginButton($request, $mode, $attributes);
}
public function processLoginRequest(
PhabricatorAuthLoginController $controller) {
$request = $controller->getRequest();
$adapter = $this->getAdapter();
$account = null;
$response = null;
if ($request->isHTTPPost()) {
// Add a CSRF code to the callback URI, which we'll verify when
// performing the login.
$client_code = $this->getAuthCSRFCode($request);
$callback_uri = $adapter->getCallbackURI();
$callback_uri = $callback_uri.$client_code.'/';
$adapter->setCallbackURI($callback_uri);
$uri = $adapter->getClientRedirectURI();
$this->saveHandshakeTokenSecret(
$client_code,
$adapter->getTokenSecret());
$response = id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI($uri);
return array($account, $response);
}
$denied = $request->getStr('denied');
if (strlen($denied)) {
// Twitter indicates that the user cancelled the login attempt by
// returning "denied" as a parameter.
throw new PhutilAuthUserAbortedException();
}
// NOTE: You can get here via GET, this should probably be a bit more
// user friendly.
$this->verifyAuthCSRFCode($request, $controller->getExtraURIData());
$token = $request->getStr('oauth_token');
$verifier = $request->getStr('oauth_verifier');
if (!$token) {
throw new Exception(pht("Expected '%s' in request!", 'oauth_token'));
}
if (!$verifier) {
throw new Exception(pht("Expected '%s' in request!", 'oauth_verifier'));
}
$adapter->setToken($token);
$adapter->setVerifier($verifier);
$client_code = $this->getAuthCSRFCode($request);
$token_secret = $this->loadHandshakeTokenSecret($client_code);
$adapter->setTokenSecret($token_secret);
// NOTE: As a side effect, this will cause the OAuth adapter to request
// an access token.
try {
$account_id = $adapter->getAccountID();
} catch (Exception $ex) {
// TODO: Handle this in a more user-friendly way.
throw $ex;
}
if (!strlen($account_id)) {
$response = $controller->buildProviderErrorResponse(
$this,
pht(
'The OAuth provider failed to retrieve an account ID.'));
return array($account, $response);
}
return array($this->loadOrCreateAccount($account_id), $response);
}
public function processEditForm(
AphrontRequest $request,
array $values) {
$key_ckey = self::PROPERTY_CONSUMER_KEY;
$key_csecret = self::PROPERTY_CONSUMER_SECRET;
return $this->processOAuthEditForm(
$request,
$values,
pht('Consumer key is required.'),
pht('Consumer secret is required.'));
}
public function extendEditForm(
AphrontRequest $request,
AphrontFormView $form,
array $values,
array $issues) {
return $this->extendOAuthEditForm(
$request,
$form,
$values,
$issues,
pht('OAuth Consumer Key'),
pht('OAuth Consumer Secret'));
}
public function renderConfigPropertyTransactionTitle(
PhabricatorAuthProviderConfigTransaction $xaction) {
$author_phid = $xaction->getAuthorPHID();
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$key = $xaction->getMetadataValue(
PhabricatorAuthProviderConfigTransaction::PROPERTY_KEY);
switch ($key) {
case self::PROPERTY_CONSUMER_KEY:
if (strlen($old)) {
return pht(
'%s updated the OAuth consumer key for this provider from '.
'"%s" to "%s".',
$xaction->renderHandleLink($author_phid),
$old,
$new);
} else {
return pht(
'%s set the OAuth consumer key for this provider to '.
'"%s".',
$xaction->renderHandleLink($author_phid),
$new);
}
case self::PROPERTY_CONSUMER_SECRET:
if (strlen($old)) {
return pht(
'%s updated the OAuth consumer secret for this provider.',
$xaction->renderHandleLink($author_phid));
} else {
return pht(
'%s set the OAuth consumer secret for this provider.',
$xaction->renderHandleLink($author_phid));
}
}
return parent::renderConfigPropertyTransactionTitle($xaction);
}
protected function synchronizeOAuthAccount(
PhabricatorExternalAccount $account) {
$adapter = $this->getAdapter();
$oauth_token = $adapter->getToken();
$oauth_token_secret = $adapter->getTokenSecret();
$account->setProperty('oauth1.token', $oauth_token);
$account->setProperty('oauth1.token.secret', $oauth_token_secret);
}
public function willRenderLinkedAccount(
PhabricatorUser $viewer,
PHUIObjectItemView $item,
PhabricatorExternalAccount $account) {
$item->addAttribute(pht('OAuth1 Account'));
parent::willRenderLinkedAccount($viewer, $item, $account);
}
/* -( Temporary Secrets )-------------------------------------------------- */
private function saveHandshakeTokenSecret($client_code, $secret) {
$secret_type = PhabricatorOAuth1SecretTemporaryTokenType::TOKENTYPE;
$key = $this->getHandshakeTokenKeyFromClientCode($client_code);
$type = $this->getTemporaryTokenType($secret_type);
// Wipe out an existing token, if one exists.
$token = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTokenResources(array($key))
->withTokenTypes(array($type))
->executeOne();
if ($token) {
$token->delete();
}
// Save the new secret.
id(new PhabricatorAuthTemporaryToken())
->setTokenResource($key)
->setTokenType($type)
->setTokenExpires(time() + phutil_units('1 hour in seconds'))
->setTokenCode($secret)
->save();
}
private function loadHandshakeTokenSecret($client_code) {
$secret_type = PhabricatorOAuth1SecretTemporaryTokenType::TOKENTYPE;
$key = $this->getHandshakeTokenKeyFromClientCode($client_code);
$type = $this->getTemporaryTokenType($secret_type);
$token = id(new PhabricatorAuthTemporaryTokenQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTokenResources(array($key))
->withTokenTypes(array($type))
->withExpired(false)
->executeOne();
if (!$token) {
throw new Exception(
pht(
'Unable to load your OAuth1 token secret from storage. It may '.
'have expired. Try authenticating again.'));
}
return $token->getTokenCode();
}
private function getTemporaryTokenType($core_type) {
// Namespace the type so that multiple providers don't step on each
// others' toes if a user starts Mediawiki and Bitbucket auth at the
// same time.
// TODO: This isn't really a proper use of the table and should get
// cleaned up some day: the type should be constant.
return $core_type.':'.$this->getProviderConfig()->getID();
}
private function getHandshakeTokenKeyFromClientCode($client_code) {
- // NOTE: This is very slightly coersive since the TemporaryToken table
+ // NOTE: This is very slightly coercive since the TemporaryToken table
// expects an "objectPHID" as an identifier, but nothing about the storage
// is bound to PHIDs.
return 'oauth1:secret/'.$client_code;
}
}
diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php
index 72b577771f..c25612408c 100644
--- a/src/applications/base/PhabricatorApplication.php
+++ b/src/applications/base/PhabricatorApplication.php
@@ -1,666 +1,666 @@
<?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 getMenuName() {
return $this->getName();
}
public function getShortDescription() {
return pht('%s Application', $this->getName());
}
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
- * unlauncahble prevents pinning and hides it from this view.
+ * 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 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 (developed by Phacility)
* and false otherwise.
*
* @return bool True if this application is developed by Phacility.
*/
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();
}
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 The viewing user.
* @param AphrontController 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 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 Application class name.
* @param PhabricatorUser 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();
}
final 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);
}
final 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 false;
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 for Phabricator to operate, 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 getQueryRoutePattern($base = null) {
return $base.'(?:query/(?P<queryKey>[^/]+)/)?';
}
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 getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new PhabricatorApplicationApplicationTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
}
diff --git a/src/applications/base/controller/PhabricatorController.php b/src/applications/base/controller/PhabricatorController.php
index 1ecfdaf557..f923eb7b73 100644
--- a/src/applications/base/controller/PhabricatorController.php
+++ b/src/applications/base/controller/PhabricatorController.php
@@ -1,582 +1,582 @@
<?php
abstract class PhabricatorController extends AphrontController {
private $handles;
public function shouldRequireLogin() {
return true;
}
public function shouldRequireAdmin() {
return false;
}
public function shouldRequireEnabledUser() {
return true;
}
public function shouldAllowPublic() {
return false;
}
public function shouldAllowPartialSessions() {
return false;
}
public function shouldRequireEmailVerification() {
return PhabricatorUserEmail::isEmailVerificationRequired();
}
public function shouldAllowRestrictedParameter($parameter_name) {
return false;
}
public function shouldRequireMultiFactorEnrollment() {
if (!$this->shouldRequireLogin()) {
return false;
}
if (!$this->shouldRequireEnabledUser()) {
return false;
}
if ($this->shouldAllowPartialSessions()) {
return false;
}
$user = $this->getRequest()->getUser();
if (!$user->getIsStandardUser()) {
return false;
}
return PhabricatorEnv::getEnvConfig('security.require-multi-factor-auth');
}
public function shouldAllowLegallyNonCompliantUsers() {
return false;
}
public function isGlobalDragAndDropUploadEnabled() {
return false;
}
public function willBeginExecution() {
$request = $this->getRequest();
if ($request->getUser()) {
// NOTE: Unit tests can set a user explicitly. Normal requests are not
// permitted to do this.
PhabricatorTestCase::assertExecutingUnitTests();
$user = $request->getUser();
} else {
$user = new PhabricatorUser();
$session_engine = new PhabricatorAuthSessionEngine();
$phsid = $request->getCookie(PhabricatorCookies::COOKIE_SESSION);
if (strlen($phsid)) {
$session_user = $session_engine->loadUserForSession(
PhabricatorAuthSession::TYPE_WEB,
$phsid);
if ($session_user) {
$user = $session_user;
}
} else {
// If the client doesn't have a session token, generate an anonymous
// session. This is used to provide CSRF protection to logged-out users.
$phsid = $session_engine->establishSession(
PhabricatorAuthSession::TYPE_WEB,
null,
$partial = false);
// This may be a resource request, in which case we just don't set
// the cookie.
if ($request->canSetCookies()) {
$request->setCookie(PhabricatorCookies::COOKIE_SESSION, $phsid);
}
}
if (!$user->isLoggedIn()) {
$user->attachAlternateCSRFString(PhabricatorHash::weakDigest($phsid));
}
$request->setUser($user);
}
id(new PhabricatorAuthSessionEngine())
->willServeRequestForUser($user);
if (PhabricatorEnv::getEnvConfig('darkconsole.enabled')) {
$dark_console = PhabricatorDarkConsoleSetting::SETTINGKEY;
if ($user->getUserSetting($dark_console) ||
PhabricatorEnv::getEnvConfig('darkconsole.always-on')) {
$console = new DarkConsoleCore();
$request->getApplicationConfiguration()->setConsole($console);
}
}
// NOTE: We want to set up the user first so we can render a real page
// here, but fire this before any real logic.
$restricted = array(
'code',
);
foreach ($restricted as $parameter) {
if ($request->getExists($parameter)) {
if (!$this->shouldAllowRestrictedParameter($parameter)) {
throw new Exception(
pht(
'Request includes restricted parameter "%s", but this '.
'controller ("%s") does not whitelist it. Refusing to '.
'serve this request because it might be part of a redirection '.
'attack.',
$parameter,
get_class($this)));
}
}
}
if ($this->shouldRequireEnabledUser()) {
if ($user->isLoggedIn() && !$user->getIsApproved()) {
$controller = new PhabricatorAuthNeedsApprovalController();
return $this->delegateToController($controller);
}
if ($user->getIsDisabled()) {
$controller = new PhabricatorDisabledUserController();
return $this->delegateToController($controller);
}
}
$auth_class = 'PhabricatorAuthApplication';
$auth_application = PhabricatorApplication::getByClass($auth_class);
// Require partial sessions to finish login before doing anything.
if (!$this->shouldAllowPartialSessions()) {
if ($user->hasSession() &&
$user->getSession()->getIsPartial()) {
$login_controller = new PhabricatorAuthFinishController();
$this->setCurrentApplication($auth_application);
return $this->delegateToController($login_controller);
}
}
// Check if the user needs to configure MFA.
$need_mfa = $this->shouldRequireMultiFactorEnrollment();
$have_mfa = $user->getIsEnrolledInMultiFactor();
if ($need_mfa && !$have_mfa) {
// Check if the cache is just out of date. Otherwise, roadblock the user
// and require MFA enrollment.
$user->updateMultiFactorEnrollment();
if (!$user->getIsEnrolledInMultiFactor()) {
$mfa_controller = new PhabricatorAuthNeedsMultiFactorController();
$this->setCurrentApplication($auth_application);
return $this->delegateToController($mfa_controller);
}
}
if ($this->shouldRequireLogin()) {
// This actually means we need either:
// - a valid user, or a public controller; and
// - permission to see the application; and
// - permission to see at least one Space if spaces are configured.
$allow_public = $this->shouldAllowPublic() &&
PhabricatorEnv::getEnvConfig('policy.allow-public');
// If this controller isn't public, and the user isn't logged in, require
// login.
if (!$allow_public && !$user->isLoggedIn()) {
$login_controller = new PhabricatorAuthStartController();
$this->setCurrentApplication($auth_application);
return $this->delegateToController($login_controller);
}
if ($user->isLoggedIn()) {
if ($this->shouldRequireEmailVerification()) {
if (!$user->getIsEmailVerified()) {
$controller = new PhabricatorMustVerifyEmailController();
$this->setCurrentApplication($auth_application);
return $this->delegateToController($controller);
}
}
}
// If Spaces are configured, require that the user have access to at
// least one. If we don't do this, they'll get confusing error messages
// later on.
$spaces = PhabricatorSpacesNamespaceQuery::getSpacesExist();
if ($spaces) {
$viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(
$user);
if (!$viewer_spaces) {
$controller = new PhabricatorSpacesNoAccessController();
return $this->delegateToController($controller);
}
}
// If the user doesn't have access to the application, don't let them use
// any of its controllers. We query the application in order to generate
// a policy exception if the viewer doesn't have permission.
$application = $this->getCurrentApplication();
if ($application) {
id(new PhabricatorApplicationQuery())
->setViewer($user)
->withPHIDs(array($application->getPHID()))
->executeOne();
}
}
if (!$this->shouldAllowLegallyNonCompliantUsers()) {
$legalpad_class = 'PhabricatorLegalpadApplication';
$legalpad = id(new PhabricatorApplicationQuery())
->setViewer($user)
->withClasses(array($legalpad_class))
->withInstalled(true)
->execute();
$legalpad = head($legalpad);
$doc_query = id(new LegalpadDocumentQuery())
->setViewer($user)
->withSignatureRequired(1)
->needViewerSignatures(true);
if ($user->hasSession() &&
!$user->getSession()->getIsPartial() &&
!$user->getSession()->getSignedLegalpadDocuments() &&
$user->isLoggedIn() &&
$legalpad) {
$sign_docs = $doc_query->execute();
$must_sign_docs = array();
foreach ($sign_docs as $sign_doc) {
if (!$sign_doc->getUserSignature($user->getPHID())) {
$must_sign_docs[] = $sign_doc;
}
}
if ($must_sign_docs) {
$controller = new LegalpadDocumentSignController();
$this->getRequest()->setURIMap(array(
'id' => head($must_sign_docs)->getID(),
));
$this->setCurrentApplication($legalpad);
return $this->delegateToController($controller);
} else {
$engine = id(new PhabricatorAuthSessionEngine())
->signLegalpadDocuments($user, $sign_docs);
}
}
}
// NOTE: We do this last so that users get a login page instead of a 403
// if they need to login.
if ($this->shouldRequireAdmin() && !$user->getIsAdmin()) {
return new Aphront403Response();
}
}
public function getApplicationURI($path = '') {
if (!$this->getCurrentApplication()) {
throw new Exception(pht('No application!'));
}
return $this->getCurrentApplication()->getApplicationURI($path);
}
public function willSendResponse(AphrontResponse $response) {
$request = $this->getRequest();
if ($response instanceof AphrontDialogResponse) {
if (!$request->isAjax() && !$request->isQuicksand()) {
$dialog = $response->getDialog();
$title = $dialog->getTitle();
$short = $dialog->getShortTitle();
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addTextCrumb(coalesce($short, $title));
$page_content = array(
$crumbs,
$response->buildResponseString(),
);
$view = id(new PhabricatorStandardPageView())
->setRequest($request)
->setController($this)
->setDeviceReady(true)
->setTitle($title)
->appendChild($page_content);
$response = id(new AphrontWebpageResponse())
->setContent($view->render())
->setHTTPResponseCode($response->getHTTPResponseCode());
} else {
$response->getDialog()->setIsStandalone(true);
return id(new AphrontAjaxResponse())
->setContent(array(
'dialog' => $response->buildResponseString(),
));
}
} else if ($response instanceof AphrontRedirectResponse) {
if ($request->isAjax() || $request->isQuicksand()) {
return id(new AphrontAjaxResponse())
->setContent(
array(
'redirect' => $response->getURI(),
));
}
}
return $response;
}
/**
* WARNING: Do not call this in new code.
*
* @deprecated See "Handles Technical Documentation".
*/
protected function loadViewerHandles(array $phids) {
return id(new PhabricatorHandleQuery())
->setViewer($this->getRequest()->getUser())
->withPHIDs($phids)
->execute();
}
public function buildApplicationMenu() {
return null;
}
protected function buildApplicationCrumbs() {
$crumbs = array();
$application = $this->getCurrentApplication();
if ($application) {
$icon = $application->getIcon();
if (!$icon) {
$icon = 'fa-puzzle';
}
$crumbs[] = id(new PHUICrumbView())
->setHref($this->getApplicationURI())
->setName($application->getName())
->setIcon($icon);
}
$view = new PHUICrumbsView();
foreach ($crumbs as $crumb) {
$view->addCrumb($crumb);
}
return $view;
}
protected function hasApplicationCapability($capability) {
return PhabricatorPolicyFilter::hasCapability(
$this->getRequest()->getUser(),
$this->getCurrentApplication(),
$capability);
}
protected function requireApplicationCapability($capability) {
PhabricatorPolicyFilter::requireCapability(
$this->getRequest()->getUser(),
$this->getCurrentApplication(),
$capability);
}
protected function explainApplicationCapability(
$capability,
$positive_message,
$negative_message) {
$can_act = $this->hasApplicationCapability($capability);
if ($can_act) {
$message = $positive_message;
$icon_name = 'fa-play-circle-o lightgreytext';
} else {
$message = $negative_message;
$icon_name = 'fa-lock';
}
$icon = id(new PHUIIconView())
->setIcon($icon_name);
require_celerity_resource('policy-css');
$phid = $this->getCurrentApplication()->getPHID();
$explain_uri = "/policy/explain/{$phid}/{$capability}/";
$message = phutil_tag(
'div',
array(
'class' => 'policy-capability-explanation',
),
array(
$icon,
javelin_tag(
'a',
array(
'href' => $explain_uri,
'sigil' => 'workflow',
),
$message),
));
return array($can_act, $message);
}
public function getDefaultResourceSource() {
return 'phabricator';
}
/**
* Create a new @{class:AphrontDialogView} with defaults filled in.
*
* @return AphrontDialogView New dialog.
*/
public function newDialog() {
$submit_uri = new PhutilURI($this->getRequest()->getRequestURI());
$submit_uri = $submit_uri->getPath();
return id(new AphrontDialogView())
->setUser($this->getRequest()->getUser())
->setSubmitURI($submit_uri);
}
public function newPage() {
$page = id(new PhabricatorStandardPageView())
->setRequest($this->getRequest())
->setController($this)
->setDeviceReady(true);
$application = $this->getCurrentApplication();
if ($application) {
$page->setApplicationName($application->getName());
if ($application->getTitleGlyph()) {
$page->setGlyph($application->getTitleGlyph());
}
}
$viewer = $this->getRequest()->getUser();
if ($viewer) {
$page->setUser($viewer);
}
return $page;
}
public function newApplicationMenu() {
return id(new PHUIApplicationMenuView())
->setViewer($this->getViewer());
}
public function newCurtainView($object = null) {
$viewer = $this->getViewer();
$action_id = celerity_generate_unique_node_id();
$action_list = id(new PhabricatorActionListView())
->setViewer($viewer)
->setID($action_id);
// NOTE: Applications (objects of class PhabricatorApplication) can't
// currently be set here, although they don't need any of the extensions
// anyway. This should probably work differently than it does, though.
if ($object) {
if ($object instanceof PhabricatorLiskDAO) {
$action_list->setObject($object);
}
}
$curtain = id(new PHUICurtainView())
->setViewer($viewer)
->setActionList($action_list);
if ($object) {
$panels = PHUICurtainExtension::buildExtensionPanels($viewer, $object);
foreach ($panels as $panel) {
$curtain->addPanel($panel);
}
}
return $curtain;
}
protected function buildTransactionTimeline(
PhabricatorApplicationTransactionInterface $object,
PhabricatorApplicationTransactionQuery $query,
PhabricatorMarkupEngine $engine = null,
$render_data = array()) {
$viewer = $this->getRequest()->getUser();
$xaction = $object->getApplicationTransactionTemplate();
$view = $xaction->getApplicationTransactionViewObject();
$pager = id(new AphrontCursorPagerView())
->readFromRequest($this->getRequest())
->setURI(new PhutilURI(
'/transactions/showolder/'.$object->getPHID().'/'));
$xactions = $query
->setViewer($viewer)
->withObjectPHIDs(array($object->getPHID()))
->needComments(true)
->executeWithCursorPager($pager);
$xactions = array_reverse($xactions);
if ($engine) {
foreach ($xactions as $xaction) {
if ($xaction->getComment()) {
$engine->addObject(
$xaction->getComment(),
PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT);
}
}
$engine->process();
$view->setMarkupEngine($engine);
}
$timeline = $view
->setUser($viewer)
->setObjectPHID($object->getPHID())
->setTransactions($xactions)
->setPager($pager)
->setRenderData($render_data)
->setQuoteTargetID($this->getRequest()->getStr('quoteTargetID'))
->setQuoteRef($this->getRequest()->getStr('quoteRef'));
$object->willRenderTimeline($timeline, $this->getRequest());
return $timeline;
}
public function buildApplicationCrumbsForEditEngine() {
- // TODO: This is kind of gross, I'm bascially just making this public so
+ // TODO: This is kind of gross, I'm basically just making this public so
// I can use it in EditEngine. We could do this without making it public
// by using controller delegation, or make it properly public.
return $this->buildApplicationCrumbs();
}
/* -( Deprecated )--------------------------------------------------------- */
/**
* DEPRECATED. Use @{method:newPage}.
*/
public function buildStandardPageView() {
return $this->newPage();
}
/**
* DEPRECATED. Use @{method:newPage}.
*/
public function buildStandardPageResponse($view, array $data) {
$page = $this->buildStandardPageView();
$page->appendChild($view);
return $page->produceAphrontResponse();
}
}
diff --git a/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php b/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php
index c3dc29bb51..90b4962ca9 100644
--- a/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php
+++ b/src/applications/calendar/editor/PhabricatorCalendarImportEditEngine.php
@@ -1,155 +1,155 @@
<?php
final class PhabricatorCalendarImportEditEngine
extends PhabricatorEditEngine {
const ENGINECONST = 'calendar.import';
private $importEngine;
public function setImportEngine(PhabricatorCalendarImportEngine $engine) {
$this->importEngine = $engine;
return $this;
}
public function getImportEngine() {
return $this->importEngine;
}
public function getEngineName() {
return pht('Calendar Imports');
}
public function isEngineConfigurable() {
return false;
}
public function getSummaryHeader() {
return pht('Configure Calendar Import Forms');
}
public function getSummaryText() {
return pht('Configure how users create and edit imports.');
}
public function getEngineApplicationClass() {
return 'PhabricatorCalendarApplication';
}
protected function newEditableObject() {
$viewer = $this->getViewer();
$engine = $this->getImportEngine();
return PhabricatorCalendarImport::initializeNewCalendarImport(
$viewer,
$engine);
}
protected function newObjectQuery() {
return new PhabricatorCalendarImportQuery();
}
protected function getObjectCreateTitleText($object) {
return pht('Create New Import');
}
protected function getObjectEditTitleText($object) {
return pht('Edit Import: %s', $object->getDisplayName());
}
protected function getObjectEditShortText($object) {
return pht('Import %d', $object->getID());
}
protected function getObjectCreateShortText() {
return pht('Create Import');
}
protected function getObjectName() {
return pht('Import');
}
protected function getObjectViewURI($object) {
return $object->getURI();
}
protected function getEditorURI() {
return $this->getApplication()->getApplicationURI('import/edit/');
}
protected function buildCustomEditFields($object) {
$viewer = $this->getViewer();
$engine = $object->getEngine();
$can_trigger = $engine->supportsTriggers($object);
$fields = array(
id(new PhabricatorTextEditField())
->setKey('name')
->setLabel(pht('Name'))
->setDescription(pht('Name of the import.'))
->setTransactionType(
PhabricatorCalendarImportNameTransaction::TRANSACTIONTYPE)
->setConduitDescription(pht('Rename the import.'))
->setConduitTypeDescription(pht('New import name.'))
->setPlaceholder($object->getDisplayName())
->setValue($object->getName()),
id(new PhabricatorBoolEditField())
->setKey('disabled')
->setOptions(pht('Active'), pht('Disabled'))
->setLabel(pht('Disabled'))
->setDescription(pht('Disable the import.'))
->setTransactionType(
PhabricatorCalendarImportDisableTransaction::TRANSACTIONTYPE)
->setIsConduitOnly(true)
->setConduitDescription(pht('Disable or restore the import.'))
->setConduitTypeDescription(pht('True to cancel the import.'))
->setValue($object->getIsDisabled()),
id(new PhabricatorBoolEditField())
->setKey('delete')
->setLabel(pht('Delete Imported Events'))
->setDescription(pht('Delete all events from this source.'))
->setTransactionType(
PhabricatorCalendarImportDisableTransaction::TRANSACTIONTYPE)
->setIsConduitOnly(true)
->setConduitDescription(pht('Disable or restore the import.'))
->setConduitTypeDescription(pht('True to delete imported events.'))
->setValue(false),
id(new PhabricatorBoolEditField())
->setKey('reload')
->setLabel(pht('Reload Import'))
->setDescription(pht('Reload events imported from this source.'))
->setTransactionType(
PhabricatorCalendarImportDisableTransaction::TRANSACTIONTYPE)
->setIsConduitOnly(true)
->setConduitDescription(pht('Disable or restore the import.'))
->setConduitTypeDescription(pht('True to reload the import.'))
->setValue(false),
);
if ($can_trigger) {
$frequency_map = PhabricatorCalendarImport::getTriggerFrequencyMap();
$frequency_options = ipull($frequency_map, 'name');
$fields[] = id(new PhabricatorSelectEditField())
->setKey('frequency')
->setLabel(pht('Update Automatically'))
- ->setDescription(pht('Configure an automatic update frequncy.'))
+ ->setDescription(pht('Configure an automatic update frequency.'))
->setTransactionType(
PhabricatorCalendarImportFrequencyTransaction::TRANSACTIONTYPE)
->setConduitDescription(pht('Set the automatic update frequency.'))
->setConduitTypeDescription(pht('Update frequency constant.'))
->setValue($object->getTriggerFrequency())
->setOptions($frequency_options);
}
$import_engine = $object->getEngine();
foreach ($import_engine->newEditEngineFields($this, $object) as $field) {
$fields[] = $field;
}
return $fields;
}
}
diff --git a/src/applications/calendar/icon/PhabricatorCalendarIconSet.php b/src/applications/calendar/icon/PhabricatorCalendarIconSet.php
index 22d5b0e4f5..ab6d2126ed 100644
--- a/src/applications/calendar/icon/PhabricatorCalendarIconSet.php
+++ b/src/applications/calendar/icon/PhabricatorCalendarIconSet.php
@@ -1,45 +1,45 @@
<?php
final class PhabricatorCalendarIconSet
extends PhabricatorIconSet {
const ICONSETKEY = 'calendar.event';
public function getSelectIconTitleText() {
return pht('Choose Event Icon');
}
protected function newIcons() {
$map = array(
'fa-calendar' => pht('Default'),
'fa-glass' => pht('Party'),
'fa-plane' => pht('Travel'),
'fa-plus-square' => pht('Health / Appointment'),
- 'fa-rocket' => pht('Sabatical / Leave'),
+ 'fa-rocket' => pht('Sabbatical / Leave'),
'fa-home' => pht('Working From Home'),
'fa-tree' => pht('Holiday'),
'fa-gamepad' => pht('Staycation'),
'fa-coffee' => pht('Coffee Meeting'),
'fa-film' => pht('Movie'),
'fa-users' => pht('Meeting'),
'fa-cutlery' => pht('Meal'),
'fa-paw' => pht('Pet Activity'),
'fa-institution' => pht('Official Business'),
'fa-bus' => pht('Field Trip'),
'fa-microphone' => pht('Conference'),
);
$icons = array();
foreach ($map as $key => $label) {
$icons[] = id(new PhabricatorIconSetIcon())
->setKey($key)
->setLabel($label);
}
return $icons;
}
}
diff --git a/src/applications/calendar/util/CalendarTimeUtil.php b/src/applications/calendar/util/CalendarTimeUtil.php
index 781f6adf7a..d3276654e4 100644
--- a/src/applications/calendar/util/CalendarTimeUtil.php
+++ b/src/applications/calendar/util/CalendarTimeUtil.php
@@ -1,90 +1,90 @@
<?php
/**
* This class is useful for generating various time objects, relative to the
* user and their timezone.
*
* For now, the class exposes two sets of static methods for the two main
* calendar views - one for the conpherence calendar widget and one for the
* user profile calendar view. These have slight differences such as
* conpherence showing both a three day "today 'til 2 days from now" *and*
- * a Sunday -> Saturday list, whilest the profile view shows a more simple
+ * a Sunday -> Saturday list, whilst the profile view shows a more simple
* seven day rolling list of events.
*/
final class CalendarTimeUtil extends Phobject {
public static function getCalendarEventEpochs(
PhabricatorUser $user,
$start_day_str = 'Sunday',
$days = 9) {
$objects = self::getStartDateTimeObjects($user, $start_day_str);
$start_day = $objects['start_day'];
$end_day = clone $start_day;
$end_day->modify('+'.$days.' days');
return array(
'start_epoch' => $start_day->format('U'),
'end_epoch' => $end_day->format('U'),
);
}
public static function getCalendarWeekTimestamps(
PhabricatorUser $user) {
return self::getTimestamps($user, 'Today', 7);
}
public static function getCalendarWidgetTimestamps(
PhabricatorUser $user) {
return self::getTimestamps($user, 'Sunday', 9);
}
/**
* Public for testing purposes only. You should probably use one of the
* functions above.
*/
public static function getTimestamps(
PhabricatorUser $user,
$start_day_str,
$days) {
$objects = self::getStartDateTimeObjects($user, $start_day_str);
$start_day = $objects['start_day'];
$timestamps = array();
for ($day = 0; $day < $days; $day++) {
$timestamp = clone $start_day;
$timestamp->modify(sprintf('+%d days', $day));
$timestamps[] = $timestamp;
}
return array(
'today' => $objects['today'],
'epoch_stamps' => $timestamps,
);
}
private static function getStartDateTimeObjects(
PhabricatorUser $user,
$start_day_str) {
$timezone = new DateTimeZone($user->getTimezoneIdentifier());
$today_epoch = PhabricatorTime::parseLocalTime('today', $user);
$today = new DateTime('@'.$today_epoch);
$today->setTimezone($timezone);
if (strtolower($start_day_str) == 'today' ||
$today->format('l') == $start_day_str) {
$start_day = clone $today;
} else {
$start_epoch = PhabricatorTime::parseLocalTime(
'last '.$start_day_str,
$user);
$start_day = new DateTime('@'.$start_epoch);
$start_day->setTimezone($timezone);
}
return array(
'today' => $today,
'start_day' => $start_day,
);
}
}
diff --git a/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php b/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php
index 6fb8ae8f31..69f2e9e8a6 100644
--- a/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php
+++ b/src/applications/calendar/xaction/PhabricatorCalendarEventFrequencyTransaction.php
@@ -1,141 +1,141 @@
<?php
final class PhabricatorCalendarEventFrequencyTransaction
extends PhabricatorCalendarEventTransactionType {
const TRANSACTIONTYPE = 'calendar.frequency';
public function generateOldValue($object) {
$rrule = $object->newRecurrenceRule();
if (!$rrule) {
return null;
}
return $rrule->getFrequency();
}
public function applyInternalEffects($object, $value) {
$rrule = id(new PhutilCalendarRecurrenceRule())
->setFrequency($value);
// If the user creates a monthly event on the 29th, 30th or 31st of a
// month, it means "the 30th of every month" as far as the RRULE is
// concerned. Such an event will not occur on months with fewer days.
- // This is surprising, and proably not what the user wants. Instead,
+ // This is surprising, and probably not what the user wants. Instead,
// schedule these events relative to the end of the month: on the "-1st",
// "-2nd" or "-3rd" day of the month. For example, a monthly event on
// the 31st of a 31-day month translates to "every month, on the last
// day of the month".
if ($value == PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY) {
$start_datetime = $object->newStartDateTime();
$y = $start_datetime->getYear();
$m = $start_datetime->getMonth();
$d = $start_datetime->getDay();
if ($d >= 29) {
$year_map = PhutilCalendarRecurrenceRule::getYearMap(
$y,
PhutilCalendarRecurrenceRule::WEEKDAY_MONDAY);
$month_days = $year_map['monthDays'][$m];
$schedule_on = -(($month_days + 1) - $d);
$rrule->setByMonthDay(array($schedule_on));
}
}
$object->setRecurrenceRule($rrule);
}
public function validateTransactions($object, array $xactions) {
$errors = array();
$valid = array(
PhutilCalendarRecurrenceRule::FREQUENCY_DAILY,
PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY,
PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY,
PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY,
);
$valid = array_fuse($valid);
foreach ($xactions as $xaction) {
$value = $xaction->getNewValue();
if (!isset($valid[$value])) {
$errors[] = $this->newInvalidError(
pht(
- 'Event frequency "%s" is not valid. Valid frequences are: %s.',
+ 'Event frequency "%s" is not valid. Valid frequencies are: %s.',
$value,
implode(', ', $valid)),
$xaction);
}
}
return $errors;
}
public function getTitle() {
$frequency = $this->getFrequency($this->getNewValue());
switch ($frequency) {
case PhutilCalendarRecurrenceRule::FREQUENCY_DAILY:
return pht(
'%s set this event to repeat daily.',
$this->renderAuthor());
case PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY:
return pht(
'%s set this event to repeat weekly.',
$this->renderAuthor());
case PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY:
return pht(
'%s set this event to repeat monthly.',
$this->renderAuthor());
case PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY:
return pht(
'%s set this event to repeat yearly.',
$this->renderAuthor());
}
}
public function getTitleForFeed() {
$frequency = $this->getFrequency($this->getNewValue());
switch ($frequency) {
case PhutilCalendarRecurrenceRule::FREQUENCY_DAILY:
return pht(
'%s set %s to repeat daily.',
$this->renderAuthor(),
$this->renderObject());
case PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY:
return pht(
'%s set %s to repeat weekly.',
$this->renderAuthor(),
$this->renderObject());
case PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY:
return pht(
'%s set %s to repeat monthly.',
$this->renderAuthor(),
$this->renderObject());
case PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY:
return pht(
'%s set %s to repeat yearly.',
$this->renderAuthor(),
$this->renderObject());
}
}
private function getFrequency($value) {
// NOTE: This is normalizing three generations of these transactions
// to use RRULE constants. It would be vaguely nice to migrate them
// for consistency.
if (is_array($value)) {
$value = idx($value, 'rule');
} else {
$value = $value;
}
return phutil_utf8_strtoupper($value);
}
}
diff --git a/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php b/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php
index f1da2c1d77..841a066320 100644
--- a/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php
+++ b/src/applications/calendar/xaction/PhabricatorCalendarEventInviteTransaction.php
@@ -1,184 +1,184 @@
<?php
final class PhabricatorCalendarEventInviteTransaction
extends PhabricatorCalendarEventTransactionType {
const TRANSACTIONTYPE = 'calendar.invite';
public function generateOldValue($object) {
$status_uninvited = PhabricatorCalendarEventInvitee::STATUS_UNINVITED;
$invitees = $object->getInvitees();
foreach ($invitees as $key => $invitee) {
if ($invitee->getStatus() == $status_uninvited) {
unset($invitees[$key]);
}
}
return array_values(mpull($invitees, 'getInviteePHID'));
}
private function generateChangeMap($object, $new_value) {
$status_invited = PhabricatorCalendarEventInvitee::STATUS_INVITED;
$status_uninvited = PhabricatorCalendarEventInvitee::STATUS_UNINVITED;
$status_attending = PhabricatorCalendarEventInvitee::STATUS_ATTENDING;
$old = $this->generateOldValue($object);
$add = array_diff($new_value, $old);
$rem = array_diff($old, $new_value);
$map = array();
foreach ($add as $phid) {
$map[$phid] = $status_invited;
}
foreach ($rem as $phid) {
$map[$phid] = $status_uninvited;
}
// If we're creating this event and the actor is inviting themselves,
// mark them as attending.
if ($this->isNewObject()) {
$acting_phid = $this->getActingAsPHID();
if (isset($map[$acting_phid])) {
$map[$acting_phid] = $status_attending;
}
}
return $map;
}
public function applyExternalEffects($object, $value) {
$map = $this->generateChangeMap($object, $value);
$invitees = $object->getInvitees();
$invitees = mpull($invitees, null, 'getInviteePHID');
foreach ($map as $phid => $status) {
$invitee = idx($invitees, $phid);
if (!$invitee) {
$invitee = id(new PhabricatorCalendarEventInvitee())
->setEventPHID($object->getPHID())
->setInviteePHID($phid)
->setInviterPHID($this->getActingAsPHID());
$invitees[] = $invitee;
}
$invitee->setStatus($status)
->save();
}
$object->attachInvitees($invitees);
}
public function validateTransactions($object, array $xactions) {
$actor = $this->getActor();
$errors = array();
$old = $object->getInvitees();
$old = mpull($old, null, 'getInviteePHID');
foreach ($xactions as $xaction) {
$new = $xaction->getNewValue();
$new = array_fuse($new);
$add = array_diff_key($new, $old);
if (!$add) {
continue;
}
// In the UI, we only allow you to invite mailable objects, but there
// is no definitive marker for "invitable object" today. Just allow
// any valid object to be invited.
$objects = id(new PhabricatorObjectQuery())
->setViewer($actor)
->withPHIDs($add)
->execute();
$objects = mpull($objects, null, 'getPHID');
foreach ($add as $phid) {
if (isset($objects[$phid])) {
continue;
}
$errors[] = $this->newInvalidError(
pht(
'Invitee "%s" identifies an object that does not exist or '.
'which you do not have permission to view.',
$phid),
$xaction);
}
}
return $errors;
}
public function getIcon() {
return 'fa-user-plus';
}
public function getTitle() {
list($add, $rem) = $this->getChanges();
if ($add && !$rem) {
return pht(
'%s invited %s attendee(s): %s.',
$this->renderAuthor(),
phutil_count($add),
$this->renderHandleList($add));
} else if (!$add && $rem) {
return pht(
'%s uninvited %s attendee(s): %s.',
$this->renderAuthor(),
phutil_count($rem),
$this->renderHandleList($rem));
} else {
return pht(
- '%s invited %s attendee(s): %s; uninvinted %s attendee(s): %s.',
+ '%s invited %s attendee(s): %s; uninvited %s attendee(s): %s.',
$this->renderAuthor(),
phutil_count($add),
$this->renderHandleList($add),
phutil_count($rem),
$this->renderHandleList($rem));
}
}
public function getTitleForFeed() {
list($add, $rem) = $this->getChanges();
if ($add && !$rem) {
return pht(
'%s invited %s attendee(s) to %s: %s.',
$this->renderAuthor(),
phutil_count($add),
$this->renderObject(),
$this->renderHandleList($add));
} else if (!$add && $rem) {
return pht(
'%s uninvited %s attendee(s) to %s: %s.',
$this->renderAuthor(),
phutil_count($rem),
$this->renderObject(),
$this->renderHandleList($rem));
} else {
return pht(
'%s updated the invite list for %s, invited %s: %s; '.
- 'uninvinted %s: %s.',
+ 'uninvited %s: %s.',
$this->renderAuthor(),
$this->renderObject(),
phutil_count($add),
$this->renderHandleList($add),
phutil_count($rem),
$this->renderHandleList($rem));
}
}
private function getChanges() {
$old = $this->getOldValue();
$new = $this->getNewValue();
$add = array_diff($new, $old);
$rem = array_diff($old, $new);
return array(array_fuse($add), array_fuse($rem));
}
}
diff --git a/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php b/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php
index 177adbbf0b..9fc68a3497 100644
--- a/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php
+++ b/src/applications/calendar/xaction/PhabricatorCalendarImportFrequencyTransaction.php
@@ -1,45 +1,45 @@
<?php
final class PhabricatorCalendarImportFrequencyTransaction
extends PhabricatorCalendarImportTransactionType {
const TRANSACTIONTYPE = 'calendar.import.frequency';
public function generateOldValue($object) {
return $object->getTriggerFrequency();
}
public function applyInternalEffects($object, $value) {
$object->setTriggerFrequency($value);
}
public function getTitle() {
return pht(
'%s changed the automatic update frequency for this import.',
$this->renderAuthor());
}
public function validateTransactions($object, array $xactions) {
$errors = array();
$frequency_map = PhabricatorCalendarImport::getTriggerFrequencyMap();
$valid = array_keys($frequency_map);
$valid = array_fuse($valid);
foreach ($xactions as $xaction) {
$value = $xaction->getNewValue();
if (!isset($valid[$value])) {
$errors[] = $this->newInvalidError(
pht(
- 'Import frequency "%s" is not valid. Valid frequences are: %s.',
+ 'Import frequency "%s" is not valid. Valid frequencies are: %s.',
$value,
implode(', ', $valid)),
$xaction);
}
}
return $errors;
}
}
diff --git a/src/applications/config/check/PhabricatorStorageSetupCheck.php b/src/applications/config/check/PhabricatorStorageSetupCheck.php
index 09cecfe6d0..cc74cce2ea 100644
--- a/src/applications/config/check/PhabricatorStorageSetupCheck.php
+++ b/src/applications/config/check/PhabricatorStorageSetupCheck.php
@@ -1,196 +1,196 @@
<?php
final class PhabricatorStorageSetupCheck extends PhabricatorSetupCheck {
public function getDefaultGroup() {
return self::GROUP_OTHER;
}
/**
* @phutil-external-symbol class PhabricatorStartup
*/
protected function executeChecks() {
$engines = PhabricatorFileStorageEngine::loadWritableChunkEngines();
$chunk_engine_active = (bool)$engines;
$this->checkS3();
if (!$chunk_engine_active) {
$doc_href = PhabricatorEnv::getDoclink('Configuring File Storage');
$message = pht(
'Large file storage has not been configured, which will limit '.
'the maximum size of file uploads. See %s for '.
'instructions on configuring uploads and storage.',
phutil_tag(
'a',
array(
'href' => $doc_href,
'target' => '_blank',
),
pht('Configuring File Storage')));
$this
->newIssue('large-files')
->setShortName(pht('Large Files'))
->setName(pht('Large File Storage Not Configured'))
->setMessage($message);
}
$post_max_size = ini_get('post_max_size');
if ($post_max_size && ((int)$post_max_size > 0)) {
$post_max_bytes = phutil_parse_bytes($post_max_size);
$post_max_need = (32 * 1024 * 1024);
if ($post_max_need > $post_max_bytes) {
$summary = pht(
'Set %s in your PHP configuration to at least 32MB '.
'to support large file uploads.',
phutil_tag('tt', array(), 'post_max_size'));
$message = pht(
'Adjust %s in your PHP configuration to at least 32MB. When '.
'set to smaller value, large file uploads may not work properly.',
phutil_tag('tt', array(), 'post_max_size'));
$this
->newIssue('php.post_max_size')
->setName(pht('PHP post_max_size Not Configured'))
->setSummary($summary)
->setMessage($message)
->setGroup(self::GROUP_PHP)
->addPHPConfig('post_max_size');
}
}
// This is somewhat arbitrary, but make sure we have enough headroom to
// upload a default file at the chunk threshold (8MB), which may be
// base64 encoded, then JSON encoded in the request, and may need to be
// held in memory in the raw and as a query string.
$need_bytes = (64 * 1024 * 1024);
$memory_limit = PhabricatorStartup::getOldMemoryLimit();
if ($memory_limit && ((int)$memory_limit > 0)) {
$memory_limit_bytes = phutil_parse_bytes($memory_limit);
$memory_usage_bytes = memory_get_usage();
$available_bytes = ($memory_limit_bytes - $memory_usage_bytes);
if ($need_bytes > $available_bytes) {
$summary = pht(
'Your PHP memory limit is configured in a way that may prevent '.
'you from uploading large files or handling large requests.');
$message = pht(
'When you upload a file via drag-and-drop or the API, chunks must '.
'be buffered into memory before being written to permanent '.
'storage. Phabricator needs memory available to store these '.
'chunks while they are uploaded, but PHP is currently configured '.
- 'to severly limit the available memory.'.
+ 'to severely limit the available memory.'.
"\n\n".
'PHP processes currently have very little free memory available '.
'(%s). To work well, processes should have at least %s.'.
"\n\n".
'(Note that the application itself must also fit in available '.
'memory, so not all of the memory under the memory limit is '.
'available for running workloads.)'.
"\n\n".
"The easiest way to resolve this issue is to set %s to %s in your ".
"PHP configuration, to disable the memory limit. There is ".
"usually little or no value to using this option to limit ".
"Phabricator process memory.".
"\n\n".
"You can also increase the limit or ignore this issue and accept ".
"that you may encounter problems uploading large files and ".
"processing large requests.",
phutil_format_bytes($available_bytes),
phutil_format_bytes($need_bytes),
phutil_tag('tt', array(), 'memory_limit'),
phutil_tag('tt', array(), '-1'));
$this
->newIssue('php.memory_limit.upload')
->setName(pht('Memory Limit Restricts File Uploads'))
->setSummary($summary)
->setMessage($message)
->setGroup(self::GROUP_PHP)
->addPHPConfig('memory_limit')
->addPHPConfigOriginalValue('memory_limit', $memory_limit);
}
}
$local_path = PhabricatorEnv::getEnvConfig('storage.local-disk.path');
if (!$local_path) {
return;
}
if (!Filesystem::pathExists($local_path) ||
!is_readable($local_path) ||
!is_writable($local_path)) {
$message = pht(
'Configured location for storing uploaded files on disk ("%s") does '.
'not exist, or is not readable or writable. Verify the directory '.
'exists and is readable and writable by the webserver.',
$local_path);
$this
->newIssue('config.storage.local-disk.path')
->setShortName(pht('Local Disk Storage'))
->setName(pht('Local Disk Storage Not Readable/Writable'))
->setMessage($message)
->addPhabricatorConfig('storage.local-disk.path');
}
}
private function checkS3() {
$access_key = PhabricatorEnv::getEnvConfig('amazon-s3.access-key');
$secret_key = PhabricatorEnv::getEnvConfig('amazon-s3.secret-key');
$region = PhabricatorEnv::getEnvConfig('amazon-s3.region');
$endpoint = PhabricatorEnv::getEnvConfig('amazon-s3.endpoint');
$how_many = 0;
if (strlen($access_key)) {
$how_many++;
}
if (strlen($secret_key)) {
$how_many++;
}
if (strlen($region)) {
$how_many++;
}
if (strlen($endpoint)) {
$how_many++;
}
// Nothing configured, no issues here.
if ($how_many === 0) {
return;
}
// Everything configured, no issues here.
if ($how_many === 4) {
return;
}
$message = pht(
'File storage in Amazon S3 has been partially configured, but you are '.
'missing some required settings. S3 will not be available to store '.
'files until you complete the configuration. Either configure S3 fully '.
'or remove the partial configuration.');
$this->newIssue('storage.s3.partial-config')
->setShortName(pht('S3 Partially Configured'))
->setName(pht('Amazon S3 is Only Partially Configured'))
->setMessage($message)
->addPhabricatorConfig('amazon-s3.access-key')
->addPhabricatorConfig('amazon-s3.secret-key')
->addPhabricatorConfig('amazon-s3.region')
->addPhabricatorConfig('amazon-s3.endpoint');
}
}
diff --git a/src/applications/conpherence/query/ConpherenceThreadQuery.php b/src/applications/conpherence/query/ConpherenceThreadQuery.php
index 1bb96537dc..5cd6489d65 100644
--- a/src/applications/conpherence/query/ConpherenceThreadQuery.php
+++ b/src/applications/conpherence/query/ConpherenceThreadQuery.php
@@ -1,326 +1,326 @@
<?php
final class ConpherenceThreadQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
const TRANSACTION_LIMIT = 100;
private $phids;
private $ids;
private $participantPHIDs;
private $needParticipants;
private $needTransactions;
private $afterTransactionID;
private $beforeTransactionID;
private $transactionLimit;
private $fulltext;
private $needProfileImage;
public function needParticipants($need) {
$this->needParticipants = $need;
return $this;
}
public function needProfileImage($need) {
$this->needProfileImage = $need;
return $this;
}
public function needTransactions($need_transactions) {
$this->needTransactions = $need_transactions;
return $this;
}
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withParticipantPHIDs(array $phids) {
$this->participantPHIDs = $phids;
return $this;
}
public function setAfterTransactionID($id) {
$this->afterTransactionID = $id;
return $this;
}
public function setBeforeTransactionID($id) {
$this->beforeTransactionID = $id;
return $this;
}
public function setTransactionLimit($transaction_limit) {
$this->transactionLimit = $transaction_limit;
return $this;
}
public function getTransactionLimit() {
return $this->transactionLimit;
}
public function withFulltext($query) {
$this->fulltext = $query;
return $this;
}
public function withTitleNgrams($ngrams) {
return $this->withNgramsConstraint(
id(new ConpherenceThreadTitleNgrams()),
$ngrams);
}
protected function loadPage() {
$table = new ConpherenceThread();
$conn_r = $table->establishConnection('r');
$data = queryfx_all(
$conn_r,
'SELECT thread.* FROM %T thread %Q %Q %Q %Q %Q',
$table->getTableName(),
$this->buildJoinClause($conn_r),
$this->buildWhereClause($conn_r),
$this->buildGroupClause($conn_r),
$this->buildOrderClause($conn_r),
$this->buildLimitClause($conn_r));
$conpherences = $table->loadAllFromArray($data);
if ($conpherences) {
$conpherences = mpull($conpherences, null, 'getPHID');
$this->loadParticipantsAndInitHandles($conpherences);
if ($this->needParticipants) {
$this->loadCoreHandles($conpherences, 'getParticipantPHIDs');
}
if ($this->needTransactions) {
$this->loadTransactionsAndHandles($conpherences);
}
if ($this->needProfileImage) {
$default = null;
$file_phids = mpull($conpherences, 'getProfileImagePHID');
$file_phids = array_filter($file_phids);
if ($file_phids) {
$files = id(new PhabricatorFileQuery())
->setParentQuery($this)
->setViewer($this->getViewer())
->withPHIDs($file_phids)
->execute();
$files = mpull($files, null, 'getPHID');
} else {
$files = array();
}
foreach ($conpherences as $conpherence) {
$file = idx($files, $conpherence->getProfileImagePHID());
if (!$file) {
if (!$default) {
$default = PhabricatorFile::loadBuiltin(
$this->getViewer(),
'conpherence.png');
}
$file = $default;
}
$conpherence->attachProfileImageFile($file);
}
}
}
return $conpherences;
}
protected function buildGroupClause(AphrontDatabaseConnection $conn_r) {
if ($this->participantPHIDs !== null || strlen($this->fulltext)) {
return 'GROUP BY thread.id';
} else {
return $this->buildApplicationSearchGroupClause($conn_r);
}
}
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
$joins = parent::buildJoinClauseParts($conn);
if ($this->participantPHIDs !== null) {
$joins[] = qsprintf(
$conn,
'JOIN %T p ON p.conpherencePHID = thread.phid',
id(new ConpherenceParticipant())->getTableName());
}
if (strlen($this->fulltext)) {
$joins[] = qsprintf(
$conn,
'JOIN %T idx ON idx.threadPHID = thread.phid',
id(new ConpherenceIndex())->getTableName());
}
// See note in buildWhereClauseParts() about this optimization.
$viewer = $this->getViewer();
if (!$viewer->isOmnipotent() && $viewer->isLoggedIn()) {
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T vp ON vp.conpherencePHID = thread.phid
AND vp.participantPHID = %s',
id(new ConpherenceParticipant())->getTableName(),
$viewer->getPHID());
}
return $joins;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
// Optimize policy filtering of private rooms. If we are not looking for
// particular rooms by ID or PHID, we can just skip over any rooms with
// "View Policy: Room Participants" if the viewer isn't a participant: we
// know they won't be able to see the room.
// This avoids overheating browse/search queries, since it's common for
// a large number of rooms to be private and have this view policy.
$viewer = $this->getViewer();
$can_optimize =
!$viewer->isOmnipotent() &&
($this->ids === null) &&
($this->phids === null);
if ($can_optimize) {
$members_policy = id(new ConpherenceThreadMembersPolicyRule())
->getObjectPolicyFullKey();
if ($viewer->isLoggedIn()) {
$where[] = qsprintf(
$conn,
'thread.viewPolicy != %s OR vp.participantPHID = %s',
$members_policy,
$viewer->getPHID());
} else {
$where[] = qsprintf(
$conn,
'thread.viewPolicy != %s',
$members_policy);
}
}
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'thread.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'thread.phid IN (%Ls)',
$this->phids);
}
if ($this->participantPHIDs !== null) {
$where[] = qsprintf(
$conn,
'p.participantPHID IN (%Ls)',
$this->participantPHIDs);
}
if (strlen($this->fulltext)) {
$where[] = qsprintf(
$conn,
'MATCH(idx.corpus) AGAINST (%s IN BOOLEAN MODE)',
$this->fulltext);
}
return $where;
}
private function loadParticipantsAndInitHandles(array $conpherences) {
$participants = id(new ConpherenceParticipant())
->loadAllWhere('conpherencePHID IN (%Ls)', array_keys($conpherences));
$map = mgroup($participants, 'getConpherencePHID');
foreach ($conpherences as $current_conpherence) {
$conpherence_phid = $current_conpherence->getPHID();
$conpherence_participants = idx(
$map,
$conpherence_phid,
array());
$conpherence_participants = mpull(
$conpherence_participants,
null,
'getParticipantPHID');
$current_conpherence->attachParticipants($conpherence_participants);
$current_conpherence->attachHandles(array());
}
return $this;
}
private function loadCoreHandles(
array $conpherences,
$method) {
$handle_phids = array();
foreach ($conpherences as $conpherence) {
$handle_phids[$conpherence->getPHID()] =
$conpherence->$method();
}
$flat_phids = array_mergev($handle_phids);
$viewer = $this->getViewer();
$handles = $viewer->loadHandles($flat_phids);
$handles = iterator_to_array($handles);
foreach ($handle_phids as $conpherence_phid => $phids) {
$conpherence = $conpherences[$conpherence_phid];
$conpherence->attachHandles(
$conpherence->getHandles() + array_select_keys($handles, $phids));
}
return $this;
}
private function loadTransactionsAndHandles(array $conpherences) {
$query = id(new ConpherenceTransactionQuery())
->setViewer($this->getViewer())
->withObjectPHIDs(array_keys($conpherences))
->needHandles(true);
- // We have to flip these for the underyling query class. The semantics of
+ // We have to flip these for the underlying query class. The semantics of
// paging are tricky business.
if ($this->afterTransactionID) {
$query->setBeforeID($this->afterTransactionID);
} else if ($this->beforeTransactionID) {
$query->setAfterID($this->beforeTransactionID);
}
if ($this->getTransactionLimit()) {
// fetch an extra for "show older" scenarios
$query->setLimit($this->getTransactionLimit() + 1);
}
$transactions = $query->execute();
$transactions = mgroup($transactions, 'getObjectPHID');
foreach ($conpherences as $phid => $conpherence) {
$current_transactions = idx($transactions, $phid, array());
$handles = array();
foreach ($current_transactions as $transaction) {
$handles += $transaction->getHandles();
}
$conpherence->attachHandles($conpherence->getHandles() + $handles);
$conpherence->attachTransactions($current_transactions);
}
return $this;
}
public function getQueryApplicationClass() {
return 'PhabricatorConpherenceApplication';
}
protected function getPrimaryTableAlias() {
return 'thread';
}
}
diff --git a/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php b/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php
index 96566db54a..07bbb67ae2 100644
--- a/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php
+++ b/src/applications/dashboard/customfield/PhabricatorDashboardPanelTabsCustomField.php
@@ -1,114 +1,114 @@
<?php
final class PhabricatorDashboardPanelTabsCustomField
extends PhabricatorStandardCustomField {
public function getFieldType() {
return 'dashboard.tabs';
}
public function shouldAppearInApplicationSearch() {
return false;
}
public function readValueFromRequest(AphrontRequest $request) {
$value = array();
$names = $request->getArr($this->getFieldKey().'_name');
$panel_ids = $request->getArr($this->getFieldKey().'_panelID');
$panels = array();
foreach ($panel_ids as $panel_id) {
$panels[] = $panel_id[0];
}
foreach ($names as $idx => $name) {
$panel_id = idx($panels, $idx);
if (strlen($name) && $panel_id) {
$value[] = array(
'name' => $name,
'panelID' => $panel_id,
);
}
}
$this->setFieldValue($value);
}
public function getApplicationTransactionTitle(
PhabricatorApplicationTransaction $xaction) {
$author_phid = $xaction->getAuthorPHID();
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$new_tabs = array();
if ($new) {
foreach ($new as $new_tab) {
$new_tabs[] = $new_tab['name'];
}
$new_tabs = implode(' | ', $new_tabs);
}
$old_tabs = array();
if ($old) {
foreach ($old as $old_tab) {
$old_tabs[] = $old_tab['name'];
}
$old_tabs = implode(' | ', $old_tabs);
}
if (!$old) {
// In case someone makes a tab panel with no tabs.
if ($new) {
return pht(
'%s set the tabs to "%s".',
$xaction->renderHandleLink($author_phid),
$new_tabs);
}
} else if (!$new) {
return pht(
'%s removed tabs.',
$xaction->renderHandleLink($author_phid));
} else {
return pht(
'%s changed the tabs from "%s" to "%s".',
$xaction->renderHandleLink($author_phid),
$old_tabs,
$new_tabs);
}
}
public function renderEditControl(array $handles) {
// NOTE: This includes archived panels so we don't mutate the tabs
- // when saving a tab panel that includes archied panels. This whole UI is
+ // when saving a tab panel that includes archived panels. This whole UI is
// hopefully temporary anyway.
$value = $this->getFieldValue();
if (!is_array($value)) {
$value = array();
}
$out = array();
for ($ii = 1; $ii <= 6; $ii++) {
$tab = idx($value, ($ii - 1), array());
$panel = idx($tab, 'panelID', null);
$panel_id = array();
if ($panel) {
$panel_id[] = $panel;
}
$out[] = id(new AphrontFormTextControl())
->setName($this->getFieldKey().'_name[]')
->setValue(idx($tab, 'name'))
->setLabel(pht('Tab %d Name', $ii));
$out[] = id(new AphrontFormTokenizerControl())
->setUser($this->getViewer())
->setDatasource(new PhabricatorDashboardPanelDatasource())
->setName($this->getFieldKey().'_panelID[]')
->setValue($panel_id)
->setLimit(1)
->setLabel(pht('Tab %d Panel', $ii));
}
return $out;
}
}
diff --git a/src/applications/differential/controller/DifferentialController.php b/src/applications/differential/controller/DifferentialController.php
index fb8a6249a7..1860a07745 100644
--- a/src/applications/differential/controller/DifferentialController.php
+++ b/src/applications/differential/controller/DifferentialController.php
@@ -1,276 +1,276 @@
<?php
abstract class DifferentialController extends PhabricatorController {
private $packageChangesetMap;
private $pathPackageMap;
private $authorityPackages;
public function buildSideNavView($for_app = false) {
$viewer = $this->getRequest()->getUser();
$nav = new AphrontSideNavFilterView();
$nav->setBaseURI(new PhutilURI($this->getApplicationURI()));
id(new DifferentialRevisionSearchEngine())
->setViewer($viewer)
->addNavigationItems($nav->getMenu());
$nav->selectFilter(null);
return $nav;
}
public function buildApplicationMenu() {
return $this->buildSideNavView(true)->getMenu();
}
protected function buildPackageMaps(array $changesets) {
assert_instances_of($changesets, 'DifferentialChangeset');
$this->packageChangesetMap = array();
$this->pathPackageMap = array();
$this->authorityPackages = array();
if (!$changesets) {
return;
}
$viewer = $this->getViewer();
$have_owners = PhabricatorApplication::isClassInstalledForViewer(
'PhabricatorOwnersApplication',
$viewer);
if (!$have_owners) {
return;
}
$changeset = head($changesets);
$diff = $changeset->getDiff();
$repository_phid = $diff->getRepositoryPHID();
if (!$repository_phid) {
return;
}
if ($viewer->getPHID()) {
$packages = id(new PhabricatorOwnersPackageQuery())
->setViewer($viewer)
->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE))
->withAuthorityPHIDs(array($viewer->getPHID()))
->execute();
$this->authorityPackages = $packages;
}
$paths = mpull($changesets, 'getOwnersFilename');
$control_query = id(new PhabricatorOwnersPackageQuery())
->setViewer($viewer)
->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE))
->withControl($repository_phid, $paths);
$control_query->execute();
foreach ($changesets as $changeset) {
$changeset_path = $changeset->getOwnersFilename();
$packages = $control_query->getControllingPackagesForPath(
$repository_phid,
$changeset_path);
$this->pathPackageMap[$changeset_path] = $packages;
foreach ($packages as $package) {
$this->packageChangesetMap[$package->getPHID()][] = $changeset;
}
}
}
protected function getAuthorityPackages() {
if ($this->authorityPackages === null) {
throw new PhutilInvalidStateException('buildPackageMaps');
}
return $this->authorityPackages;
}
protected function getChangesetPackages(DifferentialChangeset $changeset) {
if ($this->pathPackageMap === null) {
throw new PhutilInvalidStateException('buildPackageMaps');
}
$path = $changeset->getOwnersFilename();
return idx($this->pathPackageMap, $path, array());
}
protected function getPackageChangesets($package_phid) {
if ($this->packageChangesetMap === null) {
throw new PhutilInvalidStateException('buildPackageMaps');
}
return idx($this->packageChangesetMap, $package_phid, array());
}
protected function buildTableOfContents(
array $changesets,
array $visible_changesets,
array $coverage) {
$viewer = $this->getViewer();
$toc_view = id(new PHUIDiffTableOfContentsListView())
->setViewer($viewer)
->setBare(true)
->setAuthorityPackages($this->getAuthorityPackages());
foreach ($changesets as $changeset_id => $changeset) {
$is_visible = isset($visible_changesets[$changeset_id]);
$anchor = $changeset->getAnchorName();
$filename = $changeset->getFilename();
$coverage_id = 'differential-mcoverage-'.md5($filename);
$item = id(new PHUIDiffTableOfContentsItemView())
->setChangeset($changeset)
->setIsVisible($is_visible)
->setAnchor($anchor)
->setCoverage(idx($coverage, $filename))
->setCoverageID($coverage_id);
$packages = $this->getChangesetPackages($changeset);
$item->setPackages($packages);
$toc_view->addItem($item);
}
return $toc_view;
}
protected function loadDiffProperties(array $diffs) {
$diffs = mpull($diffs, null, 'getID');
$properties = id(new DifferentialDiffProperty())->loadAllWhere(
'diffID IN (%Ld)',
array_keys($diffs));
$properties = mgroup($properties, 'getDiffID');
foreach ($diffs as $id => $diff) {
$values = idx($properties, $id, array());
$values = mpull($values, 'getData', 'getName');
$diff->attachDiffProperties($values);
}
}
protected function loadHarbormasterData(array $diffs) {
$viewer = $this->getViewer();
$diffs = mpull($diffs, null, 'getPHID');
$buildables = id(new HarbormasterBuildableQuery())
->setViewer($viewer)
->withBuildablePHIDs(array_keys($diffs))
->withManualBuildables(false)
->needBuilds(true)
->needTargets(true)
->execute();
$buildables = mpull($buildables, null, 'getBuildablePHID');
foreach ($diffs as $phid => $diff) {
$diff->attachBuildable(idx($buildables, $phid));
}
$target_map = array();
foreach ($diffs as $phid => $diff) {
$target_map[$phid] = $diff->getBuildTargetPHIDs();
}
$all_target_phids = array_mergev($target_map);
if ($all_target_phids) {
$unit_messages = id(new HarbormasterBuildUnitMessage())->loadAllWhere(
'buildTargetPHID IN (%Ls)',
$all_target_phids);
$unit_messages = mgroup($unit_messages, 'getBuildTargetPHID');
} else {
$unit_messages = array();
}
foreach ($diffs as $phid => $diff) {
$target_phids = idx($target_map, $phid, array());
$messages = array_select_keys($unit_messages, $target_phids);
$messages = array_mergev($messages);
$diff->attachUnitMessages($messages);
}
// For diffs with no messages, look for legacy unit messages stored on the
// diff itself.
foreach ($diffs as $phid => $diff) {
if ($diff->getUnitMessages()) {
continue;
}
if (!$diff->hasDiffProperty('arc:unit')) {
continue;
}
$legacy_messages = $diff->getProperty('arc:unit');
if (!$legacy_messages) {
continue;
}
// Show the top 100 legacy lint messages. Previously, we showed some
// by default and let the user toggle the rest. With modern messages,
// we can send the user to the Harbormaster detail page. Just show
// "a lot" of messages in legacy cases to try to strike a balance
- // between implementation simplicitly and compatibility.
+ // between implementation simplicity and compatibility.
$legacy_messages = array_slice($legacy_messages, 0, 100);
$messages = array();
foreach ($legacy_messages as $message) {
$messages[] = HarbormasterBuildUnitMessage::newFromDictionary(
new HarbormasterBuildTarget(),
$this->getModernUnitMessageDictionary($message));
}
$diff->attachUnitMessages($messages);
}
}
private function getModernUnitMessageDictionary(array $map) {
// Strip out `null` values to satisfy stricter typechecks.
foreach ($map as $key => $value) {
if ($value === null) {
unset($map[$key]);
}
}
// Cast duration to a float since it used to be a string in some
// cases.
if (isset($map['duration'])) {
$map['duration'] = (double)$map['duration'];
}
return $map;
}
protected function getDiffTabLabels(array $diffs) {
// Make sure we're only going to render unique diffs.
$diffs = mpull($diffs, null, 'getID');
$labels = array(pht('Left'), pht('Right'));
$results = array();
foreach ($diffs as $diff) {
if (count($diffs) == 2) {
$label = array_shift($labels);
$label = pht('%s (Diff %d)', $label, $diff->getID());
} else {
$label = pht('Diff %d', $diff->getID());
}
$results[] = array(
$label,
$diff,
);
}
return $results;
}
}
diff --git a/src/applications/differential/customfield/DifferentialHarbormasterField.php b/src/applications/differential/customfield/DifferentialHarbormasterField.php
index c9b573bda2..cc13be3aa7 100644
--- a/src/applications/differential/customfield/DifferentialHarbormasterField.php
+++ b/src/applications/differential/customfield/DifferentialHarbormasterField.php
@@ -1,98 +1,98 @@
<?php
abstract class DifferentialHarbormasterField
extends DifferentialCustomField {
abstract protected function getDiffPropertyKeys();
abstract protected function loadHarbormasterTargetMessages(
array $target_phids);
abstract protected function getLegacyProperty();
abstract protected function newModernMessage(array $message);
abstract protected function renderHarbormasterStatus(
DifferentialDiff $diff,
array $messages);
abstract protected function newHarbormasterMessageView(array $messages);
public function renderDiffPropertyViewValue(DifferentialDiff $diff) {
// TODO: This load is slightly inefficient, but most of this is moving
// to Harbormaster and this simplifies the transition. Eat 1-2 extra
// queries for now.
$keys = $this->getDiffPropertyKeys();
$properties = id(new DifferentialDiffProperty())->loadAllWhere(
'diffID = %d AND name IN (%Ls)',
$diff->getID(),
$keys);
$properties = mpull($properties, 'getData', 'getName');
foreach ($keys as $key) {
$diff->attachProperty($key, idx($properties, $key));
}
$target_phids = $diff->getBuildTargetPHIDs();
if ($target_phids) {
$messages = $this->loadHarbormasterTargetMessages($target_phids);
} else {
$messages = array();
}
if (!$messages) {
// No Harbormaster messages, so look for legacy messages and make them
// look like modern messages.
$legacy_messages = $diff->getProperty($this->getLegacyProperty());
if ($legacy_messages) {
// Show the top 100 legacy lint messages. Previously, we showed some
// by default and let the user toggle the rest. With modern messages,
// we can send the user to the Harbormaster detail page. Just show
// "a lot" of messages in legacy cases to try to strike a balance
- // between implementation simplicitly and compatibility.
+ // between implementation simplicity and compatibility.
$legacy_messages = array_slice($legacy_messages, 0, 100);
foreach ($legacy_messages as $message) {
try {
$modern = $this->newModernMessage($message);
$messages[] = $modern;
} catch (Exception $ex) {
// Ignore any poorly formatted messages.
}
}
}
}
$status = $this->renderHarbormasterStatus($diff, $messages);
if ($messages) {
$path_map = mpull($diff->loadChangesets(), 'getID', 'getFilename');
foreach ($path_map as $path => $id) {
$href = '#C'.$id.'NL';
// TODO: When the diff is not the right-hand-size diff, we should
// ideally adjust this URI to be absolute.
$path_map[$path] = $href;
}
$view = $this->newHarbormasterMessageView($messages);
if ($view) {
$view->setPathURIMap($path_map);
}
} else {
$view = null;
}
if ($view) {
$view = phutil_tag(
'div',
array(
'class' => 'differential-harbormaster-table-view',
),
$view);
}
return array(
$status,
$view,
);
}
}
diff --git a/src/applications/differential/editor/DifferentialDiffEditor.php b/src/applications/differential/editor/DifferentialDiffEditor.php
index 0058485e63..261f6f1598 100644
--- a/src/applications/differential/editor/DifferentialDiffEditor.php
+++ b/src/applications/differential/editor/DifferentialDiffEditor.php
@@ -1,238 +1,238 @@
<?php
final class DifferentialDiffEditor
extends PhabricatorApplicationTransactionEditor {
private $diffDataDict;
private $lookupRepository = true;
public function setLookupRepository($bool) {
$this->lookupRepository = $bool;
return $this;
}
public function getEditorApplicationClass() {
return 'PhabricatorDifferentialApplication';
}
public function getEditorObjectsDescription() {
return pht('Differential Diffs');
}
public function getTransactionTypes() {
$types = parent::getTransactionTypes();
$types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
$types[] = DifferentialDiffTransaction::TYPE_DIFF_CREATE;
return $types;
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case DifferentialDiffTransaction::TYPE_DIFF_CREATE:
return null;
}
return parent::getCustomTransactionOldValue($object, $xaction);
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case DifferentialDiffTransaction::TYPE_DIFF_CREATE:
$this->diffDataDict = $xaction->getNewValue();
return true;
}
return parent::getCustomTransactionNewValue($object, $xaction);
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case DifferentialDiffTransaction::TYPE_DIFF_CREATE:
$dict = $this->diffDataDict;
$this->updateDiffFromDict($object, $dict);
return;
}
return parent::applyCustomInternalTransaction($object, $xaction);
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case DifferentialDiffTransaction::TYPE_DIFF_CREATE:
return;
}
return parent::applyCustomExternalTransaction($object, $xaction);
}
protected function applyFinalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
// If we didn't get an explicit `repositoryPHID` (which means the client
// is old, or couldn't figure out which repository the working copy
// belongs to), apply heuristics to try to figure it out.
if ($this->lookupRepository && !$object->getRepositoryPHID()) {
$repository = id(new DifferentialRepositoryLookup())
->setDiff($object)
->setViewer($this->getActor())
->lookupRepository();
if ($repository) {
$object->setRepositoryPHID($repository->getPHID());
$object->setRepositoryUUID($repository->getUUID());
$object->save();
}
}
return $xactions;
}
/**
* We run Herald as part of transaction validation because Herald can
* block diff creation for Differential diffs. Its important to do this
* separately so no Herald logs are saved; these logs could expose
- * information the Herald rules are inteneded to block.
+ * information the Herald rules are intended to block.
*/
protected function validateTransaction(
PhabricatorLiskDAO $object,
$type,
array $xactions) {
$errors = parent::validateTransaction($object, $type, $xactions);
foreach ($xactions as $xaction) {
switch ($type) {
case DifferentialDiffTransaction::TYPE_DIFF_CREATE:
$diff = clone $object;
$diff = $this->updateDiffFromDict($diff, $xaction->getNewValue());
$adapter = $this->buildHeraldAdapter($diff, $xactions);
$adapter->setContentSource($this->getContentSource());
$adapter->setIsNewObject($this->getIsNewObject());
$engine = new HeraldEngine();
$rules = $engine->loadRulesForAdapter($adapter);
$rules = mpull($rules, null, 'getID');
$effects = $engine->applyRules($rules, $adapter);
$action_block = DifferentialBlockHeraldAction::ACTIONCONST;
$blocking_effect = null;
foreach ($effects as $effect) {
if ($effect->getAction() == $action_block) {
$blocking_effect = $effect;
break;
}
}
if ($blocking_effect) {
$rule = $blocking_effect->getRule();
$message = $effect->getTarget();
if (!strlen($message)) {
$message = pht('(None.)');
}
$errors[] = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Rejected by Herald'),
pht(
"Creation of this diff was rejected by Herald rule %s.\n".
" Rule: %s\n".
"Reason: %s",
$rule->getMonogram(),
$rule->getName(),
$message));
}
break;
}
}
return $errors;
}
protected function shouldPublishFeedStory(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function shouldSendMail(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function supportsSearch() {
return false;
}
/* -( Herald Integration )------------------------------------------------- */
/**
* See @{method:validateTransaction}. The only Herald action is to block
* the creation of Diffs. We thus have to be careful not to save any
* data and do this validation very early.
*/
protected function shouldApplyHeraldRules(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function buildHeraldAdapter(
PhabricatorLiskDAO $object,
array $xactions) {
$adapter = id(new HeraldDifferentialDiffAdapter())
->setDiff($object);
return $adapter;
}
protected function didApplyHeraldRules(
PhabricatorLiskDAO $object,
HeraldAdapter $adapter,
HeraldTranscript $transcript) {
$xactions = array();
return $xactions;
}
private function updateDiffFromDict(DifferentialDiff $diff, $dict) {
$diff
->setSourcePath(idx($dict, 'sourcePath'))
->setSourceMachine(idx($dict, 'sourceMachine'))
->setBranch(idx($dict, 'branch'))
->setCreationMethod(idx($dict, 'creationMethod'))
->setAuthorPHID(idx($dict, 'authorPHID', $this->getActor()))
->setBookmark(idx($dict, 'bookmark'))
->setRepositoryPHID(idx($dict, 'repositoryPHID'))
->setRepositoryUUID(idx($dict, 'repositoryUUID'))
->setSourceControlSystem(idx($dict, 'sourceControlSystem'))
->setSourceControlPath(idx($dict, 'sourceControlPath'))
->setSourceControlBaseRevision(idx($dict, 'sourceControlBaseRevision'))
->setLintStatus(idx($dict, 'lintStatus'))
->setUnitStatus(idx($dict, 'unitStatus'));
return $diff;
}
}
diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php
index 88303cf420..1eeb4c8452 100644
--- a/src/applications/differential/parser/DifferentialChangesetParser.php
+++ b/src/applications/differential/parser/DifferentialChangesetParser.php
@@ -1,1638 +1,1638 @@
<?php
final class DifferentialChangesetParser extends Phobject {
const HIGHLIGHT_BYTE_LIMIT = 262144;
protected $visible = array();
protected $new = array();
protected $old = array();
protected $intra = array();
protected $newRender = null;
protected $oldRender = null;
protected $filename = null;
protected $hunkStartLines = array();
protected $comments = array();
protected $specialAttributes = array();
protected $changeset;
protected $whitespaceMode = null;
protected $renderCacheKey = null;
private $handles = array();
private $user;
private $leftSideChangesetID;
private $leftSideAttachesToNewFile;
private $rightSideChangesetID;
private $rightSideAttachesToNewFile;
private $originalLeft;
private $originalRight;
private $renderingReference;
private $isSubparser;
private $isTopLevel;
private $coverage;
private $markupEngine;
private $highlightErrors;
private $disableCache;
private $renderer;
private $characterEncoding;
private $highlightAs;
private $highlightingDisabled;
private $showEditAndReplyLinks = true;
private $canMarkDone;
private $objectOwnerPHID;
private $offsetMode;
private $rangeStart;
private $rangeEnd;
private $mask;
private $linesOfContext = 8;
private $highlightEngine;
public function setRange($start, $end) {
$this->rangeStart = $start;
$this->rangeEnd = $end;
return $this;
}
public function setMask(array $mask) {
$this->mask = $mask;
return $this;
}
public function renderChangeset() {
return $this->render($this->rangeStart, $this->rangeEnd, $this->mask);
}
public function setShowEditAndReplyLinks($bool) {
$this->showEditAndReplyLinks = $bool;
return $this;
}
public function getShowEditAndReplyLinks() {
return $this->showEditAndReplyLinks;
}
public function setHighlightAs($highlight_as) {
$this->highlightAs = $highlight_as;
return $this;
}
public function getHighlightAs() {
return $this->highlightAs;
}
public function setCharacterEncoding($character_encoding) {
$this->characterEncoding = $character_encoding;
return $this;
}
public function getCharacterEncoding() {
return $this->characterEncoding;
}
public function setRenderer(DifferentialChangesetRenderer $renderer) {
$this->renderer = $renderer;
return $this;
}
public function getRenderer() {
if (!$this->renderer) {
return new DifferentialChangesetTwoUpRenderer();
}
return $this->renderer;
}
public function setDisableCache($disable_cache) {
$this->disableCache = $disable_cache;
return $this;
}
public function getDisableCache() {
return $this->disableCache;
}
public function setCanMarkDone($can_mark_done) {
$this->canMarkDone = $can_mark_done;
return $this;
}
public function getCanMarkDone() {
return $this->canMarkDone;
}
public function setObjectOwnerPHID($phid) {
$this->objectOwnerPHID = $phid;
return $this;
}
public function getObjectOwnerPHID() {
return $this->objectOwnerPHID;
}
public function setOffsetMode($offset_mode) {
$this->offsetMode = $offset_mode;
return $this;
}
public function getOffsetMode() {
return $this->offsetMode;
}
public static function getDefaultRendererForViewer(PhabricatorUser $viewer) {
$is_unified = $viewer->compareUserSetting(
PhabricatorUnifiedDiffsSetting::SETTINGKEY,
PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED);
if ($is_unified) {
return '1up';
}
return null;
}
public function readParametersFromRequest(AphrontRequest $request) {
$this->setWhitespaceMode($request->getStr('whitespace'));
$this->setCharacterEncoding($request->getStr('encoding'));
$this->setHighlightAs($request->getStr('highlight'));
$renderer = null;
// If the viewer prefers unified diffs, always set the renderer to unified.
// Otherwise, we leave it unspecified and the client will choose a
// renderer based on the screen size.
if ($request->getStr('renderer')) {
$renderer = $request->getStr('renderer');
} else {
$renderer = self::getDefaultRendererForViewer($request->getViewer());
}
switch ($renderer) {
case '1up':
$this->setRenderer(new DifferentialChangesetOneUpRenderer());
break;
default:
$this->setRenderer(new DifferentialChangesetTwoUpRenderer());
break;
}
return $this;
}
const CACHE_VERSION = 11;
const CACHE_MAX_SIZE = 8e6;
const ATTR_GENERATED = 'attr:generated';
const ATTR_DELETED = 'attr:deleted';
const ATTR_UNCHANGED = 'attr:unchanged';
const ATTR_WHITELINES = 'attr:white';
const ATTR_MOVEAWAY = 'attr:moveaway';
const WHITESPACE_SHOW_ALL = 'show-all';
const WHITESPACE_IGNORE_TRAILING = 'ignore-trailing';
const WHITESPACE_IGNORE_MOST = 'ignore-most';
const WHITESPACE_IGNORE_ALL = 'ignore-all';
public function setOldLines(array $lines) {
$this->old = $lines;
return $this;
}
public function setNewLines(array $lines) {
$this->new = $lines;
return $this;
}
public function setSpecialAttributes(array $attributes) {
$this->specialAttributes = $attributes;
return $this;
}
public function setIntraLineDiffs(array $diffs) {
$this->intra = $diffs;
return $this;
}
public function setVisibileLinesMask(array $mask) {
$this->visible = $mask;
return $this;
}
public function setLinesOfContext($lines_of_context) {
$this->linesOfContext = $lines_of_context;
return $this;
}
public function getLinesOfContext() {
return $this->linesOfContext;
}
/**
* Configure which Changeset comments added to the right side of the visible
* diff will be attached to. The ID must be the ID of a real Differential
* Changeset.
*
* The complexity here is that we may show an arbitrary side of an arbitrary
* changeset as either the left or right part of a diff. This method allows
* the left and right halves of the displayed diff to be correctly mapped to
* storage changesets.
*
* @param id The Differential Changeset ID that comments added to the right
* side of the visible diff should be attached to.
* @param bool If true, attach new comments to the right side of the storage
* changeset. Note that this may be false, if the left side of
* some storage changeset is being shown as the right side of
* a display diff.
* @return this
*/
public function setRightSideCommentMapping($id, $is_new) {
$this->rightSideChangesetID = $id;
$this->rightSideAttachesToNewFile = $is_new;
return $this;
}
/**
* See setRightSideCommentMapping(), but this sets information for the left
* side of the display diff.
*/
public function setLeftSideCommentMapping($id, $is_new) {
$this->leftSideChangesetID = $id;
$this->leftSideAttachesToNewFile = $is_new;
return $this;
}
public function setOriginals(
DifferentialChangeset $left,
DifferentialChangeset $right) {
$this->originalLeft = $left;
$this->originalRight = $right;
return $this;
}
public function diffOriginals() {
$engine = new PhabricatorDifferenceEngine();
$changeset = $engine->generateChangesetFromFileContent(
implode('', mpull($this->originalLeft->getHunks(), 'getChanges')),
implode('', mpull($this->originalRight->getHunks(), 'getChanges')));
$parser = new DifferentialHunkParser();
return $parser->parseHunksForHighlightMasks(
$changeset->getHunks(),
$this->originalLeft->getHunks(),
$this->originalRight->getHunks());
}
/**
* Set a key for identifying this changeset in the render cache. If set, the
* parser will attempt to use the changeset render cache, which can improve
* performance for frequently-viewed changesets.
*
* By default, there is no render cache key and parsers do not use the cache.
* This is appropriate for rarely-viewed changesets.
*
* NOTE: Currently, this key must be a valid Differential Changeset ID.
*
* @param string Key for identifying this changeset in the render cache.
* @return this
*/
public function setRenderCacheKey($key) {
$this->renderCacheKey = $key;
return $this;
}
private function getRenderCacheKey() {
return $this->renderCacheKey;
}
public function setChangeset(DifferentialChangeset $changeset) {
$this->changeset = $changeset;
$this->setFilename($changeset->getFilename());
return $this;
}
public function setWhitespaceMode($whitespace_mode) {
$this->whitespaceMode = $whitespace_mode;
return $this;
}
public function setRenderingReference($ref) {
$this->renderingReference = $ref;
return $this;
}
private function getRenderingReference() {
return $this->renderingReference;
}
public function getChangeset() {
return $this->changeset;
}
public function setFilename($filename) {
$this->filename = $filename;
return $this;
}
public function setHandles(array $handles) {
assert_instances_of($handles, 'PhabricatorObjectHandle');
$this->handles = $handles;
return $this;
}
public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
$this->markupEngine = $engine;
return $this;
}
public function setUser(PhabricatorUser $user) {
$this->user = $user;
return $this;
}
public function getUser() {
return $this->user;
}
public function setCoverage($coverage) {
$this->coverage = $coverage;
return $this;
}
private function getCoverage() {
return $this->coverage;
}
public function parseInlineComment(
PhabricatorInlineCommentInterface $comment) {
// Parse only comments which are actually visible.
if ($this->isCommentVisibleOnRenderedDiff($comment)) {
$this->comments[] = $comment;
}
return $this;
}
private function loadCache() {
$render_cache_key = $this->getRenderCacheKey();
if (!$render_cache_key) {
return false;
}
$data = null;
$changeset = new DifferentialChangeset();
$conn_r = $changeset->establishConnection('r');
$data = queryfx_one(
$conn_r,
'SELECT * FROM %T WHERE id = %d',
$changeset->getTableName().'_parse_cache',
$render_cache_key);
if (!$data) {
return false;
}
if ($data['cache'][0] == '{') {
// This is likely an old-style JSON cache which we will not be able to
// deserialize.
return false;
}
$data = unserialize($data['cache']);
if (!is_array($data) || !$data) {
return false;
}
foreach (self::getCacheableProperties() as $cache_key) {
if (!array_key_exists($cache_key, $data)) {
// If we're missing a cache key, assume we're looking at an old cache
// and ignore it.
return false;
}
}
if ($data['cacheVersion'] !== self::CACHE_VERSION) {
return false;
}
// Someone displays contents of a partially cached shielded file.
if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) {
return false;
}
unset($data['cacheVersion'], $data['cacheHost']);
$cache_prop = array_select_keys($data, self::getCacheableProperties());
foreach ($cache_prop as $cache_key => $v) {
$this->$cache_key = $v;
}
return true;
}
protected static function getCacheableProperties() {
return array(
'visible',
'new',
'old',
'intra',
'newRender',
'oldRender',
'specialAttributes',
'hunkStartLines',
'cacheVersion',
'cacheHost',
'highlightingDisabled',
);
}
public function saveCache() {
if (PhabricatorEnv::isReadOnly()) {
return false;
}
if ($this->highlightErrors) {
return false;
}
$render_cache_key = $this->getRenderCacheKey();
if (!$render_cache_key) {
return false;
}
$cache = array();
foreach (self::getCacheableProperties() as $cache_key) {
switch ($cache_key) {
case 'cacheVersion':
$cache[$cache_key] = self::CACHE_VERSION;
break;
case 'cacheHost':
$cache[$cache_key] = php_uname('n');
break;
default:
$cache[$cache_key] = $this->$cache_key;
break;
}
}
$cache = serialize($cache);
// We don't want to waste too much space by a single changeset.
if (strlen($cache) > self::CACHE_MAX_SIZE) {
return;
}
$changeset = new DifferentialChangeset();
$conn_w = $changeset->establishConnection('w');
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
try {
queryfx(
$conn_w,
'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %B, %d)
ON DUPLICATE KEY UPDATE cache = VALUES(cache)',
DifferentialChangeset::TABLE_CACHE,
$render_cache_key,
$cache,
time());
} catch (AphrontQueryException $ex) {
// Ignore these exceptions. A common cause is that the cache is
// larger than 'max_allowed_packet', in which case we're better off
// not writing it.
// TODO: It would be nice to tailor this more narrowly.
}
unset($unguarded);
}
private function markGenerated($new_corpus_block = '') {
$generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false);
if (!$generated_guess) {
$generated_path_regexps = PhabricatorEnv::getEnvConfig(
'differential.generated-paths');
foreach ($generated_path_regexps as $regexp) {
if (preg_match($regexp, $this->changeset->getFilename())) {
$generated_guess = true;
break;
}
}
}
$event = new PhabricatorEvent(
PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED,
array(
'corpus' => $new_corpus_block,
'is_generated' => $generated_guess,
)
);
PhutilEventEngine::dispatchEvent($event);
$generated = $event->getValue('is_generated');
$this->specialAttributes[self::ATTR_GENERATED] = $generated;
}
public function isGenerated() {
return idx($this->specialAttributes, self::ATTR_GENERATED, false);
}
public function isDeleted() {
return idx($this->specialAttributes, self::ATTR_DELETED, false);
}
public function isUnchanged() {
return idx($this->specialAttributes, self::ATTR_UNCHANGED, false);
}
public function isWhitespaceOnly() {
return idx($this->specialAttributes, self::ATTR_WHITELINES, false);
}
public function isMoveAway() {
return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false);
}
private function applyIntraline(&$render, $intra, $corpus) {
foreach ($render as $key => $text) {
if (isset($intra[$key])) {
$render[$key] = ArcanistDiffUtils::applyIntralineDiff(
$text,
$intra[$key]);
}
}
}
private function getHighlightFuture($corpus) {
$language = $this->highlightAs;
if (!$language) {
$language = $this->highlightEngine->getLanguageFromFilename(
$this->filename);
if (($language != 'txt') &&
(strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) {
$this->highlightingDisabled = true;
$language = 'txt';
}
}
return $this->highlightEngine->getHighlightFuture(
$language,
$corpus);
}
protected function processHighlightedSource($data, $result) {
$result_lines = phutil_split_lines($result);
foreach ($data as $key => $info) {
if (!$info) {
unset($result_lines[$key]);
}
}
return $result_lines;
}
private function tryCacheStuff() {
$whitespace_mode = $this->whitespaceMode;
switch ($whitespace_mode) {
case self::WHITESPACE_SHOW_ALL:
case self::WHITESPACE_IGNORE_TRAILING:
case self::WHITESPACE_IGNORE_ALL:
break;
default:
$whitespace_mode = self::WHITESPACE_IGNORE_MOST;
break;
}
$skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_MOST);
if ($this->disableCache) {
$skip_cache = true;
}
if ($this->characterEncoding) {
$skip_cache = true;
}
if ($this->highlightAs) {
$skip_cache = true;
}
$this->whitespaceMode = $whitespace_mode;
$changeset = $this->changeset;
if ($changeset->getFileType() != DifferentialChangeType::FILE_TEXT &&
$changeset->getFileType() != DifferentialChangeType::FILE_SYMLINK) {
$this->markGenerated();
} else {
if ($skip_cache || !$this->loadCache()) {
$this->process();
if (!$skip_cache) {
$this->saveCache();
}
}
}
}
private function process() {
$whitespace_mode = $this->whitespaceMode;
$changeset = $this->changeset;
$ignore_all = (($whitespace_mode == self::WHITESPACE_IGNORE_MOST) ||
($whitespace_mode == self::WHITESPACE_IGNORE_ALL));
$force_ignore = ($whitespace_mode == self::WHITESPACE_IGNORE_ALL);
if (!$force_ignore) {
if ($ignore_all && $changeset->getWhitespaceMatters()) {
$ignore_all = false;
}
}
// The "ignore all whitespace" algorithm depends on rediffing the
// files, and we currently need complete representations of both
// files to do anything reasonable. If we only have parts of the files,
// don't use the "ignore all" algorithm.
if ($ignore_all) {
$hunks = $changeset->getHunks();
if (count($hunks) !== 1) {
$ignore_all = false;
} else {
$first_hunk = reset($hunks);
if ($first_hunk->getOldOffset() != 1 ||
$first_hunk->getNewOffset() != 1) {
$ignore_all = false;
}
}
}
if ($ignore_all) {
$old_file = $changeset->makeOldFile();
$new_file = $changeset->makeNewFile();
if ($old_file == $new_file) {
// If the old and new files are exactly identical, the synthetic
// diff below will give us nonsense and whitespace modes are
// irrelevant anyway. This occurs when you, e.g., copy a file onto
// itself in Subversion (see T271).
$ignore_all = false;
}
}
$hunk_parser = new DifferentialHunkParser();
$hunk_parser->setWhitespaceMode($whitespace_mode);
$hunk_parser->parseHunksForLineData($changeset->getHunks());
// Depending on the whitespace mode, we may need to compute a different
- // set of changes than the set of changes in the hunk data (specificaly,
+ // set of changes than the set of changes in the hunk data (specifically,
// we might want to consider changed lines which have only whitespace
// changes as unchanged).
if ($ignore_all) {
$engine = new PhabricatorDifferenceEngine();
$engine->setIgnoreWhitespace(true);
$no_whitespace_changeset = $engine->generateChangesetFromFileContent(
$old_file,
$new_file);
$type_parser = new DifferentialHunkParser();
$type_parser->parseHunksForLineData($no_whitespace_changeset->getHunks());
$hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap());
$hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap());
}
$hunk_parser->reparseHunksForSpecialAttributes();
$unchanged = false;
if (!$hunk_parser->getHasAnyChanges()) {
$filetype = $this->changeset->getFileType();
if ($filetype == DifferentialChangeType::FILE_TEXT ||
$filetype == DifferentialChangeType::FILE_SYMLINK) {
$unchanged = true;
}
}
$moveaway = false;
$changetype = $this->changeset->getChangeType();
if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) {
$moveaway = true;
}
$this->setSpecialAttributes(array(
self::ATTR_UNCHANGED => $unchanged,
self::ATTR_DELETED => $hunk_parser->getIsDeleted(),
self::ATTR_WHITELINES => !$hunk_parser->getHasTextChanges(),
self::ATTR_MOVEAWAY => $moveaway,
));
$lines_context = $this->getLinesOfContext();
$hunk_parser->generateIntraLineDiffs();
$hunk_parser->generateVisibileLinesMask($lines_context);
$this->setOldLines($hunk_parser->getOldLines());
$this->setNewLines($hunk_parser->getNewLines());
$this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs());
$this->setVisibileLinesMask($hunk_parser->getVisibleLinesMask());
$this->hunkStartLines = $hunk_parser->getHunkStartLines(
$changeset->getHunks());
$new_corpus = $hunk_parser->getNewCorpus();
$new_corpus_block = implode('', $new_corpus);
$this->markGenerated($new_corpus_block);
if ($this->isTopLevel &&
!$this->comments &&
($this->isGenerated() ||
$this->isUnchanged() ||
$this->isDeleted())) {
return;
}
$old_corpus = $hunk_parser->getOldCorpus();
$old_corpus_block = implode('', $old_corpus);
$old_future = $this->getHighlightFuture($old_corpus_block);
$new_future = $this->getHighlightFuture($new_corpus_block);
$futures = array(
'old' => $old_future,
'new' => $new_future,
);
$corpus_blocks = array(
'old' => $old_corpus_block,
'new' => $new_corpus_block,
);
$this->highlightErrors = false;
foreach (new FutureIterator($futures) as $key => $future) {
try {
try {
$highlighted = $future->resolve();
} catch (PhutilSyntaxHighlighterException $ex) {
$this->highlightErrors = true;
$highlighted = id(new PhutilDefaultSyntaxHighlighter())
->getHighlightFuture($corpus_blocks[$key])
->resolve();
}
switch ($key) {
case 'old':
$this->oldRender = $this->processHighlightedSource(
$this->old,
$highlighted);
break;
case 'new':
$this->newRender = $this->processHighlightedSource(
$this->new,
$highlighted);
break;
}
} catch (Exception $ex) {
phlog($ex);
throw $ex;
}
}
$this->applyIntraline(
$this->oldRender,
ipull($this->intra, 0),
$old_corpus);
$this->applyIntraline(
$this->newRender,
ipull($this->intra, 1),
$new_corpus);
}
private function shouldRenderPropertyChangeHeader($changeset) {
if (!$this->isTopLevel) {
// We render properties only at top level; otherwise we get multiple
// copies of them when a user clicks "Show More".
return false;
}
return true;
}
public function render(
$range_start = null,
$range_len = null,
$mask_force = array()) {
// "Top level" renders are initial requests for the whole file, versus
// requests for a specific range generated by clicking "show more". We
// generate property changes and "shield" UI elements only for toplevel
// requests.
$this->isTopLevel = (($range_start === null) && ($range_len === null));
$this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine();
$encoding = null;
if ($this->characterEncoding) {
// We are forcing this changeset to be interpreted with a specific
// character encoding, so force all the hunks into that encoding and
// propagate it to the renderer.
$encoding = $this->characterEncoding;
foreach ($this->changeset->getHunks() as $hunk) {
$hunk->forceEncoding($this->characterEncoding);
}
} else {
// We're just using the default, so tell the renderer what that is
// (by reading the encoding from the first hunk).
foreach ($this->changeset->getHunks() as $hunk) {
$encoding = $hunk->getDataEncoding();
break;
}
}
$this->tryCacheStuff();
// If we're rendering in an offset mode, treat the range numbers as line
// numbers instead of rendering offsets.
$offset_mode = $this->getOffsetMode();
if ($offset_mode) {
if ($offset_mode == 'new') {
$offset_map = $this->new;
} else {
$offset_map = $this->old;
}
$range_end = $this->getOffset($offset_map, $range_start + $range_len);
$range_start = $this->getOffset($offset_map, $range_start);
$range_len = ($range_end - $range_start);
}
$render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset);
$rows = max(
count($this->old),
count($this->new));
$renderer = $this->getRenderer()
->setUser($this->getUser())
->setChangeset($this->changeset)
->setRenderPropertyChangeHeader($render_pch)
->setIsTopLevel($this->isTopLevel)
->setOldRender($this->oldRender)
->setNewRender($this->newRender)
->setHunkStartLines($this->hunkStartLines)
->setOldChangesetID($this->leftSideChangesetID)
->setNewChangesetID($this->rightSideChangesetID)
->setOldAttachesToNewFile($this->leftSideAttachesToNewFile)
->setNewAttachesToNewFile($this->rightSideAttachesToNewFile)
->setCodeCoverage($this->getCoverage())
->setRenderingReference($this->getRenderingReference())
->setMarkupEngine($this->markupEngine)
->setHandles($this->handles)
->setOldLines($this->old)
->setNewLines($this->new)
->setOriginalCharacterEncoding($encoding)
->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks())
->setCanMarkDone($this->getCanMarkDone())
->setObjectOwnerPHID($this->getObjectOwnerPHID())
->setHighlightingDisabled($this->highlightingDisabled);
$shield = null;
if ($this->isTopLevel && !$this->comments) {
if ($this->isGenerated()) {
$shield = $renderer->renderShield(
pht(
'This file contains generated code, which does not normally '.
'need to be reviewed.'));
} else if ($this->isMoveAway()) {
// We put an empty shield on these files. Normally, they do not have
// any diff content anyway. However, if they come through `arc`, they
// may have content. We don't want to show it (it's not useful) and
// we bailed out of fully processing it earlier anyway.
// We could show a message like "this file was moved", but we show
// that as a change header anyway, so it would be redundant. Instead,
// just render an empty shield to skip rendering the diff body.
$shield = '';
} else if ($this->isUnchanged()) {
$type = 'text';
if (!$rows) {
// NOTE: Normally, diffs which don't change files do not include
// file content (for example, if you "chmod +x" a file and then
// run "git show", the file content is not available). Similarly,
// if you move a file from A to B without changing it, diffs normally
// do not show the file content. In some cases `arc` is able to
// synthetically generate content for these diffs, but for raw diffs
// we'll never have it so we need to be prepared to not render a link.
$type = 'none';
}
$type_add = DifferentialChangeType::TYPE_ADD;
if ($this->changeset->getChangeType() == $type_add) {
// Although the generic message is sort of accurate in a technical
// sense, this more-tailored message is less confusing.
$shield = $renderer->renderShield(
pht('This is an empty file.'),
$type);
} else {
$shield = $renderer->renderShield(
pht('The contents of this file were not changed.'),
$type);
}
} else if ($this->isWhitespaceOnly()) {
$shield = $renderer->renderShield(
pht('This file was changed only by adding or removing whitespace.'),
'whitespace');
} else if ($this->isDeleted()) {
$shield = $renderer->renderShield(
pht('This file was completely deleted.'));
} else if ($this->changeset->getAffectedLineCount() > 2500) {
$shield = $renderer->renderShield(
pht(
'This file has a very large number of changes (%s lines).',
new PhutilNumber($this->changeset->getAffectedLineCount())));
}
}
if ($shield !== null) {
return $renderer->renderChangesetTable($shield);
}
// This request should render the "undershield" headers if it's a top-level
// request which made it this far (indicating the changeset has no shield)
// or it's a request with no mask information (indicating it's the request
// that removes the rendering shield). Possibly, this second class of
// request might need to be made more explicit.
$is_undershield = (empty($mask_force) || $this->isTopLevel);
$renderer->setIsUndershield($is_undershield);
$old_comments = array();
$new_comments = array();
$old_mask = array();
$new_mask = array();
$feedback_mask = array();
$lines_context = $this->getLinesOfContext();
if ($this->comments) {
// If there are any comments which appear in sections of the file which
// we don't have, we're going to move them backwards to the closest
// earlier line. Two cases where this may happen are:
//
// - Porting ghost comments forward into a file which was mostly
// deleted.
// - Porting ghost comments forward from a full-context diff to a
// partial-context diff.
list($old_backmap, $new_backmap) = $this->buildLineBackmaps();
foreach ($this->comments as $comment) {
$new_side = $this->isCommentOnRightSideWhenDisplayed($comment);
$line = $comment->getLineNumber();
if ($new_side) {
$back_line = $new_backmap[$line];
} else {
$back_line = $old_backmap[$line];
}
if ($back_line != $line) {
// TODO: This should probably be cleaner, but just be simple and
// obvious for now.
$ghost = $comment->getIsGhost();
if ($ghost) {
$moved = pht(
'This comment originally appeared on line %s, but that line '.
'does not exist in this version of the diff. It has been '.
'moved backward to the nearest line.',
new PhutilNumber($line));
$ghost['reason'] = $ghost['reason']."\n\n".$moved;
$comment->setIsGhost($ghost);
}
$comment->setLineNumber($back_line);
$comment->setLineLength(0);
}
$start = max($comment->getLineNumber() - $lines_context, 0);
$end = $comment->getLineNumber() +
$comment->getLineLength() +
$lines_context;
for ($ii = $start; $ii <= $end; $ii++) {
if ($new_side) {
$new_mask[$ii] = true;
} else {
$old_mask[$ii] = true;
}
}
}
foreach ($this->old as $ii => $old) {
if (isset($old['line']) && isset($old_mask[$old['line']])) {
$feedback_mask[$ii] = true;
}
}
foreach ($this->new as $ii => $new) {
if (isset($new['line']) && isset($new_mask[$new['line']])) {
$feedback_mask[$ii] = true;
}
}
$this->comments = id(new PHUIDiffInlineThreader())
->reorderAndThreadCommments($this->comments);
foreach ($this->comments as $comment) {
$final = $comment->getLineNumber() +
$comment->getLineLength();
$final = max(1, $final);
if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
$new_comments[$final][] = $comment;
} else {
$old_comments[$final][] = $comment;
}
}
}
$renderer
->setOldComments($old_comments)
->setNewComments($new_comments);
switch ($this->changeset->getFileType()) {
case DifferentialChangeType::FILE_IMAGE:
$old = null;
$new = null;
// TODO: Improve the architectural issue as discussed in D955
// https://secure.phabricator.com/D955
$reference = $this->getRenderingReference();
$parts = explode('/', $reference);
if (count($parts) == 2) {
list($id, $vs) = $parts;
} else {
$id = $parts[0];
$vs = 0;
}
$id = (int)$id;
$vs = (int)$vs;
if (!$vs) {
$metadata = $this->changeset->getMetadata();
$data = idx($metadata, 'attachment-data');
$old_phid = idx($metadata, 'old:binary-phid');
$new_phid = idx($metadata, 'new:binary-phid');
} else {
$vs_changeset = id(new DifferentialChangeset())->load($vs);
$old_phid = null;
$new_phid = null;
// TODO: This is spooky, see D6851
if ($vs_changeset) {
$vs_metadata = $vs_changeset->getMetadata();
$old_phid = idx($vs_metadata, 'new:binary-phid');
}
$changeset = id(new DifferentialChangeset())->load($id);
if ($changeset) {
$metadata = $changeset->getMetadata();
$new_phid = idx($metadata, 'new:binary-phid');
}
}
if ($old_phid || $new_phid) {
// grab the files, (micro) optimization for 1 query not 2
$file_phids = array();
if ($old_phid) {
$file_phids[] = $old_phid;
}
if ($new_phid) {
$file_phids[] = $new_phid;
}
$files = id(new PhabricatorFileQuery())
->setViewer($this->getUser())
->withPHIDs($file_phids)
->execute();
foreach ($files as $file) {
if (empty($file)) {
continue;
}
if ($file->getPHID() == $old_phid) {
$old = $file;
} else if ($file->getPHID() == $new_phid) {
$new = $file;
}
}
}
$renderer->attachOldFile($old);
$renderer->attachNewFile($new);
return $renderer->renderFileChange($old, $new, $id, $vs);
case DifferentialChangeType::FILE_DIRECTORY:
case DifferentialChangeType::FILE_BINARY:
$output = $renderer->renderChangesetTable(null);
return $output;
}
if ($this->originalLeft && $this->originalRight) {
list($highlight_old, $highlight_new) = $this->diffOriginals();
$highlight_old = array_flip($highlight_old);
$highlight_new = array_flip($highlight_new);
$renderer
->setHighlightOld($highlight_old)
->setHighlightNew($highlight_new);
}
$renderer
->setOriginalOld($this->originalLeft)
->setOriginalNew($this->originalRight);
if ($range_start === null) {
$range_start = 0;
}
if ($range_len === null) {
$range_len = $rows;
}
$range_len = min($range_len, $rows - $range_start);
list($gaps, $mask, $depths) = $this->calculateGapsMaskAndDepths(
$mask_force,
$feedback_mask,
$range_start,
$range_len);
$renderer
->setGaps($gaps)
->setMask($mask)
->setDepths($depths);
$html = $renderer->renderTextChange(
$range_start,
$range_len,
$rows);
return $renderer->renderChangesetTable($html);
}
/**
* This function calculates a lot of stuff we need to know to display
* the diff:
*
* Gaps - compute gaps in the visible display diff, where we will render
* "Show more context" spacers. If a gap is smaller than the context size,
* we just display it. Otherwise, we record it into $gaps and will render a
* "show more context" element instead of diff text below. A given $gap
* is a tuple of $gap_line_number_start and $gap_length.
*
* Mask - compute the actual lines that need to be shown (because they
* are near changes lines, near inline comments, or the request has
* explicitly asked for them, i.e. resulting from the user clicking
- * "show more"). The $mask returned is a sparesely populated dictionary
+ * "show more"). The $mask returned is a sparsely populated dictionary
* of $visible_line_number => true.
*
* Depths - compute how indented any given line is. The $depths returned
- * is a sparesely populated dictionary of $visible_line_number => $depth.
+ * is a sparsely populated dictionary of $visible_line_number => $depth.
*
* This function also has the side effect of modifying member variable
* new such that tabs are normalized to spaces for each line of the diff.
*
* @return array($gaps, $mask, $depths)
*/
private function calculateGapsMaskAndDepths(
$mask_force,
$feedback_mask,
$range_start,
$range_len) {
$lines_context = $this->getLinesOfContext();
// Calculate gaps and mask first
$gaps = array();
$gap_start = 0;
$in_gap = false;
$base_mask = $this->visible + $mask_force + $feedback_mask;
$base_mask[$range_start + $range_len] = true;
for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) {
if (isset($base_mask[$ii])) {
if ($in_gap) {
$gap_length = $ii - $gap_start;
if ($gap_length <= $lines_context) {
for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) {
$base_mask[$jj] = true;
}
} else {
$gaps[] = array($gap_start, $gap_length);
}
$in_gap = false;
}
} else {
if (!$in_gap) {
$gap_start = $ii;
$in_gap = true;
}
}
}
$gaps = array_reverse($gaps);
$mask = $base_mask;
// Time to calculate depth.
// We need to go backwards to properly indent whitespace in this code:
//
// 0: class C {
// 1:
// 1: function f() {
// 2:
// 2: return;
// 1:
// 1: }
// 0:
// 0: }
//
$depths = array();
$last_depth = 0;
$range_end = $range_start + $range_len;
if (!isset($this->new[$range_end])) {
$range_end--;
}
for ($ii = $range_end; $ii >= $range_start; $ii--) {
// We need to expand tabs to process mixed indenting and to round
// correctly later.
$line = str_replace("\t", ' ', $this->new[$ii]['text']);
$trimmed = ltrim($line);
if ($trimmed != '') {
// We round down to flatten "/**" and " *".
$last_depth = floor((strlen($line) - strlen($trimmed)) / 2);
}
$depths[$ii] = $last_depth;
}
return array($gaps, $mask, $depths);
}
/**
* Determine if an inline comment will appear on the rendered diff,
* taking into consideration which halves of which changesets will actually
* be shown.
*
* @param PhabricatorInlineCommentInterface Comment to test for visibility.
* @return bool True if the comment is visible on the rendered diff.
*/
private function isCommentVisibleOnRenderedDiff(
PhabricatorInlineCommentInterface $comment) {
$changeset_id = $comment->getChangesetID();
$is_new = $comment->getIsNewFile();
if ($changeset_id == $this->rightSideChangesetID &&
$is_new == $this->rightSideAttachesToNewFile) {
return true;
}
if ($changeset_id == $this->leftSideChangesetID &&
$is_new == $this->leftSideAttachesToNewFile) {
return true;
}
return false;
}
/**
* Determine if a comment will appear on the right side of the display diff.
* Note that the comment must appear somewhere on the rendered changeset, as
* per isCommentVisibleOnRenderedDiff().
*
* @param PhabricatorInlineCommentInterface Comment to test for display
* location.
* @return bool True for right, false for left.
*/
private function isCommentOnRightSideWhenDisplayed(
PhabricatorInlineCommentInterface $comment) {
if (!$this->isCommentVisibleOnRenderedDiff($comment)) {
throw new Exception(pht('Comment is not visible on changeset!'));
}
$changeset_id = $comment->getChangesetID();
$is_new = $comment->getIsNewFile();
if ($changeset_id == $this->rightSideChangesetID &&
$is_new == $this->rightSideAttachesToNewFile) {
return true;
}
return false;
}
/**
* Parse the 'range' specification that this class and the client-side JS
* emit to indicate that a user clicked "Show more..." on a diff. Generally,
* use is something like this:
*
* $spec = $request->getStr('range');
* $parsed = DifferentialChangesetParser::parseRangeSpecification($spec);
* list($start, $end, $mask) = $parsed;
* $parser->render($start, $end, $mask);
*
* @param string Range specification, indicating the range of the diff that
* should be rendered.
* @return tuple List of <start, end, mask> suitable for passing to
* @{method:render}.
*/
public static function parseRangeSpecification($spec) {
$range_s = null;
$range_e = null;
$mask = array();
if ($spec) {
$match = null;
if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) {
$range_s = (int)$match[1];
$range_e = (int)$match[2];
if (count($match) > 3) {
$start = (int)$match[3];
$len = (int)$match[4];
for ($ii = $start; $ii < $start + $len; $ii++) {
$mask[$ii] = true;
}
}
}
}
return array($range_s, $range_e, $mask);
}
/**
* Render "modified coverage" information; test coverage on modified lines.
* This synthesizes diff information with unit test information into a useful
* indicator of how well tested a change is.
*/
public function renderModifiedCoverage() {
$na = phutil_tag('em', array(), '-');
$coverage = $this->getCoverage();
if (!$coverage) {
return $na;
}
$covered = 0;
$not_covered = 0;
foreach ($this->new as $k => $new) {
if (!$new['line']) {
continue;
}
if (!$new['type']) {
continue;
}
if (empty($coverage[$new['line'] - 1])) {
continue;
}
switch ($coverage[$new['line'] - 1]) {
case 'C':
$covered++;
break;
case 'U':
$not_covered++;
break;
}
}
if (!$covered && !$not_covered) {
return $na;
}
return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered)));
}
public function detectCopiedCode(
array $changesets,
$min_width = 30,
$min_lines = 3) {
assert_instances_of($changesets, 'DifferentialChangeset');
$map = array();
$files = array();
$types = array();
foreach ($changesets as $changeset) {
$file = $changeset->getFilename();
foreach ($changeset->getHunks() as $hunk) {
$lines = $hunk->getStructuredOldFile();
foreach ($lines as $line => $info) {
$type = $info['type'];
if ($type == '\\') {
continue;
}
$types[$file][$line] = $type;
$text = $info['text'];
$text = trim($text);
$files[$file][$line] = $text;
if (strlen($text) >= $min_width) {
$map[$text][] = array($file, $line);
}
}
}
}
foreach ($changesets as $changeset) {
$copies = array();
foreach ($changeset->getHunks() as $hunk) {
$added = $hunk->getStructuredNewFile();
$atype = array();
foreach ($added as $line => $info) {
$atype[$line] = $info['type'];
$added[$line] = trim($info['text']);
}
$skip_lines = 0;
foreach ($added as $line => $code) {
if ($skip_lines) {
// We're skipping lines that we already processed because we
// extended a block above them downward to include them.
$skip_lines--;
continue;
}
if ($atype[$line] !== '+') {
// This line hasn't been changed in the new file, so don't try
// to figure out where it came from.
continue;
}
if (empty($map[$code])) {
// This line was too short to trigger copy/move detection.
continue;
}
if (count($map[$code]) > 16) {
// If there are a large number of identical lines in this diff,
// don't try to figure out where this block came from: the analysis
// is O(N^2), since we need to compare every line against every
// other line. Even if we arrive at a result, it is unlikely to be
// meaningful. See T5041.
continue;
}
$best_length = 0;
// Explore all candidates.
foreach ($map[$code] as $val) {
list($file, $orig_line) = $val;
$length = 1;
// Search backward and forward to find all of the adjacent lines
// which match.
foreach (array(-1, 1) as $direction) {
$offset = $direction;
while (true) {
if (isset($copies[$line + $offset])) {
// If we run into a block above us which we've already
// attributed to a move or copy from elsewhere, stop
// looking.
break;
}
if (!isset($added[$line + $offset])) {
// If we've run off the beginning or end of the new file,
// stop looking.
break;
}
if (!isset($files[$file][$orig_line + $offset])) {
// If we've run off the beginning or end of the original
// file, we also stop looking.
break;
}
$old = $files[$file][$orig_line + $offset];
$new = $added[$line + $offset];
if ($old !== $new) {
// If the old line doesn't match the new line, stop
// looking.
break;
}
$length++;
$offset += $direction;
}
}
if ($length < $best_length) {
// If we already know of a better source (more matching lines)
// for this move/copy, stick with that one. We prefer long
// copies/moves which match a lot of context over short ones.
continue;
}
if ($length == $best_length) {
if (idx($types[$file], $orig_line) != '-') {
// If we already know of an equally good source (same number
// of matching lines) and this isn't a move, stick with the
// other one. We prefer moves over copies.
continue;
}
}
$best_length = $length;
// ($offset - 1) contains number of forward matching lines.
$best_offset = $offset - 1;
$best_file = $file;
$best_line = $orig_line;
}
$file = ($best_file == $changeset->getFilename() ? '' : $best_file);
for ($i = $best_length; $i--; ) {
$type = idx($types[$best_file], $best_line + $best_offset - $i);
$copies[$line + $best_offset - $i] = ($best_length < $min_lines
? array() // Ignore short blocks.
: array($file, $best_line + $best_offset - $i, $type));
}
$skip_lines = $best_offset;
}
}
$copies = array_filter($copies);
if ($copies) {
$metadata = $changeset->getMetadata();
$metadata['copy:lines'] = $copies;
$changeset->setMetadata($metadata);
}
}
return $changesets;
}
/**
* Build maps from lines comments appear on to actual lines.
*/
private function buildLineBackmaps() {
$old_back = array();
$new_back = array();
foreach ($this->old as $ii => $old) {
$old_back[$old['line']] = $old['line'];
}
foreach ($this->new as $ii => $new) {
$new_back[$new['line']] = $new['line'];
}
$max_old_line = 0;
$max_new_line = 0;
foreach ($this->comments as $comment) {
if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
$max_new_line = max($max_new_line, $comment->getLineNumber());
} else {
$max_old_line = max($max_old_line, $comment->getLineNumber());
}
}
$cursor = 1;
for ($ii = 1; $ii <= $max_old_line; $ii++) {
if (empty($old_back[$ii])) {
$old_back[$ii] = $cursor;
} else {
$cursor = $old_back[$ii];
}
}
$cursor = 1;
for ($ii = 1; $ii <= $max_new_line; $ii++) {
if (empty($new_back[$ii])) {
$new_back[$ii] = $cursor;
} else {
$cursor = $new_back[$ii];
}
}
return array($old_back, $new_back);
}
private function getOffset(array $map, $line) {
if (!$map) {
return null;
}
$line = (int)$line;
foreach ($map as $key => $spec) {
if ($spec && isset($spec['line'])) {
if ((int)$spec['line'] >= $line) {
return $key;
}
}
}
return $key;
}
}
diff --git a/src/applications/differential/storage/DifferentialModernHunk.php b/src/applications/differential/storage/DifferentialModernHunk.php
index cde6f29329..c3675c8adf 100644
--- a/src/applications/differential/storage/DifferentialModernHunk.php
+++ b/src/applications/differential/storage/DifferentialModernHunk.php
@@ -1,249 +1,249 @@
<?php
final class DifferentialModernHunk extends DifferentialHunk {
const DATATYPE_TEXT = 'text';
const DATATYPE_FILE = 'file';
const DATAFORMAT_RAW = 'byte';
const DATAFORMAT_DEFLATED = 'gzde';
protected $dataType;
protected $dataEncoding;
protected $dataFormat;
protected $data;
private $rawData;
private $forcedEncoding;
private $fileData;
public function getTableName() {
return 'differential_hunk_modern';
}
protected function getConfiguration() {
return array(
self::CONFIG_BINARY => array(
'data' => true,
),
self::CONFIG_COLUMN_SCHEMA => array(
'dataType' => 'bytes4',
'dataEncoding' => 'text16?',
'dataFormat' => 'bytes4',
'oldOffset' => 'uint32',
'oldLen' => 'uint32',
'newOffset' => 'uint32',
'newLen' => 'uint32',
),
self::CONFIG_KEY_SCHEMA => array(
'key_changeset' => array(
'columns' => array('changesetID'),
),
'key_created' => array(
'columns' => array('dateCreated'),
),
),
) + parent::getConfiguration();
}
public function setChanges($text) {
$this->rawData = $text;
$this->dataEncoding = $this->detectEncodingForStorage($text);
$this->dataType = self::DATATYPE_TEXT;
list($format, $data) = $this->formatDataForStorage($text);
$this->dataFormat = $format;
$this->data = $data;
return $this;
}
public function getChanges() {
return $this->getUTF8StringFromStorage(
$this->getRawData(),
nonempty($this->forcedEncoding, $this->getDataEncoding()));
}
public function forceEncoding($encoding) {
$this->forcedEncoding = $encoding;
return $this;
}
private function formatDataForStorage($data) {
$deflated = PhabricatorCaches::maybeDeflateData($data);
if ($deflated !== null) {
return array(self::DATAFORMAT_DEFLATED, $deflated);
}
return array(self::DATAFORMAT_RAW, $data);
}
public function saveAsText() {
$old_type = $this->getDataType();
$old_data = $this->getData();
if ($old_type == self::DATATYPE_TEXT) {
return $this;
}
$raw_data = $this->getRawData();
$this->setDataType(self::DATATYPE_TEXT);
list($format, $data) = $this->formatDataForStorage($raw_data);
$this->setDataFormat($format);
$this->setData($data);
$result = $this->save();
$this->destroyData($old_type, $old_data);
return $result;
}
public function saveAsFile() {
$old_type = $this->getDataType();
$old_data = $this->getData();
if ($old_type == self::DATATYPE_FILE) {
return $this;
}
$raw_data = $this->getRawData();
list($format, $data) = $this->formatDataForStorage($raw_data);
$this->setDataFormat($format);
$file = PhabricatorFile::newFromFileData(
$data,
array(
'name' => 'differential-hunk',
'mime-type' => 'application/octet-stream',
'viewPolicy' => PhabricatorPolicies::POLICY_NOONE,
));
$this->setDataType(self::DATATYPE_FILE);
$this->setData($file->getPHID());
// NOTE: Because hunks don't have a PHID and we just load hunk data with
- // the ominipotent viewer, we do not need to attach the file to anything.
+ // the omnipotent viewer, we do not need to attach the file to anything.
$result = $this->save();
$this->destroyData($old_type, $old_data);
return $result;
}
private function getRawData() {
if ($this->rawData === null) {
$type = $this->getDataType();
$data = $this->getData();
switch ($type) {
case self::DATATYPE_TEXT:
// In this storage type, the changes are stored on the object.
$data = $data;
break;
case self::DATATYPE_FILE:
$data = $this->loadFileData();
break;
default:
throw new Exception(
pht('Hunk has unsupported data type "%s"!', $type));
}
$format = $this->getDataFormat();
switch ($format) {
case self::DATAFORMAT_RAW:
// In this format, the changes are stored as-is.
$data = $data;
break;
case self::DATAFORMAT_DEFLATED:
$data = PhabricatorCaches::inflateData($data);
break;
default:
throw new Exception(
pht('Hunk has unsupported data encoding "%s"!', $type));
}
$this->rawData = $data;
}
return $this->rawData;
}
private function loadFileData() {
if ($this->fileData === null) {
$type = $this->getDataType();
if ($type !== self::DATATYPE_FILE) {
throw new Exception(
pht(
'Unable to load file data for hunk with wrong data type ("%s").',
$type));
}
$file_phid = $this->getData();
$file = $this->loadRawFile($file_phid);
$data = $file->loadFileData();
$this->fileData = $data;
}
return $this->fileData;
}
private function loadRawFile($file_phid) {
$viewer = PhabricatorUser::getOmnipotentUser();
$files = id(new PhabricatorFileQuery())
->setViewer($viewer)
->withPHIDs(array($file_phid))
->execute();
if (!$files) {
throw new Exception(
pht(
'Failed to load file ("%s") with hunk data.',
$file_phid));
}
$file = head($files);
return $file;
}
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$type = $this->getDataType();
$data = $this->getData();
$this->destroyData($type, $data, $engine);
return parent::destroyObjectPermanently($engine);
}
private function destroyData(
$type,
$data,
PhabricatorDestructionEngine $engine = null) {
if (!$engine) {
$engine = new PhabricatorDestructionEngine();
}
switch ($type) {
case self::DATATYPE_FILE:
$file = $this->loadRawFile($data);
$engine->destroyObject($file);
break;
}
}
}
diff --git a/src/applications/differential/storage/DifferentialRevision.php b/src/applications/differential/storage/DifferentialRevision.php
index 7bcd178a41..e29db36b14 100644
--- a/src/applications/differential/storage/DifferentialRevision.php
+++ b/src/applications/differential/storage/DifferentialRevision.php
@@ -1,975 +1,975 @@
<?php
final class DifferentialRevision extends DifferentialDAO
implements
PhabricatorTokenReceiverInterface,
PhabricatorPolicyInterface,
PhabricatorExtendedPolicyInterface,
PhabricatorFlaggableInterface,
PhrequentTrackableInterface,
HarbormasterBuildableInterface,
PhabricatorSubscribableInterface,
PhabricatorCustomFieldInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorMentionableInterface,
PhabricatorDestructibleInterface,
PhabricatorProjectInterface,
PhabricatorFulltextInterface,
PhabricatorFerretInterface,
PhabricatorConduitResultInterface,
PhabricatorDraftInterface {
protected $title = '';
protected $originalTitle;
protected $status;
protected $summary = '';
protected $testPlan = '';
protected $authorPHID;
protected $lastReviewerPHID;
protected $lineCount = 0;
protected $attached = array();
protected $mailKey;
protected $branchName;
protected $repositoryPHID;
protected $viewPolicy = PhabricatorPolicies::POLICY_USER;
protected $editPolicy = PhabricatorPolicies::POLICY_USER;
protected $properties = array();
private $commits = self::ATTACHABLE;
private $activeDiff = self::ATTACHABLE;
private $diffIDs = self::ATTACHABLE;
private $hashes = self::ATTACHABLE;
private $repository = self::ATTACHABLE;
private $reviewerStatus = self::ATTACHABLE;
private $customFields = self::ATTACHABLE;
private $drafts = array();
private $flags = array();
private $forceMap = array();
const TABLE_COMMIT = 'differential_commit';
const RELATION_REVIEWER = 'revw';
const RELATION_SUBSCRIBED = 'subd';
const PROPERTY_CLOSED_FROM_ACCEPTED = 'wasAcceptedBeforeClose';
public static function initializeNewRevision(PhabricatorUser $actor) {
$app = id(new PhabricatorApplicationQuery())
->setViewer($actor)
->withClasses(array('PhabricatorDifferentialApplication'))
->executeOne();
$view_policy = $app->getPolicy(
DifferentialDefaultViewCapability::CAPABILITY);
return id(new DifferentialRevision())
->setViewPolicy($view_policy)
->setAuthorPHID($actor->getPHID())
->attachRepository(null)
->attachActiveDiff(null)
->attachReviewers(array())
->setModernRevisionStatus(DifferentialRevisionStatus::NEEDS_REVIEW);
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'attached' => self::SERIALIZATION_JSON,
'unsubscribed' => self::SERIALIZATION_JSON,
'properties' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'title' => 'text255',
'originalTitle' => 'text255',
'status' => 'text32',
'summary' => 'text',
'testPlan' => 'text',
'authorPHID' => 'phid?',
'lastReviewerPHID' => 'phid?',
'lineCount' => 'uint32?',
'mailKey' => 'bytes40',
'branchName' => 'text255?',
'repositoryPHID' => 'phid?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'authorPHID' => array(
'columns' => array('authorPHID', 'status'),
),
'repositoryPHID' => array(
'columns' => array('repositoryPHID'),
),
// If you (or a project you are a member of) is reviewing a significant
// fraction of the revisions on an install, the result set of open
// revisions may be smaller than the result set of revisions where you
// are a reviewer. In these cases, this key is better than keys on the
// edge table.
'key_status' => array(
'columns' => array('status', 'phid'),
),
),
) + parent::getConfiguration();
}
public function setProperty($key, $value) {
$this->properties[$key] = $value;
return $this;
}
public function getProperty($key, $default = null) {
return idx($this->properties, $key, $default);
}
public function hasRevisionProperty($key) {
return array_key_exists($key, $this->properties);
}
public function getMonogram() {
$id = $this->getID();
return "D{$id}";
}
public function getURI() {
return '/'.$this->getMonogram();
}
public function setTitle($title) {
$this->title = $title;
if (!$this->getID()) {
$this->originalTitle = $title;
}
return $this;
}
public function loadIDsByCommitPHIDs($phids) {
if (!$phids) {
return array();
}
$revision_ids = queryfx_all(
$this->establishConnection('r'),
'SELECT * FROM %T WHERE commitPHID IN (%Ls)',
self::TABLE_COMMIT,
$phids);
return ipull($revision_ids, 'revisionID', 'commitPHID');
}
public function loadCommitPHIDs() {
if (!$this->getID()) {
return ($this->commits = array());
}
$commits = queryfx_all(
$this->establishConnection('r'),
'SELECT commitPHID FROM %T WHERE revisionID = %d',
self::TABLE_COMMIT,
$this->getID());
$commits = ipull($commits, 'commitPHID');
return ($this->commits = $commits);
}
public function getCommitPHIDs() {
return $this->assertAttached($this->commits);
}
public function getActiveDiff() {
// TODO: Because it's currently technically possible to create a revision
// without an associated diff, we allow an attached-but-null active diff.
// It would be good to get rid of this once we make diff-attaching
// transactional.
return $this->assertAttached($this->activeDiff);
}
public function attachActiveDiff($diff) {
$this->activeDiff = $diff;
return $this;
}
public function getDiffIDs() {
return $this->assertAttached($this->diffIDs);
}
public function attachDiffIDs(array $ids) {
rsort($ids);
$this->diffIDs = array_values($ids);
return $this;
}
public function attachCommitPHIDs(array $phids) {
$this->commits = array_values($phids);
return $this;
}
public function getAttachedPHIDs($type) {
return array_keys(idx($this->attached, $type, array()));
}
public function setAttachedPHIDs($type, array $phids) {
$this->attached[$type] = array_fill_keys($phids, array());
return $this;
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
DifferentialRevisionPHIDType::TYPECONST);
}
public function loadActiveDiff() {
return id(new DifferentialDiff())->loadOneWhere(
'revisionID = %d ORDER BY id DESC LIMIT 1',
$this->getID());
}
public function save() {
if (!$this->getMailKey()) {
$this->mailKey = Filesystem::readRandomCharacters(40);
}
return parent::save();
}
public function getHashes() {
return $this->assertAttached($this->hashes);
}
public function attachHashes(array $hashes) {
$this->hashes = $hashes;
return $this;
}
public function canReviewerForceAccept(
PhabricatorUser $viewer,
DifferentialReviewer $reviewer) {
if (!$reviewer->isPackage()) {
return false;
}
$map = $this->getReviewerForceAcceptMap($viewer);
if (!$map) {
return false;
}
if (isset($map[$reviewer->getReviewerPHID()])) {
return true;
}
return false;
}
private function getReviewerForceAcceptMap(PhabricatorUser $viewer) {
$fragment = $viewer->getCacheFragment();
if (!array_key_exists($fragment, $this->forceMap)) {
$map = $this->newReviewerForceAcceptMap($viewer);
$this->forceMap[$fragment] = $map;
}
return $this->forceMap[$fragment];
}
private function newReviewerForceAcceptMap(PhabricatorUser $viewer) {
$diff = $this->getActiveDiff();
if (!$diff) {
return null;
}
$repository_phid = $diff->getRepositoryPHID();
if (!$repository_phid) {
return null;
}
$paths = array();
try {
$changesets = $diff->getChangesets();
} catch (Exception $ex) {
$changesets = id(new DifferentialChangesetQuery())
->setViewer($viewer)
->withDiffs(array($diff))
->execute();
}
foreach ($changesets as $changeset) {
$paths[] = $changeset->getOwnersFilename();
}
if (!$paths) {
return null;
}
$reviewer_phids = array();
foreach ($this->getReviewers() as $reviewer) {
if (!$reviewer->isPackage()) {
continue;
}
$reviewer_phids[] = $reviewer->getReviewerPHID();
}
if (!$reviewer_phids) {
return null;
}
// Load all the reviewing packages which have control over some of the
// paths in the change. These are packages which the actor may be able
// to force-accept on behalf of.
$control_query = id(new PhabricatorOwnersPackageQuery())
->setViewer($viewer)
->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE))
->withPHIDs($reviewer_phids)
->withControl($repository_phid, $paths);
$control_packages = $control_query->execute();
if (!$control_packages) {
return null;
}
// Load all the packages which have potential control over some of the
// paths in the change and are owned by the actor. These are packages
// which the actor may be able to use their authority over to gain the
// ability to force-accept for other packages. This query doesn't apply
// dominion rules yet, and we'll bypass those rules later on.
$authority_query = id(new PhabricatorOwnersPackageQuery())
->setViewer($viewer)
->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE))
->withAuthorityPHIDs(array($viewer->getPHID()))
->withControl($repository_phid, $paths);
$authority_packages = $authority_query->execute();
if (!$authority_packages) {
return null;
}
$authority_packages = mpull($authority_packages, null, 'getPHID');
// Build a map from each path in the revision to the reviewer packages
// which control it.
$control_map = array();
foreach ($paths as $path) {
$control_packages = $control_query->getControllingPackagesForPath(
$repository_phid,
$path);
// Remove packages which the viewer has authority over. We don't need
// to check these for force-accept because they can just accept them
// normally.
$control_packages = mpull($control_packages, null, 'getPHID');
foreach ($control_packages as $phid => $control_package) {
if (isset($authority_packages[$phid])) {
unset($control_packages[$phid]);
}
}
if (!$control_packages) {
continue;
}
$control_map[$path] = $control_packages;
}
if (!$control_map) {
return null;
}
// From here on out, we only care about paths which we have at least one
// controlling package for.
$paths = array_keys($control_map);
// Now, build a map from each path to the packages which would control it
// if there were no dominion rules.
$authority_map = array();
foreach ($paths as $path) {
$authority_packages = $authority_query->getControllingPackagesForPath(
$repository_phid,
$path,
$ignore_dominion = true);
$authority_map[$path] = mpull($authority_packages, null, 'getPHID');
}
// For each path, find the most general package that the viewer has
// authority over. For example, we'll prefer a package that owns "/" to a
// package that owns "/src/".
$force_map = array();
foreach ($authority_map as $path => $package_map) {
$path_fragments = PhabricatorOwnersPackage::splitPath($path);
$fragment_count = count($path_fragments);
// Find the package that we have authority over which has the most
// general match for this path.
$best_match = null;
$best_package = null;
foreach ($package_map as $package_phid => $package) {
$package_paths = $package->getPathsForRepository($repository_phid);
foreach ($package_paths as $package_path) {
// NOTE: A strength of 0 means "no match". A strength of 1 means
// that we matched "/", so we can not possibly find another stronger
// match.
$strength = $package_path->getPathMatchStrength(
$path_fragments,
$fragment_count);
if (!$strength) {
continue;
}
if ($strength < $best_match || !$best_package) {
$best_match = $strength;
$best_package = $package;
if ($strength == 1) {
break 2;
}
}
}
}
if ($best_package) {
$force_map[$path] = array(
'strength' => $best_match,
'package' => $best_package,
);
}
}
// For each path which the viewer owns a package for, find other packages
// which that authority can be used to force-accept. Once we find a way to
- // force-accept a package, we don't need to keep loooking.
+ // force-accept a package, we don't need to keep looking.
$has_control = array();
foreach ($force_map as $path => $spec) {
$path_fragments = PhabricatorOwnersPackage::splitPath($path);
$fragment_count = count($path_fragments);
$authority_strength = $spec['strength'];
$control_packages = $control_map[$path];
foreach ($control_packages as $control_phid => $control_package) {
if (isset($has_control[$control_phid])) {
continue;
}
$control_paths = $control_package->getPathsForRepository(
$repository_phid);
foreach ($control_paths as $control_path) {
$strength = $control_path->getPathMatchStrength(
$path_fragments,
$fragment_count);
if (!$strength) {
continue;
}
if ($strength > $authority_strength) {
$authority = $spec['package'];
$has_control[$control_phid] = array(
'authority' => $authority,
'phid' => $authority->getPHID(),
);
break;
}
}
}
}
// Return a map from packages which may be force accepted to the packages
// which permit that forced acceptance.
return ipull($has_control, 'phid');
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return $this->getViewPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return $this->getEditPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $user) {
// A revision's author (which effectively means "owner" after we added
// commandeering) can always view and edit it.
$author_phid = $this->getAuthorPHID();
if ($author_phid) {
if ($user->getPHID() == $author_phid) {
return true;
}
}
return false;
}
public function describeAutomaticCapability($capability) {
$description = array(
pht('The owner of a revision can always view and edit it.'),
);
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
$description[] = pht(
'If a revision belongs to a repository, other users must be able '.
'to view the repository in order to view the revision.');
break;
}
return $description;
}
/* -( PhabricatorExtendedPolicyInterface )--------------------------------- */
public function getExtendedPolicy($capability, PhabricatorUser $viewer) {
$extended = array();
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
$repository_phid = $this->getRepositoryPHID();
$repository = $this->getRepository();
// Try to use the object if we have it, since it will save us some
// data fetching later on. In some cases, we might not have it.
$repository_ref = nonempty($repository, $repository_phid);
if ($repository_ref) {
$extended[] = array(
$repository_ref,
PhabricatorPolicyCapability::CAN_VIEW,
);
}
break;
}
return $extended;
}
/* -( PhabricatorTokenReceiverInterface )---------------------------------- */
public function getUsersToNotifyOfTokenGiven() {
return array(
$this->getAuthorPHID(),
);
}
public function getReviewers() {
return $this->assertAttached($this->reviewerStatus);
}
public function attachReviewers(array $reviewers) {
assert_instances_of($reviewers, 'DifferentialReviewer');
$reviewers = mpull($reviewers, null, 'getReviewerPHID');
$this->reviewerStatus = $reviewers;
return $this;
}
public function getReviewerPHIDs() {
$reviewers = $this->getReviewers();
return mpull($reviewers, 'getReviewerPHID');
}
public function getReviewerPHIDsForEdit() {
$reviewers = $this->getReviewers();
$status_blocking = DifferentialReviewerStatus::STATUS_BLOCKING;
$value = array();
foreach ($reviewers as $reviewer) {
$phid = $reviewer->getReviewerPHID();
if ($reviewer->getReviewerStatus() == $status_blocking) {
$value[] = 'blocking('.$phid.')';
} else {
$value[] = $phid;
}
}
return $value;
}
public function getRepository() {
return $this->assertAttached($this->repository);
}
public function attachRepository(PhabricatorRepository $repository = null) {
$this->repository = $repository;
return $this;
}
public function setModernRevisionStatus($status) {
return $this->setStatus($status);
}
public function getModernRevisionStatus() {
return $this->getStatus();
}
public function getLegacyRevisionStatus() {
return $this->getStatusObject()->getLegacyKey();
}
public function isClosed() {
return $this->getStatusObject()->isClosedStatus();
}
public function isAbandoned() {
return $this->getStatusObject()->isAbandoned();
}
public function isAccepted() {
return $this->getStatusObject()->isAccepted();
}
public function isNeedsReview() {
return $this->getStatusObject()->isNeedsReview();
}
public function isNeedsRevision() {
return $this->getStatusObject()->isNeedsRevision();
}
public function isChangePlanned() {
return $this->getStatusObject()->isChangePlanned();
}
public function isPublished() {
return $this->getStatusObject()->isPublished();
}
public function isDraft() {
return $this->getStatusObject()->isDraft();
}
public function getStatusIcon() {
return $this->getStatusObject()->getIcon();
}
public function getStatusDisplayName() {
return $this->getStatusObject()->getDisplayName();
}
public function getStatusIconColor() {
return $this->getStatusObject()->getIconColor();
}
public function getStatusObject() {
$status = $this->getStatus();
return DifferentialRevisionStatus::newForStatus($status);
}
public function getFlag(PhabricatorUser $viewer) {
return $this->assertAttachedKey($this->flags, $viewer->getPHID());
}
public function attachFlag(
PhabricatorUser $viewer,
PhabricatorFlag $flag = null) {
$this->flags[$viewer->getPHID()] = $flag;
return $this;
}
public function getHasDraft(PhabricatorUser $viewer) {
return $this->assertAttachedKey($this->drafts, $viewer->getCacheFragment());
}
public function attachHasDraft(PhabricatorUser $viewer, $has_draft) {
$this->drafts[$viewer->getCacheFragment()] = $has_draft;
return $this;
}
public function shouldBroadcast() {
if (!$this->isDraft()) {
return true;
}
return false;
}
/* -( HarbormasterBuildableInterface )------------------------------------- */
public function getHarbormasterBuildableDisplayPHID() {
return $this->getHarbormasterContainerPHID();
}
public function getHarbormasterBuildablePHID() {
return $this->loadActiveDiff()->getPHID();
}
public function getHarbormasterContainerPHID() {
return $this->getPHID();
}
public function getHarbormasterPublishablePHID() {
return $this->getPHID();
}
public function getBuildVariables() {
return array();
}
public function getAvailableBuildVariables() {
return array();
}
/* -( PhabricatorSubscribableInterface )----------------------------------- */
public function isAutomaticallySubscribed($phid) {
if ($phid == $this->getAuthorPHID()) {
return true;
}
// TODO: This only happens when adding or removing CCs, and is safe from a
// policy perspective, but the subscription pathway should have some
// opportunity to load this data properly. For now, this is the only case
// where implicit subscription is not an intrinsic property of the object.
if ($this->reviewerStatus == self::ATTACHABLE) {
$reviewers = id(new DifferentialRevisionQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs(array($this->getPHID()))
->needReviewers(true)
->executeOne()
->getReviewers();
} else {
$reviewers = $this->getReviewers();
}
foreach ($reviewers as $reviewer) {
if ($reviewer->getReviewerPHID() == $phid) {
return true;
}
}
return false;
}
/* -( PhabricatorCustomFieldInterface )------------------------------------ */
public function getCustomFieldSpecificationForRole($role) {
return PhabricatorEnv::getEnvConfig('differential.fields');
}
public function getCustomFieldBaseClass() {
return 'DifferentialCustomField';
}
public function getCustomFields() {
return $this->assertAttached($this->customFields);
}
public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) {
$this->customFields = $fields;
return $this;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new DifferentialTransactionEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new DifferentialTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
$viewer = $request->getViewer();
$render_data = $timeline->getRenderData();
$left = $request->getInt('left', idx($render_data, 'left'));
$right = $request->getInt('right', idx($render_data, 'right'));
$diffs = id(new DifferentialDiffQuery())
->setViewer($request->getUser())
->withIDs(array($left, $right))
->execute();
$diffs = mpull($diffs, null, 'getID');
$left_diff = $diffs[$left];
$right_diff = $diffs[$right];
$old_ids = $request->getStr('old', idx($render_data, 'old'));
$new_ids = $request->getStr('new', idx($render_data, 'new'));
$old_ids = array_filter(explode(',', $old_ids));
$new_ids = array_filter(explode(',', $new_ids));
$type_inline = DifferentialTransaction::TYPE_INLINE;
$changeset_ids = array_merge($old_ids, $new_ids);
$inlines = array();
foreach ($timeline->getTransactions() as $xaction) {
if ($xaction->getTransactionType() == $type_inline) {
$inlines[] = $xaction->getComment();
$changeset_ids[] = $xaction->getComment()->getChangesetID();
}
}
if ($changeset_ids) {
$changesets = id(new DifferentialChangesetQuery())
->setViewer($request->getUser())
->withIDs($changeset_ids)
->execute();
$changesets = mpull($changesets, null, 'getID');
} else {
$changesets = array();
}
foreach ($inlines as $key => $inline) {
$inlines[$key] = DifferentialInlineComment::newFromModernComment(
$inline);
}
$query = id(new DifferentialInlineCommentQuery())
->needHidden(true)
->setViewer($viewer);
// NOTE: This is a bit sketchy: this method adjusts the inlines as a
// side effect, which means it will ultimately adjust the transaction
// comments and affect timeline rendering.
$query->adjustInlinesForChangesets(
$inlines,
array_select_keys($changesets, $old_ids),
array_select_keys($changesets, $new_ids),
$this);
return $timeline
->setChangesets($changesets)
->setRevision($this)
->setLeftDiff($left_diff)
->setRightDiff($right_diff);
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$diffs = id(new DifferentialDiffQuery())
->setViewer($engine->getViewer())
->withRevisionIDs(array($this->getID()))
->execute();
foreach ($diffs as $diff) {
$engine->destroyObject($diff);
}
$conn_w = $this->establishConnection('w');
queryfx(
$conn_w,
'DELETE FROM %T WHERE revisionID = %d',
self::TABLE_COMMIT,
$this->getID());
- // we have to do paths a little differentally as they do not have
+ // we have to do paths a little differently as they do not have
// an id or phid column for delete() to act on
$dummy_path = new DifferentialAffectedPath();
queryfx(
$conn_w,
'DELETE FROM %T WHERE revisionID = %d',
$dummy_path->getTableName(),
$this->getID());
$this->delete();
$this->saveTransaction();
}
/* -( PhabricatorFulltextInterface )--------------------------------------- */
public function newFulltextEngine() {
return new DifferentialRevisionFulltextEngine();
}
/* -( PhabricatorFerretInterface )----------------------------------------- */
public function newFerretEngine() {
return new DifferentialRevisionFerretEngine();
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('title')
->setType('string')
->setDescription(pht('The revision title.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('authorPHID')
->setType('phid')
->setDescription(pht('Revision author PHID.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('status')
->setType('map<string, wild>')
->setDescription(pht('Information about revision status.')),
);
}
public function getFieldValuesForConduit() {
$status = $this->getStatusObject();
$status_info = array(
'value' => $status->getKey(),
'name' => $status->getDisplayName(),
'closed' => $status->isClosedStatus(),
'color.ansi' => $status->getANSIColor(),
);
return array(
'title' => $this->getTitle(),
'authorPHID' => $this->getAuthorPHID(),
'status' => $status_info,
);
}
public function getConduitSearchAttachments() {
return array(
id(new DifferentialReviewersSearchEngineAttachment())
->setAttachmentKey('reviewers'),
);
}
/* -( PhabricatorDraftInterface )------------------------------------------ */
public function newDraftEngine() {
return new DifferentialRevisionDraftEngine();
}
}
diff --git a/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php b/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php
index 51eb2beb9a..d23ecff47d 100644
--- a/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php
+++ b/src/applications/differential/typeahead/DifferentialResponsibleViewerFunctionDatasource.php
@@ -1,77 +1,77 @@
<?php
final class DifferentialResponsibleViewerFunctionDatasource
extends PhabricatorTypeaheadDatasource {
public function getBrowseTitle() {
return pht('Browse Viewer');
}
public function getPlaceholderText() {
return pht('Type viewer()...');
}
public function getDatasourceApplicationClass() {
return 'PhabricatorPeopleApplication';
}
public function getDatasourceFunctions() {
return array(
'viewer' => array(
'name' => pht('Current Viewer'),
'summary' => pht('Use the current viewing user.'),
'description' => pht(
'Show revisions the current viewer is responsible for. This '.
- 'function inclues revisions the viewer is responsible for through '.
+ 'function includes revisions the viewer is responsible for through '.
'membership in projects and packages.'),
),
);
}
public function loadResults() {
if ($this->getViewer()->getPHID()) {
$results = array($this->renderViewerFunctionToken());
} else {
$results = array();
}
return $this->filterResultsAgainstTokens($results);
}
protected function canEvaluateFunction($function) {
if (!$this->getViewer()->getPHID()) {
return false;
}
return parent::canEvaluateFunction($function);
}
protected function evaluateFunction($function, array $argv_list) {
$results = array();
foreach ($argv_list as $argv) {
$results[] = $this->getViewer()->getPHID();
}
return DifferentialResponsibleDatasource::expandResponsibleUsers(
$this->getViewer(),
$results);
}
public function renderFunctionTokens($function, array $argv_list) {
$tokens = array();
foreach ($argv_list as $argv) {
$tokens[] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult(
$this->renderViewerFunctionToken());
}
return $tokens;
}
private function renderViewerFunctionToken() {
return $this->newFunctionResult()
->setName(pht('Current Viewer'))
->setPHID('viewer()')
->setIcon('fa-user')
->setUnique(true);
}
}
diff --git a/src/applications/diffusion/controller/DiffusionCommitController.php b/src/applications/diffusion/controller/DiffusionCommitController.php
index 38c94e8a92..d7734856f0 100644
--- a/src/applications/diffusion/controller/DiffusionCommitController.php
+++ b/src/applications/diffusion/controller/DiffusionCommitController.php
@@ -1,1130 +1,1130 @@
<?php
final class DiffusionCommitController extends DiffusionController {
const CHANGES_LIMIT = 100;
private $commitParents;
private $commitRefs;
private $commitMerges;
private $commitErrors;
private $commitExists;
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$response = $this->loadDiffusionContext();
if ($response) {
return $response;
}
$drequest = $this->getDiffusionRequest();
$viewer = $request->getUser();
$repository = $drequest->getRepository();
$commit_identifier = $drequest->getCommit();
// If this page is being accessed via "/source/xyz/commit/...", redirect
// to the canonical URI.
$has_callsign = strlen($request->getURIData('repositoryCallsign'));
$has_id = strlen($request->getURIData('repositoryID'));
if (!$has_callsign && !$has_id) {
$canonical_uri = $repository->getCommitURI($commit_identifier);
return id(new AphrontRedirectResponse())
->setURI($canonical_uri);
}
if ($request->getStr('diff')) {
return $this->buildRawDiffResponse($drequest);
}
$commit = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepository($repository)
->withIdentifiers(array($commit_identifier))
->needCommitData(true)
->needAuditRequests(true)
->executeOne();
$crumbs = $this->buildCrumbs(array(
'commit' => true,
));
$crumbs->setBorder(true);
if (!$commit) {
if (!$this->getCommitExists()) {
return new Aphront404Response();
}
$error = id(new PHUIInfoView())
->setTitle(pht('Commit Still Parsing'))
->appendChild(
pht(
'Failed to load the commit because the commit has not been '.
'parsed yet.'));
$title = pht('Commit Still Parsing');
return $this->newPage()
->setTitle($title)
->setCrumbs($crumbs)
->appendChild($error);
}
$audit_requests = $commit->getAudits();
$commit->loadAndAttachAuditAuthority($viewer);
$commit_data = $commit->getCommitData();
$is_foreign = $commit_data->getCommitDetail('foreign-svn-stub');
$error_panel = null;
$hard_limit = 1000;
if ($commit->isImported()) {
$change_query = DiffusionPathChangeQuery::newFromDiffusionRequest(
$drequest);
$change_query->setLimit($hard_limit + 1);
$changes = $change_query->loadChanges();
} else {
$changes = array();
}
$was_limited = (count($changes) > $hard_limit);
if ($was_limited) {
$changes = array_slice($changes, 0, $hard_limit);
}
$count = count($changes);
$is_unreadable = false;
$hint = null;
if (!$count || $commit->isUnreachable()) {
$hint = id(new DiffusionCommitHintQuery())
->setViewer($viewer)
->withRepositoryPHIDs(array($repository->getPHID()))
->withOldCommitIdentifiers(array($commit->getCommitIdentifier()))
->executeOne();
if ($hint) {
$is_unreadable = $hint->isUnreadable();
}
}
if ($is_foreign) {
$subpath = $commit_data->getCommitDetail('svn-subpath');
$error_panel = new PHUIInfoView();
$error_panel->setTitle(pht('Commit Not Tracked'));
$error_panel->setSeverity(PHUIInfoView::SEVERITY_WARNING);
$error_panel->appendChild(
pht(
"This Diffusion repository is configured to track only one ".
"subdirectory of the entire Subversion repository, and this commit ".
"didn't affect the tracked subdirectory ('%s'), so no ".
"information is available.",
$subpath));
} else {
$engine = PhabricatorMarkupEngine::newDifferentialMarkupEngine();
$engine->setConfig('viewer', $viewer);
$commit_tag = $this->renderCommitHashTag($drequest);
$header = id(new PHUIHeaderView())
->setHeader(nonempty($commit->getSummary(), pht('Commit Detail')))
->setHeaderIcon('fa-code-fork')
->addTag($commit_tag);
if ($commit->getAuditStatus()) {
$icon = PhabricatorAuditCommitStatusConstants::getStatusIcon(
$commit->getAuditStatus());
$color = PhabricatorAuditCommitStatusConstants::getStatusColor(
$commit->getAuditStatus());
$status = PhabricatorAuditCommitStatusConstants::getStatusName(
$commit->getAuditStatus());
$header->setStatus($icon, $color, $status);
}
$curtain = $this->buildCurtain($commit, $repository);
$subheader = $this->buildSubheaderView($commit, $commit_data);
$details = $this->buildPropertyListView(
$commit,
$commit_data,
$audit_requests);
$message = $commit_data->getCommitMessage();
$revision = $commit->getCommitIdentifier();
$message = $this->linkBugtraq($message);
$message = $engine->markupText($message);
$detail_list = new PHUIPropertyListView();
$detail_list->addTextContent(
phutil_tag(
'div',
array(
'class' => 'diffusion-commit-message phabricator-remarkup',
),
$message));
if ($commit->isUnreachable()) {
$did_rewrite = false;
if ($hint) {
if ($hint->isRewritten()) {
$rewritten = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepository($repository)
->withIdentifiers(array($hint->getNewCommitIdentifier()))
->executeOne();
if ($rewritten) {
$did_rewrite = true;
$rewritten_uri = $rewritten->getURI();
$rewritten_name = $rewritten->getLocalName();
$rewritten_link = phutil_tag(
'a',
array(
'href' => $rewritten_uri,
),
$rewritten_name);
$this->commitErrors[] = pht(
'This commit was rewritten after it was published, which '.
'changed the commit hash. This old version of the commit is '.
'no longer reachable from any branch, tag or ref. The new '.
'version of this commit is %s.',
$rewritten_link);
}
}
}
if (!$did_rewrite) {
$this->commitErrors[] = pht(
'This commit has been deleted in the repository: it is no longer '.
'reachable from any branch, tag, or ref.');
}
}
if ($this->getCommitErrors()) {
$error_panel = id(new PHUIInfoView())
->appendChild($this->getCommitErrors())
->setSeverity(PHUIInfoView::SEVERITY_WARNING);
}
}
$timeline = $this->buildComments($commit);
$merge_table = $this->buildMergesTable($commit);
$show_changesets = false;
$info_panel = null;
$change_list = null;
$change_table = null;
if ($is_unreadable) {
$info_panel = $this->renderStatusMessage(
pht('Unreadable Commit'),
pht(
'This commit has been marked as unreadable by an administrator. '.
'It may have been corrupted or created improperly by an external '.
'tool.'));
} else if ($is_foreign) {
// Don't render anything else.
} else if (!$commit->isImported()) {
$info_panel = $this->renderStatusMessage(
pht('Still Importing...'),
pht(
'This commit is still importing. Changes will be visible once '.
'the import finishes.'));
} else if (!count($changes)) {
$info_panel = $this->renderStatusMessage(
pht('Empty Commit'),
pht(
'This commit is empty and does not affect any paths.'));
} else if ($was_limited) {
$info_panel = $this->renderStatusMessage(
pht('Enormous Commit'),
pht(
'This commit is enormous, and affects more than %d files. '.
'Changes are not shown.',
$hard_limit));
} else if (!$this->getCommitExists()) {
$info_panel = $this->renderStatusMessage(
pht('Commit No Longer Exists'),
pht('This commit no longer exists in the repository.'));
} else {
$show_changesets = true;
// The user has clicked "Show All Changes", and we should show all the
// changes inline even if there are more than the soft limit.
$show_all_details = $request->getBool('show_all');
$change_header = id(new PHUIHeaderView())
->setHeader(pht('Changes (%s)', new PhutilNumber($count)));
$warning_view = null;
if ($count > self::CHANGES_LIMIT && !$show_all_details) {
$button = id(new PHUIButtonView())
->setText(pht('Show All Changes'))
->setHref('?show_all=true')
->setTag('a')
->setIcon('fa-files-o');
$warning_view = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_WARNING)
->setTitle(pht('Very Large Commit'))
->appendChild(
pht('This commit is very large. Load each file individually.'));
$change_header->addActionLink($button);
}
$changesets = DiffusionPathChange::convertToDifferentialChangesets(
$viewer,
$changes);
// TODO: This table and panel shouldn't really be separate, but we need
// to clean up the "Load All Files" interaction first.
$change_table = $this->buildTableOfContents(
$changesets,
$change_header,
$warning_view);
$vcs = $repository->getVersionControlSystem();
switch ($vcs) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$vcs_supports_directory_changes = true;
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
$vcs_supports_directory_changes = false;
break;
default:
throw new Exception(pht('Unknown VCS.'));
}
$references = array();
foreach ($changesets as $key => $changeset) {
$file_type = $changeset->getFileType();
if ($file_type == DifferentialChangeType::FILE_DIRECTORY) {
if (!$vcs_supports_directory_changes) {
unset($changesets[$key]);
continue;
}
}
$references[$key] = $drequest->generateURI(
array(
'action' => 'rendering-ref',
'path' => $changeset->getFilename(),
));
}
// TODO: Some parts of the views still rely on properties of the
// DifferentialChangeset. Make the objects ephemeral to make sure we don't
// accidentally save them, and then set their ID to the appropriate ID for
// this application (the path IDs).
$path_ids = array_flip(mpull($changes, 'getPath'));
foreach ($changesets as $changeset) {
$changeset->makeEphemeral();
$changeset->setID($path_ids[$changeset->getFilename()]);
}
if ($count <= self::CHANGES_LIMIT || $show_all_details) {
$visible_changesets = $changesets;
} else {
$visible_changesets = array();
$inlines = PhabricatorAuditInlineComment::loadDraftAndPublishedComments(
$viewer,
$commit->getPHID());
$path_ids = mpull($inlines, null, 'getPathID');
foreach ($changesets as $key => $changeset) {
if (array_key_exists($changeset->getID(), $path_ids)) {
$visible_changesets[$key] = $changeset;
}
}
}
$change_list_title = $commit->getDisplayName();
$change_list = new DifferentialChangesetListView();
$change_list->setTitle($change_list_title);
$change_list->setChangesets($changesets);
$change_list->setVisibleChangesets($visible_changesets);
$change_list->setRenderingReferences($references);
$change_list->setRenderURI($repository->getPathURI('diff/'));
$change_list->setRepository($repository);
$change_list->setUser($viewer);
$change_list->setBackground(PHUIObjectBoxView::BLUE_PROPERTY);
// TODO: Try to setBranch() to something reasonable here?
$change_list->setStandaloneURI(
$repository->getPathURI('diff/'));
$change_list->setRawFileURIs(
// TODO: Implement this, somewhat tricky if there's an octopus merge
// or whatever?
null,
$repository->getPathURI('diff/?view=r'));
$change_list->setInlineCommentControllerURI(
'/diffusion/inline/edit/'.phutil_escape_uri($commit->getPHID()).'/');
}
$add_comment = $this->renderAddCommentPanel(
$commit,
$timeline);
$filetree_on = $viewer->compareUserSetting(
PhabricatorShowFiletreeSetting::SETTINGKEY,
PhabricatorShowFiletreeSetting::VALUE_ENABLE_FILETREE);
$pref_collapse = PhabricatorFiletreeVisibleSetting::SETTINGKEY;
$collapsed = $viewer->getUserSetting($pref_collapse);
$nav = null;
if ($show_changesets && $filetree_on) {
$nav = id(new DifferentialChangesetFileTreeSideNavBuilder())
->setTitle($commit->getDisplayName())
->setBaseURI(new PhutilURI($commit->getURI()))
->build($changesets)
->setCrumbs($crumbs)
->setCollapsed((bool)$collapsed);
}
$view = id(new PHUITwoColumnView())
->setHeader($header)
->setSubheader($subheader)
->setMainColumn(array(
$error_panel,
$timeline,
$merge_table,
$info_panel,
))
->setFooter(array(
$change_table,
$change_list,
$add_comment,
))
->addPropertySection(pht('Description'), $detail_list)
->addPropertySection(pht('Details'), $details)
->setCurtain($curtain);
$page = $this->newPage()
->setTitle($commit->getDisplayName())
->setCrumbs($crumbs)
->setPageObjectPHIDS(array($commit->getPHID()))
->appendChild(
array(
$view,
));
if ($nav) {
$page->setNavigation($nav);
}
return $page;
}
private function buildPropertyListView(
PhabricatorRepositoryCommit $commit,
PhabricatorRepositoryCommitData $data,
array $audit_requests) {
$viewer = $this->getViewer();
$commit_phid = $commit->getPHID();
$drequest = $this->getDiffusionRequest();
$repository = $drequest->getRepository();
$view = id(new PHUIPropertyListView())
->setUser($this->getRequest()->getUser());
$edge_query = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($commit_phid))
->withEdgeTypes(array(
DiffusionCommitHasTaskEdgeType::EDGECONST,
DiffusionCommitHasRevisionEdgeType::EDGECONST,
DiffusionCommitRevertsCommitEdgeType::EDGECONST,
DiffusionCommitRevertedByCommitEdgeType::EDGECONST,
));
$edges = $edge_query->execute();
$task_phids = array_keys(
$edges[$commit_phid][DiffusionCommitHasTaskEdgeType::EDGECONST]);
$revision_phid = key(
$edges[$commit_phid][DiffusionCommitHasRevisionEdgeType::EDGECONST]);
$reverts_phids = array_keys(
$edges[$commit_phid][DiffusionCommitRevertsCommitEdgeType::EDGECONST]);
$reverted_by_phids = array_keys(
$edges[$commit_phid][DiffusionCommitRevertedByCommitEdgeType::EDGECONST]);
$phids = $edge_query->getDestinationPHIDs(array($commit_phid));
if ($data->getCommitDetail('authorPHID')) {
$phids[] = $data->getCommitDetail('authorPHID');
}
if ($data->getCommitDetail('reviewerPHID')) {
$phids[] = $data->getCommitDetail('reviewerPHID');
}
if ($data->getCommitDetail('committerPHID')) {
$phids[] = $data->getCommitDetail('committerPHID');
}
// NOTE: We should never normally have more than a single push log, but
// it can occur naturally if a commit is pushed, then the branch it was
// on is deleted, then the commit is pushed again (or through other similar
// chains of events). This should be rare, but does not indicate a bug
// or data issue.
- // NOTE: We never query push logs in SVN because the commiter is always
+ // NOTE: We never query push logs in SVN because the committer is always
// the pusher and the commit time is always the push time; the push log
// is redundant and we save a query by skipping it.
$push_logs = array();
if ($repository->isHosted() && !$repository->isSVN()) {
$push_logs = id(new PhabricatorRepositoryPushLogQuery())
->setViewer($viewer)
->withRepositoryPHIDs(array($repository->getPHID()))
->withNewRefs(array($commit->getCommitIdentifier()))
->withRefTypes(array(PhabricatorRepositoryPushLog::REFTYPE_COMMIT))
->execute();
foreach ($push_logs as $log) {
$phids[] = $log->getPusherPHID();
}
}
$handles = array();
if ($phids) {
$handles = $this->loadViewerHandles($phids);
}
$props = array();
if ($audit_requests) {
$user_requests = array();
$other_requests = array();
foreach ($audit_requests as $audit_request) {
if (!$audit_request->isInteresting()) {
continue;
}
if ($audit_request->isUser()) {
$user_requests[] = $audit_request;
} else {
$other_requests[] = $audit_request;
}
}
if ($user_requests) {
$view->addProperty(
pht('Auditors'),
$this->renderAuditStatusView($commit, $user_requests));
}
if ($other_requests) {
$view->addProperty(
pht('Group Auditors'),
$this->renderAuditStatusView($commit, $other_requests));
}
}
$author_phid = $data->getCommitDetail('authorPHID');
$author_name = $data->getAuthorName();
$author_epoch = $data->getCommitDetail('authorEpoch');
$committed_info = id(new PHUIStatusItemView())
->setNote(phabricator_datetime($commit->getEpoch(), $viewer));
$committer_phid = $data->getCommitDetail('committerPHID');
$committer_name = $data->getCommitDetail('committer');
if ($committer_phid) {
$committed_info->setTarget($handles[$committer_phid]->renderLink());
} else if (strlen($committer_name)) {
$committed_info->setTarget($committer_name);
} else if ($author_phid) {
$committed_info->setTarget($handles[$author_phid]->renderLink());
} else if (strlen($author_name)) {
$committed_info->setTarget($author_name);
}
$committed_list = new PHUIStatusListView();
$committed_list->addItem($committed_info);
$view->addProperty(
pht('Committed'),
$committed_list);
if ($push_logs) {
$pushed_list = new PHUIStatusListView();
foreach ($push_logs as $push_log) {
$pushed_item = id(new PHUIStatusItemView())
->setTarget($handles[$push_log->getPusherPHID()]->renderLink())
->setNote(phabricator_datetime($push_log->getEpoch(), $viewer));
$pushed_list->addItem($pushed_item);
}
$view->addProperty(
pht('Pushed'),
$pushed_list);
}
$reviewer_phid = $data->getCommitDetail('reviewerPHID');
if ($reviewer_phid) {
$view->addProperty(
pht('Reviewer'),
$handles[$reviewer_phid]->renderLink());
}
if ($revision_phid) {
$view->addProperty(
pht('Differential Revision'),
$handles[$revision_phid]->renderLink());
}
$parents = $this->getCommitParents();
if ($parents) {
$view->addProperty(
pht('Parents'),
$viewer->renderHandleList(mpull($parents, 'getPHID')));
}
if ($this->getCommitExists()) {
$view->addProperty(
pht('Branches'),
phutil_tag(
'span',
array(
'id' => 'commit-branches',
),
pht('Unknown')));
$view->addProperty(
pht('Tags'),
phutil_tag(
'span',
array(
'id' => 'commit-tags',
),
pht('Unknown')));
$identifier = $commit->getCommitIdentifier();
$root = $repository->getPathURI("commit/{$identifier}");
Javelin::initBehavior(
'diffusion-commit-branches',
array(
$root.'/branches/' => 'commit-branches',
$root.'/tags/' => 'commit-tags',
));
}
$refs = $this->getCommitRefs();
if ($refs) {
$ref_links = array();
foreach ($refs as $ref_data) {
$ref_links[] = phutil_tag(
'a',
array(
'href' => $ref_data['href'],
),
$ref_data['ref']);
}
$view->addProperty(
pht('References'),
phutil_implode_html(', ', $ref_links));
}
if ($reverts_phids) {
$view->addProperty(
pht('Reverts'),
$viewer->renderHandleList($reverts_phids));
}
if ($reverted_by_phids) {
$view->addProperty(
pht('Reverted By'),
$viewer->renderHandleList($reverted_by_phids));
}
if ($task_phids) {
$task_list = array();
foreach ($task_phids as $phid) {
$task_list[] = $handles[$phid]->renderLink();
}
$task_list = phutil_implode_html(phutil_tag('br'), $task_list);
$view->addProperty(
pht('Tasks'),
$task_list);
}
return $view;
}
private function buildSubheaderView(
PhabricatorRepositoryCommit $commit,
PhabricatorRepositoryCommitData $data) {
$viewer = $this->getViewer();
$drequest = $this->getDiffusionRequest();
$repository = $drequest->getRepository();
if ($repository->isSVN()) {
return null;
}
$author_phid = $data->getCommitDetail('authorPHID');
$author_name = $data->getAuthorName();
$author_epoch = $data->getCommitDetail('authorEpoch');
$date = null;
if ($author_epoch !== null) {
$date = phabricator_datetime($author_epoch, $viewer);
}
if ($author_phid) {
$handles = $viewer->loadHandles(array($author_phid));
$image_uri = $handles[$author_phid]->getImageURI();
$image_href = $handles[$author_phid]->getURI();
$author = $handles[$author_phid]->renderLink();
} else if (strlen($author_name)) {
$author = $author_name;
$image_uri = null;
$image_href = null;
} else {
return null;
}
$author = phutil_tag('strong', array(), $author);
if ($date) {
$content = pht('Authored by %s on %s.', $author, $date);
} else {
$content = pht('Authored by %s.', $author);
}
return id(new PHUIHeadThingView())
->setImage($image_uri)
->setImageHref($image_href)
->setContent($content);
}
private function buildComments(PhabricatorRepositoryCommit $commit) {
$timeline = $this->buildTransactionTimeline(
$commit,
new PhabricatorAuditTransactionQuery());
$commit->willRenderTimeline($timeline, $this->getRequest());
$timeline->setQuoteRef($commit->getMonogram());
return $timeline;
}
private function renderAddCommentPanel(
PhabricatorRepositoryCommit $commit,
$timeline) {
$request = $this->getRequest();
$viewer = $request->getUser();
// TODO: This is pretty awkward, unify the CSS between Diffusion and
// Differential better.
require_celerity_resource('differential-core-view-css');
$comment_view = id(new DiffusionCommitEditEngine())
->setViewer($viewer)
->buildEditEngineCommentView($commit);
$comment_view->setTransactionTimeline($timeline);
return $comment_view;
}
private function buildMergesTable(PhabricatorRepositoryCommit $commit) {
$viewer = $this->getViewer();
$drequest = $this->getDiffusionRequest();
$repository = $drequest->getRepository();
$merges = $this->getCommitMerges();
if (!$merges) {
return null;
}
$limit = $this->getMergeDisplayLimit();
$caption = null;
if (count($merges) > $limit) {
$merges = array_slice($merges, 0, $limit);
$caption = new PHUIInfoView();
$caption->setSeverity(PHUIInfoView::SEVERITY_NOTICE);
$caption->appendChild(
pht(
'This commit merges a very large number of changes. '.
'Only the first %s are shown.',
new PhutilNumber($limit)));
}
$history_table = id(new DiffusionHistoryTableView())
->setUser($viewer)
->setDiffusionRequest($drequest)
->setHistory($merges);
$history_table->loadRevisions();
$panel = id(new PHUIObjectBoxView())
->setHeaderText(pht('Merged Changes'))
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
->setTable($history_table);
if ($caption) {
$panel->setInfoView($caption);
}
return $panel;
}
private function buildCurtain(
PhabricatorRepositoryCommit $commit,
PhabricatorRepository $repository) {
$request = $this->getRequest();
$viewer = $this->getViewer();
$curtain = $this->newCurtainView($commit);
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$commit,
PhabricatorPolicyCapability::CAN_EDIT);
$id = $commit->getID();
$edit_uri = $this->getApplicationURI("/commit/edit/{$id}/");
$action = id(new PhabricatorActionView())
->setName(pht('Edit Commit'))
->setHref($edit_uri)
->setIcon('fa-pencil')
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit);
$curtain->addAction($action);
$action = id(new PhabricatorActionView())
->setName(pht('Download Raw Diff'))
->setHref($request->getRequestURI()->alter('diff', true))
->setIcon('fa-download');
$curtain->addAction($action);
$relationship_list = PhabricatorObjectRelationshipList::newForObject(
$viewer,
$commit);
$relationship_submenu = $relationship_list->newActionMenu();
if ($relationship_submenu) {
$curtain->addAction($relationship_submenu);
}
return $curtain;
}
private function buildRawDiffResponse(DiffusionRequest $drequest) {
$diff_info = $this->callConduitWithDiffusionRequest(
'diffusion.rawdiffquery',
array(
'commit' => $drequest->getCommit(),
'path' => $drequest->getPath(),
));
$file_phid = $diff_info['filePHID'];
$file = id(new PhabricatorFileQuery())
->setViewer($this->getViewer())
->withPHIDs(array($file_phid))
->executeOne();
if (!$file) {
throw new Exception(
pht(
'Failed to load file ("%s") returned by "%s".',
$file_phid,
'diffusion.rawdiffquery'));
}
return $file->getRedirectResponse();
}
private function renderAuditStatusView(
PhabricatorRepositoryCommit $commit,
array $audit_requests) {
assert_instances_of($audit_requests, 'PhabricatorRepositoryAuditRequest');
$viewer = $this->getViewer();
$view = new PHUIStatusListView();
foreach ($audit_requests as $request) {
$code = $request->getAuditStatus();
$item = new PHUIStatusItemView();
$item->setIcon(
PhabricatorAuditStatusConstants::getStatusIcon($code),
PhabricatorAuditStatusConstants::getStatusColor($code),
PhabricatorAuditStatusConstants::getStatusName($code));
$auditor_phid = $request->getAuditorPHID();
$target = $viewer->renderHandle($auditor_phid);
$item->setTarget($target);
if ($commit->hasAuditAuthority($viewer, $request)) {
$item->setHighlighted(true);
}
$view->addItem($item);
}
return $view;
}
private function linkBugtraq($corpus) {
$url = PhabricatorEnv::getEnvConfig('bugtraq.url');
if (!strlen($url)) {
return $corpus;
}
$regexes = PhabricatorEnv::getEnvConfig('bugtraq.logregex');
if (!$regexes) {
return $corpus;
}
$parser = id(new PhutilBugtraqParser())
->setBugtraqPattern("[[ {$url} | %BUGID% ]]")
->setBugtraqCaptureExpression(array_shift($regexes));
$select = array_shift($regexes);
if ($select) {
$parser->setBugtraqSelectExpression($select);
}
return $parser->processCorpus($corpus);
}
private function buildTableOfContents(
array $changesets,
$header,
$info_view) {
$drequest = $this->getDiffusionRequest();
$viewer = $this->getViewer();
$toc_view = id(new PHUIDiffTableOfContentsListView())
->setUser($viewer)
->setHeader($header)
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY);
if ($info_view) {
$toc_view->setInfoView($info_view);
}
// TODO: This is hacky, we just want access to the linkX() methods on
// DiffusionView.
$diffusion_view = id(new DiffusionEmptyResultView())
->setDiffusionRequest($drequest);
$have_owners = PhabricatorApplication::isClassInstalledForViewer(
'PhabricatorOwnersApplication',
$viewer);
if (!$changesets) {
$have_owners = false;
}
if ($have_owners) {
if ($viewer->getPHID()) {
$packages = id(new PhabricatorOwnersPackageQuery())
->setViewer($viewer)
->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE))
->withAuthorityPHIDs(array($viewer->getPHID()))
->execute();
$toc_view->setAuthorityPackages($packages);
}
$repository = $drequest->getRepository();
$repository_phid = $repository->getPHID();
$control_query = id(new PhabricatorOwnersPackageQuery())
->setViewer($viewer)
->withStatuses(array(PhabricatorOwnersPackage::STATUS_ACTIVE))
->withControl($repository_phid, mpull($changesets, 'getFilename'));
$control_query->execute();
}
foreach ($changesets as $changeset_id => $changeset) {
$path = $changeset->getFilename();
$anchor = $changeset->getAnchorName();
$history_link = $diffusion_view->linkHistory($path);
$browse_link = $diffusion_view->linkBrowse(
$path,
array(
'type' => $changeset->getFileType(),
));
$item = id(new PHUIDiffTableOfContentsItemView())
->setChangeset($changeset)
->setAnchor($anchor)
->setContext(
array(
$history_link,
' ',
$browse_link,
));
if ($have_owners) {
$packages = $control_query->getControllingPackagesForPath(
$repository_phid,
$changeset->getFilename());
$item->setPackages($packages);
}
$toc_view->addItem($item);
}
return $toc_view;
}
private function loadCommitState() {
$viewer = $this->getViewer();
$drequest = $this->getDiffusionRequest();
$repository = $drequest->getRepository();
$commit = $drequest->getCommit();
// TODO: We could use futures here and resolve these calls in parallel.
$exceptions = array();
try {
$parent_refs = $this->callConduitWithDiffusionRequest(
'diffusion.commitparentsquery',
array(
'commit' => $commit,
));
if ($parent_refs) {
$parents = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepository($repository)
->withIdentifiers($parent_refs)
->execute();
} else {
$parents = array();
}
$this->commitParents = $parents;
} catch (Exception $ex) {
$this->commitParents = false;
$exceptions[] = $ex;
}
$merge_limit = $this->getMergeDisplayLimit();
try {
if ($repository->isSVN()) {
$this->commitMerges = array();
} else {
$merges = $this->callConduitWithDiffusionRequest(
'diffusion.mergedcommitsquery',
array(
'commit' => $commit,
'limit' => $merge_limit + 1,
));
$this->commitMerges = DiffusionPathChange::newFromConduit($merges);
}
} catch (Exception $ex) {
$this->commitMerges = false;
$exceptions[] = $ex;
}
try {
if ($repository->isGit()) {
$refs = $this->callConduitWithDiffusionRequest(
'diffusion.refsquery',
array(
'commit' => $commit,
));
} else {
$refs = array();
}
$this->commitRefs = $refs;
} catch (Exception $ex) {
$this->commitRefs = false;
$exceptions[] = $ex;
}
if ($exceptions) {
$exists = $this->callConduitWithDiffusionRequest(
'diffusion.existsquery',
array(
'commit' => $commit,
));
if ($exists) {
$this->commitExists = true;
foreach ($exceptions as $exception) {
$this->commitErrors[] = $exception->getMessage();
}
} else {
$this->commitExists = false;
$this->commitErrors[] = pht(
'This commit no longer exists in the repository. It may have '.
'been part of a branch which was deleted.');
}
} else {
$this->commitExists = true;
$this->commitErrors = array();
}
}
private function getMergeDisplayLimit() {
return 50;
}
private function getCommitExists() {
if ($this->commitExists === null) {
$this->loadCommitState();
}
return $this->commitExists;
}
private function getCommitParents() {
if ($this->commitParents === null) {
$this->loadCommitState();
}
return $this->commitParents;
}
private function getCommitRefs() {
if ($this->commitRefs === null) {
$this->loadCommitState();
}
return $this->commitRefs;
}
private function getCommitMerges() {
if ($this->commitMerges === null) {
$this->loadCommitState();
}
return $this->commitMerges;
}
private function getCommitErrors() {
if ($this->commitErrors === null) {
$this->loadCommitState();
}
return $this->commitErrors;
}
}
diff --git a/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php b/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php
index 2459c8c559..b3baafbd06 100644
--- a/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php
+++ b/src/applications/diffusion/editor/DiffusionRepositoryEditEngine.php
@@ -1,455 +1,455 @@
<?php
final class DiffusionRepositoryEditEngine
extends PhabricatorEditEngine {
const ENGINECONST = 'diffusion.repository';
private $versionControlSystem;
public function setVersionControlSystem($version_control_system) {
$this->versionControlSystem = $version_control_system;
return $this;
}
public function getVersionControlSystem() {
return $this->versionControlSystem;
}
public function isEngineConfigurable() {
return false;
}
public function isDefaultQuickCreateEngine() {
return true;
}
public function getQuickCreateOrderVector() {
return id(new PhutilSortVector())->addInt(300);
}
public function getEngineName() {
return pht('Repositories');
}
public function getSummaryHeader() {
return pht('Edit Repositories');
}
public function getSummaryText() {
return pht('Creates and edits repositories.');
}
public function getEngineApplicationClass() {
return 'PhabricatorDiffusionApplication';
}
protected function newEditableObject() {
$viewer = $this->getViewer();
$repository = PhabricatorRepository::initializeNewRepository($viewer);
$repository->setDetail('newly-initialized', true);
$vcs = $this->getVersionControlSystem();
if ($vcs) {
$repository->setVersionControlSystem($vcs);
}
// Pick a random open service to allocate this repository on, if any exist.
// If there are no services, we aren't in cluster mode and will allocate
// locally. If there are services but none permit allocations, we fail.
// Eventually we can make this more flexible, but this rule is a reasonable
// starting point as we begin to deploy cluster services.
$services = id(new AlmanacServiceQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withServiceTypes(
array(
AlmanacClusterRepositoryServiceType::SERVICETYPE,
))
->needProperties(true)
->execute();
if ($services) {
// Filter out services which do not permit new allocations.
foreach ($services as $key => $possible_service) {
if ($possible_service->getAlmanacPropertyValue('closed')) {
unset($services[$key]);
}
}
if (!$services) {
throw new Exception(
pht(
'This install is configured in cluster mode, but all available '.
'repository cluster services are closed to new allocations. '.
'At least one service must be open to allow new allocations to '.
'take place.'));
}
shuffle($services);
$service = head($services);
$repository->setAlmanacServicePHID($service->getPHID());
}
return $repository;
}
protected function newObjectQuery() {
return new PhabricatorRepositoryQuery();
}
protected function getObjectCreateTitleText($object) {
return pht('Create Repository');
}
protected function getObjectCreateButtonText($object) {
return pht('Create Repository');
}
protected function getObjectEditTitleText($object) {
return pht('Edit Repository: %s', $object->getName());
}
protected function getObjectEditShortText($object) {
return $object->getDisplayName();
}
protected function getObjectCreateShortText() {
return pht('Create Repository');
}
protected function getObjectName() {
return pht('Repository');
}
protected function getObjectViewURI($object) {
return $object->getPathURI('manage/');
}
protected function getCreateNewObjectPolicy() {
return $this->getApplication()->getPolicy(
DiffusionCreateRepositoriesCapability::CAPABILITY);
}
protected function newPages($object) {
$panels = DiffusionRepositoryManagementPanel::getAllPanels();
$pages = array();
$uris = array();
foreach ($panels as $panel_key => $panel) {
$panel->setRepository($object);
$uris[$panel_key] = $panel->getPanelURI();
$page = $panel->newEditEnginePage();
if (!$page) {
continue;
}
$pages[] = $page;
}
$basics_key = DiffusionRepositoryBasicsManagementPanel::PANELKEY;
$basics_uri = $uris[$basics_key];
$more_pages = array(
id(new PhabricatorEditPage())
->setKey('encoding')
->setLabel(pht('Text Encoding'))
->setViewURI($basics_uri)
->setFieldKeys(
array(
'encoding',
)),
id(new PhabricatorEditPage())
->setKey('extensions')
->setLabel(pht('Extensions'))
->setIsDefault(true),
);
foreach ($more_pages as $page) {
$pages[] = $page;
}
return $pages;
}
protected function willConfigureFields($object, array $fields) {
// Change the default field order so related fields are adjacent.
$after = array(
'policy.edit' => array('policy.push'),
);
$result = array();
foreach ($fields as $key => $value) {
$result[$key] = $value;
if (!isset($after[$key])) {
continue;
}
foreach ($after[$key] as $next_key) {
if (!isset($fields[$next_key])) {
continue;
}
unset($result[$next_key]);
$result[$next_key] = $fields[$next_key];
unset($fields[$next_key]);
}
}
return $result;
}
protected function buildCustomEditFields($object) {
$viewer = $this->getViewer();
$policies = id(new PhabricatorPolicyQuery())
->setViewer($viewer)
->setObject($object)
->execute();
$track_value = $object->getDetail('branch-filter', array());
$track_value = array_keys($track_value);
$autoclose_value = $object->getDetail('close-commits-filter', array());
$autoclose_value = array_keys($autoclose_value);
$automation_instructions = pht(
"Configure **Repository Automation** to allow Phabricator to ".
"write to this repository.".
"\n\n".
"IMPORTANT: This feature is new, experimental, and not supported. ".
"Use it at your own risk.");
$staging_instructions = pht(
"To make it easier to run integration tests and builds on code ".
"under review, you can configure a **Staging Area**. When `arc` ".
"creates a diff, it will push a copy of the changes to the ".
"configured staging area with a corresponding tag.".
"\n\n".
"IMPORTANT: This feature is new, experimental, and not supported. ".
"Use it at your own risk.");
$subpath_instructions = pht(
'If you want to import only part of a repository, like `trunk/`, '.
'you can set a path in **Import Only**. Phabricator will ignore '.
'commits which do not affect this path.');
return array(
id(new PhabricatorSelectEditField())
->setKey('vcs')
->setLabel(pht('Version Control System'))
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_VCS)
->setIsConduitOnly(true)
->setIsCopyable(true)
->setOptions(PhabricatorRepositoryType::getAllRepositoryTypes())
->setDescription(pht('Underlying repository version control system.'))
->setConduitDescription(
pht(
'Choose which version control system to use when creating a '.
'repository.'))
->setConduitTypeDescription(pht('Version control system selection.'))
->setValue($object->getVersionControlSystem()),
id(new PhabricatorTextEditField())
->setKey('name')
->setLabel(pht('Name'))
->setIsRequired(true)
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_NAME)
->setDescription(pht('The repository name.'))
->setConduitDescription(pht('Rename the repository.'))
->setConduitTypeDescription(pht('New repository name.'))
->setValue($object->getName()),
id(new PhabricatorTextEditField())
->setKey('callsign')
->setLabel(pht('Callsign'))
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_CALLSIGN)
->setDescription(pht('The repository callsign.'))
->setConduitDescription(pht('Change the repository callsign.'))
->setConduitTypeDescription(pht('New repository callsign.'))
->setValue($object->getCallsign()),
id(new PhabricatorTextEditField())
->setKey('shortName')
->setLabel(pht('Short Name'))
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_SLUG)
->setDescription(pht('Short, unique repository name.'))
->setConduitDescription(pht('Change the repository short name.'))
->setConduitTypeDescription(pht('New short name for the repository.'))
->setValue($object->getRepositorySlug()),
id(new PhabricatorRemarkupEditField())
->setKey('description')
->setLabel(pht('Description'))
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_DESCRIPTION)
->setDescription(pht('Repository description.'))
->setConduitDescription(pht('Change the repository description.'))
->setConduitTypeDescription(pht('New repository description.'))
->setValue($object->getDetail('description')),
id(new PhabricatorTextEditField())
->setKey('encoding')
->setLabel(pht('Text Encoding'))
->setIsCopyable(true)
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_ENCODING)
->setDescription(pht('Default text encoding.'))
->setConduitDescription(pht('Change the default text encoding.'))
->setConduitTypeDescription(pht('New text encoding.'))
->setValue($object->getDetail('encoding')),
id(new PhabricatorBoolEditField())
->setKey('allowDangerousChanges')
->setLabel(pht('Allow Dangerous Changes'))
->setIsCopyable(true)
->setIsConduitOnly(true)
->setOptions(
pht('Prevent Dangerous Changes'),
pht('Allow Dangerous Changes'))
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_DANGEROUS)
->setDescription(pht('Permit dangerous changes to be made.'))
->setConduitDescription(pht('Allow or prevent dangerous changes.'))
->setConduitTypeDescription(pht('New protection setting.'))
->setValue($object->shouldAllowDangerousChanges()),
id(new PhabricatorSelectEditField())
->setKey('status')
->setLabel(pht('Status'))
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_ACTIVATE)
->setIsConduitOnly(true)
->setOptions(PhabricatorRepository::getStatusNameMap())
->setDescription(pht('Active or inactive status.'))
->setConduitDescription(pht('Active or deactivate the repository.'))
->setConduitTypeDescription(pht('New repository status.'))
->setValue($object->getStatus()),
id(new PhabricatorTextEditField())
->setKey('defaultBranch')
->setLabel(pht('Default Branch'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_DEFAULT_BRANCH)
->setIsCopyable(true)
->setDescription(pht('Default branch name.'))
->setConduitDescription(pht('Set the default branch name.'))
->setConduitTypeDescription(pht('New default branch name.'))
->setValue($object->getDetail('default-branch')),
id(new PhabricatorTextAreaEditField())
->setIsStringList(true)
->setKey('trackOnly')
->setLabel(pht('Track Only'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_TRACK_ONLY)
->setIsCopyable(true)
->setDescription(pht('Track only these branches.'))
->setConduitDescription(pht('Set the tracked branches.'))
- ->setConduitTypeDescription(pht('New tracked branchs.'))
+ ->setConduitTypeDescription(pht('New tracked branches.'))
->setValue($track_value),
id(new PhabricatorTextAreaEditField())
->setIsStringList(true)
->setKey('autocloseOnly')
->setLabel(pht('Autoclose Only'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_AUTOCLOSE_ONLY)
->setIsCopyable(true)
->setDescription(pht('Autoclose commits on only these branches.'))
->setConduitDescription(pht('Set the autoclose branches.'))
- ->setConduitTypeDescription(pht('New default tracked branchs.'))
+ ->setConduitTypeDescription(pht('New default tracked branches.'))
->setValue($autoclose_value),
id(new PhabricatorTextEditField())
->setKey('importOnly')
->setLabel(pht('Import Only'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_SVN_SUBPATH)
->setIsCopyable(true)
->setDescription(pht('Subpath to selectively import.'))
->setConduitDescription(pht('Set the subpath to import.'))
->setConduitTypeDescription(pht('New subpath to import.'))
->setValue($object->getDetail('svn-subpath'))
->setControlInstructions($subpath_instructions),
id(new PhabricatorTextEditField())
->setKey('stagingAreaURI')
->setLabel(pht('Staging Area URI'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_STAGING_URI)
->setIsCopyable(true)
->setDescription(pht('Staging area URI.'))
->setConduitDescription(pht('Set the staging area URI.'))
->setConduitTypeDescription(pht('New staging area URI.'))
->setValue($object->getStagingURI())
->setControlInstructions($staging_instructions),
id(new PhabricatorDatasourceEditField())
->setKey('automationBlueprintPHIDs')
->setLabel(pht('Use Blueprints'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_AUTOMATION_BLUEPRINTS)
->setIsCopyable(true)
->setDatasource(new DrydockBlueprintDatasource())
->setDescription(pht('Automation blueprints.'))
->setConduitDescription(pht('Change automation blueprints.'))
->setConduitTypeDescription(pht('New blueprint PHIDs.'))
->setValue($object->getAutomationBlueprintPHIDs())
->setControlInstructions($automation_instructions),
id(new PhabricatorStringListEditField())
->setKey('symbolLanguages')
->setLabel(pht('Languages'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_SYMBOLS_LANGUAGE)
->setIsCopyable(true)
->setDescription(
pht('Languages which define symbols in this repository.'))
->setConduitDescription(
pht('Change symbol languages for this repository.'))
->setConduitTypeDescription(
- pht('New symbol langauges.'))
+ pht('New symbol languages.'))
->setValue($object->getSymbolLanguages()),
id(new PhabricatorDatasourceEditField())
->setKey('symbolRepositoryPHIDs')
->setLabel(pht('Uses Symbols From'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_SYMBOLS_SOURCES)
->setIsCopyable(true)
->setDatasource(new DiffusionRepositoryDatasource())
->setDescription(pht('Repositories to link symbols from.'))
->setConduitDescription(pht('Change symbol source repositories.'))
->setConduitTypeDescription(pht('New symbol repositories.'))
->setValue($object->getSymbolSources()),
id(new PhabricatorBoolEditField())
->setKey('publish')
->setLabel(pht('Publish/Notify'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_NOTIFY)
->setIsCopyable(true)
->setOptions(
pht('Disable Notifications, Feed, and Herald'),
pht('Enable Notifications, Feed, and Herald'))
->setDescription(pht('Configure how changes are published.'))
->setConduitDescription(pht('Change publishing options.'))
->setConduitTypeDescription(pht('New notification setting.'))
->setValue(!$object->getDetail('herald-disabled')),
id(new PhabricatorBoolEditField())
->setKey('autoclose')
->setLabel(pht('Autoclose'))
->setTransactionType(
PhabricatorRepositoryTransaction::TYPE_AUTOCLOSE)
->setIsCopyable(true)
->setOptions(
pht('Disable Autoclose'),
pht('Enable Autoclose'))
->setDescription(pht('Stop or resume autoclosing in this repository.'))
->setConduitDescription(pht('Change autoclose setting.'))
->setConduitTypeDescription(pht('New autoclose setting.'))
->setValue(!$object->getDetail('disable-autoclose')),
id(new PhabricatorPolicyEditField())
->setKey('policy.push')
->setLabel(pht('Push Policy'))
->setAliases(array('push'))
->setIsCopyable(true)
->setCapability(DiffusionPushCapability::CAPABILITY)
->setPolicies($policies)
->setTransactionType(PhabricatorRepositoryTransaction::TYPE_PUSH_POLICY)
->setDescription(
pht('Controls who can push changes to the repository.'))
->setConduitDescription(
pht('Change the push policy of the repository.'))
->setConduitTypeDescription(pht('New policy PHID or constant.'))
->setValue($object->getPolicy(DiffusionPushCapability::CAPABILITY)),
);
}
}
diff --git a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php
index 21df37d87d..7fe45834b3 100644
--- a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php
+++ b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php
@@ -1,1264 +1,1264 @@
<?php
/**
* @task config Configuring the Hook Engine
* @task hook Hook Execution
* @task git Git Hooks
* @task hg Mercurial Hooks
* @task svn Subversion Hooks
* @task internal Internals
*/
final class DiffusionCommitHookEngine extends Phobject {
const ENV_REPOSITORY = 'PHABRICATOR_REPOSITORY';
const ENV_USER = 'PHABRICATOR_USER';
const ENV_REMOTE_ADDRESS = 'PHABRICATOR_REMOTE_ADDRESS';
const ENV_REMOTE_PROTOCOL = 'PHABRICATOR_REMOTE_PROTOCOL';
const EMPTY_HASH = '0000000000000000000000000000000000000000';
private $viewer;
private $repository;
private $stdin;
private $originalArgv;
private $subversionTransaction;
private $subversionRepository;
private $remoteAddress;
private $remoteProtocol;
private $transactionKey;
private $mercurialHook;
private $mercurialCommits = array();
private $gitCommits = array();
private $heraldViewerProjects;
private $rejectCode = PhabricatorRepositoryPushLog::REJECT_BROKEN;
private $rejectDetails;
private $emailPHIDs = array();
/* -( Config )------------------------------------------------------------- */
public function setRemoteProtocol($remote_protocol) {
$this->remoteProtocol = $remote_protocol;
return $this;
}
public function getRemoteProtocol() {
return $this->remoteProtocol;
}
public function setRemoteAddress($remote_address) {
$this->remoteAddress = $remote_address;
return $this;
}
public function getRemoteAddress() {
return $this->remoteAddress;
}
public function setSubversionTransactionInfo($transaction, $repository) {
$this->subversionTransaction = $transaction;
$this->subversionRepository = $repository;
return $this;
}
public function setStdin($stdin) {
$this->stdin = $stdin;
return $this;
}
public function getStdin() {
return $this->stdin;
}
public function setOriginalArgv(array $original_argv) {
$this->originalArgv = $original_argv;
return $this;
}
public function getOriginalArgv() {
return $this->originalArgv;
}
public function setRepository(PhabricatorRepository $repository) {
$this->repository = $repository;
return $this;
}
public function getRepository() {
return $this->repository;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function setMercurialHook($mercurial_hook) {
$this->mercurialHook = $mercurial_hook;
return $this;
}
public function getMercurialHook() {
return $this->mercurialHook;
}
/* -( Hook Execution )----------------------------------------------------- */
public function execute() {
$ref_updates = $this->findRefUpdates();
$all_updates = $ref_updates;
$caught = null;
try {
try {
$this->rejectDangerousChanges($ref_updates);
} catch (DiffusionCommitHookRejectException $ex) {
// If we're rejecting dangerous changes, flag everything that we've
// seen as rejected so it's clear that none of it was accepted.
$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_DANGEROUS;
throw $ex;
}
$this->applyHeraldRefRules($ref_updates, $all_updates);
$content_updates = $this->findContentUpdates($ref_updates);
$all_updates = array_merge($all_updates, $content_updates);
$this->applyHeraldContentRules($content_updates, $all_updates);
// Run custom scripts in `hook.d/` directories.
$this->applyCustomHooks($all_updates);
// If we make it this far, we're accepting these changes. Mark all the
// logs as accepted.
$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ACCEPT;
} catch (Exception $ex) {
// We'll throw this again in a minute, but we want to save all the logs
// first.
$caught = $ex;
}
// Save all the logs no matter what the outcome was.
$event = $this->newPushEvent();
$event->setRejectCode($this->rejectCode);
$event->setRejectDetails($this->rejectDetails);
$event->openTransaction();
$event->save();
foreach ($all_updates as $update) {
$update->setPushEventPHID($event->getPHID());
$update->save();
}
$event->saveTransaction();
if ($caught) {
throw $caught;
}
// If this went through cleanly, detect pushes which are actually imports
// of an existing repository rather than an addition of new commits. If
// this push is importing a bunch of stuff, set the importing flag on
// the repository. It will be cleared once we fully process everything.
if ($this->isInitialImport($all_updates)) {
$repository = $this->getRepository();
$repository->markImporting();
}
if ($this->emailPHIDs) {
// If Herald rules triggered email to users, queue a worker to send the
// mail. We do this out-of-process so that we block pushes as briefly
// as possible.
// (We do need to pull some commit info here because the commit objects
// may not exist yet when this worker runs, which could be immediately.)
PhabricatorWorker::scheduleTask(
'PhabricatorRepositoryPushMailWorker',
array(
'eventPHID' => $event->getPHID(),
'emailPHIDs' => array_values($this->emailPHIDs),
'info' => $this->loadCommitInfoForWorker($all_updates),
),
array(
'priority' => PhabricatorWorker::PRIORITY_ALERTS,
));
}
return 0;
}
private function findRefUpdates() {
$type = $this->getRepository()->getVersionControlSystem();
switch ($type) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
return $this->findGitRefUpdates();
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
return $this->findMercurialRefUpdates();
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
return $this->findSubversionRefUpdates();
default:
throw new Exception(pht('Unsupported repository type "%s"!', $type));
}
}
private function rejectDangerousChanges(array $ref_updates) {
assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
$repository = $this->getRepository();
if ($repository->shouldAllowDangerousChanges()) {
return;
}
$flag_dangerous = PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
foreach ($ref_updates as $ref_update) {
if (!$ref_update->hasChangeFlags($flag_dangerous)) {
// This is not a dangerous change.
continue;
}
// We either have a branch deletion or a non fast-forward branch update.
// Format a message and reject the push.
$message = pht(
"DANGEROUS CHANGE: %s\n".
"Dangerous change protection is enabled for this repository.\n".
"Edit the repository configuration before making dangerous changes.",
$ref_update->getDangerousChangeDescription());
throw new DiffusionCommitHookRejectException($message);
}
}
private function findContentUpdates(array $ref_updates) {
assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
$type = $this->getRepository()->getVersionControlSystem();
switch ($type) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
return $this->findGitContentUpdates($ref_updates);
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
return $this->findMercurialContentUpdates($ref_updates);
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
return $this->findSubversionContentUpdates($ref_updates);
default:
throw new Exception(pht('Unsupported repository type "%s"!', $type));
}
}
/* -( Herald )------------------------------------------------------------- */
private function applyHeraldRefRules(
array $ref_updates,
array $all_updates) {
$this->applyHeraldRules(
$ref_updates,
new HeraldPreCommitRefAdapter(),
$all_updates);
}
private function applyHeraldContentRules(
array $content_updates,
array $all_updates) {
$this->applyHeraldRules(
$content_updates,
new HeraldPreCommitContentAdapter(),
$all_updates);
}
private function applyHeraldRules(
array $updates,
HeraldAdapter $adapter_template,
array $all_updates) {
if (!$updates) {
return;
}
$adapter_template->setHookEngine($this);
$engine = new HeraldEngine();
$rules = null;
$blocking_effect = null;
$blocked_update = null;
$blocking_xscript = null;
foreach ($updates as $update) {
$adapter = id(clone $adapter_template)
->setPushLog($update);
if ($rules === null) {
$rules = $engine->loadRulesForAdapter($adapter);
}
$effects = $engine->applyRules($rules, $adapter);
$engine->applyEffects($effects, $adapter, $rules);
$xscript = $engine->getTranscript();
// Store any PHIDs we want to send email to for later.
foreach ($adapter->getEmailPHIDs() as $email_phid) {
$this->emailPHIDs[$email_phid] = $email_phid;
}
$block_action = DiffusionBlockHeraldAction::ACTIONCONST;
if ($blocking_effect === null) {
foreach ($effects as $effect) {
if ($effect->getAction() == $block_action) {
$blocking_effect = $effect;
$blocked_update = $update;
$blocking_xscript = $xscript;
break;
}
}
}
}
if ($blocking_effect) {
$rule = $blocking_effect->getRule();
$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_HERALD;
$this->rejectDetails = $rule->getPHID();
$message = $blocking_effect->getTarget();
if (!strlen($message)) {
$message = pht('(None.)');
}
$blocked_ref_name = coalesce(
$blocked_update->getRefName(),
$blocked_update->getRefNewShort());
$blocked_name = $blocked_update->getRefType().'/'.$blocked_ref_name;
throw new DiffusionCommitHookRejectException(
pht(
"This push was rejected by Herald push rule %s.\n".
" Change: %s\n".
" Rule: %s\n".
" Reason: %s\n".
"Transcript: %s",
$rule->getMonogram(),
$blocked_name,
$rule->getName(),
$message,
PhabricatorEnv::getProductionURI(
'/herald/transcript/'.$blocking_xscript->getID().'/')));
}
}
public function loadViewerProjectPHIDsForHerald() {
// This just caches the viewer's projects so we don't need to load them
// over and over again when applying Herald rules.
if ($this->heraldViewerProjects === null) {
$this->heraldViewerProjects = id(new PhabricatorProjectQuery())
->setViewer($this->getViewer())
->withMemberPHIDs(array($this->getViewer()->getPHID()))
->execute();
}
return mpull($this->heraldViewerProjects, 'getPHID');
}
/* -( Git )---------------------------------------------------------------- */
private function findGitRefUpdates() {
$ref_updates = array();
// First, parse stdin, which lists all the ref changes. The input looks
// like this:
//
// <old hash> <new hash> <ref>
$stdin = $this->getStdin();
$lines = phutil_split_lines($stdin, $retain_endings = false);
foreach ($lines as $line) {
$parts = explode(' ', $line, 3);
if (count($parts) != 3) {
throw new Exception(pht('Expected "old new ref", got "%s".', $line));
}
$ref_old = $parts[0];
$ref_new = $parts[1];
$ref_raw = $parts[2];
if (preg_match('(^refs/heads/)', $ref_raw)) {
$ref_type = PhabricatorRepositoryPushLog::REFTYPE_BRANCH;
$ref_raw = substr($ref_raw, strlen('refs/heads/'));
} else if (preg_match('(^refs/tags/)', $ref_raw)) {
$ref_type = PhabricatorRepositoryPushLog::REFTYPE_TAG;
$ref_raw = substr($ref_raw, strlen('refs/tags/'));
} else {
throw new Exception(
pht(
"Unable to identify the reftype of '%s'. Rejecting push.",
$ref_raw));
}
$ref_update = $this->newPushLog()
->setRefType($ref_type)
->setRefName($ref_raw)
->setRefOld($ref_old)
->setRefNew($ref_new);
$ref_updates[] = $ref_update;
}
$this->findGitMergeBases($ref_updates);
$this->findGitChangeFlags($ref_updates);
return $ref_updates;
}
private function findGitMergeBases(array $ref_updates) {
assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
$futures = array();
foreach ($ref_updates as $key => $ref_update) {
// If the old hash is "00000...", the ref is being created (either a new
// branch, or a new tag). If the new hash is "00000...", the ref is being
// deleted. If both are nonempty, the ref is being updated. For updates,
// we'll figure out the `merge-base` of the old and new objects here. This
// lets us reject non-FF changes cheaply; later, we'll figure out exactly
// which commits are new.
$ref_old = $ref_update->getRefOld();
$ref_new = $ref_update->getRefNew();
if (($ref_old === self::EMPTY_HASH) ||
($ref_new === self::EMPTY_HASH)) {
continue;
}
$futures[$key] = $this->getRepository()->getLocalCommandFuture(
'merge-base %s %s',
$ref_old,
$ref_new);
}
$futures = id(new FutureIterator($futures))
->limit(8);
foreach ($futures as $key => $future) {
// If 'old' and 'new' have no common ancestors (for example, a force push
// which completely rewrites a ref), `git merge-base` will exit with
// an error and no output. It would be nice to find a positive test
// for this instead, but I couldn't immediately come up with one. See
// T4224. Assume this means there are no ancestors.
list($err, $stdout) = $future->resolve();
if ($err) {
$merge_base = null;
} else {
$merge_base = rtrim($stdout, "\n");
}
$ref_update = $ref_updates[$key];
$ref_update->setMergeBase($merge_base);
}
return $ref_updates;
}
private function findGitChangeFlags(array $ref_updates) {
assert_instances_of($ref_updates, 'PhabricatorRepositoryPushLog');
foreach ($ref_updates as $key => $ref_update) {
$ref_old = $ref_update->getRefOld();
$ref_new = $ref_update->getRefNew();
$ref_type = $ref_update->getRefType();
$ref_flags = 0;
$dangerous = null;
if (($ref_old === self::EMPTY_HASH) && ($ref_new === self::EMPTY_HASH)) {
// This happens if you try to delete a tag or branch which does not
// exist by pushing directly to the ref. Git will warn about it but
// allow it. Just call it a delete, without flagging it as dangerous.
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
} else if ($ref_old === self::EMPTY_HASH) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
} else if ($ref_new === self::EMPTY_HASH) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
$dangerous = pht(
"The change you're attempting to push deletes the branch '%s'.",
$ref_update->getRefName());
}
} else {
$merge_base = $ref_update->getMergeBase();
if ($merge_base == $ref_old) {
// This is a fast-forward update to an existing branch.
// These are safe.
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
} else {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE;
// For now, we don't consider deleting or moving tags to be a
// "dangerous" update. It's way harder to get wrong and should be easy
// to recover from once we have better logging. Only add the dangerous
// flag if this ref is a branch.
if ($ref_type == PhabricatorRepositoryPushLog::REFTYPE_BRANCH) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
$dangerous = pht(
"The change you're attempting to push updates the branch '%s' ".
"from '%s' to '%s', but this is not a fast-forward. Pushes ".
"which rewrite published branch history are dangerous.",
$ref_update->getRefName(),
$ref_update->getRefOldShort(),
$ref_update->getRefNewShort());
}
}
}
$ref_update->setChangeFlags($ref_flags);
if ($dangerous !== null) {
$ref_update->attachDangerousChangeDescription($dangerous);
}
}
return $ref_updates;
}
private function findGitContentUpdates(array $ref_updates) {
$flag_delete = PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
$futures = array();
foreach ($ref_updates as $key => $ref_update) {
if ($ref_update->hasChangeFlags($flag_delete)) {
// Deleting a branch or tag can never create any new commits.
continue;
}
// NOTE: This piece of magic finds all new commits, by walking backward
// from the new value to the value of *any* existing ref in the
// repository. Particularly, this will cover the cases of a new branch, a
// completely moved tag, etc.
$futures[$key] = $this->getRepository()->getLocalCommandFuture(
'log --format=%s %s --not --all',
'%H',
$ref_update->getRefNew());
}
$content_updates = array();
$futures = id(new FutureIterator($futures))
->limit(8);
foreach ($futures as $key => $future) {
list($stdout) = $future->resolvex();
if (!strlen(trim($stdout))) {
// This change doesn't have any new commits. One common case of this
// is creating a new tag which points at an existing commit.
continue;
}
$commits = phutil_split_lines($stdout, $retain_newlines = false);
// If we're looking at a branch, mark all of the new commits as on that
// branch. It's only possible for these commits to be on updated branches,
// since any other branch heads are necessarily behind them.
$branch_name = null;
$ref_update = $ref_updates[$key];
$type_branch = PhabricatorRepositoryPushLog::REFTYPE_BRANCH;
if ($ref_update->getRefType() == $type_branch) {
$branch_name = $ref_update->getRefName();
}
foreach ($commits as $commit) {
if ($branch_name) {
$this->gitCommits[$commit][] = $branch_name;
}
$content_updates[$commit] = $this->newPushLog()
->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)
->setRefNew($commit)
->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD);
}
}
return $content_updates;
}
/* -( Custom )------------------------------------------------------------- */
private function applyCustomHooks(array $updates) {
$args = $this->getOriginalArgv();
$stdin = $this->getStdin();
$console = PhutilConsole::getConsole();
$env = array(
self::ENV_REPOSITORY => $this->getRepository()->getPHID(),
self::ENV_USER => $this->getViewer()->getUsername(),
self::ENV_REMOTE_PROTOCOL => $this->getRemoteProtocol(),
self::ENV_REMOTE_ADDRESS => $this->getRemoteAddress(),
);
$repository = $this->getRepository();
$env += $repository->getPassthroughEnvironmentalVariables();
$directories = $repository->getHookDirectories();
foreach ($directories as $directory) {
$hooks = $this->getExecutablesInDirectory($directory);
sort($hooks);
foreach ($hooks as $hook) {
// NOTE: We're explicitly running the hooks in sequential order to
// make this more predictable.
$future = id(new ExecFuture('%s %Ls', $hook, $args))
->setEnv($env, $wipe_process_env = false)
->write($stdin);
list($err, $stdout, $stderr) = $future->resolve();
if (!$err) {
// This hook ran OK, but echo its output in case there was something
// informative.
$console->writeOut('%s', $stdout);
$console->writeErr('%s', $stderr);
continue;
}
$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_EXTERNAL;
$this->rejectDetails = basename($hook);
throw new DiffusionCommitHookRejectException(
pht(
"This push was rejected by custom hook script '%s':\n\n%s%s",
basename($hook),
$stdout,
$stderr));
}
}
}
private function getExecutablesInDirectory($directory) {
$executables = array();
if (!Filesystem::pathExists($directory)) {
return $executables;
}
foreach (Filesystem::listDirectory($directory) as $path) {
$full_path = $directory.DIRECTORY_SEPARATOR.$path;
if (!is_executable($full_path)) {
// Don't include non-executable files.
continue;
}
if (basename($full_path) == 'README') {
// Don't include README, even if it is marked as executable. It almost
// certainly got caught in the crossfire of a sweeping `chmod`, since
// users do this with some frequency.
continue;
}
$executables[] = $full_path;
}
return $executables;
}
/* -( Mercurial )---------------------------------------------------------- */
private function findMercurialRefUpdates() {
$hook = $this->getMercurialHook();
switch ($hook) {
case 'pretxnchangegroup':
return $this->findMercurialChangegroupRefUpdates();
case 'prepushkey':
return $this->findMercurialPushKeyRefUpdates();
default:
throw new Exception(pht('Unrecognized hook "%s"!', $hook));
}
}
private function findMercurialChangegroupRefUpdates() {
$hg_node = getenv('HG_NODE');
if (!$hg_node) {
throw new Exception(
pht(
'Expected %s in environment!',
'HG_NODE'));
}
// NOTE: We need to make sure this is passed to subprocesses, or they won't
// be able to see new commits. Mercurial uses this as a marker to determine
// whether the pending changes are visible or not.
$_ENV['HG_PENDING'] = getenv('HG_PENDING');
$repository = $this->getRepository();
$futures = array();
foreach (array('old', 'new') as $key) {
$futures[$key] = $repository->getLocalCommandFuture(
'heads --template %s',
'{node}\1{branch}\2');
}
// Wipe HG_PENDING out of the old environment so we see the pre-commit
// state of the repository.
$futures['old']->updateEnv('HG_PENDING', null);
$futures['commits'] = $repository->getLocalCommandFuture(
'log --rev %s --template %s',
hgsprintf('%s:%s', $hg_node, 'tip'),
'{node}\1{branch}\2');
// Resolve all of the futures now. We don't need the 'commits' future yet,
// but it simplifies the logic to just get it out of the way.
foreach (new FutureIterator($futures) as $future) {
$future->resolve();
}
list($commit_raw) = $futures['commits']->resolvex();
$commit_map = $this->parseMercurialCommits($commit_raw);
$this->mercurialCommits = $commit_map;
// NOTE: `hg heads` exits with an error code and no output if the repository
// has no heads. Most commonly this happens on a new repository. We know
// we can run `hg` successfully since the `hg log` above didn't error, so
// just ignore the error code.
list($err, $old_raw) = $futures['old']->resolve();
$old_refs = $this->parseMercurialHeads($old_raw);
list($err, $new_raw) = $futures['new']->resolve();
$new_refs = $this->parseMercurialHeads($new_raw);
$all_refs = array_keys($old_refs + $new_refs);
$ref_updates = array();
foreach ($all_refs as $ref) {
$old_heads = idx($old_refs, $ref, array());
$new_heads = idx($new_refs, $ref, array());
sort($old_heads);
sort($new_heads);
if (!$old_heads && !$new_heads) {
// This should never be possible, as it makes no sense. Explode.
throw new Exception(
pht(
'Mercurial repository has no new or old heads for branch "%s" '.
'after push. This makes no sense; rejecting change.',
$ref));
}
if ($old_heads === $new_heads) {
// No changes to this branch, so skip it.
continue;
}
$stray_heads = array();
$head_map = array();
if ($old_heads && !$new_heads) {
// This is a branch deletion with "--close-branch".
foreach ($old_heads as $old_head) {
$head_map[$old_head] = array(self::EMPTY_HASH);
}
} else if (count($old_heads) > 1) {
// HORRIBLE: In Mercurial, branches can have multiple heads. If the
// old branch had multiple heads, we need to figure out which new
// heads descend from which old heads, so we can tell whether you're
// actively creating new heads (dangerous) or just working in a
// repository that's already full of garbage (strongly discouraged but
// not as inherently dangerous). These cases should be very uncommon.
// NOTE: We're only looking for heads on the same branch. The old
// tip of the branch may be the branchpoint for other branches, but that
// is OK.
$dfutures = array();
foreach ($old_heads as $old_head) {
$dfutures[$old_head] = $repository->getLocalCommandFuture(
'log --branch %s --rev %s --template %s',
$ref,
hgsprintf('(descendants(%s) and head())', $old_head),
'{node}\1');
}
foreach (new FutureIterator($dfutures) as $future_head => $dfuture) {
list($stdout) = $dfuture->resolvex();
$descendant_heads = array_filter(explode("\1", $stdout));
if ($descendant_heads) {
// This old head has at least one descendant in the push.
$head_map[$future_head] = $descendant_heads;
} else {
// This old head has no descendants, so it is being deleted.
$head_map[$future_head] = array(self::EMPTY_HASH);
}
}
// Now, find all the new stray heads this push creates, if any. These
// are new heads which do not descend from the old heads.
$seen = array_fuse(array_mergev($head_map));
foreach ($new_heads as $new_head) {
if ($new_head === self::EMPTY_HASH) {
// If a branch head is being deleted, don't insert it as an add.
continue;
}
if (empty($seen[$new_head])) {
$head_map[self::EMPTY_HASH][] = $new_head;
}
}
} else if ($old_heads) {
$head_map[head($old_heads)] = $new_heads;
} else {
$head_map[self::EMPTY_HASH] = $new_heads;
}
foreach ($head_map as $old_head => $child_heads) {
foreach ($child_heads as $new_head) {
if ($new_head === $old_head) {
continue;
}
$ref_flags = 0;
$dangerous = null;
if ($old_head == self::EMPTY_HASH) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
} else {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
}
$deletes_existing_head = ($new_head == self::EMPTY_HASH);
$splits_existing_head = (count($child_heads) > 1);
$creates_duplicate_head = ($old_head == self::EMPTY_HASH) &&
(count($head_map) > 1);
if ($splits_existing_head || $creates_duplicate_head) {
$readable_child_heads = array();
foreach ($child_heads as $child_head) {
$readable_child_heads[] = substr($child_head, 0, 12);
}
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DANGEROUS;
if ($splits_existing_head) {
// We're splitting an existing head into two or more heads.
// This is dangerous, and a super bad idea. Note that we're only
// raising this if you're actively splitting a branch head. If a
// head split in the past, we don't consider appends to it
// to be dangerous.
$dangerous = pht(
"The change you're attempting to push splits the head of ".
"branch '%s' into multiple heads: %s. This is inadvisable ".
"and dangerous.",
$ref,
implode(', ', $readable_child_heads));
} else {
// We're adding a second (or more) head to a branch. The new
// head is not a descendant of any old head.
$dangerous = pht(
"The change you're attempting to push creates new, divergent ".
"heads for the branch '%s': %s. This is inadvisable and ".
"dangerous.",
$ref,
implode(', ', $readable_child_heads));
}
}
if ($deletes_existing_head) {
// TODO: Somewhere in here we should be setting CHANGEFLAG_REWRITE
// if we are also creating at least one other head to replace
// this one.
// NOTE: In Git, this is a dangerous change, but it is not dangerous
// in Mercurial. Mercurial branches are version controlled, and
// Mercurial does not prompt you for any special flags when pushing
// a `--close-branch` commit by default.
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
}
$ref_update = $this->newPushLog()
->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BRANCH)
->setRefName($ref)
->setRefOld($old_head)
->setRefNew($new_head)
->setChangeFlags($ref_flags);
if ($dangerous !== null) {
$ref_update->attachDangerousChangeDescription($dangerous);
}
$ref_updates[] = $ref_update;
}
}
}
return $ref_updates;
}
private function findMercurialPushKeyRefUpdates() {
$key_namespace = getenv('HG_NAMESPACE');
if ($key_namespace === 'phases') {
// Mercurial changes commit phases as part of normal push operations. We
// just ignore these, as they don't seem to represent anything
// interesting.
return array();
}
$key_name = getenv('HG_KEY');
$key_old = getenv('HG_OLD');
if (!strlen($key_old)) {
$key_old = null;
}
$key_new = getenv('HG_NEW');
if (!strlen($key_new)) {
$key_new = null;
}
if ($key_namespace !== 'bookmarks') {
throw new Exception(
pht(
"Unknown Mercurial key namespace '%s', with key '%s' (%s -> %s). ".
"Rejecting push.",
$key_namespace,
$key_name,
coalesce($key_old, pht('null')),
coalesce($key_new, pht('null'))));
}
if ($key_old === $key_new) {
// We get a callback when the bookmark doesn't change. Just ignore this,
// as it's a no-op.
return array();
}
$ref_flags = 0;
$merge_base = null;
if ($key_old === null) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
} else if ($key_new === null) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_DELETE;
} else {
list($merge_base_raw) = $this->getRepository()->execxLocalCommand(
'log --template %s --rev %s',
'{node}',
hgsprintf('ancestor(%s, %s)', $key_old, $key_new));
if (strlen(trim($merge_base_raw))) {
$merge_base = trim($merge_base_raw);
}
if ($merge_base && ($merge_base === $key_old)) {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
} else {
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_REWRITE;
}
}
$ref_update = $this->newPushLog()
->setRefType(PhabricatorRepositoryPushLog::REFTYPE_BOOKMARK)
->setRefName($key_name)
->setRefOld(coalesce($key_old, self::EMPTY_HASH))
->setRefNew(coalesce($key_new, self::EMPTY_HASH))
->setChangeFlags($ref_flags);
return array($ref_update);
}
private function findMercurialContentUpdates(array $ref_updates) {
$content_updates = array();
foreach ($this->mercurialCommits as $commit => $branches) {
$content_updates[$commit] = $this->newPushLog()
->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)
->setRefNew($commit)
->setChangeFlags(PhabricatorRepositoryPushLog::CHANGEFLAG_ADD);
}
return $content_updates;
}
private function parseMercurialCommits($raw) {
$commits_lines = explode("\2", $raw);
$commits_lines = array_filter($commits_lines);
$commit_map = array();
foreach ($commits_lines as $commit_line) {
list($node, $branch) = explode("\1", $commit_line);
$commit_map[$node] = array($branch);
}
return $commit_map;
}
private function parseMercurialHeads($raw) {
$heads_map = $this->parseMercurialCommits($raw);
$heads = array();
foreach ($heads_map as $commit => $branches) {
foreach ($branches as $branch) {
$heads[$branch][] = $commit;
}
}
return $heads;
}
/* -( Subversion )--------------------------------------------------------- */
private function findSubversionRefUpdates() {
// Subversion doesn't have any kind of mutable ref metadata.
return array();
}
private function findSubversionContentUpdates(array $ref_updates) {
list($youngest) = execx(
'svnlook youngest %s',
$this->subversionRepository);
$ref_new = (int)$youngest + 1;
$ref_flags = 0;
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_ADD;
$ref_flags |= PhabricatorRepositoryPushLog::CHANGEFLAG_APPEND;
$ref_content = $this->newPushLog()
->setRefType(PhabricatorRepositoryPushLog::REFTYPE_COMMIT)
->setRefNew($ref_new)
->setChangeFlags($ref_flags);
return array($ref_content);
}
/* -( Internals )---------------------------------------------------------- */
private function newPushLog() {
// NOTE: We generate PHIDs up front so the Herald transcripts can pick them
// up.
$phid = id(new PhabricatorRepositoryPushLog())->generatePHID();
$device = AlmanacKeys::getLiveDevice();
if ($device) {
$device_phid = $device->getPHID();
} else {
$device_phid = null;
}
return PhabricatorRepositoryPushLog::initializeNewLog($this->getViewer())
->setPHID($phid)
->setDevicePHID($device_phid)
->setRepositoryPHID($this->getRepository()->getPHID())
->attachRepository($this->getRepository())
->setEpoch(time());
}
private function newPushEvent() {
$viewer = $this->getViewer();
return PhabricatorRepositoryPushEvent::initializeNewEvent($viewer)
->setRepositoryPHID($this->getRepository()->getPHID())
->setRemoteAddress($this->getRemoteAddress())
->setRemoteProtocol($this->getRemoteProtocol())
->setEpoch(time());
}
public function loadChangesetsForCommit($identifier) {
$byte_limit = HeraldCommitAdapter::getEnormousByteLimit();
$time_limit = HeraldCommitAdapter::getEnormousTimeLimit();
$vcs = $this->getRepository()->getVersionControlSystem();
switch ($vcs) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
// For git and hg, we can use normal commands.
$drequest = DiffusionRequest::newFromDictionary(
array(
'repository' => $this->getRepository(),
'user' => $this->getViewer(),
'commit' => $identifier,
));
$raw_diff = DiffusionRawDiffQuery::newFromDiffusionRequest($drequest)
->setTimeout($time_limit)
->setByteLimit($byte_limit)
->setLinesOfContext(0)
->executeInline();
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
// TODO: This diff has 3 lines of context, which produces slightly
// incorrect "added file content" and "removed file content" results.
// This may also choke on binaries, but "svnlook diff" does not support
// the "--diff-cmd" flag.
// For subversion, we need to use `svnlook`.
$future = new ExecFuture(
'svnlook diff -t %s %s',
$this->subversionTransaction,
$this->subversionRepository);
$future->setTimeout($time_limit);
$future->setStdoutSizeLimit($byte_limit);
$future->setStderrSizeLimit($byte_limit);
list($raw_diff) = $future->resolvex();
break;
default:
throw new Exception(pht("Unknown VCS '%s!'", $vcs));
}
if (strlen($raw_diff) >= $byte_limit) {
throw new Exception(
pht(
'The raw text of this change is enormous (larger than %d '.
'bytes). Herald can not process it.',
$byte_limit));
}
if (!strlen($raw_diff)) {
// If the commit is actually empty, just return no changesets.
return array();
}
$parser = new ArcanistDiffParser();
$changes = $parser->parseDiff($raw_diff);
$diff = DifferentialDiff::newEphemeralFromRawChanges(
$changes);
return $diff->getChangesets();
}
public function loadCommitRefForCommit($identifier) {
$repository = $this->getRepository();
$vcs = $repository->getVersionControlSystem();
switch ($vcs) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
return id(new DiffusionLowLevelCommitQuery())
->setRepository($repository)
->withIdentifier($identifier)
->execute();
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
// For subversion, we need to use `svnlook`.
list($message) = execx(
'svnlook log -t %s %s',
$this->subversionTransaction,
$this->subversionRepository);
return id(new DiffusionCommitRef())
->setMessage($message);
break;
default:
throw new Exception(pht("Unknown VCS '%s!'", $vcs));
}
}
public function loadBranches($identifier) {
$repository = $this->getRepository();
$vcs = $repository->getVersionControlSystem();
switch ($vcs) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
return idx($this->gitCommits, $identifier, array());
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
// NOTE: This will be "the branch the commit was made to", not
// "a list of all branch heads which descend from the commit".
// This is consistent with Mercurial, but possibly confusing.
return idx($this->mercurialCommits, $identifier, array());
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
// Subversion doesn't have branches.
return array();
}
}
private function loadCommitInfoForWorker(array $all_updates) {
$type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT;
$map = array();
foreach ($all_updates as $update) {
if ($update->getRefType() != $type_commit) {
continue;
}
$map[$update->getRefNew()] = array();
}
foreach ($map as $identifier => $info) {
$ref = $this->loadCommitRefForCommit($identifier);
$map[$identifier] += array(
'summary' => $ref->getSummary(),
'branches' => $this->loadBranches($identifier),
);
}
return $map;
}
private function isInitialImport(array $all_updates) {
$repository = $this->getRepository();
$vcs = $repository->getVersionControlSystem();
switch ($vcs) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
// There is no meaningful way to import history into Subversion by
// pushing.
return false;
default:
break;
}
// Now, apply a heuristic to guess whether this is a normal commit or
// an initial import. We guess something is an initial import if:
//
// - the repository is currently empty; and
// - it pushes more than 7 commits at once.
//
// The number "7" is chosen arbitrarily as seeming reasonable. We could
// also look at author data (do the commits come from multiple different
// authors?) and commit date data (is the oldest commit more than 48 hours
// old), but we don't have immediate access to those and this simple
- // heruistic might be good enough.
+ // heuristic might be good enough.
$commit_count = 0;
$type_commit = PhabricatorRepositoryPushLog::REFTYPE_COMMIT;
foreach ($all_updates as $update) {
if ($update->getRefType() != $type_commit) {
continue;
}
$commit_count++;
}
if ($commit_count <= PhabricatorRepository::IMPORT_THRESHOLD) {
// If this pushes a very small number of commits, assume it's an
// initial commit or stack of a few initial commits.
return false;
}
$any_commits = id(new DiffusionCommitQuery())
->setViewer($this->getViewer())
->withRepository($repository)
->setLimit(1)
->execute();
if ($any_commits) {
// If the repository already has commits, this isn't an import.
return false;
}
return true;
}
}
diff --git a/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php b/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php
index aca7367f8c..41c009455d 100644
--- a/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php
+++ b/src/applications/diffusion/gitlfs/DiffusionGitLFSAuthenticateWorkflow.php
@@ -1,110 +1,110 @@
<?php
final class DiffusionGitLFSAuthenticateWorkflow
extends DiffusionGitSSHWorkflow {
protected function didConstruct() {
$this->setName('git-lfs-authenticate');
$this->setArguments(
array(
array(
'name' => 'argv',
'wildcard' => true,
),
));
}
protected function identifyRepository() {
return $this->loadRepositoryWithPath(
$this->getLFSPathArgument(),
PhabricatorRepositoryType::REPOSITORY_TYPE_GIT);
}
private function getLFSPathArgument() {
return $this->getLFSArgument(0);
}
private function getLFSOperationArgument() {
return $this->getLFSArgument(1);
}
private function getLFSArgument($position) {
$args = $this->getArgs();
$argv = $args->getArg('argv');
if (!isset($argv[$position])) {
throw new Exception(
pht(
'Expected `git-lfs-authenticate <path> <operation>`, but received '.
'too few arguments.'));
}
return $argv[$position];
}
protected function executeRepositoryOperations() {
$operation = $this->getLFSOperationArgument();
// NOTE: We aren't checking write access here, even for "upload". The
// HTTP endpoint should be able to do that for us.
switch ($operation) {
case 'upload':
case 'download':
break;
default:
throw new Exception(
pht(
'Git LFS operation "%s" is not supported by this server.',
$operation));
}
$repository = $this->getRepository();
if (!$repository->isGit()) {
throw new Exception(
pht(
'Repository "%s" is not a Git repository. Git LFS is only '.
'supported for Git repositories.',
$repository->getDisplayName()));
}
if (!$repository->canUseGitLFS()) {
throw new Exception(
pht('Git LFS is not enabled for this repository.'));
}
// NOTE: This is usually the same as the default URI (which does not
// need to be specified in the response), but the protocol or domain may
// differ in some situations.
$lfs_uri = $repository->getGitLFSURI('info/lfs');
- // Generate a temporary token to allow the user to acces LFS over HTTP.
+ // Generate a temporary token to allow the user to access LFS over HTTP.
// This works even if normal HTTP repository operations are not available
// on this host, and does not require the user to have a VCS password.
$user = $this->getUser();
$authorization = DiffusionGitLFSTemporaryTokenType::newHTTPAuthorization(
$repository,
$user,
$operation);
$headers = array(
'authorization' => $authorization,
);
$result = array(
'header' => $headers,
'href' => $lfs_uri,
);
$result = phutil_json_encode($result);
$this->writeIO($result);
$this->waitForGitClient();
return 0;
}
}
diff --git a/src/applications/diffusion/herald/HeraldCommitAdapter.php b/src/applications/diffusion/herald/HeraldCommitAdapter.php
index 4687028418..bfcd8e3ccb 100644
--- a/src/applications/diffusion/herald/HeraldCommitAdapter.php
+++ b/src/applications/diffusion/herald/HeraldCommitAdapter.php
@@ -1,366 +1,366 @@
<?php
final class HeraldCommitAdapter
extends HeraldAdapter
implements HarbormasterBuildableAdapterInterface {
protected $diff;
protected $revision;
protected $commit;
private $commitDiff;
protected $affectedPaths;
protected $affectedRevision;
protected $affectedPackages;
protected $auditNeededPackages;
private $buildRequests = array();
public function getAdapterApplicationClass() {
return 'PhabricatorDiffusionApplication';
}
protected function newObject() {
return new PhabricatorRepositoryCommit();
}
public function isTestAdapterForObject($object) {
return ($object instanceof PhabricatorRepositoryCommit);
}
public function getAdapterTestDescription() {
return pht(
'Test rules which run after a commit is discovered and imported.');
}
public function newTestAdapter(PhabricatorUser $viewer, $object) {
$object = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withPHIDs(array($object->getPHID()))
->needCommitData(true)
->executeOne();
if (!$object) {
throw new Exception(
pht(
'Failed to reload commit ("%s") to fetch commit data.',
$object->getPHID()));
}
return id(clone $this)
->setObject($object);
}
protected function initializeNewAdapter() {
$this->commit = $this->newObject();
}
public function setObject($object) {
$this->commit = $object;
return $this;
}
public function getObject() {
return $this->commit;
}
public function getAdapterContentType() {
return 'commit';
}
public function getAdapterContentName() {
return pht('Commits');
}
public function getAdapterContentDescription() {
return pht(
"React to new commits appearing in tracked repositories.\n".
"Commit rules can send email, flag commits, trigger audits, ".
"and run build plans.");
}
public function supportsRuleType($rule_type) {
switch ($rule_type) {
case HeraldRuleTypeConfig::RULE_TYPE_GLOBAL:
case HeraldRuleTypeConfig::RULE_TYPE_PERSONAL:
case HeraldRuleTypeConfig::RULE_TYPE_OBJECT:
return true;
default:
return false;
}
}
public function canTriggerOnObject($object) {
if ($object instanceof PhabricatorRepository) {
return true;
}
if ($object instanceof PhabricatorProject) {
return true;
}
return false;
}
public function getTriggerObjectPHIDs() {
$project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
$repository_phid = $this->getRepository()->getPHID();
$commit_phid = $this->getObject()->getPHID();
$phids = array();
$phids[] = $commit_phid;
$phids[] = $repository_phid;
// NOTE: This is projects for the repository, not for the commit. When
- // Herald evalutes, commits normally can not have any project tags yet.
+ // Herald evaluates, commits normally can not have any project tags yet.
$repository_project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$repository_phid,
$project_type);
foreach ($repository_project_phids as $phid) {
$phids[] = $phid;
}
$phids = array_unique($phids);
$phids = array_values($phids);
return $phids;
}
public function explainValidTriggerObjects() {
return pht('This rule can trigger for **repositories** and **projects**.');
}
public function getHeraldName() {
return $this->commit->getMonogram();
}
public function loadAffectedPaths() {
if ($this->affectedPaths === null) {
$result = PhabricatorOwnerPathQuery::loadAffectedPaths(
$this->getRepository(),
$this->commit,
PhabricatorUser::getOmnipotentUser());
$this->affectedPaths = $result;
}
return $this->affectedPaths;
}
public function loadAffectedPackages() {
if ($this->affectedPackages === null) {
$packages = PhabricatorOwnersPackage::loadAffectedPackages(
$this->getRepository(),
$this->loadAffectedPaths());
$this->affectedPackages = $packages;
}
return $this->affectedPackages;
}
public function loadAuditNeededPackages() {
if ($this->auditNeededPackages === null) {
$status_arr = array(
PhabricatorAuditStatusConstants::AUDIT_REQUIRED,
PhabricatorAuditStatusConstants::CONCERNED,
);
$requests = id(new PhabricatorRepositoryAuditRequest())
->loadAllWhere(
'commitPHID = %s AND auditStatus IN (%Ls)',
$this->commit->getPHID(),
$status_arr);
$this->auditNeededPackages = $requests;
}
return $this->auditNeededPackages;
}
public function loadDifferentialRevision() {
if ($this->affectedRevision === null) {
$this->affectedRevision = false;
$commit = $this->getObject();
$data = $commit->getCommitData();
$revision_id = $data->getCommitDetail('differential.revisionID');
if ($revision_id) {
// NOTE: The Herald rule owner might not actually have access to
// the revision, and can control which revision a commit is
// associated with by putting text in the commit message. However,
// the rules they can write against revisions don't actually expose
// anything interesting, so it seems reasonable to load unconditionally
// here.
$revision = id(new DifferentialRevisionQuery())
->withIDs(array($revision_id))
->setViewer(PhabricatorUser::getOmnipotentUser())
->needReviewers(true)
->executeOne();
if ($revision) {
$this->affectedRevision = $revision;
}
}
}
return $this->affectedRevision;
}
public static function getEnormousByteLimit() {
return 1024 * 1024 * 1024; // 1GB
}
public static function getEnormousTimeLimit() {
return 60 * 15; // 15 Minutes
}
private function loadCommitDiff() {
$viewer = PhabricatorUser::getOmnipotentUser();
$byte_limit = self::getEnormousByteLimit();
$time_limit = self::getEnormousTimeLimit();
$diff_info = $this->callConduit(
'diffusion.rawdiffquery',
array(
'commit' => $this->commit->getCommitIdentifier(),
'timeout' => $time_limit,
'byteLimit' => $byte_limit,
'linesOfContext' => 0,
));
if ($diff_info['tooHuge']) {
throw new Exception(
pht(
'The raw text of this change is enormous (larger than %s byte(s)). '.
'Herald can not process it.',
new PhutilNumber($byte_limit)));
}
if ($diff_info['tooSlow']) {
throw new Exception(
pht(
'The raw text of this change took too long to process (longer '.
'than %s second(s)). Herald can not process it.',
new PhutilNumber($time_limit)));
}
$file_phid = $diff_info['filePHID'];
$diff_file = id(new PhabricatorFileQuery())
->setViewer($viewer)
->withPHIDs(array($file_phid))
->executeOne();
if (!$diff_file) {
throw new Exception(
pht(
'Failed to load diff ("%s") for this change.',
$file_phid));
}
$raw = $diff_file->loadFileData();
$parser = new ArcanistDiffParser();
$changes = $parser->parseDiff($raw);
$diff = DifferentialDiff::newEphemeralFromRawChanges(
$changes);
return $diff;
}
public function isDiffEnormous() {
$this->loadDiffContent('*');
return ($this->commitDiff instanceof Exception);
}
public function loadDiffContent($type) {
if ($this->commitDiff === null) {
try {
$this->commitDiff = $this->loadCommitDiff();
} catch (Exception $ex) {
$this->commitDiff = $ex;
phlog($ex);
}
}
if ($this->commitDiff instanceof Exception) {
$ex = $this->commitDiff;
$ex_class = get_class($ex);
$ex_message = pht('Failed to load changes: %s', $ex->getMessage());
return array(
'<'.$ex_class.'>' => $ex_message,
);
}
$changes = $this->commitDiff->getChangesets();
$result = array();
foreach ($changes as $change) {
$lines = array();
foreach ($change->getHunks() as $hunk) {
switch ($type) {
case '-':
$lines[] = $hunk->makeOldFile();
break;
case '+':
$lines[] = $hunk->makeNewFile();
break;
case '*':
$lines[] = $hunk->makeChanges();
break;
default:
throw new Exception(pht("Unknown content selection '%s'!", $type));
}
}
$result[$change->getFilename()] = implode("\n", $lines);
}
return $result;
}
public function loadIsMergeCommit() {
$parents = $this->callConduit(
'diffusion.commitparentsquery',
array(
'commit' => $this->getObject()->getCommitIdentifier(),
));
return (count($parents) > 1);
}
private function callConduit($method, array $params) {
$viewer = PhabricatorUser::getOmnipotentUser();
$drequest = DiffusionRequest::newFromDictionary(
array(
'user' => $viewer,
'repository' => $this->getRepository(),
'commit' => $this->commit->getCommitIdentifier(),
));
return DiffusionQuery::callConduitWithDiffusionRequest(
$viewer,
$drequest,
$method,
$params);
}
private function getRepository() {
return $this->getObject()->getRepository();
}
/* -( HarbormasterBuildableAdapterInterface )------------------------------ */
public function getHarbormasterBuildablePHID() {
return $this->getObject()->getPHID();
}
public function getHarbormasterContainerPHID() {
return $this->getObject()->getRepository()->getPHID();
}
public function getQueuedHarbormasterBuildRequests() {
return $this->buildRequests;
}
public function queueHarbormasterBuildRequest(
HarbormasterBuildRequest $request) {
$this->buildRequests[] = $request;
}
}
diff --git a/src/applications/diffusion/protocol/DiffusionCommandEngine.php b/src/applications/diffusion/protocol/DiffusionCommandEngine.php
index b787745a02..53a086db33 100644
--- a/src/applications/diffusion/protocol/DiffusionCommandEngine.php
+++ b/src/applications/diffusion/protocol/DiffusionCommandEngine.php
@@ -1,298 +1,298 @@
<?php
abstract class DiffusionCommandEngine extends Phobject {
private $repository;
private $protocol;
private $credentialPHID;
private $argv;
private $passthru;
private $connectAsDevice;
private $sudoAsDaemon;
private $uri;
public static function newCommandEngine(PhabricatorRepository $repository) {
$engines = self::newCommandEngines();
foreach ($engines as $engine) {
if ($engine->canBuildForRepository($repository)) {
return id(clone $engine)
->setRepository($repository);
}
}
throw new Exception(
pht(
'No registered command engine can build commands for this '.
'repository ("%s").',
$repository->getDisplayName()));
}
private static function newCommandEngines() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->execute();
}
abstract protected function canBuildForRepository(
PhabricatorRepository $repository);
abstract protected function newFormattedCommand($pattern, array $argv);
abstract protected function newCustomEnvironment();
public function setRepository(PhabricatorRepository $repository) {
$this->repository = $repository;
return $this;
}
public function getRepository() {
return $this->repository;
}
public function setURI(PhutilURI $uri) {
$this->uri = $uri;
$this->setProtocol($uri->getProtocol());
return $this;
}
public function getURI() {
return $this->uri;
}
public function setProtocol($protocol) {
$this->protocol = $protocol;
return $this;
}
public function getProtocol() {
return $this->protocol;
}
public function getDisplayProtocol() {
return $this->getProtocol().'://';
}
public function setCredentialPHID($credential_phid) {
$this->credentialPHID = $credential_phid;
return $this;
}
public function getCredentialPHID() {
return $this->credentialPHID;
}
public function setArgv(array $argv) {
$this->argv = $argv;
return $this;
}
public function getArgv() {
return $this->argv;
}
public function setPassthru($passthru) {
$this->passthru = $passthru;
return $this;
}
public function getPassthru() {
return $this->passthru;
}
public function setConnectAsDevice($connect_as_device) {
$this->connectAsDevice = $connect_as_device;
return $this;
}
public function getConnectAsDevice() {
return $this->connectAsDevice;
}
public function setSudoAsDaemon($sudo_as_daemon) {
$this->sudoAsDaemon = $sudo_as_daemon;
return $this;
}
public function getSudoAsDaemon() {
return $this->sudoAsDaemon;
}
public function newFuture() {
$argv = $this->newCommandArgv();
$env = $this->newCommandEnvironment();
if ($this->getSudoAsDaemon()) {
$command = call_user_func_array('csprintf', $argv);
$command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
$argv = array('%C', $command);
}
if ($this->getPassthru()) {
$future = newv('PhutilExecPassthru', $argv);
} else {
$future = newv('ExecFuture', $argv);
}
$future->setEnv($env);
return $future;
}
private function newCommandArgv() {
$argv = $this->argv;
$pattern = $argv[0];
$argv = array_slice($argv, 1);
list($pattern, $argv) = $this->newFormattedCommand($pattern, $argv);
return array_merge(array($pattern), $argv);
}
private function newCommandEnvironment() {
$env = $this->newCommonEnvironment() + $this->newCustomEnvironment();
foreach ($env as $key => $value) {
if ($value === null) {
unset($env[$key]);
}
}
return $env;
}
private function newCommonEnvironment() {
$repository = $this->getRepository();
$env = array();
// NOTE: Force the language to "en_US.UTF-8", which overrides locale
// settings. This makes stuff print in English instead of, e.g., French,
// so we can parse the output of some commands, error messages, etc.
$env['LANG'] = 'en_US.UTF-8';
// Propagate PHABRICATOR_ENV explicitly. For discussion, see T4155.
$env['PHABRICATOR_ENV'] = PhabricatorEnv::getSelectedEnvironmentName();
$as_device = $this->getConnectAsDevice();
$credential_phid = $this->getCredentialPHID();
if ($as_device) {
$device = AlmanacKeys::getLiveDevice();
if (!$device) {
throw new Exception(
pht(
- 'Attempting to build a reposiory command (for repository "%s") '.
+ 'Attempting to build a repository command (for repository "%s") '.
'as device, but this host ("%s") is not configured as a cluster '.
'device.',
$repository->getDisplayName(),
php_uname('n')));
}
if ($credential_phid) {
throw new Exception(
pht(
'Attempting to build a repository command (for repository "%s"), '.
'but the CommandEngine is configured to connect as both the '.
'current cluster device ("%s") and with a specific credential '.
'("%s"). These options are mutually exclusive. Connections must '.
'authenticate as one or the other, not both.',
$repository->getDisplayName(),
$device->getName(),
$credential_phid));
}
}
if ($this->isAnySSHProtocol()) {
if ($credential_phid) {
$env['PHABRICATOR_CREDENTIAL'] = $credential_phid;
}
if ($as_device) {
$env['PHABRICATOR_AS_DEVICE'] = 1;
}
}
$env += $repository->getPassthroughEnvironmentalVariables();
return $env;
}
public function isSSHProtocol() {
return ($this->getProtocol() == 'ssh');
}
public function isSVNProtocol() {
return ($this->getProtocol() == 'svn');
}
public function isSVNSSHProtocol() {
return ($this->getProtocol() == 'svn+ssh');
}
public function isHTTPProtocol() {
return ($this->getProtocol() == 'http');
}
public function isHTTPSProtocol() {
return ($this->getProtocol() == 'https');
}
public function isAnyHTTPProtocol() {
return ($this->isHTTPProtocol() || $this->isHTTPSProtocol());
}
public function isAnySSHProtocol() {
return ($this->isSSHProtocol() || $this->isSVNSSHProtocol());
}
public function isCredentialSupported() {
return ($this->getPassphraseProvidesCredentialType() !== null);
}
public function isCredentialOptional() {
if ($this->isAnySSHProtocol()) {
return false;
}
return true;
}
public function getPassphraseCredentialLabel() {
if ($this->isAnySSHProtocol()) {
return pht('SSH Key');
}
if ($this->isAnyHTTPProtocol() || $this->isSVNProtocol()) {
return pht('Password');
}
return null;
}
public function getPassphraseDefaultCredentialType() {
if ($this->isAnySSHProtocol()) {
return PassphraseSSHPrivateKeyTextCredentialType::CREDENTIAL_TYPE;
}
if ($this->isAnyHTTPProtocol() || $this->isSVNProtocol()) {
return PassphrasePasswordCredentialType::CREDENTIAL_TYPE;
}
return null;
}
public function getPassphraseProvidesCredentialType() {
if ($this->isAnySSHProtocol()) {
return PassphraseSSHPrivateKeyCredentialType::PROVIDES_TYPE;
}
if ($this->isAnyHTTPProtocol() || $this->isSVNProtocol()) {
return PassphrasePasswordCredentialType::PROVIDES_TYPE;
}
return null;
}
protected function getSSHWrapper() {
$root = dirname(phutil_get_library_root('phabricator'));
return $root.'/bin/ssh-connect';
}
}
diff --git a/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php b/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php
index 32a73867e5..251935a6dc 100644
--- a/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php
+++ b/src/applications/diffusion/protocol/DiffusionMercurialWireProtocol.php
@@ -1,132 +1,132 @@
<?php
final class DiffusionMercurialWireProtocol extends Phobject {
public static function getCommandArgs($command) {
// We need to enumerate all of the Mercurial wire commands because the
// argument encoding varies based on the command. "Why?", you might ask,
// "Why would you do this?".
$commands = array(
'batch' => array('cmds', '*'),
'between' => array('pairs'),
'branchmap' => array(),
'branches' => array('nodes'),
'capabilities' => array(),
'changegroup' => array('roots'),
'changegroupsubset' => array('bases heads'),
'debugwireargs' => array('one two *'),
'getbundle' => array('*'),
'heads' => array(),
'hello' => array(),
'known' => array('nodes', '*'),
'listkeys' => array('namespace'),
'lookup' => array('key'),
'pushkey' => array('namespace', 'key', 'old', 'new'),
'stream_out' => array(''),
'unbundle' => array('heads'),
);
if (!isset($commands[$command])) {
throw new Exception(pht("Unknown Mercurial command '%s!", $command));
}
return $commands[$command];
}
public static function isReadOnlyCommand($command) {
$read_only = array(
'between' => true,
'branchmap' => true,
'branches' => true,
'capabilities' => true,
'changegroup' => true,
'changegroupsubset' => true,
'debugwireargs' => true,
'getbundle' => true,
'heads' => true,
'hello' => true,
'known' => true,
'listkeys' => true,
'lookup' => true,
'stream_out' => true,
);
// Notably, the write commands are "pushkey" and "unbundle". The
// "batch" command is theoretically read only, but we require explicit
// analysis of the actual commands.
return isset($read_only[$command]);
}
public static function isReadOnlyBatchCommand($cmds) {
if (!strlen($cmds)) {
// We expect a "batch" command to always have a "cmds" string, so err
// on the side of caution and throw if we don't get any data here. This
// either indicates a mangled command from the client or a programming
// error in our code.
throw new Exception(pht("Expected nonempty '%s' specification!", 'cmds'));
}
// For "batch" we get a "cmds" argument like:
//
// heads ;known nodes=
//
// We need to examine the commands (here, "heads" and "known") to make sure
// they're all read-only.
// NOTE: Mercurial has some code to escape semicolons, but it does not
// actually function for command separation. For example, these two batch
// commands will produce completely different results (the former will run
// the lookup; the latter will fail with a parser error):
//
// lookup key=a:xb;lookup key=z* 0
// lookup key=a:;b;lookup key=z* 0
// ^
// |
// +-- Note semicolon.
//
// So just split unconditionally.
$cmds = explode(';', $cmds);
foreach ($cmds as $sub_cmd) {
$name = head(explode(' ', $sub_cmd, 2));
if (!self::isReadOnlyCommand($name)) {
return false;
}
}
return true;
}
/** If the server version is running 3.4+ it will respond
* with 'bundle2' capability in the format of "bundle2=(url-encoding)".
- * Until we maange to properly package up bundles to send back we
+ * Until we manage to properly package up bundles to send back we
* disallow the client from knowing we speak bundle2 by removing it
* from the capabilities listing.
*
* The format of the capabilities string is: "a space separated list
* of strings representing what commands the server supports"
* @link https://www.mercurial-scm.org/wiki/CommandServer#Protocol
*
* @param string $capabilities - The string of capabilities to
* strip the bundle2 capability from. This is expected to be
* the space-separated list of strings resulting from the
* querying the 'capabilities' command.
*
* @return string The resulting space-separated list of capabilities
* which no longer contains the 'bundle2' capability. This is meant
* to replace the original $body to send back to client.
*/
public static function filterBundle2Capability($capabilities) {
$parts = explode(' ', $capabilities);
foreach ($parts as $key => $part) {
if (preg_match('/^bundle2=/', $part)) {
unset($parts[$key]);
break;
}
}
return implode(' ', $parts);
}
}
diff --git a/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php b/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php
index e822bb76b0..35bb4d4acf 100644
--- a/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php
+++ b/src/applications/diffusion/protocol/DiffusionRepositoryClusterEngine.php
@@ -1,766 +1,766 @@
<?php
/**
* Manages repository synchronization for cluster repositories.
*
* @task config Configuring Synchronization
* @task sync Cluster Synchronization
* @task internal Internals
*/
final class DiffusionRepositoryClusterEngine extends Phobject {
private $repository;
private $viewer;
private $logger;
private $clusterWriteLock;
private $clusterWriteVersion;
private $clusterWriteOwner;
/* -( Configuring Synchronization )---------------------------------------- */
public function setRepository(PhabricatorRepository $repository) {
$this->repository = $repository;
return $this;
}
public function getRepository() {
return $this->repository;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function setLog(DiffusionRepositoryClusterEngineLogInterface $log) {
$this->logger = $log;
return $this;
}
/* -( Cluster Synchronization )-------------------------------------------- */
/**
* Synchronize repository version information after creating a repository.
*
* This initializes working copy versions for all currently bound devices to
* 0, so that we don't get stuck making an ambiguous choice about which
* devices are leaders when we later synchronize before a read.
*
* @task sync
*/
public function synchronizeWorkingCopyAfterCreation() {
if (!$this->shouldEnableSynchronization(false)) {
return;
}
$repository = $this->getRepository();
$repository_phid = $repository->getPHID();
$service = $repository->loadAlmanacService();
if (!$service) {
throw new Exception(pht('Failed to load repository cluster service.'));
}
$bindings = $service->getActiveBindings();
foreach ($bindings as $binding) {
PhabricatorRepositoryWorkingCopyVersion::updateVersion(
$repository_phid,
$binding->getDevicePHID(),
0);
}
return $this;
}
/**
* @task sync
*/
public function synchronizeWorkingCopyAfterHostingChange() {
if (!$this->shouldEnableSynchronization(false)) {
return;
}
$repository = $this->getRepository();
$repository_phid = $repository->getPHID();
$versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions(
$repository_phid);
$versions = mpull($versions, null, 'getDevicePHID');
// After converting a hosted repository to observed, or vice versa, we
// need to reset version numbers because the clocks for observed and hosted
// repositories run on different units.
// We identify all the cluster leaders and reset their version to 0.
// We identify all the cluster followers and demote them.
// This allows the cluster to start over again at version 0 but keep the
// same leaders.
if ($versions) {
$max_version = (int)max(mpull($versions, 'getRepositoryVersion'));
foreach ($versions as $version) {
$device_phid = $version->getDevicePHID();
if ($version->getRepositoryVersion() == $max_version) {
PhabricatorRepositoryWorkingCopyVersion::updateVersion(
$repository_phid,
$device_phid,
0);
} else {
PhabricatorRepositoryWorkingCopyVersion::demoteDevice(
$repository_phid,
$device_phid);
}
}
}
return $this;
}
/**
* @task sync
*/
public function synchronizeWorkingCopyBeforeRead() {
if (!$this->shouldEnableSynchronization(true)) {
return;
}
$repository = $this->getRepository();
$repository_phid = $repository->getPHID();
$device = AlmanacKeys::getLiveDevice();
$device_phid = $device->getPHID();
$read_lock = PhabricatorRepositoryWorkingCopyVersion::getReadLock(
$repository_phid,
$device_phid);
$lock_wait = phutil_units('2 minutes in seconds');
$this->logLine(
pht(
'Waiting up to %s second(s) for a cluster read lock on "%s"...',
new PhutilNumber($lock_wait),
$device->getName()));
try {
$start = PhabricatorTime::getNow();
$read_lock->lock($lock_wait);
$waited = (PhabricatorTime::getNow() - $start);
if ($waited) {
$this->logLine(
pht(
'Acquired read lock after %s second(s).',
new PhutilNumber($waited)));
} else {
$this->logLine(
pht(
'Acquired read lock immediately.'));
}
} catch (Exception $ex) {
throw new PhutilProxyException(
pht(
'Failed to acquire read lock after waiting %s second(s). You '.
'may be able to retry later.',
new PhutilNumber($lock_wait)),
$ex);
}
$versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions(
$repository_phid);
$versions = mpull($versions, null, 'getDevicePHID');
$this_version = idx($versions, $device_phid);
if ($this_version) {
$this_version = (int)$this_version->getRepositoryVersion();
} else {
$this_version = -1;
}
if ($versions) {
// This is the normal case, where we have some version information and
// can identify which nodes are leaders. If the current node is not a
// leader, we want to fetch from a leader and then update our version.
$max_version = (int)max(mpull($versions, 'getRepositoryVersion'));
if ($max_version > $this_version) {
if ($repository->isHosted()) {
$fetchable = array();
foreach ($versions as $version) {
if ($version->getRepositoryVersion() == $max_version) {
$fetchable[] = $version->getDevicePHID();
}
}
$this->synchronizeWorkingCopyFromDevices($fetchable);
} else {
$this->synchornizeWorkingCopyFromRemote();
}
PhabricatorRepositoryWorkingCopyVersion::updateVersion(
$repository_phid,
$device_phid,
$max_version);
} else {
$this->logLine(
pht(
'Device "%s" is already a cluster leader and does not need '.
'to be synchronized.',
$device->getName()));
}
$result_version = $max_version;
} else {
// If no version records exist yet, we need to be careful, because we
// can not tell which nodes are leaders.
// There might be several nodes with arbitrary existing data, and we have
// no way to tell which one has the "right" data. If we pick wrong, we
// might erase some or all of the data in the repository.
- // Since this is dangeorus, we refuse to guess unless there is only one
+ // Since this is dangerous, we refuse to guess unless there is only one
// device. If we're the only device in the group, we obviously must be
// a leader.
$service = $repository->loadAlmanacService();
if (!$service) {
throw new Exception(pht('Failed to load repository cluster service.'));
}
$bindings = $service->getActiveBindings();
$device_map = array();
foreach ($bindings as $binding) {
$device_map[$binding->getDevicePHID()] = true;
}
if (count($device_map) > 1) {
throw new Exception(
pht(
'Repository "%s" exists on more than one device, but no device '.
'has any repository version information. Phabricator can not '.
'guess which copy of the existing data is authoritative. Promote '.
- 'a device or see "Ambigous Leaders" in the documentation.',
+ 'a device or see "Ambiguous Leaders" in the documentation.',
$repository->getDisplayName()));
}
if (empty($device_map[$device->getPHID()])) {
throw new Exception(
pht(
'Repository "%s" is being synchronized on device "%s", but '.
'this device is not bound to the corresponding cluster '.
'service ("%s").',
$repository->getDisplayName(),
$device->getName(),
$service->getName()));
}
// The current device is the only device in service, so it must be a
// leader. We can safely have any future nodes which come online read
// from it.
PhabricatorRepositoryWorkingCopyVersion::updateVersion(
$repository_phid,
$device_phid,
0);
$result_version = 0;
}
$read_lock->unlock();
return $result_version;
}
/**
* @task sync
*/
public function synchronizeWorkingCopyBeforeWrite() {
if (!$this->shouldEnableSynchronization(true)) {
return;
}
$repository = $this->getRepository();
$viewer = $this->getViewer();
$repository_phid = $repository->getPHID();
$device = AlmanacKeys::getLiveDevice();
$device_phid = $device->getPHID();
$table = new PhabricatorRepositoryWorkingCopyVersion();
$locked_connection = $table->establishConnection('w');
$write_lock = PhabricatorRepositoryWorkingCopyVersion::getWriteLock(
$repository_phid);
$write_lock->useSpecificConnection($locked_connection);
$lock_wait = phutil_units('2 minutes in seconds');
$this->logLine(
pht(
'Waiting up to %s second(s) for a cluster write lock...',
new PhutilNumber($lock_wait)));
try {
$start = PhabricatorTime::getNow();
$write_lock->lock($lock_wait);
$waited = (PhabricatorTime::getNow() - $start);
if ($waited) {
$this->logLine(
pht(
'Acquired write lock after %s second(s).',
new PhutilNumber($waited)));
} else {
$this->logLine(
pht(
'Acquired write lock immediately.'));
}
} catch (Exception $ex) {
throw new PhutilProxyException(
pht(
'Failed to acquire write lock after waiting %s second(s). You '.
'may be able to retry later.',
new PhutilNumber($lock_wait)),
$ex);
}
$versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions(
$repository_phid);
foreach ($versions as $version) {
if (!$version->getIsWriting()) {
continue;
}
throw new Exception(
pht(
'An previous write to this repository was interrupted; refusing '.
'new writes. This issue requires operator intervention to resolve, '.
'see "Write Interruptions" in the "Cluster: Repositories" in the '.
'documentation for instructions.'));
}
try {
$max_version = $this->synchronizeWorkingCopyBeforeRead();
} catch (Exception $ex) {
$write_lock->unlock();
throw $ex;
}
$pid = getmypid();
$hash = Filesystem::readRandomCharacters(12);
$this->clusterWriteOwner = "{$pid}.{$hash}";
PhabricatorRepositoryWorkingCopyVersion::willWrite(
$locked_connection,
$repository_phid,
$device_phid,
array(
'userPHID' => $viewer->getPHID(),
'epoch' => PhabricatorTime::getNow(),
'devicePHID' => $device_phid,
),
$this->clusterWriteOwner);
$this->clusterWriteVersion = $max_version;
$this->clusterWriteLock = $write_lock;
}
public function synchronizeWorkingCopyAfterDiscovery($new_version) {
if (!$this->shouldEnableSynchronization(true)) {
return;
}
$repository = $this->getRepository();
$repository_phid = $repository->getPHID();
if ($repository->isHosted()) {
return;
}
$viewer = $this->getViewer();
$device = AlmanacKeys::getLiveDevice();
$device_phid = $device->getPHID();
// NOTE: We are not holding a lock here because this method is only called
// from PhabricatorRepositoryDiscoveryEngine, which already holds a device
// lock. Even if we do race here and record an older version, the
// consequences are mild: we only do extra work to correct it later.
$versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions(
$repository_phid);
$versions = mpull($versions, null, 'getDevicePHID');
$this_version = idx($versions, $device_phid);
if ($this_version) {
$this_version = (int)$this_version->getRepositoryVersion();
} else {
$this_version = -1;
}
if ($new_version > $this_version) {
PhabricatorRepositoryWorkingCopyVersion::updateVersion(
$repository_phid,
$device_phid,
$new_version);
}
}
/**
* @task sync
*/
public function synchronizeWorkingCopyAfterWrite() {
if (!$this->shouldEnableSynchronization(true)) {
return;
}
if (!$this->clusterWriteLock) {
throw new Exception(
pht(
'Trying to synchronize after write, but not holding a write '.
'lock!'));
}
$repository = $this->getRepository();
$repository_phid = $repository->getPHID();
$device = AlmanacKeys::getLiveDevice();
$device_phid = $device->getPHID();
// It is possible that we've lost the global lock while receiving the push.
// For example, the master database may have been restarted between the
// time we acquired the global lock and now, when the push has finished.
// We wrote a durable lock while we were holding the the global lock,
// essentially upgrading our lock. We can still safely release this upgraded
// lock even if we're no longer holding the global lock.
// If we fail to release the lock, the repository will be frozen until
// an operator can figure out what happened, so we try pretty hard to
// reconnect to the database and release the lock.
$now = PhabricatorTime::getNow();
$duration = phutil_units('5 minutes in seconds');
$try_until = $now + $duration;
$did_release = false;
$already_failed = false;
while (PhabricatorTime::getNow() <= $try_until) {
try {
// NOTE: This means we're still bumping the version when pushes fail. We
// could select only un-rejected events instead to bump a little less
// often.
$new_log = id(new PhabricatorRepositoryPushEventQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withRepositoryPHIDs(array($repository_phid))
->setLimit(1)
->executeOne();
$old_version = $this->clusterWriteVersion;
if ($new_log) {
$new_version = $new_log->getID();
} else {
$new_version = $old_version;
}
PhabricatorRepositoryWorkingCopyVersion::didWrite(
$repository_phid,
$device_phid,
$this->clusterWriteVersion,
$new_version,
$this->clusterWriteOwner);
$did_release = true;
break;
} catch (AphrontConnectionQueryException $ex) {
$connection_exception = $ex;
} catch (AphrontConnectionLostQueryException $ex) {
$connection_exception = $ex;
}
if (!$already_failed) {
$already_failed = true;
$this->logLine(
pht('CRITICAL. Failed to release cluster write lock!'));
$this->logLine(
pht(
'The connection to the master database was lost while receiving '.
'the write.'));
$this->logLine(
pht(
'This process will spend %s more second(s) attempting to '.
'recover, then give up.',
new PhutilNumber($duration)));
}
sleep(1);
}
if ($did_release) {
if ($already_failed) {
$this->logLine(
pht('RECOVERED. Link to master database was restored.'));
}
$this->logLine(pht('Released cluster write lock.'));
} else {
throw new Exception(
pht(
'Failed to reconnect to master database and release held write '.
'lock ("%s") on device "%s" for repository "%s" after trying '.
'for %s seconds(s). This repository will be frozen.',
$this->clusterWriteOwner,
$device->getName(),
$this->getDisplayName(),
new PhutilNumber($duration)));
}
// We can continue even if we've lost this lock, everything is still
// consistent.
try {
$this->clusterWriteLock->unlock();
} catch (Exception $ex) {
// Ignore.
}
$this->clusterWriteLock = null;
$this->clusterWriteOwner = null;
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
private function shouldEnableSynchronization($require_device) {
$repository = $this->getRepository();
$service_phid = $repository->getAlmanacServicePHID();
if (!$service_phid) {
return false;
}
if (!$repository->supportsSynchronization()) {
return false;
}
if ($require_device) {
$device = AlmanacKeys::getLiveDevice();
if (!$device) {
return false;
}
}
return true;
}
/**
* @task internal
*/
private function synchornizeWorkingCopyFromRemote() {
$repository = $this->getRepository();
$device = AlmanacKeys::getLiveDevice();
$local_path = $repository->getLocalPath();
$fetch_uri = $repository->getRemoteURIEnvelope();
if ($repository->isGit()) {
$this->requireWorkingCopy();
$argv = array(
'fetch --prune -- %P %s',
$fetch_uri,
'+refs/*:refs/*',
);
} else {
throw new Exception(pht('Remote sync only supported for git!'));
}
$future = DiffusionCommandEngine::newCommandEngine($repository)
->setArgv($argv)
->setSudoAsDaemon(true)
->setCredentialPHID($repository->getCredentialPHID())
->setURI($repository->getRemoteURIObject())
->newFuture();
$future->setCWD($local_path);
try {
$future->resolvex();
} catch (Exception $ex) {
$this->logLine(
pht(
'Synchronization of "%s" from remote failed: %s',
$device->getName(),
$ex->getMessage()));
throw $ex;
}
}
/**
* @task internal
*/
private function synchronizeWorkingCopyFromDevices(array $device_phids) {
$repository = $this->getRepository();
$service = $repository->loadAlmanacService();
if (!$service) {
throw new Exception(pht('Failed to load repository cluster service.'));
}
$device_map = array_fuse($device_phids);
$bindings = $service->getActiveBindings();
$fetchable = array();
foreach ($bindings as $binding) {
// We can't fetch from nodes which don't have the newest version.
$device_phid = $binding->getDevicePHID();
if (empty($device_map[$device_phid])) {
continue;
}
// TODO: For now, only fetch over SSH. We could support fetching over
// HTTP eventually.
if ($binding->getAlmanacPropertyValue('protocol') != 'ssh') {
continue;
}
$fetchable[] = $binding;
}
if (!$fetchable) {
throw new Exception(
pht(
'Leader lost: no up-to-date nodes in repository cluster are '.
'fetchable.'));
}
$caught = null;
foreach ($fetchable as $binding) {
try {
$this->synchronizeWorkingCopyFromBinding($binding);
$caught = null;
break;
} catch (Exception $ex) {
$caught = $ex;
}
}
if ($caught) {
throw $caught;
}
}
/**
* @task internal
*/
private function synchronizeWorkingCopyFromBinding($binding) {
$repository = $this->getRepository();
$device = AlmanacKeys::getLiveDevice();
$this->logLine(
pht(
'Synchronizing this device ("%s") from cluster leader ("%s") before '.
'read.',
$device->getName(),
$binding->getDevice()->getName()));
$fetch_uri = $repository->getClusterRepositoryURIFromBinding($binding);
$local_path = $repository->getLocalPath();
if ($repository->isGit()) {
$this->requireWorkingCopy();
$argv = array(
'fetch --prune -- %s %s',
$fetch_uri,
'+refs/*:refs/*',
);
} else {
throw new Exception(pht('Binding sync only supported for git!'));
}
$future = DiffusionCommandEngine::newCommandEngine($repository)
->setArgv($argv)
->setConnectAsDevice(true)
->setSudoAsDaemon(true)
->setURI($fetch_uri)
->newFuture();
$future->setCWD($local_path);
try {
$future->resolvex();
} catch (Exception $ex) {
$this->logLine(
pht(
'Synchronization of "%s" from leader "%s" failed: %s',
$device->getName(),
$binding->getDevice()->getName(),
$ex->getMessage()));
throw $ex;
}
}
/**
* @task internal
*/
private function logLine($message) {
return $this->logText("# {$message}\n");
}
/**
* @task internal
*/
private function logText($message) {
$log = $this->logger;
if ($log) {
$log->writeClusterEngineLogMessage($message);
}
return $this;
}
private function requireWorkingCopy() {
$repository = $this->getRepository();
$local_path = $repository->getLocalPath();
if (!Filesystem::pathExists($local_path)) {
$device = AlmanacKeys::getLiveDevice();
throw new Exception(
pht(
'Repository "%s" does not have a working copy on this device '.
'yet, so it can not be synchronized. Wait for the daemons to '.
'construct one or run `bin/repository update %s` on this host '.
'("%s") to build it explicitly.',
$repository->getDisplayName(),
$repository->getMonogram(),
$device->getName()));
}
}
}
diff --git a/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php b/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php
index 22ff1d61f0..0217c97d58 100644
--- a/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php
+++ b/src/applications/diffusion/query/DiffusionCachedResolveRefsQuery.php
@@ -1,187 +1,187 @@
<?php
/**
* Resolves references into canonical, stable commit identifiers by examining
* database caches.
*
* This is a counterpart to @{class:DiffusionLowLevelResolveRefsQuery}. This
* query offers fast resolution, but can not resolve everything that the
* low-level query can.
*
* This class can resolve the most common refs (commits, branches, tags) and
- * can do so cheapy (by examining the database, without needing to make calls
+ * can do so cheaply (by examining the database, without needing to make calls
* to the VCS or the service host).
*/
final class DiffusionCachedResolveRefsQuery
extends DiffusionLowLevelQuery {
private $refs;
private $types;
public function withRefs(array $refs) {
$this->refs = $refs;
return $this;
}
public function withTypes(array $types) {
$this->types = $types;
return $this;
}
protected function executeQuery() {
if (!$this->refs) {
return array();
}
switch ($this->getRepository()->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
$result = $this->resolveGitAndMercurialRefs();
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$result = $this->resolveSubversionRefs();
break;
default:
throw new Exception(pht('Unsupported repository type!'));
}
if ($this->types !== null) {
$result = $this->filterRefsByType($result, $this->types);
}
return $result;
}
/**
* Resolve refs in Git and Mercurial repositories.
*
* We can resolve commit hashes from the commits table, and branch and tag
* names from the refcursor table.
*/
private function resolveGitAndMercurialRefs() {
$repository = $this->getRepository();
$conn_r = $repository->establishConnection('r');
$results = array();
$prefixes = array();
foreach ($this->refs as $ref) {
// We require refs to look like hashes and be at least 4 characters
// long. This is similar to the behavior of git.
if (preg_match('/^[a-f0-9]{4,}$/', $ref)) {
$prefixes[] = qsprintf(
$conn_r,
'(commitIdentifier LIKE %>)',
$ref);
}
}
if ($prefixes) {
$commits = queryfx_all(
$conn_r,
'SELECT commitIdentifier FROM %T
WHERE repositoryID = %s AND %Q',
id(new PhabricatorRepositoryCommit())->getTableName(),
$repository->getID(),
implode(' OR ', $prefixes));
foreach ($commits as $commit) {
$hash = $commit['commitIdentifier'];
foreach ($this->refs as $ref) {
if (!strncmp($hash, $ref, strlen($ref))) {
$results[$ref][] = array(
'type' => 'commit',
'identifier' => $hash,
);
}
}
}
}
$name_hashes = array();
foreach ($this->refs as $ref) {
$name_hashes[PhabricatorHash::digestForIndex($ref)] = $ref;
}
$cursors = queryfx_all(
$conn_r,
'SELECT c.refNameHash, c.refType, p.commitIdentifier, p.isClosed
FROM %T c JOIN %T p ON p.cursorID = c.id
WHERE c.repositoryPHID = %s AND c.refNameHash IN (%Ls)',
id(new PhabricatorRepositoryRefCursor())->getTableName(),
id(new PhabricatorRepositoryRefPosition())->getTableName(),
$repository->getPHID(),
array_keys($name_hashes));
foreach ($cursors as $cursor) {
if (isset($name_hashes[$cursor['refNameHash']])) {
$results[$name_hashes[$cursor['refNameHash']]][] = array(
'type' => $cursor['refType'],
'identifier' => $cursor['commitIdentifier'],
'closed' => (bool)$cursor['isClosed'],
);
// TODO: In Git, we don't store (and thus don't return) the hash
// of the tag itself. It would be vaguely nice to do this.
}
}
return $results;
}
/**
* Resolve refs in Subversion repositories.
*
* We can resolve all numeric identifiers and the keyword `HEAD`.
*/
private function resolveSubversionRefs() {
$repository = $this->getRepository();
$max_commit = id(new PhabricatorRepositoryCommit())
->loadOneWhere(
'repositoryID = %d ORDER BY epoch DESC, id DESC LIMIT 1',
$repository->getID());
if (!$max_commit) {
// This repository is empty or hasn't parsed yet, so none of the refs are
// going to resolve.
return array();
}
$max_commit_id = (int)$max_commit->getCommitIdentifier();
$results = array();
foreach ($this->refs as $ref) {
if ($ref == 'HEAD') {
// Resolve "HEAD" to mean "the most recent commit".
$results[$ref][] = array(
'type' => 'commit',
'identifier' => $max_commit_id,
);
continue;
}
if (!preg_match('/^\d+$/', $ref)) {
// This ref is non-numeric, so it doesn't resolve to anything.
continue;
}
// Resolve other commits if we can deduce their existence.
// TODO: When we import only part of a repository, we won't necessarily
// have all of the smaller commits. Should we fail to resolve them here
// for repositories with a subpath? It might let us simplify other things
// elsewhere.
if ((int)$ref <= $max_commit_id) {
$results[$ref][] = array(
'type' => 'commit',
'identifier' => (int)$ref,
);
}
}
return $results;
}
}
diff --git a/src/applications/diffusion/query/DiffusionCommitQuery.php b/src/applications/diffusion/query/DiffusionCommitQuery.php
index 1521f5770c..da8937910e 100644
--- a/src/applications/diffusion/query/DiffusionCommitQuery.php
+++ b/src/applications/diffusion/query/DiffusionCommitQuery.php
@@ -1,646 +1,646 @@
<?php
final class DiffusionCommitQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $authorPHIDs;
private $defaultRepository;
private $identifiers;
private $repositoryIDs;
private $repositoryPHIDs;
private $identifierMap;
private $responsiblePHIDs;
private $statuses;
private $packagePHIDs;
private $unreachable;
private $needAuditRequests;
private $auditIDs;
private $auditorPHIDs;
private $epochMin;
private $epochMax;
private $importing;
private $needCommitData;
private $needDrafts;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withAuthorPHIDs(array $phids) {
$this->authorPHIDs = $phids;
return $this;
}
/**
* Load commits by partial or full identifiers, e.g. "rXab82393", "rX1234",
* or "a9caf12". When an identifier matches multiple commits, they will all
* be returned; callers should be prepared to deal with more results than
* they queried for.
*/
public function withIdentifiers(array $identifiers) {
// Some workflows (like blame lookups) can pass in large numbers of
// duplicate identifiers. We only care about unique identifiers, so
// get rid of duplicates immediately.
$identifiers = array_fuse($identifiers);
$this->identifiers = $identifiers;
return $this;
}
/**
* Look up commits in a specific repository. This is a shorthand for calling
* @{method:withDefaultRepository} and @{method:withRepositoryIDs}.
*/
public function withRepository(PhabricatorRepository $repository) {
$this->withDefaultRepository($repository);
$this->withRepositoryIDs(array($repository->getID()));
return $this;
}
/**
* Look up commits in a specific repository. Prefer
- * @{method:withRepositoryIDs}; the underyling table is keyed by ID such
+ * @{method:withRepositoryIDs}; the underlying table is keyed by ID such
* that this method requires a separate initial query to map PHID to ID.
*/
public function withRepositoryPHIDs(array $phids) {
$this->repositoryPHIDs = $phids;
return $this;
}
/**
* If a default repository is provided, ambiguous commit identifiers will
* be assumed to belong to the default repository.
*
* For example, "r123" appearing in a commit message in repository X is
* likely to be unambiguously "rX123". Normally the reference would be
* considered ambiguous, but if you provide a default repository it will
* be correctly resolved.
*/
public function withDefaultRepository(PhabricatorRepository $repository) {
$this->defaultRepository = $repository;
return $this;
}
public function withRepositoryIDs(array $repository_ids) {
$this->repositoryIDs = $repository_ids;
return $this;
}
public function needCommitData($need) {
$this->needCommitData = $need;
return $this;
}
public function needDrafts($need) {
$this->needDrafts = $need;
return $this;
}
public function needAuditRequests($need) {
$this->needAuditRequests = $need;
return $this;
}
public function withAuditIDs(array $ids) {
$this->auditIDs = $ids;
return $this;
}
public function withAuditorPHIDs(array $auditor_phids) {
$this->auditorPHIDs = $auditor_phids;
return $this;
}
public function withResponsiblePHIDs(array $responsible_phids) {
$this->responsiblePHIDs = $responsible_phids;
return $this;
}
public function withPackagePHIDs(array $package_phids) {
$this->packagePHIDs = $package_phids;
return $this;
}
public function withUnreachable($unreachable) {
$this->unreachable = $unreachable;
return $this;
}
public function withStatuses(array $statuses) {
$this->statuses = $statuses;
return $this;
}
public function withEpochRange($min, $max) {
$this->epochMin = $min;
$this->epochMax = $max;
return $this;
}
public function withImporting($importing) {
$this->importing = $importing;
return $this;
}
public function getIdentifierMap() {
if ($this->identifierMap === null) {
throw new Exception(
pht(
'You must %s the query before accessing the identifier map.',
'execute()'));
}
return $this->identifierMap;
}
protected function getPrimaryTableAlias() {
return 'commit';
}
protected function willExecute() {
if ($this->identifierMap === null) {
$this->identifierMap = array();
}
}
public function newResultObject() {
return new PhabricatorRepositoryCommit();
}
protected function loadPage() {
return $this->loadStandardPage($this->newResultObject());
}
protected function willFilterPage(array $commits) {
$repository_ids = mpull($commits, 'getRepositoryID', 'getRepositoryID');
$repos = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withIDs($repository_ids)
->execute();
$min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH;
$result = array();
foreach ($commits as $key => $commit) {
$repo = idx($repos, $commit->getRepositoryID());
if ($repo) {
$commit->attachRepository($repo);
} else {
$this->didRejectResult($commit);
unset($commits[$key]);
continue;
}
// Build the identifierMap
if ($this->identifiers !== null) {
$ids = $this->identifiers;
$prefixes = array(
'r'.$commit->getRepository()->getCallsign(),
'r'.$commit->getRepository()->getCallsign().':',
'R'.$commit->getRepository()->getID().':',
'', // No prefix is valid too and will only match the commitIdentifier
);
$suffix = $commit->getCommitIdentifier();
if ($commit->getRepository()->isSVN()) {
foreach ($prefixes as $prefix) {
if (isset($ids[$prefix.$suffix])) {
$result[$prefix.$suffix][] = $commit;
}
}
} else {
// This awkward construction is so we can link the commits up in O(N)
// time instead of O(N^2).
for ($ii = $min_qualified; $ii <= strlen($suffix); $ii++) {
$part = substr($suffix, 0, $ii);
foreach ($prefixes as $prefix) {
if (isset($ids[$prefix.$part])) {
$result[$prefix.$part][] = $commit;
}
}
}
}
}
}
if ($result) {
foreach ($result as $identifier => $matching_commits) {
if (count($matching_commits) == 1) {
$result[$identifier] = head($matching_commits);
} else {
// This reference is ambiguous (it matches more than one commit) so
// don't link it.
unset($result[$identifier]);
}
}
$this->identifierMap += $result;
}
return $commits;
}
protected function didFilterPage(array $commits) {
$viewer = $this->getViewer();
if ($this->needCommitData) {
$data = id(new PhabricatorRepositoryCommitData())->loadAllWhere(
'commitID in (%Ld)',
mpull($commits, 'getID'));
$data = mpull($data, null, 'getCommitID');
foreach ($commits as $commit) {
$commit_data = idx($data, $commit->getID());
if (!$commit_data) {
$commit_data = new PhabricatorRepositoryCommitData();
}
$commit->attachCommitData($commit_data);
}
}
if ($this->needAuditRequests) {
$requests = id(new PhabricatorRepositoryAuditRequest())->loadAllWhere(
'commitPHID IN (%Ls)',
mpull($commits, 'getPHID'));
$requests = mgroup($requests, 'getCommitPHID');
foreach ($commits as $commit) {
$audit_requests = idx($requests, $commit->getPHID(), array());
$commit->attachAudits($audit_requests);
foreach ($audit_requests as $audit_request) {
$audit_request->attachCommit($commit);
}
}
}
if ($this->needDrafts) {
PhabricatorDraftEngine::attachDrafts(
$viewer,
$commits);
}
return $commits;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->repositoryPHIDs !== null) {
$map_repositories = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withPHIDs($this->repositoryPHIDs)
->execute();
if (!$map_repositories) {
throw new PhabricatorEmptyQueryException();
}
$repository_ids = mpull($map_repositories, 'getID');
if ($this->repositoryIDs !== null) {
$repository_ids = array_merge($repository_ids, $this->repositoryIDs);
}
$this->withRepositoryIDs($repository_ids);
}
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'commit.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'commit.phid IN (%Ls)',
$this->phids);
}
if ($this->repositoryIDs !== null) {
$where[] = qsprintf(
$conn,
'commit.repositoryID IN (%Ld)',
$this->repositoryIDs);
}
if ($this->authorPHIDs !== null) {
$where[] = qsprintf(
$conn,
'commit.authorPHID IN (%Ls)',
$this->authorPHIDs);
}
if ($this->epochMin !== null) {
$where[] = qsprintf(
$conn,
'commit.epoch >= %d',
$this->epochMin);
}
if ($this->epochMax !== null) {
$where[] = qsprintf(
$conn,
'commit.epoch <= %d',
$this->epochMax);
}
if ($this->importing !== null) {
if ($this->importing) {
$where[] = qsprintf(
$conn,
'(commit.importStatus & %d) != %d',
PhabricatorRepositoryCommit::IMPORTED_ALL,
PhabricatorRepositoryCommit::IMPORTED_ALL);
} else {
$where[] = qsprintf(
$conn,
'(commit.importStatus & %d) = %d',
PhabricatorRepositoryCommit::IMPORTED_ALL,
PhabricatorRepositoryCommit::IMPORTED_ALL);
}
}
if ($this->identifiers !== null) {
$min_unqualified = PhabricatorRepository::MINIMUM_UNQUALIFIED_HASH;
$min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH;
$refs = array();
$bare = array();
foreach ($this->identifiers as $identifier) {
$matches = null;
preg_match('/^(?:[rR]([A-Z]+:?|[0-9]+:))?(.*)$/',
$identifier, $matches);
$repo = nonempty(rtrim($matches[1], ':'), null);
$commit_identifier = nonempty($matches[2], null);
if ($repo === null) {
if ($this->defaultRepository) {
$repo = $this->defaultRepository->getPHID();
}
}
if ($repo === null) {
if (strlen($commit_identifier) < $min_unqualified) {
continue;
}
$bare[] = $commit_identifier;
} else {
$refs[] = array(
'repository' => $repo,
'identifier' => $commit_identifier,
);
}
}
$sql = array();
foreach ($bare as $identifier) {
$sql[] = qsprintf(
$conn,
'(commit.commitIdentifier LIKE %> AND '.
'LENGTH(commit.commitIdentifier) = 40)',
$identifier);
}
if ($refs) {
$repositories = ipull($refs, 'repository');
$repos = id(new PhabricatorRepositoryQuery())
->setViewer($this->getViewer())
->withIdentifiers($repositories);
$repos->execute();
$repos = $repos->getIdentifierMap();
foreach ($refs as $key => $ref) {
$repo = idx($repos, $ref['repository']);
if (!$repo) {
continue;
}
if ($repo->isSVN()) {
if (!ctype_digit((string)$ref['identifier'])) {
continue;
}
$sql[] = qsprintf(
$conn,
'(commit.repositoryID = %d AND commit.commitIdentifier = %s)',
$repo->getID(),
// NOTE: Because the 'commitIdentifier' column is a string, MySQL
// ignores the index if we hand it an integer. Hand it a string.
// See T3377.
(int)$ref['identifier']);
} else {
if (strlen($ref['identifier']) < $min_qualified) {
continue;
}
$identifier = $ref['identifier'];
if (strlen($identifier) == 40) {
// MySQL seems to do slightly better with this version if the
// clause, so issue it if we have a full commit hash.
$sql[] = qsprintf(
$conn,
'(commit.repositoryID = %d
AND commit.commitIdentifier = %s)',
$repo->getID(),
$identifier);
} else {
$sql[] = qsprintf(
$conn,
'(commit.repositoryID = %d
AND commit.commitIdentifier LIKE %>)',
$repo->getID(),
$identifier);
}
}
}
}
if (!$sql) {
// If we discarded all possible identifiers (e.g., they all referenced
// bogus repositories or were all too short), make sure the query finds
// nothing.
throw new PhabricatorEmptyQueryException(
pht('No commit identifiers.'));
}
$where[] = '('.implode(' OR ', $sql).')';
}
if ($this->auditIDs !== null) {
$where[] = qsprintf(
$conn,
'auditor.id IN (%Ld)',
$this->auditIDs);
}
if ($this->auditorPHIDs !== null) {
$where[] = qsprintf(
$conn,
'auditor.auditorPHID IN (%Ls)',
$this->auditorPHIDs);
}
if ($this->responsiblePHIDs !== null) {
$where[] = qsprintf(
$conn,
'(audit.auditorPHID IN (%Ls) OR commit.authorPHID IN (%Ls))',
$this->responsiblePHIDs,
$this->responsiblePHIDs);
}
if ($this->statuses !== null) {
$where[] = qsprintf(
$conn,
'commit.auditStatus IN (%Ls)',
$this->statuses);
}
if ($this->packagePHIDs !== null) {
$where[] = qsprintf(
$conn,
'package.dst IN (%Ls)',
$this->packagePHIDs);
}
if ($this->unreachable !== null) {
if ($this->unreachable) {
$where[] = qsprintf(
$conn,
'(commit.importStatus & %d) = %d',
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE,
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE);
} else {
$where[] = qsprintf(
$conn,
'(commit.importStatus & %d) = 0',
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE);
}
}
return $where;
}
protected function didFilterResults(array $filtered) {
if ($this->identifierMap) {
foreach ($this->identifierMap as $name => $commit) {
if (isset($filtered[$commit->getPHID()])) {
unset($this->identifierMap[$name]);
}
}
}
}
private function shouldJoinAuditor() {
return ($this->auditIDs || $this->auditorPHIDs);
}
private function shouldJoinAudit() {
return (bool)$this->responsiblePHIDs;
}
private function shouldJoinOwners() {
return (bool)$this->packagePHIDs;
}
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
$join = parent::buildJoinClauseParts($conn);
$audit_request = new PhabricatorRepositoryAuditRequest();
if ($this->shouldJoinAuditor()) {
$join[] = qsprintf(
$conn,
'JOIN %T auditor ON commit.phid = auditor.commitPHID',
$audit_request->getTableName());
}
if ($this->shouldJoinAudit()) {
$join[] = qsprintf(
$conn,
'LEFT JOIN %T audit ON commit.phid = audit.commitPHID',
$audit_request->getTableName());
}
if ($this->shouldJoinOwners()) {
$join[] = qsprintf(
$conn,
'JOIN %T package ON commit.phid = package.src
AND package.type = %s',
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
DiffusionCommitHasPackageEdgeType::EDGECONST);
}
return $join;
}
protected function shouldGroupQueryResultRows() {
if ($this->shouldJoinAuditor()) {
return true;
}
if ($this->shouldJoinAudit()) {
return true;
}
if ($this->shouldJoinOwners()) {
return true;
}
return parent::shouldGroupQueryResultRows();
}
public function getQueryApplicationClass() {
return 'PhabricatorDiffusionApplication';
}
public function getOrderableColumns() {
return parent::getOrderableColumns() + array(
'epoch' => array(
'table' => $this->getPrimaryTableAlias(),
'column' => 'epoch',
'type' => 'int',
'reverse' => false,
),
);
}
protected function getPagingValueMap($cursor, array $keys) {
$commit = $this->loadCursorObject($cursor);
return array(
'id' => $commit->getID(),
'epoch' => $commit->getEpoch(),
);
}
public function getBuiltinOrders() {
$parent = parent::getBuiltinOrders();
// Rename the default ID-based orders.
$parent['importnew'] = array(
'name' => pht('Import Date (Newest First)'),
) + $parent['newest'];
$parent['importold'] = array(
'name' => pht('Import Date (Oldest First)'),
) + $parent['oldest'];
return array(
'newest' => array(
'vector' => array('epoch', 'id'),
'name' => pht('Commit Date (Newest First)'),
),
'oldest' => array(
'vector' => array('-epoch', '-id'),
'name' => pht('Commit Date (Oldest First)'),
),
) + $parent;
}
}
diff --git a/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php b/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php
index ff1cc9f3c5..30ec8d9f6d 100644
--- a/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php
+++ b/src/applications/diffusion/xaction/DiffusionCommitConcernTransaction.php
@@ -1,82 +1,82 @@
<?php
final class DiffusionCommitConcernTransaction
extends DiffusionCommitAuditTransaction {
const TRANSACTIONTYPE = 'diffusion.commit.concern';
const ACTIONKEY = 'concern';
protected function getCommitActionLabel() {
return pht("Raise Concern \xE2\x9C\x98");
}
protected function getCommitActionDescription() {
return pht('This commit will be returned to the author for consideration.');
}
public function getIcon() {
return 'fa-times-circle-o';
}
public function getColor() {
return 'red';
}
protected function getCommitActionOrder() {
return 600;
}
public function getActionName() {
return pht('Raised Concern');
}
public function applyInternalEffects($object, $value) {
// NOTE: We force the commit directly into "Concern Raised" so that we
// override a possible "Needs Verification" state.
$object->setAuditStatus(
PhabricatorAuditCommitStatusConstants::CONCERN_RAISED);
}
public function applyExternalEffects($object, $value) {
$status = PhabricatorAuditStatusConstants::CONCERNED;
$actor = $this->getActor();
$this->applyAuditorEffect($object, $actor, $value, $status);
}
protected function validateAction($object, PhabricatorUser $viewer) {
if ($this->isViewerCommitAuthor($object, $viewer)) {
throw new Exception(
pht(
'You can not raise a concern with this commit because you are '.
'the commit author. You can only raise concerns with commits '.
'you did not author.'));
}
// Even if you've already raised a concern, you can raise again as long
- // as the author requsted you verify.
+ // as the author requested you verify.
$state_verify = PhabricatorAuditCommitStatusConstants::NEEDS_VERIFICATION;
if ($this->isViewerFullyRejected($object, $viewer)) {
if ($object->getAuditStatus() != $state_verify) {
throw new Exception(
pht(
'You can not raise a concern with this commit because you have '.
'already raised a concern with it.'));
}
}
}
public function getTitle() {
return pht(
'%s raised a concern with this commit.',
$this->renderAuthor());
}
public function getTitleForFeed() {
return pht(
'%s raised a concern with %s.',
$this->renderAuthor(),
$this->renderObject());
}
}
diff --git a/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php b/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php
index f9b3fc5161..a44261f7a4 100644
--- a/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php
+++ b/src/applications/diffusion/xaction/DiffusionCommitVerifyTransaction.php
@@ -1,73 +1,73 @@
<?php
final class DiffusionCommitVerifyTransaction
extends DiffusionCommitAuditTransaction {
const TRANSACTIONTYPE = 'diffusion.commit.verify';
const ACTIONKEY = 'verify';
protected function getCommitActionLabel() {
return pht('Request Verification');
}
protected function getCommitActionDescription() {
return pht(
'Auditors will be asked to verify that concerns have been addressed.');
}
protected function getCommitActionGroupKey() {
return DiffusionCommitEditEngine::ACTIONGROUP_COMMIT;
}
public function getIcon() {
return 'fa-refresh';
}
public function getColor() {
return 'indigo';
}
protected function getCommitActionOrder() {
return 600;
}
public function getActionName() {
return pht('Requested Verification');
}
public function applyInternalEffects($object, $value) {
$object->setAuditStatus(
PhabricatorAuditCommitStatusConstants::NEEDS_VERIFICATION);
}
protected function validateAction($object, PhabricatorUser $viewer) {
if (!$this->isViewerCommitAuthor($object, $viewer)) {
throw new Exception(
pht(
'You can not request verification of this commit because you '.
'are not the author.'));
}
$status = $object->getAuditStatus();
if ($status != PhabricatorAuditCommitStatusConstants::CONCERN_RAISED) {
throw new Exception(
pht(
'You can not request verification of this commit because no '.
- 'auditors have raised conerns with it.'));
+ 'auditors have raised concerns with it.'));
}
}
public function getTitle() {
return pht(
'%s requested verification of this commit.',
$this->renderAuthor());
}
public function getTitleForFeed() {
return pht(
'%s requested verification of %s.',
$this->renderAuthor(),
$this->renderObject());
}
}
diff --git a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php
index 76a81d7ef1..a050a859e3 100644
--- a/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php
+++ b/src/applications/drydock/blueprint/DrydockBlueprintImplementation.php
@@ -1,489 +1,489 @@
<?php
/**
* @task lease Lease Acquisition
* @task resource Resource Allocation
* @task interface Resource Interfaces
* @task log Logging
*/
abstract class DrydockBlueprintImplementation extends Phobject {
abstract public function getType();
abstract public function isEnabled();
abstract public function getBlueprintName();
abstract public function getDescription();
public function getBlueprintIcon() {
return 'fa-map-o';
}
public function getFieldSpecifications() {
$fields = array();
$fields += $this->getCustomFieldSpecifications();
if ($this->shouldUseConcurrentResourceLimit()) {
$fields += array(
'allocator.limit' => array(
'name' => pht('Limit'),
'caption' => pht(
'Maximum number of resources this blueprint can have active '.
'concurrently.'),
'type' => 'int',
),
);
}
return $fields;
}
protected function getCustomFieldSpecifications() {
return array();
}
public function getViewer() {
return PhabricatorUser::getOmnipotentUser();
}
/* -( Lease Acquisition )-------------------------------------------------- */
/**
* Enforce basic checks on lease/resource compatibility. Allows resources to
* reject leases if they are incompatible, even if the resource types match.
*
* For example, if a resource represents a 32-bit host, this method might
* reject leases that need a 64-bit host. The blueprint might also reject
* a resource if the lease needs 8GB of RAM and the resource only has 6GB
* free.
*
* This method should not acquire locks or expect anything to be locked. This
* is a coarse compatibility check between a lease and a resource.
*
* @param DrydockBlueprint Concrete blueprint to allocate for.
- * @param DrydockResource Candidiate resource to allocate the lease on.
+ * @param DrydockResource Candidate resource to allocate the lease on.
* @param DrydockLease Pending lease that wants to allocate here.
* @return bool True if the resource and lease are compatible.
* @task lease
*/
abstract public function canAcquireLeaseOnResource(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease);
/**
- * Acquire a lease. Allows resources to peform setup as leases are brought
+ * Acquire a lease. Allows resources to perform setup as leases are brought
* online.
*
* If acquisition fails, throw an exception.
*
* @param DrydockBlueprint Blueprint which built the resource.
* @param DrydockResource Resource to acquire a lease on.
* @param DrydockLease Requested lease.
* @return void
* @task lease
*/
abstract public function acquireLease(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease);
/**
* @return void
* @task lease
*/
public function activateLease(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease) {
throw new PhutilMethodNotImplementedException();
}
/**
* React to a lease being released.
*
* This callback is primarily useful for automatically releasing resources
* once all leases are released.
*
* @param DrydockBlueprint Blueprint which built the resource.
* @param DrydockResource Resource a lease was released on.
* @param DrydockLease Recently released lease.
* @return void
* @task lease
*/
abstract public function didReleaseLease(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease);
/**
* Destroy any temporary data associated with a lease.
*
* If a lease creates temporary state while held, destroy it here.
*
* @param DrydockBlueprint Blueprint which built the resource.
* @param DrydockResource Resource the lease is acquired on.
* @param DrydockLease The lease being destroyed.
* @return void
* @task lease
*/
abstract public function destroyLease(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease);
/* -( Resource Allocation )------------------------------------------------ */
/**
* Enforce fundamental implementation/lease checks. Allows implementations to
* reject a lease which no concrete blueprint can ever satisfy.
*
* For example, if a lease only builds ARM hosts and the lease needs a
* PowerPC host, it may be rejected here.
*
* This is the earliest rejection phase, and followed by
* @{method:canEverAllocateResourceForLease}.
*
* This method should not actually check if a resource can be allocated
* right now, or even if a blueprint which can allocate a suitable resource
* really exists, only if some blueprint may conceivably exist which could
* plausibly be able to build a suitable resource.
*
* @param DrydockLease Requested lease.
* @return bool True if some concrete blueprint of this implementation's
* type might ever be able to build a resource for the lease.
* @task resource
*/
abstract public function canAnyBlueprintEverAllocateResourceForLease(
DrydockLease $lease);
/**
* Enforce basic blueprint/lease checks. Allows blueprints to reject a lease
* which they can not build a resource for.
*
* This is the second rejection phase. It follows
* @{method:canAnyBlueprintEverAllocateResourceForLease} and is followed by
* @{method:canAllocateResourceForLease}.
*
* This method should not check if a resource can be built right now, only
* if the blueprint as configured may, at some time, be able to build a
* suitable resource.
*
* @param DrydockBlueprint Blueprint which may be asked to allocate a
* resource.
* @param DrydockLease Requested lease.
* @return bool True if this blueprint can eventually build a suitable
* resource for the lease, as currently configured.
* @task resource
*/
abstract public function canEverAllocateResourceForLease(
DrydockBlueprint $blueprint,
DrydockLease $lease);
/**
* Enforce basic availability limits. Allows blueprints to reject resource
* allocation if they are currently overallocated.
*
* This method should perform basic capacity/limit checks. For example, if
* it has a limit of 6 resources and currently has 6 resources allocated,
* it might reject new leases.
*
* This method should not acquire locks or expect locks to be acquired. This
* is a coarse check to determine if the operation is likely to succeed
* right now without needing to acquire locks.
*
* It is expected that this method will sometimes return `true` (indicating
* that a resource can be allocated) but find that another allocator has
* eaten up free capacity by the time it actually tries to build a resource.
* This is normal and the allocator will recover from it.
*
* @param DrydockBlueprint The blueprint which may be asked to allocate a
* resource.
* @param DrydockLease Requested lease.
* @return bool True if this blueprint appears likely to be able to allocate
* a suitable resource.
* @task resource
*/
abstract public function canAllocateResourceForLease(
DrydockBlueprint $blueprint,
DrydockLease $lease);
/**
* Allocate a suitable resource for a lease.
*
* This method MUST acquire, hold, and manage locks to prevent multiple
* allocations from racing. World state is not locked before this method is
* called. Blueprints are entirely responsible for any lock handling they
* need to perform.
*
* @param DrydockBlueprint The blueprint which should allocate a resource.
* @param DrydockLease Requested lease.
* @return DrydockResource Allocated resource.
* @task resource
*/
abstract public function allocateResource(
DrydockBlueprint $blueprint,
DrydockLease $lease);
/**
* @task resource
*/
public function activateResource(
DrydockBlueprint $blueprint,
DrydockResource $resource) {
throw new PhutilMethodNotImplementedException();
}
/**
* Destroy any temporary data associated with a resource.
*
* If a resource creates temporary state when allocated, destroy that state
* here. For example, you might shut down a virtual host or destroy a working
* copy on disk.
*
* @param DrydockBlueprint Blueprint which built the resource.
* @param DrydockResource Resource being destroyed.
* @return void
* @task resource
*/
abstract public function destroyResource(
DrydockBlueprint $blueprint,
DrydockResource $resource);
/**
* Get a human readable name for a resource.
*
* @param DrydockBlueprint Blueprint which built the resource.
* @param DrydockResource Resource to get the name of.
* @return string Human-readable resource name.
* @task resource
*/
abstract public function getResourceName(
DrydockBlueprint $blueprint,
DrydockResource $resource);
/* -( Resource Interfaces )------------------------------------------------ */
abstract public function getInterface(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease,
$type);
/* -( Logging )------------------------------------------------------------ */
public static function getAllBlueprintImplementations() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->execute();
}
public static function getNamedImplementation($class) {
return idx(self::getAllBlueprintImplementations(), $class);
}
protected function newResourceTemplate(DrydockBlueprint $blueprint) {
$resource = id(new DrydockResource())
->setBlueprintPHID($blueprint->getPHID())
->attachBlueprint($blueprint)
->setType($this->getType())
->setStatus(DrydockResourceStatus::STATUS_PENDING);
// Pre-allocate the resource PHID.
$resource->setPHID($resource->generatePHID());
return $resource;
}
protected function newLease(DrydockBlueprint $blueprint) {
return DrydockLease::initializeNewLease()
->setAuthorizingPHID($blueprint->getPHID());
}
protected function requireActiveLease(DrydockLease $lease) {
$lease_status = $lease->getStatus();
switch ($lease_status) {
case DrydockLeaseStatus::STATUS_PENDING:
case DrydockLeaseStatus::STATUS_ACQUIRED:
throw new PhabricatorWorkerYieldException(15);
case DrydockLeaseStatus::STATUS_ACTIVE:
return;
default:
throw new Exception(
pht(
'Lease ("%s") is in bad state ("%s"), expected "%s".',
$lease->getPHID(),
$lease_status,
DrydockLeaseStatus::STATUS_ACTIVE));
}
}
/**
* Does this implementation use concurrent resource limits?
*
* Implementations can override this method to opt into standard limit
* behavior, which provides a simple concurrent resource limit.
*
* @return bool True to use limits.
*/
protected function shouldUseConcurrentResourceLimit() {
return false;
}
/**
* Get the effective concurrent resource limit for this blueprint.
*
* @param DrydockBlueprint Blueprint to get the limit for.
* @return int|null Limit, or `null` for no limit.
*/
protected function getConcurrentResourceLimit(DrydockBlueprint $blueprint) {
if ($this->shouldUseConcurrentResourceLimit()) {
$limit = $blueprint->getFieldValue('allocator.limit');
$limit = (int)$limit;
if ($limit > 0) {
return $limit;
} else {
return null;
}
}
return null;
}
protected function getConcurrentResourceLimitSlotLock(
DrydockBlueprint $blueprint) {
$limit = $this->getConcurrentResourceLimit($blueprint);
if ($limit === null) {
return;
}
$blueprint_phid = $blueprint->getPHID();
// TODO: This logic shouldn't do anything awful, but is a little silly. It
// would be nice to unify the "huge limit" and "small limit" cases
// eventually but it's a little tricky.
// If the limit is huge, just pick a random slot. This is just stopping
// us from exploding if someone types a billion zillion into the box.
if ($limit > 1024) {
$slot = mt_rand(0, $limit - 1);
return "allocator({$blueprint_phid}).limit({$slot})";
}
// For reasonable limits, actually check for an available slot.
$locks = DrydockSlotLock::loadLocks($blueprint_phid);
$locks = mpull($locks, null, 'getLockKey');
$slots = range(0, $limit - 1);
shuffle($slots);
foreach ($slots as $slot) {
$slot_lock = "allocator({$blueprint_phid}).limit({$slot})";
if (empty($locks[$slot_lock])) {
return $slot_lock;
}
}
// If we found no free slot, just return whatever we checked last (which
// is just a random slot). There's a small chance we'll get lucky and the
// lock will be free by the time we try to take it, but usually we'll just
// fail to grab the lock, throw an appropriate lock exception, and get back
// on the right path to retry later.
return $slot_lock;
}
/**
* Apply standard limits on resource allocation rate.
*
* @param DrydockBlueprint The blueprint requesting an allocation.
* @return bool True if further allocations should be limited.
*/
protected function shouldLimitAllocatingPoolSize(
DrydockBlueprint $blueprint) {
// TODO: If this mechanism sticks around, these values should be
// configurable by the blueprint implementation.
// Limit on total number of active resources.
$total_limit = $this->getConcurrentResourceLimit($blueprint);
// Always allow at least this many allocations to be in flight at once.
$min_allowed = 1;
// Allow this fraction of allocating resources as a fraction of active
// resources.
$growth_factor = 0.25;
$resource = new DrydockResource();
$conn_r = $resource->establishConnection('r');
$counts = queryfx_all(
$conn_r,
'SELECT status, COUNT(*) N FROM %T
WHERE blueprintPHID = %s AND status != %s
GROUP BY status',
$resource->getTableName(),
$blueprint->getPHID(),
DrydockResourceStatus::STATUS_DESTROYED);
$counts = ipull($counts, 'N', 'status');
$n_alloc = idx($counts, DrydockResourceStatus::STATUS_PENDING, 0);
$n_active = idx($counts, DrydockResourceStatus::STATUS_ACTIVE, 0);
$n_broken = idx($counts, DrydockResourceStatus::STATUS_BROKEN, 0);
$n_released = idx($counts, DrydockResourceStatus::STATUS_RELEASED, 0);
// If we're at the limit on total active resources, limit additional
// allocations.
if ($total_limit !== null) {
$n_total = ($n_alloc + $n_active + $n_broken + $n_released);
if ($n_total >= $total_limit) {
return true;
}
}
// If the number of in-flight allocations is fewer than the minimum number
// of allowed allocations, don't impose a limit.
if ($n_alloc < $min_allowed) {
return false;
}
$allowed_alloc = (int)ceil($n_active * $growth_factor);
// If the number of in-flight allocation is fewer than the number of
// allowed allocations according to the pool growth factor, don't impose
// a limit.
if ($n_alloc < $allowed_alloc) {
return false;
}
return true;
}
}
diff --git a/src/applications/drydock/storage/DrydockAuthorization.php b/src/applications/drydock/storage/DrydockAuthorization.php
index cfd186c9d3..32e694918c 100644
--- a/src/applications/drydock/storage/DrydockAuthorization.php
+++ b/src/applications/drydock/storage/DrydockAuthorization.php
@@ -1,256 +1,256 @@
<?php
final class DrydockAuthorization extends DrydockDAO
implements
PhabricatorPolicyInterface,
PhabricatorConduitResultInterface {
const OBJECTAUTH_ACTIVE = 'active';
const OBJECTAUTH_INACTIVE = 'inactive';
const BLUEPRINTAUTH_REQUESTED = 'requested';
const BLUEPRINTAUTH_AUTHORIZED = 'authorized';
const BLUEPRINTAUTH_DECLINED = 'declined';
protected $blueprintPHID;
protected $blueprintAuthorizationState;
protected $objectPHID;
protected $objectAuthorizationState;
private $blueprint = self::ATTACHABLE;
private $object = self::ATTACHABLE;
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'blueprintAuthorizationState' => 'text32',
'objectAuthorizationState' => 'text32',
),
self::CONFIG_KEY_SCHEMA => array(
'key_unique' => array(
'columns' => array('objectPHID', 'blueprintPHID'),
'unique' => true,
),
'key_blueprint' => array(
'columns' => array('blueprintPHID', 'blueprintAuthorizationState'),
),
'key_object' => array(
'columns' => array('objectPHID', 'objectAuthorizationState'),
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
DrydockAuthorizationPHIDType::TYPECONST);
}
public function attachBlueprint(DrydockBlueprint $blueprint) {
$this->blueprint = $blueprint;
return $this;
}
public function getBlueprint() {
return $this->assertAttached($this->blueprint);
}
public function attachObject($object) {
$this->object = $object;
return $this;
}
public function getObject() {
return $this->assertAttached($this->object);
}
public static function getBlueprintStateIcon($state) {
$map = array(
self::BLUEPRINTAUTH_REQUESTED => 'fa-exclamation-circle pink',
self::BLUEPRINTAUTH_AUTHORIZED => 'fa-check-circle green',
self::BLUEPRINTAUTH_DECLINED => 'fa-times red',
);
return idx($map, $state, null);
}
public static function getBlueprintStateName($state) {
$map = array(
self::BLUEPRINTAUTH_REQUESTED => pht('Requested'),
self::BLUEPRINTAUTH_AUTHORIZED => pht('Authorized'),
self::BLUEPRINTAUTH_DECLINED => pht('Declined'),
);
return idx($map, $state, pht('<Unknown: %s>', $state));
}
public static function getObjectStateName($state) {
$map = array(
self::OBJECTAUTH_ACTIVE => pht('Active'),
self::OBJECTAUTH_INACTIVE => pht('Inactive'),
);
return idx($map, $state, pht('<Unknown: %s>', $state));
}
public function isAuthorized() {
$state = $this->getBlueprintAuthorizationState();
return ($state == self::BLUEPRINTAUTH_AUTHORIZED);
}
/**
- * Apply external authorization effects after a user chagnes the value of a
+ * Apply external authorization effects after a user changes the value of a
* blueprint selector control an object.
*
* @param PhabricatorUser User applying the change.
* @param phid Object PHID change is being applied to.
* @param list<phid> Old blueprint PHIDs.
* @param list<phid> New blueprint PHIDs.
* @return void
*/
public static function applyAuthorizationChanges(
PhabricatorUser $viewer,
$object_phid,
array $old,
array $new) {
$old_phids = array_fuse($old);
$new_phids = array_fuse($new);
$rem_phids = array_diff_key($old_phids, $new_phids);
$add_phids = array_diff_key($new_phids, $old_phids);
$altered_phids = $rem_phids + $add_phids;
if (!$altered_phids) {
return;
}
$authorizations = id(new DrydockAuthorizationQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withObjectPHIDs(array($object_phid))
->withBlueprintPHIDs($altered_phids)
->execute();
$authorizations = mpull($authorizations, null, 'getBlueprintPHID');
$state_active = self::OBJECTAUTH_ACTIVE;
$state_inactive = self::OBJECTAUTH_INACTIVE;
$state_requested = self::BLUEPRINTAUTH_REQUESTED;
// Disable the object side of the authorization for any existing
// authorizations.
foreach ($rem_phids as $rem_phid) {
$authorization = idx($authorizations, $rem_phid);
if (!$authorization) {
continue;
}
$authorization
->setObjectAuthorizationState($state_inactive)
->save();
}
// For new authorizations, either add them or reactivate them depending
// on the current state.
foreach ($add_phids as $add_phid) {
$needs_update = false;
$authorization = idx($authorizations, $add_phid);
if (!$authorization) {
$authorization = id(new DrydockAuthorization())
->setObjectPHID($object_phid)
->setObjectAuthorizationState($state_active)
->setBlueprintPHID($add_phid)
->setBlueprintAuthorizationState($state_requested);
$needs_update = true;
} else {
$current_state = $authorization->getObjectAuthorizationState();
if ($current_state != $state_active) {
$authorization->setObjectAuthorizationState($state_active);
$needs_update = true;
}
}
if ($needs_update) {
$authorization->save();
}
}
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
return $this->getBlueprint()->getPolicy($capability);
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return $this->getBlueprint()->hasAutomaticCapability($capability, $viewer);
}
public function describeAutomaticCapability($capability) {
return pht(
'An authorization inherits the policies of the blueprint it '.
'authorizes access to.');
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('blueprintPHID')
->setType('phid')
->setDescription(pht(
'PHID of the blueprint this request was made for.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('blueprintAuthorizationState')
->setType('map<string, wild>')
->setDescription(pht('Authorization state of this request.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('objectPHID')
->setType('phid')
->setDescription(pht(
'PHID of the object which requested authorization.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('objectAuthorizationState')
->setType('map<string, wild>')
->setDescription(pht('Authorization state of the requesting object.')),
);
}
public function getFieldValuesForConduit() {
$blueprint_state = $this->getBlueprintAuthorizationState();
$object_state = $this->getObjectAuthorizationState();
return array(
'blueprintPHID' => $this->getBlueprintPHID(),
'blueprintAuthorizationState' => array(
'value' => $blueprint_state,
'name' => self::getBlueprintStateName($blueprint_state),
),
'objectPHID' => $this->getObjectPHID(),
'objectAuthorizationState' => array(
'value' => $object_state,
'name' => self::getObjectStateName($object_state),
),
);
}
public function getConduitSearchAttachments() {
return array(
);
}
}
diff --git a/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
index a41e57fbf8..d73506f286 100644
--- a/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
+++ b/src/applications/drydock/worker/DrydockLeaseUpdateWorker.php
@@ -1,854 +1,854 @@
<?php
/**
* @task update Updating Leases
* @task command Processing Commands
* @task allocator Drydock Allocator
* @task acquire Acquiring Leases
* @task activate Activating Leases
* @task release Releasing Leases
* @task break Breaking Leases
* @task destroy Destroying Leases
*/
final class DrydockLeaseUpdateWorker extends DrydockWorker {
protected function doWork() {
$lease_phid = $this->getTaskDataValue('leasePHID');
$hash = PhabricatorHash::digestForIndex($lease_phid);
$lock_key = 'drydock.lease:'.$hash;
$lock = PhabricatorGlobalLock::newLock($lock_key)
->lock(1);
try {
$lease = $this->loadLease($lease_phid);
$this->handleUpdate($lease);
} catch (Exception $ex) {
$lock->unlock();
$this->flushDrydockTaskQueue();
throw $ex;
}
$lock->unlock();
}
/* -( Updating Leases )---------------------------------------------------- */
/**
* @task update
*/
private function handleUpdate(DrydockLease $lease) {
try {
$this->updateLease($lease);
} catch (Exception $ex) {
if ($this->isTemporaryException($ex)) {
$this->yieldLease($lease, $ex);
} else {
$this->breakLease($lease, $ex);
}
}
}
/**
* @task update
*/
private function updateLease(DrydockLease $lease) {
$this->processLeaseCommands($lease);
$lease_status = $lease->getStatus();
switch ($lease_status) {
case DrydockLeaseStatus::STATUS_PENDING:
$this->executeAllocator($lease);
break;
case DrydockLeaseStatus::STATUS_ACQUIRED:
$this->activateLease($lease);
break;
case DrydockLeaseStatus::STATUS_ACTIVE:
// Nothing to do.
break;
case DrydockLeaseStatus::STATUS_RELEASED:
case DrydockLeaseStatus::STATUS_BROKEN:
$this->destroyLease($lease);
break;
case DrydockLeaseStatus::STATUS_DESTROYED:
break;
}
$this->yieldIfExpiringLease($lease);
}
/**
* @task update
*/
private function yieldLease(DrydockLease $lease, Exception $ex) {
$duration = $this->getYieldDurationFromException($ex);
$lease->logEvent(
DrydockLeaseActivationYieldLogType::LOGCONST,
array(
'duration' => $duration,
));
throw new PhabricatorWorkerYieldException($duration);
}
/* -( Processing Commands )------------------------------------------------ */
/**
* @task command
*/
private function processLeaseCommands(DrydockLease $lease) {
if (!$lease->canReceiveCommands()) {
return;
}
$this->checkLeaseExpiration($lease);
$commands = $this->loadCommands($lease->getPHID());
foreach ($commands as $command) {
if (!$lease->canReceiveCommands()) {
break;
}
$this->processLeaseCommand($lease, $command);
$command
->setIsConsumed(true)
->save();
}
}
/**
* @task command
*/
private function processLeaseCommand(
DrydockLease $lease,
DrydockCommand $command) {
switch ($command->getCommand()) {
case DrydockCommand::COMMAND_RELEASE:
$this->releaseLease($lease);
break;
}
}
/* -( Drydock Allocator )-------------------------------------------------- */
/**
* Find or build a resource which can satisfy a given lease request, then
* acquire the lease.
*
* @param DrydockLease Requested lease.
* @return void
* @task allocator
*/
private function executeAllocator(DrydockLease $lease) {
$blueprints = $this->loadBlueprintsForAllocatingLease($lease);
// If we get nothing back, that means no blueprint is defined which can
// ever build the requested resource. This is a permanent failure, since
// we don't expect to succeed no matter how many times we try.
if (!$blueprints) {
throw new PhabricatorWorkerPermanentFailureException(
pht(
'No active Drydock blueprint exists which can ever allocate a '.
'resource for lease "%s".',
$lease->getPHID()));
}
// First, try to find a suitable open resource which we can acquire a new
// lease on.
$resources = $this->loadResourcesForAllocatingLease($blueprints, $lease);
// If no resources exist yet, see if we can build one.
if (!$resources) {
$usable_blueprints = $this->removeOverallocatedBlueprints(
$blueprints,
$lease);
// If we get nothing back here, some blueprint claims it can eventually
// satisfy the lease, just not right now. This is a temporary failure,
// and we expect allocation to succeed eventually.
if (!$usable_blueprints) {
$blueprints = $this->rankBlueprints($blueprints, $lease);
// Try to actively reclaim unused resources. If we succeed, jump back
// into the queue in an effort to claim it.
foreach ($blueprints as $blueprint) {
$reclaimed = $this->reclaimResources($blueprint, $lease);
if ($reclaimed) {
$lease->logEvent(
DrydockLeaseReclaimLogType::LOGCONST,
array(
'resourcePHIDs' => array($reclaimed->getPHID()),
));
throw new PhabricatorWorkerYieldException(15);
}
}
$lease->logEvent(
DrydockLeaseWaitingForResourcesLogType::LOGCONST,
array(
'blueprintPHIDs' => mpull($blueprints, 'getPHID'),
));
throw new PhabricatorWorkerYieldException(15);
}
$usable_blueprints = $this->rankBlueprints($usable_blueprints, $lease);
$exceptions = array();
foreach ($usable_blueprints as $blueprint) {
try {
$resources[] = $this->allocateResource($blueprint, $lease);
// Bail after allocating one resource, we don't need any more than
// this.
break;
} catch (Exception $ex) {
$exceptions[] = $ex;
}
}
if (!$resources) {
throw new PhutilAggregateException(
pht(
'All blueprints failed to allocate a suitable new resource when '.
'trying to allocate lease "%s".',
$lease->getPHID()),
$exceptions);
}
$resources = $this->removeUnacquirableResources($resources, $lease);
if (!$resources) {
// If we make it here, we just built a resource but aren't allowed
// to acquire it. We expect this during routine operation if the
// resource prevents acquisition until it activates. Yield and wait
// for activation.
throw new PhabricatorWorkerYieldException(15);
}
// NOTE: We have not acquired the lease yet, so it is possible that the
// resource we just built will be snatched up by some other lease before
// we can acquire it. This is not problematic: we'll retry a little later
- // and should suceed eventually.
+ // and should succeed eventually.
}
$resources = $this->rankResources($resources, $lease);
$exceptions = array();
$allocated = false;
foreach ($resources as $resource) {
try {
$this->acquireLease($resource, $lease);
$allocated = true;
break;
} catch (Exception $ex) {
$exceptions[] = $ex;
}
}
if (!$allocated) {
throw new PhutilAggregateException(
pht(
- 'Unable to acquire lease "%s" on any resouce.',
+ 'Unable to acquire lease "%s" on any resource.',
$lease->getPHID()),
$exceptions);
}
}
/**
* Get all the @{class:DrydockBlueprintImplementation}s which can possibly
* build a resource to satisfy a lease.
*
* This method returns blueprints which might, at some time, be able to
* build a resource which can satisfy the lease. They may not be able to
* build that resource right now.
*
* @param DrydockLease Requested lease.
* @return list<DrydockBlueprintImplementation> List of qualifying blueprint
* implementations.
* @task allocator
*/
private function loadBlueprintImplementationsForAllocatingLease(
DrydockLease $lease) {
$impls = DrydockBlueprintImplementation::getAllBlueprintImplementations();
$keep = array();
foreach ($impls as $key => $impl) {
// Don't use disabled blueprint types.
if (!$impl->isEnabled()) {
continue;
}
// Don't use blueprint types which can't allocate the correct kind of
// resource.
if ($impl->getType() != $lease->getResourceType()) {
continue;
}
if (!$impl->canAnyBlueprintEverAllocateResourceForLease($lease)) {
continue;
}
$keep[$key] = $impl;
}
return $keep;
}
/**
* Get all the concrete @{class:DrydockBlueprint}s which can possibly
* build a resource to satisfy a lease.
*
* @param DrydockLease Requested lease.
* @return list<DrydockBlueprint> List of qualifying blueprints.
* @task allocator
*/
private function loadBlueprintsForAllocatingLease(
DrydockLease $lease) {
$viewer = $this->getViewer();
$impls = $this->loadBlueprintImplementationsForAllocatingLease($lease);
if (!$impls) {
return array();
}
$blueprint_phids = $lease->getAllowedBlueprintPHIDs();
if (!$blueprint_phids) {
$lease->logEvent(DrydockLeaseNoBlueprintsLogType::LOGCONST);
return array();
}
$query = id(new DrydockBlueprintQuery())
->setViewer($viewer)
->withPHIDs($blueprint_phids)
->withBlueprintClasses(array_keys($impls))
->withDisabled(false);
// The Drydock application itself is allowed to authorize anything. This
// is primarily used for leases generated by CLI administrative tools.
$drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
$authorizing_phid = $lease->getAuthorizingPHID();
if ($authorizing_phid != $drydock_phid) {
$blueprints = id(clone $query)
->withAuthorizedPHIDs(array($authorizing_phid))
->execute();
if (!$blueprints) {
// If we didn't hit any blueprints, check if this is an authorization
// problem: re-execute the query without the authorization constraint.
// If the second query hits blueprints, the overall configuration is
// fine but this is an authorization problem. If the second query also
// comes up blank, this is some other kind of configuration issue so
// we fall through to the default pathway.
$all_blueprints = $query->execute();
if ($all_blueprints) {
$lease->logEvent(
DrydockLeaseNoAuthorizationsLogType::LOGCONST,
array(
'authorizingPHID' => $authorizing_phid,
));
return array();
}
}
} else {
$blueprints = $query->execute();
}
$keep = array();
foreach ($blueprints as $key => $blueprint) {
if (!$blueprint->canEverAllocateResourceForLease($lease)) {
continue;
}
$keep[$key] = $blueprint;
}
return $keep;
}
/**
* Load a list of all resources which a given lease can possibly be
* allocated against.
*
* @param list<DrydockBlueprint> Blueprints which may produce suitable
* resources.
* @param DrydockLease Requested lease.
* @return list<DrydockResource> Resources which may be able to allocate
* the lease.
* @task allocator
*/
private function loadResourcesForAllocatingLease(
array $blueprints,
DrydockLease $lease) {
assert_instances_of($blueprints, 'DrydockBlueprint');
$viewer = $this->getViewer();
$resources = id(new DrydockResourceQuery())
->setViewer($viewer)
->withBlueprintPHIDs(mpull($blueprints, 'getPHID'))
->withTypes(array($lease->getResourceType()))
->withStatuses(
array(
DrydockResourceStatus::STATUS_PENDING,
DrydockResourceStatus::STATUS_ACTIVE,
))
->execute();
return $this->removeUnacquirableResources($resources, $lease);
}
/**
* Remove resources which can not be acquired by a given lease from a list.
*
* @param list<DrydockResource> Candidate resources.
* @param DrydockLease Acquiring lease.
* @return list<DrydockResource> Resources which the lease may be able to
* acquire.
* @task allocator
*/
private function removeUnacquirableResources(
array $resources,
DrydockLease $lease) {
$keep = array();
foreach ($resources as $key => $resource) {
$blueprint = $resource->getBlueprint();
if (!$blueprint->canAcquireLeaseOnResource($resource, $lease)) {
continue;
}
$keep[$key] = $resource;
}
return $keep;
}
/**
* Remove blueprints which are too heavily allocated to build a resource for
* a lease from a list of blueprints.
*
* @param list<DrydockBlueprint> List of blueprints.
* @return list<DrydockBlueprint> List with blueprints that can not allocate
* a resource for the lease right now removed.
* @task allocator
*/
private function removeOverallocatedBlueprints(
array $blueprints,
DrydockLease $lease) {
assert_instances_of($blueprints, 'DrydockBlueprint');
$keep = array();
foreach ($blueprints as $key => $blueprint) {
if (!$blueprint->canAllocateResourceForLease($lease)) {
continue;
}
$keep[$key] = $blueprint;
}
return $keep;
}
/**
* Rank blueprints by suitability for building a new resource for a
* particular lease.
*
* @param list<DrydockBlueprint> List of blueprints.
* @param DrydockLease Requested lease.
* @return list<DrydockBlueprint> Ranked list of blueprints.
* @task allocator
*/
private function rankBlueprints(array $blueprints, DrydockLease $lease) {
assert_instances_of($blueprints, 'DrydockBlueprint');
// TODO: Implement improvements to this ranking algorithm if they become
// available.
shuffle($blueprints);
return $blueprints;
}
/**
* Rank resources by suitability for allocating a particular lease.
*
* @param list<DrydockResource> List of resources.
* @param DrydockLease Requested lease.
* @return list<DrydockResource> Ranked list of resources.
* @task allocator
*/
private function rankResources(array $resources, DrydockLease $lease) {
assert_instances_of($resources, 'DrydockResource');
// TODO: Implement improvements to this ranking algorithm if they become
// available.
shuffle($resources);
return $resources;
}
/**
* Perform an actual resource allocation with a particular blueprint.
*
* @param DrydockBlueprint The blueprint to allocate a resource from.
* @param DrydockLease Requested lease.
* @return DrydockResource Allocated resource.
* @task allocator
*/
private function allocateResource(
DrydockBlueprint $blueprint,
DrydockLease $lease) {
$resource = $blueprint->allocateResource($lease);
$this->validateAllocatedResource($blueprint, $resource, $lease);
// If this resource was allocated as a pending resource, queue a task to
// activate it.
if ($resource->getStatus() == DrydockResourceStatus::STATUS_PENDING) {
PhabricatorWorker::scheduleTask(
'DrydockResourceUpdateWorker',
array(
'resourcePHID' => $resource->getPHID(),
),
array(
'objectPHID' => $resource->getPHID(),
));
}
return $resource;
}
/**
* Check that the resource a blueprint allocated is roughly the sort of
* object we expect.
*
* @param DrydockBlueprint Blueprint which built the resource.
* @param wild Thing which the blueprint claims is a valid resource.
* @param DrydockLease Lease the resource was allocated for.
* @return void
* @task allocator
*/
private function validateAllocatedResource(
DrydockBlueprint $blueprint,
$resource,
DrydockLease $lease) {
if (!($resource instanceof DrydockResource)) {
throw new Exception(
pht(
'Blueprint "%s" (of type "%s") is not properly implemented: %s must '.
'return an object of type %s or throw, but returned something else.',
$blueprint->getBlueprintName(),
$blueprint->getClassName(),
'allocateResource()',
'DrydockResource'));
}
if (!$resource->isAllocatedResource()) {
throw new Exception(
pht(
'Blueprint "%s" (of type "%s") is not properly implemented: %s '.
'must actually allocate the resource it returns.',
$blueprint->getBlueprintName(),
$blueprint->getClassName(),
'allocateResource()'));
}
$resource_type = $resource->getType();
$lease_type = $lease->getResourceType();
if ($resource_type !== $lease_type) {
throw new Exception(
pht(
'Blueprint "%s" (of type "%s") is not properly implemented: it '.
'built a resource of type "%s" to satisfy a lease requesting a '.
'resource of type "%s".',
$blueprint->getBlueprintName(),
$blueprint->getClassName(),
$resource_type,
$lease_type));
}
}
private function reclaimResources(
DrydockBlueprint $blueprint,
DrydockLease $lease) {
$viewer = $this->getViewer();
$resources = id(new DrydockResourceQuery())
->setViewer($viewer)
->withBlueprintPHIDs(array($blueprint->getPHID()))
->withStatuses(
array(
DrydockResourceStatus::STATUS_ACTIVE,
))
->execute();
// TODO: We could be much smarter about this and try to release long-unused
// resources, resources with many similar copies, old resources, resources
// that are cheap to rebuild, etc.
shuffle($resources);
foreach ($resources as $resource) {
if ($this->canReclaimResource($resource)) {
$this->reclaimResource($resource, $lease);
return $resource;
}
}
return null;
}
/* -( Acquiring Leases )--------------------------------------------------- */
/**
* Perform an actual lease acquisition on a particular resource.
*
* @param DrydockResource Resource to acquire a lease on.
* @param DrydockLease Lease to acquire.
* @return void
* @task acquire
*/
private function acquireLease(
DrydockResource $resource,
DrydockLease $lease) {
$blueprint = $resource->getBlueprint();
$blueprint->acquireLease($resource, $lease);
$this->validateAcquiredLease($blueprint, $resource, $lease);
// If this lease has been acquired but not activated, queue a task to
// activate it.
if ($lease->getStatus() == DrydockLeaseStatus::STATUS_ACQUIRED) {
$this->queueTask(
__CLASS__,
array(
'leasePHID' => $lease->getPHID(),
),
array(
'objectPHID' => $lease->getPHID(),
));
}
}
/**
* Make sure that a lease was really acquired properly.
*
* @param DrydockBlueprint Blueprint which created the resource.
* @param DrydockResource Resource which was acquired.
* @param DrydockLease The lease which was supposedly acquired.
* @return void
* @task acquire
*/
private function validateAcquiredLease(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease) {
if (!$lease->isAcquiredLease()) {
throw new Exception(
pht(
'Blueprint "%s" (of type "%s") is not properly implemented: it '.
'returned from "%s" without acquiring a lease.',
$blueprint->getBlueprintName(),
$blueprint->getClassName(),
'acquireLease()'));
}
$lease_phid = $lease->getResourcePHID();
$resource_phid = $resource->getPHID();
if ($lease_phid !== $resource_phid) {
throw new Exception(
pht(
'Blueprint "%s" (of type "%s") is not properly implemented: it '.
'returned from "%s" with a lease acquired on the wrong resource.',
$blueprint->getBlueprintName(),
$blueprint->getClassName(),
'acquireLease()'));
}
}
/* -( Activating Leases )-------------------------------------------------- */
/**
* @task activate
*/
private function activateLease(DrydockLease $lease) {
$resource = $lease->getResource();
if (!$resource) {
throw new Exception(
pht('Trying to activate lease with no resource.'));
}
$resource_status = $resource->getStatus();
if ($resource_status == DrydockResourceStatus::STATUS_PENDING) {
throw new PhabricatorWorkerYieldException(15);
}
if ($resource_status != DrydockResourceStatus::STATUS_ACTIVE) {
throw new Exception(
pht(
'Trying to activate lease on a dead resource (in status "%s").',
$resource_status));
}
// NOTE: We can race resource destruction here. Between the time we
// performed the read above and now, the resource might have closed, so
// we may activate leases on dead resources. At least for now, this seems
// fine: a resource dying right before we activate a lease on it should not
- // be distinguisahble from a resource dying right after we activate a lease
+ // be distinguishable from a resource dying right after we activate a lease
// on it. We end up with an active lease on a dead resource either way, and
// can not prevent resources dying from lightning strikes.
$blueprint = $resource->getBlueprint();
$blueprint->activateLease($resource, $lease);
$this->validateActivatedLease($blueprint, $resource, $lease);
}
/**
* @task activate
*/
private function validateActivatedLease(
DrydockBlueprint $blueprint,
DrydockResource $resource,
DrydockLease $lease) {
if (!$lease->isActivatedLease()) {
throw new Exception(
pht(
'Blueprint "%s" (of type "%s") is not properly implemented: it '.
'returned from "%s" without activating a lease.',
$blueprint->getBlueprintName(),
$blueprint->getClassName(),
'acquireLease()'));
}
}
/* -( Releasing Leases )--------------------------------------------------- */
/**
* @task release
*/
private function releaseLease(DrydockLease $lease) {
$lease
->setStatus(DrydockLeaseStatus::STATUS_RELEASED)
->save();
$lease->logEvent(DrydockLeaseReleasedLogType::LOGCONST);
$resource = $lease->getResource();
if ($resource) {
$blueprint = $resource->getBlueprint();
$blueprint->didReleaseLease($resource, $lease);
}
$this->destroyLease($lease);
}
/* -( Breaking Leases )---------------------------------------------------- */
/**
* @task break
*/
protected function breakLease(DrydockLease $lease, Exception $ex) {
switch ($lease->getStatus()) {
case DrydockLeaseStatus::STATUS_BROKEN:
case DrydockLeaseStatus::STATUS_RELEASED:
case DrydockLeaseStatus::STATUS_DESTROYED:
throw new PhutilProxyException(
pht(
'Unexpected failure while destroying lease ("%s").',
$lease->getPHID()),
$ex);
}
$lease
->setStatus(DrydockLeaseStatus::STATUS_BROKEN)
->save();
$lease->logEvent(
DrydockLeaseActivationFailureLogType::LOGCONST,
array(
'class' => get_class($ex),
'message' => $ex->getMessage(),
));
$lease->awakenTasks();
$this->queueTask(
__CLASS__,
array(
'leasePHID' => $lease->getPHID(),
),
array(
'objectPHID' => $lease->getPHID(),
));
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Permanent failure while activating lease ("%s"): %s',
$lease->getPHID(),
$ex->getMessage()));
}
/* -( Destroying Leases )-------------------------------------------------- */
/**
* @task destroy
*/
private function destroyLease(DrydockLease $lease) {
$resource = $lease->getResource();
if ($resource) {
$blueprint = $resource->getBlueprint();
$blueprint->destroyLease($resource, $lease);
}
DrydockSlotLock::releaseLocks($lease->getPHID());
$lease
->setStatus(DrydockLeaseStatus::STATUS_DESTROYED)
->save();
$lease->logEvent(DrydockLeaseDestroyedLogType::LOGCONST);
$lease->awakenTasks();
}
}
diff --git a/src/applications/drydock/worker/DrydockWorker.php b/src/applications/drydock/worker/DrydockWorker.php
index f167fef5d0..443780680d 100644
--- a/src/applications/drydock/worker/DrydockWorker.php
+++ b/src/applications/drydock/worker/DrydockWorker.php
@@ -1,255 +1,255 @@
<?php
abstract class DrydockWorker extends PhabricatorWorker {
protected function getViewer() {
return PhabricatorUser::getOmnipotentUser();
}
protected function loadLease($lease_phid) {
$viewer = $this->getViewer();
$lease = id(new DrydockLeaseQuery())
->setViewer($viewer)
->withPHIDs(array($lease_phid))
->executeOne();
if (!$lease) {
throw new PhabricatorWorkerPermanentFailureException(
pht('No such lease "%s"!', $lease_phid));
}
return $lease;
}
protected function loadResource($resource_phid) {
$viewer = $this->getViewer();
$resource = id(new DrydockResourceQuery())
->setViewer($viewer)
->withPHIDs(array($resource_phid))
->executeOne();
if (!$resource) {
throw new PhabricatorWorkerPermanentFailureException(
pht('No such resource "%s"!', $resource_phid));
}
return $resource;
}
protected function loadOperation($operation_phid) {
$viewer = $this->getViewer();
$operation = id(new DrydockRepositoryOperationQuery())
->setViewer($viewer)
->withPHIDs(array($operation_phid))
->executeOne();
if (!$operation) {
throw new PhabricatorWorkerPermanentFailureException(
pht('No such operation "%s"!', $operation_phid));
}
return $operation;
}
protected function loadCommands($target_phid) {
$viewer = $this->getViewer();
$commands = id(new DrydockCommandQuery())
->setViewer($viewer)
->withTargetPHIDs(array($target_phid))
->withConsumed(false)
->execute();
$commands = msort($commands, 'getID');
return $commands;
}
protected function checkLeaseExpiration(DrydockLease $lease) {
$this->checkObjectExpiration($lease);
}
protected function checkResourceExpiration(DrydockResource $resource) {
$this->checkObjectExpiration($resource);
}
private function checkObjectExpiration($object) {
// Check if the resource or lease has expired. If it has, we're going to
// send it a release command.
// This command is sent from within the update worker so it is handled
// immediately, but doing this generates a log and improves consistency.
$expires = $object->getUntil();
if (!$expires) {
return;
}
$now = PhabricatorTime::getNow();
if ($expires > $now) {
return;
}
$viewer = $this->getViewer();
$drydock_phid = id(new PhabricatorDrydockApplication())->getPHID();
$command = DrydockCommand::initializeNewCommand($viewer)
->setTargetPHID($object->getPHID())
->setAuthorPHID($drydock_phid)
->setCommand(DrydockCommand::COMMAND_RELEASE)
->save();
}
protected function yieldIfExpiringLease(DrydockLease $lease) {
if (!$lease->canReceiveCommands()) {
return;
}
$this->yieldIfExpiring($lease->getUntil());
}
protected function yieldIfExpiringResource(DrydockResource $resource) {
if (!$resource->canReceiveCommands()) {
return;
}
$this->yieldIfExpiring($resource->getUntil());
}
private function yieldIfExpiring($expires) {
if (!$expires) {
return;
}
if (!$this->getTaskDataValue('isExpireTask')) {
return;
}
$now = PhabricatorTime::getNow();
throw new PhabricatorWorkerYieldException($expires - $now);
}
protected function isTemporaryException(Exception $ex) {
if ($ex instanceof PhabricatorWorkerYieldException) {
return true;
}
if ($ex instanceof DrydockSlotLockException) {
return true;
}
if ($ex instanceof PhutilAggregateException) {
$any_temporary = false;
foreach ($ex->getExceptions() as $sub) {
if ($this->isTemporaryException($sub)) {
$any_temporary = true;
break;
}
}
if ($any_temporary) {
return true;
}
}
if ($ex instanceof PhutilProxyException) {
return $this->isTemporaryException($ex->getPreviousException());
}
return false;
}
protected function getYieldDurationFromException(Exception $ex) {
if ($ex instanceof PhabricatorWorkerYieldException) {
return $ex->getDuration();
}
if ($ex instanceof DrydockSlotLockException) {
return 5;
}
return 15;
}
protected function flushDrydockTaskQueue() {
// NOTE: By default, queued tasks are not scheduled if the current task
// fails. This is a good, safe default behavior. For example, it can
// protect us from executing side effect tasks too many times, like
// sending extra email.
// However, it is not the behavior we want in Drydock, because we queue
// followup tasks after lease and resource failures and want them to
// execute in order to clean things up.
// At least for now, we just explicitly flush the queue before exiting
// with a failure to make sure tasks get queued up properly.
try {
$this->flushTaskQueue();
} catch (Exception $ex) {
// If this fails, we want to swallow the exception so the caller throws
// the original error, since we're more likely to be able to understand
// and fix the problem if we have the original error than if we replace
// it with this one.
phlog($ex);
}
return $this;
}
protected function canReclaimResource(DrydockResource $resource) {
$viewer = $this->getViewer();
// Don't reclaim a resource if it has been updated recently. If two
- // leases are fighting, we don't want them to keep reclaming resources
+ // leases are fighting, we don't want them to keep reclaiming resources
// from one another forever without making progress, so make resources
// immune to reclamation for a little while after they activate or update.
// TODO: It would be nice to use a more narrow time here, like "last
// activation or lease release", but we don't currently store that
// anywhere.
$updated = $resource->getDateModified();
$now = PhabricatorTime::getNow();
$ago = ($now - $updated);
if ($ago < phutil_units('3 minutes in seconds')) {
return false;
}
$statuses = array(
DrydockLeaseStatus::STATUS_PENDING,
DrydockLeaseStatus::STATUS_ACQUIRED,
DrydockLeaseStatus::STATUS_ACTIVE,
DrydockLeaseStatus::STATUS_RELEASED,
DrydockLeaseStatus::STATUS_BROKEN,
);
// Don't reclaim resources that have any active leases.
$leases = id(new DrydockLeaseQuery())
->setViewer($viewer)
->withResourcePHIDs(array($resource->getPHID()))
->withStatuses($statuses)
->setLimit(1)
->execute();
if ($leases) {
return false;
}
return true;
}
protected function reclaimResource(
DrydockResource $resource,
DrydockLease $lease) {
$viewer = $this->getViewer();
$command = DrydockCommand::initializeNewCommand($viewer)
->setTargetPHID($resource->getPHID())
->setAuthorPHID($lease->getPHID())
->setCommand(DrydockCommand::COMMAND_RECLAIM)
->save();
$resource->scheduleUpdate();
return $this;
}
}
diff --git a/src/applications/files/controller/PhabricatorFileDataController.php b/src/applications/files/controller/PhabricatorFileDataController.php
index c8bfcc488a..da438730cd 100644
--- a/src/applications/files/controller/PhabricatorFileDataController.php
+++ b/src/applications/files/controller/PhabricatorFileDataController.php
@@ -1,191 +1,191 @@
<?php
final class PhabricatorFileDataController extends PhabricatorFileController {
private $phid;
private $key;
private $file;
public function shouldRequireLogin() {
return false;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getViewer();
$this->phid = $request->getURIData('phid');
$this->key = $request->getURIData('key');
$alt = PhabricatorEnv::getEnvConfig('security.alternate-file-domain');
$base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
$alt_uri = new PhutilURI($alt);
$alt_domain = $alt_uri->getDomain();
$req_domain = $request->getHost();
$main_domain = id(new PhutilURI($base_uri))->getDomain();
if (!strlen($alt) || $main_domain == $alt_domain) {
// No alternate domain.
$should_redirect = false;
$is_alternate_domain = false;
} else if ($req_domain != $alt_domain) {
// Alternate domain, but this request is on the main domain.
$should_redirect = true;
$is_alternate_domain = false;
} else {
// Alternate domain, and on the alternate domain.
$should_redirect = false;
$is_alternate_domain = true;
}
$response = $this->loadFile();
if ($response) {
return $response;
}
$file = $this->getFile();
if ($should_redirect) {
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI($file->getCDNURI());
}
$response = new AphrontFileResponse();
$response->setCacheDurationInSeconds(60 * 60 * 24 * 30);
$response->setCanCDN($file->getCanCDN());
$begin = null;
$end = null;
// NOTE: It's important to accept "Range" requests when playing audio.
// If we don't, Safari has difficulty figuring out how long sounds are
// and glitches when trying to loop them. In particular, Safari sends
// an initial request for bytes 0-1 of the audio file, and things go south
// if we can't respond with a 206 Partial Content.
$range = $request->getHTTPHeader('range');
if (strlen($range)) {
list($begin, $end) = $response->parseHTTPRange($range);
}
$is_viewable = $file->isViewableInBrowser();
$force_download = $request->getExists('download');
$request_type = $request->getHTTPHeader('X-Phabricator-Request-Type');
$is_lfs = ($request_type == 'git-lfs');
if ($is_viewable && !$force_download) {
$response->setMimeType($file->getViewableMimeType());
} else {
$is_public = !$viewer->isLoggedIn();
$is_post = $request->isHTTPPost();
// NOTE: Require POST to download files from the primary domain if the
// request includes credentials. The "Download File" links we generate
// in the web UI are forms which use POST to satisfy this requirement.
// The intent is to make attacks based on tags like "<iframe />" and
// "<script />" (which can issue GET requests, but can not easily issue
// POST requests) more difficult to execute.
// The best defense against these attacks is to use an alternate file
// domain, which is why we strongly recommend doing so.
$is_safe = ($is_alternate_domain || $is_lfs || $is_post || $is_public);
if (!$is_safe) {
// This is marked as "external" because it is fully qualified.
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI(PhabricatorEnv::getProductionURI($file->getBestURI()));
}
$response->setMimeType($file->getMimeType());
$response->setDownload($file->getName());
}
$iterator = $file->getFileDataIterator($begin, $end);
$response->setContentLength($file->getByteSize());
$response->setContentIterator($iterator);
return $response;
}
private function loadFile() {
// Access to files is provided by knowledge of a per-file secret key in
// the URI. Knowledge of this secret is sufficient to retrieve the file.
// For some requests, we also have a valid viewer. However, for many
// requests (like alternate domain requests or Git LFS requests) we will
// not. Even if we do have a valid viewer, use the omnipotent viewer to
// make this logic simpler and more consistent.
// Beyond making the policy check itself more consistent, this also makes
- // sure we're consitent about returning HTTP 404 on bad requests instead
+ // sure we're consistent about returning HTTP 404 on bad requests instead
// of serving HTTP 200 with a login page, which can mislead some clients.
$viewer = PhabricatorUser::getOmnipotentUser();
$file = id(new PhabricatorFileQuery())
->setViewer($viewer)
->withPHIDs(array($this->phid))
->withIsDeleted(false)
->executeOne();
if (!$file) {
return new Aphront404Response();
}
// We may be on the CDN domain, so we need to use a fully-qualified URI
// here to make sure we end up back on the main domain.
$info_uri = PhabricatorEnv::getURI($file->getInfoURI());
if (!$file->validateSecretKey($this->key)) {
$dialog = $this->newDialog()
->setTitle(pht('Invalid Authorization'))
->appendParagraph(
pht(
'The link you followed to access this file is no longer '.
'valid. The visibility of the file may have changed after '.
'the link was generated.'))
->appendParagraph(
pht(
'You can continue to the file detail page to get more '.
'information and attempt to access the file.'))
->addCancelButton($info_uri, pht('Continue'));
return id(new AphrontDialogResponse())
->setDialog($dialog)
->setHTTPResponseCode(404);
}
if ($file->getIsPartial()) {
$dialog = $this->newDialog()
->setTitle(pht('Partial Upload'))
->appendParagraph(
pht(
'This file has only been partially uploaded. It must be '.
'uploaded completely before you can download it.'))
->appendParagraph(
pht(
'You can continue to the file detail page to monitor the '.
'upload progress of the file.'))
->addCancelButton($info_uri, pht('Continue'));
return id(new AphrontDialogResponse())
->setDialog($dialog)
->setHTTPResponseCode(404);
}
$this->file = $file;
return null;
}
private function getFile() {
if (!$this->file) {
throw new PhutilInvalidStateException('loadFile');
}
return $this->file;
}
}
diff --git a/src/applications/files/management/PhabricatorFilesManagementCatWorkflow.php b/src/applications/files/management/PhabricatorFilesManagementCatWorkflow.php
index 70d5eca5a2..5bf40a057d 100644
--- a/src/applications/files/management/PhabricatorFilesManagementCatWorkflow.php
+++ b/src/applications/files/management/PhabricatorFilesManagementCatWorkflow.php
@@ -1,80 +1,80 @@
<?php
final class PhabricatorFilesManagementCatWorkflow
extends PhabricatorFilesManagementWorkflow {
protected function didConstruct() {
$this
->setName('cat')
->setSynopsis(pht('Print the contents of a file.'))
->setArguments(
array(
array(
'name' => 'begin',
'param' => 'bytes',
'help' => pht('Begin printing at a specific offset.'),
),
array(
'name' => 'end',
'param' => 'bytes',
'help' => pht('End printing at a specific offset.'),
),
array(
'name' => 'salvage',
'help' => pht(
'DANGEROUS. Attempt to salvage file content even if the '.
'integrity check fails. If an adversary has tampered with '.
- 'the file, the conent may be unsafe.'),
+ 'the file, the content may be unsafe.'),
),
array(
'name' => 'names',
'wildcard' => true,
),
));
}
public function execute(PhutilArgumentParser $args) {
$console = PhutilConsole::getConsole();
$names = $args->getArg('names');
if (count($names) > 1) {
throw new PhutilArgumentUsageException(
pht('Specify exactly one file to print, like "%s".', 'F123'));
} else if (!$names) {
throw new PhutilArgumentUsageException(
pht('Specify a file to print, like "%s".', 'F123'));
}
$file = head($this->loadFilesWithNames($names));
$begin = $args->getArg('begin');
$end = $args->getArg('end');
$file->makeEphemeral();
// If we're running in "salvage" mode, wipe out any integrity hash which
// may be present. This makes us read file data without performing an
// integrity check.
$salvage = $args->getArg('salvage');
if ($salvage) {
$file->setIntegrityHash(null);
}
try {
$iterator = $file->getFileDataIterator($begin, $end);
foreach ($iterator as $data) {
echo $data;
}
} catch (PhabricatorFileIntegrityException $ex) {
throw new PhutilArgumentUsageException(
pht(
'File data integrity check failed. Use "--salvage" to bypass '.
'integrity checks. This flag is dangerous, use it at your own '.
'risk. Underlying error: %s',
$ex->getMessage()));
}
return 0;
}
}
diff --git a/src/applications/fund/editor/FundInitiativeEditEngine.php b/src/applications/fund/editor/FundInitiativeEditEngine.php
index 201a814a0c..824f4e009f 100644
--- a/src/applications/fund/editor/FundInitiativeEditEngine.php
+++ b/src/applications/fund/editor/FundInitiativeEditEngine.php
@@ -1,152 +1,152 @@
<?php
final class FundInitiativeEditEngine
extends PhabricatorEditEngine {
const ENGINECONST = 'fund.initiative';
public function getEngineName() {
return pht('Fund');
}
public function getEngineApplicationClass() {
return 'PhabricatorFundApplication';
}
public function getSummaryHeader() {
return pht('Configure Fund Forms');
}
public function getSummaryText() {
return pht('Configure creation and editing forms in Fund.');
}
public function isEngineConfigurable() {
return false;
}
protected function newEditableObject() {
return FundInitiative::initializeNewInitiative($this->getViewer());
}
protected function newObjectQuery() {
return new FundInitiativeQuery();
}
protected function getObjectCreateTitleText($object) {
return pht('Create New Initiative');
}
protected function getObjectEditTitleText($object) {
return pht('Edit Initiative: %s', $object->getName());
}
protected function getObjectEditShortText($object) {
return $object->getName();
}
protected function getObjectCreateShortText() {
return pht('Create Initiative');
}
protected function getObjectName() {
- return pht('Initivative');
+ return pht('Initiative');
}
protected function getObjectCreateCancelURI($object) {
return $this->getApplication()->getApplicationURI('/');
}
protected function getEditorURI() {
return $this->getApplication()->getApplicationURI('edit/');
}
protected function getObjectViewURI($object) {
return $object->getViewURI();
}
protected function getCreateNewObjectPolicy() {
return $this->getApplication()->getPolicy(
FundCreateInitiativesCapability::CAPABILITY);
}
protected function buildCustomEditFields($object) {
$viewer = $this->getViewer();
$v_merchant = $object->getMerchantPHID();
$merchants = id(new PhortuneMerchantQuery())
->setViewer($viewer)
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->execute();
$merchant_options = array();
foreach ($merchants as $merchant) {
$merchant_options[$merchant->getPHID()] = pht(
'Merchant %d %s',
$merchant->getID(),
$merchant->getName());
}
if ($v_merchant && empty($merchant_options[$v_merchant])) {
$merchant_options = array(
$v_merchant => pht('(Restricted Merchant)'),
) + $merchant_options;
}
$merchant_instructions = null;
if (!$merchant_options) {
$merchant_instructions = pht(
'NOTE: You do not control any merchant accounts which can receive '.
'payments from this initiative. When you create an initiative, '.
'you need to specify a merchant account where funds will be paid '.
'to. Create a merchant account in the Phortune application before '.
'creating an initiative in Fund.');
}
return array(
id(new PhabricatorTextEditField())
->setKey('name')
->setLabel(pht('Name'))
->setDescription(pht('Initiative name.'))
->setConduitTypeDescription(pht('New initiative name.'))
->setTransactionType(
FundInitiativeNameTransaction::TRANSACTIONTYPE)
->setValue($object->getName())
->setIsRequired(true),
id(new PhabricatorSelectEditField())
->setKey('merchantPHID')
->setLabel(pht('Merchant'))
->setDescription(pht('Merchant operating the initiative.'))
->setConduitTypeDescription(pht('New initiative merchant.'))
->setControlInstructions($merchant_instructions)
->setValue($object->getMerchantPHID())
->setTransactionType(
FundInitiativeMerchantTransaction::TRANSACTIONTYPE)
->setOptions($merchant_options)
->setIsRequired(true),
id(new PhabricatorRemarkupEditField())
->setKey('description')
->setLabel(pht('Description'))
->setDescription(pht('Initiative long description.'))
->setConduitTypeDescription(pht('New initiative description.'))
->setTransactionType(
FundInitiativeDescriptionTransaction::TRANSACTIONTYPE)
->setValue($object->getDescription()),
id(new PhabricatorRemarkupEditField())
->setKey('risks')
->setLabel(pht('Risks/Challenges'))
->setDescription(pht('Initiative risks and challenges.'))
->setConduitTypeDescription(pht('Initiative risks and challenges.'))
->setTransactionType(
FundInitiativeRisksTransaction::TRANSACTIONTYPE)
->setValue($object->getRisks()),
);
}
}
diff --git a/src/applications/harbormaster/conduit/HarbormasterSendMessageConduitAPIMethod.php b/src/applications/harbormaster/conduit/HarbormasterSendMessageConduitAPIMethod.php
index 9c3f88d631..dbf1f18cfa 100644
--- a/src/applications/harbormaster/conduit/HarbormasterSendMessageConduitAPIMethod.php
+++ b/src/applications/harbormaster/conduit/HarbormasterSendMessageConduitAPIMethod.php
@@ -1,273 +1,273 @@
<?php
final class HarbormasterSendMessageConduitAPIMethod
extends HarbormasterConduitAPIMethod {
public function getAPIMethodName() {
return 'harbormaster.sendmessage';
}
public function getMethodSummary() {
return pht(
'Send a message about the status of a build target to Harbormaster, '.
'notifying the application of build results in an external system.');
}
public function getMethodDescription() {
$messages = HarbormasterMessageType::getAllMessages();
$head_type = pht('Constant');
$head_desc = pht('Description');
$head_key = pht('Key');
$head_type = pht('Type');
$head_name = pht('Name');
$rows = array();
$rows[] = "| {$head_type} | {$head_desc} |";
$rows[] = '|--------------|--------------|';
foreach ($messages as $message) {
$description = HarbormasterMessageType::getMessageDescription($message);
$rows[] = "| `{$message}` | {$description} |";
}
$message_table = implode("\n", $rows);
$rows = array();
$rows[] = "| {$head_key} | {$head_type} | {$head_desc} |";
$rows[] = '|-------------|--------------|--------------|';
$unit_spec = HarbormasterBuildUnitMessage::getParameterSpec();
foreach ($unit_spec as $key => $parameter) {
$type = idx($parameter, 'type');
$type = str_replace('|', ' '.pht('or').' ', $type);
$description = idx($parameter, 'description');
$rows[] = "| `{$key}` | //{$type}// | {$description} |";
}
$unit_table = implode("\n", $rows);
$rows = array();
$rows[] = "| {$head_key} | {$head_name} | {$head_desc} |";
$rows[] = '|-------------|--------------|--------------|';
$results = ArcanistUnitTestResult::getAllResultCodes();
foreach ($results as $result_code) {
$name = ArcanistUnitTestResult::getResultCodeName($result_code);
$description = ArcanistUnitTestResult::getResultCodeDescription(
$result_code);
$rows[] = "| `{$result_code}` | **{$name}** | {$description} |";
}
$result_table = implode("\n", $rows);
$rows = array();
$rows[] = "| {$head_key} | {$head_type} | {$head_desc} |";
$rows[] = '|-------------|--------------|--------------|';
$lint_spec = HarbormasterBuildLintMessage::getParameterSpec();
foreach ($lint_spec as $key => $parameter) {
$type = idx($parameter, 'type');
$type = str_replace('|', ' '.pht('or').' ', $type);
$description = idx($parameter, 'description');
$rows[] = "| `{$key}` | //{$type}// | {$description} |";
}
$lint_table = implode("\n", $rows);
$rows = array();
$rows[] = "| {$head_key} | {$head_name} |";
$rows[] = '|-------------|--------------|';
$severities = ArcanistLintSeverity::getLintSeverities();
foreach ($severities as $key => $name) {
$rows[] = "| `{$key}` | **{$name}** |";
}
$severity_table = implode("\n", $rows);
$valid_unit = array(
array(
'name' => 'PassingTest',
'result' => ArcanistUnitTestResult::RESULT_PASS,
),
array(
'name' => 'FailingTest',
'result' => ArcanistUnitTestResult::RESULT_FAIL,
),
);
$valid_lint = array(
array(
'name' => pht('Syntax Error'),
'code' => 'EXAMPLE1',
'severity' => ArcanistLintSeverity::SEVERITY_ERROR,
'path' => 'path/to/example.c',
'line' => 17,
'char' => 3,
),
array(
'name' => pht('Not A Haiku'),
'code' => 'EXAMPLE2',
'severity' => ArcanistLintSeverity::SEVERITY_ERROR,
'path' => 'path/to/source.cpp',
'line' => 23,
'char' => 1,
'description' => pht(
'This function definition is not a haiku.'),
),
);
$json = new PhutilJSON();
$valid_unit = $json->encodeAsList($valid_unit);
$valid_lint = $json->encodeAsList($valid_lint);
return pht(
"Send a message about the status of a build target to Harbormaster, ".
"notifying the application of build results in an external system.".
"\n\n".
"Sending Messages\n".
"================\n".
"If you run external builds, you can use this method to publish build ".
"results back into Harbormaster after the external system finishes work ".
"or as it makes progress.".
"\n\n".
"The simplest way to use this method is to call it once after the ".
"build finishes with a `pass` or `fail` message. This will record the ".
"build result, and continue the next step in the build if the build was ".
"waiting for a result.".
"\n\n".
"When you send a status message about a build target, you can ".
"optionally include detailed `lint` or `unit` results alongside the ".
"message. See below for details.".
"\n\n".
"If you want to report intermediate results but a build hasn't ".
"completed yet, you can use the `work` message. This message doesn't ".
"have any direct effects, but allows you to send additional data to ".
"update the progress of the build target. The target will continue ".
"waiting for a completion message, but the UI will update to show the ".
"progress which has been made.".
"\n\n".
"Message Types\n".
"=============\n".
"When you send Harbormaster a message, you must include a `type`, ".
"which describes the overall state of the build. For example, use ".
- "`pass` to tell Harbomaster that a build completed successfully.".
+ "`pass` to tell Harbormaster that a build completed successfully.".
"\n\n".
"Supported message types are:".
"\n\n".
"%s".
"\n\n".
"Unit Results\n".
"============\n".
"You can report test results alongside a message. The simplest way to ".
"do this is to report all the results alongside a `pass` or `fail` ".
"message, but you can also send a `work` message to report intermediate ".
"results.\n\n".
"To provide unit test results, pass a list of results in the `unit` ".
- "parameter. Each result shoud be a dictionary with these keys:".
+ "parameter. Each result should be a dictionary with these keys:".
"\n\n".
"%s".
"\n\n".
"The `result` parameter recognizes these test results:".
"\n\n".
"%s".
"\n\n".
"This is a simple, valid value for the `unit` parameter. It reports ".
"one passing test and one failing test:\n\n".
"\n\n".
"```lang=json\n".
"%s".
"```".
"\n\n".
"Lint Results\n".
"============\n".
"Like unit test results, you can report lint results alongside a ".
"message. The `lint` parameter should contain results as a list of ".
"dictionaries with these keys:".
"\n\n".
"%s".
"\n\n".
"The `severity` parameter recognizes these severity levels:".
"\n\n".
"%s".
"\n\n".
"This is a simple, valid value for the `lint` parameter. It reports one ".
"error and one warning:".
"\n\n".
"```lang=json\n".
"%s".
"```".
"\n\n",
$message_table,
$unit_table,
$result_table,
$valid_unit,
$lint_table,
$severity_table,
$valid_lint);
}
protected function defineParamTypes() {
$messages = HarbormasterMessageType::getAllMessages();
$type_const = $this->formatStringConstants($messages);
return array(
'buildTargetPHID' => 'required phid',
'type' => 'required '.$type_const,
'unit' => 'optional list<wild>',
'lint' => 'optional list<wild>',
);
}
protected function defineReturnType() {
return 'void';
}
protected function execute(ConduitAPIRequest $request) {
$viewer = $request->getUser();
$build_target_phid = $request->getValue('buildTargetPHID');
$message_type = $request->getValue('type');
$build_target = id(new HarbormasterBuildTargetQuery())
->setViewer($viewer)
->withPHIDs(array($build_target_phid))
->executeOne();
if (!$build_target) {
throw new Exception(pht('No such build target!'));
}
$save = array();
$lint_messages = $request->getValue('lint', array());
foreach ($lint_messages as $lint) {
$save[] = HarbormasterBuildLintMessage::newFromDictionary(
$build_target,
$lint);
}
$unit_messages = $request->getValue('unit', array());
foreach ($unit_messages as $unit) {
$save[] = HarbormasterBuildUnitMessage::newFromDictionary(
$build_target,
$unit);
}
$save[] = HarbormasterBuildMessage::initializeNewMessage($viewer)
->setBuildTargetPHID($build_target->getPHID())
->setType($message_type);
$build_target->openTransaction();
foreach ($save as $object) {
$object->save();
}
$build_target->saveTransaction();
// If the build has completely paused because all steps are blocked on
// waiting targets, this will resume it.
$build = $build_target->getBuild();
PhabricatorWorker::scheduleTask(
'HarbormasterBuildWorker',
array(
'buildID' => $build->getID(),
),
array(
'objectPHID' => $build->getPHID(),
));
return null;
}
}
diff --git a/src/applications/harbormaster/storage/configuration/HarbormasterBuildStep.php b/src/applications/harbormaster/storage/configuration/HarbormasterBuildStep.php
index cdb2044cc5..b29958882c 100644
--- a/src/applications/harbormaster/storage/configuration/HarbormasterBuildStep.php
+++ b/src/applications/harbormaster/storage/configuration/HarbormasterBuildStep.php
@@ -1,171 +1,171 @@
<?php
final class HarbormasterBuildStep extends HarbormasterDAO
implements
PhabricatorApplicationTransactionInterface,
PhabricatorPolicyInterface,
PhabricatorCustomFieldInterface {
protected $name;
protected $description;
protected $buildPlanPHID;
protected $className;
protected $details = array();
protected $sequence = 0;
protected $stepAutoKey;
private $buildPlan = self::ATTACHABLE;
private $customFields = self::ATTACHABLE;
private $implementation;
public static function initializeNewStep(PhabricatorUser $actor) {
return id(new HarbormasterBuildStep())
->setName('')
->setDescription('');
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'details' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'className' => 'text255',
'sequence' => 'uint32',
'description' => 'text',
// T6203/NULLABILITY
// This should not be nullable. Current `null` values indicate steps
// which predated editable names. These should be backfilled with
- // default names, then the code for handling `null` shoudl be removed.
+ // default names, then the code for handling `null` should be removed.
'name' => 'text255?',
'stepAutoKey' => 'text32?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_plan' => array(
'columns' => array('buildPlanPHID'),
),
'key_stepautokey' => array(
'columns' => array('buildPlanPHID', 'stepAutoKey'),
'unique' => true,
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
HarbormasterBuildStepPHIDType::TYPECONST);
}
public function attachBuildPlan(HarbormasterBuildPlan $plan) {
$this->buildPlan = $plan;
return $this;
}
public function getBuildPlan() {
return $this->assertAttached($this->buildPlan);
}
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 getName() {
if (strlen($this->name)) {
return $this->name;
}
return $this->getStepImplementation()->getName();
}
public function getStepImplementation() {
if ($this->implementation === null) {
$obj = HarbormasterBuildStepImplementation::requireImplementation(
$this->className);
$obj->loadSettings($this);
$this->implementation = $obj;
}
return $this->implementation;
}
public function isAutostep() {
return ($this->getStepAutoKey() !== null);
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new HarbormasterBuildStepEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new HarbormasterBuildStepTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
return $this->getBuildPlan()->getPolicy($capability);
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return $this->getBuildPlan()->hasAutomaticCapability($capability, $viewer);
}
public function describeAutomaticCapability($capability) {
return pht('A build step has the same policies as its build plan.');
}
/* -( PhabricatorCustomFieldInterface )------------------------------------ */
public function getCustomFieldSpecificationForRole($role) {
return array();
}
public function getCustomFieldBaseClass() {
return 'HarbormasterBuildStepCustomField';
}
public function getCustomFields() {
return $this->assertAttached($this->customFields);
}
public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) {
$this->customFields = $fields;
return $this;
}
}
diff --git a/src/applications/legalpad/controller/LegalpadDocumentSignController.php b/src/applications/legalpad/controller/LegalpadDocumentSignController.php
index 05a691e34a..f19cf43d0e 100644
--- a/src/applications/legalpad/controller/LegalpadDocumentSignController.php
+++ b/src/applications/legalpad/controller/LegalpadDocumentSignController.php
@@ -1,701 +1,701 @@
<?php
final class LegalpadDocumentSignController extends LegalpadController {
public function shouldAllowPublic() {
return true;
}
public function shouldAllowLegallyNonCompliantUsers() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getUser();
$document = id(new LegalpadDocumentQuery())
->setViewer($viewer)
->withIDs(array($request->getURIData('id')))
->needDocumentBodies(true)
->executeOne();
if (!$document) {
return new Aphront404Response();
}
$information = $this->readSignerInformation(
$document,
$request);
if ($information instanceof AphrontResponse) {
return $information;
}
list($signer_phid, $signature_data) = $information;
$signature = null;
$type_individual = LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL;
$is_individual = ($document->getSignatureType() == $type_individual);
switch ($document->getSignatureType()) {
case LegalpadDocument::SIGNATURE_TYPE_NONE:
// nothing to sign means this should be true
$has_signed = true;
// this is a status UI element
$signed_status = null;
break;
case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL:
if ($signer_phid) {
// TODO: This is odd and should probably be adjusted after
// grey/external accounts work better, but use the omnipotent
// viewer to check for a signature so we can pick up
// anonymous/grey signatures.
$signature = id(new LegalpadDocumentSignatureQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withDocumentPHIDs(array($document->getPHID()))
->withSignerPHIDs(array($signer_phid))
->executeOne();
if ($signature && !$viewer->isLoggedIn()) {
return $this->newDialog()
->setTitle(pht('Already Signed'))
->appendParagraph(pht('You have already signed this document!'))
->addCancelButton('/'.$document->getMonogram(), pht('Okay'));
}
}
$signed_status = null;
if (!$signature) {
$has_signed = false;
$signature = id(new LegalpadDocumentSignature())
->setSignerPHID($signer_phid)
->setDocumentPHID($document->getPHID())
->setDocumentVersion($document->getVersions());
// If the user is logged in, show a notice that they haven't signed.
// If they aren't logged in, we can't be as sure, so don't show
// anything.
if ($viewer->isLoggedIn()) {
$signed_status = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_WARNING)
->setErrors(
array(
pht('You have not signed this document yet.'),
));
}
} else {
$has_signed = true;
$signature_data = $signature->getSignatureData();
// In this case, we know they've signed.
$signed_at = $signature->getDateCreated();
if ($signature->getIsExemption()) {
$exemption_phid = $signature->getExemptionPHID();
$handles = $this->loadViewerHandles(array($exemption_phid));
$exemption_handle = $handles[$exemption_phid];
$signed_text = pht(
'You do not need to sign this document. '.
'%s added a signature exemption for you on %s.',
$exemption_handle->renderLink(),
phabricator_datetime($signed_at, $viewer));
} else {
$signed_text = pht(
'You signed this document on %s.',
phabricator_datetime($signed_at, $viewer));
}
$signed_status = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_NOTICE)
->setErrors(array($signed_text));
}
$field_errors = array(
'name' => true,
'email' => true,
'agree' => true,
);
$signature->setSignatureData($signature_data);
break;
case LegalpadDocument::SIGNATURE_TYPE_CORPORATION:
$signature = id(new LegalpadDocumentSignature())
->setDocumentPHID($document->getPHID())
->setDocumentVersion($document->getVersions());
if ($viewer->isLoggedIn()) {
$has_signed = false;
$signed_status = null;
} else {
// This just hides the form.
$has_signed = true;
$login_text = pht(
'This document requires a corporate signatory. You must log in to '.
'accept this document on behalf of a company you represent.');
$signed_status = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_WARNING)
->setErrors(array($login_text));
}
$field_errors = array(
'name' => true,
'address' => true,
'contact.name' => true,
'email' => true,
);
$signature->setSignatureData($signature_data);
break;
}
$errors = array();
if ($request->isFormOrHisecPost() && !$has_signed) {
// Require two-factor auth to sign legal documents.
if ($viewer->isLoggedIn()) {
$engine = new PhabricatorAuthSessionEngine();
$engine->requireHighSecuritySession(
$viewer,
$request,
'/'.$document->getMonogram());
}
list($form_data, $errors, $field_errors) = $this->readSignatureForm(
$document,
$request);
$signature_data = $form_data + $signature_data;
$signature->setSignatureData($signature_data);
$signature->setSignatureType($document->getSignatureType());
$signature->setSignerName((string)idx($signature_data, 'name'));
$signature->setSignerEmail((string)idx($signature_data, 'email'));
$agree = $request->getExists('agree');
if (!$agree) {
$errors[] = pht(
'You must check "I agree to the terms laid forth above."');
$field_errors['agree'] = pht('Required');
}
if ($viewer->isLoggedIn() && $is_individual) {
$verified = LegalpadDocumentSignature::VERIFIED;
} else {
$verified = LegalpadDocumentSignature::UNVERIFIED;
}
$signature->setVerified($verified);
if (!$errors) {
$signature->save();
// If the viewer is logged in, signing for themselves, send them to
// the document page, which will show that they have signed the
// document. Unless of course they were required to sign the
// document to use Phabricator; in that case try really hard to
// re-direct them to where they wanted to go.
//
// Otherwise, send them to a completion page.
if ($viewer->isLoggedIn() && $is_individual) {
$next_uri = '/'.$document->getMonogram();
if ($document->getRequireSignature()) {
$request_uri = $request->getRequestURI();
$next_uri = (string)$request_uri;
}
} else {
$this->sendVerifySignatureEmail(
$document,
$signature);
$next_uri = $this->getApplicationURI('done/');
}
return id(new AphrontRedirectResponse())->setURI($next_uri);
}
}
$document_body = $document->getDocumentBody();
$engine = id(new PhabricatorMarkupEngine())
->setViewer($viewer);
$engine->addObject(
$document_body,
LegalpadDocumentBody::MARKUP_FIELD_TEXT);
$engine->process();
$document_markup = $engine->getOutput(
$document_body,
LegalpadDocumentBody::MARKUP_FIELD_TEXT);
$title = $document_body->getTitle();
$manage_uri = $this->getApplicationURI('view/'.$document->getID().'/');
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$document,
PhabricatorPolicyCapability::CAN_EDIT);
// Use the last content update as the modified date. We don't want to
// show that a document like a TOS was "updated" by an incidental change
- // to a field like the preamble or privacy settings which does not acutally
+ // to a field like the preamble or privacy settings which does not actually
// affect the content of the agreement.
$content_updated = $document_body->getDateCreated();
// NOTE: We're avoiding `setPolicyObject()` here so we don't pick up
// extra UI elements that are unnecessary and clutter the signature page.
// These details are available on the "Manage" page.
$header = id(new PHUIHeaderView())
->setHeader($title)
->setUser($viewer)
->setEpoch($content_updated)
->addActionLink(
id(new PHUIButtonView())
->setTag('a')
->setIcon('fa-pencil')
->setText(pht('Manage'))
->setHref($manage_uri)
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit));
$preamble_box = null;
if (strlen($document->getPreamble())) {
$preamble_text = new PHUIRemarkupView($viewer, $document->getPreamble());
// NOTE: We're avoiding `setObject()` here so we don't pick up extra UI
// elements like "Subscribers". This information is available on the
// "Manage" page, but just clutters up the "Signature" page.
$preamble = id(new PHUIPropertyListView())
->setUser($viewer)
->addSectionHeader(pht('Preamble'))
->addTextContent($preamble_text);
$preamble_box = new PHUIPropertyGroupView();
$preamble_box->addPropertyList($preamble);
}
$content = id(new PHUIDocumentViewPro())
->addClass('legalpad')
->setHeader($header)
->appendChild(
array(
$signed_status,
$preamble_box,
$document_markup,
));
$signature_box = null;
if (!$has_signed) {
$error_view = null;
if ($errors) {
$error_view = id(new PHUIInfoView())
->setErrors($errors);
}
$signature_form = $this->buildSignatureForm(
$document,
$signature,
$field_errors);
switch ($document->getSignatureType()) {
default:
break;
case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL:
case LegalpadDocument::SIGNATURE_TYPE_CORPORATION:
$box = id(new PHUIObjectBoxView())
->addClass('document-sign-box')
->setHeaderText(pht('Agree and Sign Document'))
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
->setForm($signature_form);
if ($error_view) {
$box->setInfoView($error_view);
}
$signature_box = phutil_tag_div(
'phui-document-view-pro-box plt', $box);
break;
}
}
$crumbs = $this->buildApplicationCrumbs();
$crumbs->setBorder(true);
$crumbs->addTextCrumb($document->getMonogram());
$box = id(new PHUITwoColumnView())
->setFooter($signature_box);
return $this->newPage()
->setTitle($title)
->setCrumbs($crumbs)
->setPageObjectPHIDs(array($document->getPHID()))
->appendChild(array(
$content,
$box,
));
}
private function readSignerInformation(
LegalpadDocument $document,
AphrontRequest $request) {
$viewer = $request->getUser();
$signer_phid = null;
$signature_data = array();
switch ($document->getSignatureType()) {
case LegalpadDocument::SIGNATURE_TYPE_NONE:
break;
case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL:
if ($viewer->isLoggedIn()) {
$signer_phid = $viewer->getPHID();
$signature_data = array(
'name' => $viewer->getRealName(),
'email' => $viewer->loadPrimaryEmailAddress(),
);
} else if ($request->isFormPost()) {
$email = new PhutilEmailAddress($request->getStr('email'));
if (strlen($email->getDomainName())) {
$email_obj = id(new PhabricatorUserEmail())
->loadOneWhere('address = %s', $email->getAddress());
if ($email_obj) {
return $this->signInResponse();
}
$external_account = id(new PhabricatorExternalAccountQuery())
->setViewer($viewer)
->withAccountTypes(array('email'))
->withAccountDomains(array($email->getDomainName()))
->withAccountIDs(array($email->getAddress()))
->loadOneOrCreate();
if ($external_account->getUserPHID()) {
return $this->signInResponse();
}
$signer_phid = $external_account->getPHID();
}
}
break;
case LegalpadDocument::SIGNATURE_TYPE_CORPORATION:
$signer_phid = $viewer->getPHID();
if ($signer_phid) {
$signature_data = array(
'contact.name' => $viewer->getRealName(),
'email' => $viewer->loadPrimaryEmailAddress(),
'actorPHID' => $viewer->getPHID(),
);
}
break;
}
return array($signer_phid, $signature_data);
}
private function buildSignatureForm(
LegalpadDocument $document,
LegalpadDocumentSignature $signature,
array $errors) {
$viewer = $this->getRequest()->getUser();
$data = $signature->getSignatureData();
$form = id(new AphrontFormView())
->setUser($viewer);
$signature_type = $document->getSignatureType();
switch ($signature_type) {
case LegalpadDocument::SIGNATURE_TYPE_NONE:
// bail out of here quick
return;
case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL:
$this->buildIndividualSignatureForm(
$form,
$document,
$signature,
$errors);
break;
case LegalpadDocument::SIGNATURE_TYPE_CORPORATION:
$this->buildCorporateSignatureForm(
$form,
$document,
$signature,
$errors);
break;
default:
throw new Exception(
pht(
'This document has an unknown signature type ("%s").',
$signature_type));
}
$form
->appendChild(
id(new AphrontFormCheckboxControl())
->setError(idx($errors, 'agree', null))
->addCheckbox(
'agree',
'agree',
pht('I agree to the terms laid forth above.'),
false));
if ($document->getRequireSignature()) {
$cancel_uri = '/logout/';
$cancel_text = pht('Log Out');
} else {
$cancel_uri = $this->getApplicationURI();
$cancel_text = pht('Cancel');
}
$form
->appendChild(
id(new AphrontFormSubmitControl())
->setValue(pht('Sign Document'))
->addCancelButton($cancel_uri, $cancel_text));
return $form;
}
private function buildIndividualSignatureForm(
AphrontFormView $form,
LegalpadDocument $document,
LegalpadDocumentSignature $signature,
array $errors) {
$data = $signature->getSignatureData();
$form
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Name'))
->setValue(idx($data, 'name', ''))
->setName('name')
->setError(idx($errors, 'name', null)));
$viewer = $this->getRequest()->getUser();
if (!$viewer->isLoggedIn()) {
$form->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Email'))
->setValue(idx($data, 'email', ''))
->setName('email')
->setError(idx($errors, 'email', null)));
}
return $form;
}
private function buildCorporateSignatureForm(
AphrontFormView $form,
LegalpadDocument $document,
LegalpadDocumentSignature $signature,
array $errors) {
$data = $signature->getSignatureData();
$form
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Company Name'))
->setValue(idx($data, 'name', ''))
->setName('name')
->setError(idx($errors, 'name', null)))
->appendChild(
id(new AphrontFormTextAreaControl())
->setLabel(pht('Company Address'))
->setValue(idx($data, 'address', ''))
->setName('address')
->setError(idx($errors, 'address', null)))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Contact Name'))
->setValue(idx($data, 'contact.name', ''))
->setName('contact.name')
->setError(idx($errors, 'contact.name', null)))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Contact Email'))
->setValue(idx($data, 'email', ''))
->setName('email')
->setError(idx($errors, 'email', null)));
return $form;
}
private function readSignatureForm(
LegalpadDocument $document,
AphrontRequest $request) {
$signature_type = $document->getSignatureType();
switch ($signature_type) {
case LegalpadDocument::SIGNATURE_TYPE_INDIVIDUAL:
$result = $this->readIndividualSignatureForm(
$document,
$request);
break;
case LegalpadDocument::SIGNATURE_TYPE_CORPORATION:
$result = $this->readCorporateSignatureForm(
$document,
$request);
break;
default:
throw new Exception(
pht(
'This document has an unknown signature type ("%s").',
$signature_type));
}
return $result;
}
private function readIndividualSignatureForm(
LegalpadDocument $document,
AphrontRequest $request) {
$signature_data = array();
$errors = array();
$field_errors = array();
$name = $request->getStr('name');
if (!strlen($name)) {
$field_errors['name'] = pht('Required');
$errors[] = pht('Name field is required.');
} else {
$field_errors['name'] = null;
}
$signature_data['name'] = $name;
$viewer = $request->getUser();
if ($viewer->isLoggedIn()) {
$email = $viewer->loadPrimaryEmailAddress();
} else {
$email = $request->getStr('email');
$addr_obj = null;
if (!strlen($email)) {
$field_errors['email'] = pht('Required');
$errors[] = pht('Email field is required.');
} else {
$addr_obj = new PhutilEmailAddress($email);
$domain = $addr_obj->getDomainName();
if (!$domain) {
$field_errors['email'] = pht('Invalid');
$errors[] = pht('A valid email is required.');
} else {
$field_errors['email'] = null;
}
}
}
$signature_data['email'] = $email;
return array($signature_data, $errors, $field_errors);
}
private function readCorporateSignatureForm(
LegalpadDocument $document,
AphrontRequest $request) {
$viewer = $request->getUser();
if (!$viewer->isLoggedIn()) {
throw new Exception(
pht(
'You can not sign a document on behalf of a corporation unless '.
'you are logged in.'));
}
$signature_data = array();
$errors = array();
$field_errors = array();
$name = $request->getStr('name');
if (!strlen($name)) {
$field_errors['name'] = pht('Required');
$errors[] = pht('Company name is required.');
} else {
$field_errors['name'] = null;
}
$signature_data['name'] = $name;
$address = $request->getStr('address');
if (!strlen($address)) {
$field_errors['address'] = pht('Required');
$errors[] = pht('Company address is required.');
} else {
$field_errors['address'] = null;
}
$signature_data['address'] = $address;
$contact_name = $request->getStr('contact.name');
if (!strlen($contact_name)) {
$field_errors['contact.name'] = pht('Required');
$errors[] = pht('Contact name is required.');
} else {
$field_errors['contact.name'] = null;
}
$signature_data['contact.name'] = $contact_name;
$email = $request->getStr('email');
$addr_obj = null;
if (!strlen($email)) {
$field_errors['email'] = pht('Required');
$errors[] = pht('Contact email is required.');
} else {
$addr_obj = new PhutilEmailAddress($email);
$domain = $addr_obj->getDomainName();
if (!$domain) {
$field_errors['email'] = pht('Invalid');
$errors[] = pht('A valid email is required.');
} else {
$field_errors['email'] = null;
}
}
$signature_data['email'] = $email;
return array($signature_data, $errors, $field_errors);
}
private function sendVerifySignatureEmail(
LegalpadDocument $doc,
LegalpadDocumentSignature $signature) {
$signature_data = $signature->getSignatureData();
$email = new PhutilEmailAddress($signature_data['email']);
$doc_name = $doc->getTitle();
$doc_link = PhabricatorEnv::getProductionURI('/'.$doc->getMonogram());
$path = $this->getApplicationURI(sprintf(
'/verify/%s/',
$signature->getSecretKey()));
$link = PhabricatorEnv::getProductionURI($path);
$name = idx($signature_data, 'name');
$body = pht(
"%s:\n\n".
"This email address was used to sign a Legalpad document ".
"in Phabricator:\n\n".
" %s\n\n".
"Please verify you own this email address and accept the ".
"agreement by clicking this link:\n\n".
" %s\n\n".
"Your signature is not valid until you complete this ".
"verification step.\n\nYou can review the document here:\n\n".
" %s\n",
$name,
$doc_name,
$link,
$doc_link);
id(new PhabricatorMetaMTAMail())
->addRawTos(array($email->getAddress()))
->setSubject(pht('[Legalpad] Signature Verification'))
->setForceDelivery(true)
->setBody($body)
->setRelatedPHID($signature->getDocumentPHID())
->saveAndSend();
}
private function signInResponse() {
return id(new Aphront403Response())
->setForbiddenText(
pht(
'The email address specified is associated with an account. '.
'Please login to that account and sign this document again.'));
}
}
diff --git a/src/applications/maniphest/controller/ManiphestReportController.php b/src/applications/maniphest/controller/ManiphestReportController.php
index 6a5b3ad50c..66aa154e8f 100644
--- a/src/applications/maniphest/controller/ManiphestReportController.php
+++ b/src/applications/maniphest/controller/ManiphestReportController.php
@@ -1,810 +1,810 @@
<?php
final class ManiphestReportController extends ManiphestController {
private $view;
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$this->view = $request->getURIData('view');
if ($request->isFormPost()) {
$uri = $request->getRequestURI();
$project = head($request->getArr('set_project'));
$project = nonempty($project, null);
$uri = $uri->alter('project', $project);
$window = $request->getStr('set_window');
$uri = $uri->alter('window', $window);
return id(new AphrontRedirectResponse())->setURI($uri);
}
$nav = new AphrontSideNavFilterView();
$nav->setBaseURI(new PhutilURI('/maniphest/report/'));
$nav->addLabel(pht('Open Tasks'));
$nav->addFilter('user', pht('By User'));
$nav->addFilter('project', pht('By Project'));
$nav->addLabel(pht('Burnup'));
$nav->addFilter('burn', pht('Burnup Rate'));
$this->view = $nav->selectFilter($this->view, 'user');
require_celerity_resource('maniphest-report-css');
switch ($this->view) {
case 'burn':
$core = $this->renderBurn();
break;
case 'user':
case 'project':
$core = $this->renderOpenTasks();
break;
default:
return new Aphront404Response();
}
$crumbs = $this->buildApplicationCrumbs()
->addTextCrumb(pht('Reports'));
$nav->appendChild($core);
$title = pht('Maniphest Reports');
return $this->newPage()
->setTitle($title)
->setCrumbs($crumbs)
->setNavigation($nav);
}
public function renderBurn() {
$request = $this->getRequest();
$viewer = $request->getUser();
$handle = null;
$project_phid = $request->getStr('project');
if ($project_phid) {
$phids = array($project_phid);
$handles = $this->loadViewerHandles($phids);
$handle = $handles[$project_phid];
}
$table = new ManiphestTransaction();
$conn = $table->establishConnection('r');
$joins = '';
if ($project_phid) {
$joins = qsprintf(
$conn,
'JOIN %T t ON x.objectPHID = t.phid
JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s',
id(new ManiphestTask())->getTableName(),
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
$project_phid);
}
$data = queryfx_all(
$conn,
'SELECT x.transactionType, x.oldValue, x.newValue, x.dateCreated
FROM %T x %Q
WHERE transactionType IN (%Ls)
ORDER BY x.dateCreated ASC',
$table->getTableName(),
$joins,
array(
ManiphestTaskStatusTransaction::TRANSACTIONTYPE,
ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE,
));
$stats = array();
$day_buckets = array();
$open_tasks = array();
$default_status = ManiphestTaskStatus::getDefaultStatus();
$duplicate_status = ManiphestTaskStatus::getDuplicateStatus();
foreach ($data as $key => $row) {
switch ($row['transactionType']) {
case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:
// NOTE: Hack to avoid json_decode().
$oldv = trim($row['oldValue'], '"');
$newv = trim($row['newValue'], '"');
break;
case ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE:
// NOTE: Merging a task does not generate a "status" transaction.
// We pretend it did. Note that this is not always accurate: it is
- // possble to merge a task which was previously closed, but this
+ // possible to merge a task which was previously closed, but this
// fake transaction always counts a merge as a closure.
$oldv = $default_status;
$newv = $duplicate_status;
break;
}
if ($oldv == 'null') {
$old_is_open = false;
} else {
$old_is_open = ManiphestTaskStatus::isOpenStatus($oldv);
}
$new_is_open = ManiphestTaskStatus::isOpenStatus($newv);
$is_open = ($new_is_open && !$old_is_open);
$is_close = ($old_is_open && !$new_is_open);
$data[$key]['_is_open'] = $is_open;
$data[$key]['_is_close'] = $is_close;
if (!$is_open && !$is_close) {
// This is either some kind of bogus event, or a resolution change
// (e.g., resolved -> invalid). Just skip it.
continue;
}
$day_bucket = phabricator_format_local_time(
$row['dateCreated'],
$viewer,
'Yz');
$day_buckets[$day_bucket] = $row['dateCreated'];
if (empty($stats[$day_bucket])) {
$stats[$day_bucket] = array(
'open' => 0,
'close' => 0,
);
}
$stats[$day_bucket][$is_close ? 'close' : 'open']++;
}
$template = array(
'open' => 0,
'close' => 0,
);
$rows = array();
$rowc = array();
$last_month = null;
$last_month_epoch = null;
$last_week = null;
$last_week_epoch = null;
$week = null;
$month = null;
$last = last_key($stats) - 1;
$period = $template;
foreach ($stats as $bucket => $info) {
$epoch = $day_buckets[$bucket];
$week_bucket = phabricator_format_local_time(
$epoch,
$viewer,
'YW');
if ($week_bucket != $last_week) {
if ($week) {
$rows[] = $this->formatBurnRow(
pht('Week of %s', phabricator_date($last_week_epoch, $viewer)),
$week);
$rowc[] = 'week';
}
$week = $template;
$last_week = $week_bucket;
$last_week_epoch = $epoch;
}
$month_bucket = phabricator_format_local_time(
$epoch,
$viewer,
'Ym');
if ($month_bucket != $last_month) {
if ($month) {
$rows[] = $this->formatBurnRow(
phabricator_format_local_time($last_month_epoch, $viewer, 'F, Y'),
$month);
$rowc[] = 'month';
}
$month = $template;
$last_month = $month_bucket;
$last_month_epoch = $epoch;
}
$rows[] = $this->formatBurnRow(phabricator_date($epoch, $viewer), $info);
$rowc[] = null;
$week['open'] += $info['open'];
$week['close'] += $info['close'];
$month['open'] += $info['open'];
$month['close'] += $info['close'];
$period['open'] += $info['open'];
$period['close'] += $info['close'];
}
if ($week) {
$rows[] = $this->formatBurnRow(
pht('Week To Date'),
$week);
$rowc[] = 'week';
}
if ($month) {
$rows[] = $this->formatBurnRow(
pht('Month To Date'),
$month);
$rowc[] = 'month';
}
$rows[] = $this->formatBurnRow(
pht('All Time'),
$period);
$rowc[] = 'aggregate';
$rows = array_reverse($rows);
$rowc = array_reverse($rowc);
$table = new AphrontTableView($rows);
$table->setRowClasses($rowc);
$table->setHeaders(
array(
pht('Period'),
pht('Opened'),
pht('Closed'),
pht('Change'),
));
$table->setColumnClasses(
array(
'right wide',
'n',
'n',
'n',
));
if ($handle) {
$inst = pht(
'NOTE: This table reflects tasks currently in '.
'the project. If a task was opened in the past but added to '.
'the project recently, it is counted on the day it was '.
'opened, not the day it was categorized. If a task was part '.
'of this project in the past but no longer is, it is not '.
'counted at all.');
$header = pht('Task Burn Rate for Project %s', $handle->renderLink());
$caption = phutil_tag('p', array(), $inst);
} else {
$header = pht('Task Burn Rate for All Tasks');
$caption = null;
}
if ($caption) {
$caption = id(new PHUIInfoView())
->appendChild($caption)
->setSeverity(PHUIInfoView::SEVERITY_NOTICE);
}
$panel = new PHUIObjectBoxView();
$panel->setHeaderText($header);
if ($caption) {
$panel->setInfoView($caption);
}
$panel->setTable($table);
$tokens = array();
if ($handle) {
$tokens = array($handle);
}
$filter = $this->renderReportFilters($tokens, $has_window = false);
$id = celerity_generate_unique_node_id();
$chart = phutil_tag(
'div',
array(
'id' => $id,
'style' => 'border: 1px solid #BFCFDA; '.
'background-color: #fff; '.
'margin: 8px 16px; '.
'height: 400px; ',
),
'');
list($burn_x, $burn_y) = $this->buildSeries($data);
require_celerity_resource('d3');
require_celerity_resource('phui-chart-css');
Javelin::initBehavior('line-chart', array(
'hardpoint' => $id,
'x' => array(
$burn_x,
),
'y' => array(
$burn_y,
),
'xformat' => 'epoch',
'yformat' => 'int',
));
$box = id(new PHUIObjectBoxView())
->setHeaderText(pht('Burnup Rate'))
->appendChild($chart);
return array($filter, $box, $panel);
}
private function renderReportFilters(array $tokens, $has_window) {
$request = $this->getRequest();
$viewer = $request->getUser();
$form = id(new AphrontFormView())
->setUser($viewer)
->appendControl(
id(new AphrontFormTokenizerControl())
->setDatasource(new PhabricatorProjectDatasource())
->setLabel(pht('Project'))
->setLimit(1)
->setName('set_project')
// TODO: This is silly, but this is Maniphest reports.
->setValue(mpull($tokens, 'getPHID')));
if ($has_window) {
list($window_str, $ignored, $window_error) = $this->getWindow();
$form
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Recently Means'))
->setName('set_window')
->setCaption(
pht('Configure the cutoff for the "Recently Closed" column.'))
->setValue($window_str)
->setError($window_error));
}
$form
->appendChild(
id(new AphrontFormSubmitControl())
->setValue(pht('Filter By Project')));
$filter = new AphrontListFilterView();
$filter->appendChild($form);
return $filter;
}
private function buildSeries(array $data) {
$out = array();
$counter = 0;
foreach ($data as $row) {
$t = (int)$row['dateCreated'];
if ($row['_is_close']) {
--$counter;
$out[$t] = $counter;
} else if ($row['_is_open']) {
++$counter;
$out[$t] = $counter;
}
}
return array(array_keys($out), array_values($out));
}
private function formatBurnRow($label, $info) {
$delta = $info['open'] - $info['close'];
$fmt = number_format($delta);
if ($delta > 0) {
$fmt = '+'.$fmt;
$fmt = phutil_tag('span', array('class' => 'red'), $fmt);
} else {
$fmt = phutil_tag('span', array('class' => 'green'), $fmt);
}
return array(
$label,
number_format($info['open']),
number_format($info['close']),
$fmt,
);
}
public function renderOpenTasks() {
$request = $this->getRequest();
$viewer = $request->getUser();
$query = id(new ManiphestTaskQuery())
->setViewer($viewer)
->withStatuses(ManiphestTaskStatus::getOpenStatusConstants());
switch ($this->view) {
case 'project':
$query->needProjectPHIDs(true);
break;
}
$project_phid = $request->getStr('project');
$project_handle = null;
if ($project_phid) {
$phids = array($project_phid);
$handles = $this->loadViewerHandles($phids);
$project_handle = $handles[$project_phid];
$query->withEdgeLogicPHIDs(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
PhabricatorQueryConstraint::OPERATOR_OR,
$phids);
}
$tasks = $query->execute();
$recently_closed = $this->loadRecentlyClosedTasks();
$date = phabricator_date(time(), $viewer);
switch ($this->view) {
case 'user':
$result = mgroup($tasks, 'getOwnerPHID');
$leftover = idx($result, '', array());
unset($result['']);
$result_closed = mgroup($recently_closed, 'getOwnerPHID');
$leftover_closed = idx($result_closed, '', array());
unset($result_closed['']);
$base_link = '/maniphest/?assigned=';
$leftover_name = phutil_tag('em', array(), pht('(Up For Grabs)'));
$col_header = pht('User');
$header = pht('Open Tasks by User and Priority (%s)', $date);
break;
case 'project':
$result = array();
$leftover = array();
foreach ($tasks as $task) {
$phids = $task->getProjectPHIDs();
if ($phids) {
foreach ($phids as $project_phid) {
$result[$project_phid][] = $task;
}
} else {
$leftover[] = $task;
}
}
$result_closed = array();
$leftover_closed = array();
foreach ($recently_closed as $task) {
$phids = $task->getProjectPHIDs();
if ($phids) {
foreach ($phids as $project_phid) {
$result_closed[$project_phid][] = $task;
}
} else {
$leftover_closed[] = $task;
}
}
$base_link = '/maniphest/?projects=';
$leftover_name = phutil_tag('em', array(), pht('(No Project)'));
$col_header = pht('Project');
$header = pht('Open Tasks by Project and Priority (%s)', $date);
break;
}
$phids = array_keys($result);
$handles = $this->loadViewerHandles($phids);
$handles = msort($handles, 'getName');
$order = $request->getStr('order', 'name');
list($order, $reverse) = AphrontTableView::parseSort($order);
require_celerity_resource('aphront-tooltip-css');
Javelin::initBehavior('phabricator-tooltips', array());
$rows = array();
$pri_total = array();
foreach (array_merge($handles, array(null)) as $handle) {
if ($handle) {
if (($project_handle) &&
($project_handle->getPHID() == $handle->getPHID())) {
// If filtering by, e.g., "bugs", don't show a "bugs" group.
continue;
}
$tasks = idx($result, $handle->getPHID(), array());
$name = phutil_tag(
'a',
array(
'href' => $base_link.$handle->getPHID(),
),
$handle->getName());
$closed = idx($result_closed, $handle->getPHID(), array());
} else {
$tasks = $leftover;
$name = $leftover_name;
$closed = $leftover_closed;
}
$taskv = $tasks;
$tasks = mgroup($tasks, 'getPriority');
$row = array();
$row[] = $name;
$total = 0;
foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $label) {
$n = count(idx($tasks, $pri, array()));
if ($n == 0) {
$row[] = '-';
} else {
$row[] = number_format($n);
}
$total += $n;
}
$row[] = number_format($total);
list($link, $oldest_all) = $this->renderOldest($taskv);
$row[] = $link;
$normal_or_better = array();
foreach ($taskv as $id => $task) {
// TODO: This is sort of a hard-code for the default "normal" status.
// When reports are more powerful, this should be made more general.
if ($task->getPriority() < 50) {
continue;
}
$normal_or_better[$id] = $task;
}
list($link, $oldest_pri) = $this->renderOldest($normal_or_better);
$row[] = $link;
if ($closed) {
$task_ids = implode(',', mpull($closed, 'getID'));
$row[] = phutil_tag(
'a',
array(
'href' => '/maniphest/?ids='.$task_ids,
'target' => '_blank',
),
number_format(count($closed)));
} else {
$row[] = '-';
}
switch ($order) {
case 'total':
$row['sort'] = $total;
break;
case 'oldest-all':
$row['sort'] = $oldest_all;
break;
case 'oldest-pri':
$row['sort'] = $oldest_pri;
break;
case 'closed':
$row['sort'] = count($closed);
break;
case 'name':
default:
$row['sort'] = $handle ? $handle->getName() : '~';
break;
}
$rows[] = $row;
}
$rows = isort($rows, 'sort');
foreach ($rows as $k => $row) {
unset($rows[$k]['sort']);
}
if ($reverse) {
$rows = array_reverse($rows);
}
$cname = array($col_header);
$cclass = array('pri right wide');
$pri_map = ManiphestTaskPriority::getShortNameMap();
foreach ($pri_map as $pri => $label) {
$cname[] = $label;
$cclass[] = 'n';
}
$cname[] = pht('Total');
$cclass[] = 'n';
$cname[] = javelin_tag(
'span',
array(
'sigil' => 'has-tooltip',
'meta' => array(
'tip' => pht('Oldest open task.'),
'size' => 200,
),
),
pht('Oldest (All)'));
$cclass[] = 'n';
$cname[] = javelin_tag(
'span',
array(
'sigil' => 'has-tooltip',
'meta' => array(
'tip' => pht(
'Oldest open task, excluding those with Low or Wishlist priority.'),
'size' => 200,
),
),
pht('Oldest (Pri)'));
$cclass[] = 'n';
list($ignored, $window_epoch) = $this->getWindow();
$edate = phabricator_datetime($window_epoch, $viewer);
$cname[] = javelin_tag(
'span',
array(
'sigil' => 'has-tooltip',
'meta' => array(
'tip' => pht('Closed after %s', $edate),
'size' => 260,
),
),
pht('Recently Closed'));
$cclass[] = 'n';
$table = new AphrontTableView($rows);
$table->setHeaders($cname);
$table->setColumnClasses($cclass);
$table->makeSortable(
$request->getRequestURI(),
'order',
$order,
$reverse,
array(
'name',
null,
null,
null,
null,
null,
null,
'total',
'oldest-all',
'oldest-pri',
'closed',
));
$panel = new PHUIObjectBoxView();
$panel->setHeaderText($header);
$panel->setTable($table);
$tokens = array();
if ($project_handle) {
$tokens = array($project_handle);
}
$filter = $this->renderReportFilters($tokens, $has_window = true);
return array($filter, $panel);
}
/**
* Load all the tasks that have been recently closed.
*/
private function loadRecentlyClosedTasks() {
list($ignored, $window_epoch) = $this->getWindow();
$table = new ManiphestTask();
$xtable = new ManiphestTransaction();
$conn_r = $table->establishConnection('r');
// TODO: Gross. This table is not meant to be queried like this. Build
// real stats tables.
$open_status_list = array();
foreach (ManiphestTaskStatus::getOpenStatusConstants() as $constant) {
$open_status_list[] = json_encode((string)$constant);
}
$rows = queryfx_all(
$conn_r,
'SELECT t.id FROM %T t JOIN %T x ON x.objectPHID = t.phid
WHERE t.status NOT IN (%Ls)
AND x.oldValue IN (null, %Ls)
AND x.newValue NOT IN (%Ls)
AND t.dateModified >= %d
AND x.dateCreated >= %d',
$table->getTableName(),
$xtable->getTableName(),
ManiphestTaskStatus::getOpenStatusConstants(),
$open_status_list,
$open_status_list,
$window_epoch,
$window_epoch);
if (!$rows) {
return array();
}
$ids = ipull($rows, 'id');
$query = id(new ManiphestTaskQuery())
->setViewer($this->getRequest()->getUser())
->withIDs($ids);
switch ($this->view) {
case 'project':
$query->needProjectPHIDs(true);
break;
}
return $query->execute();
}
/**
* Parse the "Recently Means" filter into:
*
* - A string representation, like "12 AM 7 days ago" (default);
* - a locale-aware epoch representation; and
* - a possible error.
*/
private function getWindow() {
$request = $this->getRequest();
$viewer = $request->getUser();
$window_str = $this->getRequest()->getStr('window', '12 AM 7 days ago');
$error = null;
$window_epoch = null;
// Do locale-aware parsing so that the user's timezone is assumed for
// time windows like "3 PM", rather than assuming the server timezone.
$window_epoch = PhabricatorTime::parseLocalTime($window_str, $viewer);
if (!$window_epoch) {
$error = 'Invalid';
$window_epoch = time() - (60 * 60 * 24 * 7);
}
// If the time ends up in the future, convert it to the corresponding time
// and equal distance in the past. This is so users can type "6 days" (which
// means "6 days from now") and get the behavior of "6 days ago", rather
// than no results (because the window epoch is in the future). This might
- // be a little confusing because it casues "tomorrow" to mean "yesterday"
+ // be a little confusing because it causes "tomorrow" to mean "yesterday"
// and "2022" (or whatever) to mean "ten years ago", but these inputs are
// nonsense anyway.
if ($window_epoch > time()) {
$window_epoch = time() - ($window_epoch - time());
}
return array($window_str, $window_epoch, $error);
}
private function renderOldest(array $tasks) {
assert_instances_of($tasks, 'ManiphestTask');
$oldest = null;
foreach ($tasks as $id => $task) {
if (($oldest === null) ||
($task->getDateCreated() < $tasks[$oldest]->getDateCreated())) {
$oldest = $id;
}
}
if ($oldest === null) {
return array('-', 0);
}
$oldest = $tasks[$oldest];
$raw_age = (time() - $oldest->getDateCreated());
$age = number_format($raw_age / (24 * 60 * 60)).' d';
$link = javelin_tag(
'a',
array(
'href' => '/T'.$oldest->getID(),
'sigil' => 'has-tooltip',
'meta' => array(
'tip' => 'T'.$oldest->getID().': '.$oldest->getTitle(),
),
'target' => '_blank',
),
$age);
return array($link, $raw_age);
}
}
diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php
index 1b780549dc..caf70b8f3c 100644
--- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php
+++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php
@@ -1,1061 +1,1061 @@
<?php
final class ManiphestTransactionEditor
extends PhabricatorApplicationTransactionEditor {
private $moreValidationErrors = array();
public function getEditorApplicationClass() {
return 'PhabricatorManiphestApplication';
}
public function getEditorObjectsDescription() {
return pht('Maniphest Tasks');
}
public function getTransactionTypes() {
$types = parent::getTransactionTypes();
$types[] = PhabricatorTransactions::TYPE_COMMENT;
$types[] = PhabricatorTransactions::TYPE_EDGE;
$types[] = PhabricatorTransactions::TYPE_COLUMNS;
$types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
$types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
return $types;
}
public function getCreateObjectTitle($author, $object) {
return pht('%s created this task.', $author);
}
public function getCreateObjectTitleForFeed($author, $object) {
return pht('%s created %s.', $author, $object);
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COLUMNS:
return null;
}
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COLUMNS:
return $xaction->getNewValue();
}
}
protected function transactionHasEffect(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COLUMNS:
return (bool)$new;
}
return parent::transactionHasEffect($object, $xaction);
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COLUMNS:
return;
}
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COLUMNS:
foreach ($xaction->getNewValue() as $move) {
$this->applyBoardMove($object, $move);
}
break;
}
}
protected function applyFinalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
// When we change the status of a task, update tasks this tasks blocks
// with a message to the effect of "alincoln resolved blocking task Txxx."
$unblock_xaction = null;
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:
$unblock_xaction = $xaction;
break;
}
}
if ($unblock_xaction !== null) {
$blocked_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
ManiphestTaskDependedOnByTaskEdgeType::EDGECONST);
if ($blocked_phids) {
// In theory we could apply these through policies, but that seems a
// little bit surprising. For now, use the actor's vision.
$blocked_tasks = id(new ManiphestTaskQuery())
->setViewer($this->getActor())
->withPHIDs($blocked_phids)
->needSubscriberPHIDs(true)
->needProjectPHIDs(true)
->execute();
$old = $unblock_xaction->getOldValue();
$new = $unblock_xaction->getNewValue();
foreach ($blocked_tasks as $blocked_task) {
$parent_xaction = id(new ManiphestTransaction())
->setTransactionType(
ManiphestTaskUnblockTransaction::TRANSACTIONTYPE)
->setOldValue(array($object->getPHID() => $old))
->setNewValue(array($object->getPHID() => $new));
if ($this->getIsNewObject()) {
$parent_xaction->setMetadataValue('blocker.new', true);
}
id(new ManiphestTransactionEditor())
->setActor($this->getActor())
->setActingAsPHID($this->getActingAsPHID())
->setContentSource($this->getContentSource())
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->applyTransactions($blocked_task, array($parent_xaction));
}
}
}
return $xactions;
}
protected function shouldSendMail(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
protected function getMailSubjectPrefix() {
return PhabricatorEnv::getEnvConfig('metamta.maniphest.subject-prefix');
}
protected function getMailThreadID(PhabricatorLiskDAO $object) {
return 'maniphest-task-'.$object->getPHID();
}
protected function getMailTo(PhabricatorLiskDAO $object) {
$phids = array();
if ($object->getOwnerPHID()) {
$phids[] = $object->getOwnerPHID();
}
$phids[] = $this->getActingAsPHID();
return $phids;
}
public function getMailTagsMap() {
return array(
ManiphestTransaction::MAILTAG_STATUS =>
pht("A task's status changes."),
ManiphestTransaction::MAILTAG_OWNER =>
pht("A task's owner changes."),
ManiphestTransaction::MAILTAG_PRIORITY =>
pht("A task's priority changes."),
ManiphestTransaction::MAILTAG_CC =>
pht("A task's subscribers change."),
ManiphestTransaction::MAILTAG_PROJECTS =>
pht("A task's associated projects change."),
ManiphestTransaction::MAILTAG_UNBLOCK =>
pht("One of a task's subtasks changes status."),
ManiphestTransaction::MAILTAG_COLUMN =>
pht('A task is moved between columns on a workboard.'),
ManiphestTransaction::MAILTAG_COMMENT =>
pht('Someone comments on a task.'),
ManiphestTransaction::MAILTAG_OTHER =>
pht('Other task activity not listed above occurs.'),
);
}
protected function buildReplyHandler(PhabricatorLiskDAO $object) {
return id(new ManiphestReplyHandler())
->setMailReceiver($object);
}
protected function buildMailTemplate(PhabricatorLiskDAO $object) {
$id = $object->getID();
$title = $object->getTitle();
return id(new PhabricatorMetaMTAMail())
->setSubject("T{$id}: {$title}")
->addHeader('Thread-Topic', "T{$id}: ".$object->getOriginalTitle());
}
protected function buildMailBody(
PhabricatorLiskDAO $object,
array $xactions) {
$body = parent::buildMailBody($object, $xactions);
if ($this->getIsNewObject()) {
$body->addRemarkupSection(
pht('TASK DESCRIPTION'),
$object->getDescription());
}
$body->addLinkSection(
pht('TASK DETAIL'),
PhabricatorEnv::getProductionURI('/T'.$object->getID()));
$board_phids = array();
$type_columns = PhabricatorTransactions::TYPE_COLUMNS;
foreach ($xactions as $xaction) {
if ($xaction->getTransactionType() == $type_columns) {
$moves = $xaction->getNewValue();
foreach ($moves as $move) {
$board_phids[] = $move['boardPHID'];
}
}
}
if ($board_phids) {
$projects = id(new PhabricatorProjectQuery())
->setViewer($this->requireActor())
->withPHIDs($board_phids)
->execute();
foreach ($projects as $project) {
$body->addLinkSection(
pht('WORKBOARD'),
PhabricatorEnv::getProductionURI(
'/project/board/'.$project->getID().'/'));
}
}
return $body;
}
protected function shouldPublishFeedStory(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
protected function supportsSearch() {
return true;
}
protected function shouldApplyHeraldRules(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
protected function buildHeraldAdapter(
PhabricatorLiskDAO $object,
array $xactions) {
return id(new HeraldManiphestTaskAdapter())
->setTask($object);
}
protected function requireCapabilities(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
parent::requireCapabilities($object, $xaction);
$app_capability_map = array(
ManiphestTaskPriorityTransaction::TRANSACTIONTYPE =>
ManiphestEditPriorityCapability::CAPABILITY,
ManiphestTaskStatusTransaction::TRANSACTIONTYPE =>
ManiphestEditStatusCapability::CAPABILITY,
ManiphestTaskOwnerTransaction::TRANSACTIONTYPE =>
ManiphestEditAssignCapability::CAPABILITY,
PhabricatorTransactions::TYPE_EDIT_POLICY =>
ManiphestEditPoliciesCapability::CAPABILITY,
PhabricatorTransactions::TYPE_VIEW_POLICY =>
ManiphestEditPoliciesCapability::CAPABILITY,
);
$transaction_type = $xaction->getTransactionType();
$app_capability = null;
if ($transaction_type == PhabricatorTransactions::TYPE_EDGE) {
switch ($xaction->getMetadataValue('edge:type')) {
case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST:
$app_capability = ManiphestEditProjectsCapability::CAPABILITY;
break;
}
} else {
$app_capability = idx($app_capability_map, $transaction_type);
}
if ($app_capability) {
$app = id(new PhabricatorApplicationQuery())
->setViewer($this->getActor())
->withClasses(array('PhabricatorManiphestApplication'))
->executeOne();
PhabricatorPolicyFilter::requireCapability(
$this->getActor(),
$app,
$app_capability);
}
}
protected function adjustObjectForPolicyChecks(
PhabricatorLiskDAO $object,
array $xactions) {
$copy = parent::adjustObjectForPolicyChecks($object, $xactions);
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE:
$copy->setOwnerPHID($xaction->getNewValue());
break;
default:
continue;
}
}
return $copy;
}
/**
* Get priorities for moving a task to a new priority.
*/
public static function getEdgeSubpriority(
$priority,
$is_end) {
$query = id(new ManiphestTaskQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPriorities(array($priority))
->setLimit(1);
if ($is_end) {
$query->setOrderVector(array('-priority', '-subpriority', '-id'));
} else {
$query->setOrderVector(array('priority', 'subpriority', 'id'));
}
$result = $query->executeOne();
$step = (double)(2 << 32);
if ($result) {
$base = $result->getSubpriority();
if ($is_end) {
$sub = ($base - $step);
} else {
$sub = ($base + $step);
}
} else {
$sub = 0;
}
return array($priority, $sub);
}
/**
* Get priorities for moving a task before or after another task.
*/
public static function getAdjacentSubpriority(
ManiphestTask $dst,
$is_after) {
$query = id(new ManiphestTaskQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->setOrder(ManiphestTaskQuery::ORDER_PRIORITY)
->withPriorities(array($dst->getPriority()))
->setLimit(1);
if ($is_after) {
$query->setAfterID($dst->getID());
} else {
$query->setBeforeID($dst->getID());
}
$adjacent = $query->executeOne();
$base = $dst->getSubpriority();
$step = (double)(2 << 32);
// If we find an adjacent task, we average the two subpriorities and
// return the result.
if ($adjacent) {
$epsilon = 1.0;
// If the adjacent task has a subpriority that is identical or very
// close to the task we're looking at, we're going to spread out all
// the nearby tasks.
$adjacent_sub = $adjacent->getSubpriority();
if ((abs($adjacent_sub - $base) < $epsilon)) {
$base = self::disperseBlock(
$dst,
$epsilon * 2);
if ($is_after) {
$sub = $base - $epsilon;
} else {
$sub = $base + $epsilon;
}
} else {
$sub = ($adjacent_sub + $base) / 2;
}
} else {
// Otherwise, we take a step away from the target's subpriority and
// use that.
if ($is_after) {
$sub = ($base - $step);
} else {
$sub = ($base + $step);
}
}
return array($dst->getPriority(), $sub);
}
/**
* Distribute a cluster of tasks with similar subpriorities.
*/
private static function disperseBlock(
ManiphestTask $task,
$spacing) {
$conn = $task->establishConnection('w');
// Find a block of subpriority space which is, on average, sparse enough
// to hold all the tasks that are inside it with a reasonable level of
// separation between them.
// We'll start by looking near the target task for a range of numbers
// which has more space available than tasks. For example, if the target
// task has subpriority 33 and we want to separate each task by at least 1,
// we might start by looking in the range [23, 43].
// If we find fewer than 20 tasks there, we have room to reassign them
// with the desired level of separation. We space them out, then we're
// done.
// However: if we find more than 20 tasks, we don't have enough room to
// distribute them. We'll widen our search and look in a bigger range,
// maybe [13, 53]. This range has more space, so if we find fewer than
// 40 tasks in this range we can spread them out. If we still find too
// many tasks, we keep widening the search.
$base = $task->getSubpriority();
$scale = 4.0;
while (true) {
$range = ($spacing * $scale) / 2.0;
$min = ($base - $range);
$max = ($base + $range);
$result = queryfx_one(
$conn,
'SELECT COUNT(*) N FROM %T WHERE priority = %d AND
subpriority BETWEEN %f AND %f',
$task->getTableName(),
$task->getPriority(),
$min,
$max);
$count = $result['N'];
if ($count < $scale) {
// We have found a block which we can make sparse enough, so bail and
// continue below with our selection.
break;
}
// This block had too many tasks for its size, so try again with a
// bigger block.
$scale *= 2.0;
}
$rows = queryfx_all(
$conn,
'SELECT id FROM %T WHERE priority = %d AND
subpriority BETWEEN %f AND %f
ORDER BY priority, subpriority, id',
$task->getTableName(),
$task->getPriority(),
$min,
$max);
$task_id = $task->getID();
$result = null;
// NOTE: In strict mode (which we encourage enabling) we can't structure
// this bulk update as an "INSERT ... ON DUPLICATE KEY UPDATE" unless we
// provide default values for ALL of the columns that don't have defaults.
// This is gross, but we may be moving enough rows that individual
// queries are unreasonably slow. An alternate construction which might
// be worth evaluating is to use "CASE". Another approach is to disable
// strict mode for this query.
$extra_columns = array(
'phid' => '""',
'authorPHID' => '""',
'status' => '""',
'priority' => 0,
'title' => '""',
'originalTitle' => '""',
'description' => '""',
'dateCreated' => 0,
'dateModified' => 0,
'mailKey' => '""',
'viewPolicy' => '""',
'editPolicy' => '""',
'ownerOrdering' => '""',
'spacePHID' => '""',
'bridgedObjectPHID' => '""',
'properties' => '""',
'points' => 0,
'subtype' => '""',
);
$defaults = implode(', ', $extra_columns);
$sql = array();
$offset = 0;
// Often, we'll have more room than we need in the range. Distribute the
// tasks evenly over the whole range so that we're less likely to end up
// with tasks spaced exactly the minimum distance apart, which may
// get shifted again later. We have one fewer space to distribute than we
// have tasks.
$divisor = (double)(count($rows) - 1.0);
if ($divisor > 0) {
$available_distance = (($max - $min) / $divisor);
} else {
$available_distance = 0.0;
}
foreach ($rows as $row) {
$subpriority = $min + ($offset * $available_distance);
// If this is the task that we're spreading out relative to, keep track
// of where it is ending up so we can return the new subpriority.
$id = $row['id'];
if ($id == $task_id) {
$result = $subpriority;
}
$sql[] = qsprintf(
$conn,
'(%d, %Q, %f)',
$id,
$defaults,
$subpriority);
$offset++;
}
foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) {
queryfx(
$conn,
'INSERT INTO %T (id, %Q, subpriority) VALUES %Q
ON DUPLICATE KEY UPDATE subpriority = VALUES(subpriority)',
$task->getTableName(),
implode(', ', array_keys($extra_columns)),
$chunk);
}
return $result;
}
protected function validateAllTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$errors = parent::validateAllTransactions($object, $xactions);
if ($this->moreValidationErrors) {
$errors = array_merge($errors, $this->moreValidationErrors);
}
return $errors;
}
protected function expandTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$actor = $this->getActor();
$actor_phid = $actor->getPHID();
$results = parent::expandTransactions($object, $xactions);
$is_unassigned = ($object->getOwnerPHID() === null);
$any_assign = false;
foreach ($xactions as $xaction) {
if ($xaction->getTransactionType() ==
ManiphestTaskOwnerTransaction::TRANSACTIONTYPE) {
$any_assign = true;
break;
}
}
$is_open = !$object->isClosed();
$new_status = null;
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:
$new_status = $xaction->getNewValue();
break;
}
}
if ($new_status === null) {
$is_closing = false;
} else {
$is_closing = ManiphestTaskStatus::isClosedStatus($new_status);
}
// If the task is not assigned, not being assigned, currently open, and
// being closed, try to assign the actor as the owner.
if ($is_unassigned && !$any_assign && $is_open && $is_closing) {
$is_claim = ManiphestTaskStatus::isClaimStatus($new_status);
// Don't assign the actor if they aren't a real user.
// Don't claim the task if the status is configured to not claim.
if ($actor_phid && $is_claim) {
$results[] = id(new ManiphestTransaction())
->setTransactionType(ManiphestTaskOwnerTransaction::TRANSACTIONTYPE)
->setNewValue($actor_phid);
}
}
// Automatically subscribe the author when they create a task.
if ($this->getIsNewObject()) {
if ($actor_phid) {
$results[] = id(new ManiphestTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
->setNewValue(
array(
'+' => array($actor_phid => $actor_phid),
));
}
}
return $results;
}
protected function expandTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$results = parent::expandTransaction($object, $xaction);
$type = $xaction->getTransactionType();
switch ($type) {
case PhabricatorTransactions::TYPE_COLUMNS:
try {
$more_xactions = $this->buildMoveTransaction($object, $xaction);
foreach ($more_xactions as $more_xaction) {
$results[] = $more_xaction;
}
} catch (Exception $ex) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
$ex->getMessage(),
$xaction);
$this->moreValidationErrors[] = $error;
}
break;
case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE:
// If this is a no-op update, don't expand it.
$old_value = $object->getOwnerPHID();
$new_value = $xaction->getNewValue();
if ($old_value === $new_value) {
continue;
}
// When a task is reassigned, move the old owner to the subscriber
// list so they're still in the loop.
if ($old_value) {
$results[] = id(new ManiphestTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
->setIgnoreOnNoEffect(true)
->setNewValue(
array(
'+' => array($old_value => $old_value),
));
}
break;
}
return $results;
}
private function buildMoveTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$new = $xaction->getNewValue();
if (!is_array($new)) {
$this->validateColumnPHID($new);
$new = array($new);
}
$nearby_phids = array();
foreach ($new as $key => $value) {
if (!is_array($value)) {
$this->validateColumnPHID($value);
$value = array(
'columnPHID' => $value,
);
}
PhutilTypeSpec::checkMap(
$value,
array(
'columnPHID' => 'string',
'beforePHID' => 'optional string',
'afterPHID' => 'optional string',
));
$new[$key] = $value;
if (!empty($value['beforePHID'])) {
$nearby_phids[] = $value['beforePHID'];
}
if (!empty($value['afterPHID'])) {
$nearby_phids[] = $value['afterPHID'];
}
}
if ($nearby_phids) {
$nearby_objects = id(new PhabricatorObjectQuery())
->setViewer($this->getActor())
->withPHIDs($nearby_phids)
->execute();
$nearby_objects = mpull($nearby_objects, null, 'getPHID');
} else {
$nearby_objects = array();
}
$column_phids = ipull($new, 'columnPHID');
if ($column_phids) {
$columns = id(new PhabricatorProjectColumnQuery())
->setViewer($this->getActor())
->withPHIDs($column_phids)
->execute();
$columns = mpull($columns, null, 'getPHID');
} else {
$columns = array();
}
$board_phids = mpull($columns, 'getProjectPHID');
$object_phid = $object->getPHID();
$object_phids = $nearby_phids;
// Note that we may not have an object PHID if we're creating a new
// object.
if ($object_phid) {
$object_phids[] = $object_phid;
}
if ($object_phids) {
$layout_engine = id(new PhabricatorBoardLayoutEngine())
->setViewer($this->getActor())
->setBoardPHIDs($board_phids)
->setObjectPHIDs($object_phids)
->setFetchAllBoards(true)
->executeLayout();
}
foreach ($new as $key => $spec) {
$column_phid = $spec['columnPHID'];
$column = idx($columns, $column_phid);
if (!$column) {
throw new Exception(
pht(
'Column move transaction specifies column PHID "%s", but there '.
'is no corresponding column with this PHID.',
$column_phid));
}
$board_phid = $column->getProjectPHID();
$nearby = array();
if (!empty($spec['beforePHID'])) {
$nearby['beforePHID'] = $spec['beforePHID'];
}
if (!empty($spec['afterPHID'])) {
$nearby['afterPHID'] = $spec['afterPHID'];
}
if (count($nearby) > 1) {
throw new Exception(
pht(
'Column move transaction moves object to multiple positions. '.
'Specify only "beforePHID" or "afterPHID", not both.'));
}
foreach ($nearby as $where => $nearby_phid) {
if (empty($nearby_objects[$nearby_phid])) {
throw new Exception(
pht(
'Column move transaction specifies object "%s" as "%s", but '.
'there is no corresponding object with this PHID.',
$object_phid,
$where));
}
$nearby_columns = $layout_engine->getObjectColumns(
$board_phid,
$nearby_phid);
$nearby_columns = mpull($nearby_columns, null, 'getPHID');
if (empty($nearby_columns[$column_phid])) {
throw new Exception(
pht(
'Column move transaction specifies object "%s" as "%s" in '.
'column "%s", but this object is not in that column!',
$nearby_phid,
$where,
$column_phid));
}
}
if ($object_phid) {
$old_columns = $layout_engine->getObjectColumns(
$board_phid,
$object_phid);
$old_column_phids = mpull($old_columns, 'getPHID');
} else {
$old_column_phids = array();
}
$spec += array(
'boardPHID' => $board_phid,
'fromColumnPHIDs' => $old_column_phids,
);
// Check if the object is already in this column, and isn't being moved.
// We can just drop this column change if it has no effect.
$from_map = array_fuse($spec['fromColumnPHIDs']);
$already_here = isset($from_map[$column_phid]);
$is_reordering = (bool)$nearby;
if ($already_here && !$is_reordering) {
unset($new[$key]);
} else {
$new[$key] = $spec;
}
}
$new = array_values($new);
$xaction->setNewValue($new);
$more = array();
// If we're moving the object into a column and it does not already belong
// in the column, add the appropriate board. For normal columns, this
// is the board PHID. For proxy columns, it is the proxy PHID, unless the
// object is already a member of some descendant of the proxy PHID.
// The major case where this can happen is moves via the API, but it also
// happens when a user drags a task from the "Backlog" to a milestone
// column.
if ($object_phid) {
$current_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object_phid,
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
$current_phids = array_fuse($current_phids);
} else {
$current_phids = array();
}
$add_boards = array();
foreach ($new as $move) {
$column_phid = $move['columnPHID'];
$board_phid = $move['boardPHID'];
$column = $columns[$column_phid];
$proxy_phid = $column->getProxyPHID();
// If this is a normal column, add the board if the object isn't already
// associated.
if (!$proxy_phid) {
if (!isset($current_phids[$board_phid])) {
$add_boards[] = $board_phid;
}
continue;
}
// If this is a proxy column but the object is already associated with
// the proxy board, we don't need to do anything.
if (isset($current_phids[$proxy_phid])) {
continue;
}
// If this a proxy column and the object is already associated with some
// descendant of the proxy board, we also don't need to do anything.
$descendants = id(new PhabricatorProjectQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withAncestorProjectPHIDs(array($proxy_phid))
->execute();
$found_descendant = false;
foreach ($descendants as $descendant) {
if (isset($current_phids[$descendant->getPHID()])) {
$found_descendant = true;
break;
}
}
if ($found_descendant) {
continue;
}
// Otherwise, we're moving the object to a proxy column which it is not
// a member of yet, so add an association to the column's proxy board.
$add_boards[] = $proxy_phid;
}
if ($add_boards) {
$more[] = id(new ManiphestTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
->setMetadataValue(
'edge:type',
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST)
->setIgnoreOnNoEffect(true)
->setNewValue(
array(
'+' => array_fuse($add_boards),
));
}
return $more;
}
private function applyBoardMove($object, array $move) {
$board_phid = $move['boardPHID'];
$column_phid = $move['columnPHID'];
$before_phid = idx($move, 'beforePHID');
$after_phid = idx($move, 'afterPHID');
$object_phid = $object->getPHID();
- // We're doing layout with the ominpotent viewer to make sure we don't
+ // We're doing layout with the omnipotent viewer to make sure we don't
// remove positions in columns that exist, but which the actual actor
// can't see.
$omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
$select_phids = array($board_phid);
$descendants = id(new PhabricatorProjectQuery())
->setViewer($omnipotent_viewer)
->withAncestorProjectPHIDs($select_phids)
->execute();
foreach ($descendants as $descendant) {
$select_phids[] = $descendant->getPHID();
}
$board_tasks = id(new ManiphestTaskQuery())
->setViewer($omnipotent_viewer)
->withEdgeLogicPHIDs(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
PhabricatorQueryConstraint::OPERATOR_ANCESTOR,
array($select_phids))
->execute();
$board_tasks = mpull($board_tasks, null, 'getPHID');
$board_tasks[$object_phid] = $object;
// Make sure tasks are sorted by ID, so we lay out new positions in
// a consistent way.
$board_tasks = msort($board_tasks, 'getID');
$object_phids = array_keys($board_tasks);
$engine = id(new PhabricatorBoardLayoutEngine())
->setViewer($omnipotent_viewer)
->setBoardPHIDs(array($board_phid))
->setObjectPHIDs($object_phids)
->executeLayout();
// TODO: This logic needs to be revised when we legitimately support
// multiple column positions.
$columns = $engine->getObjectColumns($board_phid, $object_phid);
foreach ($columns as $column) {
$engine->queueRemovePosition(
$board_phid,
$column->getPHID(),
$object_phid);
}
if ($before_phid) {
$engine->queueAddPositionBefore(
$board_phid,
$column_phid,
$object_phid,
$before_phid);
} else if ($after_phid) {
$engine->queueAddPositionAfter(
$board_phid,
$column_phid,
$object_phid,
$after_phid);
} else {
$engine->queueAddPosition(
$board_phid,
$column_phid,
$object_phid);
}
$engine->applyPositionUpdates();
}
private function validateColumnPHID($value) {
if (phid_get_type($value) == PhabricatorProjectColumnPHIDType::TYPECONST) {
return;
}
throw new Exception(
pht(
'When moving objects between columns on a board, columns must '.
'be identified by PHIDs. This transaction uses "%s" to identify '.
'a column, but that is not a valid column PHID.',
$value));
}
}
diff --git a/src/applications/metamta/management/PhabricatorMailManagementUnverifyWorkflow.php b/src/applications/metamta/management/PhabricatorMailManagementUnverifyWorkflow.php
index 61861d1db0..64f4976ebe 100644
--- a/src/applications/metamta/management/PhabricatorMailManagementUnverifyWorkflow.php
+++ b/src/applications/metamta/management/PhabricatorMailManagementUnverifyWorkflow.php
@@ -1,110 +1,110 @@
<?php
final class PhabricatorMailManagementUnverifyWorkflow
extends PhabricatorMailManagementWorkflow {
protected function didConstruct() {
$this
->setName('unverify')
->setSynopsis(
pht('Unverify an email address so it no longer receives mail.'))
->setExamples('**unverify** __address__ ...')
->setArguments(
array(
array(
'name' => 'addresses',
'wildcard' => true,
'help' => pht('Address (or addresses) to unverify.'),
),
));
}
public function execute(PhutilArgumentParser $args) {
$console = PhutilConsole::getConsole();
$viewer = $this->getViewer();
$addresses = $args->getArg('addresses');
if (!$addresses) {
throw new PhutilArgumentUsageException(
pht('Specify one or more email addresses to unverify.'));
}
foreach ($addresses as $address) {
$email = id(new PhabricatorUserEmail())->loadOneWhere(
'address = %s',
$address);
if (!$email) {
echo tsprintf(
"%s\n",
pht(
'Address "%s" is unknown.',
$address));
continue;
}
$user_phid = $email->getUserPHID();
$user = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withPHIDs(array($user_phid))
->executeOne();
if (!$user) {
echo tsprintf(
"%s\n",
pht(
'Address "%s" belongs to invalid user "%s".',
$address,
$user_phid));
continue;
}
if (!$email->getIsVerified()) {
echo tsprintf(
"%s\n",
pht(
- 'Address "%s" (owned by "%s") is already unveriifed.',
+ 'Address "%s" (owned by "%s") is already unverified.',
$address,
$user->getUsername()));
continue;
}
$email->openTransaction();
$email
->setIsVerified(0)
->save();
if ($email->getIsPrimary()) {
$user
->setIsEmailVerified(0)
->save();
}
$email->saveTransaction();
if ($email->getIsPrimary()) {
echo tsprintf(
"%s\n",
pht(
'Unverified "%s", the primary address for "%s".',
$address,
$user->getUsername()));
} else {
echo tsprintf(
"%s\n",
pht(
'Unverified "%s", an address for "%s".',
$address,
$user->getUsername()));
}
}
echo tsprintf(
"%s\n",
pht('Done.'));
return 0;
}
}
diff --git a/src/applications/metamta/receiver/PhabricatorMailReceiver.php b/src/applications/metamta/receiver/PhabricatorMailReceiver.php
index 05b44f364a..12483bf5b6 100644
--- a/src/applications/metamta/receiver/PhabricatorMailReceiver.php
+++ b/src/applications/metamta/receiver/PhabricatorMailReceiver.php
@@ -1,275 +1,275 @@
<?php
abstract class PhabricatorMailReceiver extends Phobject {
private $applicationEmail;
public function setApplicationEmail(
PhabricatorMetaMTAApplicationEmail $email) {
$this->applicationEmail = $email;
return $this;
}
public function getApplicationEmail() {
return $this->applicationEmail;
}
abstract public function isEnabled();
abstract public function canAcceptMail(PhabricatorMetaMTAReceivedMail $mail);
final protected function canAcceptApplicationMail(
PhabricatorApplication $app,
PhabricatorMetaMTAReceivedMail $mail) {
$application_emails = id(new PhabricatorMetaMTAApplicationEmailQuery())
->setViewer($this->getViewer())
->withApplicationPHIDs(array($app->getPHID()))
->execute();
foreach ($mail->getToAddresses() as $to_address) {
foreach ($application_emails as $application_email) {
$create_address = $application_email->getAddress();
if ($this->matchAddresses($create_address, $to_address)) {
$this->setApplicationEmail($application_email);
return true;
}
}
}
return false;
}
abstract protected function processReceivedMail(
PhabricatorMetaMTAReceivedMail $mail,
PhabricatorUser $sender);
final public function receiveMail(
PhabricatorMetaMTAReceivedMail $mail,
PhabricatorUser $sender) {
$this->processReceivedMail($mail, $sender);
}
public function getViewer() {
return PhabricatorUser::getOmnipotentUser();
}
public function validateSender(
PhabricatorMetaMTAReceivedMail $mail,
PhabricatorUser $sender) {
$failure_reason = null;
if ($sender->getIsDisabled()) {
$failure_reason = pht(
'Your account (%s) is disabled, so you can not interact with '.
'Phabricator over email.',
$sender->getUsername());
} else if ($sender->getIsStandardUser()) {
if (!$sender->getIsApproved()) {
$failure_reason = pht(
'Your account (%s) has not been approved yet. You can not interact '.
'with Phabricator over email until your account is approved.',
$sender->getUsername());
} else if (PhabricatorUserEmail::isEmailVerificationRequired() &&
!$sender->getIsEmailVerified()) {
$failure_reason = pht(
'You have not verified the email address for your account (%s). '.
'You must verify your email address before you can interact '.
'with Phabricator over email.',
$sender->getUsername());
}
}
if ($failure_reason) {
throw new PhabricatorMetaMTAReceivedMailProcessingException(
MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER,
$failure_reason);
}
}
/**
* Identifies the sender's user account for a piece of received mail. Note
* that this method does not validate that the sender is who they say they
* are, just that they've presented some credential which corresponds to a
* recognizable user.
*/
public function loadSender(PhabricatorMetaMTAReceivedMail $mail) {
$raw_from = $mail->getHeader('From');
$from = self::getRawAddress($raw_from);
$reasons = array();
// Try to find a user with this email address.
$user = PhabricatorUser::loadOneWithEmailAddress($from);
if ($user) {
return $user;
} else {
$reasons[] = pht(
'This email was sent from "%s", but that address is not recognized by '.
'Phabricator and does not correspond to any known user account.',
$raw_from);
}
// If we missed on "From", try "Reply-To" if we're configured for it.
$raw_reply_to = $mail->getHeader('Reply-To');
if (strlen($raw_reply_to)) {
$reply_to_key = 'metamta.insecure-auth-with-reply-to';
$allow_reply_to = PhabricatorEnv::getEnvConfig($reply_to_key);
if ($allow_reply_to) {
$reply_to = self::getRawAddress($raw_reply_to);
$user = PhabricatorUser::loadOneWithEmailAddress($reply_to);
if ($user) {
return $user;
} else {
$reasons[] = pht(
'Phabricator is configured to authenticate users using the '.
'"Reply-To" header, but the reply address ("%s") on this '.
'message does not correspond to any known user account.',
$raw_reply_to);
}
} else {
$reasons[] = pht(
'(Phabricator is not configured to authenticate users using the '.
'"Reply-To" header, so it was ignored.)');
}
}
// If we don't know who this user is, load or create an external user
// account for them if we're configured for it.
$email_key = 'phabricator.allow-email-users';
$allow_email_users = PhabricatorEnv::getEnvConfig($email_key);
if ($allow_email_users) {
$from_obj = new PhutilEmailAddress($from);
$xuser = id(new PhabricatorExternalAccountQuery())
->setViewer($this->getViewer())
->withAccountTypes(array('email'))
->withAccountDomains(array($from_obj->getDomainName(), 'self'))
->withAccountIDs(array($from_obj->getAddress()))
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->loadOneOrCreate();
return $xuser->getPhabricatorUser();
} else {
// NOTE: Currently, we'll always drop this mail (since it's headed to
// an unverified recipient). See T12237. These details are still useful
// because they'll appear in the mail logs and Mail web UI.
$reasons[] = pht(
'Phabricator is also not configured to allow unknown external users '.
'to send mail to the system using just an email address.');
$reasons[] = pht(
'To interact with Phabricator, add this address ("%s") to your '.
'account.',
$raw_from);
}
if ($this->getApplicationEmail()) {
$application_email = $this->getApplicationEmail();
$default_user_phid = $application_email->getConfigValue(
PhabricatorMetaMTAApplicationEmail::CONFIG_DEFAULT_AUTHOR);
if ($default_user_phid) {
$user = id(new PhabricatorUser())->loadOneWhere(
'phid = %s',
$default_user_phid);
if ($user) {
return $user;
}
$reasons[] = pht(
'Phabricator is misconfigured: the application email '.
'"%s" is set to user "%s", but that user does not exist.',
$application_email->getAddress(),
$default_user_phid);
}
}
$reasons = implode("\n\n", $reasons);
throw new PhabricatorMetaMTAReceivedMailProcessingException(
MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER,
$reasons);
}
/**
* Determine if two inbound email addresses are effectively identical. This
* method strips and normalizes addresses so that equivalent variations are
* correctly detected as identical. For example, these addresses are all
* considered to match one another:
*
* "Abraham Lincoln" <alincoln@example.com>
* alincoln@example.com
* <ALincoln@example.com>
* "Abraham" <phabricator+ALINCOLN@EXAMPLE.COM> # With configured prefix.
*
* @param string Email address.
* @param string Another email address.
* @return bool True if addresses match.
*/
public static function matchAddresses($u, $v) {
$u = self::getRawAddress($u);
$v = self::getRawAddress($v);
$u = self::stripMailboxPrefix($u);
$v = self::stripMailboxPrefix($v);
return ($u === $v);
}
/**
* Strip a global mailbox prefix from an address if it is present. Phabricator
* can be configured to prepend a prefix to all reply addresses, which can
* make forwarding rules easier to write. A prefix looks like:
*
* example@phabricator.example.com # No Prefix
* phabricator+example@phabricator.example.com # Prefix "phabricator"
*
* @param string Email address, possibly with a mailbox prefix.
* @return string Email address with any prefix stripped.
*/
public static function stripMailboxPrefix($address) {
$address = id(new PhutilEmailAddress($address))->getAddress();
$prefix_key = 'metamta.single-reply-handler-prefix';
$prefix = PhabricatorEnv::getEnvConfig($prefix_key);
$len = strlen($prefix);
if ($len) {
$prefix = $prefix.'+';
$len = $len + 1;
}
if ($len) {
if (!strncasecmp($address, $prefix, $len)) {
$address = substr($address, strlen($prefix));
}
}
return $address;
}
/**
- * Reduce an email address to its canonical form. For example, an adddress
+ * Reduce an email address to its canonical form. For example, an address
* like:
*
* "Abraham Lincoln" < ALincoln@example.com >
*
* ...will be reduced to:
*
* alincoln@example.com
*
* @param string Email address in noncanonical form.
* @return string Canonical email address.
*/
public static function getRawAddress($address) {
$address = id(new PhutilEmailAddress($address))->getAddress();
return trim(phutil_utf8_strtolower($address));
}
}
diff --git a/src/applications/owners/storage/__tests__/PhabricatorOwnersPackageTestCase.php b/src/applications/owners/storage/__tests__/PhabricatorOwnersPackageTestCase.php
index f21f09b44e..d219abfc1e 100644
--- a/src/applications/owners/storage/__tests__/PhabricatorOwnersPackageTestCase.php
+++ b/src/applications/owners/storage/__tests__/PhabricatorOwnersPackageTestCase.php
@@ -1,194 +1,194 @@
<?php
final class PhabricatorOwnersPackageTestCase extends PhabricatorTestCase {
public function testFindLongestPathsPerPackage() {
$rows = array(
array(
'id' => 1,
'excluded' => 0,
'dominion' => PhabricatorOwnersPackage::DOMINION_STRONG,
'path' => 'src/',
),
array(
'id' => 1,
'excluded' => 1,
'dominion' => PhabricatorOwnersPackage::DOMINION_STRONG,
'path' => 'src/releeph/',
),
array(
'id' => 2,
'excluded' => 0,
'dominion' => PhabricatorOwnersPackage::DOMINION_STRONG,
'path' => 'src/releeph/',
),
);
$paths = array(
'src/' => array('src/a.php' => true, 'src/releeph/b.php' => true),
'src/releeph/' => array('src/releeph/b.php' => true),
);
$this->assertEqual(
array(
1 => strlen('src/'),
2 => strlen('src/releeph/'),
),
PhabricatorOwnersPackage::findLongestPathsPerPackage($rows, $paths));
$paths = array(
'src/' => array('src/releeph/b.php' => true),
'src/releeph/' => array('src/releeph/b.php' => true),
);
$this->assertEqual(
array(
2 => strlen('src/releeph/'),
),
PhabricatorOwnersPackage::findLongestPathsPerPackage($rows, $paths));
// Test packages with weak dominion. Here, only package #2 should own the
// path. Package #1's claim is ceded to Package #2 because it uses weak
// rules. Package #2 gets the claim even though it also has weak rules
// because there is no more-specific package.
$rows = array(
array(
'id' => 1,
'excluded' => 0,
'dominion' => PhabricatorOwnersPackage::DOMINION_WEAK,
'path' => 'src/',
),
array(
'id' => 2,
'excluded' => 0,
'dominion' => PhabricatorOwnersPackage::DOMINION_WEAK,
'path' => 'src/applications/',
),
);
$pvalue = array('src/applications/main/main.c' => true);
$paths = array(
'src/' => $pvalue,
'src/applications/' => $pvalue,
);
$this->assertEqual(
array(
2 => strlen('src/applications/'),
),
PhabricatorOwnersPackage::findLongestPathsPerPackage($rows, $paths));
// Now, add a more specific path to Package #1. This tests nested ownership
// in packages with weak dominion rules. This time, Package #1 should end
- // up back on top, with Package #2 cedeing control to its more specific
+ // up back on top, with Package #2 ceding control to its more specific
// path.
$rows[] = array(
'id' => 1,
'excluded' => 0,
'dominion' => PhabricatorOwnersPackage::DOMINION_WEAK,
'path' => 'src/applications/main/',
);
$paths['src/applications/main/'] = $pvalue;
$this->assertEqual(
array(
1 => strlen('src/applications/main/'),
),
PhabricatorOwnersPackage::findLongestPathsPerPackage($rows, $paths));
// Test cases where multiple packages own the same path, with various
// dominion rules.
$main_c = 'src/applications/main/main.c';
$rules = array(
// All claims strong.
array(
PhabricatorOwnersPackage::DOMINION_STRONG,
PhabricatorOwnersPackage::DOMINION_STRONG,
PhabricatorOwnersPackage::DOMINION_STRONG,
),
// All claims weak.
array(
PhabricatorOwnersPackage::DOMINION_WEAK,
PhabricatorOwnersPackage::DOMINION_WEAK,
PhabricatorOwnersPackage::DOMINION_WEAK,
),
// Mixture of strong and weak claims, strong first.
array(
PhabricatorOwnersPackage::DOMINION_STRONG,
PhabricatorOwnersPackage::DOMINION_STRONG,
PhabricatorOwnersPackage::DOMINION_WEAK,
),
// Mixture of strong and weak claims, weak first.
array(
PhabricatorOwnersPackage::DOMINION_WEAK,
PhabricatorOwnersPackage::DOMINION_STRONG,
PhabricatorOwnersPackage::DOMINION_STRONG,
),
);
foreach ($rules as $rule_idx => $rule) {
$rows = array(
array(
'id' => 1,
'excluded' => 0,
'dominion' => $rule[0],
'path' => $main_c,
),
array(
'id' => 2,
'excluded' => 0,
'dominion' => $rule[1],
'path' => $main_c,
),
array(
'id' => 3,
'excluded' => 0,
'dominion' => $rule[2],
'path' => $main_c,
),
);
$paths = array(
$main_c => $pvalue,
);
// If one or more packages have strong dominion, they should own the
// path. If not, all the packages with weak dominion should own the
// path.
$strong = array();
$weak = array();
foreach ($rule as $idx => $dominion) {
if ($dominion == PhabricatorOwnersPackage::DOMINION_STRONG) {
$strong[] = $idx + 1;
} else {
$weak[] = $idx + 1;
}
}
if ($strong) {
$expect = $strong;
} else {
$expect = $weak;
}
$expect = array_fill_keys($expect, strlen($main_c));
$actual = PhabricatorOwnersPackage::findLongestPathsPerPackage(
$rows,
$paths);
ksort($actual);
$this->assertEqual(
$expect,
$actual,
pht('Ruleset "%s" for Identical Ownership', $rule_idx));
}
}
}
diff --git a/src/applications/passphrase/credentialtype/PassphraseCredentialType.php b/src/applications/passphrase/credentialtype/PassphraseCredentialType.php
index 1da93bcd38..60cb9bb5ae 100644
--- a/src/applications/passphrase/credentialtype/PassphraseCredentialType.php
+++ b/src/applications/passphrase/credentialtype/PassphraseCredentialType.php
@@ -1,138 +1,138 @@
<?php
/**
* @task password Managing Encryption Passwords
*/
abstract class PassphraseCredentialType extends Phobject {
abstract public function getCredentialType();
abstract public function getProvidesType();
abstract public function getCredentialTypeName();
abstract public function getCredentialTypeDescription();
abstract public function getSecretLabel();
public function newSecretControl() {
return new AphrontFormTextAreaControl();
}
public static function getAllTypes() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getCredentialType')
->execute();
}
public static function getAllCreateableTypes() {
$types = self::getAllTypes();
foreach ($types as $key => $type) {
if (!$type->isCreateable()) {
unset($types[$key]);
}
}
return $types;
}
public static function getAllProvidesTypes() {
$types = array();
foreach (self::getAllTypes() as $type) {
$types[] = $type->getProvidesType();
}
return array_unique($types);
}
public static function getTypeByConstant($constant) {
$all = self::getAllTypes();
$all = mpull($all, null, 'getCredentialType');
return idx($all, $constant);
}
/**
* Can users create new credentials of this type?
*
* @return bool True if new credentials of this type can be created.
*/
public function isCreateable() {
return true;
}
public function didInitializeNewCredential(
PhabricatorUser $actor,
PassphraseCredential $credential) {
return $credential;
}
public function hasPublicKey() {
return false;
}
public function getPublicKey(
PhabricatorUser $viewer,
PassphraseCredential $credential) {
return null;
}
/* -( Passwords )---------------------------------------------------------- */
/**
* Return true to show an additional "Password" field. This is used by
* SSH credentials to strip passwords off private keys.
*
* @return bool True if a password field should be shown to the user.
*
* @task password
*/
public function shouldShowPasswordField() {
return false;
}
/**
* Return the label for the password field, if one is shown.
*
* @return string Human-readable field label.
*
* @task password
*/
public function getPasswordLabel() {
return pht('Password');
}
/**
- * Return true if the provided credental requires a password to decrypt.
+ * Return true if the provided credential requires a password to decrypt.
*
* @param PhutilOpaqueEnvelope Credential secret value.
* @return bool True if the credential needs a password.
*
* @task password
*/
public function requiresPassword(PhutilOpaqueEnvelope $secret) {
return false;
}
/**
* Return the decrypted credential secret, or `null` if the password does
* not decrypt the credential.
*
* @param PhutilOpaqueEnvelope Credential secret value.
* @param PhutilOpaqueEnvelope Credential password.
* @return
* @task password
*/
public function decryptSecret(
PhutilOpaqueEnvelope $secret,
PhutilOpaqueEnvelope $password) {
return $secret;
}
public function shouldRequireUsername() {
return true;
}
}
diff --git a/src/applications/passphrase/credentialtype/PassphraseSSHPrivateKeyTextCredentialType.php b/src/applications/passphrase/credentialtype/PassphraseSSHPrivateKeyTextCredentialType.php
index d573f19f58..fa686aac4c 100644
--- a/src/applications/passphrase/credentialtype/PassphraseSSHPrivateKeyTextCredentialType.php
+++ b/src/applications/passphrase/credentialtype/PassphraseSSHPrivateKeyTextCredentialType.php
@@ -1,68 +1,68 @@
<?php
final class PassphraseSSHPrivateKeyTextCredentialType
extends PassphraseSSHPrivateKeyCredentialType {
const CREDENTIAL_TYPE = 'ssh-key-text';
public function getCredentialType() {
return self::CREDENTIAL_TYPE;
}
public function getCredentialTypeName() {
return pht('SSH Private Key');
}
public function getCredentialTypeDescription() {
return pht('Store the plaintext of an SSH private key.');
}
public function getSecretLabel() {
return pht('Private Key');
}
public function shouldShowPasswordField() {
return true;
}
public function getPasswordLabel() {
return pht('Password for Key');
}
public function requiresPassword(PhutilOpaqueEnvelope $secret) {
// According to the internet, this is the canonical test for an SSH private
// key with a password.
return preg_match('/ENCRYPTED/', $secret->openEnvelope());
}
public function decryptSecret(
PhutilOpaqueEnvelope $secret,
PhutilOpaqueEnvelope $password) {
$tmp = new TempFile();
Filesystem::writeFile($tmp, $secret->openEnvelope());
if (!Filesystem::binaryExists('ssh-keygen')) {
throw new Exception(
pht(
'Decrypting SSH keys requires the `%s` binary, but it '.
'is not available in %s. Either make it available or strip the '.
- 'password fromt his SSH key manually before uploading it.',
+ 'password from this SSH key manually before uploading it.',
'ssh-keygen',
'$PATH'));
}
list($err, $stdout, $stderr) = exec_manual(
'ssh-keygen -p -P %P -N %s -f %s',
$password,
'',
(string)$tmp);
if ($err) {
return null;
} else {
return new PhutilOpaqueEnvelope(Filesystem::readFile($tmp));
}
}
}
diff --git a/src/applications/passphrase/view/PassphraseCredentialControl.php b/src/applications/passphrase/view/PassphraseCredentialControl.php
index 9c9706fe3f..8ba1ef9bc8 100644
--- a/src/applications/passphrase/view/PassphraseCredentialControl.php
+++ b/src/applications/passphrase/view/PassphraseCredentialControl.php
@@ -1,207 +1,207 @@
<?php
final class PassphraseCredentialControl extends AphrontFormControl {
private $options = array();
private $credentialType;
private $defaultUsername;
private $allowNull;
public function setAllowNull($allow_null) {
$this->allowNull = $allow_null;
return $this;
}
public function setDefaultUsername($default_username) {
$this->defaultUsername = $default_username;
return $this;
}
public function setCredentialType($credential_type) {
$this->credentialType = $credential_type;
return $this;
}
public function getCredentialType() {
return $this->credentialType;
}
public function setOptions(array $options) {
assert_instances_of($options, 'PassphraseCredential');
$this->options = $options;
return $this;
}
protected function getCustomControlClass() {
return 'passphrase-credential-control';
}
protected function renderInput() {
$options_map = array();
foreach ($this->options as $option) {
$options_map[$option->getPHID()] = pht(
'%s %s',
$option->getMonogram(),
$option->getName());
}
// The user editing the form may not have permission to see the current
// credential. Populate it into the menu to allow them to save the form
// without making any changes.
$current_phid = $this->getValue();
if (strlen($current_phid) && empty($options_map[$current_phid])) {
$viewer = $this->getViewer();
$current_name = null;
try {
$user_credential = id(new PassphraseCredentialQuery())
->setViewer($viewer)
->withPHIDs(array($current_phid))
->executeOne();
if ($user_credential) {
$current_name = pht(
'%s %s',
$user_credential->getMonogram(),
$user_credential->getName());
}
} catch (PhabricatorPolicyException $policy_exception) {
- // Pull the credential with the ominipotent viewer so we can look up
+ // Pull the credential with the omnipotent viewer so we can look up
// the ID and provide the monogram.
$omnipotent_credential = id(new PassphraseCredentialQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs(array($current_phid))
->executeOne();
if ($omnipotent_credential) {
$current_name = pht(
'%s (Restricted Credential)',
$omnipotent_credential->getMonogram());
}
}
if ($current_name === null) {
$current_name = pht(
'Invalid Credential ("%s")',
$current_phid);
}
$options_map = array(
$current_phid => $current_name,
) + $options_map;
}
$disabled = $this->getDisabled();
if ($this->allowNull) {
$options_map = array('' => pht('(No Credentials)')) + $options_map;
} else {
if (!$options_map) {
$options_map[''] = pht('(No Existing Credentials)');
$disabled = true;
}
}
Javelin::initBehavior('passphrase-credential-control');
$options = AphrontFormSelectControl::renderSelectTag(
$this->getValue(),
$options_map,
array(
'id' => $this->getControlID(),
'name' => $this->getName(),
'disabled' => $disabled ? 'disabled' : null,
'sigil' => 'passphrase-credential-select',
));
if ($this->credentialType) {
$button = javelin_tag(
'a',
array(
'href' => '#',
'class' => 'button button-grey mll',
'sigil' => 'passphrase-credential-add',
'mustcapture' => true,
'style' => 'height: 20px;', // move aphront-form to tables
),
pht('Add New Credential'));
} else {
$button = null;
}
return javelin_tag(
'div',
array(
'sigil' => 'passphrase-credential-control',
'meta' => array(
'type' => $this->getCredentialType(),
'username' => $this->defaultUsername,
'allowNull' => $this->allowNull,
),
),
array(
$options,
$button,
));
}
/**
* Verify that a given actor has permission to use all of the credentials
* in a list of credential transactions.
*
* In general, the rule here is:
*
* - If you're editing an object and it uses a credential you can't use,
* that's fine as long as you don't change the credential.
* - If you do change the credential, the new credential must be one you
* can use.
*
* @param PhabricatorUser The acting user.
* @param list<PhabricatorApplicationTransaction> List of credential altering
* transactions.
* @return bool True if the transactions are valid.
*/
public static function validateTransactions(
PhabricatorUser $actor,
array $xactions) {
$new_phids = array();
foreach ($xactions as $xaction) {
$new = $xaction->getNewValue();
if (!$new) {
// Removing a credential, so this is OK.
continue;
}
$old = $xaction->getOldValue();
if ($old == $new) {
// This is a no-op transaction, so this is also OK.
continue;
}
// Otherwise, we need to check this credential.
$new_phids[] = $new;
}
if (!$new_phids) {
// No new credentials being set, so this is fine.
return true;
}
$usable_credentials = id(new PassphraseCredentialQuery())
->setViewer($actor)
->withPHIDs($new_phids)
->execute();
$usable_credentials = mpull($usable_credentials, null, 'getPHID');
foreach ($new_phids as $phid) {
if (empty($usable_credentials[$phid])) {
return false;
}
}
return true;
}
}
diff --git a/src/applications/people/markup/PhabricatorMentionRemarkupRule.php b/src/applications/people/markup/PhabricatorMentionRemarkupRule.php
index aa5b7f908e..60d4a8168f 100644
--- a/src/applications/people/markup/PhabricatorMentionRemarkupRule.php
+++ b/src/applications/people/markup/PhabricatorMentionRemarkupRule.php
@@ -1,192 +1,192 @@
<?php
final class PhabricatorMentionRemarkupRule extends PhutilRemarkupRule {
const KEY_RULE_MENTION = 'rule.mention';
const KEY_RULE_MENTION_ORIGINAL = 'rule.mention.original';
const KEY_MENTIONED = 'phabricator.mentioned-user-phids';
// NOTE: The negative lookbehind prevents matches like "mail@lists", while
// allowing constructs like "@tomo/@mroch". Since we now allow periods in
- // usernames, we can't resonably distinguish that "@company.com" isn't a
+ // usernames, we can't reasonably distinguish that "@company.com" isn't a
// username, so we'll incorrectly pick it up, but there's little to be done
// about that. We forbid terminal periods so that we can correctly capture
// "@joe" instead of "@joe." in "Hey, @joe.".
//
// We disallow "@@joe" because it creates a false positive in the common
// construction "l@@k", made popular by eBay.
const REGEX = '/(?<!\w|@)@([a-zA-Z0-9._-]*[a-zA-Z0-9_-])/';
public function apply($text) {
return preg_replace_callback(
self::REGEX,
array($this, 'markupMention'),
$text);
}
protected function markupMention(array $matches) {
$engine = $this->getEngine();
if ($engine->isTextMode()) {
return $engine->storeText($matches[0]);
}
$token = $engine->storeText('');
// Store the original text exactly so we can preserve casing if it doesn't
// resolve into a username.
$original_key = self::KEY_RULE_MENTION_ORIGINAL;
$original = $engine->getTextMetadata($original_key, array());
$original[$token] = $matches[1];
$engine->setTextMetadata($original_key, $original);
$metadata_key = self::KEY_RULE_MENTION;
$metadata = $engine->getTextMetadata($metadata_key, array());
$username = strtolower($matches[1]);
if (empty($metadata[$username])) {
$metadata[$username] = array();
}
$metadata[$username][] = $token;
$engine->setTextMetadata($metadata_key, $metadata);
return $token;
}
public function didMarkupText() {
$engine = $this->getEngine();
$metadata_key = self::KEY_RULE_MENTION;
$metadata = $engine->getTextMetadata($metadata_key, array());
if (empty($metadata)) {
// No mentions, or we already processed them.
return;
}
$original_key = self::KEY_RULE_MENTION_ORIGINAL;
$original = $engine->getTextMetadata($original_key, array());
$usernames = array_keys($metadata);
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->getEngine()->getConfig('viewer'))
->withUsernames($usernames)
->needAvailability(true)
->execute();
$actual_users = array();
$mentioned_key = self::KEY_MENTIONED;
$mentioned = $engine->getTextMetadata($mentioned_key, array());
foreach ($users as $row) {
$actual_users[strtolower($row->getUserName())] = $row;
$mentioned[$row->getPHID()] = $row->getPHID();
}
$engine->setTextMetadata($mentioned_key, $mentioned);
$context_object = $engine->getConfig('contextObject');
foreach ($metadata as $username => $tokens) {
$exists = isset($actual_users[$username]);
$user_has_no_permission = false;
if ($exists) {
$user = $actual_users[$username];
Javelin::initBehavior('phui-hovercards');
// Check if the user has view access to the object she was mentioned in
if ($context_object
&& $context_object instanceof PhabricatorPolicyInterface) {
if (!PhabricatorPolicyFilter::hasCapability(
$user,
$context_object,
PhabricatorPolicyCapability::CAN_VIEW)) {
// User mentioned has no permission to this object
$user_has_no_permission = true;
}
}
$user_href = '/p/'.$user->getUserName().'/';
if ($engine->isHTMLMailMode()) {
$user_href = PhabricatorEnv::getProductionURI($user_href);
if ($user_has_no_permission) {
$colors = '
border-color: #92969D;
color: #92969D;
background-color: #F7F7F7;';
} else {
$colors = '
border-color: #f1f7ff;
color: #19558d;
background-color: #f1f7ff;';
}
$tag = phutil_tag(
'a',
array(
'href' => $user_href,
'style' => $colors.'
border: 1px solid transparent;
border-radius: 3px;
font-weight: bold;
padding: 0 4px;',
),
'@'.$user->getUserName());
} else {
if ($engine->getConfig('uri.full')) {
$user_href = PhabricatorEnv::getURI($user_href);
}
$tag = id(new PHUITagView())
->setType(PHUITagView::TYPE_PERSON)
->setPHID($user->getPHID())
->setName('@'.$user->getUserName())
->setHref($user_href);
if ($user_has_no_permission) {
$tag->addClass('phabricator-remarkup-mention-nopermission');
}
if ($user->getIsDisabled()) {
$tag->setDotColor(PHUITagView::COLOR_GREY);
} else if (!$user->isResponsive()) {
$tag->setDotColor(PHUITagView::COLOR_VIOLET);
} else {
if ($user->getAwayUntil()) {
$away = PhabricatorCalendarEventInvitee::AVAILABILITY_AWAY;
if ($user->getDisplayAvailability() == $away) {
$tag->setDotColor(PHUITagView::COLOR_RED);
} else {
$tag->setDotColor(PHUITagView::COLOR_ORANGE);
}
}
}
}
foreach ($tokens as $token) {
$engine->overwriteStoredText($token, $tag);
}
} else {
// NOTE: The structure here is different from the 'exists' branch,
// because we want to preserve the original text capitalization and it
// may differ for each token.
foreach ($tokens as $token) {
$tag = phutil_tag(
'span',
array(
'class' => 'phabricator-remarkup-mention-unknown',
),
'@'.idx($original, $token, $username));
$engine->overwriteStoredText($token, $tag);
}
}
}
// Don't re-process these mentions.
$engine->setTextMetadata($metadata_key, array());
}
}
diff --git a/src/applications/people/query/PhabricatorPeopleQuery.php b/src/applications/people/query/PhabricatorPeopleQuery.php
index e756a9a4fd..35ff480833 100644
--- a/src/applications/people/query/PhabricatorPeopleQuery.php
+++ b/src/applications/people/query/PhabricatorPeopleQuery.php
@@ -1,642 +1,642 @@
<?php
final class PhabricatorPeopleQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $usernames;
private $realnames;
private $emails;
private $phids;
private $ids;
private $dateCreatedAfter;
private $dateCreatedBefore;
private $isAdmin;
private $isSystemAgent;
private $isMailingList;
private $isDisabled;
private $isApproved;
private $nameLike;
private $nameTokens;
private $namePrefixes;
private $isEnrolledInMultiFactor;
private $needPrimaryEmail;
private $needProfile;
private $needProfileImage;
private $needAvailability;
private $needBadgeAwards;
private $cacheKeys = array();
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withEmails(array $emails) {
$this->emails = $emails;
return $this;
}
public function withRealnames(array $realnames) {
$this->realnames = $realnames;
return $this;
}
public function withUsernames(array $usernames) {
$this->usernames = $usernames;
return $this;
}
public function withDateCreatedBefore($date_created_before) {
$this->dateCreatedBefore = $date_created_before;
return $this;
}
public function withDateCreatedAfter($date_created_after) {
$this->dateCreatedAfter = $date_created_after;
return $this;
}
public function withIsAdmin($admin) {
$this->isAdmin = $admin;
return $this;
}
public function withIsSystemAgent($system_agent) {
$this->isSystemAgent = $system_agent;
return $this;
}
public function withIsMailingList($mailing_list) {
$this->isMailingList = $mailing_list;
return $this;
}
public function withIsDisabled($disabled) {
$this->isDisabled = $disabled;
return $this;
}
public function withIsApproved($approved) {
$this->isApproved = $approved;
return $this;
}
public function withNameLike($like) {
$this->nameLike = $like;
return $this;
}
public function withNameTokens(array $tokens) {
$this->nameTokens = array_values($tokens);
return $this;
}
public function withNamePrefixes(array $prefixes) {
$this->namePrefixes = $prefixes;
return $this;
}
public function withIsEnrolledInMultiFactor($enrolled) {
$this->isEnrolledInMultiFactor = $enrolled;
return $this;
}
public function needPrimaryEmail($need) {
$this->needPrimaryEmail = $need;
return $this;
}
public function needProfile($need) {
$this->needProfile = $need;
return $this;
}
public function needProfileImage($need) {
$cache_key = PhabricatorUserProfileImageCacheType::KEY_URI;
if ($need) {
$this->cacheKeys[$cache_key] = true;
} else {
unset($this->cacheKeys[$cache_key]);
}
return $this;
}
public function needAvailability($need) {
$this->needAvailability = $need;
return $this;
}
public function needUserSettings($need) {
$cache_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES;
if ($need) {
$this->cacheKeys[$cache_key] = true;
} else {
unset($this->cacheKeys[$cache_key]);
}
return $this;
}
public function needBadgeAwards($need) {
$cache_key = PhabricatorUserBadgesCacheType::KEY_BADGES;
if ($need) {
$this->cacheKeys[$cache_key] = true;
} else {
unset($this->cacheKeys[$cache_key]);
}
return $this;
}
public function newResultObject() {
return new PhabricatorUser();
}
protected function loadPage() {
$table = new PhabricatorUser();
$data = $this->loadStandardPageRows($table);
if ($this->needPrimaryEmail) {
$table->putInSet(new LiskDAOSet());
}
return $table->loadAllFromArray($data);
}
protected function didFilterPage(array $users) {
if ($this->needProfile) {
$user_list = mpull($users, null, 'getPHID');
$profiles = new PhabricatorUserProfile();
$profiles = $profiles->loadAllWhere(
'userPHID IN (%Ls)',
array_keys($user_list));
$profiles = mpull($profiles, null, 'getUserPHID');
foreach ($user_list as $user_phid => $user) {
$profile = idx($profiles, $user_phid);
if (!$profile) {
$profile = PhabricatorUserProfile::initializeNewProfile($user);
}
$user->attachUserProfile($profile);
}
}
if ($this->needAvailability) {
$rebuild = array();
foreach ($users as $user) {
$cache = $user->getAvailabilityCache();
if ($cache !== null) {
$user->attachAvailability($cache);
} else {
$rebuild[] = $user;
}
}
if ($rebuild) {
$this->rebuildAvailabilityCache($rebuild);
}
}
$this->fillUserCaches($users);
return $users;
}
protected function shouldGroupQueryResultRows() {
if ($this->nameTokens) {
return true;
}
return parent::shouldGroupQueryResultRows();
}
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
$joins = parent::buildJoinClauseParts($conn);
if ($this->emails) {
$email_table = new PhabricatorUserEmail();
$joins[] = qsprintf(
$conn,
'JOIN %T email ON email.userPHID = user.PHID',
$email_table->getTableName());
}
if ($this->nameTokens) {
foreach ($this->nameTokens as $key => $token) {
$token_table = 'token_'.$key;
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.userID = user.id AND %T.token LIKE %>',
PhabricatorUser::NAMETOKEN_TABLE,
$token_table,
$token_table,
$token_table,
$token);
}
}
return $joins;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->usernames !== null) {
$where[] = qsprintf(
$conn,
'user.userName IN (%Ls)',
$this->usernames);
}
if ($this->namePrefixes) {
$parts = array();
foreach ($this->namePrefixes as $name_prefix) {
$parts[] = qsprintf(
$conn,
'user.username LIKE %>',
$name_prefix);
}
$where[] = '('.implode(' OR ', $parts).')';
}
if ($this->emails !== null) {
$where[] = qsprintf(
$conn,
'email.address IN (%Ls)',
$this->emails);
}
if ($this->realnames !== null) {
$where[] = qsprintf(
$conn,
'user.realName IN (%Ls)',
$this->realnames);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'user.phid IN (%Ls)',
$this->phids);
}
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'user.id IN (%Ld)',
$this->ids);
}
if ($this->dateCreatedAfter) {
$where[] = qsprintf(
$conn,
'user.dateCreated >= %d',
$this->dateCreatedAfter);
}
if ($this->dateCreatedBefore) {
$where[] = qsprintf(
$conn,
'user.dateCreated <= %d',
$this->dateCreatedBefore);
}
if ($this->isAdmin !== null) {
$where[] = qsprintf(
$conn,
'user.isAdmin = %d',
(int)$this->isAdmin);
}
if ($this->isDisabled !== null) {
$where[] = qsprintf(
$conn,
'user.isDisabled = %d',
(int)$this->isDisabled);
}
if ($this->isApproved !== null) {
$where[] = qsprintf(
$conn,
'user.isApproved = %d',
(int)$this->isApproved);
}
if ($this->isSystemAgent !== null) {
$where[] = qsprintf(
$conn,
'user.isSystemAgent = %d',
(int)$this->isSystemAgent);
}
if ($this->isMailingList !== null) {
$where[] = qsprintf(
$conn,
'user.isMailingList = %d',
(int)$this->isMailingList);
}
if (strlen($this->nameLike)) {
$where[] = qsprintf(
$conn,
'user.username LIKE %~ OR user.realname LIKE %~',
$this->nameLike,
$this->nameLike);
}
if ($this->isEnrolledInMultiFactor !== null) {
$where[] = qsprintf(
$conn,
'user.isEnrolledInMultiFactor = %d',
(int)$this->isEnrolledInMultiFactor);
}
return $where;
}
protected function getPrimaryTableAlias() {
return 'user';
}
public function getQueryApplicationClass() {
return 'PhabricatorPeopleApplication';
}
public function getOrderableColumns() {
return parent::getOrderableColumns() + array(
'username' => array(
'table' => 'user',
'column' => 'username',
'type' => 'string',
'reverse' => true,
'unique' => true,
),
);
}
protected function getPagingValueMap($cursor, array $keys) {
$user = $this->loadCursorObject($cursor);
return array(
'id' => $user->getID(),
'username' => $user->getUsername(),
);
}
private function rebuildAvailabilityCache(array $rebuild) {
$rebuild = mpull($rebuild, null, 'getPHID');
// Limit the window we look at because far-future events are largely
// irrelevant and this makes the cache cheaper to build and allows it to
// self-heal over time.
$min_range = PhabricatorTime::getNow();
$max_range = $min_range + phutil_units('72 hours in seconds');
// NOTE: We don't need to generate ghosts here, because we only care if
// the user is attending, and you can't attend a ghost event: RSVP'ing
// to it creates a real event.
$events = id(new PhabricatorCalendarEventQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withInvitedPHIDs(array_keys($rebuild))
->withIsCancelled(false)
->withDateRange($min_range, $max_range)
->execute();
// Group all the events by invited user. Only examine events that users
// are actually attending.
$map = array();
$invitee_map = array();
foreach ($events as $event) {
foreach ($event->getInvitees() as $invitee) {
if (!$invitee->isAttending()) {
continue;
}
// If the user is set to "Available" for this event, don't consider it
- // when computin their away status.
+ // when computing their away status.
if (!$invitee->getDisplayAvailability($event)) {
continue;
}
$invitee_phid = $invitee->getInviteePHID();
if (!isset($rebuild[$invitee_phid])) {
continue;
}
$map[$invitee_phid][] = $event;
$event_phid = $event->getPHID();
$invitee_map[$invitee_phid][$event_phid] = $invitee;
}
}
// We need to load these users' timezone settings to figure out their
// availability if they're attending all-day events.
$this->needUserSettings(true);
$this->fillUserCaches($rebuild);
foreach ($rebuild as $phid => $user) {
$events = idx($map, $phid, array());
// We loaded events with the omnipotent user, but want to shift them
// into the user's timezone before building the cache because they will
// be unavailable during their own local day.
foreach ($events as $event) {
$event->applyViewerTimezone($user);
}
$cursor = $min_range;
$next_event = null;
if ($events) {
// Find the next time when the user has no meetings. If we move forward
// because of an event, we check again for events after that one ends.
while (true) {
foreach ($events as $event) {
$from = $event->getStartDateTimeEpochForCache();
$to = $event->getEndDateTimeEpochForCache();
if (($from <= $cursor) && ($to > $cursor)) {
$cursor = $to;
if (!$next_event) {
$next_event = $event;
}
continue 2;
}
}
break;
}
}
if ($cursor > $min_range) {
$invitee = $invitee_map[$phid][$next_event->getPHID()];
$availability_type = $invitee->getDisplayAvailability($next_event);
$availability = array(
'until' => $cursor,
'eventPHID' => $next_event->getPHID(),
'availability' => $availability_type,
);
// We only cache this availability until the end of the current event,
// since the event PHID (and possibly the availability type) are only
// valid for that long.
// NOTE: This doesn't handle overlapping events with the greatest
- // possible care. In theory, if you're attenting multiple events
+ // possible care. In theory, if you're attending multiple events
// simultaneously we should accommodate that. However, it's complex
// to compute, rare, and probably not confusing most of the time.
$availability_ttl = $next_event->getEndDateTimeEpochForCache();
} else {
$availability = array(
'until' => null,
'eventPHID' => null,
'availability' => null,
);
// Cache that the user is available until the next event they are
// invited to starts.
$availability_ttl = $max_range;
foreach ($events as $event) {
$from = $event->getStartDateTimeEpochForCache();
if ($from > $cursor) {
$availability_ttl = min($from, $availability_ttl);
}
}
}
// Never TTL the cache to longer than the maximum range we examined.
$availability_ttl = min($availability_ttl, $max_range);
$user->writeAvailabilityCache($availability, $availability_ttl);
$user->attachAvailability($availability);
}
}
private function fillUserCaches(array $users) {
if (!$this->cacheKeys) {
return;
}
$user_map = mpull($users, null, 'getPHID');
$keys = array_keys($this->cacheKeys);
$hashes = array();
foreach ($keys as $key) {
$hashes[] = PhabricatorHash::digestForIndex($key);
}
$types = PhabricatorUserCacheType::getAllCacheTypes();
// First, pull any available caches. If we wanted to be particularly clever
// we could do this with JOINs in the main query.
$cache_table = new PhabricatorUserCache();
$cache_conn = $cache_table->establishConnection('r');
$cache_data = queryfx_all(
$cache_conn,
'SELECT cacheKey, userPHID, cacheData, cacheType FROM %T
WHERE cacheIndex IN (%Ls) AND userPHID IN (%Ls)',
$cache_table->getTableName(),
$hashes,
array_keys($user_map));
$skip_validation = array();
// After we read caches from the database, discard any which have data that
// invalid or out of date. This allows cache types to implement TTLs or
// versions instead of or in addition to explicit cache clears.
foreach ($cache_data as $row_key => $row) {
$cache_type = $row['cacheType'];
if (isset($skip_validation[$cache_type])) {
continue;
}
if (empty($types[$cache_type])) {
unset($cache_data[$row_key]);
continue;
}
$type = $types[$cache_type];
if (!$type->shouldValidateRawCacheData()) {
$skip_validation[$cache_type] = true;
continue;
}
$user = $user_map[$row['userPHID']];
$raw_data = $row['cacheData'];
if (!$type->isRawCacheDataValid($user, $row['cacheKey'], $raw_data)) {
unset($cache_data[$row_key]);
continue;
}
}
$need = array();
$cache_data = igroup($cache_data, 'userPHID');
foreach ($user_map as $user_phid => $user) {
$raw_rows = idx($cache_data, $user_phid, array());
$raw_data = ipull($raw_rows, 'cacheData', 'cacheKey');
foreach ($keys as $key) {
if (isset($raw_data[$key]) || array_key_exists($key, $raw_data)) {
continue;
}
$need[$key][$user_phid] = $user;
}
$user->attachRawCacheData($raw_data);
}
// If we missed any cache values, bulk-construct them now. This is
// usually much cheaper than generating them on-demand for each user
// record.
if (!$need) {
return;
}
$writes = array();
foreach ($need as $cache_key => $need_users) {
$type = PhabricatorUserCacheType::getCacheTypeForKey($cache_key);
if (!$type) {
continue;
}
$data = $type->newValueForUsers($cache_key, $need_users);
foreach ($data as $user_phid => $raw_value) {
$data[$user_phid] = $raw_value;
$writes[] = array(
'userPHID' => $user_phid,
'key' => $cache_key,
'type' => $type,
'value' => $raw_value,
);
}
foreach ($need_users as $user_phid => $user) {
if (isset($data[$user_phid]) || array_key_exists($user_phid, $data)) {
$user->attachRawCacheData(
array(
$cache_key => $data[$user_phid],
));
}
}
}
PhabricatorUserCache::writeCaches($writes);
}
}
diff --git a/src/applications/people/query/PhabricatorPeopleSearchEngine.php b/src/applications/people/query/PhabricatorPeopleSearchEngine.php
index fa363c4428..0a4367d367 100644
--- a/src/applications/people/query/PhabricatorPeopleSearchEngine.php
+++ b/src/applications/people/query/PhabricatorPeopleSearchEngine.php
@@ -1,323 +1,323 @@
<?php
final class PhabricatorPeopleSearchEngine
extends PhabricatorApplicationSearchEngine {
public function getResultTypeDescription() {
return pht('Users');
}
public function getApplicationClassName() {
return 'PhabricatorPeopleApplication';
}
public function newQuery() {
return id(new PhabricatorPeopleQuery())
->needPrimaryEmail(true)
->needProfileImage(true);
}
protected function buildCustomSearchFields() {
$fields = array(
id(new PhabricatorSearchStringListField())
->setLabel(pht('Usernames'))
->setKey('usernames')
->setAliases(array('username'))
->setDescription(pht('Find users by exact username.')),
id(new PhabricatorSearchTextField())
->setLabel(pht('Name Contains'))
->setKey('nameLike')
->setDescription(
pht('Find users whose usernames contain a substring.')),
id(new PhabricatorSearchThreeStateField())
->setLabel(pht('Administrators'))
->setKey('isAdmin')
->setOptions(
pht('(Show All)'),
pht('Show Only Administrators'),
pht('Hide Administrators'))
->setDescription(
pht(
'Pass true to find only administrators, or false to omit '.
'administrators.')),
id(new PhabricatorSearchThreeStateField())
->setLabel(pht('Disabled'))
->setKey('isDisabled')
->setOptions(
pht('(Show All)'),
pht('Show Only Disabled Users'),
pht('Hide Disabled Users'))
->setDescription(
pht(
'Pass true to find only disabled users, or false to omit '.
'disabled users.')),
id(new PhabricatorSearchThreeStateField())
->setLabel(pht('Bots'))
->setKey('isBot')
->setAliases(array('isSystemAgent'))
->setOptions(
pht('(Show All)'),
pht('Show Only Bots'),
pht('Hide Bots'))
->setDescription(
pht(
'Pass true to find only bots, or false to omit bots.')),
id(new PhabricatorSearchThreeStateField())
->setLabel(pht('Mailing Lists'))
->setKey('isMailingList')
->setOptions(
pht('(Show All)'),
pht('Show Only Mailing Lists'),
pht('Hide Mailing Lists'))
->setDescription(
pht(
'Pass true to find only mailing lists, or false to omit '.
'mailing lists.')),
id(new PhabricatorSearchThreeStateField())
->setLabel(pht('Needs Approval'))
->setKey('needsApproval')
->setOptions(
pht('(Show All)'),
pht('Show Only Unapproved Users'),
- pht('Hide Unappproved Users'))
+ pht('Hide Unapproved Users'))
->setDescription(
pht(
'Pass true to find only users awaiting administrative approval, '.
'or false to omit these users.')),
);
$viewer = $this->requireViewer();
if ($viewer->getIsAdmin()) {
$fields[] = id(new PhabricatorSearchThreeStateField())
->setLabel(pht('Has MFA'))
->setKey('mfa')
->setOptions(
pht('(Show All)'),
pht('Show Only Users With MFA'),
pht('Hide Users With MFA'))
->setDescription(
pht(
'Pass true to find only users who are enrolled in MFA, or false '.
'to omit these users.'));
}
$fields[] = id(new PhabricatorSearchDateField())
->setKey('createdStart')
->setLabel(pht('Joined After'))
->setDescription(
pht('Find user accounts created after a given time.'));
$fields[] = id(new PhabricatorSearchDateField())
->setKey('createdEnd')
->setLabel(pht('Joined Before'))
->setDescription(
pht('Find user accounts created before a given time.'));
return $fields;
}
protected function getDefaultFieldOrder() {
return array(
'...',
'createdStart',
'createdEnd',
);
}
protected function buildQueryFromParameters(array $map) {
$query = $this->newQuery();
$viewer = $this->requireViewer();
// If the viewer can't browse the user directory, restrict the query to
// just the user's own profile. This is a little bit silly, but serves to
// restrict users from creating a dashboard panel which essentially just
// contains a user directory anyway.
$can_browse = PhabricatorPolicyFilter::hasCapability(
$viewer,
$this->getApplication(),
PeopleBrowseUserDirectoryCapability::CAPABILITY);
if (!$can_browse) {
$query->withPHIDs(array($viewer->getPHID()));
}
if ($map['usernames']) {
$query->withUsernames($map['usernames']);
}
if ($map['nameLike']) {
$query->withNameLike($map['nameLike']);
}
if ($map['isAdmin'] !== null) {
$query->withIsAdmin($map['isAdmin']);
}
if ($map['isDisabled'] !== null) {
$query->withIsDisabled($map['isDisabled']);
}
if ($map['isMailingList'] !== null) {
$query->withIsMailingList($map['isMailingList']);
}
if ($map['isBot'] !== null) {
$query->withIsSystemAgent($map['isBot']);
}
if ($map['needsApproval'] !== null) {
$query->withIsApproved(!$map['needsApproval']);
}
if (idx($map, 'mfa') !== null) {
$viewer = $this->requireViewer();
if (!$viewer->getIsAdmin()) {
throw new PhabricatorSearchConstraintException(
pht(
'The "Has MFA" query constraint may only be used by '.
'administrators, to prevent attackers from using it to target '.
'weak accounts.'));
}
$query->withIsEnrolledInMultiFactor($map['mfa']);
}
if ($map['createdStart']) {
$query->withDateCreatedAfter($map['createdStart']);
}
if ($map['createdEnd']) {
$query->withDateCreatedBefore($map['createdEnd']);
}
return $query;
}
protected function getURI($path) {
return '/people/'.$path;
}
protected function getBuiltinQueryNames() {
$names = array(
'active' => pht('Active'),
'all' => pht('All'),
);
$viewer = $this->requireViewer();
if ($viewer->getIsAdmin()) {
$names['approval'] = pht('Approval Queue');
}
return $names;
}
public function buildSavedQueryFromBuiltin($query_key) {
$query = $this->newSavedQuery();
$query->setQueryKey($query_key);
switch ($query_key) {
case 'all':
return $query;
case 'active':
return $query
->setParameter('isDisabled', false);
case 'approval':
return $query
->setParameter('needsApproval', true)
->setParameter('isDisabled', false);
}
return parent::buildSavedQueryFromBuiltin($query_key);
}
protected function renderResultList(
array $users,
PhabricatorSavedQuery $query,
array $handles) {
assert_instances_of($users, 'PhabricatorUser');
$request = $this->getRequest();
$viewer = $this->requireViewer();
$list = new PHUIObjectItemListView();
$is_approval = ($query->getQueryKey() == 'approval');
foreach ($users as $user) {
$primary_email = $user->loadPrimaryEmail();
if ($primary_email && $primary_email->getIsVerified()) {
$email = pht('Verified');
} else {
$email = pht('Unverified');
}
$item = new PHUIObjectItemView();
$item->setHeader($user->getFullName())
->setHref('/p/'.$user->getUsername().'/')
->addAttribute(phabricator_datetime($user->getDateCreated(), $viewer))
->addAttribute($email)
->setImageURI($user->getProfileImageURI());
if ($is_approval && $primary_email) {
$item->addAttribute($primary_email->getAddress());
}
if ($user->getIsDisabled()) {
$item->addIcon('fa-ban', pht('Disabled'));
$item->setDisabled(true);
}
if (!$is_approval) {
if (!$user->getIsApproved()) {
$item->addIcon('fa-clock-o', pht('Needs Approval'));
}
}
if ($user->getIsAdmin()) {
$item->addIcon('fa-star', pht('Admin'));
}
if ($user->getIsSystemAgent()) {
$item->addIcon('fa-desktop', pht('Bot'));
}
if ($user->getIsMailingList()) {
$item->addIcon('fa-envelope-o', pht('Mailing List'));
}
if ($viewer->getIsAdmin()) {
if ($user->getIsEnrolledInMultiFactor()) {
$item->addIcon('fa-lock', pht('Has MFA'));
}
}
if ($viewer->getIsAdmin()) {
$user_id = $user->getID();
if ($is_approval) {
$item->addAction(
id(new PHUIListItemView())
->setIcon('fa-ban')
->setName(pht('Disable'))
->setWorkflow(true)
->setHref($this->getApplicationURI('disapprove/'.$user_id.'/')));
$item->addAction(
id(new PHUIListItemView())
->setIcon('fa-thumbs-o-up')
->setName(pht('Approve'))
->setWorkflow(true)
->setHref($this->getApplicationURI('approve/'.$user_id.'/')));
}
}
$list->addItem($item);
}
$result = new PhabricatorApplicationSearchResultView();
$result->setObjectList($list);
$result->setNoDataString(pht('No accounts found.'));
return $result;
}
}
diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php
index 166feaa237..1745154826 100644
--- a/src/applications/people/storage/PhabricatorUser.php
+++ b/src/applications/people/storage/PhabricatorUser.php
@@ -1,1619 +1,1619 @@
<?php
/**
* @task availability Availability
* @task image-cache Profile Image Cache
* @task factors Multi-Factor Authentication
* @task handles Managing Handles
* @task settings Settings
* @task cache User Cache
*/
final class PhabricatorUser
extends PhabricatorUserDAO
implements
PhutilPerson,
PhabricatorPolicyInterface,
PhabricatorCustomFieldInterface,
PhabricatorDestructibleInterface,
PhabricatorSSHPublicKeyInterface,
PhabricatorFlaggableInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorFulltextInterface,
PhabricatorFerretInterface,
PhabricatorConduitResultInterface {
const SESSION_TABLE = 'phabricator_session';
const NAMETOKEN_TABLE = 'user_nametoken';
const MAXIMUM_USERNAME_LENGTH = 64;
protected $userName;
protected $realName;
protected $passwordSalt;
protected $passwordHash;
protected $profileImagePHID;
protected $defaultProfileImagePHID;
protected $defaultProfileImageVersion;
protected $availabilityCache;
protected $availabilityCacheTTL;
protected $conduitCertificate;
protected $isSystemAgent = 0;
protected $isMailingList = 0;
protected $isAdmin = 0;
protected $isDisabled = 0;
protected $isEmailVerified = 0;
protected $isApproved = 0;
protected $isEnrolledInMultiFactor = 0;
protected $accountSecret;
private $profile = null;
private $availability = self::ATTACHABLE;
private $preferences = null;
private $omnipotent = false;
private $customFields = self::ATTACHABLE;
private $badgePHIDs = self::ATTACHABLE;
private $alternateCSRFString = self::ATTACHABLE;
private $session = self::ATTACHABLE;
private $rawCacheData = array();
private $usableCacheData = array();
private $authorities = array();
private $handlePool;
private $csrfSalt;
private $settingCacheKeys = array();
private $settingCache = array();
private $allowInlineCacheGeneration;
private $conduitClusterToken = self::ATTACHABLE;
protected function readField($field) {
switch ($field) {
// Make sure these return booleans.
case 'isAdmin':
return (bool)$this->isAdmin;
case 'isDisabled':
return (bool)$this->isDisabled;
case 'isSystemAgent':
return (bool)$this->isSystemAgent;
case 'isMailingList':
return (bool)$this->isMailingList;
case 'isEmailVerified':
return (bool)$this->isEmailVerified;
case 'isApproved':
return (bool)$this->isApproved;
default:
return parent::readField($field);
}
}
/**
* Is this a live account which has passed required approvals? Returns true
* if this is an enabled, verified (if required), approved (if required)
* account, and false otherwise.
*
* @return bool True if this is a standard, usable account.
*/
public function isUserActivated() {
if (!$this->isLoggedIn()) {
return false;
}
if ($this->isOmnipotent()) {
return true;
}
if ($this->getIsDisabled()) {
return false;
}
if (!$this->getIsApproved()) {
return false;
}
if (PhabricatorUserEmail::isEmailVerificationRequired()) {
if (!$this->getIsEmailVerified()) {
return false;
}
}
return true;
}
/**
* Is this a user who we can reasonably expect to respond to requests?
*
* This is used to provide a grey "disabled/unresponsive" dot cue when
* rendering handles and tags, so it isn't a surprise if you get ignored
* when you ask things of users who will not receive notifications or could
* not respond to them (because they are disabled, unapproved, do not have
* verified email addresses, etc).
*
* @return bool True if this user can receive and respond to requests from
* other humans.
*/
public function isResponsive() {
if (!$this->isUserActivated()) {
return false;
}
if (!$this->getIsEmailVerified()) {
return false;
}
return true;
}
public function canEstablishWebSessions() {
if ($this->getIsMailingList()) {
return false;
}
if ($this->getIsSystemAgent()) {
return false;
}
return true;
}
public function canEstablishAPISessions() {
if ($this->getIsDisabled()) {
return false;
}
// Intracluster requests are permitted even if the user is logged out:
// in particular, public users are allowed to issue intracluster requests
// when browsing Diffusion.
if (PhabricatorEnv::isClusterRemoteAddress()) {
if (!$this->isLoggedIn()) {
return true;
}
}
if (!$this->isUserActivated()) {
return false;
}
if ($this->getIsMailingList()) {
return false;
}
return true;
}
public function canEstablishSSHSessions() {
if (!$this->isUserActivated()) {
return false;
}
if ($this->getIsMailingList()) {
return false;
}
return true;
}
/**
* Returns `true` if this is a standard user who is logged in. Returns `false`
* for logged out, anonymous, or external users.
*
* @return bool `true` if the user is a standard user who is logged in with
* a normal session.
*/
public function getIsStandardUser() {
$type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
return $this->getPHID() && (phid_get_type($this->getPHID()) == $type_user);
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'userName' => 'sort64',
'realName' => 'text128',
'passwordSalt' => 'text32?',
'passwordHash' => 'text128?',
'profileImagePHID' => 'phid?',
'conduitCertificate' => 'text255',
'isSystemAgent' => 'bool',
'isMailingList' => 'bool',
'isDisabled' => 'bool',
'isAdmin' => 'bool',
'isEmailVerified' => 'uint32',
'isApproved' => 'uint32',
'accountSecret' => 'bytes64',
'isEnrolledInMultiFactor' => 'bool',
'availabilityCache' => 'text255?',
'availabilityCacheTTL' => 'uint32?',
'defaultProfileImagePHID' => 'phid?',
'defaultProfileImageVersion' => 'text64?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'userName' => array(
'columns' => array('userName'),
'unique' => true,
),
'realName' => array(
'columns' => array('realName'),
),
'key_approved' => array(
'columns' => array('isApproved'),
),
),
self::CONFIG_NO_MUTATE => array(
'availabilityCache' => true,
'availabilityCacheTTL' => true,
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorPeopleUserPHIDType::TYPECONST);
}
public function setPassword(PhutilOpaqueEnvelope $envelope) {
if (!$this->getPHID()) {
throw new Exception(
pht(
'You can not set a password for an unsaved user because their PHID '.
'is a salt component in the password hash.'));
}
if (!strlen($envelope->openEnvelope())) {
$this->setPasswordHash('');
} else {
$this->setPasswordSalt(md5(Filesystem::readRandomBytes(32)));
$hash = $this->hashPassword($envelope);
$this->setPasswordHash($hash->openEnvelope());
}
return $this;
}
public function getMonogram() {
return '@'.$this->getUsername();
}
public function isLoggedIn() {
return !($this->getPHID() === null);
}
public function save() {
if (!$this->getConduitCertificate()) {
$this->setConduitCertificate($this->generateConduitCertificate());
}
if (!strlen($this->getAccountSecret())) {
$this->setAccountSecret(Filesystem::readRandomCharacters(64));
}
$result = parent::save();
if ($this->profile) {
$this->profile->save();
}
$this->updateNameTokens();
PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
return $result;
}
public function attachSession(PhabricatorAuthSession $session) {
$this->session = $session;
return $this;
}
public function getSession() {
return $this->assertAttached($this->session);
}
public function hasSession() {
return ($this->session !== self::ATTACHABLE);
}
private function generateConduitCertificate() {
return Filesystem::readRandomCharacters(255);
}
public function comparePassword(PhutilOpaqueEnvelope $envelope) {
if (!strlen($envelope->openEnvelope())) {
return false;
}
if (!strlen($this->getPasswordHash())) {
return false;
}
return PhabricatorPasswordHasher::comparePassword(
$this->getPasswordHashInput($envelope),
new PhutilOpaqueEnvelope($this->getPasswordHash()));
}
private function getPasswordHashInput(PhutilOpaqueEnvelope $password) {
$input =
$this->getUsername().
$password->openEnvelope().
$this->getPHID().
$this->getPasswordSalt();
return new PhutilOpaqueEnvelope($input);
}
private function hashPassword(PhutilOpaqueEnvelope $password) {
$hasher = PhabricatorPasswordHasher::getBestHasher();
$input_envelope = $this->getPasswordHashInput($password);
return $hasher->getPasswordHashForStorage($input_envelope);
}
const CSRF_CYCLE_FREQUENCY = 3600;
const CSRF_SALT_LENGTH = 8;
const CSRF_TOKEN_LENGTH = 16;
const CSRF_BREACH_PREFIX = 'B@';
const EMAIL_CYCLE_FREQUENCY = 86400;
const EMAIL_TOKEN_LENGTH = 24;
private function getRawCSRFToken($offset = 0) {
return $this->generateToken(
time() + (self::CSRF_CYCLE_FREQUENCY * $offset),
self::CSRF_CYCLE_FREQUENCY,
PhabricatorEnv::getEnvConfig('phabricator.csrf-key'),
self::CSRF_TOKEN_LENGTH);
}
public function getCSRFToken() {
if ($this->isOmnipotent()) {
// We may end up here when called from the daemons. The omnipotent user
// has no meaningful CSRF token, so just return `null`.
return null;
}
if ($this->csrfSalt === null) {
$this->csrfSalt = Filesystem::readRandomCharacters(
self::CSRF_SALT_LENGTH);
}
$salt = $this->csrfSalt;
// Generate a token hash to mitigate BREACH attacks against SSL. See
// discussion in T3684.
$token = $this->getRawCSRFToken();
$hash = PhabricatorHash::weakDigest($token, $salt);
return self::CSRF_BREACH_PREFIX.$salt.substr(
$hash, 0, self::CSRF_TOKEN_LENGTH);
}
public function validateCSRFToken($token) {
// We expect a BREACH-mitigating token. See T3684.
$breach_prefix = self::CSRF_BREACH_PREFIX;
$breach_prelen = strlen($breach_prefix);
if (strncmp($token, $breach_prefix, $breach_prelen) !== 0) {
return false;
}
$salt = substr($token, $breach_prelen, self::CSRF_SALT_LENGTH);
$token = substr($token, $breach_prelen + self::CSRF_SALT_LENGTH);
// When the user posts a form, we check that it contains a valid CSRF token.
- // Tokens cycle each hour (every CSRF_CYLCE_FREQUENCY seconds) and we accept
+ // Tokens cycle each hour (every CSRF_CYCLE_FREQUENCY seconds) and we accept
// either the current token, the next token (users can submit a "future"
// token if you have two web frontends that have some clock skew) or any of
// the last 6 tokens. This means that pages are valid for up to 7 hours.
// There is also some Javascript which periodically refreshes the CSRF
// tokens on each page, so theoretically pages should be valid indefinitely.
// However, this code may fail to run (if the user loses their internet
// connection, or there's a JS problem, or they don't have JS enabled).
// Choosing the size of the window in which we accept old CSRF tokens is
// an issue of balancing concerns between security and usability. We could
// choose a very narrow (e.g., 1-hour) window to reduce vulnerability to
// attacks using captured CSRF tokens, but it's also more likely that real
// users will be affected by this, e.g. if they close their laptop for an
// hour, open it back up, and try to submit a form before the CSRF refresh
// can kick in. Since the user experience of submitting a form with expired
// CSRF is often quite bad (you basically lose data, or it's a big pain to
// recover at least) and I believe we gain little additional protection
// by keeping the window very short (the overwhelming value here is in
// preventing blind attacks, and most attacks which can capture CSRF tokens
// can also just capture authentication information [sniffing networks]
// or act as the user [xss]) the 7 hour default seems like a reasonable
// balance. Other major platforms have much longer CSRF token lifetimes,
// like Rails (session duration) and Django (forever), which suggests this
// is a reasonable analysis.
$csrf_window = 6;
for ($ii = -$csrf_window; $ii <= 1; $ii++) {
$valid = $this->getRawCSRFToken($ii);
$digest = PhabricatorHash::weakDigest($valid, $salt);
$digest = substr($digest, 0, self::CSRF_TOKEN_LENGTH);
if (phutil_hashes_are_identical($digest, $token)) {
return true;
}
}
return false;
}
private function generateToken($epoch, $frequency, $key, $len) {
if ($this->getPHID()) {
$vec = $this->getPHID().$this->getAccountSecret();
} else {
$vec = $this->getAlternateCSRFString();
}
if ($this->hasSession()) {
$vec = $vec.$this->getSession()->getSessionKey();
}
$time_block = floor($epoch / $frequency);
$vec = $vec.$key.$time_block;
return substr(PhabricatorHash::weakDigest($vec), 0, $len);
}
public function getUserProfile() {
return $this->assertAttached($this->profile);
}
public function attachUserProfile(PhabricatorUserProfile $profile) {
$this->profile = $profile;
return $this;
}
public function loadUserProfile() {
if ($this->profile) {
return $this->profile;
}
$profile_dao = new PhabricatorUserProfile();
$this->profile = $profile_dao->loadOneWhere('userPHID = %s',
$this->getPHID());
if (!$this->profile) {
$this->profile = PhabricatorUserProfile::initializeNewProfile($this);
}
return $this->profile;
}
public function loadPrimaryEmailAddress() {
$email = $this->loadPrimaryEmail();
if (!$email) {
throw new Exception(pht('User has no primary email address!'));
}
return $email->getAddress();
}
public function loadPrimaryEmail() {
return $this->loadOneRelative(
new PhabricatorUserEmail(),
'userPHID',
'getPHID',
'(isPrimary = 1)');
}
/* -( Settings )----------------------------------------------------------- */
public function getUserSetting($key) {
// NOTE: We store available keys and cached values separately to make it
// faster to check for `null` in the cache, which is common.
if (isset($this->settingCacheKeys[$key])) {
return $this->settingCache[$key];
}
$settings_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES;
if ($this->getPHID()) {
$settings = $this->requireCacheData($settings_key);
} else {
$settings = $this->loadGlobalSettings();
}
if (array_key_exists($key, $settings)) {
$value = $settings[$key];
return $this->writeUserSettingCache($key, $value);
}
$cache = PhabricatorCaches::getRuntimeCache();
$cache_key = "settings.defaults({$key})";
$cache_map = $cache->getKeys(array($cache_key));
if ($cache_map) {
$value = $cache_map[$cache_key];
} else {
$defaults = PhabricatorSetting::getAllSettings();
if (isset($defaults[$key])) {
$value = id(clone $defaults[$key])
->setViewer($this)
->getSettingDefaultValue();
} else {
$value = null;
}
$cache->setKey($cache_key, $value);
}
return $this->writeUserSettingCache($key, $value);
}
/**
* Test if a given setting is set to a particular value.
*
* @param const Setting key.
* @param wild Value to compare.
* @return bool True if the setting has the specified value.
* @task settings
*/
public function compareUserSetting($key, $value) {
$actual = $this->getUserSetting($key);
return ($actual == $value);
}
private function writeUserSettingCache($key, $value) {
$this->settingCacheKeys[$key] = true;
$this->settingCache[$key] = $value;
return $value;
}
public function getTranslation() {
return $this->getUserSetting(PhabricatorTranslationSetting::SETTINGKEY);
}
public function getTimezoneIdentifier() {
return $this->getUserSetting(PhabricatorTimezoneSetting::SETTINGKEY);
}
public static function getGlobalSettingsCacheKey() {
return 'user.settings.globals.v1';
}
private function loadGlobalSettings() {
$cache_key = self::getGlobalSettingsCacheKey();
$cache = PhabricatorCaches::getMutableStructureCache();
$settings = $cache->getKey($cache_key);
if (!$settings) {
$preferences = PhabricatorUserPreferences::loadGlobalPreferences($this);
$settings = $preferences->getPreferences();
$cache->setKey($cache_key, $settings);
}
return $settings;
}
/**
* Override the user's timezone identifier.
*
* This is primarily useful for unit tests.
*
* @param string New timezone identifier.
* @return this
* @task settings
*/
public function overrideTimezoneIdentifier($identifier) {
$timezone_key = PhabricatorTimezoneSetting::SETTINGKEY;
$this->settingCacheKeys[$timezone_key] = true;
$this->settingCache[$timezone_key] = $identifier;
return $this;
}
public function getGender() {
return $this->getUserSetting(PhabricatorPronounSetting::SETTINGKEY);
}
public function loadEditorLink(
$path,
$line,
PhabricatorRepository $repository = null) {
$editor = $this->getUserSetting(PhabricatorEditorSetting::SETTINGKEY);
if (is_array($path)) {
$multi_key = PhabricatorEditorMultipleSetting::SETTINGKEY;
$multiedit = $this->getUserSetting($multi_key);
switch ($multiedit) {
case PhabricatorEditorMultipleSetting::VALUE_SPACES:
$path = implode(' ', $path);
break;
case PhabricatorEditorMultipleSetting::VALUE_SINGLE:
default:
return null;
}
}
if (!strlen($editor)) {
return null;
}
if ($repository) {
$callsign = $repository->getCallsign();
} else {
$callsign = null;
}
$uri = strtr($editor, array(
'%%' => '%',
'%f' => phutil_escape_uri($path),
'%l' => phutil_escape_uri($line),
'%r' => phutil_escape_uri($callsign),
));
// The resulting URI must have an allowed protocol. Otherwise, we'll return
// a link to an error page explaining the misconfiguration.
$ok = PhabricatorHelpEditorProtocolController::hasAllowedProtocol($uri);
if (!$ok) {
return '/help/editorprotocol/';
}
return (string)$uri;
}
public function getAlternateCSRFString() {
return $this->assertAttached($this->alternateCSRFString);
}
public function attachAlternateCSRFString($string) {
$this->alternateCSRFString = $string;
return $this;
}
/**
* Populate the nametoken table, which used to fetch typeahead results. When
* a user types "linc", we want to match "Abraham Lincoln" from on-demand
* typeahead sources. To do this, we need a separate table of name fragments.
*/
public function updateNameTokens() {
$table = self::NAMETOKEN_TABLE;
$conn_w = $this->establishConnection('w');
$tokens = PhabricatorTypeaheadDatasource::tokenizeString(
$this->getUserName().' '.$this->getRealName());
$sql = array();
foreach ($tokens as $token) {
$sql[] = qsprintf(
$conn_w,
'(%d, %s)',
$this->getID(),
$token);
}
queryfx(
$conn_w,
'DELETE FROM %T WHERE userID = %d',
$table,
$this->getID());
if ($sql) {
queryfx(
$conn_w,
'INSERT INTO %T (userID, token) VALUES %Q',
$table,
implode(', ', $sql));
}
}
public function sendWelcomeEmail(PhabricatorUser $admin) {
if (!$this->canEstablishWebSessions()) {
throw new Exception(
pht(
'Can not send welcome mail to users who can not establish '.
'web sessions!'));
}
$admin_username = $admin->getUserName();
$admin_realname = $admin->getRealName();
$user_username = $this->getUserName();
$is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
$base_uri = PhabricatorEnv::getProductionURI('/');
$engine = new PhabricatorAuthSessionEngine();
$uri = $engine->getOneTimeLoginURI(
$this,
$this->loadPrimaryEmail(),
PhabricatorAuthSessionEngine::ONETIME_WELCOME);
$body = pht(
"Welcome to Phabricator!\n\n".
"%s (%s) has created an account for you.\n\n".
" Username: %s\n\n".
"To login to Phabricator, follow this link and set a password:\n\n".
" %s\n\n".
"After you have set a password, you can login in the future by ".
"going here:\n\n".
" %s\n",
$admin_username,
$admin_realname,
$user_username,
$uri,
$base_uri);
if (!$is_serious) {
$body .= sprintf(
"\n%s\n",
pht("Love,\nPhabricator"));
}
$mail = id(new PhabricatorMetaMTAMail())
->addTos(array($this->getPHID()))
->setForceDelivery(true)
->setSubject(pht('[Phabricator] Welcome to Phabricator'))
->setBody($body)
->saveAndSend();
}
public function sendUsernameChangeEmail(
PhabricatorUser $admin,
$old_username) {
$admin_username = $admin->getUserName();
$admin_realname = $admin->getRealName();
$new_username = $this->getUserName();
$password_instructions = null;
if (PhabricatorPasswordAuthProvider::getPasswordProvider()) {
$engine = new PhabricatorAuthSessionEngine();
$uri = $engine->getOneTimeLoginURI(
$this,
null,
PhabricatorAuthSessionEngine::ONETIME_USERNAME);
$password_instructions = sprintf(
"%s\n\n %s\n\n%s\n",
pht(
"If you use a password to login, you'll need to reset it ".
"before you can login again. You can reset your password by ".
"following this link:"),
$uri,
pht(
"And, of course, you'll need to use your new username to login ".
"from now on. If you use OAuth to login, nothing should change."));
}
$body = sprintf(
"%s\n\n %s\n %s\n\n%s",
pht(
'%s (%s) has changed your Phabricator username.',
$admin_username,
$admin_realname),
pht(
'Old Username: %s',
$old_username),
pht(
'New Username: %s',
$new_username),
$password_instructions);
$mail = id(new PhabricatorMetaMTAMail())
->addTos(array($this->getPHID()))
->setForceDelivery(true)
->setSubject(pht('[Phabricator] Username Changed'))
->setBody($body)
->saveAndSend();
}
public static function describeValidUsername() {
return pht(
'Usernames must contain only numbers, letters, period, underscore and '.
'hyphen, and can not end with a period. They must have no more than %d '.
'characters.',
new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH));
}
public static function validateUsername($username) {
// NOTE: If you update this, make sure to update:
//
// - Remarkup rule for @mentions.
// - Routing rule for "/p/username/".
// - Unit tests, obviously.
// - describeValidUsername() method, above.
if (strlen($username) > self::MAXIMUM_USERNAME_LENGTH) {
return false;
}
return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]\z/', $username);
}
public static function getDefaultProfileImageURI() {
return celerity_get_resource_uri('/rsrc/image/avatar.png');
}
public function getProfileImageURI() {
$uri_key = PhabricatorUserProfileImageCacheType::KEY_URI;
return $this->requireCacheData($uri_key);
}
public function getUnreadNotificationCount() {
$notification_key = PhabricatorUserNotificationCountCacheType::KEY_COUNT;
return $this->requireCacheData($notification_key);
}
public function getUnreadMessageCount() {
$message_key = PhabricatorUserMessageCountCacheType::KEY_COUNT;
return $this->requireCacheData($message_key);
}
public function getRecentBadgeAwards() {
$badges_key = PhabricatorUserBadgesCacheType::KEY_BADGES;
return $this->requireCacheData($badges_key);
}
public function getFullName() {
if (strlen($this->getRealName())) {
return $this->getUsername().' ('.$this->getRealName().')';
} else {
return $this->getUsername();
}
}
public function getTimeZone() {
return new DateTimeZone($this->getTimezoneIdentifier());
}
public function getTimeZoneOffset() {
$timezone = $this->getTimeZone();
$now = new DateTime('@'.PhabricatorTime::getNow());
$offset = $timezone->getOffset($now);
// Javascript offsets are in minutes and have the opposite sign.
$offset = -(int)($offset / 60);
return $offset;
}
public function getTimeZoneOffsetInHours() {
$offset = $this->getTimeZoneOffset();
$offset = (int)round($offset / 60);
$offset = -$offset;
return $offset;
}
public function formatShortDateTime($when, $now = null) {
if ($now === null) {
$now = PhabricatorTime::getNow();
}
try {
$when = new DateTime('@'.$when);
$now = new DateTime('@'.$now);
} catch (Exception $ex) {
return null;
}
$zone = $this->getTimeZone();
$when->setTimeZone($zone);
$now->setTimeZone($zone);
if ($when->format('Y') !== $now->format('Y')) {
// Different year, so show "Feb 31 2075".
$format = 'M j Y';
} else if ($when->format('Ymd') !== $now->format('Ymd')) {
// Same year but different month and day, so show "Feb 31".
$format = 'M j';
} else {
// Same year, month and day so show a time of day.
$pref_time = PhabricatorTimeFormatSetting::SETTINGKEY;
$format = $this->getUserSetting($pref_time);
}
return $when->format($format);
}
public function __toString() {
return $this->getUsername();
}
public static function loadOneWithEmailAddress($address) {
$email = id(new PhabricatorUserEmail())->loadOneWhere(
'address = %s',
$address);
if (!$email) {
return null;
}
return id(new PhabricatorUser())->loadOneWhere(
'phid = %s',
$email->getUserPHID());
}
public function getDefaultSpacePHID() {
// TODO: We might let the user switch which space they're "in" later on;
// for now just use the global space if one exists.
// If the viewer has access to the default space, use that.
$spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces($this);
foreach ($spaces as $space) {
if ($space->getIsDefaultNamespace()) {
return $space->getPHID();
}
}
// Otherwise, use the space with the lowest ID that they have access to.
// This just tends to keep the default stable and predictable over time,
// so adding a new space won't change behavior for users.
if ($spaces) {
$spaces = msort($spaces, 'getID');
return head($spaces)->getPHID();
}
return null;
}
/**
* Grant a user a source of authority, to let them bypass policy checks they
* could not otherwise.
*/
public function grantAuthority($authority) {
$this->authorities[] = $authority;
return $this;
}
/**
* Get authorities granted to the user.
*/
public function getAuthorities() {
return $this->authorities;
}
public function hasConduitClusterToken() {
return ($this->conduitClusterToken !== self::ATTACHABLE);
}
public function attachConduitClusterToken(PhabricatorConduitToken $token) {
$this->conduitClusterToken = $token;
return $this;
}
public function getConduitClusterToken() {
return $this->assertAttached($this->conduitClusterToken);
}
/* -( Availability )------------------------------------------------------- */
/**
* @task availability
*/
public function attachAvailability(array $availability) {
$this->availability = $availability;
return $this;
}
/**
* Get the timestamp the user is away until, if they are currently away.
*
* @return int|null Epoch timestamp, or `null` if the user is not away.
* @task availability
*/
public function getAwayUntil() {
$availability = $this->availability;
$this->assertAttached($availability);
if (!$availability) {
return null;
}
return idx($availability, 'until');
}
public function getDisplayAvailability() {
$availability = $this->availability;
$this->assertAttached($availability);
if (!$availability) {
return null;
}
$busy = PhabricatorCalendarEventInvitee::AVAILABILITY_BUSY;
return idx($availability, 'availability', $busy);
}
public function getAvailabilityEventPHID() {
$availability = $this->availability;
$this->assertAttached($availability);
if (!$availability) {
return null;
}
return idx($availability, 'eventPHID');
}
/**
* Get cached availability, if present.
*
* @return wild|null Cache data, or null if no cache is available.
* @task availability
*/
public function getAvailabilityCache() {
$now = PhabricatorTime::getNow();
if ($this->availabilityCacheTTL <= $now) {
return null;
}
try {
return phutil_json_decode($this->availabilityCache);
} catch (Exception $ex) {
return null;
}
}
/**
* Write to the availability cache.
*
* @param wild Availability cache data.
* @param int|null Cache TTL.
* @return this
* @task availability
*/
public function writeAvailabilityCache(array $availability, $ttl) {
if (PhabricatorEnv::isReadOnly()) {
return $this;
}
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$this->establishConnection('w'),
'UPDATE %T SET availabilityCache = %s, availabilityCacheTTL = %nd
WHERE id = %d',
$this->getTableName(),
phutil_json_encode($availability),
$ttl,
$this->getID());
unset($unguarded);
return $this;
}
/* -( Multi-Factor Authentication )---------------------------------------- */
/**
* Update the flag storing this user's enrollment in multi-factor auth.
*
* With certain settings, we need to check if a user has MFA on every page,
* so we cache MFA enrollment on the user object for performance. Calling this
* method synchronizes the cache by examining enrollment records. After
* updating the cache, use @{method:getIsEnrolledInMultiFactor} to check if
* the user is enrolled.
*
* This method should be called after any changes are made to a given user's
* multi-factor configuration.
*
* @return void
* @task factors
*/
public function updateMultiFactorEnrollment() {
$factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
$enrolled = count($factors) ? 1 : 0;
if ($enrolled !== $this->isEnrolledInMultiFactor) {
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
queryfx(
$this->establishConnection('w'),
'UPDATE %T SET isEnrolledInMultiFactor = %d WHERE id = %d',
$this->getTableName(),
$enrolled,
$this->getID());
unset($unguarded);
$this->isEnrolledInMultiFactor = $enrolled;
}
}
/**
* Check if the user is enrolled in multi-factor authentication.
*
* Enrolled users have one or more multi-factor authentication sources
* attached to their account. For performance, this value is cached. You
* can use @{method:updateMultiFactorEnrollment} to update the cache.
*
* @return bool True if the user is enrolled.
* @task factors
*/
public function getIsEnrolledInMultiFactor() {
return $this->isEnrolledInMultiFactor;
}
/* -( Omnipotence )-------------------------------------------------------- */
/**
* Returns true if this user is omnipotent. Omnipotent users bypass all policy
* checks.
*
* @return bool True if the user bypasses policy checks.
*/
public function isOmnipotent() {
return $this->omnipotent;
}
/**
* Get an omnipotent user object for use in contexts where there is no acting
* user, notably daemons.
*
* @return PhabricatorUser An omnipotent user.
*/
public static function getOmnipotentUser() {
static $user = null;
if (!$user) {
$user = new PhabricatorUser();
$user->omnipotent = true;
$user->makeEphemeral();
}
return $user;
}
/**
* Get a scalar string identifying this user.
*
- * This is similar to using the PHID, but distinguishes between ominpotent
+ * This is similar to using the PHID, but distinguishes between omnipotent
* and public users explicitly. This allows safe construction of cache keys
* or cache buckets which do not conflate public and omnipotent users.
*
* @return string Scalar identifier.
*/
public function getCacheFragment() {
if ($this->isOmnipotent()) {
return 'u.omnipotent';
}
$phid = $this->getPHID();
if ($phid) {
return 'u.'.$phid;
}
return 'u.public';
}
/* -( Managing Handles )--------------------------------------------------- */
/**
* Get a @{class:PhabricatorHandleList} which benefits from this viewer's
* internal handle pool.
*
* @param list<phid> List of PHIDs to load.
* @return PhabricatorHandleList Handle list object.
* @task handle
*/
public function loadHandles(array $phids) {
if ($this->handlePool === null) {
$this->handlePool = id(new PhabricatorHandlePool())
->setViewer($this);
}
return $this->handlePool->newHandleList($phids);
}
/**
* Get a @{class:PHUIHandleView} for a single handle.
*
* This benefits from the viewer's internal handle pool.
*
* @param phid PHID to render a handle for.
* @return PHUIHandleView View of the handle.
* @task handle
*/
public function renderHandle($phid) {
return $this->loadHandles(array($phid))->renderHandle($phid);
}
/**
* Get a @{class:PHUIHandleListView} for a list of handles.
*
* This benefits from the viewer's internal handle pool.
*
* @param list<phid> List of PHIDs to render.
* @return PHUIHandleListView View of the handles.
* @task handle
*/
public function renderHandleList(array $phids) {
return $this->loadHandles($phids)->renderList();
}
public function attachBadgePHIDs(array $phids) {
$this->badgePHIDs = $phids;
return $this;
}
public function getBadgePHIDs() {
return $this->assertAttached($this->badgePHIDs);
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::POLICY_PUBLIC;
case PhabricatorPolicyCapability::CAN_EDIT:
if ($this->getIsSystemAgent() || $this->getIsMailingList()) {
return PhabricatorPolicies::POLICY_ADMIN;
} else {
return PhabricatorPolicies::POLICY_NOONE;
}
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return $this->getPHID() && ($viewer->getPHID() === $this->getPHID());
}
public function describeAutomaticCapability($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_EDIT:
return pht('Only you can edit your information.');
default:
return null;
}
}
/* -( PhabricatorCustomFieldInterface )------------------------------------ */
public function getCustomFieldSpecificationForRole($role) {
return PhabricatorEnv::getEnvConfig('user.fields');
}
public function getCustomFieldBaseClass() {
return 'PhabricatorUserCustomField';
}
public function getCustomFields() {
return $this->assertAttached($this->customFields);
}
public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) {
$this->customFields = $fields;
return $this;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$this->delete();
$externals = id(new PhabricatorExternalAccount())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($externals as $external) {
$external->delete();
}
$prefs = id(new PhabricatorUserPreferencesQuery())
->setViewer($engine->getViewer())
->withUsers(array($this))
->execute();
foreach ($prefs as $pref) {
$engine->destroyObject($pref);
}
$profiles = id(new PhabricatorUserProfile())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($profiles as $profile) {
$profile->delete();
}
$keys = id(new PhabricatorAuthSSHKeyQuery())
->setViewer($engine->getViewer())
->withObjectPHIDs(array($this->getPHID()))
->execute();
foreach ($keys as $key) {
$engine->destroyObject($key);
}
$emails = id(new PhabricatorUserEmail())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($emails as $email) {
$email->delete();
}
$sessions = id(new PhabricatorAuthSession())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($sessions as $session) {
$session->delete();
}
$factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere(
'userPHID = %s',
$this->getPHID());
foreach ($factors as $factor) {
$factor->delete();
}
$this->saveTransaction();
}
/* -( PhabricatorSSHPublicKeyInterface )----------------------------------- */
public function getSSHPublicKeyManagementURI(PhabricatorUser $viewer) {
if ($viewer->getPHID() == $this->getPHID()) {
// If the viewer is managing their own keys, take them to the normal
// panel.
return '/settings/panel/ssh/';
} else {
// Otherwise, take them to the administrative panel for this user.
return '/settings/user/'.$this->getUsername().'/page/ssh/';
}
}
public function getSSHKeyDefaultName() {
return 'id_rsa_phabricator';
}
public function getSSHKeyNotifyPHIDs() {
return array(
$this->getPHID(),
);
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorUserProfileEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new PhabricatorUserTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( PhabricatorFulltextInterface )--------------------------------------- */
public function newFulltextEngine() {
return new PhabricatorUserFulltextEngine();
}
/* -( PhabricatorFerretInterface )----------------------------------------- */
public function newFerretEngine() {
return new PhabricatorUserFerretEngine();
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('username')
->setType('string')
->setDescription(pht("The user's username.")),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('realName')
->setType('string')
->setDescription(pht("The user's real name.")),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('roles')
->setType('list<string>')
- ->setDescription(pht('List of acccount roles.')),
+ ->setDescription(pht('List of account roles.')),
);
}
public function getFieldValuesForConduit() {
$roles = array();
if ($this->getIsDisabled()) {
$roles[] = 'disabled';
}
if ($this->getIsSystemAgent()) {
$roles[] = 'bot';
}
if ($this->getIsMailingList()) {
$roles[] = 'list';
}
if ($this->getIsAdmin()) {
$roles[] = 'admin';
}
if ($this->getIsEmailVerified()) {
$roles[] = 'verified';
}
if ($this->getIsApproved()) {
$roles[] = 'approved';
}
if ($this->isUserActivated()) {
$roles[] = 'activated';
}
return array(
'username' => $this->getUsername(),
'realName' => $this->getRealName(),
'roles' => $roles,
);
}
public function getConduitSearchAttachments() {
return array();
}
/* -( User Cache )--------------------------------------------------------- */
/**
* @task cache
*/
public function attachRawCacheData(array $data) {
$this->rawCacheData = $data + $this->rawCacheData;
return $this;
}
public function setAllowInlineCacheGeneration($allow_cache_generation) {
$this->allowInlineCacheGeneration = $allow_cache_generation;
return $this;
}
/**
* @task cache
*/
protected function requireCacheData($key) {
if (isset($this->usableCacheData[$key])) {
return $this->usableCacheData[$key];
}
$type = PhabricatorUserCacheType::requireCacheTypeForKey($key);
if (isset($this->rawCacheData[$key])) {
$raw_value = $this->rawCacheData[$key];
$usable_value = $type->getValueFromStorage($raw_value);
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
// By default, we throw if a cache isn't available. This is consistent
// with the standard `needX()` + `attachX()` + `getX()` interaction.
if (!$this->allowInlineCacheGeneration) {
throw new PhabricatorDataNotAttachedException($this);
}
$user_phid = $this->getPHID();
// Try to read the actual cache before we generate a new value. We can
// end up here via Conduit, which does not use normal sessions and can
// not pick up a free cache load during session identification.
if ($user_phid) {
$raw_data = PhabricatorUserCache::readCaches(
$type,
$key,
array($user_phid));
if (array_key_exists($user_phid, $raw_data)) {
$raw_value = $raw_data[$user_phid];
$usable_value = $type->getValueFromStorage($raw_value);
$this->rawCacheData[$key] = $raw_value;
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
}
$usable_value = $type->getDefaultValue();
if ($user_phid) {
$map = $type->newValueForUsers($key, array($this));
if (array_key_exists($user_phid, $map)) {
$raw_value = $map[$user_phid];
$usable_value = $type->getValueFromStorage($raw_value);
$this->rawCacheData[$key] = $raw_value;
PhabricatorUserCache::writeCache(
$type,
$key,
$user_phid,
$raw_value);
}
}
$this->usableCacheData[$key] = $usable_value;
return $usable_value;
}
/**
* @task cache
*/
public function clearCacheData($key) {
unset($this->rawCacheData[$key]);
unset($this->usableCacheData[$key]);
return $this;
}
public function getCSSValue($variable_key) {
$preference = PhabricatorAccessibilitySetting::SETTINGKEY;
$key = $this->getUserSetting($preference);
$postprocessor = CelerityPostprocessor::getPostprocessor($key);
$variables = $postprocessor->getVariables();
if (!isset($variables[$variable_key])) {
throw new Exception(
pht(
'Unknown CSS variable "%s"!',
$variable_key));
}
return $variables[$variable_key];
}
}
diff --git a/src/applications/phid/handle/pool/PhabricatorHandleList.php b/src/applications/phid/handle/pool/PhabricatorHandleList.php
index d9a279f8ba..a7b404c5a7 100644
--- a/src/applications/phid/handle/pool/PhabricatorHandleList.php
+++ b/src/applications/phid/handle/pool/PhabricatorHandleList.php
@@ -1,192 +1,192 @@
<?php
/**
* A list of object handles.
*
* This is a convenience class which behaves like an array but makes working
* with handles more convenient, improves their caching and batching semantics,
* and provides some utility behavior.
*
* Load a handle list by calling `loadHandles()` on a `$viewer`:
*
* $handles = $viewer->loadHandles($phids);
*
* This creates a handle list object, which behaves like an array of handles.
* However, it benefits from the viewer's internal handle cache and performs
* just-in-time bulk loading.
*/
final class PhabricatorHandleList
extends Phobject
implements
Iterator,
ArrayAccess,
Countable {
private $handlePool;
private $phids;
private $count;
private $handles;
private $cursor;
private $map;
public function setHandlePool(PhabricatorHandlePool $pool) {
$this->handlePool = $pool;
return $this;
}
public function setPHIDs(array $phids) {
$this->phids = $phids;
$this->count = count($phids);
return $this;
}
private function loadHandles() {
$this->handles = $this->handlePool->loadPHIDs($this->phids);
}
private function getHandle($phid) {
if ($this->handles === null) {
$this->loadHandles();
}
if (empty($this->handles[$phid])) {
throw new Exception(
pht(
'Requested handle "%s" was not loaded.',
$phid));
}
return $this->handles[$phid];
}
/**
* Get a handle from this list if it exists.
*
* This has similar semantics to @{function:idx}.
*/
public function getHandleIfExists($phid, $default = null) {
if ($this->handles === null) {
$this->loadHandles();
}
return idx($this->handles, $phid, $default);
}
/**
* Create a new list with a subset of the PHIDs in this list.
*/
public function newSublist(array $phids) {
foreach ($phids as $phid) {
if (!isset($this[$phid])) {
throw new Exception(
pht(
- 'Trying to create a new sublist of an existsing handle list, '.
+ 'Trying to create a new sublist of an existing handle list, '.
'but PHID "%s" does not appear in the parent list.',
$phid));
}
}
return $this->handlePool->newHandleList($phids);
}
/* -( Rendering )---------------------------------------------------------- */
/**
* Return a @{class:PHUIHandleListView} which can render the handles in
* this list.
*/
public function renderList() {
return id(new PHUIHandleListView())
->setHandleList($this);
}
/**
* Return a @{class:PHUIHandleView} which can render a specific handle.
*/
public function renderHandle($phid) {
if (!isset($this[$phid])) {
throw new Exception(
pht('Trying to render a handle which does not exist!'));
}
return id(new PHUIHandleView())
->setHandleList($this)
->setHandlePHID($phid);
}
/* -( Iterator )----------------------------------------------------------- */
public function rewind() {
$this->cursor = 0;
}
public function current() {
return $this->getHandle($this->phids[$this->cursor]);
}
public function key() {
return $this->phids[$this->cursor];
}
public function next() {
++$this->cursor;
}
public function valid() {
return ($this->cursor < $this->count);
}
/* -( ArrayAccess )-------------------------------------------------------- */
public function offsetExists($offset) {
// NOTE: We're intentionally not loading handles here so that isset()
// checks do not trigger fetches. This gives us better bulk loading
// behavior, particularly when invoked through methods like renderHandle().
if ($this->map === null) {
$this->map = array_fill_keys($this->phids, true);
}
return isset($this->map[$offset]);
}
public function offsetGet($offset) {
if ($this->handles === null) {
$this->loadHandles();
}
return $this->handles[$offset];
}
public function offsetSet($offset, $value) {
$this->raiseImmutableException();
}
public function offsetUnset($offset) {
$this->raiseImmutableException();
}
private function raiseImmutableException() {
throw new Exception(
pht(
'Trying to mutate a %s, but this is not permitted; '.
'handle lists are immutable.',
__CLASS__));
}
/* -( Countable )---------------------------------------------------------- */
public function count() {
return $this->count;
}
}
diff --git a/src/applications/phid/resolver/PhabricatorPHIDResolver.php b/src/applications/phid/resolver/PhabricatorPHIDResolver.php
index 9070139a0b..940221f551 100644
--- a/src/applications/phid/resolver/PhabricatorPHIDResolver.php
+++ b/src/applications/phid/resolver/PhabricatorPHIDResolver.php
@@ -1,46 +1,46 @@
<?php
/**
* Resolve a list of identifiers into PHIDs.
*
- * This class simplifies the process of convering a list of mixed token types
+ * This class simplifies the process of converting a list of mixed token types
* (like some PHIDs and some usernames) into a list of just PHIDs.
*/
abstract class PhabricatorPHIDResolver extends Phobject {
private $viewer;
final public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
final public function getViewer() {
return $this->viewer;
}
final public function resolvePHIDs(array $phids) {
$type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN;
$names = array();
foreach ($phids as $key => $phid) {
if (phid_get_type($phid) == $type_unknown) {
$names[$key] = $phid;
}
}
if ($names) {
$map = $this->getResolutionMap($names);
foreach ($names as $key => $name) {
if (isset($map[$name])) {
$phids[$key] = $map[$name];
}
}
}
return $phids;
}
abstract protected function getResolutionMap(array $names);
}
diff --git a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
index 1e7c348297..5adfc62e19 100644
--- a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
+++ b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
@@ -1,380 +1,380 @@
<?php
final class PhortuneStripePaymentProvider extends PhortunePaymentProvider {
const STRIPE_PUBLISHABLE_KEY = 'stripe.publishable-key';
const STRIPE_SECRET_KEY = 'stripe.secret-key';
public function isAcceptingLivePayments() {
return preg_match('/_live_/', $this->getPublishableKey());
}
public function getName() {
return pht('Stripe');
}
public function getConfigureName() {
return pht('Add Stripe Payments Account');
}
public function getConfigureDescription() {
return pht(
'Allows you to accept credit or debit card payments with a '.
'stripe.com account.');
}
public function getConfigureProvidesDescription() {
return pht('This merchant accepts credit and debit cards via Stripe.');
}
public function getPaymentMethodDescription() {
return pht('Add Credit or Debit Card (US and Canada)');
}
public function getPaymentMethodIcon() {
return 'Stripe';
}
public function getPaymentMethodProviderDescription() {
return pht('Processed by Stripe');
}
public function getDefaultPaymentMethodDisplayName(
PhortunePaymentMethod $method) {
return pht('Credit/Debit Card');
}
public function getAllConfigurableProperties() {
return array(
self::STRIPE_PUBLISHABLE_KEY,
self::STRIPE_SECRET_KEY,
);
}
public function getAllConfigurableSecretProperties() {
return array(
self::STRIPE_SECRET_KEY,
);
}
public function processEditForm(
AphrontRequest $request,
array $values) {
$errors = array();
$issues = array();
if (!strlen($values[self::STRIPE_SECRET_KEY])) {
$errors[] = pht('Stripe Secret Key is required.');
$issues[self::STRIPE_SECRET_KEY] = pht('Required');
}
if (!strlen($values[self::STRIPE_PUBLISHABLE_KEY])) {
$errors[] = pht('Stripe Publishable Key is required.');
$issues[self::STRIPE_PUBLISHABLE_KEY] = pht('Required');
}
return array($errors, $issues, $values);
}
public function extendEditForm(
AphrontRequest $request,
AphrontFormView $form,
array $values,
array $issues) {
$form
->appendChild(
id(new AphrontFormTextControl())
->setName(self::STRIPE_SECRET_KEY)
->setValue($values[self::STRIPE_SECRET_KEY])
->setError(idx($issues, self::STRIPE_SECRET_KEY, true))
->setLabel(pht('Stripe Secret Key')))
->appendChild(
id(new AphrontFormTextControl())
->setName(self::STRIPE_PUBLISHABLE_KEY)
->setValue($values[self::STRIPE_PUBLISHABLE_KEY])
->setError(idx($issues, self::STRIPE_PUBLISHABLE_KEY, true))
->setLabel(pht('Stripe Publishable Key')));
}
public function getConfigureInstructions() {
return pht(
"To configure Stripe, register or log in to an existing account on ".
"[[https://stripe.com | stripe.com]]. Once logged in:\n\n".
" - Go to {nav icon=user, name=Your Account > Account Settings ".
"> API Keys}\n".
" - Copy the **Secret Key** and **Publishable Key** into the fields ".
"above.\n\n".
"You can either use the test keys to add this provider in test mode, ".
"or the live keys to accept live payments.");
}
public function canRunConfigurationTest() {
return true;
}
public function runConfigurationTest() {
$this->loadStripeAPILibraries();
$secret_key = $this->getSecretKey();
$account = Stripe_Account::retrieve($secret_key);
}
/**
* @phutil-external-symbol class Stripe_Charge
* @phutil-external-symbol class Stripe_CardError
* @phutil-external-symbol class Stripe_Account
*/
protected function executeCharge(
PhortunePaymentMethod $method,
PhortuneCharge $charge) {
$this->loadStripeAPILibraries();
$price = $charge->getAmountAsCurrency();
$secret_key = $this->getSecretKey();
$params = array(
'amount' => $price->getValueInUSDCents(),
'currency' => $price->getCurrency(),
'customer' => $method->getMetadataValue('stripe.customerID'),
'description' => $charge->getPHID(),
'capture' => true,
);
$stripe_charge = Stripe_Charge::create($params, $secret_key);
$id = $stripe_charge->id;
if (!$id) {
throw new Exception(pht('Stripe charge call did not return an ID!'));
}
$charge->setMetadataValue('stripe.chargeID', $id);
$charge->save();
}
protected function executeRefund(
PhortuneCharge $charge,
PhortuneCharge $refund) {
$this->loadStripeAPILibraries();
$charge_id = $charge->getMetadataValue('stripe.chargeID');
if (!$charge_id) {
throw new Exception(
pht('Unable to refund charge; no Stripe chargeID!'));
}
$refund_cents = $refund
->getAmountAsCurrency()
->negate()
->getValueInUSDCents();
$secret_key = $this->getSecretKey();
$params = array(
'amount' => $refund_cents,
);
$stripe_charge = Stripe_Charge::retrieve($charge_id, $secret_key);
$stripe_refund = $stripe_charge->refunds->create($params);
$id = $stripe_refund->id;
if (!$id) {
throw new Exception(pht('Stripe refund call did not return an ID!'));
}
$charge->setMetadataValue('stripe.refundID', $id);
$charge->save();
}
public function updateCharge(PhortuneCharge $charge) {
$this->loadStripeAPILibraries();
$charge_id = $charge->getMetadataValue('stripe.chargeID');
if (!$charge_id) {
throw new Exception(
pht('Unable to update charge; no Stripe chargeID!'));
}
$secret_key = $this->getSecretKey();
$stripe_charge = Stripe_Charge::retrieve($charge_id, $secret_key);
// TODO: Deal with disputes / chargebacks / surprising refunds.
}
private function getPublishableKey() {
return $this
->getProviderConfig()
->getMetadataValue(self::STRIPE_PUBLISHABLE_KEY);
}
private function getSecretKey() {
return $this
->getProviderConfig()
->getMetadataValue(self::STRIPE_SECRET_KEY);
}
/* -( Adding Payment Methods )--------------------------------------------- */
public function canCreatePaymentMethods() {
return true;
}
/**
* @phutil-external-symbol class Stripe_Token
* @phutil-external-symbol class Stripe_Customer
*/
public function createPaymentMethodFromRequest(
AphrontRequest $request,
PhortunePaymentMethod $method,
array $token) {
$this->loadStripeAPILibraries();
$errors = array();
$secret_key = $this->getSecretKey();
$stripe_token = $token['stripeCardToken'];
// First, make sure the token is valid.
$info = id(new Stripe_Token())->retrieve($stripe_token, $secret_key);
$account_phid = $method->getAccountPHID();
$author_phid = $method->getAuthorPHID();
$params = array(
'card' => $stripe_token,
'description' => $account_phid.':'.$author_phid,
);
// Then, we need to create a Customer in order to be able to charge
// the card more than once. We create one Customer for each card;
// they do not map to PhortuneAccounts because we allow an account to
// have more than one active card.
$customer = Stripe_Customer::create($params, $secret_key);
$card = $info->card;
$method
->setBrand($card->brand)
->setLastFourDigits($card->last4)
->setExpires($card->exp_year, $card->exp_month)
->setMetadata(
array(
'type' => 'stripe.customer',
'stripe.customerID' => $customer->id,
'stripe.cardToken' => $stripe_token,
));
return $errors;
}
public function renderCreatePaymentMethodForm(
AphrontRequest $request,
array $errors) {
$ccform = id(new PhortuneCreditCardForm())
->setSecurityAssurance(
pht('Payments are processed securely by Stripe.'))
->setUser($request->getUser())
->setErrors($errors)
->addScript('https://js.stripe.com/v2/');
Javelin::initBehavior(
'stripe-payment-form',
array(
'stripePublishableKey' => $this->getPublishableKey(),
'formID' => $ccform->getFormID(),
));
return $ccform->buildForm();
}
private function getStripeShortErrorCode($error_code) {
$prefix = 'cc:stripe:';
if (strncmp($error_code, $prefix, strlen($prefix))) {
return null;
}
return substr($error_code, strlen($prefix));
}
public function validateCreatePaymentMethodToken(array $token) {
return isset($token['stripeCardToken']);
}
public function translateCreatePaymentMethodErrorCode($error_code) {
$short_code = $this->getStripeShortErrorCode($error_code);
if ($short_code) {
static $map = array(
'error:invalid_number' => PhortuneErrCode::ERR_CC_INVALID_NUMBER,
'error:invalid_cvc' => PhortuneErrCode::ERR_CC_INVALID_CVC,
'error:invalid_expiry_month' => PhortuneErrCode::ERR_CC_INVALID_EXPIRY,
'error:invalid_expiry_year' => PhortuneErrCode::ERR_CC_INVALID_EXPIRY,
);
if (isset($map[$short_code])) {
return $map[$short_code];
}
}
return $error_code;
}
/**
* See https://stripe.com/docs/api#errors for more information on possible
* errors.
*/
public function getCreatePaymentMethodErrorMessage($error_code) {
$short_code = $this->getStripeShortErrorCode($error_code);
if (!$short_code) {
return null;
}
switch ($short_code) {
case 'error:incorrect_number':
$error_key = 'number';
$message = pht('Invalid or incorrect credit card number.');
break;
case 'error:incorrect_cvc':
$error_key = 'cvc';
$message = pht('Card CVC is invalid or incorrect.');
break;
$error_key = 'exp';
$message = pht('Card expiration date is invalid or incorrect.');
break;
case 'error:invalid_expiry_month':
case 'error:invalid_expiry_year':
case 'error:invalid_cvc':
case 'error:invalid_number':
// NOTE: These should be translated into Phortune error codes earlier,
// so we don't expect to receive them here. They are listed for clarity
// and completeness. If we encounter one, we treat it as an unknown
// error.
break;
case 'error:invalid_amount':
case 'error:missing':
case 'error:card_declined':
case 'error:expired_card':
case 'error:duplicate_transaction':
case 'error:processing_error':
default:
- // NOTE: These errors currently don't recevive a detailed message.
+ // NOTE: These errors currently don't receive a detailed message.
// NOTE: We can also end up here with "http:nnn" messages.
// TODO: At least some of these should have a better message, or be
// translated into common errors above.
break;
}
return null;
}
private function loadStripeAPILibraries() {
$root = dirname(phutil_get_library_root('phabricator'));
require_once $root.'/externals/stripe-php/lib/Stripe.php';
}
}
diff --git a/src/applications/phrequent/storage/PhrequentTimeBlock.php b/src/applications/phrequent/storage/PhrequentTimeBlock.php
index ee5fd00a44..3800651476 100644
--- a/src/applications/phrequent/storage/PhrequentTimeBlock.php
+++ b/src/applications/phrequent/storage/PhrequentTimeBlock.php
@@ -1,323 +1,323 @@
<?php
final class PhrequentTimeBlock extends Phobject {
private $events;
public function __construct(array $events) {
assert_instances_of($events, 'PhrequentUserTime');
$this->events = $events;
}
public function getTimeSpentOnObject($phid, $now) {
$slices = idx($this->getObjectTimeRanges(), $phid);
if (!$slices) {
return null;
}
return $slices->getDuration($now);
}
public function getObjectTimeRanges() {
$ranges = array();
$range_start = time();
foreach ($this->events as $event) {
$range_start = min($range_start, $event->getDateStarted());
}
$object_ranges = array();
$object_ongoing = array();
foreach ($this->events as $event) {
// First, convert each event's preempting stack into a linear timeline
// of events.
$timeline = array();
$timeline[] = array(
'event' => $event,
'at' => (int)$event->getDateStarted(),
'type' => 'start',
);
$timeline[] = array(
'event' => $event,
'at' => (int)nonempty($event->getDateEnded(), PHP_INT_MAX),
'type' => 'end',
);
$base_phid = $event->getObjectPHID();
if (!$event->getDateEnded()) {
$object_ongoing[$base_phid] = true;
}
$preempts = $event->getPreemptingEvents();
foreach ($preempts as $preempt) {
$same_object = ($preempt->getObjectPHID() == $base_phid);
$timeline[] = array(
'event' => $preempt,
'at' => (int)$preempt->getDateStarted(),
'type' => $same_object ? 'start' : 'push',
);
$timeline[] = array(
'event' => $preempt,
'at' => (int)nonempty($preempt->getDateEnded(), PHP_INT_MAX),
'type' => $same_object ? 'end' : 'pop',
);
}
// Now, figure out how much time was actually spent working on the
// object.
usort($timeline, array(__CLASS__, 'sortTimeline'));
$stack = array();
$depth = null;
// NOTE: "Strata" track the separate layers between each event tracking
// the object we care about. Events might look like this:
//
// |xxxxxxxxxxxxxxxxx|
// |yyyyyyy|
// |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
// 9AM 5PM
//
// ...where we care about event "x". When "y" is popped, that shouldn't
// pop the top stack -- we need to pop the stack a level down. Each
// event tracking "x" creates a new stratum, and we keep track of where
// timeline events are among the strata in order to keep stack depths
// straight.
$stratum = null;
$strata = array();
$ranges = array();
foreach ($timeline as $timeline_event) {
$id = $timeline_event['event']->getID();
$type = $timeline_event['type'];
switch ($type) {
case 'start':
$stack[] = $depth;
$depth = 0;
$stratum = count($stack);
$strata[$id] = $stratum;
$range_start = $timeline_event['at'];
break;
case 'end':
if ($strata[$id] == $stratum) {
if ($depth == 0) {
$ranges[] = array($range_start, $timeline_event['at']);
$depth = array_pop($stack);
} else {
// Here, we've prematurely ended the current stratum. Merge all
// the higher strata into it. This looks like this:
//
// V
// V
// |zzzzzzzz|
// |xxxxx|
// |yyyyyyyyyyyyy|
// |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
$depth = array_pop($stack) + $depth;
}
} else {
// Here, we've prematurely ended a deeper stratum. Merge higher
- // stata. This looks like this:
+ // strata. This looks like this:
//
// V
// V
// |aaaaaaa|
// |xxxxxxxxxxxxxxxxxxx|
// |zzzzzzzzzzzzz|
// |xxxxxxx|
// |yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy|
// |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
$extra = $stack[$strata[$id]];
unset($stack[$strata[$id] - 1]);
$stack = array_values($stack);
$stack[$strata[$id] - 1] += $extra;
}
// Regardless of how we got here, we need to merge down any higher
// strata.
$target = $strata[$id];
foreach ($strata as $strata_id => $id_stratum) {
if ($id_stratum >= $target) {
$strata[$strata_id]--;
}
}
$stratum = count($stack);
unset($strata[$id]);
break;
case 'push':
$strata[$id] = $stratum;
if ($depth == 0) {
$ranges[] = array($range_start, $timeline_event['at']);
}
$depth++;
break;
case 'pop':
if ($strata[$id] == $stratum) {
$depth--;
if ($depth == 0) {
$range_start = $timeline_event['at'];
}
} else {
$stack[$strata[$id]]--;
}
unset($strata[$id]);
break;
}
}
// Filter out ranges with an indefinite start time. These occur when
// popping the stack when there are multiple ongoing events.
foreach ($ranges as $key => $range) {
if ($range[0] == PHP_INT_MAX) {
unset($ranges[$key]);
}
}
$object_ranges[$base_phid][] = $ranges;
}
// Collapse all the ranges so we don't double-count time.
foreach ($object_ranges as $phid => $ranges) {
$object_ranges[$phid] = self::mergeTimeRanges(array_mergev($ranges));
}
foreach ($object_ranges as $phid => $ranges) {
foreach ($ranges as $key => $range) {
if ($range[1] == PHP_INT_MAX) {
$ranges[$key][1] = null;
}
}
$object_ranges[$phid] = new PhrequentTimeSlices(
$phid,
isset($object_ongoing[$phid]),
$ranges);
}
// Reorder the ranges to be more stack-like, so the first item is the
// top of the stack.
$object_ranges = array_reverse($object_ranges, $preserve_keys = true);
return $object_ranges;
}
/**
* Returns the current list of work.
*/
public function getCurrentWorkStack($now, $include_inactive = false) {
$ranges = $this->getObjectTimeRanges();
$results = array();
$active = null;
foreach ($ranges as $phid => $slices) {
if (!$include_inactive) {
if (!$slices->getIsOngoing()) {
continue;
}
}
$results[] = array(
'phid' => $phid,
'time' => $slices->getDuration($now),
'ongoing' => $slices->getIsOngoing(),
);
}
return $results;
}
/**
* Merge a list of time ranges (pairs of `<start, end>` epochs) so that no
* elements overlap. For example, the ranges:
*
* array(
* array(50, 150),
* array(100, 175),
* );
*
* ...are merged to:
*
* array(
* array(50, 175),
* );
*
* This is used to avoid double-counting time on objects which had timers
* started multiple times.
*
* @param list<pair<int, int>> List of possibly overlapping time ranges.
* @return list<pair<int, int>> Nonoverlapping time ranges.
*/
public static function mergeTimeRanges(array $ranges) {
$ranges = isort($ranges, 0);
$result = array();
$current = null;
foreach ($ranges as $key => $range) {
if ($current === null) {
$current = $range;
continue;
}
if ($range[0] <= $current[1]) {
$current[1] = max($range[1], $current[1]);
continue;
}
$result[] = $current;
$current = $range;
}
$result[] = $current;
return $result;
}
/**
* Sort events in timeline order. Notably, for events which occur on the same
* second, we want to process end events after start events.
*/
public static function sortTimeline(array $u, array $v) {
// If these events occur at different times, ordering is obvious.
if ($u['at'] != $v['at']) {
return ($u['at'] < $v['at']) ? -1 : 1;
}
$u_end = ($u['type'] == 'end' || $u['type'] == 'pop');
$v_end = ($v['type'] == 'end' || $v['type'] == 'pop');
$u_id = $u['event']->getID();
$v_id = $v['event']->getID();
if ($u_end == $v_end) {
// These are both start events or both end events. Sort them by ID.
if (!$u_end) {
return ($u_id < $v_id) ? -1 : 1;
} else {
return ($u_id < $v_id) ? 1 : -1;
}
} else {
// Sort them (start, end) if they're the same event, and (end, start)
// otherwise.
if ($u_id == $v_id) {
return $v_end ? -1 : 1;
} else {
return $v_end ? 1 : -1;
}
}
return 0;
}
}
diff --git a/src/applications/phriction/editor/PhrictionTransactionEditor.php b/src/applications/phriction/editor/PhrictionTransactionEditor.php
index 1920b4c718..fcc9fe0474 100644
--- a/src/applications/phriction/editor/PhrictionTransactionEditor.php
+++ b/src/applications/phriction/editor/PhrictionTransactionEditor.php
@@ -1,621 +1,621 @@
<?php
final class PhrictionTransactionEditor
extends PhabricatorApplicationTransactionEditor {
const VALIDATE_CREATE_ANCESTRY = 'create';
const VALIDATE_MOVE_ANCESTRY = 'move';
private $description;
private $oldContent;
private $newContent;
private $moveAwayDocument;
private $skipAncestorCheck;
private $contentVersion;
private $processContentVersionError = true;
private $contentDiffURI;
public function setDescription($description) {
$this->description = $description;
return $this;
}
private function getDescription() {
return $this->description;
}
private function setOldContent(PhrictionContent $content) {
$this->oldContent = $content;
return $this;
}
public function getOldContent() {
return $this->oldContent;
}
private function setNewContent(PhrictionContent $content) {
$this->newContent = $content;
return $this;
}
public function getNewContent() {
return $this->newContent;
}
public function setSkipAncestorCheck($bool) {
$this->skipAncestorCheck = $bool;
return $this;
}
public function getSkipAncestorCheck() {
return $this->skipAncestorCheck;
}
public function setContentVersion($version) {
$this->contentVersion = $version;
return $this;
}
public function getContentVersion() {
return $this->contentVersion;
}
public function setProcessContentVersionError($process) {
$this->processContentVersionError = $process;
return $this;
}
public function getProcessContentVersionError() {
return $this->processContentVersionError;
}
public function setMoveAwayDocument(PhrictionDocument $document) {
$this->moveAwayDocument = $document;
return $this;
}
public function getEditorApplicationClass() {
return 'PhabricatorPhrictionApplication';
}
public function getEditorObjectsDescription() {
return pht('Phriction Documents');
}
public function getTransactionTypes() {
$types = parent::getTransactionTypes();
$types[] = PhabricatorTransactions::TYPE_EDGE;
$types[] = PhabricatorTransactions::TYPE_COMMENT;
$types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
$types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
return $types;
}
protected function shouldApplyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhrictionDocumentTitleTransaction::TRANSACTIONTYPE:
case PhrictionDocumentContentTransaction::TRANSACTIONTYPE:
case PhrictionDocumentDeleteTransaction::TRANSACTIONTYPE:
case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE:
case PhrictionDocumentMoveAwayTransaction::TRANSACTIONTYPE:
return true;
}
}
return parent::shouldApplyInitialEffects($object, $xactions);
}
protected function applyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
$this->setOldContent($object->getContent());
$this->setNewContent($this->buildNewContentTemplate($object));
}
protected function expandTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$xactions = parent::expandTransaction($object, $xaction);
switch ($xaction->getTransactionType()) {
case PhrictionDocumentContentTransaction::TRANSACTIONTYPE:
if ($this->getIsNewObject()) {
break;
}
$content = $xaction->getNewValue();
if ($content === '') {
$xactions[] = id(new PhrictionTransaction())
->setTransactionType(
PhrictionDocumentDeleteTransaction::TRANSACTIONTYPE)
->setNewValue(true)
->setMetadataValue('contentDelete', true);
}
break;
case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE:
$document = $xaction->getNewValue();
$xactions[] = id(new PhrictionTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY)
->setNewValue($document->getViewPolicy());
$xactions[] = id(new PhrictionTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY)
->setNewValue($document->getEditPolicy());
break;
default:
break;
}
return $xactions;
}
protected function applyFinalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
$save_content = false;
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhrictionDocumentTitleTransaction::TRANSACTIONTYPE:
case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE:
case PhrictionDocumentMoveAwayTransaction::TRANSACTIONTYPE:
case PhrictionDocumentDeleteTransaction::TRANSACTIONTYPE:
case PhrictionDocumentContentTransaction::TRANSACTIONTYPE:
$save_content = true;
break;
default:
break;
}
}
if ($save_content) {
$content = $this->getNewContent();
$content->setDocumentID($object->getID());
$content->save();
$object->setContentID($content->getID());
$object->save();
$object->attachContent($content);
}
if ($this->getIsNewObject() && !$this->getSkipAncestorCheck()) {
// Stub out empty parent documents if they don't exist
$ancestral_slugs = PhabricatorSlug::getAncestry($object->getSlug());
if ($ancestral_slugs) {
$ancestors = id(new PhrictionDocumentQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withSlugs($ancestral_slugs)
->needContent(true)
->execute();
$ancestors = mpull($ancestors, null, 'getSlug');
$stub_type = PhrictionChangeType::CHANGE_STUB;
foreach ($ancestral_slugs as $slug) {
$ancestor_doc = idx($ancestors, $slug);
// We check for change type to prevent near-infinite recursion
if (!$ancestor_doc && $content->getChangeType() != $stub_type) {
$ancestor_doc = PhrictionDocument::initializeNewDocument(
$this->getActor(),
$slug);
$stub_xactions = array();
$stub_xactions[] = id(new PhrictionTransaction())
->setTransactionType(
PhrictionDocumentTitleTransaction::TRANSACTIONTYPE)
->setNewValue(PhabricatorSlug::getDefaultTitle($slug))
->setMetadataValue('stub:create:phid', $object->getPHID());
$stub_xactions[] = id(new PhrictionTransaction())
->setTransactionType(
PhrictionDocumentContentTransaction::TRANSACTIONTYPE)
->setNewValue('')
->setMetadataValue('stub:create:phid', $object->getPHID());
$stub_xactions[] = id(new PhrictionTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY)
->setNewValue($object->getViewPolicy());
$stub_xactions[] = id(new PhrictionTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY)
->setNewValue($object->getEditPolicy());
$sub_editor = id(new PhrictionTransactionEditor())
->setActor($this->getActor())
->setContentSource($this->getContentSource())
->setContinueOnNoEffect($this->getContinueOnNoEffect())
->setSkipAncestorCheck(true)
->setDescription(pht('Empty Parent Document'))
->applyTransactions($ancestor_doc, $stub_xactions);
}
}
}
}
if ($this->moveAwayDocument !== null) {
$move_away_xactions = array();
$move_away_xactions[] = id(new PhrictionTransaction())
->setTransactionType(
PhrictionDocumentMoveAwayTransaction::TRANSACTIONTYPE)
->setNewValue($object);
$sub_editor = id(new PhrictionTransactionEditor())
->setActor($this->getActor())
->setContentSource($this->getContentSource())
->setContinueOnNoEffect($this->getContinueOnNoEffect())
->setDescription($this->getDescription())
->applyTransactions($this->moveAwayDocument, $move_away_xactions);
}
// Compute the content diff URI for the publishing phase.
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhrictionDocumentContentTransaction::TRANSACTIONTYPE:
$uri = id(new PhutilURI('/phriction/diff/'.$object->getID().'/'))
->alter('l', $this->getOldContent()->getVersion())
->alter('r', $this->getNewContent()->getVersion());
$this->contentDiffURI = (string)$uri;
break 2;
default:
break;
}
}
return $xactions;
}
protected function shouldSendMail(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
protected function getMailSubjectPrefix() {
return '[Phriction]';
}
protected function getMailTo(PhabricatorLiskDAO $object) {
return array(
$object->getContent()->getAuthorPHID(),
$this->getActingAsPHID(),
);
}
public function getMailTagsMap() {
return array(
PhrictionTransaction::MAILTAG_TITLE =>
pht("A document's title changes."),
PhrictionTransaction::MAILTAG_CONTENT =>
pht("A document's content changes."),
PhrictionTransaction::MAILTAG_DELETE =>
pht('A document is deleted.'),
PhrictionTransaction::MAILTAG_SUBSCRIBERS =>
pht('A document\'s subscribers change.'),
PhrictionTransaction::MAILTAG_OTHER =>
pht('Other document activity not listed above occurs.'),
);
}
protected function buildReplyHandler(PhabricatorLiskDAO $object) {
return id(new PhrictionReplyHandler())
->setMailReceiver($object);
}
protected function buildMailTemplate(PhabricatorLiskDAO $object) {
$id = $object->getID();
$title = $object->getContent()->getTitle();
return id(new PhabricatorMetaMTAMail())
->setSubject($title)
->addHeader('Thread-Topic', $object->getPHID());
}
protected function buildMailBody(
PhabricatorLiskDAO $object,
array $xactions) {
$body = parent::buildMailBody($object, $xactions);
if ($this->getIsNewObject()) {
$body->addRemarkupSection(
pht('DOCUMENT CONTENT'),
$object->getContent()->getContent());
} else if ($this->contentDiffURI) {
$body->addLinkSection(
pht('DOCUMENT DIFF'),
PhabricatorEnv::getProductionURI($this->contentDiffURI));
}
$description = $object->getContent()->getDescription();
if (strlen($description)) {
$body->addTextSection(
pht('EDIT NOTES'),
$description);
}
$body->addLinkSection(
pht('DOCUMENT DETAIL'),
PhabricatorEnv::getProductionURI(
PhrictionDocument::getSlugURI($object->getSlug())));
return $body;
}
protected function shouldPublishFeedStory(
PhabricatorLiskDAO $object,
array $xactions) {
return $this->shouldSendMail($object, $xactions);
}
protected function getFeedRelatedPHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
$phids = parent::getFeedRelatedPHIDs($object, $xactions);
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE:
$dict = $xaction->getNewValue();
$phids[] = $dict['phid'];
break;
}
}
return $phids;
}
protected function validateTransaction(
PhabricatorLiskDAO $object,
$type,
array $xactions) {
$errors = parent::validateTransaction($object, $type, $xactions);
foreach ($xactions as $xaction) {
switch ($type) {
case PhrictionDocumentContentTransaction::TRANSACTIONTYPE:
if ($xaction->getMetadataValue('stub:create:phid')) {
continue;
}
if ($this->getProcessContentVersionError()) {
$error = $this->validateContentVersion($object, $type, $xaction);
if ($error) {
$this->setProcessContentVersionError(false);
$errors[] = $error;
}
}
if ($this->getIsNewObject()) {
$ancestry_errors = $this->validateAncestry(
$object,
$type,
$xaction,
self::VALIDATE_CREATE_ANCESTRY);
if ($ancestry_errors) {
$errors = array_merge($errors, $ancestry_errors);
}
}
break;
case PhrictionDocumentMoveToTransaction::TRANSACTIONTYPE:
$source_document = $xaction->getNewValue();
$ancestry_errors = $this->validateAncestry(
$object,
$type,
$xaction,
self::VALIDATE_MOVE_ANCESTRY);
if ($ancestry_errors) {
$errors = array_merge($errors, $ancestry_errors);
}
$target_document = id(new PhrictionDocumentQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withSlugs(array($object->getSlug()))
->needContent(true)
->executeOne();
// Prevent overwrites and no-op moves.
$exists = PhrictionDocumentStatus::STATUS_EXISTS;
if ($target_document) {
$message = null;
if ($target_document->getSlug() == $source_document->getSlug()) {
$message = pht(
'You can not move a document to its existing location. '.
'Choose a different location to move the document to.');
} else if ($target_document->getStatus() == $exists) {
$message = pht(
'You can not move this document there, because it would '.
'overwrite an existing document which is already at that '.
'location. Move or delete the existing document first.');
}
if ($message !== null) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Invalid'),
$message,
$xaction);
$errors[] = $error;
}
}
break;
}
}
return $errors;
}
public function validateAncestry(
PhabricatorLiskDAO $object,
$type,
PhabricatorApplicationTransaction $xaction,
$verb) {
$errors = array();
- // NOTE: We use the ominpotent user for these checks because policy
+ // NOTE: We use the omnipotent user for these checks because policy
// doesn't matter; existence does.
$other_doc_viewer = PhabricatorUser::getOmnipotentUser();
$ancestral_slugs = PhabricatorSlug::getAncestry($object->getSlug());
if ($ancestral_slugs) {
$ancestors = id(new PhrictionDocumentQuery())
->setViewer($other_doc_viewer)
->withSlugs($ancestral_slugs)
->execute();
$ancestors = mpull($ancestors, null, 'getSlug');
foreach ($ancestral_slugs as $slug) {
$ancestor_doc = idx($ancestors, $slug);
if (!$ancestor_doc) {
$create_uri = '/phriction/edit/?slug='.$slug;
$create_link = phutil_tag(
'a',
array(
'href' => $create_uri,
),
$slug);
switch ($verb) {
case self::VALIDATE_MOVE_ANCESTRY:
$message = pht(
'Can not move document because the parent document with '.
'slug %s does not exist!',
$create_link);
break;
case self::VALIDATE_CREATE_ANCESTRY:
$message = pht(
'Can not create document because the parent document with '.
'slug %s does not exist!',
$create_link);
break;
}
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Missing Ancestor'),
$message,
$xaction);
$errors[] = $error;
}
}
}
return $errors;
}
private function validateContentVersion(
PhabricatorLiskDAO $object,
$type,
PhabricatorApplicationTransaction $xaction) {
$error = null;
if ($this->getContentVersion() &&
($object->getContent()->getVersion() != $this->getContentVersion())) {
$error = new PhabricatorApplicationTransactionValidationError(
$type,
pht('Edit Conflict'),
pht(
'Another user made changes to this document after you began '.
'editing it. Do you want to overwrite their changes? '.
'(If you choose to overwrite their changes, you should review '.
'the document edit history to see what you overwrote, and '.
'then make another edit to merge the changes if necessary.)'),
$xaction);
}
return $error;
}
protected function requireCapabilities(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
/*
* New objects have a special case. If a user can't see
* x/y
* then definitely don't let them make some
* x/y/z
* We need to load the direct parent to handle this case.
*/
if ($this->getIsNewObject()) {
$actor = $this->requireActor();
$parent_doc = null;
$ancestral_slugs = PhabricatorSlug::getAncestry($object->getSlug());
// No ancestral slugs is "/"; the first person gets to play with "/".
if ($ancestral_slugs) {
$parent = end($ancestral_slugs);
$parent_doc = id(new PhrictionDocumentQuery())
->setViewer($actor)
->withSlugs(array($parent))
->executeOne();
// If the $actor can't see the $parent_doc then they can't create
// the child $object; throw a policy exception.
if (!$parent_doc) {
id(new PhabricatorPolicyFilter())
->setViewer($actor)
->raisePolicyExceptions(true)
->rejectObject(
$object,
$object->getEditPolicy(),
PhabricatorPolicyCapability::CAN_EDIT);
}
// If the $actor can't edit the $parent_doc then they can't create
// the child $object; throw a policy exception.
if (!PhabricatorPolicyFilter::hasCapability(
$actor,
$parent_doc,
PhabricatorPolicyCapability::CAN_EDIT)) {
id(new PhabricatorPolicyFilter())
->setViewer($actor)
->raisePolicyExceptions(true)
->rejectObject(
$object,
$object->getEditPolicy(),
PhabricatorPolicyCapability::CAN_EDIT);
}
}
}
return parent::requireCapabilities($object, $xaction);
}
protected function supportsSearch() {
return true;
}
protected function shouldApplyHeraldRules(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
protected function buildHeraldAdapter(
PhabricatorLiskDAO $object,
array $xactions) {
return id(new PhrictionDocumentHeraldAdapter())
->setDocument($object);
}
private function buildNewContentTemplate(
PhrictionDocument $document) {
$new_content = id(new PhrictionContent())
->setSlug($document->getSlug())
->setAuthorPHID($this->getActor()->getPHID())
->setChangeType(PhrictionChangeType::CHANGE_EDIT)
->setTitle($this->getOldContent()->getTitle())
->setContent($this->getOldContent()->getContent());
if (strlen($this->getDescription())) {
$new_content->setDescription($this->getDescription());
}
$new_content->setVersion($this->getOldContent()->getVersion() + 1);
return $new_content;
}
protected function getCustomWorkerState() {
return array(
'contentDiffURI' => $this->contentDiffURI,
);
}
protected function loadCustomWorkerState(array $state) {
$this->contentDiffURI = idx($state, 'contentDiffURI');
return $this;
}
}
diff --git a/src/applications/policy/filter/PhabricatorPolicyFilter.php b/src/applications/policy/filter/PhabricatorPolicyFilter.php
index f3e1bd2083..fb03936ec2 100644
--- a/src/applications/policy/filter/PhabricatorPolicyFilter.php
+++ b/src/applications/policy/filter/PhabricatorPolicyFilter.php
@@ -1,973 +1,973 @@
<?php
final class PhabricatorPolicyFilter extends Phobject {
private $viewer;
private $objects;
private $capabilities;
private $raisePolicyExceptions;
private $userProjects;
private $customPolicies = array();
private $objectPolicies = array();
private $forcedPolicy;
public static function mustRetainCapability(
PhabricatorUser $user,
PhabricatorPolicyInterface $object,
$capability) {
if (!self::hasCapability($user, $object, $capability)) {
throw new Exception(
pht(
"You can not make that edit, because it would remove your ability ".
"to '%s' the object.",
$capability));
}
}
public static function requireCapability(
PhabricatorUser $user,
PhabricatorPolicyInterface $object,
$capability) {
$filter = id(new PhabricatorPolicyFilter())
->setViewer($user)
->requireCapabilities(array($capability))
->raisePolicyExceptions(true)
->apply(array($object));
}
/**
* Perform a capability check, acting as though an object had a specific
* policy. This is primarily used to check if a policy is valid (for example,
* to prevent users from editing away their ability to edit an object).
*
* Specifically, a check like this:
*
* PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
* $viewer,
* $object,
* PhabricatorPolicyCapability::CAN_EDIT,
* $potential_new_policy);
*
* ...will throw a @{class:PhabricatorPolicyException} if the new policy would
* remove the user's ability to edit the object.
*
* @param PhabricatorUser The viewer to perform a policy check for.
* @param PhabricatorPolicyInterface The object to perform a policy check on.
* @param string Capability to test.
* @param string Perform the test as though the object has this
* policy instead of the policy it actually has.
* @return void
*/
public static function requireCapabilityWithForcedPolicy(
PhabricatorUser $viewer,
PhabricatorPolicyInterface $object,
$capability,
$forced_policy) {
id(new PhabricatorPolicyFilter())
->setViewer($viewer)
->requireCapabilities(array($capability))
->raisePolicyExceptions(true)
->forcePolicy($forced_policy)
->apply(array($object));
}
public static function hasCapability(
PhabricatorUser $user,
PhabricatorPolicyInterface $object,
$capability) {
$filter = new PhabricatorPolicyFilter();
$filter->setViewer($user);
$filter->requireCapabilities(array($capability));
$result = $filter->apply(array($object));
return (count($result) == 1);
}
public static function canInteract(
PhabricatorUser $user,
PhabricatorPolicyInterface $object) {
$capabilities = $object->getCapabilities();
$capabilities = array_fuse($capabilities);
$can_interact = PhabricatorPolicyCapability::CAN_INTERACT;
$can_view = PhabricatorPolicyCapability::CAN_VIEW;
$require = array();
// If the object doesn't support a separate "Interact" capability, we
// only use the "View" capability: for most objects, you can interact
// with them if you can see them.
$require[] = $can_view;
if (isset($capabilities[$can_interact])) {
$require[] = $can_interact;
}
foreach ($require as $capability) {
if (!self::hasCapability($user, $object, $capability)) {
return false;
}
}
return true;
}
public function setViewer(PhabricatorUser $user) {
$this->viewer = $user;
return $this;
}
public function requireCapabilities(array $capabilities) {
$this->capabilities = $capabilities;
return $this;
}
public function raisePolicyExceptions($raise) {
$this->raisePolicyExceptions = $raise;
return $this;
}
public function forcePolicy($forced_policy) {
$this->forcedPolicy = $forced_policy;
return $this;
}
public function apply(array $objects) {
assert_instances_of($objects, 'PhabricatorPolicyInterface');
$viewer = $this->viewer;
$capabilities = $this->capabilities;
if (!$viewer || !$capabilities) {
throw new PhutilInvalidStateException('setViewer', 'requireCapabilities');
}
// If the viewer is omnipotent, short circuit all the checks and just
// return the input unmodified. This is an optimization; we know the
// result already.
if ($viewer->isOmnipotent()) {
return $objects;
}
// Before doing any actual object checks, make sure the viewer can see
// the applications that these objects belong to. This is normally enforced
// in the Query layer before we reach object filtering, but execution
// sometimes reaches policy filtering without running application checks.
$objects = $this->applyApplicationChecks($objects);
$filtered = array();
$viewer_phid = $viewer->getPHID();
if (empty($this->userProjects[$viewer_phid])) {
$this->userProjects[$viewer_phid] = array();
}
$need_projects = array();
$need_policies = array();
$need_objpolicies = array();
foreach ($objects as $key => $object) {
$object_capabilities = $object->getCapabilities();
foreach ($capabilities as $capability) {
if (!in_array($capability, $object_capabilities)) {
throw new Exception(
pht(
"Testing for capability '%s' on an object which does ".
"not have that capability!",
$capability));
}
$policy = $this->getObjectPolicy($object, $capability);
if (PhabricatorPolicyQuery::isObjectPolicy($policy)) {
$need_objpolicies[$policy][] = $object;
continue;
}
$type = phid_get_type($policy);
if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) {
$need_projects[$policy] = $policy;
continue;
}
if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) {
$need_policies[$policy][] = $object;
continue;
}
}
}
if ($need_objpolicies) {
$this->loadObjectPolicies($need_objpolicies);
}
if ($need_policies) {
$this->loadCustomPolicies($need_policies);
}
// If we need projects, check if any of the projects we need are also the
// objects we're filtering. Because of how project rules work, this is a
// common case.
if ($need_projects) {
foreach ($objects as $object) {
if ($object instanceof PhabricatorProject) {
$project_phid = $object->getPHID();
if (isset($need_projects[$project_phid])) {
$is_member = $object->isUserMember($viewer_phid);
$this->userProjects[$viewer_phid][$project_phid] = $is_member;
unset($need_projects[$project_phid]);
}
}
}
}
if ($need_projects) {
$need_projects = array_unique($need_projects);
// NOTE: We're using the omnipotent user here to avoid a recursive
// descent into madness. We don't actually need to know if the user can
// see these projects or not, since: the check is "user is member of
// project", not "user can see project"; and membership implies
// visibility anyway. Without this, we may load other projects and
// re-enter the policy filter and generally create a huge mess.
$projects = id(new PhabricatorProjectQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withMemberPHIDs(array($viewer->getPHID()))
->withPHIDs($need_projects)
->execute();
foreach ($projects as $project) {
$this->userProjects[$viewer_phid][$project->getPHID()] = true;
}
}
foreach ($objects as $key => $object) {
foreach ($capabilities as $capability) {
if (!$this->checkCapability($object, $capability)) {
// If we're missing any capability, move on to the next object.
continue 2;
}
}
// If we make it here, we have all of the required capabilities.
$filtered[$key] = $object;
}
- // If we survied the primary checks, apply extended checks to objects
+ // If we survived the primary checks, apply extended checks to objects
// with extended policies.
$results = array();
$extended = array();
foreach ($filtered as $key => $object) {
if ($object instanceof PhabricatorExtendedPolicyInterface) {
$extended[$key] = $object;
} else {
$results[$key] = $object;
}
}
if ($extended) {
$results += $this->applyExtendedPolicyChecks($extended);
// Put results back in the original order.
$results = array_select_keys($results, array_keys($filtered));
}
return $results;
}
private function applyExtendedPolicyChecks(array $extended_objects) {
$viewer = $this->viewer;
$filter_capabilities = $this->capabilities;
// Iterate over the objects we need to filter and pull all the nonempty
// policies into a flat, structured list.
$all_structs = array();
foreach ($extended_objects as $key => $extended_object) {
foreach ($filter_capabilities as $extended_capability) {
$extended_policies = $extended_object->getExtendedPolicy(
$extended_capability,
$viewer);
if (!$extended_policies) {
continue;
}
foreach ($extended_policies as $extended_policy) {
list($object, $capabilities) = $extended_policy;
// Build a description of the capabilities we need to check. This
// will be something like `"view"`, or `"edit view"`, or possibly
// a longer string with custom capabilities. Later, group the objects
// up into groups which need the same capabilities tested.
$capabilities = (array)$capabilities;
$capabilities = array_fuse($capabilities);
ksort($capabilities);
$group = implode(' ', $capabilities);
$struct = array(
'key' => $key,
'for' => $extended_capability,
'object' => $object,
'capabilities' => $capabilities,
'group' => $group,
);
$all_structs[] = $struct;
}
}
}
// Extract any bare PHIDs from the structs; we need to load these objects.
// These are objects which are required in order to perform an extended
// policy check but which the original viewer did not have permission to
// see (they presumably had other permissions which let them load the
// object in the first place).
$all_phids = array();
foreach ($all_structs as $idx => $struct) {
$object = $struct['object'];
if (is_string($object)) {
$all_phids[$object] = $object;
}
}
// If we have some bare PHIDs, we need to load the corresponding objects.
if ($all_phids) {
// We can pull these with the omnipotent user because we're immediately
// filtering them.
$ref_objects = id(new PhabricatorObjectQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($all_phids)
->execute();
$ref_objects = mpull($ref_objects, null, 'getPHID');
} else {
$ref_objects = array();
}
// Group the list of checks by the capabilities we need to check.
$groups = igroup($all_structs, 'group');
foreach ($groups as $structs) {
$head = head($structs);
// All of the items in each group are checking for the same capabilities.
$capabilities = $head['capabilities'];
$key_map = array();
$objects_in = array();
foreach ($structs as $struct) {
$extended_key = $struct['key'];
if (empty($extended_objects[$extended_key])) {
// If this object has already been rejected by an earlier filtering
// pass, we don't need to do any tests on it.
continue;
}
$object = $struct['object'];
if (is_string($object)) {
// This is really a PHID, so look it up.
$object_phid = $object;
if (empty($ref_objects[$object_phid])) {
// We weren't able to load the corresponding object, so just
// reject this result outright.
$reject = $extended_objects[$extended_key];
unset($extended_objects[$extended_key]);
// TODO: This could be friendlier.
$this->rejectObject($reject, false, '<bad-ref>');
continue;
}
$object = $ref_objects[$object_phid];
}
$phid = $object->getPHID();
$key_map[$phid][] = $extended_key;
$objects_in[$phid] = $object;
}
if ($objects_in) {
$objects_out = $this->executeExtendedPolicyChecks(
$viewer,
$capabilities,
$objects_in,
$key_map);
$objects_out = mpull($objects_out, null, 'getPHID');
} else {
$objects_out = array();
}
// If any objects were removed by filtering, we're going to reject all
// of the original objects which needed them.
foreach ($objects_in as $phid => $object_in) {
if (isset($objects_out[$phid])) {
// This object survived filtering, so we don't need to throw any
// results away.
continue;
}
foreach ($key_map[$phid] as $extended_key) {
if (empty($extended_objects[$extended_key])) {
// We've already rejected this object, so we don't need to reject
// it again.
continue;
}
$reject = $extended_objects[$extended_key];
unset($extended_objects[$extended_key]);
// It's possible that we're rejecting this object for multiple
// capability/policy failures, but just pick the first one to show
// to the user.
$first_capability = head($capabilities);
$first_policy = $object_in->getPolicy($first_capability);
$this->rejectObject($reject, $first_policy, $first_capability);
}
}
}
return $extended_objects;
}
private function executeExtendedPolicyChecks(
PhabricatorUser $viewer,
array $capabilities,
array $objects,
array $key_map) {
// Do crude cycle detection by seeing if we have a huge stack depth.
// Although more sophisticated cycle detection is possible in theory,
// it is difficult with hierarchical objects like subprojects. Many other
// checks make it difficult to create cycles normally, so just do a
// simple check here to limit damage.
static $depth;
$depth++;
if ($depth > 32) {
foreach ($objects as $key => $object) {
$this->rejectObject($objects[$key], false, '<cycle>');
unset($objects[$key]);
continue;
}
}
if (!$objects) {
return array();
}
$caught = null;
try {
$result = id(new PhabricatorPolicyFilter())
->setViewer($viewer)
->requireCapabilities($capabilities)
->apply($objects);
} catch (Exception $ex) {
$caught = $ex;
}
$depth--;
if ($caught) {
throw $caught;
}
return $result;
}
private function checkCapability(
PhabricatorPolicyInterface $object,
$capability) {
$policy = $this->getObjectPolicy($object, $capability);
if (!$policy) {
// TODO: Formalize this somehow?
$policy = PhabricatorPolicies::POLICY_USER;
}
if ($policy == PhabricatorPolicies::POLICY_PUBLIC) {
// If the object is set to "public" but that policy is disabled for this
// install, restrict the policy to "user".
if (!PhabricatorEnv::getEnvConfig('policy.allow-public')) {
$policy = PhabricatorPolicies::POLICY_USER;
}
// If the object is set to "public" but the capability is not a public
// capability, restrict the policy to "user".
$capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
if (!$capobj || !$capobj->shouldAllowPublicPolicySetting()) {
$policy = PhabricatorPolicies::POLICY_USER;
}
}
$viewer = $this->viewer;
if ($viewer->isOmnipotent()) {
return true;
}
if ($object instanceof PhabricatorSpacesInterface) {
$space_phid = $object->getSpacePHID();
if (!$this->canViewerSeeObjectsInSpace($viewer, $space_phid)) {
$this->rejectObjectFromSpace($object, $space_phid);
return false;
}
}
if ($object->hasAutomaticCapability($capability, $viewer)) {
return true;
}
switch ($policy) {
case PhabricatorPolicies::POLICY_PUBLIC:
return true;
case PhabricatorPolicies::POLICY_USER:
if ($viewer->getPHID()) {
return true;
} else {
$this->rejectObject($object, $policy, $capability);
}
break;
case PhabricatorPolicies::POLICY_ADMIN:
if ($viewer->getIsAdmin()) {
return true;
} else {
$this->rejectObject($object, $policy, $capability);
}
break;
case PhabricatorPolicies::POLICY_NOONE:
$this->rejectObject($object, $policy, $capability);
break;
default:
if (PhabricatorPolicyQuery::isObjectPolicy($policy)) {
if ($this->checkObjectPolicy($policy, $object)) {
return true;
} else {
$this->rejectObject($object, $policy, $capability);
break;
}
}
$type = phid_get_type($policy);
if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) {
if (!empty($this->userProjects[$viewer->getPHID()][$policy])) {
return true;
} else {
$this->rejectObject($object, $policy, $capability);
}
} else if ($type == PhabricatorPeopleUserPHIDType::TYPECONST) {
if ($viewer->getPHID() == $policy) {
return true;
} else {
$this->rejectObject($object, $policy, $capability);
}
} else if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) {
if ($this->checkCustomPolicy($policy, $object)) {
return true;
} else {
$this->rejectObject($object, $policy, $capability);
}
} else {
// Reject objects with unknown policies.
$this->rejectObject($object, false, $capability);
}
}
return false;
}
public function rejectObject(
PhabricatorPolicyInterface $object,
$policy,
$capability) {
if (!$this->raisePolicyExceptions) {
return;
}
if ($this->viewer->isOmnipotent()) {
// Never raise policy exceptions for the omnipotent viewer. Although we
// will never normally issue a policy rejection for the omnipotent
// viewer, we can end up here when queries blanket reject objects that
// have failed to load, without distinguishing between nonexistent and
// nonvisible objects.
return;
}
$capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
$rejection = null;
if ($capobj) {
$rejection = $capobj->describeCapabilityRejection();
$capability_name = $capobj->getCapabilityName();
} else {
$capability_name = $capability;
}
if (!$rejection) {
// We couldn't find the capability object, or it doesn't provide a
// tailored rejection string.
$rejection = pht(
'You do not have the required capability ("%s") to do whatever you '.
'are trying to do.',
$capability);
}
$more = PhabricatorPolicy::getPolicyExplanation($this->viewer, $policy);
$more = (array)$more;
$more = array_filter($more);
$exceptions = PhabricatorPolicy::getSpecialRules(
$object,
$this->viewer,
$capability,
true);
$details = array_filter(array_merge($more, $exceptions));
$access_denied = $this->renderAccessDenied($object);
$full_message = pht(
'[%s] (%s) %s // %s',
$access_denied,
$capability_name,
$rejection,
implode(' ', $details));
$exception = id(new PhabricatorPolicyException($full_message))
->setTitle($access_denied)
->setObjectPHID($object->getPHID())
->setRejection($rejection)
->setCapability($capability)
->setCapabilityName($capability_name)
->setMoreInfo($details);
throw $exception;
}
private function loadObjectPolicies(array $map) {
$viewer = $this->viewer;
$viewer_phid = $viewer->getPHID();
$rules = PhabricatorPolicyQuery::getObjectPolicyRules(null);
// Make sure we have clean, empty policy rule objects.
foreach ($rules as $key => $rule) {
$rules[$key] = clone $rule;
}
$results = array();
foreach ($map as $key => $object_list) {
$rule = idx($rules, $key);
if (!$rule) {
continue;
}
foreach ($object_list as $object_key => $object) {
if (!$rule->canApplyToObject($object)) {
unset($object_list[$object_key]);
}
}
$rule->willApplyRules($viewer, array(), $object_list);
$results[$key] = $rule;
}
$this->objectPolicies[$viewer_phid] = $results;
}
private function loadCustomPolicies(array $map) {
$viewer = $this->viewer;
$viewer_phid = $viewer->getPHID();
$custom_policies = id(new PhabricatorPolicyQuery())
->setViewer($viewer)
->withPHIDs(array_keys($map))
->execute();
$custom_policies = mpull($custom_policies, null, 'getPHID');
$classes = array();
$values = array();
$objects = array();
foreach ($custom_policies as $policy_phid => $policy) {
foreach ($policy->getCustomRuleClasses() as $class) {
$classes[$class] = $class;
$values[$class][] = $policy->getCustomRuleValues($class);
foreach (idx($map, $policy_phid, array()) as $object) {
$objects[$class][] = $object;
}
}
}
foreach ($classes as $class => $ignored) {
$rule_object = newv($class, array());
// Filter out any objects which the rule can't apply to.
$target_objects = idx($objects, $class, array());
foreach ($target_objects as $key => $target_object) {
if (!$rule_object->canApplyToObject($target_object)) {
unset($target_objects[$key]);
}
}
$rule_object->willApplyRules(
$viewer,
array_mergev($values[$class]),
$target_objects);
$classes[$class] = $rule_object;
}
foreach ($custom_policies as $policy) {
$policy->attachRuleObjects($classes);
}
if (empty($this->customPolicies[$viewer_phid])) {
$this->customPolicies[$viewer_phid] = array();
}
$this->customPolicies[$viewer->getPHID()] += $custom_policies;
}
private function checkObjectPolicy(
$policy_phid,
PhabricatorPolicyInterface $object) {
$viewer = $this->viewer;
$viewer_phid = $viewer->getPHID();
$rule = idx($this->objectPolicies[$viewer_phid], $policy_phid);
if (!$rule) {
return false;
}
if (!$rule->canApplyToObject($object)) {
return false;
}
return $rule->applyRule($viewer, null, $object);
}
private function checkCustomPolicy(
$policy_phid,
PhabricatorPolicyInterface $object) {
$viewer = $this->viewer;
$viewer_phid = $viewer->getPHID();
$policy = idx($this->customPolicies[$viewer_phid], $policy_phid);
if (!$policy) {
// Reject, this policy is bogus.
return false;
}
$objects = $policy->getRuleObjects();
$action = null;
foreach ($policy->getRules() as $rule) {
if (!is_array($rule)) {
// Reject, this policy rule is invalid.
return false;
}
$rule_object = idx($objects, idx($rule, 'rule'));
if (!$rule_object) {
// Reject, this policy has a bogus rule.
return false;
}
if (!$rule_object->canApplyToObject($object)) {
// Reject, this policy rule can't be applied to the given object.
return false;
}
// If the user matches this rule, use this action.
if ($rule_object->applyRule($viewer, idx($rule, 'value'), $object)) {
$action = idx($rule, 'action');
break;
}
}
if ($action === null) {
$action = $policy->getDefaultAction();
}
if ($action === PhabricatorPolicy::ACTION_ALLOW) {
return true;
}
return false;
}
private function getObjectPolicy(
PhabricatorPolicyInterface $object,
$capability) {
if ($this->forcedPolicy) {
return $this->forcedPolicy;
} else {
return $object->getPolicy($capability);
}
}
private function renderAccessDenied(PhabricatorPolicyInterface $object) {
// NOTE: Not every type of policy object has a real PHID; just load an
// empty handle if a real PHID isn't available.
$phid = nonempty($object->getPHID(), PhabricatorPHIDConstants::PHID_VOID);
$handle = id(new PhabricatorHandleQuery())
->setViewer($this->viewer)
->withPHIDs(array($phid))
->executeOne();
$object_name = $handle->getObjectName();
$is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business');
if ($is_serious) {
$access_denied = pht(
'Access Denied: %s',
$object_name);
} else {
$access_denied = pht(
'You Shall Not Pass: %s',
$object_name);
}
return $access_denied;
}
private function canViewerSeeObjectsInSpace(
PhabricatorUser $viewer,
$space_phid) {
$spaces = PhabricatorSpacesNamespaceQuery::getAllSpaces();
// If there are no spaces, everything exists in an implicit default space
// with no policy controls. This is the default state.
if (!$spaces) {
if ($space_phid !== null) {
return false;
} else {
return true;
}
}
if ($space_phid === null) {
$space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
} else {
$space = idx($spaces, $space_phid);
}
if (!$space) {
return false;
}
// This may be more involved later, but for now being able to see the
// space is equivalent to being able to see everything in it.
return self::hasCapability(
$viewer,
$space,
PhabricatorPolicyCapability::CAN_VIEW);
}
private function rejectObjectFromSpace(
PhabricatorPolicyInterface $object,
$space_phid) {
if (!$this->raisePolicyExceptions) {
return;
}
if ($this->viewer->isOmnipotent()) {
return;
}
$access_denied = $this->renderAccessDenied($object);
$rejection = pht(
'This object is in a space you do not have permission to access.');
$full_message = pht('[%s] %s', $access_denied, $rejection);
$exception = id(new PhabricatorPolicyException($full_message))
->setTitle($access_denied)
->setObjectPHID($object->getPHID())
->setRejection($rejection)
->setCapability(PhabricatorPolicyCapability::CAN_VIEW);
throw $exception;
}
private function applyApplicationChecks(array $objects) {
$viewer = $this->viewer;
foreach ($objects as $key => $object) {
// Don't filter handles: users are allowed to see handles from an
// application they can't see even if they can not see objects from
// that application. Note that the application policies still apply to
// the underlying object, so these will be "Restricted Object" handles.
// If we don't let these through, PhabricatorHandleQuery will completely
// fail to load results for PHIDs that are part of applications which
// the viewer can not see, but a fundamental property of handles is that
// we always load something and they can safely be assumed to load.
if ($object instanceof PhabricatorObjectHandle) {
continue;
}
$phid = $object->getPHID();
if (!$phid) {
continue;
}
$application_class = $this->getApplicationForPHID($phid);
if ($application_class === null) {
continue;
}
$can_see = PhabricatorApplication::isClassInstalledForViewer(
$application_class,
$viewer);
if ($can_see) {
continue;
}
unset($objects[$key]);
$application = newv($application_class, array());
$this->rejectObject(
$application,
$application->getPolicy(PhabricatorPolicyCapability::CAN_VIEW),
PhabricatorPolicyCapability::CAN_VIEW);
}
return $objects;
}
private function getApplicationForPHID($phid) {
static $class_map = array();
$phid_type = phid_get_type($phid);
if (!isset($class_map[$phid_type])) {
$type_objects = PhabricatorPHIDType::getTypes(array($phid_type));
$type_object = idx($type_objects, $phid_type);
if (!$type_object) {
$class = false;
} else {
$class = $type_object->getPHIDTypeApplicationClass();
}
$class_map[$phid_type] = $class;
}
$class = $class_map[$phid_type];
if ($class === false) {
return null;
}
return $class;
}
}
diff --git a/src/applications/project/query/PhabricatorProjectColumnQuery.php b/src/applications/project/query/PhabricatorProjectColumnQuery.php
index 41ea770137..13f2f52a43 100644
--- a/src/applications/project/query/PhabricatorProjectColumnQuery.php
+++ b/src/applications/project/query/PhabricatorProjectColumnQuery.php
@@ -1,166 +1,166 @@
<?php
final class PhabricatorProjectColumnQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $projectPHIDs;
private $proxyPHIDs;
private $statuses;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withProjectPHIDs(array $project_phids) {
$this->projectPHIDs = $project_phids;
return $this;
}
public function withProxyPHIDs(array $proxy_phids) {
$this->proxyPHIDs = $proxy_phids;
return $this;
}
public function withStatuses(array $status) {
$this->statuses = $status;
return $this;
}
public function newResultObject() {
return new PhabricatorProjectColumn();
}
protected function loadPage() {
return $this->loadStandardPage($this->newResultObject());
}
protected function willFilterPage(array $page) {
$projects = array();
$project_phids = array_filter(mpull($page, 'getProjectPHID'));
if ($project_phids) {
$projects = id(new PhabricatorProjectQuery())
->setParentQuery($this)
->setViewer($this->getViewer())
->withPHIDs($project_phids)
->execute();
$projects = mpull($projects, null, 'getPHID');
}
foreach ($page as $key => $column) {
$phid = $column->getProjectPHID();
$project = idx($projects, $phid);
if (!$project) {
$this->didRejectResult($page[$key]);
unset($page[$key]);
continue;
}
$column->attachProject($project);
}
$proxy_phids = array_filter(mpull($page, 'getProjectPHID'));
return $page;
}
protected function didFilterPage(array $page) {
$proxy_phids = array();
foreach ($page as $column) {
$proxy_phid = $column->getProxyPHID();
if ($proxy_phid !== null) {
$proxy_phids[$proxy_phid] = $proxy_phid;
}
}
if ($proxy_phids) {
$proxies = id(new PhabricatorObjectQuery())
->setParentQuery($this)
->setViewer($this->getViewer())
->withPHIDs($proxy_phids)
->execute();
$proxies = mpull($proxies, null, 'getPHID');
} else {
$proxies = array();
}
foreach ($page as $key => $column) {
$proxy_phid = $column->getProxyPHID();
if ($proxy_phid !== null) {
$proxy = idx($proxies, $proxy_phid);
- // Only attach valid proxies, so we don't end up getting surprsied if
+ // Only attach valid proxies, so we don't end up getting surprised if
// an install somehow gets junk into their database.
if (!($proxy instanceof PhabricatorColumnProxyInterface)) {
$proxy = null;
}
if (!$proxy) {
$this->didRejectResult($column);
unset($page[$key]);
continue;
}
} else {
$proxy = null;
}
$column->attachProxy($proxy);
}
return $page;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'phid IN (%Ls)',
$this->phids);
}
if ($this->projectPHIDs !== null) {
$where[] = qsprintf(
$conn,
'projectPHID IN (%Ls)',
$this->projectPHIDs);
}
if ($this->proxyPHIDs !== null) {
$where[] = qsprintf(
$conn,
'proxyPHID IN (%Ls)',
$this->proxyPHIDs);
}
if ($this->statuses !== null) {
$where[] = qsprintf(
$conn,
'status IN (%Ld)',
$this->statuses);
}
return $where;
}
public function getQueryApplicationClass() {
return 'PhabricatorProjectApplication';
}
}
diff --git a/src/applications/project/remarkup/ProjectRemarkupRule.php b/src/applications/project/remarkup/ProjectRemarkupRule.php
index 70d6b8eda3..96092bab86 100644
--- a/src/applications/project/remarkup/ProjectRemarkupRule.php
+++ b/src/applications/project/remarkup/ProjectRemarkupRule.php
@@ -1,76 +1,76 @@
<?php
final class ProjectRemarkupRule extends PhabricatorObjectRemarkupRule {
protected function getObjectNamePrefix() {
return '#';
}
protected function renderObjectRef(
$object,
PhabricatorObjectHandle $handle,
$anchor,
$id) {
if ($this->getEngine()->isTextMode()) {
return '#'.$id;
}
$tag = $handle->renderTag();
$tag->setPHID($handle->getPHID());
return $tag;
}
protected function getObjectIDPattern() {
// NOTE: The latter half of this rule matches monograms with internal
// periods, like `#domain.com`, but does not match monograms with terminal
- // periods, because they're probably just puncutation.
+ // periods, because they're probably just punctuation.
// Broadly, this will not match every possible project monogram, and we
// accept some false negatives -- like `#dot.` -- in order to avoid a bunch
// of false positives on general use of the `#` character.
// In other contexts, the PhabricatorProjectProjectPHIDType pattern is
// controlling and these names should parse correctly.
// These characters may never appear anywhere in a hashtag.
$never = '\s?!,:;{}#\\(\\)"\'\\*/~';
// These characters may not appear at the edge of the string.
$never_edge = '.';
return
'[^'.$never_edge.$never.']+'.
'(?:'.
'[^'.$never.']*'.
'[^'.$never_edge.$never.']+'.
')*';
}
protected function loadObjects(array $ids) {
$viewer = $this->getEngine()->getConfig('viewer');
// Put the "#" back on the front of these IDs.
$names = array();
foreach ($ids as $id) {
$names[] = '#'.$id;
}
// Issue a query by object name.
$query = id(new PhabricatorObjectQuery())
->setViewer($viewer)
->withNames($names);
$query->execute();
$projects = $query->getNamedResults();
// Slice the "#" off again.
$result = array();
foreach ($projects as $name => $project) {
$result[substr($name, 1)] = $project;
}
return $result;
}
}
diff --git a/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php b/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php
index dd67f5bc59..56d371f1d6 100644
--- a/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php
+++ b/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php
@@ -1,384 +1,384 @@
<?php
/**
* This DifferentialFieldSpecification exists for two reason:
*
* 1: To parse "Releeph: picks RQ<nn>" headers in commits created by
* arc-releeph so that RQs committed by arc-releeph have real
- * PhabricatorRepositoryCommits associated with them (instaed of just the SHA
+ * PhabricatorRepositoryCommits associated with them (instead of just the SHA
* of the commit, as seen by the pusher).
*
* 2: If requestors want to commit directly to their release branch, they can
* use this header to (i) indicate on a differential revision that this
* differential revision is for the release branch, and (ii) when they land
* their diff on to the release branch manually, the ReleephRequest is
* automatically updated (instead of having to use the "Mark Manually Picked"
* button.)
*
*/
final class DifferentialReleephRequestFieldSpecification extends Phobject {
// TODO: This class is essentially dead right now, see T2222.
const ACTION_PICKS = 'picks';
const ACTION_REVERTS = 'reverts';
private $releephAction;
private $releephPHIDs = array();
public function getStorageKey() {
return 'releeph:actions';
}
public function getValueForStorage() {
return json_encode(array(
'releephAction' => $this->releephAction,
'releephPHIDs' => $this->releephPHIDs,
));
}
public function setValueFromStorage($json) {
if ($json) {
$dict = phutil_json_decode($json);
$this->releephAction = idx($dict, 'releephAction');
$this->releephPHIDs = idx($dict, 'releephPHIDs');
}
return $this;
}
public function shouldAppearOnRevisionView() {
return true;
}
public function renderLabelForRevisionView() {
return pht('Releeph');
}
public function getRequiredHandlePHIDs() {
return mpull($this->loadReleephRequests(), 'getPHID');
}
public function renderValueForRevisionView() {
static $tense;
if ($tense === null) {
$tense = array(
self::ACTION_PICKS => array(
'future' => pht('Will pick'),
'past' => pht('Picked'),
),
self::ACTION_REVERTS => array(
'future' => pht('Will revert'),
'past' => pht('Reverted'),
),
);
}
$releeph_requests = $this->loadReleephRequests();
if (!$releeph_requests) {
return null;
}
if ($this->getRevision()->isClosed()) {
$verb = $tense[$this->releephAction]['past'];
} else {
$verb = $tense[$this->releephAction]['future'];
}
$parts = hsprintf('%s...', $verb);
foreach ($releeph_requests as $releeph_request) {
$parts->appendHTML(phutil_tag('br'));
$parts->appendHTML(
$this->getHandle($releeph_request->getPHID())->renderLink());
}
return $parts;
}
public function shouldAppearOnCommitMessage() {
return true;
}
public function getCommitMessageKey() {
return 'releephActions';
}
public function setValueFromParsedCommitMessage($dict) {
$this->releephAction = $dict['releephAction'];
$this->releephPHIDs = $dict['releephPHIDs'];
return $this;
}
public function renderValueForCommitMessage($is_edit) {
$releeph_requests = $this->loadReleephRequests();
if (!$releeph_requests) {
return null;
}
$parts = array($this->releephAction);
foreach ($releeph_requests as $releeph_request) {
$parts[] = 'RQ'.$releeph_request->getID();
}
return implode(' ', $parts);
}
/**
* Releeph fields should look like:
*
* Releeph: picks RQ1 RQ2, RQ3
* Releeph: reverts RQ1
*/
public function parseValueFromCommitMessage($value) {
/**
* Releeph commit messages look like this (but with more blank lines,
* omitted here):
*
* Make CaptainHaddock more reasonable
* Releeph: picks RQ1
* Requested By: edward
* Approved By: edward (requestor)
* Request Reason: x
* Summary: Make the Haddock implementation more reasonable.
* Test Plan: none
* Reviewers: user1
*
* Some of these fields are recognized by Differential (e.g. "Requested
* By"). They are folded up into the "Releeph" field, parsed by this
* class. As such $value includes more than just the first-line:
*
* "picks RQ1\n\nRequested By: edward\n\nApproved By: edward (requestor)"
*
* To hack around this, just consider the first line of $value when
* determining what Releeph actions the parsed commit is performing.
*/
$first_line = head(array_filter(explode("\n", $value)));
$tokens = preg_split('/\s*,?\s+/', $first_line);
$raw_action = array_shift($tokens);
$action = strtolower($raw_action);
if (!$action) {
return null;
}
switch ($action) {
case self::ACTION_REVERTS:
case self::ACTION_PICKS:
break;
default:
throw new DifferentialFieldParseException(
pht(
"Commit message contains unknown Releeph action '%s'!",
$raw_action));
break;
}
$releeph_requests = array();
foreach ($tokens as $token) {
$match = array();
if (!preg_match('/^(?:RQ)?(\d+)$/i', $token, $match)) {
$label = $this->renderLabelForCommitMessage();
throw new DifferentialFieldParseException(
pht(
"Commit message contains unparseable ".
"Releeph request token '%s'!",
$token));
}
$id = (int)$match[1];
$releeph_request = id(new ReleephRequest())->load($id);
if (!$releeph_request) {
throw new DifferentialFieldParseException(
pht(
'Commit message references non existent Releeph request: %s!',
$value));
}
$releeph_requests[] = $releeph_request;
}
if (count($releeph_requests) > 1) {
$rqs_seen = array();
$groups = array();
foreach ($releeph_requests as $releeph_request) {
$releeph_branch = $releeph_request->getBranch();
$branch_name = $releeph_branch->getName();
$rq_id = 'RQ'.$releeph_request->getID();
if (idx($rqs_seen, $rq_id)) {
throw new DifferentialFieldParseException(
pht(
'Commit message refers to %s multiple times!',
$rq_id));
}
$rqs_seen[$rq_id] = true;
if (!isset($groups[$branch_name])) {
$groups[$branch_name] = array();
}
$groups[$branch_name][] = $rq_id;
}
if (count($groups) > 1) {
$lists = array();
foreach ($groups as $branch_name => $rq_ids) {
$lists[] = implode(', ', $rq_ids).' in '.$branch_name;
}
throw new DifferentialFieldParseException(
pht(
'Commit message references multiple Releeph requests, '.
'but the requests are in different branches: %s',
implode('; ', $lists)));
}
}
$phids = mpull($releeph_requests, 'getPHID');
$data = array(
'releephAction' => $action,
'releephPHIDs' => $phids,
);
return $data;
}
public function renderLabelForCommitMessage() {
return pht('Releeph');
}
public function shouldAppearOnCommitMessageTemplate() {
return false;
}
public function didParseCommit(
PhabricatorRepository $repo,
PhabricatorRepositoryCommit $commit,
PhabricatorRepositoryCommitData $data) {
// NOTE: This is currently dead code. See T2222.
$releeph_requests = $this->loadReleephRequests();
if (!$releeph_requests) {
return;
}
$releeph_branch = head($releeph_requests)->getBranch();
if (!$this->isCommitOnBranch($repo, $commit, $releeph_branch)) {
return;
}
foreach ($releeph_requests as $releeph_request) {
if ($this->releephAction === self::ACTION_PICKS) {
$action = 'pick';
} else {
$action = 'revert';
}
$actor_phid = coalesce(
$data->getCommitDetail('committerPHID'),
$data->getCommitDetail('authorPHID'));
$actor = id(new PhabricatorUser())
->loadOneWhere('phid = %s', $actor_phid);
$xactions = array();
$xactions[] = id(new ReleephRequestTransaction())
->setTransactionType(ReleephRequestTransaction::TYPE_DISCOVERY)
->setMetadataValue('action', $action)
->setMetadataValue('authorPHID',
$data->getCommitDetail('authorPHID'))
->setMetadataValue('committerPHID',
$data->getCommitDetail('committerPHID'))
->setNewValue($commit->getPHID());
$editor = id(new ReleephRequestTransactionalEditor())
->setActor($actor)
->setContinueOnNoEffect(true)
->setContentSource(
PhabricatorContentSource::newForSource(
PhabricatorUnknownContentSource::SOURCECONST));
$editor->applyTransactions($releeph_request, $xactions);
}
}
private function loadReleephRequests() {
if (!$this->releephPHIDs) {
return array();
}
return id(new ReleephRequestQuery())
->setViewer($this->getViewer())
->withPHIDs($this->releephPHIDs)
->execute();
}
private function isCommitOnBranch(
PhabricatorRepository $repo,
PhabricatorRepositoryCommit $commit,
ReleephBranch $releeph_branch) {
switch ($repo->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
list($output) = $repo->execxLocalCommand(
'branch --all --no-color --contains %s',
$commit->getCommitIdentifier());
$remote_prefix = 'remotes/origin/';
$branches = array();
foreach (array_filter(explode("\n", $output)) as $line) {
$tokens = explode(' ', $line);
$ref = last($tokens);
if (strncmp($ref, $remote_prefix, strlen($remote_prefix)) === 0) {
$branch = substr($ref, strlen($remote_prefix));
$branches[$branch] = $branch;
}
}
return idx($branches, $releeph_branch->getName());
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$change_query = DiffusionPathChangeQuery::newFromDiffusionRequest(
DiffusionRequest::newFromDictionary(array(
'user' => $this->getUser(),
'repository' => $repo,
'commit' => $commit->getCommitIdentifier(),
)));
$path_changes = $change_query->loadChanges();
$commit_paths = mpull($path_changes, 'getPath');
$branch_path = $releeph_branch->getName();
$in_branch = array();
$ex_branch = array();
foreach ($commit_paths as $path) {
if (strncmp($path, $branch_path, strlen($branch_path)) === 0) {
$in_branch[] = $path;
} else {
$ex_branch[] = $path;
}
}
if ($in_branch && $ex_branch) {
$error = pht(
'CONFUSION: commit %s in %s contains %d path change(s) that were '.
'part of a Releeph branch, but also has %d path change(s) not '.
'part of a Releeph branch!',
$commit->getCommitIdentifier(),
$repo->getDisplayName(),
count($in_branch),
count($ex_branch));
phlog($error);
}
return !empty($in_branch);
break;
}
}
}
diff --git a/src/applications/releeph/field/selector/ReleephDefaultFieldSelector.php b/src/applications/releeph/field/selector/ReleephDefaultFieldSelector.php
index df5e075ebc..af18bfd3d6 100644
--- a/src/applications/releeph/field/selector/ReleephDefaultFieldSelector.php
+++ b/src/applications/releeph/field/selector/ReleephDefaultFieldSelector.php
@@ -1,71 +1,71 @@
<?php
final class ReleephDefaultFieldSelector extends ReleephFieldSelector {
/**
* Determine if this install is Facebook.
*
* TODO: This is a giant hacky mess because I am dumb and moved forward on
* Releeph changes with partial information. Recover from this as gracefully
- * as possible. This obivously is an abomination. -epriestley
+ * as possible. This obviously is an abomination. -epriestley
*/
public static function isFacebook() {
return class_exists('ReleephFacebookKarmaFieldSpecification');
}
/**
* @phutil-external-symbol class ReleephFacebookKarmaFieldSpecification
* @phutil-external-symbol class ReleephFacebookSeverityFieldSpecification
* @phutil-external-symbol class ReleephFacebookTagFieldSpecification
* @phutil-external-symbol class ReleephFacebookTasksFieldSpecification
*/
public function getFieldSpecifications() {
if (self::isFacebook()) {
return array(
new ReleephCommitMessageFieldSpecification(),
new ReleephSummaryFieldSpecification(),
new ReleephReasonFieldSpecification(),
new ReleephAuthorFieldSpecification(),
new ReleephRevisionFieldSpecification(),
new ReleephRequestorFieldSpecification(),
new ReleephFacebookKarmaFieldSpecification(),
new ReleephFacebookSeverityFieldSpecification(),
new ReleephOriginalCommitFieldSpecification(),
new ReleephDiffMessageFieldSpecification(),
new ReleephIntentFieldSpecification(),
new ReleephBranchCommitFieldSpecification(),
new ReleephDiffSizeFieldSpecification(),
new ReleephDiffChurnFieldSpecification(),
new ReleephDependsOnFieldSpecification(),
new ReleephFacebookTagFieldSpecification(),
new ReleephFacebookTasksFieldSpecification(),
);
} else {
return array(
new ReleephCommitMessageFieldSpecification(),
new ReleephSummaryFieldSpecification(),
new ReleephReasonFieldSpecification(),
new ReleephAuthorFieldSpecification(),
new ReleephRevisionFieldSpecification(),
new ReleephRequestorFieldSpecification(),
new ReleephSeverityFieldSpecification(),
new ReleephOriginalCommitFieldSpecification(),
new ReleephDiffMessageFieldSpecification(),
new ReleephIntentFieldSpecification(),
new ReleephBranchCommitFieldSpecification(),
new ReleephDiffSizeFieldSpecification(),
new ReleephDiffChurnFieldSpecification(),
);
}
}
public function sortFieldsForCommitMessage(array $fields) {
return self::selectFields($fields, array(
'ReleephCommitMessageFieldSpecification',
'ReleephRequestorFieldSpecification',
'ReleephIntentFieldSpecification',
'ReleephReasonFieldSpecification',
));
}
}
diff --git a/src/applications/repository/daemon/PhabricatorMercurialGraphStream.php b/src/applications/repository/daemon/PhabricatorMercurialGraphStream.php
index 33f947e2f8..205b267ed5 100644
--- a/src/applications/repository/daemon/PhabricatorMercurialGraphStream.php
+++ b/src/applications/repository/daemon/PhabricatorMercurialGraphStream.php
@@ -1,161 +1,161 @@
<?php
/**
* Streaming interface on top of "hg log" that gives us performant access to
* the Mercurial commit graph with one nonblocking invocation of "hg". See
* @{class:PhabricatorRepositoryPullLocalDaemon}.
*/
final class PhabricatorMercurialGraphStream
extends PhabricatorRepositoryGraphStream {
private $repository;
private $iterator;
private $parents = array();
private $dates = array();
private $local = array();
private $localParents = array();
public function __construct(PhabricatorRepository $repository, $commit) {
$this->repository = $repository;
$future = $repository->getLocalCommandFuture(
'log --template %s --rev %s',
'{rev}\1{node}\1{date}\1{parents}\2',
hgsprintf('reverse(ancestors(%s))', $commit));
$this->iterator = new LinesOfALargeExecFuture($future);
$this->iterator->setDelimiter("\2");
$this->iterator->rewind();
}
public function getParents($commit) {
if (!isset($this->parents[$commit])) {
$this->parseUntil('node', $commit);
$local = $this->localParents[$commit];
// The normal parsing pass gives us the local revision numbers of the
// parents, but since we've decided we care about this data, we need to
// convert them into full hashes. To do this, we parse to the deepest
// one and then just look them up.
$parents = array();
if ($local) {
$this->parseUntil('rev', min($local));
foreach ($local as $rev) {
$parents[] = $this->local[$rev];
}
}
$this->parents[$commit] = $parents;
// Throw away the local info for this commit, we no longer need it.
unset($this->localParents[$commit]);
}
return $this->parents[$commit];
}
public function getCommitDate($commit) {
if (!isset($this->dates[$commit])) {
$this->parseUntil('node', $commit);
}
return $this->dates[$commit];
}
/**
* Parse until we have consumed some object. There are two types of parses:
* parse until we find a commit hash ($until_type = "node"), or parse until we
* find a local commit number ($until_type = "rev"). We use the former when
* looking up commits, and the latter when resolving parents.
*/
private function parseUntil($until_type, $until_name) {
if ($this->isParsed($until_type, $until_name)) {
return;
}
$hglog = $this->iterator;
while ($hglog->valid()) {
$line = $hglog->current();
$hglog->next();
$line = trim($line);
if (!strlen($line)) {
break;
}
list($rev, $node, $date, $parents) = explode("\1", $line);
$rev = (int)$rev;
$date = (int)head(explode('.', $date));
$this->dates[$node] = $date;
$this->local[$rev] = $node;
$this->localParents[$node] = $this->parseParents($parents, $rev);
if ($this->isParsed($until_type, $until_name)) {
return;
}
}
throw new Exception(
pht(
"No such %s '%s' in repository!",
$until_type,
$until_name));
}
/**
* Parse a {parents} template, returning the local commit numbers.
*/
private function parseParents($parents, $target_rev) {
// The hg '{parents}' token is empty if there is one "natural" parent
- // (predecessor local commit ID). Othwerwise, it may have one or two
+ // (predecessor local commit ID). Otherwise, it may have one or two
// parents. The string looks like this:
//
// 151:1f6c61a60586 154:1d5f799ebe1e
$parents = trim($parents);
if (strlen($parents)) {
$local = array();
$parents = explode(' ', $parents);
foreach ($parents as $key => $parent) {
$parent = (int)head(explode(':', $parent));
if ($parent == -1) {
// Initial commits will sometimes have "-1" as a parent.
continue;
}
$local[] = $parent;
}
} else if ($target_rev) {
// We have empty parents. If there's a predecessor, that's the local
// parent number.
$local = array($target_rev - 1);
} else {
// Initial commits will sometimes have no parents.
$local = array();
}
return $local;
}
/**
* Returns true if the object specified by $type ('rev' or 'node') and
* $name (rev or node name) has been consumed from the hg process.
*/
private function isParsed($type, $name) {
switch ($type) {
case 'rev':
return isset($this->local[$name]);
case 'node':
return isset($this->dates[$name]);
}
}
}
diff --git a/src/applications/repository/data/PhabricatorRepositoryURINormalizer.php b/src/applications/repository/data/PhabricatorRepositoryURINormalizer.php
index 27e93535ab..a29d110a67 100644
--- a/src/applications/repository/data/PhabricatorRepositoryURINormalizer.php
+++ b/src/applications/repository/data/PhabricatorRepositoryURINormalizer.php
@@ -1,140 +1,140 @@
<?php
/**
* Normalize repository URIs. For example, these URIs are generally equivalent
* and all point at the same repository:
*
* ssh://user@host/repo
* ssh://user@host/repo/
* ssh://user@host:22/repo
* user@host:/repo
* ssh://user@host/repo.git
*
* This class can be used to normalize URIs like this, in order to detect
* alternate spellings of the same repository URI. In particular, the
* @{method:getNormalizedPath} method will return:
*
* repo
*
* ...for all of these URIs. Generally, usage looks like this:
*
* $norm_a = new PhabricatorRepositoryURINormalizer($type, $uri_a);
* $norm_b = new PhabricatorRepositoryURINormalizer($type, $uri_b);
*
* if ($norm_a->getNormalizedPath() == $norm_b->getNormalizedPath()) {
* // URIs appear to point at the same repository.
* } else {
* // URIs are very unlikely to be the same repository.
* }
*
- * Because a repository can be hosted at arbitrarly many arbitrary URIs, there
+ * Because a repository can be hosted at arbitrarily many arbitrary URIs, there
* is no way to completely prevent false negatives by only examining URIs
* (that is, repositories with totally different URIs could really be the same).
- * However, normalization is relatively agressive and false negatives should
+ * However, normalization is relatively aggressive and false negatives should
* be rare: if normalization says two URIs are different repositories, they
* probably are.
*
* @task normal Normalizing URIs
*/
final class PhabricatorRepositoryURINormalizer extends Phobject {
const TYPE_GIT = 'git';
const TYPE_SVN = 'svn';
const TYPE_MERCURIAL = 'hg';
private $type;
private $uri;
public function __construct($type, $uri) {
switch ($type) {
case self::TYPE_GIT:
case self::TYPE_SVN:
case self::TYPE_MERCURIAL:
break;
default:
throw new Exception(pht('Unknown URI type "%s"!', $type));
}
$this->type = $type;
$this->uri = $uri;
}
public static function getAllURITypes() {
return array(
self::TYPE_GIT,
self::TYPE_SVN,
self::TYPE_MERCURIAL,
);
}
/* -( Normalizing URIs )--------------------------------------------------- */
/**
* @task normal
*/
public function getPath() {
switch ($this->type) {
case self::TYPE_GIT:
$uri = new PhutilURI($this->uri);
return $uri->getPath();
case self::TYPE_SVN:
case self::TYPE_MERCURIAL:
$uri = new PhutilURI($this->uri);
if ($uri->getProtocol()) {
return $uri->getPath();
}
return $this->uri;
}
}
public function getNormalizedURI() {
return $this->getNormalizedDomain().'/'.$this->getNormalizedPath();
}
/**
* @task normal
*/
public function getNormalizedPath() {
$path = $this->getPath();
$path = trim($path, '/');
switch ($this->type) {
case self::TYPE_GIT:
$path = preg_replace('/\.git$/', '', $path);
break;
case self::TYPE_SVN:
case self::TYPE_MERCURIAL:
break;
}
// If this is a Phabricator URI, strip it down to the callsign. We mutably
// allow you to clone repositories as "/diffusion/X/anything.git", for
// example.
$matches = null;
if (preg_match('@^(diffusion/(?:[A-Z]+|\d+))@', $path, $matches)) {
$path = $matches[1];
}
return $path;
}
public function getNormalizedDomain() {
$domain = null;
$uri = new PhutilURI($this->uri);
$domain = $uri->getDomain();
if (!strlen($domain)) {
$domain = '<void>';
}
return phutil_utf8_strtolower($domain);
}
}
diff --git a/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php b/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php
index d0b84a6b31..8e99aabafa 100644
--- a/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php
+++ b/src/applications/repository/engine/PhabricatorRepositoryDiscoveryEngine.php
@@ -1,939 +1,939 @@
<?php
/**
* @task discover Discovering Repositories
* @task svn Discovering Subversion Repositories
* @task git Discovering Git Repositories
* @task hg Discovering Mercurial Repositories
* @task internal Internals
*/
final class PhabricatorRepositoryDiscoveryEngine
extends PhabricatorRepositoryEngine {
private $repairMode;
private $commitCache = array();
private $workingSet = array();
const MAX_COMMIT_CACHE_SIZE = 65535;
/* -( Discovering Repositories )------------------------------------------- */
public function setRepairMode($repair_mode) {
$this->repairMode = $repair_mode;
return $this;
}
public function getRepairMode() {
return $this->repairMode;
}
/**
* @task discovery
*/
public function discoverCommits() {
$repository = $this->getRepository();
$lock = $this->newRepositoryLock($repository, 'repo.look', false);
try {
$lock->lock();
} catch (PhutilLockException $ex) {
throw new DiffusionDaemonLockException(
pht(
'Another process is currently discovering repository "%s", '.
'skipping discovery.',
$repository->getDisplayName()));
}
try {
$result = $this->discoverCommitsWithLock();
} catch (Exception $ex) {
$lock->unlock();
throw $ex;
}
$lock->unlock();
return $result;
}
private function discoverCommitsWithLock() {
$repository = $this->getRepository();
$viewer = $this->getViewer();
$vcs = $repository->getVersionControlSystem();
switch ($vcs) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$refs = $this->discoverSubversionCommits();
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
$refs = $this->discoverMercurialCommits();
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
$refs = $this->discoverGitCommits();
break;
default:
throw new Exception(pht("Unknown VCS '%s'!", $vcs));
}
if ($this->isInitialImport($refs)) {
$this->log(
pht(
'Discovered more than %s commit(s) in an empty repository, '.
'marking repository as importing.',
new PhutilNumber(PhabricatorRepository::IMPORT_THRESHOLD)));
$repository->markImporting();
}
// Clear the working set cache.
$this->workingSet = array();
// Record discovered commits and mark them in the cache.
foreach ($refs as $ref) {
$this->recordCommit(
$repository,
$ref->getIdentifier(),
$ref->getEpoch(),
$ref->getCanCloseImmediately(),
$ref->getParents());
$this->commitCache[$ref->getIdentifier()] = true;
}
$this->markUnreachableCommits($repository);
$version = $this->getObservedVersion($repository);
if ($version !== null) {
id(new DiffusionRepositoryClusterEngine())
->setViewer($viewer)
->setRepository($repository)
->synchronizeWorkingCopyAfterDiscovery($version);
}
return $refs;
}
/* -( Discovering Git Repositories )--------------------------------------- */
/**
* @task git
*/
private function discoverGitCommits() {
$repository = $this->getRepository();
if (!$repository->isHosted()) {
$this->verifyGitOrigin($repository);
}
$heads = id(new DiffusionLowLevelGitRefQuery())
->setRepository($repository)
->execute();
if (!$heads) {
// This repository has no heads at all, so we don't need to do
// anything. Generally, this means the repository is empty.
return array();
}
$heads = $this->sortRefs($heads);
$head_commits = mpull($heads, 'getCommitIdentifier');
$this->log(
pht(
'Discovering commits in repository "%s".',
$repository->getDisplayName()));
$this->fillCommitCache($head_commits);
$refs = array();
foreach ($heads as $ref) {
$name = $ref->getShortName();
$commit = $ref->getCommitIdentifier();
$this->log(
pht(
'Examining "%s" (%s) at "%s".',
$name,
$ref->getRefType(),
$commit));
if (!$repository->shouldTrackRef($ref)) {
$this->log(pht('Skipping, ref is untracked.'));
continue;
}
if ($this->isKnownCommit($commit)) {
$this->log(pht('Skipping, HEAD is known.'));
continue;
}
// In Git, it's possible to tag anything. We just skip tags that don't
// point to a commit. See T11301.
$fields = $ref->getRawFields();
$ref_type = idx($fields, 'objecttype');
$tag_type = idx($fields, '*objecttype');
if ($ref_type != 'commit' && $tag_type != 'commit') {
$this->log(pht('Skipping, this is not a commit.'));
continue;
}
$this->log(pht('Looking for new commits.'));
$head_refs = $this->discoverStreamAncestry(
new PhabricatorGitGraphStream($repository, $commit),
$commit,
$repository->shouldAutocloseRef($ref));
$this->didDiscoverRefs($head_refs);
$refs[] = $head_refs;
}
return array_mergev($refs);
}
/* -( Discovering Subversion Repositories )-------------------------------- */
/**
* @task svn
*/
private function discoverSubversionCommits() {
$repository = $this->getRepository();
if (!$repository->isHosted()) {
$this->verifySubversionRoot($repository);
}
$upper_bound = null;
$limit = 1;
$refs = array();
do {
// Find all the unknown commits on this path. Note that we permit
// importing an SVN subdirectory rather than the entire repository, so
// commits may be nonsequential.
if ($upper_bound === null) {
$at_rev = 'HEAD';
} else {
$at_rev = ($upper_bound - 1);
}
try {
list($xml, $stderr) = $repository->execxRemoteCommand(
'log --xml --quiet --limit %d %s',
$limit,
$repository->getSubversionBaseURI($at_rev));
} catch (CommandException $ex) {
$stderr = $ex->getStderr();
if (preg_match('/(path|File) not found/', $stderr)) {
// We've gone all the way back through history and this path was not
// affected by earlier commits.
break;
}
throw $ex;
}
$xml = phutil_utf8ize($xml);
$log = new SimpleXMLElement($xml);
foreach ($log->logentry as $entry) {
$identifier = (int)$entry['revision'];
$epoch = (int)strtotime((string)$entry->date[0]);
$refs[$identifier] = id(new PhabricatorRepositoryCommitRef())
->setIdentifier($identifier)
->setEpoch($epoch)
->setCanCloseImmediately(true);
if ($upper_bound === null) {
$upper_bound = $identifier;
} else {
$upper_bound = min($upper_bound, $identifier);
}
}
// Discover 2, 4, 8, ... 256 logs at a time. This allows us to initially
// import large repositories fairly quickly, while pulling only as much
// data as we need in the common case (when we've already imported the
// repository and are just grabbing one commit at a time).
$limit = min($limit * 2, 256);
} while ($upper_bound > 1 && !$this->isKnownCommit($upper_bound));
krsort($refs);
while ($refs && $this->isKnownCommit(last($refs)->getIdentifier())) {
array_pop($refs);
}
$refs = array_reverse($refs);
$this->didDiscoverRefs($refs);
return $refs;
}
private function verifySubversionRoot(PhabricatorRepository $repository) {
list($xml) = $repository->execxRemoteCommand(
'info --xml %s',
$repository->getSubversionPathURI());
$xml = phutil_utf8ize($xml);
$xml = new SimpleXMLElement($xml);
$remote_root = (string)($xml->entry[0]->repository[0]->root[0]);
$expect_root = $repository->getSubversionPathURI();
$normal_type_svn = PhabricatorRepositoryURINormalizer::TYPE_SVN;
$remote_normal = id(new PhabricatorRepositoryURINormalizer(
$normal_type_svn,
$remote_root))->getNormalizedPath();
$expect_normal = id(new PhabricatorRepositoryURINormalizer(
$normal_type_svn,
$expect_root))->getNormalizedPath();
if ($remote_normal != $expect_normal) {
throw new Exception(
pht(
'Repository "%s" does not have a correctly configured remote URI. '.
'The remote URI for a Subversion repository MUST point at the '.
'repository root. The root for this repository is "%s", but the '.
'configured URI is "%s". To resolve this error, set the remote URI '.
'to point at the repository root. If you want to import only part '.
'of a Subversion repository, use the "Import Only" option.',
$repository->getDisplayName(),
$remote_root,
$expect_root));
}
}
/* -( Discovering Mercurial Repositories )--------------------------------- */
/**
* @task hg
*/
private function discoverMercurialCommits() {
$repository = $this->getRepository();
$branches = id(new DiffusionLowLevelMercurialBranchesQuery())
->setRepository($repository)
->execute();
$this->fillCommitCache(mpull($branches, 'getCommitIdentifier'));
$refs = array();
foreach ($branches as $branch) {
// NOTE: Mercurial branches may have multiple heads, so the names may
// not be unique.
$name = $branch->getShortName();
$commit = $branch->getCommitIdentifier();
$this->log(pht('Examining branch "%s" head "%s".', $name, $commit));
if (!$repository->shouldTrackBranch($name)) {
$this->log(pht('Skipping, branch is untracked.'));
continue;
}
if ($this->isKnownCommit($commit)) {
$this->log(pht('Skipping, this head is a known commit.'));
continue;
}
$this->log(pht('Looking for new commits.'));
$branch_refs = $this->discoverStreamAncestry(
new PhabricatorMercurialGraphStream($repository, $commit),
$commit,
$close_immediately = true);
$this->didDiscoverRefs($branch_refs);
$refs[] = $branch_refs;
}
return array_mergev($refs);
}
/* -( Internals )---------------------------------------------------------- */
private function discoverStreamAncestry(
PhabricatorRepositoryGraphStream $stream,
$commit,
$close_immediately) {
$discover = array($commit);
$graph = array();
$seen = array();
// Find all the reachable, undiscovered commits. Build a graph of the
// edges.
while ($discover) {
$target = array_pop($discover);
if (empty($graph[$target])) {
$graph[$target] = array();
}
$parents = $stream->getParents($target);
foreach ($parents as $parent) {
if ($this->isKnownCommit($parent)) {
continue;
}
$graph[$target][$parent] = true;
if (empty($seen[$parent])) {
$seen[$parent] = true;
$discover[] = $parent;
}
}
}
// Now, sort them topographically.
$commits = $this->reduceGraph($graph);
$refs = array();
foreach ($commits as $commit) {
$epoch = $stream->getCommitDate($commit);
// If the epoch doesn't fit into a uint32, treat it as though it stores
// the current time. For discussion, see T11537.
if ($epoch > 0xFFFFFFFF) {
$epoch = PhabricatorTime::getNow();
}
// If the epoch is not present at all, treat it as though it stores the
// value "0". For discussion, see T12062. This behavior is consistent
// with the behavior of "git show".
if (!strlen($epoch)) {
$epoch = 0;
}
$refs[] = id(new PhabricatorRepositoryCommitRef())
->setIdentifier($commit)
->setEpoch($epoch)
->setCanCloseImmediately($close_immediately)
->setParents($stream->getParents($commit));
}
return $refs;
}
private function reduceGraph(array $edges) {
foreach ($edges as $commit => $parents) {
$edges[$commit] = array_keys($parents);
}
$graph = new PhutilDirectedScalarGraph();
$graph->addNodes($edges);
$commits = $graph->getTopographicallySortedNodes();
// NOTE: We want the most ancestral nodes first, so we need to reverse the
// list we get out of AbstractDirectedGraph.
$commits = array_reverse($commits);
return $commits;
}
private function isKnownCommit($identifier) {
if (isset($this->commitCache[$identifier])) {
return true;
}
if (isset($this->workingSet[$identifier])) {
return true;
}
if ($this->repairMode) {
// In repair mode, rediscover the entire repository, ignoring the
// database state. We can hit the local cache above, but if we miss it
// stop the script from going to the database cache.
return false;
}
$this->fillCommitCache(array($identifier));
return isset($this->commitCache[$identifier]);
}
private function fillCommitCache(array $identifiers) {
if (!$identifiers) {
return;
}
$max_size = self::MAX_COMMIT_CACHE_SIZE;
// If we're filling more identifiers than would fit in the cache, ignore
// the ones that don't fit. Because the cache is FIFO, overfilling it can
// cause the entire cache to miss. See T12296.
if (count($identifiers) > $max_size) {
$identifiers = array_slice($identifiers, 0, $max_size);
}
// When filling the cache we ignore commits which have been marked as
// unreachable, treating them as though they do not exist. When recording
// commits later we'll revive commits that exist but are unreachable.
$commits = id(new PhabricatorRepositoryCommit())->loadAllWhere(
'repositoryID = %d AND commitIdentifier IN (%Ls)
AND (importStatus & %d) != %d',
$this->getRepository()->getID(),
$identifiers,
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE,
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE);
foreach ($commits as $commit) {
$this->commitCache[$commit->getCommitIdentifier()] = true;
}
while (count($this->commitCache) > $max_size) {
array_shift($this->commitCache);
}
}
/**
* Sort branches so we process closeable branches first. This makes the
* whole import process a little cheaper, since we can close these commits
* the first time through rather than catching them in the refs step.
*
* @task internal
*
* @param list<DiffusionRepositoryRef> List of refs.
* @return list<DiffusionRepositoryRef> Sorted list of refs.
*/
private function sortRefs(array $refs) {
$repository = $this->getRepository();
$head_refs = array();
$tail_refs = array();
foreach ($refs as $ref) {
if ($repository->shouldAutocloseRef($ref)) {
$head_refs[] = $ref;
} else {
$tail_refs[] = $ref;
}
}
return array_merge($head_refs, $tail_refs);
}
private function recordCommit(
PhabricatorRepository $repository,
$commit_identifier,
$epoch,
$close_immediately,
array $parents) {
$commit = new PhabricatorRepositoryCommit();
$conn_w = $repository->establishConnection('w');
// First, try to revive an existing unreachable commit (if one exists) by
// removing the "unreachable" flag. If we succeed, we don't need to do
// anything else: we already discovered this commit some time ago.
queryfx(
$conn_w,
'UPDATE %T SET importStatus = (importStatus & ~%d)
WHERE repositoryID = %d AND commitIdentifier = %s',
$commit->getTableName(),
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE,
$repository->getID(),
$commit_identifier);
if ($conn_w->getAffectedRows()) {
$commit = $commit->loadOneWhere(
'repositoryID = %d AND commitIdentifier = %s',
$repository->getID(),
$commit_identifier);
// After reviving a commit, schedule new daemons for it.
$this->didDiscoverCommit($repository, $commit, $epoch);
return;
}
$commit->setRepositoryID($repository->getID());
$commit->setCommitIdentifier($commit_identifier);
$commit->setEpoch($epoch);
if ($close_immediately) {
$commit->setImportStatus(PhabricatorRepositoryCommit::IMPORTED_CLOSEABLE);
}
$data = new PhabricatorRepositoryCommitData();
try {
// If this commit has parents, look up their IDs. The parent commits
// should always exist already.
$parent_ids = array();
if ($parents) {
$parent_rows = queryfx_all(
$conn_w,
'SELECT id, commitIdentifier FROM %T
WHERE commitIdentifier IN (%Ls) AND repositoryID = %d',
$commit->getTableName(),
$parents,
$repository->getID());
$parent_map = ipull($parent_rows, 'id', 'commitIdentifier');
foreach ($parents as $parent) {
if (empty($parent_map[$parent])) {
throw new Exception(
pht('Unable to identify parent "%s"!', $parent));
}
$parent_ids[] = $parent_map[$parent];
}
} else {
// Write an explicit 0 so we can distinguish between "really no
// parents" and "data not available".
if (!$repository->isSVN()) {
$parent_ids = array(0);
}
}
$commit->openTransaction();
$commit->save();
$data->setCommitID($commit->getID());
$data->save();
foreach ($parent_ids as $parent_id) {
queryfx(
$conn_w,
'INSERT IGNORE INTO %T (childCommitID, parentCommitID)
VALUES (%d, %d)',
PhabricatorRepository::TABLE_PARENTS,
$commit->getID(),
$parent_id);
}
$commit->saveTransaction();
$this->didDiscoverCommit($repository, $commit, $epoch);
if ($this->repairMode) {
// Normally, the query should throw a duplicate key exception. If we
// reach this in repair mode, we've actually performed a repair.
$this->log(pht('Repaired commit "%s".', $commit_identifier));
}
PhutilEventEngine::dispatchEvent(
new PhabricatorEvent(
PhabricatorEventType::TYPE_DIFFUSION_DIDDISCOVERCOMMIT,
array(
'repository' => $repository,
'commit' => $commit,
)));
} catch (AphrontDuplicateKeyQueryException $ex) {
$commit->killTransaction();
// Ignore. This can happen because we discover the same new commit
// more than once when looking at history, or because of races or
// data inconsistency or cosmic radiation; in any case, we're still
// in a good state if we ignore the failure.
}
}
private function didDiscoverCommit(
PhabricatorRepository $repository,
PhabricatorRepositoryCommit $commit,
$epoch) {
$this->insertTask($repository, $commit);
// Update the repository summary table.
queryfx(
$commit->establishConnection('w'),
'INSERT INTO %T (repositoryID, size, lastCommitID, epoch)
VALUES (%d, 1, %d, %d)
ON DUPLICATE KEY UPDATE
size = size + 1,
lastCommitID =
IF(VALUES(epoch) > epoch, VALUES(lastCommitID), lastCommitID),
epoch = IF(VALUES(epoch) > epoch, VALUES(epoch), epoch)',
PhabricatorRepository::TABLE_SUMMARY,
$repository->getID(),
$commit->getID(),
$epoch);
}
private function didDiscoverRefs(array $refs) {
foreach ($refs as $ref) {
$this->workingSet[$ref->getIdentifier()] = true;
}
}
private function insertTask(
PhabricatorRepository $repository,
PhabricatorRepositoryCommit $commit,
$data = array()) {
$vcs = $repository->getVersionControlSystem();
switch ($vcs) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
$class = 'PhabricatorRepositoryGitCommitMessageParserWorker';
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$class = 'PhabricatorRepositorySvnCommitMessageParserWorker';
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
$class = 'PhabricatorRepositoryMercurialCommitMessageParserWorker';
break;
default:
throw new Exception(pht("Unknown repository type '%s'!", $vcs));
}
$data['commitID'] = $commit->getID();
// If the repository is importing for the first time, we schedule tasks
// at IMPORT priority, which is very low. Making progress on importing a
// new repository for the first time is less important than any other
// daemon task.
- // If the repostitory has finished importing and we're just catching up
+ // If the repository has finished importing and we're just catching up
// on recent commits, we schedule discovery at COMMIT priority, which is
// slightly below the default priority.
// Note that followup tasks and triggered tasks (like those generated by
// Herald or Harbormaster) will queue at DEFAULT priority, so that each
// commit tends to fully import before we start the next one. This tends
// to give imports fairly predictable progress. See T11677 for some
// discussion.
if ($repository->isImporting()) {
$task_priority = PhabricatorWorker::PRIORITY_IMPORT;
} else {
$task_priority = PhabricatorWorker::PRIORITY_COMMIT;
}
$options = array(
'priority' => $task_priority,
);
PhabricatorWorker::scheduleTask($class, $data, $options);
}
private function isInitialImport(array $refs) {
$commit_count = count($refs);
if ($commit_count <= PhabricatorRepository::IMPORT_THRESHOLD) {
// If we fetched a small number of commits, assume it's an initial
// commit or a stack of a few initial commits.
return false;
}
$viewer = $this->getViewer();
$repository = $this->getRepository();
$any_commits = id(new DiffusionCommitQuery())
->setViewer($viewer)
->withRepository($repository)
->setLimit(1)
->execute();
if ($any_commits) {
// If the repository already has commits, this isn't an import.
return false;
}
return true;
}
private function getObservedVersion(PhabricatorRepository $repository) {
if ($repository->isHosted()) {
return null;
}
if ($repository->isGit()) {
return $this->getGitObservedVersion($repository);
}
return null;
}
private function getGitObservedVersion(PhabricatorRepository $repository) {
$refs = id(new DiffusionLowLevelGitRefQuery())
->setRepository($repository)
->execute();
if (!$refs) {
return null;
}
// In Git, the observed version is the most recently discovered commit
// at any repository HEAD. It's possible for this to regress temporarily
// if a branch is pushed and then deleted. This is acceptable because it
// doesn't do anything meaningfully bad and will fix itself on the next
// push.
$ref_identifiers = mpull($refs, 'getCommitIdentifier');
$ref_identifiers = array_fuse($ref_identifiers);
$version = queryfx_one(
$repository->establishConnection('w'),
'SELECT MAX(id) version FROM %T WHERE repositoryID = %d
AND commitIdentifier IN (%Ls)',
id(new PhabricatorRepositoryCommit())->getTableName(),
$repository->getID(),
$ref_identifiers);
if (!$version) {
return null;
}
return (int)$version['version'];
}
private function markUnreachableCommits(PhabricatorRepository $repository) {
// For now, this is only supported for Git.
if (!$repository->isGit()) {
return;
}
// Find older versions of refs which we haven't processed yet. We're going
// to make sure their commits are still reachable.
$old_refs = id(new PhabricatorRepositoryOldRef())->loadAllWhere(
'repositoryPHID = %s',
$repository->getPHID());
// If we don't have any refs to update, bail out before building a graph
// stream. In particular, this improves behavior in empty repositories,
// where `git log` exits with an error.
if (!$old_refs) {
return;
}
// We can share a single graph stream across all the checks we need to do.
$stream = new PhabricatorGitGraphStream($repository);
foreach ($old_refs as $old_ref) {
$identifier = $old_ref->getCommitIdentifier();
$this->markUnreachableFrom($repository, $stream, $identifier);
// If nothing threw an exception, we're all done with this ref.
$old_ref->delete();
}
}
private function markUnreachableFrom(
PhabricatorRepository $repository,
PhabricatorGitGraphStream $stream,
$identifier) {
$unreachable = array();
$commit = id(new PhabricatorRepositoryCommit())->loadOneWhere(
'repositoryID = %s AND commitIdentifier = %s',
$repository->getID(),
$identifier);
if (!$commit) {
return;
}
$look = array($commit);
$seen = array();
while ($look) {
$target = array_pop($look);
// If we've already checked this commit (for example, because history
// branches and then merges) we don't need to check it again.
$target_identifier = $target->getCommitIdentifier();
if (isset($seen[$target_identifier])) {
continue;
}
$seen[$target_identifier] = true;
try {
$stream->getCommitDate($target_identifier);
$reachable = true;
} catch (Exception $ex) {
$reachable = false;
}
if ($reachable) {
// This commit is reachable, so we don't need to go any further
// down this road.
continue;
}
$unreachable[] = $target;
// Find the commit's parents and check them for reachability, too. We
// have to look in the database since we no may longer have the commit
// in the repository.
$rows = queryfx_all(
$commit->establishConnection('w'),
'SELECT commit.* FROM %T commit
JOIN %T parents ON commit.id = parents.parentCommitID
WHERE parents.childCommitID = %d',
$commit->getTableName(),
PhabricatorRepository::TABLE_PARENTS,
$target->getID());
if (!$rows) {
continue;
}
$parents = id(new PhabricatorRepositoryCommit())
->loadAllFromArray($rows);
foreach ($parents as $parent) {
$look[] = $parent;
}
}
$unreachable = array_reverse($unreachable);
$flag = PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE;
foreach ($unreachable as $unreachable_commit) {
$unreachable_commit->writeImportStatusFlag($flag);
}
// If anything was unreachable, just rebuild the whole summary table.
// We can't really update it incrementally when a commit becomes
// unreachable.
if ($unreachable) {
$this->rebuildSummaryTable($repository);
}
}
private function rebuildSummaryTable(PhabricatorRepository $repository) {
$conn_w = $repository->establishConnection('w');
$data = queryfx_one(
$conn_w,
'SELECT COUNT(*) N, MAX(id) id, MAX(epoch) epoch
FROM %T WHERE repositoryID = %d AND (importStatus & %d) != %d',
id(new PhabricatorRepositoryCommit())->getTableName(),
$repository->getID(),
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE,
PhabricatorRepositoryCommit::IMPORTED_UNREACHABLE);
queryfx(
$conn_w,
'INSERT INTO %T (repositoryID, size, lastCommitID, epoch)
VALUES (%d, %d, %d, %d)
ON DUPLICATE KEY UPDATE
size = VALUES(size),
lastCommitID = VALUES(lastCommitID),
epoch = VALUES(epoch)',
PhabricatorRepository::TABLE_SUMMARY,
$repository->getID(),
$data['N'],
$data['id'],
$data['epoch']);
}
}
diff --git a/src/applications/repository/management/PhabricatorRepositoryManagementThawWorkflow.php b/src/applications/repository/management/PhabricatorRepositoryManagementThawWorkflow.php
index 2f09ffd9be..71076314da 100644
--- a/src/applications/repository/management/PhabricatorRepositoryManagementThawWorkflow.php
+++ b/src/applications/repository/management/PhabricatorRepositoryManagementThawWorkflow.php
@@ -1,186 +1,186 @@
<?php
final class PhabricatorRepositoryManagementThawWorkflow
extends PhabricatorRepositoryManagementWorkflow {
protected function didConstruct() {
$this
->setName('thaw')
->setExamples('**thaw** [options] __repository__ ...')
->setSynopsis(
pht(
'Resolve issues with frozen cluster repositories. Very advanced '.
'and dangerous.'))
->setArguments(
array(
array(
'name' => 'demote',
'param' => 'device',
'help' => pht(
'Demote a device, discarding local changes. Clears stuck '.
'write locks and recovers from lost leaders.'),
),
array(
'name' => 'promote',
'param' => 'device',
'help' => pht(
'Promote a device, discarding changes on other devices. '.
'Resolves ambiguous leadership and recovers from demotion '.
'mistakes.'),
),
array(
'name' => 'force',
'help' => pht('Run operations without asking for confirmation.'),
),
array(
'name' => 'repositories',
'wildcard' => true,
),
));
}
public function execute(PhutilArgumentParser $args) {
$viewer = $this->getViewer();
$repositories = $this->loadRepositories($args, 'repositories');
if (!$repositories) {
throw new PhutilArgumentUsageException(
pht('Specify one or more repositories to thaw.'));
}
$promote = $args->getArg('promote');
$demote = $args->getArg('demote');
if (!$promote && !$demote) {
throw new PhutilArgumentUsageException(
pht('You must choose a device to --promote or --demote.'));
}
if ($promote && $demote) {
throw new PhutilArgumentUsageException(
pht('Specify either --promote or --demote, but not both.'));
}
$device_name = nonempty($promote, $demote);
$device = id(new AlmanacDeviceQuery())
->setViewer($viewer)
->withNames(array($device_name))
->executeOne();
if (!$device) {
throw new PhutilArgumentUsageException(
pht('No device "%s" exists.', $device_name));
}
if ($promote) {
$risk_message = pht(
'Promoting a device can cause the loss of any repository data which '.
'only exists on other devices. The version of the repository on the '.
'promoted device will become authoritative.');
} else {
$risk_message = pht(
'Demoting a device can cause the loss of any repository data which '.
'only exists on the demoted device. The version of the repository '.
'on some other device will become authoritative.');
}
echo tsprintf(
"**<bg:red> %s </bg>** %s\n",
pht('DATA AT RISK'),
$risk_message);
$is_force = $args->getArg('force');
- $prompt = pht('Accept the possibilty of permanent data loss?');
+ $prompt = pht('Accept the possibility of permanent data loss?');
if (!$is_force && !phutil_console_confirm($prompt)) {
throw new PhutilArgumentUsageException(
pht('User aborted the workflow.'));
}
foreach ($repositories as $repository) {
$repository_phid = $repository->getPHID();
$write_lock = PhabricatorRepositoryWorkingCopyVersion::getWriteLock(
$repository_phid);
echo tsprintf(
"%s\n",
pht(
'Waiting to acquire write lock for "%s"...',
$repository->getDisplayName()));
$write_lock->lock(phutil_units('5 minutes in seconds'));
try {
$service = $repository->loadAlmanacService();
if (!$service) {
throw new PhutilArgumentUsageException(
pht(
'Repository "%s" is not a cluster repository: it is not '.
'bound to an Almanac service.',
$repository->getDisplayName()));
}
$bindings = $service->getActiveBindings();
$bindings = mpull($bindings, null, 'getDevicePHID');
if (empty($bindings[$device->getPHID()])) {
throw new PhutilArgumentUsageException(
pht(
'Repository "%s" has no active binding to device "%s". Only '.
'actively bound devices can be promoted or demoted.',
$repository->getDisplayName(),
$device->getName()));
}
$versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions(
$repository->getPHID());
$versions = mpull($versions, null, 'getDevicePHID');
$versions = array_select_keys($versions, array_keys($bindings));
if ($versions && $promote) {
throw new PhutilArgumentUsageException(
pht(
'Unable to promote "%s" for repository "%s": the leaders for '.
'this cluster are not ambiguous.',
$device->getName(),
$repository->getDisplayName()));
}
if ($promote) {
PhabricatorRepositoryWorkingCopyVersion::updateVersion(
$repository->getPHID(),
$device->getPHID(),
0);
echo tsprintf(
"%s\n",
pht(
'Promoted "%s" to become a leader for "%s".',
$device->getName(),
$repository->getDisplayName()));
}
if ($demote) {
PhabricatorRepositoryWorkingCopyVersion::demoteDevice(
$repository->getPHID(),
$device->getPHID());
echo tsprintf(
"%s\n",
pht(
'Demoted "%s" from leadership of repository "%s".',
$device->getName(),
$repository->getDisplayName()));
}
} catch (Exception $ex) {
$write_lock->unlock();
throw $ex;
}
$write_lock->unlock();
}
return 0;
}
}
diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php
index 09fb239bf0..713ed92270 100644
--- a/src/applications/repository/storage/PhabricatorRepository.php
+++ b/src/applications/repository/storage/PhabricatorRepository.php
@@ -1,2645 +1,2645 @@
<?php
/**
* @task uri Repository URI Management
* @task autoclose Autoclose
* @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 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 BECAUSE_REPOSITORY_IMPORTING = 'auto/importing';
const BECAUSE_AUTOCLOSE_DISABLED = 'auto/disabled';
const BECAUSE_NOT_ON_AUTOCLOSE_BRANCH = 'auto/nobranch';
const BECAUSE_BRANCH_UNTRACKED = 'auto/notrack';
const BECAUSE_BRANCH_NOT_AUTOCLOSE = 'auto/noclose';
const BECAUSE_AUTOCLOSE_FORCED = 'auto/forced';
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 (strlen($callsign)) {
return "r{$callsign}";
}
$id = $this->getID();
return "R{$id}";
}
public function getDisplayName() {
$slug = $this->getRepositorySlug();
if (strlen($slug)) {
return $slug;
}
return $this->getMonogram();
}
public function getAllMonograms() {
$monograms = array();
$monograms[] = 'R'.$this->getID();
$callsign = $this->getCallsign();
if (strlen($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 getHumanReadableDetail($key, $default = null) {
$value = $this->getDetail($key, $default);
switch ($key) {
case 'branch-filter':
case 'close-commits-filter':
$value = array_keys($value);
$value = implode(', ', $value);
break;
}
return $value;
}
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 (!strlen($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 (strlen($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 (!strlen($name)) {
$name = $this->getName();
$name = phutil_utf8_strtolower($name);
$name = preg_replace('@[/ -:<>]+@', '-', $name);
$name = trim($name, '-');
if (!strlen($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)->execute();
}
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)->execute();
}
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 (strlen($short_name)) {
return "/source/{$short_name}/";
}
$callsign = $this->getCallsign();
if (strlen($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 (strlen($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 'graph':
case 'clone':
case 'browse':
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 (strlen($path)) {
$path = ltrim($path, '/');
$path = str_replace(array(';', '$'), array(';;', '$$'), $path);
$path = phutil_escape_uri($path);
}
$raw_branch = $branch;
if (strlen($branch)) {
$branch = phutil_escape_uri_path_component($branch);
$path = "{$branch}/{$path}";
}
$raw_commit = $commit;
if (strlen($commit)) {
$commit = str_replace('$', '$$', $commit);
$commit = ';'.phutil_escape_uri($commit);
}
if (strlen($line)) {
$line = '$'.phutil_escape_uri($line);
}
$query = array();
switch ($action) {
case 'change':
case 'history':
case 'graph':
case 'browse':
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 (strlen($head)) {
$query['head'] = $head;
} else if (strlen($raw_commit)) {
$query['commit'] = $raw_commit;
} else if (strlen($raw_branch)) {
$query['head'] = $raw_branch;
}
if (strlen($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;
}
$uri = new PhutilURI($uri);
if (isset($params['lint'])) {
$params['params'] = idx($params, 'params', array()) + array(
'lint' => $params['lint'],
);
}
$query = idx($params, 'params', array()) + $query;
if ($query) {
$uri->setQueryParams($query);
}
return $uri;
}
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 (strlen($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 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'] * 4;
$status = $row['importStatus'];
if ($status & PhabricatorRepositoryCommit::IMPORTED_MESSAGE) {
$done += $row['N'];
}
if ($status & PhabricatorRepositoryCommit::IMPORTED_CHANGE) {
$done += $row['N'];
}
if ($status & PhabricatorRepositoryCommit::IMPORTED_OWNERS) {
$done += $row['N'];
}
if ($status & PhabricatorRepositoryCommit::IMPORTED_HERALD) {
$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;
}
/**
* Should this repository publish feed, notifications, audits, and email?
*
* We do not publish information about repositories during initial import,
* or if the repository has been set not to publish.
*/
public function shouldPublish() {
if ($this->isImporting()) {
return false;
}
if ($this->getDetail('herald-disabled')) {
return false;
}
return true;
}
/* -( Autoclose )---------------------------------------------------------- */
public function shouldAutocloseRef(DiffusionRepositoryRef $ref) {
if (!$ref->isBranch()) {
return false;
}
return $this->shouldAutocloseBranch($ref->getShortName());
}
/**
* Determine if autoclose is active for a branch.
*
* For more details about why, use @{method:shouldSkipAutocloseBranch}.
*
* @param string Branch name to check.
* @return bool True if autoclose is active for the branch.
* @task autoclose
*/
public function shouldAutocloseBranch($branch) {
return ($this->shouldSkipAutocloseBranch($branch) === null);
}
/**
* Determine if autoclose is active for a commit.
*
* For more details about why, use @{method:shouldSkipAutocloseCommit}.
*
* @param PhabricatorRepositoryCommit Commit to check.
* @return bool True if autoclose is active for the commit.
* @task autoclose
*/
public function shouldAutocloseCommit(PhabricatorRepositoryCommit $commit) {
return ($this->shouldSkipAutocloseCommit($commit) === null);
}
/**
* Determine why autoclose should be skipped for a branch.
*
* This method gives a detailed reason why autoclose will be skipped. To
* perform a simple test, use @{method:shouldAutocloseBranch}.
*
* @param string Branch name to check.
* @return const|null Constant identifying reason to skip this branch, or null
* if autoclose is active.
* @task autoclose
*/
public function shouldSkipAutocloseBranch($branch) {
$all_reason = $this->shouldSkipAllAutoclose();
if ($all_reason) {
return $all_reason;
}
if (!$this->shouldTrackBranch($branch)) {
return self::BECAUSE_BRANCH_UNTRACKED;
}
if (!$this->isBranchInFilter($branch, 'close-commits-filter')) {
return self::BECAUSE_BRANCH_NOT_AUTOCLOSE;
}
return null;
}
/**
* Determine why autoclose should be skipped for a commit.
*
* This method gives a detailed reason why autoclose will be skipped. To
* perform a simple test, use @{method:shouldAutocloseCommit}.
*
* @param PhabricatorRepositoryCommit Commit to check.
* @return const|null Constant identifying reason to skip this commit, or null
* if autoclose is active.
* @task autoclose
*/
public function shouldSkipAutocloseCommit(
PhabricatorRepositoryCommit $commit) {
$all_reason = $this->shouldSkipAllAutoclose();
if ($all_reason) {
return $all_reason;
}
switch ($this->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
return null;
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
break;
default:
throw new Exception(pht('Unrecognized version control system.'));
}
$closeable_flag = PhabricatorRepositoryCommit::IMPORTED_CLOSEABLE;
if (!$commit->isPartiallyImported($closeable_flag)) {
return self::BECAUSE_NOT_ON_AUTOCLOSE_BRANCH;
}
return null;
}
/**
* Determine why all autoclose operations should be skipped for this
* repository.
*
* @return const|null Constant identifying reason to skip all autoclose
* operations, or null if autoclose operations are not blocked at the
* repository level.
* @task autoclose
*/
private function shouldSkipAllAutoclose() {
if ($this->isImporting()) {
return self::BECAUSE_REPOSITORY_IMPORTING;
}
if ($this->getDetail('disable-autoclose', false)) {
return self::BECAUSE_AUTOCLOSE_DISABLED;
}
return null;
}
/* -( 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@libphutil:PhutilURI}.
* @task uri
*/
public function getRemoteURIObject() {
$raw_uri = $this->getDetail('remote-uri');
if (!strlen($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 we should connect to the remote using SSH flags and
* credentials.
*
* @return bool True to use the SSH protocol.
* @task uri
*/
private function shouldUseSSH() {
if ($this->isHosted()) {
return false;
}
$protocol = $this->getRemoteProtocol();
if ($this->isSSHProtocol($protocol)) {
return true;
}
return false;
}
/**
* Determine if we should connect to the remote using HTTP flags and
* credentials.
*
* @return bool True to use the HTTP protocol.
* @task uri
*/
private function shouldUseHTTP() {
if ($this->isHosted()) {
return false;
}
$protocol = $this->getRemoteProtocol();
return ($protocol == 'http' || $protocol == 'https');
}
/**
* Determine if we should connect to the remote using SVN flags and
* credentials.
*
* @return bool True to use the SVN protocol.
* @task uri
*/
private function shouldUseSVNProtocol() {
if ($this->isHosted()) {
return false;
}
$protocol = $this->getRemoteProtocol();
return ($protocol == 'svn');
}
/**
* Determine if a protocol is SSH or SSH-like.
*
* @param string 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) {
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;
}
}
}
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;
}
// TODO: Unprototype this feature.
if (!PhabricatorEnv::getEnvConfig('phabricator.show-prototypes')) {
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 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 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;
}
/**
- * Retrieve the sevice URI for the device hosting this repository.
+ * 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 Viewing user.
* @param bool `true` to throw if a remote URI would be returned.
* @param list<string> List of allowable protocols.
* @return string|null URI, or `null` for local repositories.
*/
public function getAlmanacServiceURI(
PhabricatorUser $viewer,
$never_proxy,
array $protocols) {
$cache_key = $this->getAlmanacServiceCacheKey();
if (!$cache_key) {
return null;
}
$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 null;
}
$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 null;
}
}
if (isset($protocol_map[$uri['protocol']])) {
$results[] = new PhutilURI($uri['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) {
throw new Exception(
pht(
'Refusing to proxy a repository request from a cluster host. '.
'Cluster hosts must correctly route their intracluster requests.'));
}
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()));
}
}
shuffle($results);
return head($results);
}
public function supportsSynchronization() {
// TODO: For now, this is only supported for Git.
if (!$this->isGit()) {
return false;
}
return true;
}
public function getAlmanacServiceCacheKey() {
$service_phid = $this->getAlmanacServicePHID();
if (!$service_phid) {
return null;
}
$repository_phid = $this->getPHID();
return "diffusion.repository({$repository_phid}).service({$service_phid})";
}
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 '.
'interfaces.'));
}
$uris = array();
foreach ($bindings as $binding) {
$iface = $binding->getInterface();
$uri = $this->getClusterRepositoryURIFromBinding($binding);
$protocol = $uri->getProtocol();
$device_name = $iface->getDevice()->getName();
$uris[] = array(
'protocol' => $protocol,
'uri' => (string)$uri,
'device' => $device_name,
);
}
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 Viewing user.
* @param bool `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,
$never_proxy,
array(
'http',
'https',
));
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 Phabricator 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 Phabricator 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 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, explictitly bring expected Git
+ // 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();
}
/* -( 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;
}
$has_ssh = (bool)strlen(PhabricatorEnv::getEnvConfig('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))
->needBindings(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 getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new PhabricatorRepositoryTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( 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.')),
);
}
public function getFieldValuesForConduit() {
return array(
'name' => $this->getName(),
'vcs' => $this->getVersionControlSystem(),
'callsign' => $this->getCallsign(),
'shortName' => $this->getRepositorySlug(),
'status' => $this->getStatus(),
'isImporting' => (bool)$this->isImporting(),
);
}
public function getConduitSearchAttachments() {
return array(
id(new DiffusionRepositoryURIsSearchEngineAttachment())
->setAttachmentKey('uris'),
);
}
/* -( PhabricatorFulltextInterface )--------------------------------------- */
public function newFulltextEngine() {
return new PhabricatorRepositoryFulltextEngine();
}
/* -( PhabricatorFerretInterface )----------------------------------------- */
public function newFerretEngine() {
return new PhabricatorRepositoryFerretEngine();
}
}
diff --git a/src/applications/repository/storage/PhabricatorRepositoryURI.php b/src/applications/repository/storage/PhabricatorRepositoryURI.php
index cbd25b74eb..c8d560705a 100644
--- a/src/applications/repository/storage/PhabricatorRepositoryURI.php
+++ b/src/applications/repository/storage/PhabricatorRepositoryURI.php
@@ -1,748 +1,748 @@
<?php
final class PhabricatorRepositoryURI
extends PhabricatorRepositoryDAO
implements
PhabricatorApplicationTransactionInterface,
PhabricatorPolicyInterface,
PhabricatorExtendedPolicyInterface,
PhabricatorConduitResultInterface {
protected $repositoryPHID;
protected $uri;
protected $builtinProtocol;
protected $builtinIdentifier;
protected $credentialPHID;
protected $ioType;
protected $displayType;
protected $isDisabled;
private $repository = self::ATTACHABLE;
const BUILTIN_PROTOCOL_SSH = 'ssh';
const BUILTIN_PROTOCOL_HTTP = 'http';
const BUILTIN_PROTOCOL_HTTPS = 'https';
const BUILTIN_IDENTIFIER_ID = 'id';
const BUILTIN_IDENTIFIER_SHORTNAME = 'shortname';
const BUILTIN_IDENTIFIER_CALLSIGN = 'callsign';
const DISPLAY_DEFAULT = 'default';
const DISPLAY_NEVER = 'never';
const DISPLAY_ALWAYS = 'always';
const IO_DEFAULT = 'default';
const IO_OBSERVE = 'observe';
const IO_MIRROR = 'mirror';
const IO_NONE = 'none';
const IO_READ = 'read';
const IO_READWRITE = 'readwrite';
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'uri' => 'text255',
'builtinProtocol' => 'text32?',
'builtinIdentifier' => 'text32?',
'credentialPHID' => 'phid?',
'ioType' => 'text32',
'displayType' => 'text32',
'isDisabled' => 'bool',
),
self::CONFIG_KEY_SCHEMA => array(
'key_builtin' => array(
'columns' => array(
'repositoryPHID',
'builtinProtocol',
'builtinIdentifier',
),
'unique' => true,
),
),
) + parent::getConfiguration();
}
public static function initializeNewURI() {
return id(new self())
->setIoType(self::IO_DEFAULT)
->setDisplayType(self::DISPLAY_DEFAULT)
->setIsDisabled(0);
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorRepositoryURIPHIDType::TYPECONST);
}
public function attachRepository(PhabricatorRepository $repository) {
$this->repository = $repository;
return $this;
}
public function getRepository() {
return $this->assertAttached($this->repository);
}
public function getRepositoryURIBuiltinKey() {
if (!$this->getBuiltinProtocol()) {
return null;
}
$parts = array(
$this->getBuiltinProtocol(),
$this->getBuiltinIdentifier(),
);
return implode('.', $parts);
}
public function isBuiltin() {
return (bool)$this->getBuiltinProtocol();
}
public function getEffectiveDisplayType() {
$display = $this->getDisplayType();
if ($display != self::DISPLAY_DEFAULT) {
return $display;
}
return $this->getDefaultDisplayType();
}
public function getDefaultDisplayType() {
switch ($this->getEffectiveIOType()) {
case self::IO_MIRROR:
case self::IO_OBSERVE:
case self::IO_NONE:
return self::DISPLAY_NEVER;
case self::IO_READ:
case self::IO_READWRITE:
// By default, only show the "best" version of the builtin URI, not the
// other redundant versions.
$repository = $this->getRepository();
$other_uris = $repository->getURIs();
$identifier_value = array(
self::BUILTIN_IDENTIFIER_SHORTNAME => 3,
self::BUILTIN_IDENTIFIER_CALLSIGN => 2,
self::BUILTIN_IDENTIFIER_ID => 1,
);
$have_identifiers = array();
foreach ($other_uris as $other_uri) {
if ($other_uri->getIsDisabled()) {
continue;
}
$identifier = $other_uri->getBuiltinIdentifier();
if (!$identifier) {
continue;
}
$have_identifiers[$identifier] = $identifier_value[$identifier];
}
$best_identifier = max($have_identifiers);
$this_identifier = $identifier_value[$this->getBuiltinIdentifier()];
if ($this_identifier < $best_identifier) {
return self::DISPLAY_NEVER;
}
return self::DISPLAY_ALWAYS;
}
return self::DISPLAY_NEVER;
}
public function getEffectiveIOType() {
$io = $this->getIoType();
if ($io != self::IO_DEFAULT) {
return $io;
}
return $this->getDefaultIOType();
}
public function getDefaultIOType() {
if ($this->isBuiltin()) {
$repository = $this->getRepository();
$other_uris = $repository->getURIs();
$any_observe = false;
foreach ($other_uris as $other_uri) {
if ($other_uri->getIoType() == self::IO_OBSERVE) {
$any_observe = true;
break;
}
}
if ($any_observe) {
return self::IO_READ;
} else {
return self::IO_READWRITE;
}
}
return self::IO_NONE;
}
public function getNormalizedURI() {
$vcs = $this->getRepository()->getVersionControlSystem();
$map = array(
PhabricatorRepositoryType::REPOSITORY_TYPE_GIT =>
PhabricatorRepositoryURINormalizer::TYPE_GIT,
PhabricatorRepositoryType::REPOSITORY_TYPE_SVN =>
PhabricatorRepositoryURINormalizer::TYPE_SVN,
PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL =>
PhabricatorRepositoryURINormalizer::TYPE_MERCURIAL,
);
$type = $map[$vcs];
$display = (string)$this->getDisplayURI();
$normal_uri = new PhabricatorRepositoryURINormalizer($type, $display);
return $normal_uri->getNormalizedURI();
}
public function getDisplayURI() {
return $this->getURIObject();
}
public function getEffectiveURI() {
return $this->getURIObject();
}
public function getURIEnvelope() {
$uri = $this->getEffectiveURI();
$command_engine = $this->newCommandEngine();
$is_http = $command_engine->isAnyHTTPProtocol();
// For SVN, we use `--username` and `--password` flags separately in the
// CommandEngine, so we don't need to add any credentials here.
$is_svn = $this->getRepository()->isSVN();
$credential_phid = $this->getCredentialPHID();
if ($is_http && !$is_svn && $credential_phid) {
$key = PassphrasePasswordKey::loadFromPHID(
$credential_phid,
PhabricatorUser::getOmnipotentUser());
$uri->setUser($key->getUsernameEnvelope()->openEnvelope());
$uri->setPass($key->getPasswordEnvelope()->openEnvelope());
}
return new PhutilOpaqueEnvelope((string)$uri);
}
private function getURIObject() {
// Users can provide Git/SCP-style URIs in the form "user@host:path".
// In the general case, these are not equivalent to any "ssh://..." form
// because the path is relative.
if ($this->isBuiltin()) {
$builtin_protocol = $this->getForcedProtocol();
$builtin_domain = $this->getForcedHost();
$raw_uri = "{$builtin_protocol}://{$builtin_domain}";
} else {
$raw_uri = $this->getURI();
}
$port = $this->getForcedPort();
$default_ports = array(
'ssh' => 22,
'http' => 80,
'https' => 443,
);
$uri = new PhutilURI($raw_uri);
// Make sure to remove any password from the URI before we do anything
// with it; this should always be provided by the associated credential.
$uri->setPass(null);
$protocol = $this->getForcedProtocol();
if ($protocol) {
$uri->setProtocol($protocol);
}
if ($port) {
$uri->setPort($port);
}
// Remove any explicitly set default ports.
$uri_port = $uri->getPort();
$uri_protocol = $uri->getProtocol();
$uri_default = idx($default_ports, $uri_protocol);
if ($uri_default && ($uri_default == $uri_port)) {
$uri->setPort(null);
}
$user = $this->getForcedUser();
if ($user) {
$uri->setUser($user);
}
$host = $this->getForcedHost();
if ($host) {
$uri->setDomain($host);
}
$path = $this->getForcedPath();
if ($path) {
$uri->setPath($path);
}
return $uri;
}
private function getForcedProtocol() {
$repository = $this->getRepository();
switch ($this->getBuiltinProtocol()) {
case self::BUILTIN_PROTOCOL_SSH:
if ($repository->isSVN()) {
return 'svn+ssh';
} else {
return 'ssh';
}
case self::BUILTIN_PROTOCOL_HTTP:
return 'http';
case self::BUILTIN_PROTOCOL_HTTPS:
return 'https';
default:
return null;
}
}
private function getForcedUser() {
switch ($this->getBuiltinProtocol()) {
case self::BUILTIN_PROTOCOL_SSH:
return AlmanacKeys::getClusterSSHUser();
default:
return null;
}
}
private function getForcedHost() {
$phabricator_uri = PhabricatorEnv::getURI('/');
$phabricator_uri = new PhutilURI($phabricator_uri);
$phabricator_host = $phabricator_uri->getDomain();
switch ($this->getBuiltinProtocol()) {
case self::BUILTIN_PROTOCOL_SSH:
$ssh_host = PhabricatorEnv::getEnvConfig('diffusion.ssh-host');
if ($ssh_host !== null) {
return $ssh_host;
}
return $phabricator_host;
case self::BUILTIN_PROTOCOL_HTTP:
case self::BUILTIN_PROTOCOL_HTTPS:
return $phabricator_host;
default:
return null;
}
}
private function getForcedPort() {
$protocol = $this->getBuiltinProtocol();
if ($protocol == self::BUILTIN_PROTOCOL_SSH) {
return PhabricatorEnv::getEnvConfig('diffusion.ssh-port');
}
- // If Phabricator is running on a nonstandard port, use that as the defualt
+ // If Phabricator is running on a nonstandard port, use that as the default
// port for URIs with the same protocol.
$is_http = ($protocol == self::BUILTIN_PROTOCOL_HTTP);
$is_https = ($protocol == self::BUILTIN_PROTOCOL_HTTPS);
if ($is_http || $is_https) {
$uri = PhabricatorEnv::getURI('/');
$uri = new PhutilURI($uri);
$port = $uri->getPort();
if (!$port) {
return null;
}
$uri_protocol = $uri->getProtocol();
$use_port =
($is_http && ($uri_protocol == 'http')) ||
($is_https && ($uri_protocol == 'https'));
if (!$use_port) {
return null;
}
return $port;
}
return null;
}
private function getForcedPath() {
if (!$this->isBuiltin()) {
return null;
}
$repository = $this->getRepository();
$id = $repository->getID();
$callsign = $repository->getCallsign();
$short_name = $repository->getRepositorySlug();
$clone_name = $repository->getCloneName();
if ($repository->isGit()) {
$suffix = '.git';
} else if ($repository->isHg()) {
$suffix = '/';
} else {
$suffix = '';
$clone_name = '';
}
switch ($this->getBuiltinIdentifier()) {
case self::BUILTIN_IDENTIFIER_ID:
return "/diffusion/{$id}/{$clone_name}{$suffix}";
case self::BUILTIN_IDENTIFIER_SHORTNAME:
return "/source/{$short_name}{$suffix}";
case self::BUILTIN_IDENTIFIER_CALLSIGN:
return "/diffusion/{$callsign}/{$clone_name}{$suffix}";
default:
return null;
}
}
public function getViewURI() {
$id = $this->getID();
return $this->getRepository()->getPathURI("uri/view/{$id}/");
}
public function getEditURI() {
$id = $this->getID();
return $this->getRepository()->getPathURI("uri/edit/{$id}/");
}
public function getAvailableIOTypeOptions() {
$options = array(
self::IO_DEFAULT,
self::IO_NONE,
);
if ($this->isBuiltin()) {
$options[] = self::IO_READ;
$options[] = self::IO_READWRITE;
} else {
$options[] = self::IO_OBSERVE;
$options[] = self::IO_MIRROR;
}
$map = array();
$io_map = self::getIOTypeMap();
foreach ($options as $option) {
$spec = idx($io_map, $option, array());
$label = idx($spec, 'label', $option);
$short = idx($spec, 'short');
$name = pht('%s: %s', $label, $short);
$map[$option] = $name;
}
return $map;
}
public function getAvailableDisplayTypeOptions() {
$options = array(
self::DISPLAY_DEFAULT,
self::DISPLAY_ALWAYS,
self::DISPLAY_NEVER,
);
$map = array();
$display_map = self::getDisplayTypeMap();
foreach ($options as $option) {
$spec = idx($display_map, $option, array());
$label = idx($spec, 'label', $option);
$short = idx($spec, 'short');
$name = pht('%s: %s', $label, $short);
$map[$option] = $name;
}
return $map;
}
public static function getIOTypeMap() {
return array(
self::IO_DEFAULT => array(
'label' => pht('Default'),
'short' => pht('Use default behavior.'),
),
self::IO_OBSERVE => array(
'icon' => 'fa-download',
'color' => 'green',
'label' => pht('Observe'),
'note' => pht(
'Phabricator will observe changes to this URI and copy them.'),
'short' => pht('Copy from a remote.'),
),
self::IO_MIRROR => array(
'icon' => 'fa-upload',
'color' => 'green',
'label' => pht('Mirror'),
'note' => pht(
'Phabricator will push a copy of any changes to this URI.'),
'short' => pht('Push a copy to a remote.'),
),
self::IO_NONE => array(
'icon' => 'fa-times',
'color' => 'grey',
'label' => pht('No I/O'),
'note' => pht(
'Phabricator will not push or pull any changes to this URI.'),
'short' => pht('Do not perform any I/O.'),
),
self::IO_READ => array(
'icon' => 'fa-folder',
'color' => 'blue',
'label' => pht('Read Only'),
'note' => pht(
'Phabricator will serve a read-only copy of the repository from '.
'this URI.'),
'short' => pht('Serve repository in read-only mode.'),
),
self::IO_READWRITE => array(
'icon' => 'fa-folder-open',
'color' => 'blue',
'label' => pht('Read/Write'),
'note' => pht(
'Phabricator will serve a read/write copy of the repository from '.
'this URI.'),
'short' => pht('Serve repository in read/write mode.'),
),
);
}
public static function getDisplayTypeMap() {
return array(
self::DISPLAY_DEFAULT => array(
'label' => pht('Default'),
'short' => pht('Use default behavior.'),
),
self::DISPLAY_ALWAYS => array(
'icon' => 'fa-eye',
'color' => 'green',
'label' => pht('Visible'),
'note' => pht('This URI will be shown to users as a clone URI.'),
'short' => pht('Show as a clone URI.'),
),
self::DISPLAY_NEVER => array(
'icon' => 'fa-eye-slash',
'color' => 'grey',
'label' => pht('Hidden'),
'note' => pht(
'This URI will be hidden from users.'),
'short' => pht('Do not show as a clone URI.'),
),
);
}
public function newCommandEngine() {
$repository = $this->getRepository();
return DiffusionCommandEngine::newCommandEngine($repository)
->setCredentialPHID($this->getCredentialPHID())
->setURI($this->getEffectiveURI());
}
public function getURIScore() {
$score = 0;
$io_points = array(
self::IO_READWRITE => 200,
self::IO_READ => 100,
);
$score += idx($io_points, $this->getEffectiveIOType(), 0);
$protocol_points = array(
self::BUILTIN_PROTOCOL_SSH => 30,
self::BUILTIN_PROTOCOL_HTTPS => 20,
self::BUILTIN_PROTOCOL_HTTP => 10,
);
$score += idx($protocol_points, $this->getBuiltinProtocol(), 0);
$identifier_points = array(
self::BUILTIN_IDENTIFIER_SHORTNAME => 3,
self::BUILTIN_IDENTIFIER_CALLSIGN => 2,
self::BUILTIN_IDENTIFIER_ID => 1,
);
$score += idx($identifier_points, $this->getBuiltinIdentifier(), 0);
return $score;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new DiffusionURIEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new PhabricatorRepositoryURITransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
case PhabricatorPolicyCapability::CAN_EDIT:
return PhabricatorPolicies::getMostOpenPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
/* -( PhabricatorExtendedPolicyInterface )--------------------------------- */
public function getExtendedPolicy($capability, PhabricatorUser $viewer) {
$extended = array();
switch ($capability) {
case PhabricatorPolicyCapability::CAN_EDIT:
// To edit a repository URI, you must be able to edit the
// corresponding repository.
$extended[] = array($this->getRepository(), $capability);
break;
}
return $extended;
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('repositoryPHID')
->setType('phid')
->setDescription(pht('The associated repository PHID.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('uri')
->setType('map<string, string>')
->setDescription(pht('The raw and effective URI.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('io')
->setType('map<string, const>')
->setDescription(
pht('The raw, default, and effective I/O Type settings.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('display')
->setType('map<string, const>')
->setDescription(
pht('The raw, default, and effective Display Type settings.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('credentialPHID')
->setType('phid?')
->setDescription(
pht('The associated credential PHID, if one exists.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('disabled')
->setType('bool')
->setDescription(pht('True if the URI is disabled.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('builtin')
->setType('map<string, string>')
->setDescription(
pht('Information about builtin URIs.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('dateCreated')
->setType('int')
->setDescription(
pht('Epoch timestamp when the object was created.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('dateModified')
->setType('int')
->setDescription(
pht('Epoch timestamp when the object was last updated.')),
);
}
public function getFieldValuesForConduit() {
return array(
'repositoryPHID' => $this->getRepositoryPHID(),
'uri' => array(
'raw' => $this->getURI(),
'display' => (string)$this->getDisplayURI(),
'effective' => (string)$this->getEffectiveURI(),
'normalized' => (string)$this->getNormalizedURI(),
),
'io' => array(
'raw' => $this->getIOType(),
'default' => $this->getDefaultIOType(),
'effective' => $this->getEffectiveIOType(),
),
'display' => array(
'raw' => $this->getDisplayType(),
'default' => $this->getDefaultDisplayType(),
'effective' => $this->getEffectiveDisplayType(),
),
'credentialPHID' => $this->getCredentialPHID(),
'disabled' => (bool)$this->getIsDisabled(),
'builtin' => array(
'protocol' => $this->getBuiltinProtocol(),
'identifier' => $this->getBuiltinIdentifier(),
),
'dateCreated' => $this->getDateCreated(),
'dateModified' => $this->getDateModified(),
);
}
public function getConduitSearchAttachments() {
return array();
}
}
diff --git a/src/applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php b/src/applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php
index 51abc70d35..750cc91c47 100644
--- a/src/applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php
+++ b/src/applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php
@@ -1,186 +1,186 @@
<?php
final class PhabricatorRepositoryWorkingCopyVersion
extends PhabricatorRepositoryDAO {
protected $repositoryPHID;
protected $devicePHID;
protected $repositoryVersion;
protected $isWriting;
protected $lockOwner;
protected $writeProperties;
protected function getConfiguration() {
return array(
self::CONFIG_TIMESTAMPS => false,
self::CONFIG_COLUMN_SCHEMA => array(
'repositoryVersion' => 'uint32',
'isWriting' => 'bool',
'writeProperties' => 'text?',
'lockOwner' => 'text255?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_workingcopy' => array(
'columns' => array('repositoryPHID', 'devicePHID'),
'unique' => true,
),
),
) + parent::getConfiguration();
}
public static function loadVersions($repository_phid) {
$version = new self();
$conn_w = $version->establishConnection('w');
$table = $version->getTableName();
// This is a normal read, but force it to come from the master.
$rows = queryfx_all(
$conn_w,
'SELECT * FROM %T WHERE repositoryPHID = %s',
$table,
$repository_phid);
return $version->loadAllFromArray($rows);
}
public static function getReadLock($repository_phid, $device_phid) {
$repository_hash = PhabricatorHash::digestForIndex($repository_phid);
$device_hash = PhabricatorHash::digestForIndex($device_phid);
$lock_key = "repo.read({$repository_hash}, {$device_hash})";
return PhabricatorGlobalLock::newLock($lock_key);
}
public static function getWriteLock($repository_phid) {
$repository_hash = PhabricatorHash::digestForIndex($repository_phid);
$lock_key = "repo.write({$repository_hash})";
return PhabricatorGlobalLock::newLock($lock_key);
}
/**
* Before a write, set the "isWriting" flag.
*
* This allows us to detect when we lose a node partway through a write and
* may have committed and acknowledged a write on a node that lost the lock
* partway through the write and is no longer reachable.
*
- * In particular, if a node loses its connection to the datbase the global
+ * In particular, if a node loses its connection to the database the global
* lock is released by default. This is a durable lock which stays locked
* by default.
*/
public static function willWrite(
AphrontDatabaseConnection $locked_connection,
$repository_phid,
$device_phid,
array $write_properties,
$lock_owner) {
$version = new self();
$table = $version->getTableName();
queryfx(
$locked_connection,
'INSERT INTO %T
(repositoryPHID, devicePHID, repositoryVersion, isWriting,
writeProperties, lockOwner)
VALUES
(%s, %s, %d, %d, %s, %s)
ON DUPLICATE KEY UPDATE
isWriting = VALUES(isWriting),
writeProperties = VALUES(writeProperties),
lockOwner = VALUES(lockOwner)',
$table,
$repository_phid,
$device_phid,
0,
1,
phutil_json_encode($write_properties),
$lock_owner);
}
/**
* After a write, update the version and release the "isWriting" lock.
*/
public static function didWrite(
$repository_phid,
$device_phid,
$old_version,
$new_version,
$lock_owner) {
$version = new self();
$conn_w = $version->establishConnection('w');
$table = $version->getTableName();
queryfx(
$conn_w,
'UPDATE %T SET
repositoryVersion = %d,
isWriting = 0,
lockOwner = NULL
WHERE
repositoryPHID = %s AND
devicePHID = %s AND
repositoryVersion = %d AND
isWriting = 1 AND
lockOwner = %s',
$table,
$new_version,
$repository_phid,
$device_phid,
$old_version,
$lock_owner);
}
/**
* After a fetch, set the local version to the fetched version.
*/
public static function updateVersion(
$repository_phid,
$device_phid,
$new_version) {
$version = new self();
$conn_w = $version->establishConnection('w');
$table = $version->getTableName();
queryfx(
$conn_w,
'INSERT INTO %T
(repositoryPHID, devicePHID, repositoryVersion, isWriting)
VALUES
(%s, %s, %d, %d)
ON DUPLICATE KEY UPDATE
repositoryVersion = VALUES(repositoryVersion)',
$table,
$repository_phid,
$device_phid,
$new_version,
0);
}
/**
* Explicitly demote a device.
*/
public static function demoteDevice(
$repository_phid,
$device_phid) {
$version = new self();
$conn_w = $version->establishConnection('w');
$table = $version->getTableName();
queryfx(
$conn_w,
'DELETE FROM %T WHERE repositoryPHID = %s AND devicePHID = %s',
$table,
$repository_phid,
$device_phid);
}
}
diff --git a/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryMercurialCommitChangeParserWorker.php b/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryMercurialCommitChangeParserWorker.php
index 0fdd18b20f..60e6c5a5e6 100644
--- a/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryMercurialCommitChangeParserWorker.php
+++ b/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryMercurialCommitChangeParserWorker.php
@@ -1,308 +1,308 @@
<?php
final class PhabricatorRepositoryMercurialCommitChangeParserWorker
extends PhabricatorRepositoryCommitChangeParserWorker {
protected function parseCommitChanges(
PhabricatorRepository $repository,
PhabricatorRepositoryCommit $commit) {
list($stdout) = $repository->execxLocalCommand(
'status -C --change %s',
$commit->getCommitIdentifier());
$status = ArcanistMercurialParser::parseMercurialStatusDetails($stdout);
$common_attributes = array(
'repositoryID' => $repository->getID(),
'commitID' => $commit->getID(),
'commitSequence' => $commit->getEpoch(),
);
$changes = array();
// Like Git, Mercurial doesn't track directories directly. We need to infer
// directory creation and removal by observing file creation and removal
// and testing if the directories in question are previously empty (thus,
// created) or subsequently empty (thus, removed).
$maybe_new_directories = array();
$maybe_del_directories = array();
$all_directories = array();
// Parse the basic information from "hg status", which shows files that
// were directly affected by the change.
foreach ($status as $path => $path_info) {
$path = '/'.$path;
$flags = $path_info['flags'];
$change_target = $path_info['from'] ? '/'.$path_info['from'] : null;
$changes[$path] = array(
'path' => $path,
'isDirect' => true,
'targetPath' => $change_target,
'targetCommitID' => $change_target ? $commit->getID() : null,
// We're going to fill these in shortly.
'changeType' => null,
'fileType' => null,
'flags' => $flags,
) + $common_attributes;
if ($flags & ArcanistRepositoryAPI::FLAG_ADDED) {
$maybe_new_directories[] = dirname($path);
} else if ($flags & ArcanistRepositoryAPI::FLAG_DELETED) {
$maybe_del_directories[] = dirname($path);
}
$all_directories[] = dirname($path);
}
// Add change information for each source path which doesn't appear in the
// status. These files were copied, but were not modified. We also know they
// must exist.
foreach ($changes as $path => $change) {
$from = $change['targetPath'];
if ($from && empty($changes[$from])) {
$changes[$from] = array(
'path' => $from,
'isDirect' => false,
'targetPath' => null,
'targetCommitID' => null,
'changeType' => DifferentialChangeType::TYPE_COPY_AWAY,
'fileType' => null,
'flags' => 0,
) + $common_attributes;
}
}
$away = array();
foreach ($changes as $path => $change) {
$target_path = $change['targetPath'];
if ($target_path) {
$away[$target_path][] = $path;
}
}
// Now that we have all the direct changes, figure out change types.
foreach ($changes as $path => $change) {
$flags = $change['flags'];
$from = $change['targetPath'];
if ($from) {
$target = $changes[$from];
} else {
$target = null;
}
if ($flags & ArcanistRepositoryAPI::FLAG_ADDED) {
if ($target) {
if ($target['flags'] & ArcanistRepositoryAPI::FLAG_DELETED) {
$change_type = DifferentialChangeType::TYPE_MOVE_HERE;
} else {
$change_type = DifferentialChangeType::TYPE_COPY_HERE;
}
} else {
$change_type = DifferentialChangeType::TYPE_ADD;
}
} else if ($flags & ArcanistRepositoryAPI::FLAG_DELETED) {
if (isset($away[$path])) {
if (count($away[$path]) > 1) {
$change_type = DifferentialChangeType::TYPE_MULTICOPY;
} else {
$change_type = DifferentialChangeType::TYPE_MOVE_AWAY;
}
} else {
$change_type = DifferentialChangeType::TYPE_DELETE;
}
} else {
if (isset($away[$path])) {
$change_type = DifferentialChangeType::TYPE_COPY_AWAY;
} else {
$change_type = DifferentialChangeType::TYPE_CHANGE;
}
}
$changes[$path]['changeType'] = $change_type;
}
// Go through all the affected directories and identify any which were
// actually added or deleted.
$dir_status = array();
foreach ($maybe_del_directories as $dir) {
$exists = false;
foreach (DiffusionPathIDQuery::expandPathToRoot($dir) as $path) {
if (isset($dir_status[$path])) {
break;
}
// If we know some child exists, we know this path exists. If we don't
// know that a child exists, test if this directory still exists.
if (!$exists) {
$exists = $this->mercurialPathExists(
$repository,
$path,
$commit->getCommitIdentifier());
}
if ($exists) {
$dir_status[$path] = DifferentialChangeType::TYPE_CHILD;
} else {
$dir_status[$path] = DifferentialChangeType::TYPE_DELETE;
}
}
}
list($stdout) = $repository->execxLocalCommand(
'parents --rev %s --style default',
$commit->getCommitIdentifier());
$parents = ArcanistMercurialParser::parseMercurialLog($stdout);
$parent = reset($parents);
if ($parent) {
// TODO: We should expand this to a full 40-character hash using "hg id".
$parent = $parent['rev'];
}
foreach ($maybe_new_directories as $dir) {
$exists = false;
foreach (DiffusionPathIDQuery::expandPathToRoot($dir) as $path) {
if (isset($dir_status[$path])) {
break;
}
if (!$exists) {
if ($parent) {
$exists = $this->mercurialPathExists($repository, $path, $parent);
} else {
$exists = false;
}
}
if ($exists) {
$dir_status[$path] = DifferentialChangeType::TYPE_CHILD;
} else {
$dir_status[$path] = DifferentialChangeType::TYPE_ADD;
}
}
}
foreach ($all_directories as $dir) {
foreach (DiffusionPathIDQuery::expandPathToRoot($dir) as $path) {
if (isset($dir_status[$path])) {
break;
}
$dir_status[$path] = DifferentialChangeType::TYPE_CHILD;
}
}
// Merge all the directory statuses into the path statuses.
foreach ($dir_status as $path => $status) {
if (isset($changes[$path])) {
// TODO: The UI probably doesn't handle any of these cases with
// terrible elegance, but they are exceedingly rare.
$existing_type = $changes[$path]['changeType'];
if ($existing_type == DifferentialChangeType::TYPE_DELETE) {
// This change removes a file, replaces it with a directory, and then
// adds children of that directory. Mark it as a "change" instead,
// and make the type a directory.
$changes[$path]['fileType'] = DifferentialChangeType::FILE_DIRECTORY;
$changes[$path]['changeType'] = DifferentialChangeType::TYPE_CHANGE;
} else if ($existing_type == DifferentialChangeType::TYPE_MOVE_AWAY ||
$existing_type == DifferentialChangeType::TYPE_MULTICOPY) {
// This change moves or copies a file, replaces it with a directory,
// and then adds children to that directory. Mark it as "copy away"
// instead of whatever it was, and make the type a directory.
$changes[$path]['fileType'] = DifferentialChangeType::FILE_DIRECTORY;
$changes[$path]['changeType']
= DifferentialChangeType::TYPE_COPY_AWAY;
} else if ($existing_type == DifferentialChangeType::TYPE_ADD) {
- // This change removes a diretory and replaces it with a file. Mark
+ // This change removes a directory and replaces it with a file. Mark
// it as "change" instead of "add".
$changes[$path]['changeType'] = DifferentialChangeType::TYPE_CHANGE;
}
continue;
}
$changes[$path] = array(
'path' => $path,
'isDirect' => ($status == DifferentialChangeType::TYPE_CHILD)
? false
: true,
'fileType' => DifferentialChangeType::FILE_DIRECTORY,
'changeType' => $status,
'targetPath' => null,
'targetCommitID' => null,
) + $common_attributes;
}
// TODO: use "hg diff --git" to figure out which files are symlinks.
foreach ($changes as $path => $change) {
if (empty($change['fileType'])) {
$changes[$path]['fileType'] = DifferentialChangeType::FILE_NORMAL;
}
}
$all_paths = array();
foreach ($changes as $path => $change) {
$all_paths[$path] = true;
if ($change['targetPath']) {
$all_paths[$change['targetPath']] = true;
}
}
$path_map = $this->lookupOrCreatePaths(array_keys($all_paths));
foreach ($changes as $key => $change) {
$changes[$key]['pathID'] = $path_map[$change['path']];
if ($change['targetPath']) {
$changes[$key]['targetPathID'] = $path_map[$change['targetPath']];
} else {
$changes[$key]['targetPathID'] = null;
}
}
$results = array();
foreach ($changes as $change) {
$result = id(new PhabricatorRepositoryParsedChange())
->setPathID($change['pathID'])
->setTargetPathID($change['targetPathID'])
->setTargetCommitID($change['targetCommitID'])
->setChangeType($change['changeType'])
->setFileType($change['fileType'])
->setIsDirect($change['isDirect'])
->setCommitSequence($change['commitSequence']);
$results[] = $result;
}
return $results;
}
private function mercurialPathExists(
PhabricatorRepository $repository,
$path,
$rev) {
if ($path == '/') {
return true;
}
// NOTE: For directories, this grabs the entire directory contents, but
// we don't have any more surgical approach available to us in Mercurial.
// We can't use "log" because it doesn't have enough information for us
// to figure out when a directory is deleted by a change.
list($err) = $repository->execLocalCommand(
'cat --rev %s -- %s > /dev/null',
$rev,
$path);
if ($err) {
return false;
} else {
return true;
}
}
}
diff --git a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
index f4c460ce34..c0497a0c03 100644
--- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
+++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php
@@ -1,1444 +1,1444 @@
<?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
*/
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) {
$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 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 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 (strlen($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 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 to populate.
* @param PhabricatorSavedQuery The 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);
}
$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 infrastruture) will be put in the middle.
+ * 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 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('');
}
/**
* 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 to read user PHIDs from.
* @param string Key to read in the request.
* @param list<const> 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 to read PHIDs from.
* @param string 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 to read PHIDs from.
* @param string Key to read in the request.
* @param list<const> 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 to read strings from.
* @param string 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 (!strlen($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');
if (!strlen($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());
$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;
}
final public function renderNewUserView() {
$body = $this->getNewUserBody();
if (!$body) {
return null;
}
return $body;
}
protected function getNewUserHeader() {
return null;
}
protected function getNewUserBody() {
return null;
}
public function newUseResultsActions(PhabricatorSavedQuery $saved) {
return array();
}
}
diff --git a/src/applications/search/management/PhabricatorSearchManagementWorkflow.php b/src/applications/search/management/PhabricatorSearchManagementWorkflow.php
index 43dad986ea..b09f4b2105 100644
--- a/src/applications/search/management/PhabricatorSearchManagementWorkflow.php
+++ b/src/applications/search/management/PhabricatorSearchManagementWorkflow.php
@@ -1,26 +1,26 @@
<?php
abstract class PhabricatorSearchManagementWorkflow
extends PhabricatorManagementWorkflow {
protected function validateClusterSearchConfig() {
// Configuration is normally validated by setup self-checks on the web
- // workflow, but users may reasonsably run `bin/search` commands after
+ // workflow, but users may reasonably run `bin/search` commands after
// making manual edits to "local.json". Re-verify configuration here before
// continuing.
$config_key = 'cluster.search';
$config_value = PhabricatorEnv::getEnvConfig($config_key);
try {
PhabricatorClusterSearchConfigType::validateValue($config_value);
} catch (Exception $ex) {
throw new PhutilArgumentUsageException(
pht(
'Setting "%s" is misconfigured: %s',
$config_key,
$ex->getMessage()));
}
}
}
diff --git a/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php
index e67d22b555..071979d391 100644
--- a/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php
+++ b/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php
@@ -1,160 +1,160 @@
<?php
final class PhabricatorMotivatorProfileMenuItem
extends PhabricatorProfileMenuItem {
const MENUITEMKEY = 'motivator';
public function getMenuItemTypeIcon() {
return 'fa-coffee';
}
public function getMenuItemTypeName() {
return pht('Motivator');
}
public function canAddToObject($object) {
return ($object instanceof PhabricatorHomeApplication);
}
public function getDisplayName(
PhabricatorProfileMenuItemConfiguration $config) {
$options = $this->getOptions();
$name = idx($options, $config->getMenuItemProperty('source'));
if ($name !== null) {
return pht('Motivator: %s', $name);
} else {
return pht('Motivator');
}
}
public function buildEditEngineFields(
PhabricatorProfileMenuItemConfiguration $config) {
return array(
id(new PhabricatorInstructionsEditField())
->setValue(
pht(
'Motivate your team with inspirational quotes from great minds. '.
'This menu item shows a new quote every day.')),
id(new PhabricatorSelectEditField())
->setKey('source')
->setLabel(pht('Source'))
->setOptions($this->getOptions()),
);
}
private function getOptions() {
return array(
'catfacts' => pht('Cat Facts'),
);
}
protected function newNavigationMenuItems(
PhabricatorProfileMenuItemConfiguration $config) {
$source = $config->getMenuItemProperty('source');
switch ($source) {
case 'catfacts':
default:
$facts = $this->getCatFacts();
$fact_name = pht('Cat Facts');
$fact_icon = 'fa-paw';
break;
}
$fact_text = $this->selectFact($facts);
$item = $this->newItem()
->setName($fact_name)
->setIcon($fact_icon)
->setTooltip($fact_text)
->setHref('#');
return array(
$item,
);
}
private function getCatFacts() {
return array(
pht('Cats purr when they are happy, upset, or asleep.'),
- pht('The first cats evolved on the savanah about 8,000 years ago.'),
+ pht('The first cats evolved on the savannah about 8,000 years ago.'),
pht(
'Cats have a tail, two feet, between one and three ears, and two '.
'other feet.'),
pht('Cats use their keen sense of smell to avoid feeling empathy.'),
pht('The first cats evolved in swamps about 65 years ago.'),
pht(
'You can tell how warm a cat is by examining the coloration: cooler '.
'areas are darker.'),
pht(
'Cat tails are flexible because they contain thousands of tiny '.
'bones.'),
pht(
'A cattail is a wetland plant with an appearance that resembles '.
'the tail of a cat.'),
pht(
'Cats must eat a diet rich in fish to replace the tiny bones in '.
'their tails.'),
pht('Cats are stealthy predators and nearly invisible to radar.'),
pht(
'Cats use a special type of magnetism to help them land on their '.
'feet.'),
pht(
'A cat can run seven times faster than a human, but only for a '.
'short distance.'),
pht(
'The largest recorded cat was nearly 11 inches long from nose to '.
'tail.'),
pht(
'Not all cats can retract their claws, but most of them can.'),
pht(
- 'In the wild, cats and racoons sometimes hunt together in packs.'),
+ 'In the wild, cats and raccoons sometimes hunt together in packs.'),
pht(
'The Spanish word for cat is "cato". The biggest cat is called '.
'"el cato".'),
pht(
'The Japanese word for cat is "kome", which is also the word for '.
'rice. Japanese cats love to eat rice, so the two are synonymous.'),
pht('Cats have five pointy ends.'),
pht('cat -A can find mice hiding in files.'),
pht('A cat\'s visual, olfactory, and auditory senses, '.
'Contribute to their hunting skills and natural defenses.'),
pht(
'Cats with high self-esteem seek out high perches '.
'to launch their attacks. Watch out!'),
pht('Cats prefer vanilla ice cream.'),
pht('Taco cat spelled backwards is taco cat.'),
pht(
'Cats will often bring you their prey because they feel sorry '.
'for your inability to hunt.'),
pht('Cats spend most of their time plotting to kill their owner.'),
pht('Outside of the CAT scan, cats have made almost no contributions '.
'to modern medicine.'),
pht('In ancient Egypt, the cat-god Horus watched over all cats.'),
pht('The word "catastrophe" has no etymological relationship to the '.
'word "cat".'),
pht('Many cats appear black in low light, suffering a -2 modifier to '.
'luck rolls.'),
pht('The popular trivia game "World of Warcraft" features a race of '.
'cat people called the Khajiit.'),
);
}
private function selectFact(array $facts) {
// This is a simple pseudorandom number generator that avoids touching
// srand(), because it would seed it to a highly predictable value. It
// selects a new fact every day.
$seed = ((int)date('Y') * 366) + (int)date('z');
for ($ii = 0; $ii < 32; $ii++) {
$seed = ((1664525 * $seed) + 1013904223) % (1 << 31);
}
return $facts[$seed % count($facts)];
}
}
diff --git a/src/applications/settings/panel/PhabricatorSettingsPanel.php b/src/applications/settings/panel/PhabricatorSettingsPanel.php
index d2008f8857..19ac6fec62 100644
--- a/src/applications/settings/panel/PhabricatorSettingsPanel.php
+++ b/src/applications/settings/panel/PhabricatorSettingsPanel.php
@@ -1,301 +1,301 @@
<?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 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 unavailble in this install. By default, all panels are
+ * 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;
}
/* -( 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 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? 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/settings/query/PhabricatorUserPreferencesQuery.php b/src/applications/settings/query/PhabricatorUserPreferencesQuery.php
index de4887cbb8..1a6133724d 100644
--- a/src/applications/settings/query/PhabricatorUserPreferencesQuery.php
+++ b/src/applications/settings/query/PhabricatorUserPreferencesQuery.php
@@ -1,196 +1,196 @@
<?php
final class PhabricatorUserPreferencesQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $userPHIDs;
private $builtinKeys;
private $hasUserPHID;
private $users = array();
private $synthetic;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withHasUserPHID($is_user) {
$this->hasUserPHID = $is_user;
return $this;
}
public function withUserPHIDs(array $phids) {
$this->userPHIDs = $phids;
return $this;
}
public function withUsers(array $users) {
assert_instances_of($users, 'PhabricatorUser');
$this->users = mpull($users, null, 'getPHID');
$this->withUserPHIDs(array_keys($this->users));
return $this;
}
public function withBuiltinKeys(array $keys) {
$this->builtinKeys = $keys;
return $this;
}
/**
* Always return preferences for every queried user.
*
* If no settings exist for a user, a new empty settings object with
* appropriate defaults is returned.
*
- * @param bool True to generat synthetic preferences for missing users.
+ * @param bool True to generate synthetic preferences for missing users.
*/
public function needSyntheticPreferences($synthetic) {
$this->synthetic = $synthetic;
return $this;
}
public function newResultObject() {
return new PhabricatorUserPreferences();
}
protected function loadPage() {
$preferences = $this->loadStandardPage($this->newResultObject());
if ($this->synthetic) {
$user_map = mpull($preferences, null, 'getUserPHID');
foreach ($this->userPHIDs as $user_phid) {
if (isset($user_map[$user_phid])) {
continue;
}
$preferences[] = $this->newResultObject()
->setUserPHID($user_phid);
}
}
return $preferences;
}
protected function willFilterPage(array $prefs) {
$user_phids = mpull($prefs, 'getUserPHID');
$user_phids = array_filter($user_phids);
// If some of the preferences are attached to users, try to use any objects
// we were handed first. If we're missing some, load them.
if ($user_phids) {
$users = $this->users;
$user_phids = array_fuse($user_phids);
$load_phids = array_diff_key($user_phids, $users);
$load_phids = array_keys($load_phids);
if ($load_phids) {
$load_users = id(new PhabricatorPeopleQuery())
->setViewer($this->getViewer())
->withPHIDs($load_phids)
->execute();
$load_users = mpull($load_users, null, 'getPHID');
$users += $load_users;
}
} else {
$users = array();
}
$need_global = array();
foreach ($prefs as $key => $pref) {
$user_phid = $pref->getUserPHID();
if (!$user_phid) {
$pref->attachUser(null);
continue;
}
$need_global[] = $pref;
$user = idx($users, $user_phid);
if (!$user) {
$this->didRejectResult($pref);
unset($prefs[$key]);
continue;
}
$pref->attachUser($user);
}
// If we loaded any user preferences, load the global defaults and attach
// them if they exist.
if ($need_global) {
$global = id(new self())
->setViewer($this->getViewer())
->withBuiltinKeys(
array(
PhabricatorUserPreferences::BUILTIN_GLOBAL_DEFAULT,
))
->executeOne();
if ($global) {
foreach ($need_global as $pref) {
$pref->attachDefaultSettings($global);
}
}
}
return $prefs;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'phid IN (%Ls)',
$this->phids);
}
if ($this->userPHIDs !== null) {
$where[] = qsprintf(
$conn,
'userPHID IN (%Ls)',
$this->userPHIDs);
}
if ($this->builtinKeys !== null) {
$where[] = qsprintf(
$conn,
'builtinKey IN (%Ls)',
$this->builtinKeys);
}
if ($this->hasUserPHID !== null) {
if ($this->hasUserPHID) {
$where[] = qsprintf(
$conn,
'userPHID IS NOT NULL');
} else {
$where[] = qsprintf(
$conn,
'userPHID IS NULL');
}
}
return $where;
}
public function getQueryApplicationClass() {
return 'PhabricatorSettingsApplication';
}
}
diff --git a/src/applications/settings/setting/PhabricatorTimezoneSetting.php b/src/applications/settings/setting/PhabricatorTimezoneSetting.php
index b7a6b94e30..887e08129b 100644
--- a/src/applications/settings/setting/PhabricatorTimezoneSetting.php
+++ b/src/applications/settings/setting/PhabricatorTimezoneSetting.php
@@ -1,102 +1,102 @@
<?php
final class PhabricatorTimezoneSetting
extends PhabricatorOptionGroupSetting {
const SETTINGKEY = 'timezone';
public function getSettingName() {
return pht('Timezone');
}
public function getSettingPanelKey() {
return PhabricatorDateTimeSettingsPanel::PANELKEY;
}
protected function getSettingOrder() {
return 100;
}
protected function getControlInstructions() {
return pht('Select your local timezone.');
}
public function getSettingDefaultValue() {
return date_default_timezone_get();
}
public function assertValidValue($value) {
// NOTE: This isn't doing anything fancy, it's just a much faster
// validator than doing all the timezone calculations to build the full
// list of options.
if (!$value) {
return;
}
static $identifiers;
if ($identifiers === null) {
$identifiers = DateTimeZone::listIdentifiers();
$identifiers = array_fuse($identifiers);
}
if (isset($identifiers[$value])) {
return;
}
throw new Exception(
pht(
- 'Timezone "%s" is not a valid timezone identiifer.',
+ 'Timezone "%s" is not a valid timezone identifier.',
$value));
}
protected function getSelectOptionGroups() {
$timezones = DateTimeZone::listIdentifiers();
$now = new DateTime('@'.PhabricatorTime::getNow());
$groups = array();
foreach ($timezones as $timezone) {
$zone = new DateTimeZone($timezone);
$offset = -($zone->getOffset($now) / (60 * 60));
$groups[$offset][] = $timezone;
}
krsort($groups);
$option_groups = array(
array(
'label' => pht('Default'),
'options' => array(),
),
);
foreach ($groups as $offset => $group) {
if ($offset >= 0) {
$label = pht('UTC-%d', $offset);
} else {
$label = pht('UTC+%d', -$offset);
}
sort($group);
$option_groups[] = array(
'label' => $label,
'options' => array_fuse($group),
);
}
return $option_groups;
}
public function expandSettingTransaction($object, $xaction) {
// When the user changes their timezone, we also clear any ignored
// timezone offset.
return array(
$xaction,
$this->newSettingTransaction(
$object,
PhabricatorTimezoneIgnoreOffsetSetting::SETTINGKEY,
null),
);
}
}
diff --git a/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php
index e005c120f0..817e92ce39 100644
--- a/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php
+++ b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php
@@ -1,119 +1,119 @@
<?php
final class PhabricatorSubscriptionsEditController
extends PhabricatorController {
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getViewer();
$phid = $request->getURIData('phid');
$action = $request->getURIData('action');
if (!$request->isFormPost()) {
return new Aphront400Response();
}
switch ($action) {
case 'add':
$is_add = true;
break;
case 'delete':
$is_add = false;
break;
default:
return new Aphront400Response();
}
$handle = id(new PhabricatorHandleQuery())
->setViewer($viewer)
->withPHIDs(array($phid))
->executeOne();
$object = id(new PhabricatorObjectQuery())
->setViewer($viewer)
->withPHIDs(array($phid))
->executeOne();
if (!($object instanceof PhabricatorSubscribableInterface)) {
return $this->buildErrorResponse(
pht('Bad Object'),
pht('This object is not subscribable.'),
$handle->getURI());
}
if ($object->isAutomaticallySubscribed($viewer->getPHID())) {
return $this->buildErrorResponse(
pht('Automatically Subscribed'),
pht('You are automatically subscribed to this object.'),
$handle->getURI());
}
if (!PhabricatorPolicyFilter::canInteract($viewer, $object)) {
$lock = PhabricatorEditEngineLock::newForObject($viewer, $object);
$dialog = $this->newDialog()
->addCancelButton($handle->getURI());
return $lock->willBlockUserInteractionWithDialog($dialog);
}
if ($object instanceof PhabricatorApplicationTransactionInterface) {
if ($is_add) {
$xaction_value = array(
'+' => array($viewer->getPHID()),
);
} else {
$xaction_value = array(
'-' => array($viewer->getPHID()),
);
}
$xaction = id($object->getApplicationTransactionTemplate())
->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
->setNewValue($xaction_value);
$editor = id($object->getApplicationTransactionEditor())
->setActor($viewer)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->setContentSourceFromRequest($request);
$editor->applyTransactions(
$object->getApplicationTransactionObject(),
array($xaction));
} else {
// TODO: Eventually, get rid of this once everything implements
- // PhabriatorApplicationTransactionInterface.
+ // PhabricatorApplicationTransactionInterface.
$editor = id(new PhabricatorSubscriptionsEditor())
->setActor($viewer)
->setObject($object);
if ($is_add) {
$editor->subscribeExplicit(array($viewer->getPHID()), $explicit = true);
} else {
$editor->unsubscribe(array($viewer->getPHID()));
}
$editor->save();
}
// TODO: We should just render the "Unsubscribe" action and swap it out
// in the document for Ajax requests.
return id(new AphrontReloadResponse())->setURI($handle->getURI());
}
private function buildErrorResponse($title, $message, $uri) {
$request = $this->getRequest();
$viewer = $request->getUser();
$dialog = id(new AphrontDialogView())
->setUser($viewer)
->setTitle($title)
->appendChild($message)
->addCancelButton($uri);
return id(new AphrontDialogResponse())->setDialog($dialog);
}
}
diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php
index e1d6812778..6c9cb5e201 100644
--- a/src/applications/transactions/editengine/PhabricatorEditEngine.php
+++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php
@@ -1,2417 +1,2417 @@
<?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();
}
foreach ($extensions as $extension) {
$extension->setViewer($viewer);
if (!$extension->supportsObject($this, $object)) {
continue;
}
$extension_fields = $extension->buildCustomEditFields($this, $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);
}
$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;
}
/**
* 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;
}
private 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);
if (!strlen($first->getName())) {
$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 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;
}
/**
* Flag this workflow as a create or edit.
*
* @param bool 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 ID, PHID, or monogram.
* @param list<const> 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 Object ID.
* @param list<const> 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 Object PHID.
* @param list<const> 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 Configured query.
- * @param list<const> List of required capabilitiy constants, or omit for
+ * @param list<const> 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 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 (strlen($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 (!strlen($page_key)) {
$pages = $this->getPages($object);
if ($pages) {
$page_key = head_key($pages);
}
}
if (strlen($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();
if ($this->getIsCreate()) {
$cancel_uri = $this->getObjectCreateCancelURI($object);
$submit_button = $this->getObjectCreateButtonText($object);
} 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->isFormPost() && $request->getBool('editEngine')) {
$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)
->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);
}
}
} else {
if ($this->getIsCreate()) {
$template = $request->getStr('template');
if (strlen($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);
}
$box = id(new PHUIObjectBoxView())
->setUser($viewer)
->setHeader($box_header)
->setValidationException($validation_exception)
->setBackground(PHUIObjectBoxView::WHITE_CONFIG)
->appendChild($form);
// This is fairly questionable, but in use by Settings.
if ($request->getURIData('formSaved')) {
$box->setFormSaved(true);
}
$content = array(
$box,
$previews,
);
$view = new PHUITwoColumnView();
$page_header = $this->getPageHeader($object);
if ($page_header) {
$view->setHeader($page_header);
}
$page = $controller->newPage()
->setTitle($header_text)
->setCrumbs($crumbs)
->appendChild($view);
$navigation = $this->getNavigation();
if ($navigation) {
$view->setFixed(true);
$view->setNavigation($navigation);
$view->setMainColumn($content);
} else {
$view->setFooter($content);
}
return $page;
}
protected function newEditResponse(
AphrontRequest $request,
$object,
array $xactions) {
return id(new AphrontRedirectResponse())
->setURI($this->getEffectiveObjectEditDoneURI($object));
}
private function buildEditForm($object, array $fields) {
$viewer = $this->getViewer();
$controller = $this->getController();
$request = $controller->getRequest();
$fields = $this->willBuildEditForm($object, $fields);
$form = id(new AphrontFormView())
->setUser($viewer)
->addHiddenInput('editEngine', 'true');
foreach ($this->contextParameters as $param) {
$form->addHiddenInput($param, $request->getStr($param));
}
foreach ($fields as $field) {
$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;
}
/**
* Test if the viewer could apply a certain type of change by using the
* normal "Edit" form.
*
* This method returns `true` if the user has access to an edit form and
* that edit form has a field which applied the specified transaction type,
* and that field is visible and editable for the user.
*
* For example, you can use it to test if a user is able to reassign tasks
- * or not, prior to rendering dedicated UI for task reassingment.
+ * or not, prior to rendering dedicated UI for task reassignment.
*
* Note that this method does NOT test if the user can actually edit the
* current object, just if they have access to the related field.
*
* @param const Transaction type to test for.
* @return bool True if the user could "Edit" to apply the transaction type.
*/
final public function hasEditAccessToTransaction($xaction_type) {
$viewer = $this->getViewer();
$object = $this->getTargetObject();
if (!$object) {
$object = $this->newEditableObject();
}
$config = $this->loadDefaultEditConfiguration($object);
if (!$config) {
return false;
}
$fields = $this->buildEditFields($object);
$field = null;
foreach ($fields as $form_field) {
$field_xaction_type = $form_field->getTransactionType();
if ($field_xaction_type === $xaction_type) {
$field = $form_field;
break;
}
}
if (!$field) {
return false;
}
if (!$field->shouldReadValueFromSubmit()) {
return false;
}
return true;
}
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)id(new PhutilURI($config_uri))
->setQueryParams($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/');
$view = id(new PhabricatorApplicationTransactionCommentView())
->setUser($viewer)
->setObjectPHID($object_phid)
->setHeaderText($header_text)
->setAction($comment_uri)
->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 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 PHUIDocumentViewPro())
->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();
if (!$request->isFormPost()) {
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');
$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);
$is_empty = (!strlen($comment_text) && !$actions);
$draft
->setProperty('comment', $comment_text)
->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 (strlen($comment_text) || !$xactions) {
$xactions[] = id(clone $template)
->setTransactionType(PhabricatorTransactions::TYPE_COMMENT)
->attachComment(
id(clone $comment_template)
->setContent($comment_text));
}
$editor = $object->getApplicationTransactionEditor()
->setActor($viewer)
->setContinueOnNoEffect($request->isContinueRequest())
->setContinueOnMissingFields(true)
->setContentSourceFromRequest($request)
->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);
}
if (!$is_preview) {
PhabricatorVersionedDraft::purgeDrafts(
$object->getPHID(),
$viewer->getPHID(),
$this->loadDraftVersion($object));
$draft_engine = $this->newDraftEngine($object);
if ($draft_engine) {
$draft_engine
->setVersionedDraft(null)
->synchronize();
}
}
if ($request->isAjax() && $is_preview) {
$preview_content = $this->newCommentPreviewContent($object, $xactions);
return id(new PhabricatorApplicationTransactionResponse())
->setViewer($viewer)
->setTransactions($xactions)
->setIsPreview($is_preview)
->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)));
}
$identifier = $request->getValue('objectIdentifier');
if ($identifier) {
$this->setIsCreate(false);
$object = $this->newObjectFromIdentifier($identifier);
} else {
$this->requireCreateCapability();
$this->setIsCreate(true);
$object = $this->newEditableObject();
}
$this->validateObject($object);
$fields = $this->buildEditFields($object);
$types = $this->getConduitEditTypesFromFields($fields);
$template = $object->getApplicationTransactionTemplate();
$xactions = $this->getConduitTransactions($request, $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' => $object->getID(),
'phid' => $object->getPHID(),
),
'transactions' => $xactions_struct,
);
}
/**
* Generate transactions which can be applied from edit actions in a Conduit
* request.
*
* @param ConduitAPIRequest The request.
* @param list<PhabricatorEditType> Supported edit types.
* @param PhabricatorApplicationTransaction Template transaction.
* @return list<PhabricatorApplicationTransaction> Generated transactions.
* @task conduit
*/
private function getConduitTransactions(
ConduitAPIRequest $request,
array $types,
PhabricatorApplicationTransaction $template) {
$viewer = $request->getUser();
$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));
}
$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))));
}
}
$results = array();
if ($this->getIsCreate()) {
$results[] = id(clone $template)
->setTransactionType(PhabricatorTransactions::TYPE_CREATE);
}
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 {
$xaction['value'] = $parameter_type->getValue(
$xaction,
'value',
$request->getIsStrictlyTyped());
} 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) {
$field_type->setField($field);
$types[$field_type->getEditType()] = $field_type;
}
}
return $types;
}
public function getConduitEditTypes() {
$config = $this->loadDefaultConfiguration();
if (!$config) {
return array();
}
$object = $this->newEditableObject();
$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;
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getPHID() {
return get_class($this);
}
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::getMostOpenPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return $this->getCreateNewObjectPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
}
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
index 07edf5b0c8..175a5775ee 100644
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -1,3867 +1,3867 @@
<?php
/**
*
* Publishing and Managing State
* ======
*
* After applying changes, the Editor queues a worker to publish mail, feed,
* and notifications, and to perform other background work like updating search
* indexes. This allows it to do this work without impacting performance for
* users.
*
* When work is moved to the daemons, the Editor state is serialized by
* @{method:getWorkerState}, then reloaded in a daemon process by
* @{method:loadWorkerState}. **This is fragile.**
*
* State is not persisted into the daemons by default, because we can not send
* arbitrary objects into the queue. This means the default behavior of any
* state properties is to reset to their defaults without warning prior to
* publishing.
*
* The easiest way to avoid this is to keep Editors stateless: the overwhelming
* majority of Editors can be written statelessly. If you need to maintain
* state, you can either:
*
* - not require state to exist during publishing; or
* - pass state to the daemons by implementing @{method:getCustomWorkerState}
* and @{method:loadCustomWorkerState}.
*
* This architecture isn't ideal, and we may eventually split this class into
* "Editor" and "Publisher" parts to make it more robust. See T6367 for some
* discussion and context.
*
* @task mail Sending Mail
* @task feed Publishing Feed Stories
* @task search Search Index
* @task files Integration with Files
* @task workers Managing Workers
*/
abstract class PhabricatorApplicationTransactionEditor
extends PhabricatorEditor {
private $contentSource;
private $object;
private $xactions;
private $isNewObject;
private $mentionedPHIDs;
private $continueOnNoEffect;
private $continueOnMissingFields;
private $parentMessageID;
private $heraldAdapter;
private $heraldTranscript;
private $subscribers;
private $unmentionablePHIDMap = array();
private $applicationEmail;
private $isPreview;
private $isHeraldEditor;
private $isInverseEdgeEditor;
private $actingAsPHID;
private $disableEmail;
private $heraldEmailPHIDs = array();
private $heraldForcedEmailPHIDs = array();
private $heraldHeader;
private $mailToPHIDs = array();
private $mailCCPHIDs = array();
private $feedNotifyPHIDs = array();
private $feedRelatedPHIDs = array();
private $modularTypes;
const STORAGE_ENCODING_BINARY = 'binary';
/**
* Get the class name for the application this editor is a part of.
*
* Uninstalling the application will disable the editor.
*
* @return string Editor's application class name.
*/
abstract public function getEditorApplicationClass();
/**
* Get a description of the objects this editor edits, like "Differential
* Revisions".
*
* @return string Human readable description of edited objects.
*/
abstract public function getEditorObjectsDescription();
public function setActingAsPHID($acting_as_phid) {
$this->actingAsPHID = $acting_as_phid;
return $this;
}
public function getActingAsPHID() {
if ($this->actingAsPHID) {
return $this->actingAsPHID;
}
return $this->getActor()->getPHID();
}
/**
* When the editor tries to apply transactions that have no effect, should
* it raise an exception (default) or drop them and continue?
*
* Generally, you will set this flag for edits coming from "Edit" interfaces,
* and leave it cleared for edits coming from "Comment" interfaces, so the
* user will get a useful error if they try to submit a comment that does
* nothing (e.g., empty comment with a status change that has already been
* performed by another user).
*
* @param bool True to drop transactions without effect and continue.
* @return this
*/
public function setContinueOnNoEffect($continue) {
$this->continueOnNoEffect = $continue;
return $this;
}
public function getContinueOnNoEffect() {
return $this->continueOnNoEffect;
}
/**
* When the editor tries to apply transactions which don't populate all of
* an object's required fields, should it raise an exception (default) or
* drop them and continue?
*
* For example, if a user adds a new required custom field (like "Severity")
* to a task, all existing tasks won't have it populated. When users
* manually edit existing tasks, it's usually desirable to have them provide
* a severity. However, other operations (like batch editing just the
* owner of a task) will fail by default.
*
* By setting this flag for edit operations which apply to specific fields
* (like the priority, batch, and merge editors in Maniphest), these
* operations can continue to function even if an object is outdated.
*
* @param bool True to continue when transactions don't completely satisfy
* all required fields.
* @return this
*/
public function setContinueOnMissingFields($continue_on_missing_fields) {
$this->continueOnMissingFields = $continue_on_missing_fields;
return $this;
}
public function getContinueOnMissingFields() {
return $this->continueOnMissingFields;
}
/**
* Not strictly necessary, but reply handlers ideally set this value to
* make email threading work better.
*/
public function setParentMessageID($parent_message_id) {
$this->parentMessageID = $parent_message_id;
return $this;
}
public function getParentMessageID() {
return $this->parentMessageID;
}
public function getIsNewObject() {
return $this->isNewObject;
}
protected function getMentionedPHIDs() {
return $this->mentionedPHIDs;
}
public function setIsPreview($is_preview) {
$this->isPreview = $is_preview;
return $this;
}
public function getIsPreview() {
return $this->isPreview;
}
public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
$this->isInverseEdgeEditor = $is_inverse_edge_editor;
return $this;
}
public function getIsInverseEdgeEditor() {
return $this->isInverseEdgeEditor;
}
public function setIsHeraldEditor($is_herald_editor) {
$this->isHeraldEditor = $is_herald_editor;
return $this;
}
public function getIsHeraldEditor() {
return $this->isHeraldEditor;
}
/**
* Prevent this editor from generating email when applying transactions.
*
* @param bool True to disable email.
* @return this
*/
public function setDisableEmail($disable_email) {
$this->disableEmail = $disable_email;
return $this;
}
public function getDisableEmail() {
return $this->disableEmail;
}
public function setUnmentionablePHIDMap(array $map) {
$this->unmentionablePHIDMap = $map;
return $this;
}
public function getUnmentionablePHIDMap() {
return $this->unmentionablePHIDMap;
}
protected function shouldEnableMentions(
PhabricatorLiskDAO $object,
array $xactions) {
return true;
}
public function setApplicationEmail(
PhabricatorMetaMTAApplicationEmail $email) {
$this->applicationEmail = $email;
return $this;
}
public function getApplicationEmail() {
return $this->applicationEmail;
}
public function getTransactionTypesForObject($object) {
$old = $this->object;
try {
$this->object = $object;
$result = $this->getTransactionTypes();
$this->object = $old;
} catch (Exception $ex) {
$this->object = $old;
throw $ex;
}
return $result;
}
public function getTransactionTypes() {
$types = array();
$types[] = PhabricatorTransactions::TYPE_CREATE;
if ($this->object instanceof PhabricatorEditEngineSubtypeInterface) {
$types[] = PhabricatorTransactions::TYPE_SUBTYPE;
}
if ($this->object instanceof PhabricatorSubscribableInterface) {
$types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS;
}
if ($this->object instanceof PhabricatorCustomFieldInterface) {
$types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD;
}
if ($this->object instanceof HarbormasterBuildableInterface) {
$types[] = PhabricatorTransactions::TYPE_BUILDABLE;
}
if ($this->object instanceof PhabricatorTokenReceiverInterface) {
$types[] = PhabricatorTransactions::TYPE_TOKEN;
}
if ($this->object instanceof PhabricatorProjectInterface ||
$this->object instanceof PhabricatorMentionableInterface) {
$types[] = PhabricatorTransactions::TYPE_EDGE;
}
if ($this->object instanceof PhabricatorSpacesInterface) {
$types[] = PhabricatorTransactions::TYPE_SPACE;
}
$template = $this->object->getApplicationTransactionTemplate();
if ($template instanceof PhabricatorModularTransaction) {
$xtypes = $template->newModularTransactionTypes();
foreach ($xtypes as $xtype) {
$types[] = $xtype->getTransactionTypeConstant();
}
}
if ($template) {
try {
$comment = $template->getApplicationTransactionCommentObject();
} catch (PhutilMethodNotImplementedException $ex) {
$comment = null;
}
if ($comment) {
$types[] = PhabricatorTransactions::TYPE_COMMENT;
}
}
return $types;
}
private function adjustTransactionValues(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
if ($xaction->shouldGenerateOldValue()) {
$old = $this->getTransactionOldValue($object, $xaction);
$xaction->setOldValue($old);
}
$new = $this->getTransactionNewValue($object, $xaction);
$xaction->setNewValue($new);
}
private function getTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->generateOldValue($object);
}
switch ($type) {
case PhabricatorTransactions::TYPE_CREATE:
return null;
case PhabricatorTransactions::TYPE_SUBTYPE:
return $object->getEditEngineSubtype();
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return array_values($this->subscribers);
case PhabricatorTransactions::TYPE_VIEW_POLICY:
if ($this->getIsNewObject()) {
return null;
}
return $object->getViewPolicy();
case PhabricatorTransactions::TYPE_EDIT_POLICY:
if ($this->getIsNewObject()) {
return null;
}
return $object->getEditPolicy();
case PhabricatorTransactions::TYPE_JOIN_POLICY:
if ($this->getIsNewObject()) {
return null;
}
return $object->getJoinPolicy();
case PhabricatorTransactions::TYPE_SPACE:
if ($this->getIsNewObject()) {
return null;
}
$space_phid = $object->getSpacePHID();
if ($space_phid === null) {
$default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
if ($default_space) {
$space_phid = $default_space->getPHID();
}
}
return $space_phid;
case PhabricatorTransactions::TYPE_EDGE:
$edge_type = $xaction->getMetadataValue('edge:type');
if (!$edge_type) {
throw new Exception(
pht(
"Edge transaction has no '%s'!",
'edge:type'));
}
$old_edges = array();
if ($object->getPHID()) {
$edge_src = $object->getPHID();
$old_edges = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($edge_src))
->withEdgeTypes(array($edge_type))
->needEdgeData(true)
->execute();
$old_edges = $old_edges[$edge_src][$edge_type];
}
return $old_edges;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
// NOTE: Custom fields have their old value pre-populated when they are
// built by PhabricatorCustomFieldList.
return $xaction->getOldValue();
case PhabricatorTransactions::TYPE_COMMENT:
return null;
default:
return $this->getCustomTransactionOldValue($object, $xaction);
}
}
private function getTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->generateNewValue($object, $xaction->getNewValue());
}
switch ($type) {
case PhabricatorTransactions::TYPE_CREATE:
return null;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return $this->getPHIDTransactionNewValue($xaction);
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_BUILDABLE:
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_INLINESTATE:
case PhabricatorTransactions::TYPE_SUBTYPE:
return $xaction->getNewValue();
case PhabricatorTransactions::TYPE_SPACE:
$space_phid = $xaction->getNewValue();
if (!strlen($space_phid)) {
// If an install has no Spaces or the Spaces controls are not visible
// to the viewer, we might end up with the empty string here instead
// of a strict `null`, because some controller just used `getStr()`
// to read the space PHID from the request.
// Just make this work like callers might reasonably expect so we
// don't need to handle this specially in every EditController.
return $this->getActor()->getDefaultSpacePHID();
} else {
return $space_phid;
}
case PhabricatorTransactions::TYPE_EDGE:
$new_value = $this->getEdgeTransactionNewValue($xaction);
$edge_type = $xaction->getMetadataValue('edge:type');
$type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
if ($edge_type == $type_project) {
$new_value = $this->applyProjectConflictRules($new_value);
}
return $new_value;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->getNewValueFromApplicationTransactions($xaction);
case PhabricatorTransactions::TYPE_COMMENT:
return null;
default:
return $this->getCustomTransactionNewValue($object, $xaction);
}
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception(pht('Capability not supported!'));
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
throw new Exception(pht('Capability not supported!'));
}
protected function transactionHasEffect(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_CREATE:
return true;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->getApplicationTransactionHasEffect($xaction);
case PhabricatorTransactions::TYPE_EDGE:
// A straight value comparison here doesn't always get the right
// result, because newly added edges aren't fully populated. Instead,
// compare the changes in a more granular way.
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$old_dst = array_keys($old);
$new_dst = array_keys($new);
// NOTE: For now, we don't consider edge reordering to be a change.
// We have very few order-dependent edges and effectively no order
// oriented UI. This might change in the future.
sort($old_dst);
sort($new_dst);
if ($old_dst !== $new_dst) {
// We've added or removed edges, so this transaction definitely
// has an effect.
return true;
}
// We haven't added or removed edges, but we might have changed
// edge data.
foreach ($old as $key => $old_value) {
$new_value = $new[$key];
if ($old_value['data'] !== $new_value['data']) {
return true;
}
}
return false;
}
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
return $xtype->getTransactionHasEffect(
$object,
$xaction->getOldValue(),
$xaction->getNewValue());
}
if ($xaction->hasComment()) {
return true;
}
return ($xaction->getOldValue() !== $xaction->getNewValue());
}
protected function shouldApplyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function applyInitialEffects(
PhabricatorLiskDAO $object,
array $xactions) {
throw new PhutilMethodNotImplementedException();
}
private function applyInternalEffects(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->applyInternalEffects($object, $xaction->getNewValue());
}
switch ($type) {
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->applyApplicationTransactionInternalEffects($xaction);
case PhabricatorTransactions::TYPE_CREATE:
case PhabricatorTransactions::TYPE_SUBTYPE:
case PhabricatorTransactions::TYPE_BUILDABLE:
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
case PhabricatorTransactions::TYPE_INLINESTATE:
case PhabricatorTransactions::TYPE_EDGE:
case PhabricatorTransactions::TYPE_SPACE:
case PhabricatorTransactions::TYPE_COMMENT:
return $this->applyBuiltinInternalTransaction($object, $xaction);
}
return $this->applyCustomInternalTransaction($object, $xaction);
}
private function applyExternalEffects(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
$xtype = clone $xtype;
$xtype->setStorage($xaction);
return $xtype->applyExternalEffects($object, $xaction->getNewValue());
}
switch ($type) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$subeditor = id(new PhabricatorSubscriptionsEditor())
->setObject($object)
->setActor($this->requireActor());
$old_map = array_fuse($xaction->getOldValue());
$new_map = array_fuse($xaction->getNewValue());
$subeditor->unsubscribe(
array_keys(
array_diff_key($old_map, $new_map)));
$subeditor->subscribeExplicit(
array_keys(
array_diff_key($new_map, $old_map)));
$subeditor->save();
// for the rest of these edits, subscribers should include those just
// added as well as those just removed.
$subscribers = array_unique(array_merge(
$this->subscribers,
$xaction->getOldValue(),
$xaction->getNewValue()));
$this->subscribers = $subscribers;
return $this->applyBuiltinExternalTransaction($object, $xaction);
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->applyApplicationTransactionExternalEffects($xaction);
case PhabricatorTransactions::TYPE_CREATE:
case PhabricatorTransactions::TYPE_SUBTYPE:
case PhabricatorTransactions::TYPE_EDGE:
case PhabricatorTransactions::TYPE_BUILDABLE:
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INLINESTATE:
case PhabricatorTransactions::TYPE_SPACE:
case PhabricatorTransactions::TYPE_COMMENT:
return $this->applyBuiltinExternalTransaction($object, $xaction);
}
return $this->applyCustomExternalTransaction($object, $xaction);
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
throw new Exception(
pht(
"Transaction type '%s' is missing an internal apply implementation!",
$type));
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
throw new Exception(
pht(
"Transaction type '%s' is missing an external apply implementation!",
$type));
}
/**
* @{class:PhabricatorTransactions} provides many built-in transactions
* which should not require much - if any - code in specific applications.
*
* This method is a hook for the exceedingly-rare cases where you may need
* to do **additional** work for built-in transactions. Developers should
* extend this method, making sure to return the parent implementation
* regardless of handling any transactions.
*
* See also @{method:applyBuiltinExternalTransaction}.
*/
protected function applyBuiltinInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_VIEW_POLICY:
$object->setViewPolicy($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_EDIT_POLICY:
$object->setEditPolicy($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_JOIN_POLICY:
$object->setJoinPolicy($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_SPACE:
$object->setSpacePHID($xaction->getNewValue());
break;
case PhabricatorTransactions::TYPE_SUBTYPE:
$object->setEditEngineSubtype($xaction->getNewValue());
break;
}
}
/**
* See @{method::applyBuiltinInternalTransaction}.
*/
protected function applyBuiltinExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_EDGE:
if ($this->getIsInverseEdgeEditor()) {
// If we're writing an inverse edge transaction, don't actually
// do anything. The initiating editor on the other side of the
// transaction will take care of the edge writes.
break;
}
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$src = $object->getPHID();
$const = $xaction->getMetadataValue('edge:type');
$type = PhabricatorEdgeType::getByConstant($const);
if ($type->shouldWriteInverseTransactions()) {
$this->applyInverseEdgeTransactions(
$object,
$xaction,
$type->getInverseEdgeConstant());
}
foreach ($new as $dst_phid => $edge) {
$new[$dst_phid]['src'] = $src;
}
$editor = new PhabricatorEdgeEditor();
foreach ($old as $dst_phid => $edge) {
if (!empty($new[$dst_phid])) {
if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
continue;
}
}
$editor->removeEdge($src, $const, $dst_phid);
}
foreach ($new as $dst_phid => $edge) {
if (!empty($old[$dst_phid])) {
if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
continue;
}
}
$data = array(
'data' => $edge['data'],
);
$editor->addEdge($src, $const, $dst_phid, $data);
}
$editor->save();
$this->updateWorkboardColumns($object, $const, $old, $new);
break;
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_SPACE:
$this->scrambleFileSecrets($object);
break;
}
}
/**
* Fill in a transaction's common values, like author and content source.
*/
protected function populateTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$actor = $this->getActor();
// TODO: This needs to be more sophisticated once we have meta-policies.
$xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
if ($actor->isOmnipotent()) {
$xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE);
} else {
$xaction->setEditPolicy($this->getActingAsPHID());
}
// If the transaction already has an explicit author PHID, allow it to
// stand. This is used by applications like Owners that hook into the
// post-apply change pipeline.
if (!$xaction->getAuthorPHID()) {
$xaction->setAuthorPHID($this->getActingAsPHID());
}
$xaction->setContentSource($this->getContentSource());
$xaction->attachViewer($actor);
$xaction->attachObject($object);
if ($object->getPHID()) {
$xaction->setObjectPHID($object->getPHID());
}
return $xaction;
}
protected function didApplyInternalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
return $xactions;
}
protected function applyFinalEffects(
PhabricatorLiskDAO $object,
array $xactions) {
return $xactions;
}
public function setContentSource(PhabricatorContentSource $content_source) {
$this->contentSource = $content_source;
return $this;
}
public function setContentSourceFromRequest(AphrontRequest $request) {
return $this->setContentSource(
PhabricatorContentSource::newFromRequest($request));
}
public function getContentSource() {
return $this->contentSource;
}
final public function applyTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$this->object = $object;
$this->xactions = $xactions;
$this->isNewObject = ($object->getPHID() === null);
$this->validateEditParameters($object, $xactions);
$actor = $this->requireActor();
// NOTE: Some transaction expansion requires that the edited object be
// attached.
foreach ($xactions as $xaction) {
$xaction->attachObject($object);
$xaction->attachViewer($actor);
}
$xactions = $this->expandTransactions($object, $xactions);
$xactions = $this->expandSupportTransactions($object, $xactions);
$xactions = $this->combineTransactions($xactions);
foreach ($xactions as $xaction) {
$xaction = $this->populateTransaction($object, $xaction);
}
$is_preview = $this->getIsPreview();
$read_locking = false;
$transaction_open = false;
if (!$is_preview) {
$errors = array();
$type_map = mgroup($xactions, 'getTransactionType');
foreach ($this->getTransactionTypes() as $type) {
$type_xactions = idx($type_map, $type, array());
$errors[] = $this->validateTransaction($object, $type, $type_xactions);
}
$errors[] = $this->validateAllTransactions($object, $xactions);
$errors = array_mergev($errors);
$continue_on_missing = $this->getContinueOnMissingFields();
foreach ($errors as $key => $error) {
if ($continue_on_missing && $error->getIsMissingFieldError()) {
unset($errors[$key]);
}
}
if ($errors) {
throw new PhabricatorApplicationTransactionValidationException($errors);
}
$this->willApplyTransactions($object, $xactions);
if ($object->getID()) {
foreach ($xactions as $xaction) {
// If any of the transactions require a read lock, hold one and
// reload the object. We need to do this fairly early so that the
// call to `adjustTransactionValues()` (which populates old values)
// is based on the synchronized state of the object, which may differ
// from the state when it was originally loaded.
if ($this->shouldReadLock($object, $xaction)) {
$object->openTransaction();
$object->beginReadLocking();
$transaction_open = true;
$read_locking = true;
$object->reload();
break;
}
}
}
if ($this->shouldApplyInitialEffects($object, $xactions)) {
if (!$transaction_open) {
$object->openTransaction();
$transaction_open = true;
}
}
}
if ($this->shouldApplyInitialEffects($object, $xactions)) {
$this->applyInitialEffects($object, $xactions);
}
foreach ($xactions as $xaction) {
$this->adjustTransactionValues($object, $xaction);
}
try {
$xactions = $this->filterTransactions($object, $xactions);
} catch (Exception $ex) {
if ($read_locking) {
$object->endReadLocking();
}
if ($transaction_open) {
$object->killTransaction();
}
throw $ex;
}
// TODO: Once everything is on EditEngine, just use getIsNewObject() to
// figure this out instead.
$mark_as_create = false;
$create_type = PhabricatorTransactions::TYPE_CREATE;
foreach ($xactions as $xaction) {
if ($xaction->getTransactionType() == $create_type) {
$mark_as_create = true;
}
}
if ($mark_as_create) {
foreach ($xactions as $xaction) {
$xaction->setIsCreateTransaction(true);
}
}
// Now that we've merged, filtered, and combined transactions, check for
// required capabilities.
foreach ($xactions as $xaction) {
$this->requireCapabilities($object, $xaction);
}
$xactions = $this->sortTransactions($xactions);
$file_phids = $this->extractFilePHIDs($object, $xactions);
if ($is_preview) {
$this->loadHandles($xactions);
return $xactions;
}
$comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
->setActor($actor)
->setActingAsPHID($this->getActingAsPHID())
->setContentSource($this->getContentSource());
if (!$transaction_open) {
$object->openTransaction();
}
try {
foreach ($xactions as $xaction) {
$this->applyInternalEffects($object, $xaction);
}
$xactions = $this->didApplyInternalEffects($object, $xactions);
try {
$object->save();
} catch (AphrontDuplicateKeyQueryException $ex) {
// This callback has an opportunity to throw a better exception,
// so execution may end here.
$this->didCatchDuplicateKeyException($object, $xactions, $ex);
throw $ex;
}
foreach ($xactions as $xaction) {
$xaction->setObjectPHID($object->getPHID());
if ($xaction->getComment()) {
$xaction->setPHID($xaction->generatePHID());
$comment_editor->applyEdit($xaction, $xaction->getComment());
} else {
$xaction->save();
}
}
if ($file_phids) {
$this->attachFiles($object, $file_phids);
}
foreach ($xactions as $xaction) {
$this->applyExternalEffects($object, $xaction);
}
$xactions = $this->applyFinalEffects($object, $xactions);
if ($read_locking) {
$object->endReadLocking();
$read_locking = false;
}
$object->saveTransaction();
} catch (Exception $ex) {
$object->killTransaction();
throw $ex;
}
// If we need to perform cache engine updates, execute them now.
id(new PhabricatorCacheEngine())
->updateObject($object);
// Now that we've completely applied the core transaction set, try to apply
// Herald rules. Herald rules are allowed to either take direct actions on
// the database (like writing flags), or take indirect actions (like saving
// some targets for CC when we generate mail a little later), or return
// transactions which we'll apply normally using another Editor.
// First, check if *this* is a sub-editor which is itself applying Herald
// rules: if it is, stop working and return so we don't descend into
// madness.
// Otherwise, we're not a Herald editor, so process Herald rules (possibly
// using a Herald editor to apply resulting transactions) and then send out
// mail, notifications, and feed updates about everything.
if ($this->getIsHeraldEditor()) {
// We are the Herald editor, so stop work here and return the updated
// transactions.
return $xactions;
} else if ($this->getIsInverseEdgeEditor()) {
// If we're applying inverse edge transactions, don't trigger Herald.
// From a product perspective, the current set of inverse edges (most
// often, mentions) aren't things users would expect to trigger Herald.
// From a technical perspective, objects loaded by the inverse editor may
// not have enough data to execute rules. At least for now, just stop
// Herald from executing when applying inverse edges.
} else if ($this->shouldApplyHeraldRules($object, $xactions)) {
// We are not the Herald editor, so try to apply Herald rules.
$herald_xactions = $this->applyHeraldRules($object, $xactions);
if ($herald_xactions) {
$xscript_id = $this->getHeraldTranscript()->getID();
foreach ($herald_xactions as $herald_xaction) {
// Don't set a transcript ID if this is a transaction from another
// application or source, like Owners.
if ($herald_xaction->getAuthorPHID()) {
continue;
}
$herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
}
// NOTE: We're acting as the omnipotent user because rules deal with
// their own policy issues. We use a synthetic author PHID (the
// Herald application) as the author of record, so that transactions
// will render in a reasonable way ("Herald assigned this task ...").
$herald_actor = PhabricatorUser::getOmnipotentUser();
$herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
// TODO: It would be nice to give transactions a more specific source
// which points at the rule which generated them. You can figure this
// out from transcripts, but it would be cleaner if you didn't have to.
$herald_source = PhabricatorContentSource::newForSource(
PhabricatorHeraldContentSource::SOURCECONST);
$herald_editor = newv(get_class($this), array())
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->setParentMessageID($this->getParentMessageID())
->setIsHeraldEditor(true)
->setActor($herald_actor)
->setActingAsPHID($herald_phid)
->setContentSource($herald_source);
$herald_xactions = $herald_editor->applyTransactions(
$object,
$herald_xactions);
// Merge the new transactions into the transaction list: we want to
// send email and publish feed stories about them, too.
$xactions = array_merge($xactions, $herald_xactions);
}
// If Herald did not generate transactions, we may still need to handle
// "Send an Email" rules.
$adapter = $this->getHeraldAdapter();
$this->heraldEmailPHIDs = $adapter->getEmailPHIDs();
$this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs();
}
$xactions = $this->didApplyTransactions($object, $xactions);
if ($object instanceof PhabricatorCustomFieldInterface) {
// Maybe this makes more sense to move into the search index itself? For
// now I'm putting it here since I think we might end up with things that
// need it to be up to date once the next page loads, but if we don't go
// there we could move it into search once search moves to the daemons.
// It now happens in the search indexer as well, but the search indexer is
// always daemonized, so the logic above still potentially holds. We could
// possibly get rid of this. The major motivation for putting it in the
// indexer was to enable reindexing to work.
$fields = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
$fields->readFieldsFromStorage($object);
$fields->rebuildIndexes($object);
}
$herald_xscript = $this->getHeraldTranscript();
if ($herald_xscript) {
$herald_header = $herald_xscript->getXHeraldRulesHeader();
$herald_header = HeraldTranscript::saveXHeraldRulesHeader(
$object->getPHID(),
$herald_header);
} else {
$herald_header = HeraldTranscript::loadXHeraldRulesHeader(
$object->getPHID());
}
$this->heraldHeader = $herald_header;
// We're going to compute some of the data we'll use to publish these
// transactions here, before queueing a worker.
//
// Primarily, this is more correct: we want to publish the object as it
// exists right now. The worker may not execute for some time, and we want
// to use the current To/CC list, not respect any changes which may occur
// between now and when the worker executes.
//
// As a secondary benefit, this tends to reduce the amount of state that
// Editors need to pass into workers.
$object = $this->willPublish($object, $xactions);
if (!$this->getDisableEmail()) {
if ($this->shouldSendMail($object, $xactions)) {
$this->mailToPHIDs = $this->getMailTo($object);
$this->mailCCPHIDs = $this->getMailCC($object);
}
}
if ($this->shouldPublishFeedStory($object, $xactions)) {
$this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs($object, $xactions);
$this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs($object, $xactions);
}
PhabricatorWorker::scheduleTask(
'PhabricatorApplicationTransactionPublishWorker',
array(
'objectPHID' => $object->getPHID(),
'actorPHID' => $this->getActingAsPHID(),
'xactionPHIDs' => mpull($xactions, 'getPHID'),
'state' => $this->getWorkerState(),
),
array(
'objectPHID' => $object->getPHID(),
'priority' => PhabricatorWorker::PRIORITY_ALERTS,
));
return $xactions;
}
protected function didCatchDuplicateKeyException(
PhabricatorLiskDAO $object,
array $xactions,
Exception $ex) {
return;
}
public function publishTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$this->object = $object;
$this->xactions = $xactions;
// Hook for edges or other properties that may need (re-)loading
$object = $this->willPublish($object, $xactions);
// The object might have changed, so reassign it.
$this->object = $object;
$messages = array();
if (!$this->getDisableEmail()) {
if ($this->shouldSendMail($object, $xactions)) {
$messages = $this->buildMail($object, $xactions);
}
}
if ($this->supportsSearch()) {
PhabricatorSearchWorker::queueDocumentForIndexing(
$object->getPHID(),
array(
'transactionPHIDs' => mpull($xactions, 'getPHID'),
));
}
if ($this->shouldPublishFeedStory($object, $xactions)) {
$mailed = array();
foreach ($messages as $mail) {
foreach ($mail->buildRecipientList() as $phid) {
$mailed[$phid] = $phid;
}
}
$this->publishFeedStory($object, $xactions, $mailed);
}
// NOTE: This actually sends the mail. We do this last to reduce the chance
// that we send some mail, hit an exception, then send the mail again when
// retrying.
foreach ($messages as $mail) {
$mail->save();
}
return $xactions;
}
protected function didApplyTransactions($object, array $xactions) {
// Hook for subclasses.
return $xactions;
}
/**
* Determine if the editor should hold a read lock on the object while
* applying a transaction.
*
* If the editor does not hold a lock, two editors may read an object at the
* same time, then apply their changes without any synchronization. For most
* transactions, this does not matter much. However, it is important for some
* transactions. For example, if an object has a transaction count on it, both
* editors may read the object with `count = 23`, then independently update it
* and save the object with `count = 24` twice. This will produce the wrong
* state: the object really has 25 transactions, but the count is only 24.
*
* Generally, transactions fall into one of four buckets:
*
* - Append operations: Actions like adding a comment to an object purely
* add information to its state, and do not depend on the current object
* state in any way. These transactions never need to hold locks.
* - Overwrite operations: Actions like changing the title or description
* of an object replace the current value with a new value, so the end
* state is consistent without a lock. We currently do not lock these
* transactions, although we may in the future.
* - Edge operations: Edge and subscription operations have internal
* synchronization which limits the damage race conditions can cause.
* We do not currently lock these transactions, although we may in the
* future.
* - Update operations: Actions like incrementing a count on an object.
* These operations generally should use locks, unless it is not
* important that the state remain consistent in the presence of races.
*
* @param PhabricatorLiskDAO Object being updated.
* @param PhabricatorApplicationTransaction Transaction being applied.
* @return bool True to synchronize the edit with a lock.
*/
protected function shouldReadLock(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return false;
}
private function loadHandles(array $xactions) {
$phids = array();
foreach ($xactions as $key => $xaction) {
$phids[$key] = $xaction->getRequiredHandlePHIDs();
}
$handles = array();
$merged = array_mergev($phids);
if ($merged) {
$handles = id(new PhabricatorHandleQuery())
->setViewer($this->requireActor())
->withPHIDs($merged)
->execute();
}
foreach ($xactions as $key => $xaction) {
$xaction->setHandles(array_select_keys($handles, $phids[$key]));
}
}
private function loadSubscribers(PhabricatorLiskDAO $object) {
if ($object->getPHID() &&
($object instanceof PhabricatorSubscribableInterface)) {
$subs = PhabricatorSubscribersQuery::loadSubscribersForPHID(
$object->getPHID());
$this->subscribers = array_fuse($subs);
} else {
$this->subscribers = array();
}
}
private function validateEditParameters(
PhabricatorLiskDAO $object,
array $xactions) {
if (!$this->getContentSource()) {
throw new PhutilInvalidStateException('setContentSource');
}
// Do a bunch of sanity checks that the incoming transactions are fresh.
// They should be unsaved and have only "transactionType" and "newValue"
// set.
$types = array_fill_keys($this->getTransactionTypes(), true);
assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
foreach ($xactions as $xaction) {
if ($xaction->getPHID() || $xaction->getID()) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht('You can not apply transactions which already have IDs/PHIDs!'));
}
if ($xaction->getObjectPHID()) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'You can not apply transactions which already have %s!',
'objectPHIDs'));
}
if ($xaction->getCommentPHID()) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'You can not apply transactions which already have %s!',
'commentPHIDs'));
}
if ($xaction->getCommentVersion() !== 0) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'You can not apply transactions which already have '.
'commentVersions!'));
}
$expect_value = !$xaction->shouldGenerateOldValue();
$has_value = $xaction->hasOldValue();
if ($expect_value && !$has_value) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'This transaction is supposed to have an %s set, but it does not!',
'oldValue'));
}
if ($has_value && !$expect_value) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'This transaction should generate its %s automatically, '.
'but has already had one set!',
'oldValue'));
}
$type = $xaction->getTransactionType();
if (empty($types[$type])) {
throw new PhabricatorApplicationTransactionStructureException(
$xaction,
pht(
'Transaction has type "%s", but that transaction type is not '.
'supported by this editor (%s).',
$type,
get_class($this)));
}
}
}
protected function requireCapabilities(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
if ($this->getIsNewObject()) {
return;
}
$actor = $this->requireActor();
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
PhabricatorPolicyFilter::requireCapability(
$actor,
$object,
PhabricatorPolicyCapability::CAN_VIEW);
break;
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_SPACE:
PhabricatorPolicyFilter::requireCapability(
$actor,
$object,
PhabricatorPolicyCapability::CAN_EDIT);
break;
}
}
private function buildSubscribeTransaction(
PhabricatorLiskDAO $object,
array $xactions,
array $changes) {
if (!($object instanceof PhabricatorSubscribableInterface)) {
return null;
}
if ($this->shouldEnableMentions($object, $xactions)) {
// Identify newly mentioned users. We ignore users who were previously
// mentioned so that we don't re-subscribe users after an edit of text
// which mentions them.
$old_texts = mpull($changes, 'getOldValue');
$new_texts = mpull($changes, 'getNewValue');
$old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
$this->getActor(),
$old_texts);
$new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
$this->getActor(),
$new_texts);
$phids = array_diff($new_phids, $old_phids);
} else {
$phids = array();
}
$this->mentionedPHIDs = $phids;
if ($object->getPHID()) {
// Don't try to subscribe already-subscribed mentions: we want to generate
// a dialog about an action having no effect if the user explicitly adds
// existing CCs, but not if they merely mention existing subscribers.
$phids = array_diff($phids, $this->subscribers);
}
if ($phids) {
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->getActor())
->withPHIDs($phids)
->execute();
$users = mpull($users, null, 'getPHID');
foreach ($phids as $key => $phid) {
// Do not subscribe mentioned users
// who do not have VIEW Permissions
if ($object instanceof PhabricatorPolicyInterface
&& !PhabricatorPolicyFilter::hasCapability(
$users[$phid],
$object,
PhabricatorPolicyCapability::CAN_VIEW)
) {
unset($phids[$key]);
} else {
if ($object->isAutomaticallySubscribed($phid)) {
unset($phids[$key]);
}
}
}
$phids = array_values($phids);
}
// No else here to properly return null should we unset all subscriber
if (!$phids) {
return null;
}
$xaction = newv(get_class(head($xactions)), array());
$xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
$xaction->setNewValue(array('+' => $phids));
return $xaction;
}
protected function mergeTransactions(
PhabricatorApplicationTransaction $u,
PhabricatorApplicationTransaction $v) {
$type = $u->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
$object = $this->object;
return $xtype->mergeTransactions($object, $u, $v);
}
switch ($type) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return $this->mergePHIDOrEdgeTransactions($u, $v);
case PhabricatorTransactions::TYPE_EDGE:
$u_type = $u->getMetadataValue('edge:type');
$v_type = $v->getMetadataValue('edge:type');
if ($u_type == $v_type) {
return $this->mergePHIDOrEdgeTransactions($u, $v);
}
return null;
}
// By default, do not merge the transactions.
return null;
}
/**
* Optionally expand transactions which imply other effects. For example,
* resigning from a revision in Differential implies removing yourself as
* a reviewer.
*/
protected function expandTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$results = array();
foreach ($xactions as $xaction) {
foreach ($this->expandTransaction($object, $xaction) as $expanded) {
$results[] = $expanded;
}
}
return $results;
}
protected function expandTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return array($xaction);
}
public function getExpandedSupportTransactions(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$xactions = array($xaction);
$xactions = $this->expandSupportTransactions(
$object,
$xactions);
if (count($xactions) == 1) {
return array();
}
foreach ($xactions as $index => $cxaction) {
if ($cxaction === $xaction) {
unset($xactions[$index]);
break;
}
}
return $xactions;
}
private function expandSupportTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$this->loadSubscribers($object);
$xactions = $this->applyImplicitCC($object, $xactions);
$changes = $this->getRemarkupChanges($xactions);
$subscribe_xaction = $this->buildSubscribeTransaction(
$object,
$xactions,
$changes);
if ($subscribe_xaction) {
$xactions[] = $subscribe_xaction;
}
// TODO: For now, this is just a placeholder.
$engine = PhabricatorMarkupEngine::getEngine('extract');
$engine->setConfig('viewer', $this->requireActor());
$block_xactions = $this->expandRemarkupBlockTransactions(
$object,
$xactions,
$changes,
$engine);
foreach ($block_xactions as $xaction) {
$xactions[] = $xaction;
}
return $xactions;
}
private function getRemarkupChanges(array $xactions) {
$changes = array();
foreach ($xactions as $key => $xaction) {
foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
$changes[] = $change;
}
}
return $changes;
}
private function getRemarkupChangesFromTransaction(
PhabricatorApplicationTransaction $transaction) {
return $transaction->getRemarkupChanges();
}
private function expandRemarkupBlockTransactions(
PhabricatorLiskDAO $object,
array $xactions,
array $changes,
PhutilMarkupEngine $engine) {
$block_xactions = $this->expandCustomRemarkupBlockTransactions(
$object,
$xactions,
$changes,
$engine);
$mentioned_phids = array();
if ($this->shouldEnableMentions($object, $xactions)) {
foreach ($changes as $change) {
// Here, we don't care about processing only new mentions after an edit
// because there is no way for an object to ever "unmention" itself on
// another object, so we can ignore the old value.
$engine->markupText($change->getNewValue());
$mentioned_phids += $engine->getTextMetadata(
PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS,
array());
}
}
if (!$mentioned_phids) {
return $block_xactions;
}
$mentioned_objects = id(new PhabricatorObjectQuery())
->setViewer($this->getActor())
->withPHIDs($mentioned_phids)
->execute();
$mentionable_phids = array();
if ($this->shouldEnableMentions($object, $xactions)) {
foreach ($mentioned_objects as $mentioned_object) {
if ($mentioned_object instanceof PhabricatorMentionableInterface) {
$mentioned_phid = $mentioned_object->getPHID();
if (idx($this->getUnmentionablePHIDMap(), $mentioned_phid)) {
continue;
}
// don't let objects mention themselves
if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
continue;
}
$mentionable_phids[$mentioned_phid] = $mentioned_phid;
}
}
}
if ($mentionable_phids) {
$edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST;
$block_xactions[] = newv(get_class(head($xactions)), array())
->setIgnoreOnNoEffect(true)
->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
->setMetadataValue('edge:type', $edge_type)
->setNewValue(array('+' => $mentionable_phids));
}
return $block_xactions;
}
protected function expandCustomRemarkupBlockTransactions(
PhabricatorLiskDAO $object,
array $xactions,
array $changes,
PhutilMarkupEngine $engine) {
return array();
}
/**
* Attempt to combine similar transactions into a smaller number of total
* transactions. For example, two transactions which edit the title of an
* object can be merged into a single edit.
*/
private function combineTransactions(array $xactions) {
$stray_comments = array();
$result = array();
$types = array();
foreach ($xactions as $key => $xaction) {
$type = $xaction->getTransactionType();
if (isset($types[$type])) {
foreach ($types[$type] as $other_key) {
$other_xaction = $result[$other_key];
// Don't merge transactions with different authors. For example,
// don't merge Herald transactions and owners transactions.
if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
continue;
}
$merged = $this->mergeTransactions($result[$other_key], $xaction);
if ($merged) {
$result[$other_key] = $merged;
if ($xaction->getComment() &&
($xaction->getComment() !== $merged->getComment())) {
$stray_comments[] = $xaction->getComment();
}
if ($result[$other_key]->getComment() &&
($result[$other_key]->getComment() !== $merged->getComment())) {
$stray_comments[] = $result[$other_key]->getComment();
}
// Move on to the next transaction.
continue 2;
}
}
}
$result[$key] = $xaction;
$types[$type][] = $key;
}
// If we merged any comments away, restore them.
foreach ($stray_comments as $comment) {
$xaction = newv(get_class(head($result)), array());
$xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT);
$xaction->setComment($comment);
$result[] = $xaction;
}
return array_values($result);
}
public function mergePHIDOrEdgeTransactions(
PhabricatorApplicationTransaction $u,
PhabricatorApplicationTransaction $v) {
$result = $u->getNewValue();
foreach ($v->getNewValue() as $key => $value) {
if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) {
if (empty($result[$key])) {
$result[$key] = $value;
} else {
// We're merging two lists of edge adds, sets, or removes. Merge
// them by merging individual PHIDs within them.
$merged = $result[$key];
foreach ($value as $dst => $v_spec) {
if (empty($merged[$dst])) {
$merged[$dst] = $v_spec;
} else {
// Two transactions are trying to perform the same operation on
// the same edge. Normalize the edge data and then merge it. This
// allows transactions to specify how data merges execute in a
// precise way.
$u_spec = $merged[$dst];
if (!is_array($u_spec)) {
$u_spec = array('dst' => $u_spec);
}
if (!is_array($v_spec)) {
$v_spec = array('dst' => $v_spec);
}
$ux_data = idx($u_spec, 'data', array());
$vx_data = idx($v_spec, 'data', array());
$merged_data = $this->mergeEdgeData(
$u->getMetadataValue('edge:type'),
$ux_data,
$vx_data);
$u_spec['data'] = $merged_data;
$merged[$dst] = $u_spec;
}
}
$result[$key] = $merged;
}
} else {
$result[$key] = array_merge($value, idx($result, $key, array()));
}
}
$u->setNewValue($result);
// When combining an "ignore" transaction with a normal transaction, make
// sure we don't propagate the "ignore" flag.
if (!$v->getIgnoreOnNoEffect()) {
$u->setIgnoreOnNoEffect(false);
}
return $u;
}
protected function mergeEdgeData($type, array $u, array $v) {
return $v + $u;
}
protected function getPHIDTransactionNewValue(
PhabricatorApplicationTransaction $xaction,
$old = null) {
if ($old !== null) {
$old = array_fuse($old);
} else {
$old = array_fuse($xaction->getOldValue());
}
return $this->getPHIDList($old, $xaction->getNewValue());
}
public function getPHIDList(array $old, array $new) {
$new_add = idx($new, '+', array());
unset($new['+']);
$new_rem = idx($new, '-', array());
unset($new['-']);
$new_set = idx($new, '=', null);
if ($new_set !== null) {
$new_set = array_fuse($new_set);
}
unset($new['=']);
if ($new) {
throw new Exception(
pht(
"Invalid '%s' value for PHID transaction. Value should contain only ".
"keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
'new',
'+',
'-',
'='));
}
$result = array();
foreach ($old as $phid) {
if ($new_set !== null && empty($new_set[$phid])) {
continue;
}
$result[$phid] = $phid;
}
if ($new_set !== null) {
foreach ($new_set as $phid) {
$result[$phid] = $phid;
}
}
foreach ($new_add as $phid) {
$result[$phid] = $phid;
}
foreach ($new_rem as $phid) {
unset($result[$phid]);
}
return array_values($result);
}
protected function getEdgeTransactionNewValue(
PhabricatorApplicationTransaction $xaction) {
$new = $xaction->getNewValue();
$new_add = idx($new, '+', array());
unset($new['+']);
$new_rem = idx($new, '-', array());
unset($new['-']);
$new_set = idx($new, '=', null);
unset($new['=']);
if ($new) {
throw new Exception(
pht(
"Invalid '%s' value for Edge transaction. Value should contain only ".
"keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
'new',
'+',
'-',
'='));
}
$old = $xaction->getOldValue();
$lists = array($new_set, $new_add, $new_rem);
foreach ($lists as $list) {
$this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
}
$result = array();
foreach ($old as $dst_phid => $edge) {
if ($new_set !== null && empty($new_set[$dst_phid])) {
continue;
}
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge,
$dst_phid);
}
if ($new_set !== null) {
foreach ($new_set as $dst_phid => $edge) {
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge,
$dst_phid);
}
}
foreach ($new_add as $dst_phid => $edge) {
$result[$dst_phid] = $this->normalizeEdgeTransactionValue(
$xaction,
$edge,
$dst_phid);
}
foreach ($new_rem as $dst_phid => $edge) {
unset($result[$dst_phid]);
}
return $result;
}
private function checkEdgeList($list, $edge_type) {
if (!$list) {
return;
}
foreach ($list as $key => $item) {
if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
throw new Exception(
pht(
'Edge transactions must have destination PHIDs as in edge '.
'lists (found key "%s" on transaction of type "%s").',
$key,
$edge_type));
}
if (!is_array($item) && $item !== $key) {
throw new Exception(
pht(
'Edge transactions must have PHIDs or edge specs as values '.
'(found value "%s" on transaction of type "%s").',
$item,
$edge_type));
}
}
}
private function normalizeEdgeTransactionValue(
PhabricatorApplicationTransaction $xaction,
$edge,
$dst_phid) {
if (!is_array($edge)) {
if ($edge != $dst_phid) {
throw new Exception(
pht(
'Transaction edge data must either be the edge PHID or an edge '.
'specification dictionary.'));
}
$edge = array();
} else {
foreach ($edge as $key => $value) {
switch ($key) {
case 'src':
case 'dst':
case 'type':
case 'data':
case 'dateCreated':
case 'dateModified':
case 'seq':
case 'dataID':
break;
default:
throw new Exception(
pht(
'Transaction edge specification contains unexpected key "%s".',
$key));
}
}
}
$edge['dst'] = $dst_phid;
$edge_type = $xaction->getMetadataValue('edge:type');
if (empty($edge['type'])) {
$edge['type'] = $edge_type;
} else {
if ($edge['type'] != $edge_type) {
$this_type = $edge['type'];
throw new Exception(
pht(
"Edge transaction includes edge of type '%s', but ".
"transaction is of type '%s'. Each edge transaction ".
"must alter edges of only one type.",
$this_type,
$edge_type));
}
}
if (!isset($edge['data'])) {
$edge['data'] = array();
}
return $edge;
}
protected function sortTransactions(array $xactions) {
$head = array();
$tail = array();
// Move bare comments to the end, so the actions precede them.
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
if ($type == PhabricatorTransactions::TYPE_COMMENT) {
$tail[] = $xaction;
} else {
$head[] = $xaction;
}
}
return array_values(array_merge($head, $tail));
}
protected function filterTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
$type_comment = PhabricatorTransactions::TYPE_COMMENT;
$no_effect = array();
$has_comment = false;
$any_effect = false;
foreach ($xactions as $key => $xaction) {
if ($this->transactionHasEffect($object, $xaction)) {
if ($xaction->getTransactionType() != $type_comment) {
$any_effect = true;
}
} else if ($xaction->getIgnoreOnNoEffect()) {
unset($xactions[$key]);
} else {
$no_effect[$key] = $xaction;
}
if ($xaction->hasComment()) {
$has_comment = true;
}
}
if (!$no_effect) {
return $xactions;
}
if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
throw new PhabricatorApplicationTransactionNoEffectException(
$no_effect,
$any_effect,
$has_comment);
}
if (!$any_effect && !$has_comment) {
// If we only have empty comment transactions, just drop them all.
return array();
}
foreach ($no_effect as $key => $xaction) {
if ($xaction->hasComment()) {
$xaction->setTransactionType($type_comment);
$xaction->setOldValue(null);
$xaction->setNewValue(null);
} else {
unset($xactions[$key]);
}
}
return $xactions;
}
/**
* Hook for validating transactions. This callback will be invoked for each
* available transaction type, even if an edit does not apply any transactions
* of that type. This allows you to raise exceptions when required fields are
* missing, by detecting that the object has no field value and there is no
* transaction which sets one.
*
* @param PhabricatorLiskDAO Object being edited.
* @param string Transaction type to validate.
* @param list<PhabricatorApplicationTransaction> Transactions of given type,
* which may be empty if the edit does not apply any transactions of the
* given type.
* @return list<PhabricatorApplicationTransactionValidationError> List of
* validation errors.
*/
protected function validateTransaction(
PhabricatorLiskDAO $object,
$type,
array $xactions) {
$errors = array();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
$errors[] = $xtype->validateTransactions($object, $xactions);
}
switch ($type) {
case PhabricatorTransactions::TYPE_VIEW_POLICY:
$errors[] = $this->validatePolicyTransaction(
$object,
$xactions,
$type,
PhabricatorPolicyCapability::CAN_VIEW);
break;
case PhabricatorTransactions::TYPE_EDIT_POLICY:
$errors[] = $this->validatePolicyTransaction(
$object,
$xactions,
$type,
PhabricatorPolicyCapability::CAN_EDIT);
break;
case PhabricatorTransactions::TYPE_SPACE:
$errors[] = $this->validateSpaceTransactions(
$object,
$xactions,
$type);
break;
case PhabricatorTransactions::TYPE_SUBTYPE:
$errors[] = $this->validateSubtypeTransactions(
$object,
$xactions,
$type);
break;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$groups = array();
foreach ($xactions as $xaction) {
$groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
}
$field_list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_EDIT);
$field_list->setViewer($this->getActor());
$role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
foreach ($field_list->getFields() as $field) {
if (!$field->shouldEnableForRole($role_xactions)) {
continue;
}
$errors[] = $field->validateApplicationTransactions(
$this,
$type,
idx($groups, $field->getFieldKey(), array()));
}
break;
}
return array_mergev($errors);
}
public function validatePolicyTransaction(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type,
$capability) {
$actor = $this->requireActor();
$errors = array();
// Note $this->xactions is necessary; $xactions is $this->xactions of
// $transaction_type
$policy_object = $this->adjustObjectForPolicyChecks(
$object,
$this->xactions);
// Make sure the user isn't editing away their ability to $capability this
// object.
foreach ($xactions as $xaction) {
try {
PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
$actor,
$policy_object,
$capability,
$xaction->getNewValue());
} catch (PhabricatorPolicyException $ex) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'You can not select this %s policy, because you would no longer '.
'be able to %s the object.',
$capability,
$capability),
$xaction);
}
}
if ($this->getIsNewObject()) {
if (!$xactions) {
$has_capability = PhabricatorPolicyFilter::hasCapability(
$actor,
$policy_object,
$capability);
if (!$has_capability) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'The selected %s policy excludes you. Choose a %s policy '.
'which allows you to %s the object.',
$capability,
$capability,
$capability));
}
}
}
return $errors;
}
private function validateSpaceTransactions(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type) {
$errors = array();
$actor = $this->getActor();
$has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
$actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
$active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
$actor);
foreach ($xactions as $xaction) {
$space_phid = $xaction->getNewValue();
if ($space_phid === null) {
if (!$has_spaces) {
// The install doesn't have any spaces, so this is fine.
continue;
}
// The install has some spaces, so every object needs to be put
// in a valid space.
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht('You must choose a space for this object.'),
$xaction);
continue;
}
// If the PHID isn't `null`, it needs to be a valid space that the
// viewer can see.
if (empty($actor_spaces[$space_phid])) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'You can not shift this object in the selected space, because '.
'the space does not exist or you do not have access to it.'),
$xaction);
} else if (empty($active_spaces[$space_phid])) {
// It's OK to edit objects in an archived space, so just move on if
// we aren't adjusting the value.
$old_space_phid = $this->getTransactionOldValue($object, $xaction);
if ($space_phid == $old_space_phid) {
continue;
}
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Archived'),
pht(
'You can not shift this object into the selected space, because '.
'the space is archived. Objects can not be created inside (or '.
'moved into) archived spaces.'),
$xaction);
}
}
return $errors;
}
private function validateSubtypeTransactions(
PhabricatorLiskDAO $object,
array $xactions,
$transaction_type) {
$errors = array();
$map = $object->newEditEngineSubtypeMap();
$old = $object->getEditEngineSubtype();
foreach ($xactions as $xaction) {
$new = $xaction->getNewValue();
if ($old == $new) {
continue;
}
if (!isset($map[$new])) {
$errors[] = new PhabricatorApplicationTransactionValidationError(
$transaction_type,
pht('Invalid'),
pht(
'The subtype "%s" is not a valid subtype.',
$new),
$xaction);
continue;
}
}
return $errors;
}
protected function adjustObjectForPolicyChecks(
PhabricatorLiskDAO $object,
array $xactions) {
$copy = clone $object;
foreach ($xactions as $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$clone_xaction = clone $xaction;
$clone_xaction->setOldValue(array_values($this->subscribers));
$clone_xaction->setNewValue(
$this->getPHIDTransactionNewValue(
$clone_xaction));
PhabricatorPolicyRule::passTransactionHintToRule(
$copy,
new PhabricatorSubscriptionsSubscribersPolicyRule(),
array_fuse($clone_xaction->getNewValue()));
break;
case PhabricatorTransactions::TYPE_SPACE:
$space_phid = $this->getTransactionNewValue($object, $xaction);
$copy->setSpacePHID($space_phid);
break;
}
}
return $copy;
}
protected function validateAllTransactions(
PhabricatorLiskDAO $object,
array $xactions) {
return array();
}
/**
* Check for a missing text field.
*
* A text field is missing if the object has no value and there are no
* transactions which set a value, or if the transactions remove the value.
* This method is intended to make implementing @{method:validateTransaction}
* more convenient:
*
* $missing = $this->validateIsEmptyTextField(
* $object->getName(),
* $xactions);
*
* This will return `true` if the net effect of the object and transactions
* is an empty field.
*
* @param wild Current field value.
* @param list<PhabricatorApplicationTransaction> Transactions editing the
* field.
* @return bool True if the field will be an empty text field after edits.
*/
protected function validateIsEmptyTextField($field_value, array $xactions) {
if (strlen($field_value) && empty($xactions)) {
return false;
}
if ($xactions && strlen(last($xactions)->getNewValue())) {
return false;
}
return true;
}
/* -( Implicit CCs )------------------------------------------------------- */
/**
* When a user interacts with an object, we might want to add them to CC.
*/
final public function applyImplicitCC(
PhabricatorLiskDAO $object,
array $xactions) {
if (!($object instanceof PhabricatorSubscribableInterface)) {
// If the object isn't subscribable, we can't CC them.
return $xactions;
}
$actor_phid = $this->getActingAsPHID();
$type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
if (phid_get_type($actor_phid) != $type_user) {
// Transactions by application actors like Herald, Harbormaster and
// Diffusion should not CC the applications.
return $xactions;
}
if ($object->isAutomaticallySubscribed($actor_phid)) {
// If they're auto-subscribed, don't CC them.
return $xactions;
}
$should_cc = false;
foreach ($xactions as $xaction) {
if ($this->shouldImplyCC($object, $xaction)) {
$should_cc = true;
break;
}
}
if (!$should_cc) {
// Only some types of actions imply a CC (like adding a comment).
return $xactions;
}
if ($object->getPHID()) {
if (isset($this->subscribers[$actor_phid])) {
// If the user is already subscribed, don't implicitly CC them.
return $xactions;
}
$unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
$unsub = array_fuse($unsub);
if (isset($unsub[$actor_phid])) {
// If the user has previously unsubscribed from this object explicitly,
// don't implicitly CC them.
return $xactions;
}
}
$xaction = newv(get_class(head($xactions)), array());
$xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
$xaction->setNewValue(array('+' => array($actor_phid)));
array_unshift($xactions, $xaction);
return $xactions;
}
protected function shouldImplyCC(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return $xaction->isCommentTransaction();
}
/* -( Sending Mail )------------------------------------------------------- */
/**
* @task mail
*/
protected function shouldSendMail(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
/**
* @task mail
*/
private function buildMail(
PhabricatorLiskDAO $object,
array $xactions) {
$email_to = $this->mailToPHIDs;
$email_cc = $this->mailCCPHIDs;
$email_cc = array_merge($email_cc, $this->heraldEmailPHIDs);
$targets = $this->buildReplyHandler($object)
->getMailTargets($email_to, $email_cc);
// Set this explicitly before we start swapping out the effective actor.
$this->setActingAsPHID($this->getActingAsPHID());
$messages = array();
foreach ($targets as $target) {
$original_actor = $this->getActor();
$viewer = $target->getViewer();
$this->setActor($viewer);
$locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation());
$caught = null;
$mail = null;
try {
// Reload handles for the new viewer.
$this->loadHandles($xactions);
$mail = $this->buildMailForTarget($object, $xactions, $target);
} catch (Exception $ex) {
$caught = $ex;
}
$this->setActor($original_actor);
unset($locale);
if ($caught) {
throw $ex;
}
if ($mail) {
$messages[] = $mail;
}
}
$this->runHeraldMailRules($messages);
return $messages;
}
private function buildMailForTarget(
PhabricatorLiskDAO $object,
array $xactions,
PhabricatorMailTarget $target) {
// Check if any of the transactions are visible for this viewer. If we
// don't have any visible transactions, don't send the mail.
$any_visible = false;
foreach ($xactions as $xaction) {
if (!$xaction->shouldHideForMail($xactions)) {
$any_visible = true;
break;
}
}
if (!$any_visible) {
return null;
}
$mail = $this->buildMailTemplate($object);
$body = $this->buildMailBody($object, $xactions);
$mail_tags = $this->getMailTags($object, $xactions);
$action = $this->getMailAction($object, $xactions);
if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) {
$this->addEmailPreferenceSectionToMailBody(
$body,
$object,
$xactions);
}
$mail
->setSensitiveContent(false)
->setFrom($this->getActingAsPHID())
->setSubjectPrefix($this->getMailSubjectPrefix())
->setVarySubjectPrefix('['.$action.']')
->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
->setRelatedPHID($object->getPHID())
->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs)
->setMailTags($mail_tags)
->setIsBulk(true)
->setBody($body->render())
->setHTMLBody($body->renderHTML());
foreach ($body->getAttachments() as $attachment) {
$mail->addAttachment($attachment);
}
if ($this->heraldHeader) {
$mail->addHeader('X-Herald-Rules', $this->heraldHeader);
}
if ($object instanceof PhabricatorProjectInterface) {
$this->addMailProjectMetadata($object, $mail);
}
if ($this->getParentMessageID()) {
$mail->setParentMessageID($this->getParentMessageID());
}
return $target->willSendMail($mail);
}
private function addMailProjectMetadata(
PhabricatorLiskDAO $object,
PhabricatorMetaMTAMail $template) {
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
if (!$project_phids) {
return;
}
// TODO: This viewer isn't quite right. It would be slightly better to use
// the mail recipient, but that's not very easy given the way rendering
// works today.
$handles = id(new PhabricatorHandleQuery())
->setViewer($this->requireActor())
->withPHIDs($project_phids)
->execute();
$project_tags = array();
foreach ($handles as $handle) {
if (!$handle->isComplete()) {
continue;
}
$project_tags[] = '<'.$handle->getObjectName().'>';
}
if (!$project_tags) {
return;
}
$project_tags = implode(', ', $project_tags);
$template->addHeader('X-Phabricator-Projects', $project_tags);
}
protected function getMailThreadID(PhabricatorLiskDAO $object) {
return $object->getPHID();
}
/**
* @task mail
*/
protected function getStrongestAction(
PhabricatorLiskDAO $object,
array $xactions) {
return last(msort($xactions, 'getActionStrength'));
}
/**
* @task mail
*/
protected function buildReplyHandler(PhabricatorLiskDAO $object) {
throw new Exception(pht('Capability not supported.'));
}
/**
* @task mail
*/
protected function getMailSubjectPrefix() {
throw new Exception(pht('Capability not supported.'));
}
/**
* @task mail
*/
protected function getMailTags(
PhabricatorLiskDAO $object,
array $xactions) {
$tags = array();
foreach ($xactions as $xaction) {
$tags[] = $xaction->getMailTags();
}
return array_mergev($tags);
}
/**
* @task mail
*/
public function getMailTagsMap() {
// TODO: We should move shared mail tags, like "comment", here.
return array();
}
/**
* @task mail
*/
protected function getMailAction(
PhabricatorLiskDAO $object,
array $xactions) {
return $this->getStrongestAction($object, $xactions)->getActionName();
}
/**
* @task mail
*/
protected function buildMailTemplate(PhabricatorLiskDAO $object) {
throw new Exception(pht('Capability not supported.'));
}
/**
* @task mail
*/
protected function getMailTo(PhabricatorLiskDAO $object) {
throw new Exception(pht('Capability not supported.'));
}
/**
* @task mail
*/
protected function getMailCC(PhabricatorLiskDAO $object) {
$phids = array();
$has_support = false;
if ($object instanceof PhabricatorSubscribableInterface) {
$phid = $object->getPHID();
$phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid);
$has_support = true;
}
if ($object instanceof PhabricatorProjectInterface) {
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
if ($project_phids) {
$projects = id(new PhabricatorProjectQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($project_phids)
->needWatchers(true)
->execute();
$watcher_phids = array();
foreach ($projects as $project) {
foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
$watcher_phids[$phid] = $phid;
}
}
if ($watcher_phids) {
// We need to do a visibility check for all the watchers, as
// watching a project is not a guarantee that you can see objects
// associated with it.
$users = id(new PhabricatorPeopleQuery())
->setViewer($this->requireActor())
->withPHIDs($watcher_phids)
->execute();
$watchers = array();
foreach ($users as $user) {
$can_see = PhabricatorPolicyFilter::hasCapability(
$user,
$object,
PhabricatorPolicyCapability::CAN_VIEW);
if ($can_see) {
$watchers[] = $user->getPHID();
}
}
$phids[] = $watchers;
}
}
$has_support = true;
}
if (!$has_support) {
throw new Exception(
pht('The object being edited does not implement any standard '.
'interfaces (like PhabricatorSubscribableInterface) which allow '.
'CCs to be generated automatically. Override the "getMailCC()" '.
'method and generate CCs explicitly.'));
}
return array_mergev($phids);
}
/**
* @task mail
*/
protected function buildMailBody(
PhabricatorLiskDAO $object,
array $xactions) {
$body = new PhabricatorMetaMTAMailBody();
$body->setViewer($this->requireActor());
$this->addHeadersAndCommentsToMailBody($body, $xactions);
$this->addCustomFieldsToMailBody($body, $object, $xactions);
return $body;
}
/**
* @task mail
*/
protected function addEmailPreferenceSectionToMailBody(
PhabricatorMetaMTAMailBody $body,
PhabricatorLiskDAO $object,
array $xactions) {
$href = PhabricatorEnv::getProductionURI(
'/settings/panel/emailpreferences/');
$body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
}
/**
* @task mail
*/
protected function addHeadersAndCommentsToMailBody(
PhabricatorMetaMTAMailBody $body,
array $xactions,
$object_label = null,
$object_href = null) {
$headers = array();
$headers_html = array();
$comments = array();
$details = array();
foreach ($xactions as $xaction) {
if ($xaction->shouldHideForMail($xactions)) {
continue;
}
$header = $xaction->getTitleForMail();
if ($header !== null) {
$headers[] = $header;
}
$header_html = $xaction->getTitleForHTMLMail();
if ($header_html !== null) {
$headers_html[] = $header_html;
}
$comment = $xaction->getBodyForMail();
if ($comment !== null) {
$comments[] = $comment;
}
if ($xaction->hasChangeDetailsForMail()) {
$details[] = $xaction;
}
}
$headers_text = implode("\n", $headers);
$body->addRawPlaintextSection($headers_text);
$headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
$header_button = null;
if ($object_label !== null) {
$button_style = array(
'text-decoration: none;',
'padding: 4px 8px;',
'margin: 0 8px 8px;',
'float: right;',
'color: #464C5C;',
'font-weight: bold;',
'border-radius: 3px;',
'background-color: #F7F7F9;',
'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
'display: inline-block;',
'border: 1px solid rgba(71,87,120,.2);',
);
$header_button = phutil_tag(
'a',
array(
'style' => implode(' ', $button_style),
'href' => $object_href,
),
$object_label);
}
$xactions_style = array(
);
$header_action = phutil_tag(
'td',
array(),
$header_button);
$header_action = phutil_tag(
'td',
array(
'style' => implode(' ', $xactions_style),
),
array(
$headers_html,
// Add an extra newline to prevent the "View Object" button from
// running into the transaction text in Mail.app text snippet
// previews.
"\n",
));
$headers_html = phutil_tag(
'table',
array(),
phutil_tag('tr', array(), array($header_action, $header_button)));
$body->addRawHTMLSection($headers_html);
foreach ($comments as $comment) {
$body->addRemarkupSection(null, $comment);
}
foreach ($details as $xaction) {
$details = $xaction->renderChangeDetailsForMail($body->getViewer());
if ($details !== null) {
$label = $this->getMailDiffSectionHeader($xaction);
$body->addHTMLSection($label, $details);
}
}
}
private function getMailDiffSectionHeader($xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
return $xtype->getMailDiffSectionHeader();
}
return pht('EDIT DETAILS');
}
/**
* @task mail
*/
protected function addCustomFieldsToMailBody(
PhabricatorMetaMTAMailBody $body,
PhabricatorLiskDAO $object,
array $xactions) {
if ($object instanceof PhabricatorCustomFieldInterface) {
$field_list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_TRANSACTIONMAIL);
$field_list->setViewer($this->getActor());
$field_list->readFieldsFromStorage($object);
foreach ($field_list->getFields() as $field) {
$field->updateTransactionMailBody(
$body,
$this,
$xactions);
}
}
}
/**
* @task mail
*/
private function runHeraldMailRules(array $messages) {
foreach ($messages as $message) {
$engine = new HeraldEngine();
$adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
->setObject($message);
$rules = $engine->loadRulesForAdapter($adapter);
$effects = $engine->applyRules($rules, $adapter);
$engine->applyEffects($effects, $adapter, $rules);
}
}
/* -( Publishing Feed Stories )-------------------------------------------- */
/**
* @task feed
*/
protected function shouldPublishFeedStory(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
/**
* @task feed
*/
protected function getFeedStoryType() {
return 'PhabricatorApplicationTransactionFeedStory';
}
/**
* @task feed
*/
protected function getFeedRelatedPHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
$phids = array(
$object->getPHID(),
$this->getActingAsPHID(),
);
if ($object instanceof PhabricatorProjectInterface) {
$project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
foreach ($project_phids as $project_phid) {
$phids[] = $project_phid;
}
}
return $phids;
}
/**
* @task feed
*/
protected function getFeedNotifyPHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
return array_unique(array_merge(
$this->getMailTo($object),
$this->getMailCC($object)));
}
/**
* @task feed
*/
protected function getFeedStoryData(
PhabricatorLiskDAO $object,
array $xactions) {
$xactions = msort($xactions, 'getActionStrength');
$xactions = array_reverse($xactions);
return array(
'objectPHID' => $object->getPHID(),
'transactionPHIDs' => mpull($xactions, 'getPHID'),
);
}
/**
* @task feed
*/
protected function publishFeedStory(
PhabricatorLiskDAO $object,
array $xactions,
array $mailed_phids) {
$xactions = mfilter($xactions, 'shouldHideForFeed', true);
if (!$xactions) {
return;
}
$related_phids = $this->feedRelatedPHIDs;
$subscribed_phids = $this->feedNotifyPHIDs;
$story_type = $this->getFeedStoryType();
$story_data = $this->getFeedStoryData($object, $xactions);
id(new PhabricatorFeedStoryPublisher())
->setStoryType($story_type)
->setStoryData($story_data)
->setStoryTime(time())
->setStoryAuthorPHID($this->getActingAsPHID())
->setRelatedPHIDs($related_phids)
->setPrimaryObjectPHID($object->getPHID())
->setSubscribedPHIDs($subscribed_phids)
->setMailRecipientPHIDs($mailed_phids)
->setMailTags($this->getMailTags($object, $xactions))
->publish();
}
/* -( Search Index )------------------------------------------------------- */
/**
* @task search
*/
protected function supportsSearch() {
return false;
}
/* -( Herald Integration )-------------------------------------------------- */
protected function shouldApplyHeraldRules(
PhabricatorLiskDAO $object,
array $xactions) {
return false;
}
protected function buildHeraldAdapter(
PhabricatorLiskDAO $object,
array $xactions) {
throw new Exception(pht('No herald adapter specified.'));
}
private function setHeraldAdapter(HeraldAdapter $adapter) {
$this->heraldAdapter = $adapter;
return $this;
}
protected function getHeraldAdapter() {
return $this->heraldAdapter;
}
private function setHeraldTranscript(HeraldTranscript $transcript) {
$this->heraldTranscript = $transcript;
return $this;
}
protected function getHeraldTranscript() {
return $this->heraldTranscript;
}
private function applyHeraldRules(
PhabricatorLiskDAO $object,
array $xactions) {
$adapter = $this->buildHeraldAdapter($object, $xactions)
->setContentSource($this->getContentSource())
->setIsNewObject($this->getIsNewObject())
->setAppliedTransactions($xactions);
if ($this->getApplicationEmail()) {
$adapter->setApplicationEmail($this->getApplicationEmail());
}
$xscript = HeraldEngine::loadAndApplyRules($adapter);
$this->setHeraldAdapter($adapter);
$this->setHeraldTranscript($xscript);
if ($adapter instanceof HarbormasterBuildableAdapterInterface) {
HarbormasterBuildable::applyBuildPlans(
$adapter->getHarbormasterBuildablePHID(),
$adapter->getHarbormasterContainerPHID(),
$adapter->getQueuedHarbormasterBuildRequests());
}
return array_merge(
$this->didApplyHeraldRules($object, $adapter, $xscript),
$adapter->getQueuedTransactions());
}
protected function didApplyHeraldRules(
PhabricatorLiskDAO $object,
HeraldAdapter $adapter,
HeraldTranscript $transcript) {
return array();
}
/* -( Custom Fields )------------------------------------------------------ */
/**
* @task customfield
*/
private function getCustomFieldForTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$field_key = $xaction->getMetadataValue('customfield:key');
if (!$field_key) {
throw new Exception(
pht(
"Custom field transaction has no '%s'!",
'customfield:key'));
}
$field = PhabricatorCustomField::getObjectField(
$object,
PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
$field_key);
if (!$field) {
throw new Exception(
pht(
"Custom field transaction has invalid '%s'; field '%s' ".
"is disabled or does not exist.",
'customfield:key',
$field_key));
}
if (!$field->shouldAppearInApplicationTransactions()) {
throw new Exception(
pht(
"Custom field transaction '%s' does not implement ".
"integration for %s.",
$field_key,
'ApplicationTransactions'));
}
$field->setViewer($this->getActor());
return $field;
}
/* -( Files )-------------------------------------------------------------- */
/**
* Extract the PHIDs of any files which these transactions attach.
*
* @task files
*/
private function extractFilePHIDs(
PhabricatorLiskDAO $object,
array $xactions) {
$changes = $this->getRemarkupChanges($xactions);
$blocks = mpull($changes, 'getNewValue');
$phids = array();
if ($blocks) {
$phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
$this->getActor(),
$blocks);
}
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if ($xtype) {
$phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
} else {
$phids[] = $this->extractFilePHIDsFromCustomTransaction(
$object,
$xaction);
}
}
$phids = array_unique(array_filter(array_mergev($phids)));
if (!$phids) {
return array();
}
// Only let a user attach files they can actually see, since this would
// otherwise let you access any file by attaching it to an object you have
// view permission on.
$files = id(new PhabricatorFileQuery())
->setViewer($this->getActor())
->withPHIDs($phids)
->execute();
return mpull($files, 'getPHID');
}
/**
* @task files
*/
protected function extractFilePHIDsFromCustomTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
return array();
}
/**
* @task files
*/
private function attachFiles(
PhabricatorLiskDAO $object,
array $file_phids) {
if (!$file_phids) {
return;
}
$editor = new PhabricatorEdgeEditor();
$src = $object->getPHID();
$type = PhabricatorObjectHasFileEdgeType::EDGECONST;
foreach ($file_phids as $dst) {
$editor->addEdge($src, $type, $dst);
}
$editor->save();
}
private function applyInverseEdgeTransactions(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction,
$inverse_type) {
$old = $xaction->getOldValue();
$new = $xaction->getNewValue();
$add = array_keys(array_diff_key($new, $old));
$rem = array_keys(array_diff_key($old, $new));
$add = array_fuse($add);
$rem = array_fuse($rem);
$all = $add + $rem;
$nodes = id(new PhabricatorObjectQuery())
->setViewer($this->requireActor())
->withPHIDs($all)
->execute();
foreach ($nodes as $node) {
if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
continue;
}
if ($node instanceof PhabricatorUser) {
// TODO: At least for now, don't record inverse edge transactions
// for users (for example, "alincoln joined project X"): Feed fills
// this role instead.
continue;
}
$editor = $node->getApplicationTransactionEditor();
$template = $node->getApplicationTransactionTemplate();
$target = $node->getApplicationTransactionObject();
if (isset($add[$node->getPHID()])) {
$edge_edit_type = '+';
} else {
$edge_edit_type = '-';
}
$template
->setTransactionType($xaction->getTransactionType())
->setMetadataValue('edge:type', $inverse_type)
->setNewValue(
array(
$edge_edit_type => array($object->getPHID() => $object->getPHID()),
));
$editor
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->setParentMessageID($this->getParentMessageID())
->setIsInverseEdgeEditor(true)
->setActor($this->requireActor())
->setActingAsPHID($this->getActingAsPHID())
->setContentSource($this->getContentSource());
$editor->applyTransactions($target, array($template));
}
}
/* -( Workers )------------------------------------------------------------ */
/**
* Load any object state which is required to publish transactions.
*
* This hook is invoked in the main process before we compute data related
* to publishing transactions (like email "To" and "CC" lists), and again in
* the worker before publishing occurs.
*
* @return object Publishable object.
* @task workers
*/
protected function willPublish(PhabricatorLiskDAO $object, array $xactions) {
return $object;
}
/**
* Convert the editor state to a serializable dictionary which can be passed
* to a worker.
*
* This data will be loaded with @{method:loadWorkerState} in the worker.
*
* @return dict<string, wild> Serializable editor state.
* @task workers
*/
final private function getWorkerState() {
$state = array();
foreach ($this->getAutomaticStateProperties() as $property) {
$state[$property] = $this->$property;
}
$custom_state = $this->getCustomWorkerState();
$custom_encoding = $this->getCustomWorkerStateEncoding();
$state += array(
'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
'custom.encoding' => $custom_encoding,
);
return $state;
}
/**
* Hook; return custom properties which need to be passed to workers.
*
* @return dict<string, wild> Custom properties.
* @task workers
*/
protected function getCustomWorkerState() {
return array();
}
/**
* Hook; return storage encoding for custom properties which need to be
* passed to workers.
*
* This primarily allows binary data to be passed to workers and survive
* JSON encoding.
*
* @return dict<string, string> Property encodings.
* @task workers
*/
protected function getCustomWorkerStateEncoding() {
return array();
}
/**
* Load editor state using a dictionary emitted by @{method:getWorkerState}.
*
* This method is used to load state when running worker operations.
*
* @param dict<string, wild> Editor state, from @{method:getWorkerState}.
* @return this
* @task workers
*/
final public function loadWorkerState(array $state) {
foreach ($this->getAutomaticStateProperties() as $property) {
$this->$property = idx($state, $property);
}
$exclude = idx($state, 'excludeMailRecipientPHIDs', array());
$this->setExcludeMailRecipientPHIDs($exclude);
$custom_state = idx($state, 'custom', array());
$custom_encodings = idx($state, 'custom.encoding', array());
$custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
$this->loadCustomWorkerState($custom);
return $this;
}
/**
* Hook; set custom properties on the editor from data emitted by
* @{method:getCustomWorkerState}.
*
* @param dict<string, wild> Custom state,
* from @{method:getCustomWorkerState}.
* @return this
* @task workers
*/
protected function loadCustomWorkerState(array $state) {
return $this;
}
/**
* Get a list of object properties which should be automatically sent to
* workers in the state data.
*
* These properties will be automatically stored and loaded by the editor in
* the worker.
*
* @return list<string> List of properties.
* @task workers
*/
private function getAutomaticStateProperties() {
return array(
'parentMessageID',
'disableEmail',
'isNewObject',
'heraldEmailPHIDs',
'heraldForcedEmailPHIDs',
'heraldHeader',
'mailToPHIDs',
'mailCCPHIDs',
'feedNotifyPHIDs',
'feedRelatedPHIDs',
);
}
/**
* Apply encodings prior to storage.
*
* See @{method:getCustomWorkerStateEncoding}.
*
* @param map<string, wild> Map of values to encode.
* @param map<string, string> Map of encodings to apply.
* @return map<string, wild> Map of encoded values.
* @task workers
*/
final private function encodeStateForStorage(
array $state,
array $encodings) {
foreach ($state as $key => $value) {
$encoding = idx($encodings, $key);
switch ($encoding) {
case self::STORAGE_ENCODING_BINARY:
// The mechanics of this encoding (serialize + base64) are a little
// awkward, but it allows us encode arrays and still be JSON-safe
// with binary data.
$value = @serialize($value);
if ($value === false) {
throw new Exception(
pht(
'Failed to serialize() value for key "%s".',
$key));
}
$value = base64_encode($value);
if ($value === false) {
throw new Exception(
pht(
'Failed to base64 encode value for key "%s".',
$key));
}
break;
}
$state[$key] = $value;
}
return $state;
}
/**
* Undo storage encoding applied when storing state.
*
* See @{method:getCustomWorkerStateEncoding}.
*
* @param map<string, wild> Map of encoded values.
* @param map<string, string> Map of encodings.
* @return map<string, wild> Map of decoded values.
* @task workers
*/
final private function decodeStateFromStorage(
array $state,
array $encodings) {
foreach ($state as $key => $value) {
$encoding = idx($encodings, $key);
switch ($encoding) {
case self::STORAGE_ENCODING_BINARY:
$value = base64_decode($value);
if ($value === false) {
throw new Exception(
pht(
'Failed to base64_decode() value for key "%s".',
$key));
}
$value = unserialize($value);
break;
}
$state[$key] = $value;
}
return $state;
}
/**
* Remove conflicts from a list of projects.
*
* Objects aren't allowed to be tagged with multiple milestones in the same
* group, nor projects such that one tag is the ancestor of any other tag.
* If the list of PHIDs include mutually exclusive projects, remove the
* conflicting projects.
*
* @param list<phid> List of project PHIDs.
* @return list<phid> List with conflicts removed.
*/
private function applyProjectConflictRules(array $phids) {
if (!$phids) {
return array();
}
// Overall, the last project in the list wins in cases of conflict (so when
// you add something, the thing you just added sticks and removes older
// values).
// Beyond that, there are two basic cases:
// Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
// If multiple projects are milestones of the same parent, we only keep the
// last one.
// Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
// in the list, we remove "A" and keep "A > B". If "A" comes later, we
// remove "A > B" and keep "A".
// Note that it's OK to be in "A > B" and "A > C". There's only a conflict
// if one project is an ancestor of another. It's OK to have something
// tagged with multiple projects which share a common ancestor, so long as
// they are not mutual ancestors.
$viewer = PhabricatorUser::getOmnipotentUser();
$projects = id(new PhabricatorProjectQuery())
->setViewer($viewer)
->withPHIDs(array_keys($phids))
->execute();
$projects = mpull($projects, null, 'getPHID');
// We're going to build a map from each project with milestones to the last
// milestone in the list. This last milestone is the milestone we'll keep.
$milestone_map = array();
// We're going to build a set of the projects which have no descendants
// later in the list. This allows us to apply both ancestor rules.
$ancestor_map = array();
foreach ($phids as $phid => $ignored) {
$project = idx($projects, $phid);
if (!$project) {
continue;
}
// This is the last milestone we've seen, so set it as the selection for
// the project's parent. This might be setting a new value or overwriting
// an earlier value.
if ($project->isMilestone()) {
$parent_phid = $project->getParentProjectPHID();
$milestone_map[$parent_phid] = $phid;
}
// Since this is the last item in the list we've examined so far, add it
// to the set of projects with no later descendants.
$ancestor_map[$phid] = $phid;
// Remove any ancestors from the set, since this is a later descendant.
foreach ($project->getAncestorProjects() as $ancestor) {
$ancestor_phid = $ancestor->getPHID();
unset($ancestor_map[$ancestor_phid]);
}
}
// Now that we've built the maps, we can throw away all the projects which
// have conflicts.
foreach ($phids as $phid => $ignored) {
$project = idx($projects, $phid);
if (!$project) {
// If a PHID is invalid, we just leave it as-is. We could clean it up,
// but leaving it untouched is less likely to cause collateral damage.
continue;
}
// If this was a milestone, check if it was the last milestone from its
// group in the list. If not, remove it from the list.
if ($project->isMilestone()) {
$parent_phid = $project->getParentProjectPHID();
if ($milestone_map[$parent_phid] !== $phid) {
unset($phids[$phid]);
continue;
}
}
// If a later project in the list is a subproject of this one, it will
// have removed ancestors from the map. If this project does not point
// at itself in the ancestor map, it should be discarded in favor of a
// subproject that comes later.
if (idx($ancestor_map, $phid) !== $phid) {
unset($phids[$phid]);
continue;
}
// If a later project in the list is an ancestor of this one, it will
// have added itself to the map. If any ancestor of this project points
- // at itself in the map, this project should be dicarded in favor of
+ // at itself in the map, this project should be discarded in favor of
// that later ancestor.
foreach ($project->getAncestorProjects() as $ancestor) {
$ancestor_phid = $ancestor->getPHID();
if (isset($ancestor_map[$ancestor_phid])) {
unset($phids[$phid]);
continue 2;
}
}
}
return $phids;
}
/**
* When the view policy for an object is changed, scramble the secret keys
* for attached files to invalidate existing URIs.
*/
private function scrambleFileSecrets($object) {
// If this is a newly created object, we don't need to scramble anything
// since it couldn't have been previously published.
if ($this->getIsNewObject()) {
return;
}
// If the object is a file itself, scramble it.
if ($object instanceof PhabricatorFile) {
if ($this->shouldScramblePolicy($object->getViewPolicy())) {
$object->scrambleSecret();
$object->save();
}
}
$phid = $object->getPHID();
$attached_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$phid,
PhabricatorObjectHasFileEdgeType::EDGECONST);
if (!$attached_phids) {
return;
}
$omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
$files = id(new PhabricatorFileQuery())
->setViewer($omnipotent_viewer)
->withPHIDs($attached_phids)
->execute();
foreach ($files as $file) {
$view_policy = $file->getViewPolicy();
if ($this->shouldScramblePolicy($view_policy)) {
$file->scrambleSecret();
$file->save();
}
}
}
/**
* Check if a policy is strong enough to justify scrambling. Objects which
* are set to very open policies don't need to scramble their files, and
* files with very open policies don't need to be scrambled when associated
* objects change.
*/
private function shouldScramblePolicy($policy) {
switch ($policy) {
case PhabricatorPolicies::POLICY_PUBLIC:
case PhabricatorPolicies::POLICY_USER:
return false;
}
return true;
}
private function updateWorkboardColumns($object, $const, $old, $new) {
// If an object is removed from a project, remove it from any proxy
// columns for that project. This allows a task which is moved up from a
// milestone to the parent to move back into the "Backlog" column on the
// parent workboard.
if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) {
return;
}
// TODO: This should likely be some future WorkboardInterface.
$appears_on_workboards = ($object instanceof ManiphestTask);
if (!$appears_on_workboards) {
return;
}
$removed_phids = array_keys(array_diff_key($old, $new));
if (!$removed_phids) {
return;
}
// Find any proxy columns for the removed projects.
$proxy_columns = id(new PhabricatorProjectColumnQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withProxyPHIDs($removed_phids)
->execute();
if (!$proxy_columns) {
return array();
}
$proxy_phids = mpull($proxy_columns, 'getPHID');
$position_table = new PhabricatorProjectColumnPosition();
$conn_w = $position_table->establishConnection('w');
queryfx(
$conn_w,
'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
$position_table->getTableName(),
$object->getPHID(),
$proxy_phids);
}
private function getModularTransactionTypes() {
if ($this->modularTypes === null) {
$template = $this->object->getApplicationTransactionTemplate();
if ($template instanceof PhabricatorModularTransaction) {
$xtypes = $template->newModularTransactionTypes();
foreach ($xtypes as $key => $xtype) {
$xtype = clone $xtype;
$xtype->setEditor($this);
$xtypes[$key] = $xtype;
}
} else {
$xtypes = array();
}
$this->modularTypes = $xtypes;
}
return $this->modularTypes;
}
private function getModularTransactionType($type) {
$types = $this->getModularTransactionTypes();
return idx($types, $type);
}
private function willApplyTransactions($object, array $xactions) {
foreach ($xactions as $xaction) {
$type = $xaction->getTransactionType();
$xtype = $this->getModularTransactionType($type);
if (!$xtype) {
continue;
}
$xtype->willApplyTransactions($object, $xactions);
}
}
public function getCreateObjectTitle($author, $object) {
return pht('%s created this object.', $author);
}
public function getCreateObjectTitleForFeed($author, $object) {
return pht('%s created an object: %s.', $author, $object);
}
}
diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
index 6956561490..8b747281b3 100644
--- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
+++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
@@ -1,554 +1,554 @@
<?php
/**
* @task functions Token Functions
*/
abstract class PhabricatorTypeaheadDatasource extends Phobject {
private $viewer;
private $query;
private $rawQuery;
private $offset;
private $limit;
private $parameters = array();
private $functionStack = array();
private $isBrowse;
private $phase = self::PHASE_CONTENT;
const PHASE_PREFIX = 'prefix';
const PHASE_CONTENT = 'content';
public function setLimit($limit) {
$this->limit = $limit;
return $this;
}
public function getLimit() {
return $this->limit;
}
public function setOffset($offset) {
$this->offset = $offset;
return $this;
}
public function getOffset() {
return $this->offset;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function setRawQuery($raw_query) {
$this->rawQuery = $raw_query;
return $this;
}
public function getPrefixQuery() {
return phutil_utf8_strtolower($this->getRawQuery());
}
public function getRawQuery() {
return $this->rawQuery;
}
public function setQuery($query) {
$this->query = $query;
return $this;
}
public function getQuery() {
return $this->query;
}
public function setParameters(array $params) {
$this->parameters = $params;
return $this;
}
public function getParameters() {
return $this->parameters;
}
public function getParameter($name, $default = null) {
return idx($this->parameters, $name, $default);
}
public function setIsBrowse($is_browse) {
$this->isBrowse = $is_browse;
return $this;
}
public function getIsBrowse() {
return $this->isBrowse;
}
public function setPhase($phase) {
$this->phase = $phase;
return $this;
}
public function getPhase() {
return $this->phase;
}
public function getDatasourceURI() {
$uri = new PhutilURI('/typeahead/class/'.get_class($this).'/');
$uri->setQueryParams($this->parameters);
return (string)$uri;
}
public function getBrowseURI() {
if (!$this->isBrowsable()) {
return null;
}
$uri = new PhutilURI('/typeahead/browse/'.get_class($this).'/');
$uri->setQueryParams($this->parameters);
return (string)$uri;
}
abstract public function getPlaceholderText();
public function getBrowseTitle() {
return get_class($this);
}
abstract public function getDatasourceApplicationClass();
abstract public function loadResults();
protected function loadResultsForPhase($phase, $limit) {
// By default, sources just load all of their results in every phase and
// rely on filtering at a higher level to sequence phases correctly.
$this->setLimit($limit);
return $this->loadResults();
}
protected function didLoadResults(array $results) {
return $results;
}
public static function tokenizeString($string) {
$string = phutil_utf8_strtolower($string);
$string = trim($string);
if (!strlen($string)) {
return array();
}
// NOTE: Splitting on "(" and ")" is important for milestones.
$tokens = preg_split('/[\s\[\]\(\)-]+/u', $string);
$tokens = array_unique($tokens);
// Make sure we don't return the empty token, as this will boil down to a
// JOIN against every token.
foreach ($tokens as $key => $value) {
if (!strlen($value)) {
unset($tokens[$key]);
}
}
return array_values($tokens);
}
public function getTokens() {
return self::tokenizeString($this->getRawQuery());
}
protected function executeQuery(
PhabricatorCursorPagedPolicyAwareQuery $query) {
return $query
->setViewer($this->getViewer())
->setOffset($this->getOffset())
->setLimit($this->getLimit())
->execute();
}
/**
* Can the user browse through results from this datasource?
*
* Browsable datasources allow the user to switch from typeahead mode to
* a browse mode where they can scroll through all results.
*
* By default, datasources are browsable, but some datasources can not
* generate a meaningful result set or can't filter results on the server.
*
* @return bool
*/
public function isBrowsable() {
return true;
}
/**
* Filter a list of results, removing items which don't match the query
* tokens.
*
* This is useful for datasources which return a static list of hard-coded
* or configured results and can't easily do query filtering in a real
* query class. Instead, they can just build the entire result set and use
* this method to filter it.
*
* For datasources backed by database objects, this is often much less
* efficient than filtering at the query level.
*
* @param list<PhabricatorTypeaheadResult> List of typeahead results.
* @return list<PhabricatorTypeaheadResult> Filtered results.
*/
protected function filterResultsAgainstTokens(array $results) {
$tokens = $this->getTokens();
if (!$tokens) {
return $results;
}
$map = array();
foreach ($tokens as $token) {
$map[$token] = strlen($token);
}
foreach ($results as $key => $result) {
$rtokens = self::tokenizeString($result->getName());
// For each token in the query, we need to find a match somewhere
// in the result name.
foreach ($map as $token => $length) {
// Look for a match.
$match = false;
foreach ($rtokens as $rtoken) {
if (!strncmp($rtoken, $token, $length)) {
// This part of the result name has the query token as a prefix.
$match = true;
break;
}
}
if (!$match) {
// We didn't find a match for this query token, so throw the result
// away. Try with the next result.
unset($results[$key]);
break;
}
}
}
return $results;
}
protected function newFunctionResult() {
return id(new PhabricatorTypeaheadResult())
->setTokenType(PhabricatorTypeaheadTokenView::TYPE_FUNCTION)
->setIcon('fa-asterisk')
->addAttribute(pht('Function'));
}
public function newInvalidToken($name) {
return id(new PhabricatorTypeaheadTokenView())
->setValue($name)
->setIcon('fa-exclamation-circle')
->setTokenType(PhabricatorTypeaheadTokenView::TYPE_INVALID);
}
public function renderTokens(array $values) {
$phids = array();
$setup = array();
$tokens = array();
foreach ($values as $key => $value) {
if (!self::isFunctionToken($value)) {
$phids[$key] = $value;
} else {
$function = $this->parseFunction($value);
if ($function) {
$setup[$function['name']][$key] = $function;
} else {
$name = pht('Invalid Function: %s', $value);
$tokens[$key] = $this->newInvalidToken($name)
->setKey($value);
}
}
}
// Give special non-function tokens which are also not PHIDs (like statuses
// and priorities) an opportunity to render.
$type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN;
$special = array();
foreach ($values as $key => $value) {
if (phid_get_type($value) == $type_unknown) {
$special[$key] = $value;
}
}
if ($special) {
$special_tokens = $this->renderSpecialTokens($special);
foreach ($special_tokens as $key => $token) {
$tokens[$key] = $token;
unset($phids[$key]);
}
}
if ($phids) {
$handles = $this->getViewer()->loadHandles($phids);
foreach ($phids as $key => $phid) {
$handle = $handles[$phid];
$tokens[$key] = PhabricatorTypeaheadTokenView::newFromHandle($handle);
}
}
if ($setup) {
foreach ($setup as $function_name => $argv_list) {
// Render the function tokens.
$function_tokens = $this->renderFunctionTokens(
$function_name,
ipull($argv_list, 'argv'));
// Rekey the function tokens using the original array keys.
$function_tokens = array_combine(
array_keys($argv_list),
$function_tokens);
// For any functions which were invalid, set their value to the
// original input value before it was parsed.
foreach ($function_tokens as $key => $token) {
$type = $token->getTokenType();
if ($type == PhabricatorTypeaheadTokenView::TYPE_INVALID) {
$token->setKey($values[$key]);
}
}
$tokens += $function_tokens;
}
}
return array_select_keys($tokens, array_keys($values));
}
protected function renderSpecialTokens(array $values) {
return array();
}
/* -( Token Functions )---------------------------------------------------- */
/**
* @task functions
*/
public function getDatasourceFunctions() {
return array();
}
/**
* @task functions
*/
public function getAllDatasourceFunctions() {
return $this->getDatasourceFunctions();
}
/**
* @task functions
*/
protected function canEvaluateFunction($function) {
return $this->shouldStripFunction($function);
}
/**
* @task functions
*/
protected function shouldStripFunction($function) {
$functions = $this->getDatasourceFunctions();
return isset($functions[$function]);
}
/**
* @task functions
*/
protected function evaluateFunction($function, array $argv_list) {
throw new PhutilMethodNotImplementedException();
}
/**
* @task functions
*/
protected function evaluateValues(array $values) {
return $values;
}
/**
* @task functions
*/
public function evaluateTokens(array $tokens) {
$results = array();
$evaluate = array();
foreach ($tokens as $token) {
if (!self::isFunctionToken($token)) {
$results[] = $token;
} else {
// Put a placeholder in the result list so that we retain token order
// when possible. We'll overwrite this below.
$results[] = null;
$evaluate[last_key($results)] = $token;
}
}
$results = $this->evaluateValues($results);
foreach ($evaluate as $result_key => $function) {
$function = self::parseFunction($function);
if (!$function) {
throw new PhabricatorTypeaheadInvalidTokenException();
}
$name = $function['name'];
$argv = $function['argv'];
$evaluated_tokens = $this->evaluateFunction($name, array($argv));
if (!$evaluated_tokens) {
unset($results[$result_key]);
} else {
$is_first = true;
foreach ($evaluated_tokens as $phid) {
if ($is_first) {
$results[$result_key] = $phid;
$is_first = false;
} else {
$results[] = $phid;
}
}
}
}
$results = array_values($results);
$results = $this->didEvaluateTokens($results);
return $results;
}
/**
* @task functions
*/
protected function didEvaluateTokens(array $results) {
return $results;
}
/**
* @task functions
*/
public static function isFunctionToken($token) {
// We're looking for a "(" so that a string like "members(q" is identified
// and parsed as a function call. This allows us to start generating
- // results immeidately, before the user fully types out "members(quack)".
+ // results immediately, before the user fully types out "members(quack)".
return (strpos($token, '(') !== false);
}
/**
* @task functions
*/
public function parseFunction($token, $allow_partial = false) {
$matches = null;
if ($allow_partial) {
$ok = preg_match('/^([^(]+)\((.*?)\)?$/', $token, $matches);
} else {
$ok = preg_match('/^([^(]+)\((.*)\)$/', $token, $matches);
}
if (!$ok) {
return null;
}
$function = trim($matches[1]);
if (!$this->canEvaluateFunction($function)) {
return null;
}
return array(
'name' => $function,
'argv' => array(trim($matches[2])),
);
}
/**
* @task functions
*/
public function renderFunctionTokens($function, array $argv_list) {
throw new PhutilMethodNotImplementedException();
}
/**
* @task functions
*/
public function setFunctionStack(array $function_stack) {
$this->functionStack = $function_stack;
return $this;
}
/**
* @task functions
*/
public function getFunctionStack() {
return $this->functionStack;
}
/**
* @task functions
*/
protected function getCurrentFunction() {
return nonempty(last($this->functionStack), null);
}
protected function renderTokensFromResults(array $results, array $values) {
$tokens = array();
foreach ($values as $key => $value) {
if (empty($results[$value])) {
continue;
}
$tokens[$key] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult(
$results[$value]);
}
return $tokens;
}
public function getWireTokens(array $values) {
// TODO: This is a bit hacky for now: we're sort of generating wire
// results, rendering them, then reverting them back to wire results. This
// is pretty silly. It would probably be much cleaner to make
// renderTokens() call this method instead, then render from the result
// structure.
$rendered = $this->renderTokens($values);
$tokens = array();
foreach ($rendered as $key => $render) {
$tokens[$key] = id(new PhabricatorTypeaheadResult())
->setPHID($render->getKey())
->setIcon($render->getIcon())
->setColor($render->getColor())
->setDisplayName($render->getValue())
->setTokenType($render->getTokenType());
}
return mpull($tokens, 'getWireFormat', 'getPHID');
}
}
diff --git a/src/applications/uiexample/examples/PhabricatorStatusUIExample.php b/src/applications/uiexample/examples/PhabricatorStatusUIExample.php
index 6583bc5848..76a345b8e3 100644
--- a/src/applications/uiexample/examples/PhabricatorStatusUIExample.php
+++ b/src/applications/uiexample/examples/PhabricatorStatusUIExample.php
@@ -1,92 +1,92 @@
<?php
final class PhabricatorStatusUIExample extends PhabricatorUIExample {
public function getName() {
return pht('Status List');
}
public function getDescription() {
return pht(
'Use %s to show relationships with objects.',
phutil_tag('tt', array(), 'PHUIStatusListView'));
}
public function renderExample() {
$out = array();
$view = new PHUIStatusListView();
$view->addItem(
id(new PHUIStatusItemView())
->setIcon(PHUIStatusItemView::ICON_ACCEPT, 'green', pht('Yum'))
->setTarget(pht('Apple'))
->setNote(pht('You can eat them.')));
$view->addItem(
id(new PHUIStatusItemView())
->setIcon(PHUIStatusItemView::ICON_ADD, 'blue', pht('Has Peel'))
->setTarget(pht('Banana'))
->setNote(pht('Comes in bunches.'))
->setHighlighted(true));
$view->addItem(
id(new PHUIStatusItemView())
->setIcon(PHUIStatusItemView::ICON_WARNING, 'dark', pht('Caution'))
- ->setTarget(pht('Pomegranite'))
+ ->setTarget(pht('Pomegranate'))
->setNote(pht('Lots of seeds. Watch out.')));
$view->addItem(
id(new PHUIStatusItemView())
->setIcon(PHUIStatusItemView::ICON_REJECT, 'red', pht('Bleh!'))
->setTarget(pht('Zucchini'))
->setNote(pht('Slimy and gross. Yuck!')));
$out[] = id(new PHUIHeaderView())
->setHeader(pht('Fruit and Vegetable Status'));
$out[] = id(new PHUIBoxView())
->addMargin(PHUI::MARGIN_LARGE)
->addPadding(PHUI::PADDING_LARGE)
->setBorder(true)
->appendChild($view);
$view = new PHUIStatusListView();
$manifest = array(
PHUIStatusItemView::ICON_ACCEPT => 'PHUIStatusItemView::ICON_ACCEPT',
PHUIStatusItemView::ICON_REJECT => 'PHUIStatusItemView::ICON_REJECT',
PHUIStatusItemView::ICON_LEFT => 'PHUIStatusItemView::ICON_LEFT',
PHUIStatusItemView::ICON_RIGHT => 'PHUIStatusItemView::ICON_RIGHT',
PHUIStatusItemView::ICON_UP => 'PHUIStatusItemView::ICON_UP',
PHUIStatusItemView::ICON_DOWN => 'PHUIStatusItemView::ICON_DOWN',
PHUIStatusItemView::ICON_QUESTION => 'PHUIStatusItemView::ICON_QUESTION',
PHUIStatusItemView::ICON_WARNING => 'PHUIStatusItemView::ICON_WARNING',
PHUIStatusItemView::ICON_INFO => 'PHUIStatusItemView::ICON_INFO',
PHUIStatusItemView::ICON_ADD => 'PHUIStatusItemView::ICON_ADD',
PHUIStatusItemView::ICON_MINUS => 'PHUIStatusItemView::ICON_MINUS',
PHUIStatusItemView::ICON_OPEN => 'PHUIStatusItemView::ICON_OPEN',
PHUIStatusItemView::ICON_CLOCK => 'PHUIStatusItemView::ICON_CLOCK',
);
foreach ($manifest as $icon => $label) {
$view->addItem(
id(new PHUIStatusItemView())
->setIcon($icon, 'indigo')
->setTarget($label));
}
$out[] = id(new PHUIHeaderView())
->setHeader(pht('All Icons'));
$out[] = id(new PHUIBoxView())
->addMargin(PHUI::MARGIN_LARGE)
->addPadding(PHUI::PADDING_LARGE)
->setBorder(true)
->appendChild($view);
return $out;
}
}
diff --git a/src/docs/contributor/cla.diviner b/src/docs/contributor/cla.diviner
index f08711483a..54e0142beb 100644
--- a/src/docs/contributor/cla.diviner
+++ b/src/docs/contributor/cla.diviner
@@ -1,169 +1,169 @@
@title Understanding the Phacility CLA
@group detail
Describes the Contributor License Agreement (CLA).
Overview
========
IMPORTANT: This document is not legal advice.
Phacility requires contributors to sign a Contributor License Agreement
(often abbreviated "CLA") before we can accept contributions into the upstream.
This document explains what this document means and why we require it.
This requirement is not unusual, and many large open source projects require a
similar CLA, including Python, Go, jQuery, and Apache Software Foundation
projects.
You can read more about CLAs and find more examples of companies and projects
which require them on Wikipedia's
[[ https://en.wikipedia.org/wiki/Contributor_License_Agreement | CLA ]] page.
Our CLA is substantially similar to the CLA required by Apache, the
"Apache Individual Contributor License Agreement V2.0". Many projects which
require a CLA use this CLA or a similar one.
Why We Require a CLA
====================
While many projects require a CLA, others do not. This project requires a CLA
primarily because:
- it gives us certain rights, particularly the ability to relicense the work
later;
- it makes the terms of your contribution clear, protecting us from liability
related to copyright and patent disputes.
**More Rights**: We consider the cost of maintaining changes to greatly
outweigh the cost of writing them in the first place. When we accept work
into the upstream, we are agreeing to bear that maintenance cost.
This cost is not worthwhile to us unless the changes come with no strings
attached. Among other concerns, we would be unable to redistribute Phabricator
under a different license in the future without the additional rights the CLA
gives us.
For a concrete example of the problems this causes, Bootstrap switched from
GPLv2 to MIT in 2012-2013. You can see the issue tracking the process and read
about what they had to go through to do this here:
https://github.com/twbs/bootstrap/issues/2054
This took almost 18 months and required a huge amount of effort. We are not
willing to encumber the project with that kind of potential cost in order to
accept contributions.
The rights you give us by signing the CLA allow us to release the software
under a different license later without asking you for permission, including a
license you may not agree with.
They do not allow us to //undo// the existing release under the Apache license,
but allow us to make an //additional// release under a different license, or
release under multiple licenses (if we do, users may choose which license or
licenses they wish to use the software under). It would also allow us to
discontinue updating the release under the Apache license.
While we do not currently plan to relicense Phabricator, we do not want to
give up the ability to do so: we may want or need to in the future.
The most likely scenario which would lead to us changing the license is if a
new version of the Apache license is released. Open source software licenses
are still largely untested in the US legal system, and they may face challenges
in the future which could require adapting them to a changing legal
environment. If this occurs, we would want to be able to update to a newer
version of the license which accounted for these changes.
It is also possible that we may want to change open source licenses (for
example, to MIT) or adopt dual-licensing (for example, both Apache and MIT). We
might want to do this so that our license is compatible with the licenses used
by other software we want to be distributed alongside.
Although we currently believe it is unlikely, it is also possible we may want
to relicense Phabricator under a closed, proprietary, or literally evil license.
By signing the CLA, you are giving us the power to do this without requiring
you to consent. If you are not comfortable with this, do not sign the CLA and
do not contribute to Phabricator.
**Limitation of Liability**: The second benefit the CLA provides is that it
-makes the terms of your contribition explicitly clear upfront, and it puts us
+makes the terms of your contribution explicitly clear upfront, and it puts us
in a much stronger legal position if a contributor later claims there is
ambiguity about ownership of their work. We can point at the document they
signed as proof that they consented to our use and understood the terms of
their contribution.
//SCO v. IBM// was a lawsuit filed in 2003 alleging (roughly) that IBM had
improperly contributed code owned by SCO to Linux. The details of this and the
subsequent cases are very complex and the situation is not a direct parallel to
anything we are likely to face, but SCO claimed billions of dollars in damages
and the litigation has now been ongoing for more than a decade.
We want to avoid situations like this in the future by making the terms of
-contibution explicit upfront.
+contribution explicit upfront.
Generally, we believe the terms of the CLA are fair and reasonable for
contributors, and that the primary way contributors benefit from contributing
to Phabricator is that we publish and maintain their changes so they do not
have to fork the software.
If you have strong ideological reasons for contributing to open source, you may
not be comfortable with the terms of the CLA (for example, it may be important
to you that your changes are never available under a license which you haven't
explicitly approved). This is fine and we can understand why contributors may
hold this viewpoint, but we can not accept your changes into the upstream.
Corporate vs Individual CLAs
============================
We offer two CLAs:
- {L28}
- {L30}
These are both substantially similar to the corresponding Apache CLAs.
If you own the work you are contributing, sign the individual CLA. If your
employer owns the work you are contributing, have them sign the corporate CLA.
**If you are employed, there is a substantial possibility that your employer
owns your work.** If they do, you do not have the right to contribute it to us
or assign the rights that we require, and can not contribute under the
individual CLA. Work with your employer to contribute under the corporate CLA
instead.
Particularly, this clause in the individual CLA is the important one:
> 4. You represent that you are legally entitled to grant the above license. If
> your employer(s) has rights to intellectual property that you create that
> includes your Contributions, you represent that you have received permission
> to make Contributions on behalf of that employer, that your employer has
> waived such rights for your Contributions to Phacility, or that your employer
> has executed a separate Corporate CLA with Phacility.
Ownership of your work varies based on where you live, how you are employed,
and your agreements with your employer. However, at least in the US, it is
likely that your employer owns your work unless you have anticipated conflicts
and specifically avoided them. This generally makes sense: if you are paid by
your employer for your work, they own the product of your work and you receive
salary and benefits in fair exchange for that work.
Your employer may have an ownership claim on your work even if you perform it
on your own time, if you use their equipment (like a company laptop or phone),
resources, facilities, or trade secrets, or signed something like an "Invention
Assignment Agreement" when you were hired. Such agreements are common. The
details of the strength of their claim will vary based on your situation and
local law.
If you are unsure, you should speak with your employer or a lawyer. If you
contribute code you do not own under the individual CLA, you are exposing
-yourself to liability. You may also be exposing us to liablity, but we'll have
+yourself to liability. You may also be exposing us to liability, but we'll have
the CLA on our side to show that we were unwilling pawns in your malicious
scheme to defraud your employer.
The good news is that most employers are happy to contribute to open source
projects. Incentives are generally well aligned: they get features they want,
and it reflects well on them. In the past, potential contributors who have
approached their employers about a corporate CLA have generally had little
difficulty getting approval.
diff --git a/src/docs/contributor/describing_problems.diviner b/src/docs/contributor/describing_problems.diviner
index 57bb849b02..d06d7b7d64 100644
--- a/src/docs/contributor/describing_problems.diviner
+++ b/src/docs/contributor/describing_problems.diviner
@@ -1,159 +1,159 @@
@title Describing Root Problems
@group detail
Explains how to describe a root problem effectively.
Overview
========
We receive many feature requests with poor problem descriptions. You may have
filed such a request if you've been sent here. This document explains what we
want, and how to give us the information to help you.
We will **never** implement a feature request without first understanding the
root problem.
Good problem descriptions let us answer your questions quickly and correctly,
and suggest workarounds or alternate ways to accomplish what you want.
Poor problem descriptions require us to ask multiple clarifying questions and
do not give us enough information to suggest alternate solutions or
workarounds. We need to keep going back and forth to understand the problem
you're really facing, which means it will take a long time to get the answer
you want.
What We Want
============
We want a description of your overarching goal. The problem you started trying
to solve first, long before you decided what feature you needed.
This doesn't need to be very detailed, we just need to know what you are
ultimately hoping to accomplish.
Problem descriptions should include context and explain why you're encountering
a problem and why it's important for you to resolve it.
Here are some examples of good ways to start a problem description:
> My company does contracting work for government agencies. Because of the
> nature of our customers, deadlines are critical and it's very important
> for us to keep track of where we are on a timeline. We're using Maniphest
> to track tasks...
> I have poor eyesight, and use a screenreader to help me use software like
> Phabricator in my job as a developer. I'm having difficulty...
> We work on a large server program which has very long compile times.
> Switching branches is a huge pain (you have to rebuild the binary after
> every switch, which takes about 8 minutes), but we've recently begun using
> `git worktree` to help, which has made life a lot better. However, ...
> I triage manual test failures from our offshore QA team. Here's how our
> workflow works...
All of these descriptions are helpful: the provide context about what goals
you're trying to accomplish and why.
Here are some examples of ways to start a problem description that probably
are not very good:
> {icon times color=red} Add custom keyboard shortcuts.
> {icon times color=red} I have a problem: there is no way to download
> .tar archives of repositories.
> {icon times color=red} I want an RSS feed of my tokens. My root problem is
> that I do not have an RSS feed of my tokens.
> {icon times color=red} There is no way to see other users' email addresses.
> That is a problem.
> {icon times color=red} I've used some other software that has a cool
> feature. Phabricator should have that feature too.
These problem descriptions are not helpful. They do not describe goals or
provide context.
"5 Whys" Technique
================
If you're having trouble understanding what we're asking for, one technique
which may help is ask yourself "Why?" repeatedly. Each answer will usually
get you closer to describing the root problem.
For example:
> I want custom keyboard shortcuts.
This is a very poor feature request which does not describe the root problem.
It limits us to only one possible solution. Try asking "Why?" to get closer
to the root problem.
> **Why?**
> I want to add a shortcut to create a new task.
This is still very poor, but we can now think about solutions involving making
this whole flow easier, or adding a shortcut for exactly this to the upstream,
which might be a lot easier than adding custom keyboard shortcuts.
It's common to stop here and report this as your root problem. This is **not**
a root problem. This problem is only //slightly// more general than the one
we started with. Let's ask "Why?" again to get closer to the root problem.
> **Why?**
> I create a lot of very similar tasks every day.
This is still quite poor, but we can now think about solutions like a bulk task
creation flow, or maybe point you at task creation templating or prefilling or
the Conduit API or email integration or Doorkeeper.
> **Why?**
> The other developers email me issues and I copy/paste them into Maniphest.
This is getting closer, but still doesn't tell us what your goal is.
> **Why?**
> We set up email integration before, but each task needs to have specific
> projects so that didn't work and now I'm stuck doing the entry by hand.
This is in the realm of reasonable, and likely easy to solve with custom
inbound addresses and Herald rules, or with a small extension to Herald. We
might try to improve the documentation to make the feature easier to discover
or understand.
You could (and should) go even further than this and explain why tasks need to
be tagged with specific projects. It's very easy to provide more context and
can only improve the speed and quality of our response.
Note that this solution (Herald rules on inbound email) has nothing to do with
the narrow feature request (keyboard shortcuts) that you otherwise arrived at,
but there's no possible way we can suggest a solution involving email
integration or Herald if your report doesn't even mention that part of the
context.
Additional Resources
====================
Poor problem descriptions are a common issue in software development and
extensively documented elsewhere. Here are some additional resources describing
-how to describe problems and ask questions effectivey:
+how to describe problems and ask questions effectively:
- [[ http://www.catb.org/esr/faqs/smart-questions.html | How To Ask
Questions The Smart Way ]], by Eric S. Raymond
- [[ http://xyproblem.info | XY Problem ]]
- [[ https://en.wikipedia.org/wiki/5_Whys | 5 Whys Technique ]]
Asking good questions and describing problems clearly is an important,
fundamental communication skill that software professionals should cultivate.
Next Steps
==========
Continue by:
- returning to @{article:Contributing Feature Requests}.
diff --git a/src/docs/contributor/internationalization.diviner b/src/docs/contributor/internationalization.diviner
index e9cdb9dfcd..9c78eb60fb 100644
--- a/src/docs/contributor/internationalization.diviner
+++ b/src/docs/contributor/internationalization.diviner
@@ -1,381 +1,381 @@
@title Internationalization
@group developer
Describes Phabricator translation and localization.
Overview
========
Phabricator partially supports internationalization, but many of the tools
are missing or in a prototype state.
This document describes what tools exist today, how to add new translations,
and how to use the translation tools to make a codebase translatable.
Adding a New Locale
===================
To add a new locale, subclass @{class:PhutilLocale}. This allows you to
introduce a new locale, like "German" or "Klingon".
Once you've created a locale, applications can add translations for that
locale.
For instructions on adding new classes, see
@{article@phabcontrib:Adding New Classes}.
Adding Translations to Locale
=============================
To translate strings, subclass @{class:PhutilTranslation}. Translations need
to belong to a locale: the locale defines an available language, and each
translation subclass provides strings for it.
Translations are separated from locales so that third-party applications can
provide translations into different locales without needing to define those
locales themselves.
For instructions on adding new classes, see
@{article@phabcontrib:Adding New Classes}.
Writing Translatable Code
=========================
Strings are marked for translation with @{function@libphutil:pht}.
The `pht()` function takes a string (and possibly some parameters) and returns
the translated version of that string in the current viewer's locale, if a
translation is available.
If text strings will ultimately be read by humans, they should essentially
always be wrapped in `pht()`. For example:
```lang=php
$dialog->appendParagraph(pht('This is an example.'));
```
This allows the code to return the correct Spanish or German or Russian
version of the text, if the viewer is using Phabricator in one of those
languages and a translation is available.
Using `pht()` properly so that strings are translatable can be tricky. Briefly,
the major rules are:
- Only pass static strings as the first parameter to `pht()`.
- Use parameters to create strings containing user names, object names, etc.
- Translate full sentences, not sentence fragments.
- Let the translation framework handle plural rules.
- Use @{class@libphutil:PhutilNumber} for numbers.
- Let the translation framework handle subject gender rules.
- Translate all human-readable text, even exceptions and error messages.
See the next few sections for details on these rules.
Use Static Strings
==================
The first parameter to `pht()` must always be a static string. Broadly, this
means it should not contain variables or function or method calls (it's OK to
split it across multiple lines and concatenate the parts together).
These are good:
```lang=php
pht('The night is dark.');
pht(
'Two roads diverged in a yellow wood, '.
'and sorry I could not travel both '.
'and be one traveler, long I stood.');
```
These won't work (they might appear to work, but are wrong):
```lang=php, counterexample
pht(some_function());
pht('The duck says, '.$quack);
pht($string);
```
The first argument must be a static string so it can be extracted by static
analysis tools and dumped in a big file for translators. If it contains
functions or variables, it can't be extracted, so translators won't be able to
translate it.
Lint will warn you about problems with use of static strings in calls to
`pht()`.
Parameters
==========
You can provide parameters to a translation string by using `sprintf()`-style
patterns in the input string. For example:
```lang=php
pht('%s earned an award.', $actor);
pht('%s closed %s.', $actor, $task);
```
This is primarily appropriate for usernames, object names, counts, and
untranslatable strings like URIs or instructions to run commands from the CLI.
Parameters normally should not be used to combine two pieces of translated
text: see the next section for guidance.
Sentence Fragments
==================
You should almost always pass the largest block of text to `pht()` that you
can. Particularly, it's important to pass complete sentences, not try to build
a translation by stringing together sentence fragments.
There are several reasons for this:
- It gives translators more context, so they can be more confident they are
producing a satisfying, natural-sounding translation which will make sense
and sound good to native speakers.
- In some languages, one fragment may need to translate differently depending
on what the other fragment says.
- In some languages, the most natural-sounding translation may change the
order of words in the sentence.
For example, suppose we want to translate these sentence to give the user some
instructions about how to use an interface:
> Turn the switch to the right.
> Turn the switch to the left.
> Turn the dial to the right.
> Turn the dial to the left.
Maybe we have a function like this:
```
function get_string($is_switch, $is_right) {
// ...
}
```
One way to write the function body would be like this:
```lang=php, counterexample
$what = $is_switch ? pht('switch') : pht('dial');
$dir = $is_right ? pht('right') : pht('left');
return pht('Turn the ').$what.pht(' to the ').$dir.pht('.');
```
This will work fine in English, but won't work well in other languages.
One problem with doing this is handling gendered nouns. Languages like Spanish
have gendered nouns, where some nouns are "masculine" and others are
"feminine". The gender of a noun affects which article (in English, the word
"the" is an article) should be used with it.
In English, we say "**the** knob" and "**the** switch", but a Spanish speaker
would say "**la** perilla" and "**el** interruptor", because the noun for
"knob" in Spanish is feminine (so it is used with the article "la") while the
noun for "switch" is masculine (so it is used with the article "el").
A Spanish speaker can not translate the string "Turn the" correctly without
knowing which gender the noun has. Spanish has //two// translations for this
string ("Gira el", "Gira la"), and the form depends on which noun is being
used.
Another problem is that this reduces flexibility. Translating fragments like
this locks translators into a specific word order, when rearranging the words
might make the sentence sound much more natural to a native speaker.
For example, if the string read "The knob, to the right, turn it.", it
would technically be English and most English readers would understand the
meaning, but no native English speaker would speak or write like this.
However, some languages have different subject-verb order rules or
-colloquisalisms, and a word order which transliterates like this may sound more
+colloquialisms, and a word order which transliterates like this may sound more
natural to a native speaker. By translating fragments instead of complete
sentences, you lock translators into English word order.
Finally, the last fragment is just a period. If a translator is presented with
this string in an interface without much context, they have no hope of guessing
how it is used in the software (it could be an end-of-sentence marker, or a
decimal point, or a date separator, or a currency separator, all of which have
very different translations in many locales). It will also conflict with all
other translations of the same string in the codebase, so even if they are
given context they can't translate it without technical problems.
To avoid these issues, provide complete sentences for translation. This almost
always takes the form of writing out alternatives in full. This is a good way
to implement the example function:
```lang=php
if ($is_switch) {
if ($is_right) {
return pht('Turn the switch to the right.');
} else {
return pht('Turn the switch to the left.');
}
} else {
if ($is_right) {
return pht('Turn the dial to the right.');
} else {
return pht('Turn the dial to the left.');
}
}
```
Although this is more verbose, translators can now get genders correct,
rearrange word order, and have far more context when translating. This enables
better, natural-sounding translations which are more satisfying to native
speakers.
Singular and Plural
===================
Different languages have various rules for plural nouns.
In English there are usually two plural noun forms: for one thing, and any
other number of things. For example, we say that one chair is a "chair" and any
other number of chairs are "chairs": "0 chairs", "1 chair", "2 chairs", etc.
In other languages, there are different (and, in some cases, more) plural
forms. For example, in Czech, there are separate forms for "one", "several",
and "many".
Because plural noun rules depend on the language, you should not write code
which hard-codes English rules. For example, this won't translate well:
```lang=php, counterexample
if ($count == 1) {
return pht('This will take an hour.');
} else {
return pht('This will take hours.');
}
```
This code is hard-coding the English rule for plural nouns. In languages like
Czech, the correct word for "hours" may be different if the count is 2 or 15,
but a translator won't be able to provide the correct translation if the string
is written like this.
Instead, pass a generic string to the translation engine which //includes// the
number of objects, and let it handle plural nouns. This is the correct way to
write the translation:
```lang=php
return pht('This will take %s hour(s).', new PhutilNumber($count));
```
If you now load the web UI, you'll see "hour(s)" literally in the UI. To fix
this so the translation sounds better in English, provide translations for this
string in the @{class@phabricator:PhabricatorUSEnglishTranslation} file:
```lang=php
'This will take %s hour(s).' => array(
'This will take an hour.',
'This will take hours.',
),
```
The string will then sound natural in English, but non-English translators will
also be able to produce a natural translation.
Note that the translations don't actually include the number in this case. The
number is being passed from the code, but that just lets the translation engine
get the rules right: the number does not need to appear in the final
translations shown to the user.
Using PhutilNumber
==================
When translating numbers, you should almost always use `%s` and wrap the count
or number in `new PhutilNumber($count)`. For example:
```lang=php
pht('You have %s experience point(s).', new PhutilNumber($xp));
```
This will let the translation engine handle plural noun rules correctly, and
also format large numbers correctly in a locale-aware way with proper unit and
decimal separators (for example, `1000000` may be printed as "1,000,000",
with commas for readability).
The exception to this rule is IDs which should not be written with unit
separators. For example, this is correct for an object ID:
```lang=php
pht('This diff has ID %d.', $diff->getID());
```
Male and Female
===============
Different languages also use different words for talking about subjects who are
male, female or have an unknown gender. In English this is mostly just
pronouns (like "he" and "she") but there are more complex rules in other
languages, and languages like Czech also require verb agreement.
When a parameter refers to a gendered person, pass an object which implements
@{interface@libphutil:PhutilPerson} to `pht()` so translators can provide
gendered translation variants.
```lang=php
pht('%s wrote', $actor);
```
Translators will create these translations:
```lang=php
// English translation
'%s wrote';
// Czech translation
array('%s napsal', '%s napsala');
```
(You usually don't need to worry very much about this rule, it is difficult to
get wrong in standard code.)
Exceptions and Errors
=====================
You should translate all human-readable text, even exceptions and error
messages. This is primarily a rule of convenience which is straightforward
and easy to follow, not a technical rule.
Some exceptions and error messages don't //technically// need to be translated,
as they will never be shown to a user, but many exceptions and error messages
are (or will become) user-facing on some way. When writing a message, there is
often no clear and objective way to determine which type of message you are
writing. Rather than try to distinguish which are which, we simply translate
all human-readable text. This rule is unambiguous and easy to follow.
In cases where similar error or exception text is often repeated, it is
probably appropriate to define an exception for that category of error rather
than write the text out repeatedly, anyway. Two examples are
@{class@libphutil:PhutilInvalidStateException} and
@{class@libphutil:PhutilMethodNotImplementedException}, which mostly exist to
produce a consistent message about a common error state in a convenient way.
There are a handful of error strings in the codebase which may be used before
the translation framework is loaded, or may be used during handling other
errors, possibly raised from within the translation framework. This handful
of special cases are left untranslated to prevent fatals and cycles in the
error handler.
Next Steps
==========
Continue by:
- adding a new locale or translation file with
@{article@phabcontrib:Adding New Classes}.
diff --git a/src/docs/contributor/reproduction_steps.diviner b/src/docs/contributor/reproduction_steps.diviner
index d5d58913b7..1050e43c48 100644
--- a/src/docs/contributor/reproduction_steps.diviner
+++ b/src/docs/contributor/reproduction_steps.diviner
@@ -1,252 +1,252 @@
@title Providing Reproduction Steps
@group detail
Describes how to provide reproduction steps.
Overview
========
When you submit a bug report about Phabricator, you **MUST** include
reproduction steps. We can not help you with bugs we can not reproduce, and
will not accept reports which omit reproduction steps or have incomplete or
insufficient instructions.
This document explains what we're looking for in good reproduction steps.
Briefly:
- Reproduction steps must allow us to reproduce the problem locally on a
clean, up-to-date install of Phabricator.
- Reproduction should be as simple as possible.
Good reproduction steps can take time to write out clearly, simplify, and
verify. As a reporter, we expect you to shoulder as much of this burden as you
can, to make make it easy for us to reproduce and resolve bugs.
We do not have the resources to pursue reports with limited, inaccurate, or
incomplete reproduction steps, and will not accept them. These reports require
large amounts of our time and are often fruitless.
Example Reproduction Steps
==========================
Here's an example of what good reproduction steps might look like:
---
Reproduction steps:
- Click "Create Event" in Calendar.
- Fill in the required fields with any text (name, description, etc).
- Set an invalid time for one of the dates, like the meaningless string
"Tea Time". This is not valid, so we're expecting an error when we
submit the form.
- Click "Create" to save the event.
Expected result:
- Form reloads with an error message about the specific mistake.
- All field values are retained.
Actual result:
- Form reloads with an error message about the specific mistake.
- Most values are discarded, so I have to re-type the name, description, etc.
---
These steps are **complete** and **self-contained**: anyone can reproduce the
issue by following these steps. These steps are **clear** and **easy to
follow**. These steps are also simple and minimal: they don't include any
extra unnecessary steps.
Finally, these steps explain what the reporter expected to happen, what they
observed, and how those behaviors differ. This isn't as important when the
observation is an obvious error like an exception, but can be important if a
behavior is merely odd or ambiguous.
Reliable Reproduction
=====================
When you file a bug report, the first thing we do to fix it is to try to
reproduce the problem locally on an up-to-date install of Phabricator. We will
do this by following the steps you provide. If we can recreate the issue
locally, we can almost always resolve the problem (often very promptly).
However, many reports do not have enough information, are missing important
steps, or rely on data (like commits, users, other projects, permission
settings, feed stories, etc) that we don't have access to. We either can't
follow these steps, or can't reproduce issues by following them.
Reproduction steps must be complete and self-contained, and must allow
**anyone** to reproduce the issue on a new, empty install of Phabricator. If
the bug you're seeing depends on data or configuration which would not be
present on a new install, you need to include that information in your steps.
For example, if you're seeing an issue which depends on a particular policy
setting or configuration setting, you need to include instructions for creating
the policy or adjusting the setting in your steps.
Getting Started
===============
To generate reproduction steps, first find a sequence of actions which
reproduce the issue you're seeing reliably.
Next, write down everything you did as clearly as possible. Make sure each step
is self-contained: anyone should be able to follow your steps, without access
to private or proprietary data.
Now, to verify that your steps provide a complete, self-contained way to
reproduce the issue, follow them yourself on a new, empty, up-to-date instance
of Phabricator.
If you can't easily start an empty instance locally, you can launch an empty
instance on Phacility in about 60 seconds (see the "Resources" section for
details).
If you can follow your steps and reproduce the issue on a clean instance,
we'll probably be able to follow them and reproduce the issue ourselves.
If you can't follow your steps because they depend on information which is not
available on a clean instance (for example, a certain config setting), rewrite
-them to include instrutions to create that information (for example, adjusting
+them to include instructions to create that information (for example, adjusting
the config to the problematic value).
If you follow your instructions but the issue does not reproduce, the issue
might already be fixed. Make sure your install is up to date.
If your install is up to date and the issue still doesn't reproduce on a clean
install, your reproduction steps are missing important information. You need to
figure out what key element differs between your install and the clean install.
Once you have working reproduction steps, your steps may have details which
aren't actually necessary to reproduce the issue. You may be able to simplify
them by removing some steps or describing steps more narrowly. For help, see
"Simplifying Steps" below.
Resources
=========
We provide some resources which can make it easier to start building steps, or
to simplify steps.
**Phacility Test Instances**: You can launch a new, up-to-date instance of
Phabricator on Phacility in about a minute at <https://admin.phacility.com>.
These instances run `stable`.
You can use these instances to make sure that issues haven't already been
fixed, that they reproduce on a clean install, or that your steps are really
complete, and that the root cause isn't custom code or local extensions. Using
a test instance will avoid disrupting other users on your install.
**Test Repositories**: There are several test repositories on
`secure.phabricator.com` which you can push commits to if you need to build
an example to demonstrate a problem.
For example, if you're seeing an issue with a certain filename but the commit
where the problem occurs is in a proprietary internal repository, push a commit
that affects a file with a similar name to a test repository, then reproduce
against the test data. This will allow you to generate steps which anyone can
follow.
Simplifying Steps
=================
If you aren't sure how to simplify reproduction steps, this section has some
advice.
In general, you'll simplify reproduction steps by first finding a scenario
where the issue reproduces reliably (a "bad" case) and a second, similar
situation where it does not reproduce (a "good" case). Once you have a "bad"
case and a "good" case, you'll change the scenarios step-by-step to be more
similar to each other, until you have two scenarios which differ only a very
small amount. This remaining difference usually points clearly at the root
cause of the issue.
For example, suppose you notice that you get an error if you commit a file
named `A Banana.doc`, but not if you commit a file named `readme.md`. In
this case, `A Banana.doc` is your "bad" case and `readme.md` is your "good"
case.
There are several differences between these file names, and any of them might
be causing the problem. To narrow this down, you can try making the scenarios
more similar. For example, do these files work?
- `A_Banana.doc` - Problem with spaces?
- `A Banana.md` - File extension issue?
- `A Ban.doc` - Path length issue?
- `a banana.doc` - Issue with letter case?
Some of these probably work, while others might not. These could lead you to a
smaller case which reproduces the problem, which might be something like this:
- Files like `a b`, which contain spaces, do not work.
- Files like `a.doc`, which have the `.doc` extension, do not work.
- Files like `AAAAAAAAAA`, which have more than 9 letters, do not work.
- Files like `A`, which have uppercase letters, do not work.
With a simpler reproduction scenario, you can simplify your steps to be more
-tailored and mimimal. This will help us pointpoint the issue more quickly and
+tailored and minimal. This will help us pinpoint the issue more quickly and
be more certain that we've understood and resolved it.
It is more important that steps be complete than that they be simple, and it's
acceptable to submit complex instructions if you have difficulty simplifying
them, so long as they are complete, self-contained, and accurately reproduce
the issue.
Things to Avoid
===============
These are common mistakes when providing reproduction instructions:
**Insufficient Information**: The most common issue we see is instructions
which do not have enough information: they are missing critical details which
are necessary in order to follow them on a clean install.
**Inaccurate Steps**: The second most common issue we see is instructions
which do not actually reproduce a problem when followed on a clean, up-to-date
install. Verify your steps by testing them.
**Private Information**: Some users provide reports which hinge on the
particulars of private commits in proprietary repositories we can not access.
This is not useful, because we can not examine the underlying commit to figure
out why it is causing an issue.
Instead, reproduce the issue in a public repository. There are several test
repositories available which you can push commits to in order to construct a
reproduction case.
**Screenshots**: Screenshots can be helpful to explain a set of steps or show
what you're seeing, but they usually aren't sufficient on their own because
they don't contain all the information we need to reproduce them.
For example, a screenshot may show a particular policy or object, but not have
enough information for us rebuild a similar object locally.
Alternatives
============
If you have an issue which you can't build reproduction steps for, or which
only reproduces in your environment, or which you don't want to narrow down
to a minimal reproduction case, we can't accept it as a bug report. These
issues are tremendously time consuming for us to pursue and rarely benefit
more than one install.
If the issue is important but falls outside the scope of permissible bug
reports, we're happy to provide more tailored support at consulting rates. See
[[ https://secure.phabricator.com/w/consulting/ | Consulting ]] for details.
Next Steps
==========
Continue by:
- returning to @{article:Contributing Bug Reports}.
diff --git a/src/docs/user/cluster/cluster.diviner b/src/docs/user/cluster/cluster.diviner
index 30ad53efb8..2362b80485 100644
--- a/src/docs/user/cluster/cluster.diviner
+++ b/src/docs/user/cluster/cluster.diviner
@@ -1,342 +1,342 @@
@title Clustering Introduction
@group cluster
Guide to configuring Phabricator across multiple hosts for availability and
performance.
Overview
========
WARNING: This feature is a prototype. Installs should expect a challenging
adventure when deploying clusters. In the best of times, configuring a
cluster is complex and requires significant operations experience.
Phabricator can be configured to run on multiple hosts with redundant services
to improve its availability and scalability, and make disaster recovery much
easier.
Clustering is more complex to setup and maintain than running everything on a
single host, but greatly reduces the cost of recovering from hardware and
network failures.
Each Phabricator service has an array of clustering options that can be
configured somewhat independently. Configuring a cluster is inherently complex,
and this is an advanced feature aimed at installs with large userbases and
experienced operations personnel who need this high degree of flexibility.
The remainder of this document summarizes how to add redundancy to each
service and where your efforts are likely to have the greatest impact.
For additional guidance on setting up a cluster, see "Overlaying Services"
and "Cluster Recipes" at the bottom of this document.
Clusterable Services
====================
This table provides an overview of clusterable services, their setup
complexity, and the rough impact that converting them to run on multiple hosts
will have on availability, resistance to data loss, and scalability.
| Service | Setup | Availability | Loss Resistance | Scalability
|---------|-------|--------------|-----------|------------
| **Databases** | Moderate | **High** | **High** | Low
| **Repositories** | Complex | Moderate | **High** | Moderate
| **Daemons** | Minimal | Low | No Risk | Low
| **SSH Servers** | Minimal | Low | No Risk | Low
| **Web Servers** | Minimal | **High** | No Risk | Moderate
| **Notifications** | Minimal | Low | No Risk | Low
| **Fulltext Search** | Minimal | Low | No Risk | Low
See below for a walkthrough of these services in greater detail.
Preparing for Clustering
========================
To begin deploying Phabricator in cluster mode, set up `cluster.addresses`
in your configuration.
This option should contain a list of network address blocks which are considered
to be part of the cluster. Hosts in this list are allowed to bend (or even
break) some of the security and policy rules when they make requests to other
hosts in the cluster, so this list should be as small as possible. See "Cluster
Whitelist Security" below for discussion.
If you are deploying hardware in EC2, a reasonable approach is to launch a
dedicated Phabricator VPC, whitelist the whole VPC as a Phabricator cluster,
and then deploy only Phabricator services into that VPC.
If you have additional auxiliary hosts which run builds and tests via Drydock,
you should //not// include them in the cluster address definition. For more
detailed discussion of the Drydock security model, see
@{article:Drydock User Guide: Security}.
Most other clustering features will not work until you define a cluster by
configuring `cluster.addresses`.
Cluster Whitelist Security
========================
When you configure `cluster.addresses`, you should keep the list of trusted
cluster hosts as small as possible. Hosts on this list gain additional
capabilities, including these:
**Trusted HTTP Headers**: Normally, Phabricator distrusts the load balancer
HTTP headers `X-Forwarded-For` and `X-Forwarded-Proto` because they may be
client-controlled and can be set to arbitrary values by an attacker if no load
balancer is deployed. In particular, clients can set `X-Forwarded-For` to any
value and spoof traffic from arbitrary remotes.
These headers are trusted when they are received from a host on the cluster
address whitelist. This allows requests from cluster loadbalancers to be
interpreted correctly by default without requiring additional custom code or
configuration.
**Intracluster HTTP**: Requests from cluster hosts are not required to use
HTTPS, even if `security.require-https` is enabled, because it is common to
terminate HTTPS on load balancers and use plain HTTP for requests within a
cluster.
**Special Authentication Mechanisms**: Cluster hosts are allowed to connect to
other cluster hosts with "root credentials", and to impersonate any user
account.
The use of root credentials is required because the daemons must be able to
bypass policies in order to function properly: they need to send mail about
private conversations and import commits in private repositories.
The ability to impersonate users is required because SSH nodes must receive,
interpret, modify, and forward SSH traffic. They can not use the original
credentials to do this because SSH authentication is asymmetric and they do not
have the user's private key. Instead, they use root credentials and impersonate
the user within the cluster.
These mechanisms are still authenticated (and use asymmetric keys, like SSH
does), so access to a host in the cluster address block does not mean that an
attacker can immediately compromise the cluster. However, an over-broad cluster
address whitelist may give an attacker who gains some access additional tools
to escalate access.
Note that if an attacker gains access to an actual cluster host, these extra
powers are largely moot. Most cluster hosts must be able to connect to the
master database to function properly, so the attacker will just do that and
freely read or modify whatever data they want.
Cluster: Databases
=================
Configuring multiple database hosts is moderately complex, but normally has the
highest impact on availability and resistance to data loss. This is usually the
most important service to make redundant if your focus is on availability and
disaster recovery.
Configuring replicas allows Phabricator to run in read-only mode if you lose
the master and to quickly promote the replica as a replacement.
For details, see @{article:Cluster: Databases}.
Cluster: Repositories
=====================
Configuring multiple repository hosts is complex, but is required before you
can add multiple daemon or web hosts.
Repository replicas are important for availability if you host repositories
on Phabricator, but less important if you host repositories elsewhere
(instead, you should focus on making that service more available).
The distributed nature of Git and Mercurial tend to mean that they are
naturally somewhat resistant to data loss: every clone of a repository includes
the entire history.
Repositories may become a scalability bottleneck, although this is rare unless
your install has an unusually heavy repository read volume. Slow clones/fetches
may hint at a repository capacity problem. Adding more repository hosts will
provide an approximately linear increase in capacity.
For details, see @{article:Cluster: Repositories}.
Cluster: Daemons
================
Configuring multiple daemon hosts is straightforward, but you must configure
repositories first.
With daemons running on multiple hosts you can transparently survive the loss
of any subset of hosts without an interruption to daemon services, as long as
at least one host remains alive. Daemons are stateless, so spreading daemons
across multiple hosts provides no resistance to data loss.
Daemons can become a bottleneck, particularly if your install sees a large
volume of write traffic to repositories. If the daemon task queue has a
backlog, that hints at a capacity problem. If existing hosts have unused
resources, increase `phd.taskmasters` until they are fully utilized. From
there, adding more daemon hosts will provide an approximately linear increase
in capacity.
For details, see @{article:Cluster: Daemons}.
Cluster: SSH Servers
====================
Configuring multiple SSH hosts is straightforward, but you must configure
repositories first.
With multiple SSH hosts you can transparently survive the loss of any subset
of hosts without interruption to repository services, as long as at last one
host remains alive. SSH services are stateless, so putting multiple hosts in
service provides no resistance to data loss because no data is at risk.
SSH hosts are very rarely a scalability bottleneck.
For details, see @{article:Cluster: SSH Servers}.
Cluster: Web Servers
====================
Configuring multiple web hosts is straightforward, but you must configure
repositories first.
With multiple web hosts you can transparently survive the loss of any subset
of hosts as long as at least one host remains alive. Web services are stateless,
so putting multiple hosts in service provides no resistance to data loss
because no data is at risk.
Web hosts can become a bottleneck, particularly if you have a workload that is
heavily focused on reads from the web UI (like a public install with many
anonymous users). Slow responses to web requests may hint at a web capacity
problem. Adding more hosts will provide an approximately linear increase in
capacity.
For details, see @{article:Cluster: Web Servers}.
Cluster: Notifications
======================
Configuring multiple notification hosts is simple and has no pre-requisites.
With multiple notification hosts, you can survive the loss of any subset of
-hosts as long as at least one host remains alive. Service may be breifly
+hosts as long as at least one host remains alive. Service may be briefly
disrupted directly after the incident which destroys the other hosts.
Notifications are noncritical, so this normally has little practical impact
on service availability. Notifications are also stateless, so clustering this
service provides no resistance to data loss because no data is at risk.
Notification delivery normally requires very few resources, so adding more
hosts is unlikely to have much impact on scalability.
For details, see @{article:Cluster: Notifications}.
Cluster: Fulltext Search
========================
Configuring search services is relatively simple and has no pre-requisites.
By default, Phabricator uses MySQL as a fulltext search engine, so deploying
multiple database hosts will effectively also deploy multiple fulltext search
hosts.
Search indexes can be completely rebuilt from the database, so there is no
risk of data loss no matter how fulltext search is configured.
For details, see @{article:Cluster: Search}.
Overlaying Services
===================
Although hosts can run a single dedicated service type, certain groups of
services work well together. Phabricator clusters usually do not need to be
very large, so deploying a small number of hosts with multiple services is a
good place to start.
In planning a cluster, consider these blended host types:
**Everything**: Run HTTP, SSH, MySQL, notifications, repositories and daemons
on a single host. This is the starting point for single-node setups, and
usually also the best configuration when adding the second node.
**Everything Except Databases**: Run HTTP, SSH, notifications, repositories and
daemons on one host, and MySQL on a different host. MySQL uses many of the same
resources that other services use. It's also simpler to separate than other
services, and tends to benefit the most from dedicated hardware.
**Repositories and Daemons**: Run repositories and daemons on the same host.
Repository hosts //must// run daemons, and it normally makes sense to
completely overlay repositories and daemons. These services tend to use
different resources (repositories are heavier on I/O and lighter on CPU/RAM;
daemons are heavier on CPU/RAM and lighter on I/O).
Repositories and daemons are also both less latency sensitive than other
service types, so there's a wider margin of error for under provisioning them
before performance is noticeably affected.
These nodes tend to use system resources in a balanced way. Individual nodes
in this class do not need to be particularly powerful.
**Frontend Servers**: Run HTTP and SSH on the same host. These are easy to set
up, stateless, and you can scale the pool up or down easily to meet demand.
Routing both types of ingress traffic through the same initial tier can
simplify load balancing.
These nodes tend to need relatively little RAM.
Cluster Recipes
===============
This section provides some guidance on reasonable ways to scale up a cluster.
The smallest possible cluster is **two hosts**. Run everything (web, ssh,
database, notifications, repositories, and daemons) on each host. One host will
serve as the master; the other will serve as a replica.
Ideally, you should physically separate these hosts to reduce the chance that a
natural disaster or infrastructure disruption could disable or destroy both
hosts at the same time.
From here, you can choose how you expand the cluster.
To improve **scalability and performance**, separate loaded services onto
dedicated hosts and then add more hosts of that type to increase capacity. If
you have a two-node cluster, the best way to improve scalability by adding one
host is likely to separate the master database onto its own host.
Note that increasing scale may //decrease// availability by leaving you with
too little capacity after a failure. If you have three hosts handling traffic
and one datacenter fails, too much traffic may be sent to the single remaining
host in the surviving datacenter. You can hedge against this by mirroring new
hosts in other datacenters (for example, also separate the replica database
onto its own host).
After separating databases, separating repository + daemon nodes is likely
the next step to consider.
To improve **availability**, add another copy of everything you run in one
datacenter to a new datacenter. For example, if you have a two-node cluster,
the best way to improve availability is to run everything on a third host in a
third datacenter. If you have a 6-node cluster with a web node, a database node
and a repo + daemon node in two datacenters, add 3 more nodes to create a copy
of each node in a third datacenter.
You can continue adding hosts until you run out of hosts.
Next Steps
==========
Continue by:
- learning how Phacility configures and operates a large, multi-tenant
production cluster in ((cluster)).
diff --git a/src/docs/user/cluster/cluster_databases.diviner b/src/docs/user/cluster/cluster_databases.diviner
index 7255662958..51bf14d925 100644
--- a/src/docs/user/cluster/cluster_databases.diviner
+++ b/src/docs/user/cluster/cluster_databases.diviner
@@ -1,409 +1,409 @@
@title Cluster: Databases
@group cluster
Configuring Phabricator to use multiple database hosts.
Overview
========
You can deploy Phabricator with multiple database hosts, configured as a master
and a set of replicas. The advantages of doing this are:
- faster recovery from disasters by promoting a replica;
- graceful degradation if the master fails; and
- some tools to help monitor and manage replica health.
This configuration is complex, and many installs do not need to pursue it.
If you lose the master, Phabricator can degrade automatically into read-only
mode and remain available, but can not fully recover without operational
intervention unless the master recovers on its own.
Phabricator will not currently send read traffic to replicas unless the master
has failed, so configuring a replica will not currently spread any load away
from the master. Future versions of Phabricator are expected to be able to
distribute some read traffic to replicas.
Phabricator can not currently be configured into a multi-master mode, nor can
it be configured to automatically promote a replica to become the new master.
There are no current plans to support multi-master mode or autonomous failover,
although this may change in the future.
Phabricator applications //can// be partitioned across multiple database
masters. This does not provide redundancy and generally does not increase
-resiliance or resistance to data loss, but can help you scale and operate
+resilience or resistance to data loss, but can help you scale and operate
Phabricator. For details, see
@{article:Cluster: Partitioning and Advanced Configuration}.
Setting up MySQL Replication
============================
To begin, set up a replica database server and configure MySQL replication.
If you aren't sure how to do this, refer to the MySQL manual for instructions.
The MySQL documentation is comprehensive and walks through the steps and
options in good detail. You should understand MySQL replication before
deploying it in production: Phabricator layers on top of it, and does not
attempt to abstract it away.
Some useful notes for configuring replication for Phabricator:
**Binlog Format**: Phabricator issues some queries which MySQL will detect as
unsafe if you use the `STATEMENT` binlog format (the default). Instead, use
`MIXED` (recommended) or `ROW` as the `binlog_format`.
**Grant `REPLICATION CLIENT` Privilege**: If you give the user that Phabricator
will use to connect to the replica database server the `REPLICATION CLIENT`
privilege, Phabricator's status console can give you more information about
replica health and state.
**Copying Data to Replicas**: Phabricator currently uses a mixture of MyISAM
and InnoDB tables, so it can be difficult to guarantee that a dump is wholly
consistent and suitable for loading into a replica because MySQL uses different
consistency mechanisms for the different storage engines.
An approach you may want to consider to limit downtime but still produce a
consistent dump is to leave Phabricator running but configured in read-only
mode while dumping:
- Stop all the daemons.
- Set `cluster.read-only` to `true` and deploy the new configuration. The
web UI should now show that Phabricator is in "Read Only" mode.
- Dump the database. You can do this with `bin/storage dump --for-replica`
to add the `--master-data` flag to the underlying command and include a
`CHANGE MASTER ...` statement in the dump.
- Once the dump finishes, turn `cluster.read-only` off again to restore
service. Continue loading the dump into the replica normally.
**Log Expiration**: You can configure MySQL to automatically clean up old
binary logs on startup with the `expire_logs_days` option. If you do not
configure this and do not explicitly purge old logs with `PURGE BINARY LOGS`,
the binary logs on disk will grow unboundedly and relatively quickly.
Once you have a working replica, continue below to tell Phabricator about it.
Configuring Replicas
====================
Once your replicas are in working order, tell Phabricator about them by
configuring the `cluster.databases` option. This option must be configured from
the command line or in configuration files because Phabricator needs to read
it //before// it can connect to databases.
This option value will list all of the database hosts that you want Phabricator
to interact with: your master and all your replicas. Each entry in the list
should have these keys:
- `host`: //Required string.// The database host name.
- `role`: //Required string.// The cluster role of this host, one of
`master` or `replica`.
- `port`: //Optional int.// The port to connect to. If omitted, the default
port from `mysql.port` will be used.
- `user`: //Optional string.// The MySQL username to use to connect to this
host. If omitted, the default from `mysql.user` will be used.
- `pass`: //Optional string.// The password to use to connect to this host.
If omitted, the default from `mysql.pass` will be used.
- `disabled`: //Optional bool.// If set to `true`, Phabricator will not
connect to this host. You can use this to temporarily take a host out
of service.
When `cluster.databases` is configured the `mysql.host` option is not used.
The other MySQL connection configuration options (`mysql.port`, `mysql.user`,
`mysql.pass`) are used only to provide defaults.
Once you've configured this option, restart Phabricator for the changes to take
effect, then continue to "Monitoring Replicas" to verify the configuration.
Monitoring Replicas
===================
You can monitor replicas in {nav Config > Database Servers}. This interface
shows you a quick overview of replicas and their health, and can detect some
common issues with replication.
The table on this page shows each database and current status.
NOTE: This page runs its diagnostics //from the web server that is serving the
request//. If you are recovering from a disaster, the view this page shows
may be partial or misleading, and two requests served by different servers may
see different views of the cluster.
**Connection**: Phabricator tries to connect to each configured database, then
shows the result in this column. If it fails, a brief diagnostic message with
details about the error is shown. If it succeeds, the column shows a rough
measurement of latency from the current webserver to the database.
**Replication**: This is a summary of replication status on the database. If
things are properly configured and stable, the replicas should be actively
replicating and no more than a few seconds behind master, and the master
should //not// be replicating from another database.
To report this status, the user Phabricator is connecting as must have the
`REPLICATION CLIENT` privilege (or the `SUPER` privilege) so it can run the
`SHOW SLAVE STATUS` command. The `REPLICATION CLIENT` privilege only enables
the user to run diagnostic commands so it should be reasonable to grant it in
most cases, but it is not required. If you choose not to grant it, this page
can not show any useful diagnostic information about replication status but
everything else will still work.
If a replica is more than a second behind master, this page will show the
current replication delay. If the replication delay is more than 30 seconds,
it will report "Slow Replication" with a warning icon.
If replication is delayed, data is at risk: if you lose the master and can not
later recover it (for example, because a meteor has obliterated the datacenter
housing the physical host), data which did not make it to the replica will be
lost forever.
Beyond the risk of data loss, any read-only traffic sent to the replica will
see an older view of the world which could be confusing for users: it may
appear that their data has been lost, even if it is safe and just hasn't
replicated yet.
Phabricator will attempt to prevent clients from seeing out-of-date views, but
sometimes sending traffic to a delayed replica is the best available option
(for example, if the master can not be reached).
**Health**: This column shows the result of recent health checks against the
server. After several checks in a row fail, Phabricator will mark the server
as unhealthy and stop sending traffic to it until several checks in a row
later succeed.
Note that each web server tracks database health independently, so if you have
several servers they may have different views of database health. This is
normal and not problematic.
For more information on health checks, see "Unreachable Masters" below.
**Messages**: This column has additional details about any errors shown in the
other columns. These messages can help you understand or resolve problems.
Testing Replicas
================
To test that your configuration can survive a disaster, turn off the master
database. Do this with great ceremony, making a cool explosion sound as you
run the `mysqld stop` command.
If things have been set up properly, Phabricator should degrade to a temporary
read-only mode immediately. After a brief period of unresponsiveness, it will
degrade further into a longer-term read-only mode. For details on how this
works internally, see "Unreachable Masters" below.
Once satisfied, turn the master back on. After a brief delay, Phabricator
should recognize that the master is healthy again and recover fully.
Throughout this process, the {nav Database Servers} console will show a
current view of the world from the perspective of the web server handling the
request. You can use it to monitor state.
You can perform a more narrow test by enabling `cluster.read-only` in
configuration. This will put Phabricator into read-only mode immediately
without turning off any databases.
You can use this mode to understand which capabilities will and will not be
available in read-only mode, and make sure any information you want to remain
accessible in a disaster (like wiki pages or contact information) is really
accessible.
See the next section, "Degradation to Read Only Mode", for more details about
when, why, and how Phabricator degrades.
If you run custom code or extensions, they may not accommodate read-only mode
properly. You should specifically test that they function correctly in
read-only mode and do not prevent you from accessing important information.
Degradation to Read-Only Mode
=============================
Phabricator will degrade to read-only mode when any of these conditions occur:
- you turn it on explicitly;
- you configure cluster mode, but don't set up any masters;
- the master can not be reached while handling a request; or
- recent attempts to connect to the master have consistently failed.
When Phabricator is running in read-only mode, users can still read data and
browse and clone repositories, but they can not edit, update, or push new
changes. For example, users can still read disaster recovery information on
the wiki or emergency contact information on user profiles.
You can enable this mode explicitly by configuring `cluster.read-only`. Some
reasons you might want to do this include:
- to test that the mode works like you expect it to;
- to make sure that information you need will be available;
- to prevent new writes while performing database maintenance; or
- to permanently archive a Phabricator install.
You can also enable this mode implicitly by configuring `cluster.databases`
but disabling the master, or by not specifying any host as a master. This may
be more convenient than turning it on explicitly during the course of
operations work.
If Phabricator is unable to reach the master database, it will degrade into
read-only mode automatically. See "Unreachable Masters" below for details on
how this process works.
If you end up in a situation where you have lost the master and can not get it
back online (or can not restore it quickly) you can promote a replica to become
the new master. See the next section, "Promoting a Replica", for details.
Promoting a Replica
===================
If you lose access to the master database, Phabricator will degrade into
read-only mode. This is described in greater detail below.
The easiest way to get out of read-only mode is to restore the master database.
If the database recovers on its own or operations staff can revive it,
Phabricator will return to full working order after a few moments.
If you can't restore the master or are unsure you will be able to restore the
master quickly, you can promote a replica to become the new master instead.
Before doing this, you should first assess how far behind the master the
replica was when the link died. Any data which was not replicated will either
be lost or become very difficult to recover after you promote a replica.
For example, if some `T1234` had been created on the master but had not yet
replicated and you promote the replica, a new `T1234` may be created on the
replica after promotion. Even if you can recover the master later, merging
the data will be difficult because each database may have conflicting changes
which can not be merged easily.
If there was a significant replication delay at the time of the failure, you
may wait to try harder or spend more time attempting to recover the master
before choosing to promote.
If you have made a choice to promote, disable replication on the replica and
mark it as the `master` in `cluster.databases`. Remove the original master and
deploy the configuration change to all surviving hosts.
Once write service is restored, you should provision, deploy, and configure a
new replica by following the steps you took the first time around. You are
critically vulnerable to a second disruption until you have restored the
redundancy.
Unreachable Masters
===================
This section describes how Phabricator determines that a master has been lost,
marks it unreachable, and degrades into read-only mode.
Phabricator degrades into read-only mode automatically in two ways: very
briefly in response to a single connection failure, or more permanently in
response to a series of connection failures.
In the first case, if a request needs to connect to the master but is not able
to, Phabricator will temporarily degrade into read-only mode for the remainder
of that request. The alternative is to fail abruptly, but Phabricator can
sometimes degrade successfully and still respond to the user's request, so it
makes an effort to finish serving the request from replicas.
If the request was a write (like posting a comment) it will fail anyway, but
if it was a read that did not actually need to use the master it may succeed.
This temporary mode is intended to recover as gracefully as possible from brief
interruptions in service (a few seconds), like a server being restarted, a
network link becoming temporarily unavailable, or brief periods of load-related
disruption. If the anomaly is temporary, Phabricator should recover immediately
(on the next request once service is restored).
This mode can be slow for users (they need to wait on connection attempts to
the master which fail) and does not reduce load on the master (requests still
attempt to connect to it).
The second way Phabricator degrades is by running periodic health checks
against databases, and marking them unhealthy if they fail over a longer period
of time. This mechanism is very similar to the health checks that most HTTP
load balancers perform against web servers.
If a database fails several health checks in a row, Phabricator will mark it as
unhealthy and stop sending all traffic (except for more health checks) to it.
This improves performance during a service interruption and reduces load on the
master, which may help it recover from load problems.
You can monitor the status of health checks in the {nav Database Servers}
console. The "Health" column shows how many checks have run recently and
how many have succeeded.
Health checks run every 3 seconds, and 5 checks in a row must fail or succeed
before Phabricator marks the database as healthy or unhealthy, so it will
generally take about 15 seconds for a database to change state after it goes
down or comes up.
If all of the recent checks fail, Phabricator will mark the database as
unhealthy and stop sending traffic to it. If the master was the database that
was marked as unhealthy, Phabricator will actively degrade into read-only mode
until it recovers.
This mode only attempts to connect to the unhealthy database once every few
seconds to see if it is recovering, so performance will be better on average
(users rarely need to wait for bad connections to fail or time out) and the
database will receive less load.
Once all of the recent checks succeed, Phabricator will mark the database as
healthy again and continue sending traffic to it.
Health checks are tracked individually for each web server, so some web servers
may see a host as healthy while others see it as unhealthy. This is normal, and
can accurately reflect the state of the world: for example, the link between
datacenters may have been lost, so hosts in one datacenter can no longer see
the master, while hosts in the other datacenter still have a healthy link to
it.
Backups
======
Even if you configure replication, you should still retain separate backup
snapshots. Replicas protect you from data loss if you lose a host, but they do
not let you recover from data mutation mistakes.
If something issues `DELETE` or `UPDATE` statements and destroys data on the
master, the mutation will propagate to the replicas almost immediately and the
data will be gone forever. Normally, the only way to recover this data is from
backup snapshots.
Although you should still have a backup process, your backup process can
safely pull dumps from a replica instead of the master. This operation can
be slow, so offloading it to a replica can make the performance of the master
more consistent.
To dump from a replica, you can use `bin/storage dump --host <host>` to
control which host the command connects to. (You may still want to execute
this command //from// that host, to avoid sending the whole dump over the
network).
With the `--for-replica` flag, the `bin/storage dump` command creates dumps
with `--master-data`, which includes a `CHANGE MASTER` statement in the output.
This may be helpful when initially setting up new replicas, as it can make it
easier to change the binlog coordinates to the correct position for the dump.
With recent versions of MySQL, it is also possible to configure a //delayed//
replica which intentionally lags behind the master (say, by 12 hours). In the
event of a bad mutation, this could give you a larger window of time to
recognize the issue and recover the lost data from the delayed replica (which
might be quick) without needing to restore backups (which might be very slow).
Delayed replication is outside the scope of this document, but may be worth
considering as an additional data security step on top of backup snapshots
depending on your resources and needs. If you configure a delayed replica, do
not add it to the `cluster.databases` configuration: Phabricator should never
send traffic to it, and does not need to know about it.
Next Steps
==========
Continue by:
- returning to @{article:Clustering Introduction}.
diff --git a/src/docs/user/cluster/cluster_devices.diviner b/src/docs/user/cluster/cluster_devices.diviner
index 57729fdb86..c90aa220c4 100644
--- a/src/docs/user/cluster/cluster_devices.diviner
+++ b/src/docs/user/cluster/cluster_devices.diviner
@@ -1,247 +1,247 @@
@title Cluster: Devices
@group cluster
Guide to configuring hosts to act as cluster devices.
Cluster Context
===============
This document describes a step in configuring Phabricator to run on
multiple hosts in a cluster configuration. This is an advanced feature. For
more information on clustering, see @{article:Clustering Introduction}.
In this context, device configuration is mostly relevant to configuring
repository services in a cluster. You can find more details about this in
@{article:Cluster: Repositories}.
Overview
========
Some cluster services need to be able to authenticate themselves and interact
with other services. For example, two repository hosts holding copies of the
same repository must be able to fetch changes from one another, even if the
repository is private.
Within a cluster, devices authenticate using SSH keys. Some operations happen
over SSH (using keys in a normal way, as you would when running `ssh` from the
command line), while others happen over HTTP (using SSH keys to sign requests).
Before hosts can authenticate to one another, you need to configure the
credentials so other devices know the keys can be trusted. Beyond establishing
trust, this configuration will establish //device identity//, so each host
knows which device it is explicitly.
Today, this is primarily necessary when configuring repository clusters.
Using Almanac
=============
The tool Phabricator uses to manage cluster devices is the **Almanac**
application, and most configuration will occur through the application's web
UI. If you are not familiar with it, see @{article:Almanac User Guide} first.
This document assumes you are familiar with Almanac concepts.
What Lies Ahead
===============
Here's a brief overview of the steps required to register cluster devices. The
remainder of this document walks through these points in more detail.
- Create an Almanac device record for each device.
- Generate, add, and trust SSH keys if necessary.
- Install Phabricator on the host.
- Use `bin/almanac register` from the host to register it as a device.
See below for guidance on each of these steps.
Individual vs Shared Keys
=========================
Before getting started, you should choose how you plan to manage device SSH
keys. Trust and device identity are handled separately, and there are two ways
to set up SSH keys so that devices can authenticate with one another:
- you can generate a unique SSH key for each device; or
- you can generate one SSH key and share it across multiple devices.
Using **unique keys** allows the tools to do some more sanity/safety checks and
makes it a bit more difficult to misconfigure things, but you'll have to do
more work managing the actual keys. This may be a better choice if you are
setting up a small cluster (2-3 devices) for the first time.
Using **shared keys** makes key management easier but safety checks won't be
able to catch a few kinds of mistakes. This may be a better choice if you are
setting up a larger cluster, plan to expand the cluster later, or have
experience with Phabricator clustering.
Because all cluster keys are all-powerful, there is no material difference
between these methods from a security or trust viewpoint. Unique keys are just
potentially easier to administrate at small scales, while shared keys are
easier at larger scales.
Create Almanac Device Records
=============================
For each host you plan to make part of a Phabricator cluster, go to the
{nav Almanac} application and create a **device** record. For guidance on this
application, see @{article:Almanac User Guide}.
Add **interfaces** to each device record so Phabricator can tell how to
connect to these hosts. Normally, you'll add one HTTP interface (usually on
port 80) and one SSH interface (by default, on port 2222) to each device:
For example, if you are building a two-host repository cluster, you may end
up with records that look like these:
- Device: `repo001.mycompany.net`
- Interface: `123.0.0.1:2222`
- Interface: `123.0.0.1:80`
- Device: `repo002.mycompany.net`
- Interface: `123.0.0.2:2222`
- Interface: `123.0.0.2:80`
Note that these hosts will normally run two `sshd` ports: the standard `sshd`
which you connect to to operate and administrate the host, and the special
Phabricator `sshd` that you connect to to clone and push repositories.
You should specify the Phabricator `sshd` port, **not** the standard `sshd`
port.
If you're using **unique** SSH keys for each device, continue to the next step.
If you're using **shared** SSH keys, create a third device with no interfaces,
like `keywarden.mycompany.net`. This device will just be used as a container to
hold the trusted SSH key and is not a real device.
NOTE: Do **not** create a **service** record yet. Today, service records become
active immediately once they are created, and you haven't set things up yet.
Generate and Trust SSH Keys
===========================
Next, you need to generate or upload SSH keys and mark them as trusted. Marking
a key as trusted gives it tremendous power.
If you're using **unique** SSH keys, upload or generate a key for each
individual device from the device detail screen in the Almanac web UI. Save the
private keys for the next step.
If you're using a **shared** SSH key, upload or generate a single key for
the keywarden device from the device detail screen in the Almanac web UI.
Save the private key for the next step.
Regardless of how many keys you generated, take the key IDs from the tables
in the web UI and run this command from the command line for each key, to mark
each key as trusted:
```
phabricator/ $ ./bin/almanac trust-key --id <key-id-1>
phabricator/ $ ./bin/almanac trust-key --id <key-id-2>
...
```
The warnings this command emits are serious. The private keys are now trusted,
and allow any user or device possessing them to sign requests that bypass
policy checks without requiring additional credentials. Guard them carefully!
If you need to revoke trust for a key later, use `untrust-key`:
```
phabricator/ $ ./bin/almanac untrust-key --id <key-id>
```
Once the keys are trusted, continue to the next step.
Install Phabricator
===================
If you haven't already, install Phabricator on each device you plan to enroll
in the cluster. Cluster repository devices must provide services over both HTTP
and SSH, so you need to install and configure both a webserver and a
Phabricator `sshd` on these hosts.
Generally, you will follow whatever process you otherwise use when installing
Phabricator.
NOTE: Do not start the daemons on the new devices yet. They won't work properly
until you've finished configuring things.
Once Phabricator is installed, you can enroll the devices in the cluster by
registering them.
Register Devices
================
To register a host as an Almanac device, use `bin/almanac register`.
If you are using **unique** keys, run it like this:
```
$ ./bin/almanac register \
--device <device> \
--private-key <key>
```
For example, you might run this command on `repo001` when using unique keys:
```
$ ./bin/almanac register \
--device repo001.mycompany.net \
--private-key /path/to/private.key
```
If you are using a **shared** key, this will be a little more complicated
because you need to override some checks that are intended to prevent mistakes.
Use the `--identify-as` flag to choose a device identity:
```
$ ./bin/almanac register \
--device <keywarden-device> \
--private-key <key> \
--identify-as <actual-device>
```
For example, you might run this command on `repo001` when using a shared key:
```
-$ ./bin/almanac register
+$ ./bin/almanac register \
--device keywarden.mycompany.net \
--private-key /path/to/private-key \
--identify-as repo001.mycompany.net
```
In particular, note that `--device` is always the **trusted** device associated
with the trusted key. The `--identify-as` flag allows several different hosts
to share the same key but still identify as different devices.
The overall effect of the `bin/almanac` command is to copy identity and key
files into `phabricator/conf/keys/`. You can inspect the results by examining
that directory. The helper script just catches potential mistakes and makes
sure the process is completed correctly.
Note that a copy of the active private key is stored in the `conf/keys/`
directory permanently.
When converting a host into a cluster host, you may need to revisit
@{article:Diffusion User Guide: Repository Hosting} and double check the `sudo`
permission for the host. In particular, cluster hosts need to be able to run
`ssh` via `sudo` so they can read the device private key.
Next Steps
==========
Now that devices are registered, you can build cluster services from them.
Return to the relevant cluster service documentation to continue:
- build repository clusters with @{article:Cluster: Repositories};
- return to @{article:Clustering Introduction}; or
- review the Almanac application with @{article:Almanac User Guide}.
diff --git a/src/docs/user/cluster/cluster_partitioning.diviner b/src/docs/user/cluster/cluster_partitioning.diviner
index 4dbb47f9d4..20ae11d6a6 100644
--- a/src/docs/user/cluster/cluster_partitioning.diviner
+++ b/src/docs/user/cluster/cluster_partitioning.diviner
@@ -1,241 +1,241 @@
@title Cluster: Partitioning and Advanced Configuration
@group cluster
Guide to partitioning Phabricator applications across multiple database hosts.
Overview
========
You can partition Phabricator's applications across multiple databases. For
example, you can move an application like Files or Maniphest to a dedicated
database host.
The advantages of doing this are:
- moving heavily used applications to dedicated hardware can help you
scale; and
- you can match application workloads to hardware or configuration to make
operating the cluster easier.
This configuration is complex, and very few installs will benefit from pursuing
it. Phabricator will normally run comfortably with a single database master
even for large organizations.
-Partitioning generally does not do much to increase resiliance or make it
+Partitioning generally does not do much to increase resilience or make it
easier to recover from disasters, and is primarily a mechanism for scaling and
operational convenience.
If you are considering partitioning, you likely want to configure replication
with a single master first. Even if you choose not to deploy replication, you
should review and understand how replication works before you partition. For
details, see @{article:Cluster: Databases}.
Databases also support some advanced configuration options. Briefly:
- `persistent`: Allows use of persistent connections, reducing pressure on
outbound ports.
See "Advanced Configuration", below, for additional discussion.
What Partitioning Does
======================
When you partition Phabricator, you move all of the data for one or more
applications (like Maniphest) to a new master database host. This is possible
because Phabricator stores data for each application in its own logical
database (like `phabricator_maniphest`) and performs no joins between databases.
If you're running into scale limits on a single master database, you can move
one or more of your most commonly-used applications to a second database host
and continue adding users. You can keep partitioning applications until all
heavily used applications have dedicated database servers.
Alternatively or additionally, you can partition applications to make operating
the cluster easier. Some applications have unusual workloads or requirements,
and moving them to separate hosts may make things easier to deal with overall.
For example: if Files accounts for most of the data on your install, you might
move it to a different host to make backing up everything else easier.
Configuration Overview
======================
To configure partitioning, you will add multiple entries to `cluster.databases`
with the `master` role. Each `master` should specify a new `partition` key,
which contains a list of application databases it should host.
One master may be specified as the `default` partition. Applications not
explicitly configured to be assigned elsewhere will be assigned here.
When you define multiple `master` databases, you must also specify which master
each `replica` database follows. Here's a simple example config:
```lang=json
...
"cluster.databases": [
{
"host": "db001.corporation.com",
"role": "master",
"user": "phabricator",
"pass": "hunter2!trustno1",
"port": 3306,
"partition": [
"default"
]
},
{
"host": "db002.corporation.com",
"role": "replica",
"user": "phabricator",
"pass": "hunter2!trustno1",
"port": 3306,
"master": "db001.corporation.com:3306"
},
{
"host": "db003.corporation.com",
"role": "master",
"user": "phabricator",
"pass": "hunter2!trustno1",
"port": 3306,
"partition": [
"file",
"passphrase",
"slowvote"
]
},
{
"host": "db004.corporation.com",
"role": "replica",
"user": "phabricator",
"pass": "hunter2!trustno1",
"port": 3306,
"master": "db003.corporation.com:3306"
}
],
...
```
In this configuration, `db001` is a master and `db002` replicates it.
`db003` is a second master, replicated by `db004`.
Applications have been partitioned like this:
- `db003`/`db004`: Files, Passphrase, Slowvote
- `db001`/`db002`: Default (all other applications)
Not all of the database partition names are the same as the application
names. You can get a list of databases with `bin/storage databases` to identify
the correct database names.
After you have configured partitioning, it needs to be committed to the
databases. This writes a copy of the configuration to tables on the databases,
preventing errors if a webserver accidentally starts with an old or invalid
configuration.
To commit the configuration, run this command:
```
phabricator/ $ ./bin/storage partition
```
Run this command after making any partition or clustering changes. Webservers
will not serve traffic if their configuration and the database configuration
differ.
Launching a new Partition
=========================
To add a new partition, follow these steps:
- Set up the new database host or hosts.
- Add the new database to `cluster.databases`, but keep its "partition"
configuration empty (just an empty list). If this is the first time you
are partitioning, you will need to configure your existing master as the
new "default". This will let Phabricator interact with it, but won't send
any traffic to it yet.
- Run `bin/storage partition`.
- Run `bin/storage upgrade` to initialize the schemata on the new hosts.
- Stop writes to the applications you want to move by putting Phabricator
in read-only mode, or shutting down the webserver and daemons, or telling
everyone not to touch anything.
- Dump the data from the application databases on the old master.
- Load the data into the application databases on the new master.
- Reconfigure the "partition" setup so that Phabricator knows the databases
have moved.
- Run `bin/storage partition`.
- While still in read-only mode, check that all the data appears to be
intact.
- Resume writes.
You can do this with a small, rarely-used application first (on most installs,
Slowvote might be a good candidate) if you want to run through the process
end-to-end before performing a larger, higher-stakes migration.
How Partitioning Works
======================
If you have multiple masters, Phabricator keeps the entire set of schemata up
to date on all of them. When you run `bin/storage upgrade` or other storage
management commands, they generally affect all masters (if they do not, they
will prompt you to be more specific).
When the application goes to read or write normal data (for example, to query a
list of tasks) it only connects to the master which the application it is
acting on behalf of is assigned to.
In most cases, a masters will not have any data in most the databases which are
not assigned to it. If they do (for example, because they previously hosted the
application) the data is ignored. This approach (of maintaining all schemata on
all hosts) makes it easier to move data and to quickly revert changes if a
configuration mistake occurs.
There are some exceptions to this rule. For example, all masters keep track
of which patches have been applied to that particular master so that
`bin/storage upgrade` can upgrade hosts correctly.
Phabricator does not perform joins across logical databases, so there are no
meaningful differences in runtime behavior if two applications are on the same
physical host or different physical hosts.
Advanced Configuration
======================
Separate from partitioning, some advanced configuration is supported. These
options must be set on database specifications in `cluster.databases`. You can
configure them without actually building a cluster by defining a cluster with
only one master.
`persistent` //(bool)// Enables persistent connections. Defaults to off.
-With persitent connections enabled, Phabricator will keep a pool of database
+With persistent connections enabled, Phabricator will keep a pool of database
connections open between web requests and reuse them when serving subsequent
requests.
The primary benefit of using persistent connections is that it will greatly
reduce pressure on how quickly outbound TCP ports are opened and closed. After
a TCP port closes, it normally can't be used again for about 60 seconds, so
-rapidly cycling ports can cause resource exuastion. If you're seeing failures
+rapidly cycling ports can cause resource exhaustion. If you're seeing failures
because requests are unable to bind to an outbound port, enabling this option
is likely to fix the issue. This option may also slightly increase performance.
The cost of using persistent connections is that you may need to raise the
MySQL `max_connections` setting: although Phabricator will make far fewer
connections, the connections it does make will be longer-lived. Raising this
setting will increase MySQL memory requirements and may run into other limits,
like `open_files_limit`, which may also need to be raised.
Persistent connections are enabled per-database. If you always want to use
them, set the flag on each configured database in `cluster.databases`.
Next Steps
==========
Continue by:
- returning to @{article:Clustering Introduction}.
diff --git a/src/docs/user/cluster/cluster_repositories.diviner b/src/docs/user/cluster/cluster_repositories.diviner
index 21aba731cc..44003679af 100644
--- a/src/docs/user/cluster/cluster_repositories.diviner
+++ b/src/docs/user/cluster/cluster_repositories.diviner
@@ -1,492 +1,492 @@
@title Cluster: Repositories
@group cluster
Configuring Phabricator to use multiple repository hosts.
Overview
========
If you use Git, you can deploy Phabricator with multiple repository hosts,
configured so that each host is readable and writable. The advantages of doing
this are:
- you can completely survive the loss of repository hosts;
- reads and writes can scale across multiple machines; and
- read and write performance across multiple geographic regions may improve.
This configuration is complex, and many installs do not need to pursue it.
This configuration is not currently supported with Subversion or Mercurial.
How Reads and Writes Work
=========================
Phabricator repository replicas are multi-master: every node is readable and
writable, and a cluster of nodes can (almost always) survive the loss of any
arbitrary subset of nodes so long as at least one node is still alive.
Phabricator maintains an internal version for each repository, and increments
it when the repository is mutated.
Before responding to a read, replicas make sure their version of the repository
is up to date (no node in the cluster has a newer version of the repository).
If it isn't, they block the read until they can complete a fetch.
Before responding to a write, replicas obtain a global lock, perform the same
version check and fetch if necessary, then allow the write to continue.
Additionally, repositories passively check other nodes for updates and
replicate changes in the background. After you push a change to a repository,
it will usually spread passively to all other repository nodes within a few
minutes.
Even if passive replication is slow, the active replication makes acknowledged
changes sequential to all observers: after a write is acknowledged, all
subsequent reads are guaranteed to see it. The system does not permit stale
reads, and you do not need to wait for a replication delay to see a consistent
view of the repository no matter which node you ask.
HTTP vs HTTPS
=============
Intracluster requests (from the daemons to repository servers, or from
webservers to repository servers) are permitted to use HTTP, even if you have
set `security.require-https` in your configuration.
It is common to terminate SSL at a load balancer and use plain HTTP beyond
that, and the `security.require-https` feature is primarily focused on making
client browser behavior more convenient for users, so it does not apply to
intracluster traffic.
Using HTTP within the cluster leaves you vulnerable to attackers who can
observe traffic within a datacenter, or observe traffic between datacenters.
This is normally very difficult, but within reach for state-level adversaries
like the NSA.
If you are concerned about these attackers, you can terminate HTTPS on
repository hosts and bind to them with the "https" protocol. Just be aware that
the `security.require-https` setting won't prevent you from making
configuration mistakes, as it doesn't cover intracluster traffic.
Other mitigations are possible, but securing a network against the NSA and
similar agents of other rogue nations is beyond the scope of this document.
Repository Hosts
================
Repository hosts must run a complete, fully configured copy of Phabricator,
including a webserver. They must also run a properly configured `sshd`.
If you are converting existing hosts into cluster hosts, you may need to
revisit @{article:Diffusion User Guide: Repository Hosting} and make sure
the system user accounts have all the necessary `sudo` permissions. In
particular, cluster devices need `sudo` access to `ssh` so they can read
device keys.
Generally, these hosts will run the same set of services and configuration that
web hosts run. If you prefer, you can overlay these services and put web and
repository services on the same hosts. See @{article:Clustering Introduction}
for some guidance on overlaying services.
When a user requests information about a repository that can only be satisfied
by examining a repository working copy, the webserver receiving the request
will make an HTTP service call to a repository server which hosts the
repository to retrieve the data it needs. It will use the result of this query
to respond to the user.
Setting up a Cluster Services
=============================
To set up clustering, first register the devices that you want to use as part
of the cluster with Almanac. For details, see @{article:Cluster: Devices}.
NOTE: Once you create a service, new repositories will immediately allocate
on it. You may want to disable repository creation during initial setup.
Once the hosts are registered as devices, you can create a new service in
Almanac:
- First, register at least one device according to the device clustering
instructions.
- Create a new service of type **Phabricator Cluster: Repository** in
Almanac.
- Bind this service to all the interfaces on the device or devices.
- For each binding, add a `protocol` key with one of these values:
`ssh`, `http`, `https`.
For example, a service might look like this:
- Service: `repos001.mycompany.net`
- Binding: `repo001.mycompany.net:80`, `protocol=http`
- Binding: `repo001.mycompany.net:2222`, `protocol=ssh`
The service itself has a `closed` property. You can set this to `true` to
disable new repository allocations on this service (for example, if it is
reaching capacity).
Migrating to Clustered Services
===============================
To convert existing repositories on an install into cluster repositories, you
will generally perform these steps:
- Register the existing host as a cluster device.
- Configure a single host repository service using //only// that host.
This puts you in a transitional state where repositories on the host can work
as either on-host repositories or cluster repositories. You can move forward
from here slowly and make sure services still work, with a quick path back to
safety if you run into trouble.
To move forward, migrate one repository to the service and make sure things
work correctly. If you run into issues, you can back out by migrating the
repository off the service.
To migrate a repository onto a cluster service, use this command:
```
$ ./bin/repository clusterize <repository> --service <service>
```
To migrate a repository back off a service, use this command:
```
$ ./bin/repository clusterize <repository> --remove-service
```
This command only changes how Phabricator connects to the repository; it does
not move any data or make any complex structural changes.
When Phabricator needs information about a non-clustered repository, it just
runs a command like `git log` directly on disk. When Phabricator needs
information about a clustered repository, it instead makes a service call to
another server, asking that server to run `git log` instead.
In a single-host cluster the server will make this service call to itself, so
nothing will really change. But this //is// an effective test for most
possible configuration mistakes.
If your canary repository works well, you can migrate the rest of your
repositories when ready (you can use `bin/repository list` to quickly get a
list of all repository monograms).
Once all repositories are migrated, you've reached a stable state and can
remain here as long as you want. This state is sufficient to convert daemons,
SSH, and web services into clustered versions and spread them across multiple
machines if those goals are more interesting.
Obviously, your single-device "cluster" will not be able to survive the loss of
the single repository host, but you can take as long as you want to expand the
cluster and add redundancy.
After creating a service, you do not need to `clusterize` new repositories:
they will automatically allocate onto an open service.
When you're ready to expand the cluster, continue below.
Expanding a Cluster
===================
To expand an existing cluster, follow these general steps:
- Register new devices in Almanac.
- Add bindings to the new devices to the repository service, also in Almanac.
- Start the daemons on the new devices.
For instructions on configuring and registering devices, see
@{article:Cluster: Devices}.
As soon as you add active bindings to a service, Phabricator will begin
synchronizing repositories and sending traffic to the new device. You do not
need to copy any repository data to the device: Phabricator will automatically
synchronize it.
If you have a large amount of repository data, you may want to help this
process along by copying the repository directory from an existing cluster
device before bringing the new host online. This is optional, but can reduce
the amount of time required to fully synchronize the cluster.
You do not need to synchronize the most up-to-date data or stop writes during
this process. For example, loading the most recent backup snapshot onto the new
device will substantially reduce the amount of data that needs to be
synchronized.
Contracting a Cluster
=====================
To reduce the size of an existing cluster, follow these general steps:
- Disable the bindings from the service to the dead device in Almanac.
If you are removing a device because it failed abruptly (or removing several
devices at once) it is possible that some repositories will have lost all their
leaders. See "Loss of Leaders" below to understand and resolve this.
Monitoring Services
===================
You can get an overview of repository cluster status from the
{nav Config > Repository Servers} screen. This table shows a high-level
overview of all active repository services.
**Repos**: The number of repositories hosted on this service.
**Sync**: Synchronization status of repositories on this service. This is an
at-a-glance view of service health, and can show these values:
- **Synchronized**: All nodes are fully synchronized and have the latest
version of all repositories.
- **Partial**: All repositories either have at least two leaders, or have
a very recent write which is not expected to have propagated yet.
- **Unsynchronized**: At least one repository has changes which are
only available on one node and were not pushed very recently. Data may
be at risk.
- **No Repositories**: This service has no repositories.
- **Ambiguous Leader**: At least one repository has an ambiguous leader.
If this screen identifies problems, you can drill down into repository details
to get more information about them. See the next section for details.
Monitoring Repositories
=======================
You can get a more detailed view the current status of a specific repository on
cluster devices in {nav Diffusion > (Repository) > Manage Repository > Cluster
Configuration}.
This screen shows all the configured devices which are hosting the repository
and the available version on that device.
**Version**: When a repository is mutated by a push, Phabricator increases
an internal version number for the repository. This column shows which version
is on disk on the corresponding device.
After a change is pushed, the device which received the change will have a
larger version number than the other devices. The change should be passively
replicated to the remaining devices after a brief period of time, although this
can take a while if the change was large or the network connection between
devices is slow or unreliable.
You can click the version number to see the corresponding push logs for that
change. The logs contain details about what was changed, and can help you
identify if replication is slow because a change is large or for some other
reason.
**Writing**: This shows that the device is currently holding a write lock. This
normally means that it is actively receiving a push, but can also mean that
there was a write interruption. See "Write Interruptions" below for details.
**Last Writer**: This column identifies the user who most recently pushed a
change to this device. If the write lock is currently held, this user is
the user whose change is holding the lock.
**Last Write At**: When the most recent write started. If the write lock is
currently held, this shows when the lock was acquired.
Cluster Failure Modes
=====================
There are three major cluster failure modes:
- **Write Interruptions**: A write started but did not complete, leaving
the disk state and cluster state out of sync.
- **Loss of Leaders**: None of the devices with the most up-to-date data
are reachable.
- **Ambiguous Leaders**: The internal state of the repository is unclear.
Phabricator can detect these issues, and responds by freezing the repository
(usually preventing all reads and writes) until the issue is resolved. These
conditions are normally rare and very little data is at risk, but Phabricator
errs on the side of caution and requires decisions which may result in data
loss to be confirmed by a human.
The next sections cover these failure modes and appropriate responses in
more detail. In general, you will respond to these issues by assessing the
situation and then possibly choosing to discard some data.
Write Interruptions
===================
A repository cluster can be put into an inconsistent state by an interruption
in a brief window during and immediately after a write. This looks like this:
- A change is pushed to a server.
- The server acquires a write lock and begins writing the change.
- During or immediately after the write, lightning strikes the server
and destroys it.
Phabricator can not commit changes to a working copy (stored on disk) and to
the global state (stored in a database) atomically, so there is necessarily a
narrow window between committing these two different states when some tragedy
can befall a server, leaving the global and local views of the repository state
possibly divergent.
In these cases, Phabricator fails into a frozen state where further writes
are not permitted until the failure is investigated and resolved. When a
repository is frozen in this way it remains readable.
You can use the monitoring console to review the state of a frozen repository
with a held write lock. The **Writing** column will show which device is
holding the lock, and whoever is named in the **Last Writer** column may be
able to help you figure out what happened by providing more information about
what they were doing and what they observed.
Because the push was not acknowledged, it is normally safe to resolve this
issue by demoting the device. Demoting the device will undo any changes
committed by the push, and they will be lost forever.
However, the user should have received an error anyway, and should not expect
their push to have worked. Still, data is technically at risk and you may want
to investigate further and try to understand the issue in more detail before
continuing.
There is no way to explicitly keep the write, but if it was committed to disk
you can recover it manually from the working copy on the device (for example,
by using `git format-patch`) and then push it again after recovering.
If you demote the device, the in-process write will be thrown away, even if it
was complete on disk. To demote the device and release the write lock, run this
command:
```
phabricator/ $ ./bin/repository thaw <repository> --demote <device>
```
{icon exclamation-triangle, color="yellow"} Any committed but unacknowledged
data on the device will be lost.
Loss of Leaders
===============
A more straightforward failure condition is the loss of all servers in a
cluster which have the most up-to-date copy of a repository. This looks like
this:
- There is a cluster setup with two devices, X and Y.
- A new change is pushed to server X.
- Before the change can propagate to server Y, lightning strikes server X
and destroys it.
Here, all of the "leader" devices with the most up-to-date copy of the
repository have been lost. Phabricator will freeze the repository refuse to
serve requests because it can not serve reads consistently and can not accept
new writes without data loss.
The most straightforward way to resolve this issue is to restore any leader to
service. The change will be able to replicate to other devices once a leader
comes back online.
If you are unable to restore a leader or unsure that you can restore one
quickly, you can use the monitoring console to review which changes are
present on the leaders but not present on the followers by examining the
push logs.
If you are comfortable discarding these changes, you can instruct Phabricator
that it can forget about the leaders in two ways: disable the service bindings
to all of the leader devices so they are no longer part of the cluster, or use
`bin/repository thaw` to `--demote` the leaders explicitly.
If you do this, **you will lose data**. Either action will discard any changes
on the affected leaders which have not replicated to other devices in the
cluster.
To remove a device from the cluster, disable all of the bindings to it
in Almanac, using the web UI.
{icon exclamation-triangle, color="red"} Any data which is only present on
the disabled device will be lost.
To demote a device without removing it from the cluster, run this command:
```
phabricator/ $ ./bin/repository thaw rXYZ --demote repo002.corp.net
```
{icon exclamation-triangle, color="red"} Any data which is only present on
**this** device will be lost.
Ambiguous Leaders
=================
Repository clusters can also freeze if the leader devices are ambiguous. This
can happen if you replace an entire cluster with new devices suddenly, or make
a mistake with the `--demote` flag. This may arise from some kind of operator
error, like these:
- Someone accidentally uses `bin/repository thaw ... --demote` to demote
every device in a cluster.
- Someone accidentally deletes all the version information for a repository
from the database by making a mistake with a `DELETE` or `UPDATE` query.
- Someone accidentally disables all of the devices in a cluster, then adds
entirely new ones before repositories can propagate.
If you are moving repositories into cluster services, you can also reach this
state if you use `clusterize` to associate a repository with a service that is
bound to multiple active devices. In this case, Phabricator will not know which
device or devices have up-to-date information.
When Phabricator can not tell which device in a cluster is a leader, it freezes
the cluster because it is possible that some devices have less data and others
-have more, and if it choses a leader arbitrarily it may destroy some data
+have more, and if it chooses a leader arbitrarily it may destroy some data
which you would prefer to retain.
To resolve this, you need to tell Phabricator which device has the most
up-to-date data and promote that device to become a leader. If you know all
devices have the same data, you are free to promote any device.
If you promote a device, **you may lose data** if you promote the wrong device
and some other device really had more up-to-date data. If you want to double
check, you can examine the working copies on disk before promoting by
connecting to the machines and using commands like `git log` to inspect state.
Once you have identified a device which has data you're happy with, use
`bin/repository thaw` to `--promote` the device. The data on the chosen
device will become authoritative:
```
phabricator/ $ ./bin/repository thaw rXYZ --promote repo002.corp.net
```
{icon exclamation-triangle, color="red"} Any data which is only present on
**other** devices will be lost.
Backups
======
Even if you configure clustering, you should still consider retaining separate
backup snapshots. Replicas protect you from data loss if you lose a host, but
they do not let you rewind time to recover from data mutation mistakes.
If something issues a `--force` push that destroys branch heads, the mutation
will propagate to the replicas.
You may be able to manually restore the branches by using tools like the
Phabricator push log or the Git reflog so it is less important to retain
repository snapshots than database snapshots, but it is still possible for
data to be lost permanently, especially if you don't notice the problem for
some time.
Retaining separate backup snapshots will improve your ability to recover more
data more easily in a wider range of disaster situations.
Next Steps
==========
Continue by:
- returning to @{article:Clustering Introduction}.
diff --git a/src/docs/user/cluster/cluster_search.diviner b/src/docs/user/cluster/cluster_search.diviner
index c658f50db4..25c35aa34a 100644
--- a/src/docs/user/cluster/cluster_search.diviner
+++ b/src/docs/user/cluster/cluster_search.diviner
@@ -1,210 +1,210 @@
@title Cluster: Search
@group cluster
Overview
========
You can configure Phabricator to connect to one or more fulltext search
services.
By default, Phabricator will use MySQL for fulltext search. This is suitable
for most installs. However, alternate engines are supported.
Configuring Search Services
===========================
To configure search services, adjust the `cluster.search` configuration
option. This option contains a list of one or more fulltext search services,
like this:
```lang=json
[
{
"type": "...",
"hosts": [
...
],
"roles": {
"read": true,
"write": true
}
}
]
```
When a user makes a change to a document, Phabricator writes the updated
document into every configured, writable fulltext service.
When a user issues a query, Phabricator tries configured, readable services
in order until it is able to execute the query successfully.
These options are supported by all service types:
| Key | Description |
|---|---|
| `type` | Constant identifying the service type, like `mysql`.
| `roles` | Dictionary of role settings, for enabling reads and writes.
| `hosts` | List of hosts for this service.
Some service types support additional options.
Available Service Types
=======================
These service types are supported:
| Service | Key | Description |
|---|---|---|
| MySQL | `mysql` | Default MySQL fulltext index.
| Elasticsearch | `elasticsearch` | Use an external Elasticsearch service
Fulltext Service Roles
======================
These roles are supported:
| Role | Key | Description
|---|---|---|
| Read | `read` | Allows the service to be queried when users search.
| Write | `write` | Allows documents to be published to the service.
Specifying Hosts
================
The `hosts` key should contain a list of dictionaries, each specifying the
details of a host. A service should normally have one or more hosts.
When an option is set at the service level, it serves as a default for all
hosts. It may be overridden by changing the value for a particular host.
Service Type: MySQL
==============
The `mysql` service type does not require any configuration, and does not
need to have hosts specified. This service uses the builtin database to
index and search documents.
A typical `mysql` service configuration looks like this:
```lang=json
{
"type": "mysql"
}
```
Service Type: Elasticsearch
======================
-The `elasticsearch` sevice type supports these options:
+The `elasticsearch` service type supports these options:
| Key | Description |
|---|---|
| `protocol` | Either `"http"` (default) or `"https"`.
| `port` | Elasticsearch TCP port.
| `version` | Elasticsearch version, either `2` or `5` (default).
-| `path` | Path for the index. Defaults to `/phabriator`. Advanced.
+| `path` | Path for the index. Defaults to `/phabricator`. Advanced.
A typical `elasticsearch` service configuration looks like this:
```lang=json
{
"type": "elasticsearch",
"hosts": [
{
"protocol": "http",
"host": "127.0.0.1",
"port": 9200
}
]
}
```
Monitoring Search Services
==========================
You can monitor fulltext search in {nav Config > Search Servers}. This
interface shows you a quick overview of services and their health.
The table on this page shows some basic stats for each configured service,
followed by the configuration and current status of each host.
Rebuilding Indexes
==================
After adding new search services, you will need to rebuild document indexes
on them. To do this, first initialize the services:
```
phabricator/ $ ./bin/search init
```
This will perform index setup steps and other one-time configuration.
To populate documents in all indexes, run this command:
```
phabricator/ $ ./bin/search index --force --background --type all
```
This initiates an exhaustive rebuild of the document indexes. To get a more
detailed list of indexing options available, run:
```
phabricator/ $ ./bin/search help index
```
Advanced Example
================
This is a more advanced example which shows a configuration with multiple
different services in different roles. In this example:
- Phabricator is using an Elasticsearch 2 service as its primary fulltext
service.
- An Elasticsearch 5 service is online, but only receiving writes.
- The MySQL service is serving as a backup if Elasticsearch fails.
This particular configuration may not be very useful. It is primarily
intended to show how to configure many different options.
```lang=json
[
{
"type": "elasticsearch",
"version": 2,
"hosts": [
{
"host": "elastic2.mycompany.com",
"port": 9200,
"protocol": "http"
}
]
},
{
"type": "elasticsearch",
"version": 5,
"hosts": [
{
"host": "elastic5.mycompany.com",
"port": 9789,
"protocol": "https"
"roles": {
"read": false,
"write": true
}
}
]
},
{
"type": "mysql"
}
]
```
diff --git a/src/docs/user/configuration/configuration_guide.diviner b/src/docs/user/configuration/configuration_guide.diviner
index 98c27b9736..8b221bda41 100644
--- a/src/docs/user/configuration/configuration_guide.diviner
+++ b/src/docs/user/configuration/configuration_guide.diviner
@@ -1,212 +1,212 @@
@title Configuration Guide
@group config
This document contains basic configuration instructions for Phabricator.
= Prerequisites =
This document assumes you've already installed all the components you need.
If you haven't, see @{article:Installation Guide}.
The next steps are:
- Configure your webserver (Apache, nginx, or lighttpd).
- Access Phabricator with your browser.
- Follow the instructions to complete setup.
= Webserver: Configuring Apache =
NOTE: Follow these instructions to use Apache. To use nginx or lighttpd, scroll
down to their sections.
Get Apache running and verify it's serving a test page. Consult the Apache
documentation for help. Make sure `mod_php` and `mod_rewrite` are enabled,
and `mod_ssl` if you intend to set up SSL.
If you haven't already, set up a domain name to point to the host you're
installing on. You can either install Phabricator on a subdomain (like
phabricator.example.com) or an entire domain, but you can not install it in
some subdirectory of an existing website. Navigate to whatever domain you're
going to use and make sure Apache serves you something to verify that DNS
is correctly configured.
NOTE: The domain must contain a dot ('.'), i.e. not be just a bare name like
'http://example/'. Some web browsers will not set cookies otherwise.
Now create a VirtualHost entry for Phabricator. It should look something like
this:
name=httpd.conf
<VirtualHost *>
# Change this to the domain which points to your host.
ServerName phabricator.example.com
# Change this to the path where you put 'phabricator' when you checked it
# out from GitHub when following the Installation Guide.
#
# Make sure you include "/webroot" at the end!
DocumentRoot /path/to/phabricator/webroot
RewriteEngine on
RewriteRule ^(.*)$ /index.php?__path__=$1 [B,L,QSA]
</VirtualHost>
If Apache isn't currently configured to serve documents out of the directory
where you put Phabricator, you may also need to add `<Directory />` section. The
syntax for this section depends on which version of Apache you're running.
(If you don't know, you can usually figure this out by running `httpd -v`.)
For Apache versions older than 2.4, use this:
name="Apache Older Than 2.4"
<Directory "/path/to/phabricator/webroot">
Order allow,deny
Allow from all
</Directory>
For Apache versions 2.4 and newer, use this:
name="Apache 2.4 and Newer"
<Directory "/path/to/phabricator/webroot">
Require all granted
</Directory>
After making your edits, restart Apache, then continue to "Setup" below.
= Webserver: Configuring nginx =
NOTE: Follow these instructions to use nginx. To use Apache or lighttpd, scroll
to their sections.
For nginx, use a configuration like this:
name=nginx.conf
server {
server_name phabricator.example.com;
root /path/to/phabricator/webroot;
location / {
index index.php;
rewrite ^/(.*)$ /index.php?__path__=/$1 last;
}
location /index.php {
fastcgi_pass localhost:9000;
fastcgi_index index.php;
#required if PHP was built with --enable-force-cgi-redirect
fastcgi_param REDIRECT_STATUS 200;
#variables to make the $_SERVER populate in PHP
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
}
}
Restart nginx after making your edits, then continue to "Setup" below.
= Webserver: Configuring lighttpd =
-NOTE: Follow these instructions to use lighttpd. To use Apache or niginx, scroll
+NOTE: Follow these instructions to use lighttpd. To use Apache or nginx, scroll
up to their sections.
For lighttpd, add a section like this to your lighttpd.conf:
$HTTP["host"] =~ "phabricator(\.example\.com)?" {
server.document-root = "/path/to/phabricator/webroot"
url.rewrite-once = (
# This simulates QSA ("query string append") mode in apache
"^(/[^?]*)\?(.*)" => "/index.php?__path__=$1&$2",
"^(/.*)$" => "/index.php?__path__=$1",
)
}
You should also ensure the following modules are listed in your
server.modules list:
mod_fastcgi
mod_rewrite
Finally, you should run the following commands to enable php support:
- $ sudo apt-get install php5-cgi # for ubuntu; other distros should be similar
+ $ sudo apt-get install php5-cgi # for Ubuntu; other distros should be similar
$ sudo lighty-enable-mod fastcgi-php
Restart lighttpd after making your edits, then continue below.
Load Balancer Health Checks
===========================
If you're using a load balancer in front of your webserver, you can configure
it to perform health checks using the path `/status/`.
= Setup =
Now, navigate to whichever subdomain you set up. You should see instructions to
continue setup. The rest of this document contains additional instructions for
specific setup steps.
When you resolve any issues and see the welcome screen, enter credentials to
create your initial administrator account. After you log in, you'll want to
configure how other users will be able to log in or register -- until you do,
no one else will be able to sign up or log in. For more information, see
@{article:Configuring Accounts and Registration}.
= Storage: Configuring MySQL =
During setup, you'll need to configure MySQL. To do this, get MySQL running and
verify you can connect to it. Consult the MySQL documentation for help. When
MySQL works, you need to load the Phabricator schemata into it. To do this, run:
phabricator/ $ ./bin/storage upgrade
If your configuration uses an unprivileged user to connect to the database, you
may have to override the default user so the schema changes can be applied with
root or some other admin user:
phabricator/ $ ./bin/storage upgrade --user <user> --password <password>
You can avoid the prompt the script issues by passing the `--force` flag (for
example, if you are scripting the upgrade process).
phabricator/ $ ./bin/storage upgrade --force
NOTE: When you update Phabricator, run `storage upgrade` again to apply any
new updates.
= Next Steps =
Continue by:
- setting up your admin account and login/registration with
@{article:Configuring Accounts and Registration}; or
- understanding advanced configuration topics with
@{article:Configuration User Guide: Advanced Configuration}; or
- configuring an alternate file domain with
@{article:Configuring a File Domain}; or
- configuring a preamble script to set up the environment properly behind a
load balancer, or adjust rate limiting with
@{article:Configuring a Preamble Script}; or
- configuring where uploaded files and attachments will be stored with
@{article:Configuring File Storage}; or
- configuring Phabricator so it can send mail with
@{article:Configuring Outbound Email}; or
- configuring inbound mail with @{article:Configuring Inbound Email}; or
- importing repositories with @{article:Diffusion User Guide}; or
- learning about daemons with @{article:Managing Daemons with phd}; or
- learning about notification with
@{article:Notifications User Guide: Setup and Configuration}; or
- configuring backups with
@{article:Configuring Backups and Performing Migrations}; or
- contributing to Phabricator with @{article:Contributor Introduction}.
diff --git a/src/docs/user/configuration/configuring_backups.diviner b/src/docs/user/configuration/configuring_backups.diviner
index d32eebb0da..9c283c5722 100644
--- a/src/docs/user/configuration/configuring_backups.diviner
+++ b/src/docs/user/configuration/configuring_backups.diviner
@@ -1,171 +1,171 @@
@title Configuring Backups and Performing Migrations
@group config
Advice for backing up Phabricator, or migrating from one machine to another.
Overview
========
Phabricator does not currently have a comprehensive backup system, but creating
backups is not particularly difficult and Phabricator does have a few basic
tools which can help you set up a reasonable process. In particular, the things
which needs to be backed up are:
- the MySQL databases;
- hosted repositories;
- uploaded files; and
- your Phabricator configuration files.
This document discusses approaches for backing up this data.
If you are migrating from one machine to another, you can generally follow the
same steps you would if you were creating a backup and then restoring it, you
will just backup the old machine and then restore the data onto the new
machine.
WARNING: You need to restart Phabricator after restoring data.
Restarting Phabricator after performing a restore makes sure that caches are
flushed properly. For complete instructions, see
@{article:Restarting Phabricator}.
Backup: MySQL Databases
=======================
Most of Phabricator's data is stored in MySQL, and it's the most important thing
to back up. You can run `bin/storage dump` to get a dump of all the MySQL
databases. This is a convenience script which just runs a normal `mysqldump`,
but will only dump databases Phabricator owns.
Since most of this data is compressible, it may be helpful to run it through
gzip prior to storage. For example:
phabricator/ $ ./bin/storage dump | gzip > backup.sql.gz
Then store the backup somewhere safe, like in a box buried under an old tree
stump. No one will ever think to look for it there.
Restore: MySQL
==============
To restore a MySQL dump, just pipe it to `mysql` on a clean host. (You may need
to uncompress it first, if you compressed it prior to storage.)
$ gunzip -c backup.sql.gz | mysql
Backup: Hosted Repositories
===========================
If you host repositories in Phabricator, you should back them up. You can use
`bin/repository list-paths` to show the local paths on disk for each
repository. To back them up, copy them elsewhere.
You can also just clone them and keep the clones up to date, or use
-{nav Add Mirror} to have the mirror somewhere automatically.
+{nav Add Mirror} to have them mirror somewhere automatically.
Restore: Hosted Repositories
============================
To restore hosted repositories, copy them back into the correct locations
as shown by `bin/repository list-paths`.
Backup: Uploaded Files
======================
Uploaded files may be stored in several different locations. The backup
procedure depends on where files are stored:
**Default / MySQL**: Under the default configuration, uploaded files are stored
in MySQL, so the MySQL backup will include all files. In this case, you don't
need to do any additional work.
**Amazon S3**: If you use Amazon S3, redundancy and backups are built in to the
service. This is probably sufficient for most installs. If you trust Amazon with
your data //except not really//, you can backup your S3 bucket outside of
Phabricator.
**Local Disk**: If you use the local disk storage engine, you'll need to back up
files manually. You can do this by creating a copy of the root directory where
you told Phabricator to put files (the `storage.local-disk.path` configuration
setting).
For more information about configuring how files are stored, see
@{article:Configuring File Storage}.
Restore: Uploaded Files
=======================
To restore a backup of local disk storage, just copy the backup into place.
Backup: Configuration Files
===========================
You should also backup your configuration files, and any scripts you use to
deploy or administrate Phabricator (like a customized upgrade script). The best
way to do this is to check them into a private repository somewhere and just use
whatever backup process you already have in place for repositories. Just copying
them somewhere will work fine too, of course.
In particular, you should backup this configuration file which Phabricator
creates:
phabricator/conf/local/local.json
This file contains all of the configuration settings that have been adjusted
by using `bin/config set <key> <value>`.
Restore: Configuration Files
============================
To restore configuration files, just copy them into the right locations. Copy
your backup of `local.json` to `phabricator/conf/local/local.json`.
Security
========
MySQL dumps have no builtin encryption and most data in Phabricator is stored in
a raw, accessible form, so giving a user access to backups is a lot like giving
them shell access to the machine Phabricator runs on. In particular, a user who
has the backups can:
- read data that policies do not permit them to see;
- read email addresses and object secret keys; and
- read other users' session and conduit tokens and impersonate them.
Some of this information is durable, so disclosure of even a very old backup may
present a risk. If you restrict access to the Phabricator host or database, you
should also restrict access to the backups.
Skipping Indexes
================
By default, `bin/storage dump` does not dump all of the data in the database:
it skips some caches which can be rebuilt automatically and do not need to be
backed up. Some of these caches are very large, so the size of the dump may
be significantly smaller than the size of the databases.
If you have a large amount of data, you can specify `--no-indexes` when taking
a database dump to skip additional tables which contain search indexes. This
will reduce the size (and increase the speed) of the backup. This is an
advanced option which most installs will not benefit from.
This index data can be rebuilt after a restore, but will not be rebuilt
automatically. If you choose to use this flag, you must manually rebuild
indexes after a restore (for details, see ((reindex))).
Next Steps
==========
Continue by:
- returning to the @{article:Configuration Guide}.
diff --git a/src/docs/user/configuration/configuring_encryption.diviner b/src/docs/user/configuration/configuring_encryption.diviner
index dbd0e76314..36315506e6 100644
--- a/src/docs/user/configuration/configuring_encryption.diviner
+++ b/src/docs/user/configuration/configuring_encryption.diviner
@@ -1,196 +1,196 @@
@title Configuring Encryption
@group config
Setup guide for configuring encryption.
Overview
========
Phabricator supports at-rest encryption of uploaded file data stored in the
"Files" application.
Configuring at-rest file data encryption does not encrypt any other data or
resources. In particular, it does not encrypt the database and does not encrypt
Passphrase credentials.
Attackers who compromise a Phabricator host can read the master key and decrypt
the data. In most configurations, this does not represent a significant
barrier above and beyond accessing the file data. Thus, configuring at-rest
encryption is primarily useful for two types of installs:
- If you maintain your own webserver and database hardware but want to use
Amazon S3 or a similar cloud provider as a blind storage server, file data
encryption can let you do so without needing to trust the cloud provider.
- If you face a regulatory or compliance need to encrypt data at rest but do
not need to actually secure this data, encrypting the data and placing the
master key in plaintext next to it may satisfy compliance requirements.
The remainder of this document discusses how to configure at-rest encryption.
Quick Start
===========
To configure encryption, you will generally follow these steps:
- Generate a master key with `bin/files generate-key`.
- Add the master key it to the `keyring`, but don't mark it as `default` yet.
- Use `bin/files encode ...` to test encrypting a few files.
- Mark the key as `default` to automatically encrypt new files.
- Use `bin/files encode --all ...` to encrypt any existing files.
See the following sections for detailed guidance on these steps.
Configuring a Keyring
=====================
To configure a keyring, set `keyring` with `bin/config` or by using another
configuration source. This option should be a list of keys in this format:
```lang=json
...
"keyring": [
{
"name": "master.key",
"type": "aes-256-cbc",
"material.base64": "UcHUJqq8MhZRwhvDV8sJwHj7bNJoM4tWfOIi..."
"default": true
},
...
]
...
```
Each key should have these properties:
- `name`: //Required string.// A unique key name.
- `type`: //Required string.// Type of the key. Only `aes-256-cbc` is
supported.
- `material.base64`: //Required string.// The key material. See below for
details.
- `default`: //Optional bool.// Optionally, mark exactly one key as the
default key to enable encryption of newly uploaded file data.
The key material is sensitive and an attacker who learns it can decrypt data
from the storage engine.
Format: Raw Data
================
The `raw` storage format is automatically selected for all newly uploaded
-file data if no key is makred as the `default` key in the keyring. This is
+file data if no key is marked as the `default` key in the keyring. This is
the behavior of Phabricator if you haven't configured anything.
This format stores raw data without modification.
Format: AES256
==============
The `aes-256-cbc` storage format is automatically selected for all newly
uploaded file data if an AES256 key is marked as the `default` key in the
keyring.
This format uses AES256 in CBC mode. Each block of file data is encrypted with
a unique, randomly generated private key. That key is then encrypted with the
master key. Among other motivations, this strategy allows the master key to be
cycled relatively cheaply later (see "Cycling Master Keys" below).
AES256 keys should be randomly generated and 256 bits (32 characters) in
length, then base64 encoded when represented in `keyring`.
You can generate a valid, properly encoded AES256 master key with this command:
```
phabricator/ $ ./bin/files generate-key --type aes-256-cbc
```
This mode is generally similar to the default server-side encryption mode
supported by Amazon S3.
Format: ROT13
=============
The `rot13` format is a test format that is never selected by default. You can
select this format explicitly with `bin/files encode` to test storage and
encryption behavior.
This format applies ROT13 encoding to file data.
Changing File Storage Formats
=============================
To test configuration, you can explicitly change the storage format of a file.
This will read the file data, decrypt it if necessary, write a new copy of the
data with the desired encryption, then update the file to point at the new
data. You can use this to make sure encryption works before turning it on by
default.
To change the format of an individual file, run this command:
```
phabricator/ $ ./bin/files encode --as <format> F123 [--key <key>]
```
-This will change the storage format of the sepcified file.
+This will change the storage format of the specified file.
Verifying Storage Formats
=========================
You can review the storage format of a file from the web UI, in the
{nav Storage} tab under "Format". You can also use the "Engine" and "Handle"
properties to identify where the underlying data is stored and verify that
it is encrypted or encoded in the way you expect.
See @{article:Configuring File Storage} for more information on storage
engines.
Cycling Master Keys
===================
If you need to cycle your master key, some storage formats support key cycling.
Cycling a file's encryption key decodes the local key for the data using the
old master key, then re-encodes it using the new master key. This is primarily
useful if you believe your master key may have been compromised.
First, add a new key to the keyring and mark it as the default key. You need
to leave the old key in place for now so existing data can be decrypted.
To cycle an individual file, run this command:
```
phabricator/ $ ./bin/files cycle F123
```
Verify that cycling worked properly by examining the command output and
accessing the file to check that the data is present and decryptable. You
can cycle additional files to gain additional confidence.
You can cycle all files with this command:
```
phabricator/ $ ./bin/files cycle --all
```
Once all files have been cycled, remove the old master key from the keyring.
Not all storage formats support key cycling: cycling a file only has an effect
if the storage format is an encrypted format. For example, cycling a file that
uses the `raw` storage format has no effect.
Next Steps
==========
Continue by:
- understanding storage engines with @{article:Configuring File Storage}; or
- returning to the @{article:Configuration Guide}.
diff --git a/src/docs/user/configuration/managing_garbage.diviner b/src/docs/user/configuration/managing_garbage.diviner
index 1896a73904..0b18bd2a0a 100644
--- a/src/docs/user/configuration/managing_garbage.diviner
+++ b/src/docs/user/configuration/managing_garbage.diviner
@@ -1,68 +1,68 @@
@title Managing Garbage Collection
@group config
Understanding and configuring garbage collection.
Overview
========
Phabricator generates various logs and caches during normal operation. Some of
these logs and caches are usually of very little use after some time has
passed, so they are deleted automatically (often after a month or two) in a
process called "garbage collection".
Garbage collection is performed automatically by the daemons. You can review
all of the installed garbage collectors by browsing to {nav Config > Garbage
Collectors}.
Configuring Retention Policies
==============================
You can reconfigure the data retention policies for most collectors.
-The default retention polcies should be suitable for most installs. However,
+The default retention policies should be suitable for most installs. However,
you might want to **decrease** retention to reduce the amount of disk space
used by some high-volume log that you don't find particularly interesting, or
to adhere to an organizational data retention policy.
Alternatively, you might want to **increase** retention if you want to retain
some logs for a longer period of time, perhaps for auditing or analytic
purposes.
You can review the current retention policies in
{nav Config > Garbage Collectors}. To change a policy, use
`bin/garbage set-policy` to select a new policy:
```
phabricator/ $ ./bin/garbage set-policy --collector cache.markup --days 7
```
You can use `--days` to select how long data is retained for. You can also use
`--indefinite` to set an indefinite retention policy. This will stop the
garbage collector from cleaning up any data. Finally, you can use `--default`
to restore the default policy.
Your changes should be reflected in the web UI immediately, and will take
effect in the actual collector **the next time the daemons are restarted**.
Troubleshooting
===============
You can manually run a collector with `bin/garbage collect`.
```
phabricator/ $ ./bin/garbage collect --collector cache.general
```
By using the `--trace` flag, you can inspect the operation of the collector
in detail.
Next Steps
==========
Continue by:
- exploring other daemon topics with @{article:Managing Daemons with phd}.
diff --git a/src/docs/user/field/conduit_changes.diviner b/src/docs/user/field/conduit_changes.diviner
index a5aa8fef6e..96a890600a 100644
--- a/src/docs/user/field/conduit_changes.diviner
+++ b/src/docs/user/field/conduit_changes.diviner
@@ -1,59 +1,59 @@
@title Managing Conduit Changes
@group fieldmanual
Help with managing Conduit API changes.
Overview
========
Many parts of the Conduit API are stable, but some parts are subject to change.
For example, when we write a new application, it usually adds several new API
methods and may update older methods.
This document discusses API stability and how to minimize disruption when
-transitionig between API versions.
+transitioning between API versions.
Method Statuses
===============
Methods have one of three statuses:
- **Unstable**: This is a new or experimental method which is subject to
change. You may call these methods to get access to recently released
features, but should expect that you may need to adjust your usage of
them before they stabilize.
- **Stable**: This is an established method which generally will not change.
- **Deprecated**: This method will be removed in a future version of
Phabricator and callers should cease using it.
Normally, a method is deprecated only when it is obsolete or a new, more
powerful method is available to replace it.
Finding Deprecated Calls
========================
You can identify calls to deprecated methods in {nav Conduit > Call Logs}.
Use {nav My Deprecated Calls} to find calls to deprecated methods you have
made, and {nav Deprecated Call Logs} to find deprecated calls by all users.
You can also search for calls by specific users. For example, it may be useful
to search for any bot accounts you run to make sure they aren't calling
outdated APIs.
The most common cause of calls to deprecated methods is users running very
old versions of Arcanist. They can normally upgrade by running `arc upgrade`.
When the changelogs mention a method deprecation, you can use the call logs
to identify callers and notify them to upgrade or switch away. When the
changelogs mention a method removal, you can use the call logs to verify that
you will not be impacted.
Next Steps
==========
Continue by:
- returning to @{article:Conduit API Overview}.
diff --git a/src/docs/user/field/darkconsole.diviner b/src/docs/user/field/darkconsole.diviner
index a31f0492ae..cbdfb9bda5 100644
--- a/src/docs/user/field/darkconsole.diviner
+++ b/src/docs/user/field/darkconsole.diviner
@@ -1,181 +1,181 @@
@title Using DarkConsole
@group fieldmanual
Enabling and using the built-in debugging and performance console.
Overview
========
DarkConsole is a debugging console built into Phabricator which exposes
configuration, performance and error information. It can help you detect,
understand and resolve bugs and performance problems in Phabricator
applications.
Security Warning
================
WARNING: Because DarkConsole exposes some configuration and debugging
information, it is disabled by default and you should be cautious about
enabling it in production.
Particularly, DarkConsole may expose some information about your session
details or other private material. It has some crude safeguards against this,
but does not completely sanitize output.
This is mostly a risk if you take screenshots or copy/paste output and share
it with others.
Enabling DarkConsole
====================
You enable DarkConsole in your configuration, by setting `darkconsole.enabled`
to `true`, and then turning it on in {nav Settings > Developer Settings}.
Once DarkConsole is enabled, you can show or hide it by pressing ##`## on your
keyboard.
Since the setting is not available to logged-out users, you can also set
`darkconsole.always-on` if you need to access DarkConsole on logged-out pages.
DarkConsole has a number of tabs, each of which is powered by a "plugin". You
can use them to access different debugging and performance features.
Plugin: Error Log
=================
The "Error Log" plugin shows errors that occurred while generating the page,
similar to the httpd `error.log`. You can send information to the error log
explicitly with the @{function@libphutil:phlog} function.
If errors occurred, a red dot will appear on the plugin tab.
Plugin: Request
===============
The "Request" plugin shows information about the HTTP request the server
received, and the server itself.
Plugin: Services
================
The "Services" plugin lists calls a page made to external services, like
MySQL and subprocesses.
The Services tab can help you understand and debug issues related to page
behavior: for example, you can use it to see exactly what queries or commands a
page is running. In some cases, you can re-run those queries or commands
yourself to examine their output and look for problems.
This tab can also be particularly useful in understanding page performance,
because many performance problems are caused by inefficient queries (queries
with bad query plans or which take too long) or repeated queries (queries which
could be better structured or benefit from caching).
When analyzing performance problems, the major things to look for are:
**Summary**: In the summary table at the top of the tab, are any categories
of events dominating the performance cost? For normal pages, the costs should
be roughly along these lines:
| Event Type | Approximate Cost |
|---|---|
| Connect | 1%-10% |
| Query | 10%-40% |
| Cache | 1% |
| Event | 1% |
| Conduit | 0%-80% |
| Exec | 0%-80% |
| All Services | 10%-75% |
| Entire Page | 100ms - 1000ms |
These ranges are rough, but should usually be what you expect from a page
summary. If any of these numbers are way off (for example, "Event" is taking
50% of runtime), that points toward a possible problem in that section of the
code, and can guide you to examining the related service calls more carefully.
**Duration**: In the Duration column, look for service calls that take a long
time. Sometimes these calls are just what the page is doing, but sometimes they
may indicate a problem.
Some questions that may help understanding this column are: are there a small
number of calls which account for a majority of the total page generation time?
Do these calls seem fundamental to the behavior of the page, or is it not clear
why they need to be made? Do some of them seem like they could be cached?
If there are queries which look slow, using the "Analyze Query Plans" button
may help reveal poor query plans.
Generally, this column can help pinpoint these kinds of problems:
- Queries or other service calls which are huge and inefficient.
- Work the page is doing which it could cache instead.
- Problems with network services.
- Missing keys or poor query plans.
**Repeated Calls**: In the "Details" column, look for service calls that are
being made over and over again. Sometimes this is normal, but usually it
indicates a call that can be batched or cached.
Some things to look for are: are similar calls being made over and over again?
Do calls mostly make sense given what the page is doing? Could any calls be
cached? Could multiple small calls be collected into one larger call? Are any
of the service calls clearly goofy nonsense that shouldn't be happening?
Generally, this column can help pinpoint these kinds of problems:
- Unbatched queries which should be batched (see
@{article:Performance: N+1 Query Problem}).
- Opportunities to improve performance with caching.
- - General goofiness in how service calls are woking.
+ - General goofiness in how service calls are working.
If the services tab looks fine, and particularly if a page is slow but the
"All Services" cost is small, that may indicate a problem in PHP. The best
tool to understand problems in PHP is XHProf.
Plugin: Startup
===============
The "Startup" plugin shows information about startup phases. This information
can provide insight about performance problems which occur before the profiler
can start.
Normally, the profiler is the best tool for understanding runtime performance,
but some work is performed before the profiler starts (for example, loading
libraries and configuration). If there is a substantial difference between the
wall time reported by the profiler and the "Entire Page" cost reported by the
Services tab, the Startup tab can help account for that time.
It is normal for starting the profiler to increase the cost of the page
somewhat: the profiler itself adds overhead while it is running, and the page
must do some work after the profiler is stopped to save the profile and
complete other shutdown operations.
Plugin: XHProf
==============
The "XHProf" plugin gives you access to the XHProf profiler. To use it, you need
to install the corresponding PHP plugin.
Once it is installed, you can use XHProf to profile the runtime performance of
a page. This will show you a detailed breakdown of where PHP spent time. This
can help find slow or inefficient application code, and is the most powerful
general-purpose performance tool available.
For instructions on installing and using XHProf, see @{article:Using XHProf}.
Next Steps
==========
Continue by:
- installing XHProf with @{article:Using XHProf}; or
- understanding and reporting performance issues with
@{article:Troubleshooting Performance Problems}.
diff --git a/src/docs/user/field/performance.diviner b/src/docs/user/field/performance.diviner
index 09413e47df..c5980acd0e 100644
--- a/src/docs/user/field/performance.diviner
+++ b/src/docs/user/field/performance.diviner
@@ -1,179 +1,179 @@
@title Troubleshooting Performance Problems
@group fieldmanual
Guide to the troubleshooting slow pages and hangs.
Overview
========
This document describes how to isolate, examine, understand and resolve or
report performance issues like slow pages and hangs.
This document covers the general process for handling performance problems,
and outlines the major tools available for understanding them:
- **Multimeter** helps you understand sources of load and broad resource
utilization. This is a coarse, high-level tool.
- **DarkConsole** helps you dig into a specific slow page and understand
service calls. This is a general, mid-level tool.
- **XHProf** gives you detailed application performance profiles. This
is a fine-grained, low-level tool.
Performance and the Upstream
============================
Performance issues and hangs will often require upstream involvement to fully
resolve. The intent is for Phabricator to perform well in all reasonable cases,
not require tuning for different workloads (as long as those workloads are
generally reasonable). Poor performance with a reasonable workload is likely a
bug, not a configuration problem.
However, some pages are slow because Phabricator legitimately needs to do a lot
of work to generate them. For example, if you write a 100MB wiki document,
Phabricator will need substantial time to process it, it will take a long time
to download over the network, and your browser will probably not be able to
render it especially quickly.
-We may be able to improve perfomance in some cases, but Phabricator is not
+We may be able to improve performance in some cases, but Phabricator is not
magic and can not wish away real complexity. The best solution to these problems
is usually to find another way to solve your problem: for example, maybe the
100MB document can be split into several smaller documents.
Here are some examples of performance problems under reasonable workloads that
the upstream can help resolve:
- {icon check, color=green} Commenting on a file and mentioning that same
file results in a hang.
- {icon check, color=green} Creating a new user takes many seconds.
- {icon check, color=green} Loading Feed hangs on 32-bit systems.
The upstream will be less able to help resolve unusual workloads with high
inherent complexity, like these:
- {icon times, color=red} A 100MB wiki page takes a long time to render.
- - {icon times, color=red} A turing-complete simulation of Conway's Game of
+ - {icon times, color=red} A Turing-complete simulation of Conway's Game of
Life implemented in 958,000 Herald rules executes slowly.
- {icon times, color=red} Uploading an 8GB file takes several minutes.
Generally, the path forward will be:
- Follow the instructions in this document to gain the best understanding of
the issue (and of how to reproduce it) that you can.
- In particular, is it being caused by an unusual workload (like a 100MB
wiki page)? If so, consider other ways to solve the problem.
- File a report with the upstream by following the instructions in
@{article:Contributing Bug Reports}.
The remaining sections in this document walk through these steps.
Understanding Performance Problems
==================================
To isolate, examine, and understand performance problems, follow these steps:
**General Slowness**: If you are experiencing generally poor performance, use
Multimeter to understand resource usage and look for load-based causes. See
@{article:Multimeter User Guide}. If that isn't fruitful, treat this like a
reproducible performance problem on an arbitrary page.
**Hangs**: If you are experiencing hangs (pages which never return, or which
time out with a fatal after some number of seconds), they are almost always
the result of bugs in the upstream. Report them by following these
instructions:
- Set `debug.time-limit` to a value like `5`.
- Reproduce the hang. The page should exit after 5 seconds with a more useful
stack trace.
- File a report with the reproduction instructions and the stack trace in
the upstream. See @{article:Contributing Bug Reports} for detailed
instructions.
- Clear `debug.time-limit` again to take your install out of debug mode.
If part of the reproduction instructions include "Create a 100MB wiki page",
the upstream may be less sympathetic to your cause than if reproducing the
issue does not require an unusual, complex workload.
In some cases, the hang may really just a very large amount of processing time.
If you're very excited about 100MB wiki pages and don't mind waiting many
minutes for them to render, you may be able to adjust `max_execution_time` in
your PHP configuration to allow the process enough time to complete, or adjust
settings in your webserver config to let it wait longer for results.
**DarkConsole**: If you have a reproducible performance problem (for example,
loading a specific page is very slow), you can enable DarkConsole (a builtin
debugging console) to examine page performance in detail.
The two most useful tabs in DarkConsole are the "Services" tab and the
"XHProf" tab.
The "Services" module allows you to examine service calls (network calls,
subprocesses, events, etc) and find slow queries, slow services, inefficient
query plans, and unnecessary calls. Broadly, you're looking for slow or
repeated service calls, or calls which don't make sense given what the page
should be doing.
After installing XHProf (see @{article:Using XHProf}) you'll gain access to the
"XHProf" tab, which is a full tracing profiler. You can use the "Profile Page"
button to generate a complete trace of where a page is spending time. When
reading a profile, you're looking for the overall use of time, and for anything
which sticks out as taking unreasonably long or not making sense.
See @{article:Using DarkConsole} for complete instructions on configuring
and using DarkConsole.
**AJAX Requests**: To debug Ajax requests, activate DarkConsole and then turn
on the profiler or query analyzer on the main request by clicking the
appropriate button. The setting will cascade to Ajax requests made by the page
and they'll show up in the console with full query analysis or profiling
information.
**Command-Line Hangs**: If you have a script or daemon hanging, you can send
it `SIGHUP` to have it dump a stack trace to `sys_get_temp_dir()` (usually
`/tmp`).
Do this with:
```
$ kill -HUP <pid>
```
You can use this command to figure out where the system's temporary directory
is:
```
$ php -r 'echo sys_get_temp_dir()."\n";'
```
On most systems, this is `/tmp`. The trace should appear in that directory with
a name like `phabricator_backtrace_<pid>`. Examining this trace may provide
a key to understanding the problem.
**Command-Line Performance**: If you have general performance issues with
command-line scripts, you can add `--trace` to see a service call log. This is
similar to the "Services" tab in DarkConsole. This may help identify issues.
After installing XHProf, you can also add `--xprofile <filename>` to emit a
detailed performance profile. You can `arc upload` these files and then view
them in XHProf from the web UI.
Next Steps
==========
If you've done all you can to isolate and understand the problem you're
experiencing, report it to the upstream. Including as much relevant data as
you can, including:
- reproduction instructions;
- traces from `debug.time-limit` for hangs;
- screenshots of service call logs from DarkConsole (review these carefully,
as they can sometimes contain sensitive information);
- traces from CLI scripts with `--trace`;
- traces from sending HUP to processes; and
- XHProf profile files from `--xprofile` or "Download .xhprof Profile" in
the web UI.
After collecting this information:
- follow the instructions in @{article:Contributing Bug Reports} to file
a report in the upstream.
diff --git a/src/docs/user/field/repository_imports.diviner b/src/docs/user/field/repository_imports.diviner
index 495758ea4a..1b1c92bbc0 100644
--- a/src/docs/user/field/repository_imports.diviner
+++ b/src/docs/user/field/repository_imports.diviner
@@ -1,247 +1,247 @@
@title Troubleshooting Repository Imports
@group fieldmanual
Guide to the troubleshooting repositories which import incompletely.
Overview
========
When you first import an external source code repository (or push new commits to
a hosted repository), Phabricator imports those commits in the background.
While a repository is initially importing, some features won't work. While
individual commits are importing, some of their metadata won't be available in
the web UI.
Sometimes, the import process may hang or fail to complete. This document can
help you understand the import process and troubleshoot problems with it.
Understanding the Import Pipeline
=================================
Phabricator first performs commit discovery on repositories. This examines
a repository and identifies all the commits in it at a very shallow level,
then creates stub objects for them. These stub objects primarily serve to
assign various internal IDs to each commit.
Commit discovery occurs in the update phase, and you can learn more about
updates in @{article:Diffusion User Guide: Repository Updates}.
After commits are discovered, background tasks are queued to actually import
commits. These tasks do things like look at commit messages, trigger mentions
and autoclose rules, cache changes, trigger Herald, publish feed stories and
email, and apply Owners rules. You can learn more about some of these steps in
@{article:Diffusion User Guide: Autoclose}.
Specifically, the import pipeline has four steps:
- **Message**: Parses the commit message and author metadata.
- **Change**: Caches the paths the commit affected.
- **Owners**: Runs Owners rules.
- **Herald**: Runs Herald rules and publishes notifications.
These steps run in sequence for each commit, but all discovered commits import
in parallel.
Identifying Missing Steps
=========================
There are a few major pieces of information you can look at to understand where
the import process is stuck.
First, to identify which commits have missing import steps, run this command:
```
phabricator/ $ ./bin/repository importing rXYZ
```
That will show what work remains to be done. Each line shows a commit which
is discovered but not imported, and the import steps that are remaining for
that commit. Generally, the commit is stuck on the first step in the list.
Second, load the Daemon Console (at `/daemon/` in the web UI). This will show
what work is currently being done and waiting to complete. The most important
sections are "Queued Tasks" (work waiting in queue) and "Leased Tasks"
(work currently being done).
Third, run this command to look at the daemon logs:
```
phabricator/ $ ./bin/phd log
```
This can show you any errors the daemons have encountered recently.
The next sections will walk through how to use this information to understand
and resolve the issue.
Handling Permanent Failures
===========================
Some commits can not be imported, which will permanently stop a repository from
fully importing. These are rare, but can be caused by unusual data in a
repository, version peculiarities, or bugs in the importer.
Permanent failures usually look like a small number of commits stuck on the
"Message" or "Change" steps in the output of `repository importing`. If you
have a larger number of commits, it is less likely that there are any permanent
problems.
In the Daemon console, permanent failures usually look like a small number of
tasks in "Leased Tasks" with a large failure count. These tasks are retrying
until they succeed, but a bug is permanently preventing them from succeeding,
so they'll rack up a large number of retries over time.
In the daemon log, these commits usually emit specific errors showing why
they're failing to import.
These failures are the easiest to identify and understand, and can often be
resolved quickly. Choose some failing commit from the output of `bin/repository
importing` and use this command to re-run any missing steps manually in the
foreground:
```
phabricator/ $ ./bin/repository reparse --importing --trace rXYZabcdef012...
```
This command is always safe to run, no matter what the actual root cause of
the problem is.
If this fails with an error, you've likely identified a problem with
Phabricator. Collect as much information as you can about what makes the commit
special and file a bug in the upstream by following the instructions in
@{article:Contributing Bug Reports}.
If the commit imports cleanly, this is more likely to be caused by some other
issue.
Handling Temporary Failures
===========================
Some commits may temporarily fail to import: perhaps the network or services
may have briefly been down, or some configuration wasn't quite right, or the
daemons were killed halfway through the work.
These commits will retry eventually and usually succeed, but some of the retry
-time limits are very conserative (up to 24 hours) and you might not want to
+time limits are very conservative (up to 24 hours) and you might not want to
wait that long.
In the Daemon console, temporarily failures usually look like tasks in the
"Leased Tasks" column with a large "Expires" value but a low "Failures" count
(usually 0 or 1). The "Expires" column is showing how long Phabricator is
waiting to retry these tasks.
In the daemon log, these temporary failures might have created log entries, but
might also not have. For example, if the failure was rooted in a network issue
-that probably will create a log entry, but if the faiulre was rooted in the
+that probably will create a log entry, but if the failure was rooted in the
daemons being abruptly killed that may not create a log entry.
You can follow the instructions from "Handling Permanent Failures" above to
reparse steps individually to look for an error that represents a root cause,
but sometimes this can happen because of some transient issue which won't be
identifiable.
The easiest way to fix this is to restart the daemons. When you restart
daemons, all task leases are immediately expired, so any tasks waiting for a
long time will run right away:
```
phabricator/ $ ./bin/phd restart
```
This command is always safe to run, no matter what the actual root cause of
the problem is.
After restarting the daemons, any pending tasks should be able to retry
immediately.
For more information on managing the daemons, see
@{article:Managing Daemons with phd}.
Forced Parsing
==============
In rare cases, the actual tasks may be lost from the task queue. Usually, they
-have been stolen by gremlins or spritied away by ghosts, or someone may have
+have been stolen by gremlins or spirited away by ghosts, or someone may have
been too ambitious with running manual SQL commands and deleted a bunch of
extra things they shouldn't have.
There is no normal set of conditions under which this should occur, but you can
force Phabricator to re-queue the tasks to recover from it if it does occur.
This will look like missing steps in `repository importing`, but nothing in the
"Queued Tasks" or "Leased Tasks" sections of the daemon console. The daemon
logs will also be empty, since the tasks have vanished.
To re-queue parse tasks for a repository, run this command, which will queue
up all of the missing work in `repository importing`:
```
phabricator/ $ ./bin/repository reparse --importing --all rXYZ
```
This command may cause duplicate work to occur if you have misdiagnosed the
problem and the tasks aren't actually lost. For example, it could queue a
second task to perform publishing, which could cause Phabricator to send a
second copy of email about the commit. Other than that, it is safe to run even
if this isn't the problem.
After running this command, you should see tasks in "Queued Tasks" and "Leased
Tasks" in the console which correspond to the commits in `repository
importing`, and progress should resume.
Forced Imports
==============
In some cases, you might want to force a repository to be flagged as imported
even though the import isn't complete. The most common and reasonable case
where you might want to do this is if you've identified a permanent failure
with a small number of commits (maybe just one) and reported it upstream, and
are waiting for a fix. You might want to start using the repository immediately,
even if a few things can't import yet.
You should be cautious about doing this. The "importing" flag controls
publishing of notifications and email, so if you flag a repository as imported
but it still has a lot of work queued, it may send an enormous amount of email
as that work completes.
To mark a repository as imported even though it really isn't, run this
command:
```
phabricator/ $ ./bin/repository mark-imported rXYZ
```
If you do this by mistake, you can reverse it later by using the
`--mark-not-imported` flag.
General Tips
============
Broadly, `bin/repository` contains several useful debugging commands which
-let you figure out where failures are occuring. You can add the `--trace` flag
+let you figure out where failures are occurring. You can add the `--trace` flag
to any command to get more details about what it is doing. For any command,
you can use `help` to learn more about what it does and which flag it takes:
```
phabricator/ $ bin/repository help <command>
```
In particular, you can use flags with the `repository reparse` command to
manually run parse steps in the foreground, including re-running steps and
running steps out of order.
Next Steps
==========
Continue by:
- returning to the @{article:Diffusion User Guide}.
diff --git a/src/docs/user/field/xhprof.diviner b/src/docs/user/field/xhprof.diviner
index 7d032e6865..ad6fe82cb8 100644
--- a/src/docs/user/field/xhprof.diviner
+++ b/src/docs/user/field/xhprof.diviner
@@ -1,122 +1,122 @@
@title Using XHProf
@group fieldmanual
Describes how to install and use XHProf, a PHP profiling tool.
Overview
========
XHProf is a profiling tool which will let you understand application
performance in Phabricator.
After you install XHProf, you can use it from the web UI and the CLI to
generate detailed performance profiles. It is the most powerful tool available
for understanding application performance and identifying and fixing slow code.
Installing XHProf
=================
You are likely to have the most luck building XHProf from source:
$ git clone https://github.com/phacility/xhprof.git
From any source distribution of the extension, build and install it like this:
$ cd xhprof/
$ cd extension/
$ phpize
$ ./configure
$ make
$ sudo make install
You may also need to add `extension=xhprof.so` to your php.ini.
You can also try using PECL to install it, but this may not work well with
recent versions of PHP:
$ pecl install xhprof
Once you've installed it, `php -i` should report it as installed (you may
see a different version number, which is fine):
$ php -i | grep xhprof
...
xhprof => 0.9.2
...
Using XHProf: Web UI
====================
To profile a web page, activate DarkConsole and navigate to the XHProf tab.
Use the **Profile Page** button to generate a profile.
For instructions on activating DarkConsole, see @{article:Using DarkConsole}.
Using XHProf: CLI
=================
From the command line, use the `--xprofile <filename>` flag to generate a
profile of any script.
You can then upload this file to Phabricator (using `arc upload` may be easiest)
and view it in the web UI.
Analyzing Profiles
==================
Understanding profiles is as much art as science, so be warned that you may not
make much headway. Even if you aren't able to conclusively read a profile
yourself, you can attach profiles when submitting bug reports to the upstream
and we can look at them. This may yield new insight.
When looking at profiles, the "Wall Time (Inclusive)" column is usually the
most important. This shows the total amount of time spent in a function or
method and all of its children. Usually, to improve the performance of a page,
we're trying to find something that's slow and make it not slow: this column
can help identify which things are slowest.
The "Wall Time (Exclusive)" column shows time spent in a function or method,
excluding time spent in its children. This can give you hint about whether the
call itself is slow or it's just making calls to other things that are slow.
You can also get a sense of this by clicking a call to see its children, and
seeing if the bulk of runtime is spent in a child call. This tends to indicate
that you're looking at a problem which is deeper in the stack, and you need
to go down further to identify and understand it.
Conversely, if the "Wall Time (Exclusive)" column is large, or the children
-of a call are all cheap, there's probably something expesive happening in the
+of a call are all cheap, there's probably something expensive happening in the
call itself.
The "Count" column can also sometimes tip you off that something is amiss, if
a method which shouldn't be called very often is being called a lot.
Some general thing to look for -- these aren't smoking guns, but are unusual
and can lead to finding a performance issue:
- Is a low-level utility method like `phutil_utf8ize()` or `array_merge()`
taking more than a few percent of the page runtime?
- Do any methods (especially high-level methods) have >10,00 calls?
- Are we spending more than 100ms doing anything which isn't loading data
or rendering data?
- Does anything look suspiciously expensive or out of place?
- Is the profile for the slow page a lot different than the profile for a
fast page?
Some performance problems are obvious and will jump out of a profile; others
may require a more nuanced understanding of the codebase to sniff out which
parts are suspicious. If you aren't able to make progress with a profile,
report the issue upstream and attach the profile to your report.
Next Steps
==========
Continue by:
- enabling DarkConsole with @{article:Using DarkConsole}; or
- understanding and reporting performance problems with
@{article:Troubleshooting Performance Problems}.
diff --git a/src/docs/user/userguide/almanac.diviner b/src/docs/user/userguide/almanac.diviner
index d6f0853430..9250724c03 100644
--- a/src/docs/user/userguide/almanac.diviner
+++ b/src/docs/user/userguide/almanac.diviner
@@ -1,182 +1,182 @@
@title Almanac User Guide
@group userguide
Using Almanac to manage devices and services.
Overview
========
Almanac is a device and service inventory application. It allows you to create
lists of //devices// and //services// that humans and other applications can
use to keep track of what is running where.
Almanac is an infrastructure application that will normally be used by
administrators to configure advanced Phabricator features. In most cases,
normal users will very rarely interact with Almanac directly.
At a very high level, Almanac can be thought of as a bit like a DNS server.
Callers ask it for information about services, and it responds with details
about which devices host those services. However, it can respond to a broader
range of queries and provide more detailed responses than DNS alone can.
Today, the primary use cases for Almanac are internal to Phabricator:
- Providing a list of build servers to Drydock so it can run build and
integration tasks.
- Configuring Phabricator to operate in a cluster setup.
Beyond internal uses, Almanac is a general-purpose service and device inventory
application and can be used to configure and manage other types of service and
hardware inventories, but these use cases are currently considered experimental
and you should be exercise caution in pursuing them.
Example: Drydock Build Pool
================================
Here's a quick example of how you might configure Almanac to solve a real-world
problem. This section describes configuration at a high level to give you an
introduction to Almanac concepts and a better idea of how the pieces fit
together.
In this scenario, we want to use Drydock to run some sort of build process. To
do this, Drydock needs hardware to run on. We're going to use Almanac to give
Drydock a list of hosts it should use.
In this scenario, Almanac will work a bit like a DNS server. When we're done,
Drydock will be able to query Almanac for information about a service (like
`build.mycompany.com`) and get back information about which hosts are part of
that service and which addresses/ports it should connect to.
Before getting started, we need to create a **network**. For simplicity, let's
suppose everything will be connected through the public internet. If you
haven't already, you'd create a "Public Internet" network first.
Once we have a network, we create the actual physical or virtual hosts by
launching instances in EC2, or racking and powering on some servers, or already
having some hardware on hand we want to use. We set the hosts up normally and
connect them to the internet (or another network).
After the hosts exist, we add them to Almanac as **devices**, like
`build001.mycompany.com`, `build002.mycompany.com`, and so on. In Almanac,
devices are usually physical or virtual hosts, although you could also use it
to inventory other types of devices and hardware.
For each **device**, we add an **interface**. This is just an address and port
on a particular network. Since we're going to connect to these hosts over
SSH, we'll add interfaces on the standard SSH port 22. An example configuration
might look a little bit like this:
| Device | Network | Address | Port |
|--------|---------|---------|------|
| `build001.mycompany.com` | Public Internet | 58.8.9.10 | 22
| `build002.mycompany.com` | Public Internet | 58.8.9.11 | 22
| ... | Public Internet | ... | 22
Now, we create the **service**. This is what we'll tell Drydock about, and
it can query for information about this service to find connected devices.
Here, we'll call it `build.mycompany.com`.
After creating the service, add **bindings** to the interfaces we configured
above. This will tell Drydock where it should actually connect to.
Once this is complete, we're done in Almanac and can continue configuration in
Drydock, which is outside the scope of this example. Once everything is fully
configured, this is how Almanac will be used by Drydock:
- Drydock will query information about `build.mycompany.com` from Almanac.
- Drydock will get back a list of bound interfaces, among other data.
- The interfaces provide information about addresses and ports that Drydock
can use to connect to the actual devices.
You can now add and remove devices to the pool by binding them and unbinding
them from the service.
Concepts
========
The major concepts in Almanac are **devices**, **interfaces**, **services**,
**bindings**, **networks**, and **namespaces**.
**Devices**: Almanac devices represent physical or virtual devices.
Usually, they are hosts (like `web001.mycompany.net`), although you could
use devices to keep inventory of any other kind of device or physical asset
(like phones, laptops, or office chairs).
Each device has a name, and may have properties and interfaces.
**Interfaces**: Interfaces are listening address/port combinations on devices.
For example, if you have a webserver host device named `web001.mycompany.net`,
you might add an interface on port `80`.
Interfaces tell users and applications where they should connect to to access
services and devices.
**Services**: These are named services like `build.mycompany.net` that work
a bit like DNS. Humans or other applications can look up a service to find
configuration information and learn which devices are hosting the service.
Each service has a name, and may have properties and bindings.
**Bindings**: Bindings are connections between services and interfaces. They
tell callers which devices host a named service.
-**Networks**: Networks allow Almanac to distingiush between addresses on
+**Networks**: Networks allow Almanac to distinguish between addresses on
different networks, like VPNs vs the public internet.
If you have hosts in different VPNs or on private networks, you might have
multiple devices which share the same IP address (like `10.0.0.3`). Networks
allow Almanac to distinguish between devices with the same address on different
sections of the network.
**Namespaces**: Namespaces let you control who is permitted to create devices
and services with particular names. For example, the namespace `mycompany.com`
controls who can create services with names like `a.mycompany.com` and
`b.mycompany.com`.
Namespaces
==========
Almanac namespaces allow you to control who can create services and devices
with certain names.
If you keep a list of cattle as devices with names like
`cow001.herd.myranch.moo`, `cow002.herd.myranch.moo`, you might have some
applications which query for all devices in `*.herd.myranch.moo`, and thus
want to limit who can create devices there in order to prevent mistakes.
If a namespace like `herd.myranch.moo` exists, users must have permission to
edit the namespace in order to create new services, devices, or namespaces
within it. For example, a user can not create `cow003.herd.myranch.moo` if
they do not have edit permission on the `herd.myranch.moo` namespace.
When you try to create a `cow003.herd.myranch.moo` service (or rename an
existing service to have that name), Almanac looks for these namespaces, then
checks the policy of the first one it finds:
| Namespace |
|----|-----
| `cow003.herd.ranch.moo` | //"Nearest" namespace, considered first.//
| `herd.ranch.moo` | |
| `ranch.moo` | |
| `moo` | //"Farthest" namespace, considered last.//
Note that namespaces treat names as lists of domain parts, not as strict
substrings, so the namespace `herd.myranch.moo` does not prevent
someone from creating `goatherd.myranch.moo` or `goat001.goatherd.myranch.moo`.
The name `goatherd.myranch.moo` is not part of the `herd.myranch.moo` namespace
because the initial subdomain differs.
If a name belongs to multiple namespaces, the policy of the nearest namespace
is controlling. For example, if `myranch.moo` has a very restrictive edit
policy but `shed.myranch.moo` has a more open one, users can create devices and
services like `rake.shed.myranch.moo` as long as they can pass the policy check
for `shed.myranch.moo`, even if they do not have permission under the policy
for `myranch.moo`.
Users can edit services and devices within a namespace if they have edit
permission on the service or device itself, as long as they don't try to rename
the service or device to move it into a namespace they don't have permission
to access.
diff --git a/src/docs/user/userguide/arcanist.diviner b/src/docs/user/userguide/arcanist.diviner
index 324ff10490..f6ccc3550f 100644
--- a/src/docs/user/userguide/arcanist.diviner
+++ b/src/docs/user/userguide/arcanist.diviner
@@ -1,177 +1,177 @@
@title Arcanist User Guide
@group userguide
Guide to Arcanist, a command-line interface to Phabricator.
-Arcanists provides command-line access to many Phabricator tools (like
+Arcanist provides command-line access to many Phabricator tools (like
Differential, Files, and Paste), integrates with static analysis ("lint") and
unit tests, and manages common workflows like getting changes into Differential
for review.
A detailed command reference is available by running `arc help`. This
document provides an overview of common workflows and installation.
Arcanist has technical, contributor-focused documentation here:
<https://secure.phabricator.com/book/arcanist/>
= Quick Start =
A quick start guide is available at @{article:Arcanist Quick Start}. It provides
a much more compact summary of how to get `arc` set up and running for a new
project. You may want to start there, and return here if you need more
information.
= Overview =
Arcanist is a wrapper script that sits on top of other tools (e.g.,
Differential, linters, unit test frameworks, git, Mercurial, and SVN) and
provides a simple command-line API to manage code review and some related
revision control operations.
For a detailed list of all available commands, run:
$ arc help
For detailed information about a specific command, run:
$ arc help <command>
Arcanist allows you to do things like:
- get detailed help about available commands with `arc help`
- send your code to Differential for review with `arc diff` (for detailed
instructions, see @{article:Arcanist User Guide: arc diff})
- show pending revision information with `arc list`
- find likely reviewers for a change with `arc cover`
- apply changes in a revision to the working copy with `arc patch`
- download a patch from Differential with `arc export`
- update Git commit messages after review with `arc amend`
- commit SVN changes with `arc commit`
- push Git and Mercurial changes with `arc land`
- view enhanced information about Git branches with `arc branch`
Once you've configured lint and unit test integration, you can also:
- check your code for syntax and style errors with `arc lint`
(see @{article:Arcanist User Guide: Lint})
- run unit tests that cover your changes with `arc unit`
Arcanist integrates with other tools:
- upload and download files with `arc upload` and `arc download`
- create and view pastes with `arc paste`
Arcanist has some advanced features as well, you can:
- execute Conduit method calls with `arc call-conduit`
- create or update libphutil libraries with `arc liberate`
- activate tab completion with `arc shell-complete`
- ...or extend Arcanist and add new commands.
Except where otherwise noted, these workflows are generally agnostic to the
underlying version control system and will work properly in git, Mercurial, or
SVN repositories.
= Installing Arcanist =
Arcanist is meant to be installed on your local machine or development server --
whatever machine you're editing code on. It runs on:
- Linux;
- Other operating systems which are pretty similar to Linux, or which
Linux is pretty similar to;
- FreeBSD, a fine operating system held in great esteem by many;
- Mac OS X (see @{article:Arcanist User Guide: Mac OS X}); and
- Windows (see @{article:Arcanist User Guide: Windows}).
Arcanist is written in PHP, so you need to install the PHP CLI first if you
don't already have it. Arcanist should run on PHP 5.2 and newer. If you don't
have PHP installed, you can download it from <http://www.php.net/>.
To install Arcanist, pick an install directory and clone the code from GitHub:
some_install_path/ $ git clone https://github.com/phacility/libphutil.git
some_install_path/ $ git clone https://github.com/phacility/arcanist.git
This should leave you with a directory structure like this
some_install_path/ # Wherever you chose to install it.
arcanist/ # Arcanist-specific code and libraries.
libphutil/ # A shared library Arcanist depends upon.
Now add `some_install_path/arcanist/bin/` to your PATH environment variable.
When you type "arc", you should see something like this:
Usage Exception: No command provided. Try 'arc help'.
If you get that far, you've done things correctly. If you get an error or have
trouble getting this far, see these detailed guides:
- On Windows: @{article:Arcanist User Guide: Windows}
- On Mac OS X: @{article:Arcanist User Guide: Mac OS X}
You can later upgrade Arcanist and libphutil to the latest versions with
`arc upgrade`:
$ arc upgrade
== Installing Arcanist for a Team ==
Arcanist changes quickly, so it can be something of a headache to get it
installed and keep people up to date. Here are some approaches you might be
able to use:
- Facebook does most development on development servers, which have a standard
environment and NFS mounts. Arcanist and libphutil themselves live on an
NFS mount, and the default `.bashrc` adds them to the PATH. Updating the
mount source updates everyone's versions, and new employees have a working
`arc` when they first log in.
- Another common approach is to write an install script as an action into
existing build scripts, so users can run `make install-arc` or
`ant install-arc` or similar.
== Installing Tab Completion ==
If you use `bash`, you can set up tab completion by adding something like this
to your `.bashrc`, `.profile` or similar:
source /path/to/arcanist/resources/shell/bash-completion
== Configuration ==
Some Arcanist commands can be configured. This configuration is read from
three sources, in order:
# A project can specify configuration in an `.arcconfig` file. This file is
JSON, and can be updated using `arc set-config --local` or by editing
it manually.
# User configuration is read from `~/.arcconfig`. This file is JSON, and can
be updated using `arc set-config`.
# Host configuration is read from `/etc/arcconfig` (on Windows, the path
is `C:\ProgramData\Phabricator\Arcanist\config`).
Arcanist uses the first definition it encounters as the runtime setting.
Existing settings can be printed with `arc get-config`.
Use `arc help set-config` and `arc help get-config` for more information
about reading and writing configuration.
== Next Steps ==
Continue by:
- setting up a new project for use with `arc`, with
@{article:Arcanist User Guide: Configuring a New Project}; or
- learning how to use `arc` to send changes for review with
@{article:Arcanist User Guide: arc diff}.
Advanced topics are also available. These are detailed guides to configuring
technical features of `arc` that refine its behavior. You do not need to read
them to get it working.
- @{article:Arcanist User Guide: Commit Ranges}
- @{article:Arcanist User Guide: Lint}
- @{article:Arcanist User Guide: Customizing Existing Linters}
- @{article:Arcanist User Guide: Customizing Lint, Unit Tests and Workflows}
- @{article:Arcanist User Guide: Code Coverage}
diff --git a/src/docs/user/userguide/arcanist_lint.diviner b/src/docs/user/userguide/arcanist_lint.diviner
index d7fcce14e8..460b8f85b6 100644
--- a/src/docs/user/userguide/arcanist_lint.diviner
+++ b/src/docs/user/userguide/arcanist_lint.diviner
@@ -1,419 +1,419 @@
@title Arcanist User Guide: Lint
@group userguide
Guide to lint, linters, and linter configuration.
This is a configuration guide that helps you set up advanced features. If you're
just getting started, you don't need to look at this yet. Instead, start with
the @{article:Arcanist User Guide}.
This guide explains how lint works when configured in an `arc` project. If
you haven't set up a project yet, do that first. For instructions, see
@{article:Arcanist User Guide: Configuring a New Project}.
Overview
========
"Lint" refers to a general class of programming tools which analyze source code
and raise warnings and errors about it. For example, a linter might raise
warnings about syntax errors, uses of undeclared variables, calls to deprecated
functions, spacing and formatting conventions, misuse of scope, implicit
fallthrough in switch statements, missing license headers, use of dangerous
language features, or a variety of other issues.
Integrating lint into your development pipeline has two major benefits:
- you can detect and prevent a large class of programming errors; and
- you can simplify code review by addressing many mechanical and formatting
problems automatically.
When arc is integrated with a lint toolkit, it enables the `arc lint` command
and runs lint on changes during `arc diff`. The user is prompted to fix errors
and warnings before sending their code for review, and lint issues which are
not fixed are visible during review.
There are many lint and static analysis tools available for a wide variety of
languages. Arcanist ships with bindings for many popular tools, and you can
write new bindings fairly easily if you have custom tools.
Available Linters
=================
To see a list of available linters, run:
$ arc linters
Arcanist ships with bindings for a number of linters that can check for errors
or problems in JS, CSS, PHP, Python, C, C++, C#, Less, Puppet, Ruby, JSON, XML,
and several other languages.
Some general purpose linters are also available. These linters can check for
cross-language issues like sensible filenames, trailing or mixed whitespace,
character sets, spelling mistakes, and unresolved merge conflicts.
If you have a tool you'd like to use as a linter that isn't supported by
default, you can write bindings for it. For information on writing new linter
bindings, see @{article:Arcanist User Guide: Customizing Lint, Unit Tests and
Workflows}.
Configuring Lint
================
To configure lint integration for your project, create a file called `.arclint`
at the project root. This file should be in JSON format, and look like this:
```lang=js
{
"linters": {
"sample": {
"type": "pep8"
}
}
}
```
Here, the key ("sample") is a human-readable label identifying the linter. It
does not affect linter behavior, so just choose something that makes sense to
you.
The `type` specifies which linter to run. Use `arc linters` to find the names of
the available linters.
**Including and Excluding Files**: By default, a linter will run on every file.
This is appropriate for some linters (like the Filename linter), but normally
you only want to run a linter like **pep8** on Python files. To include or
exclude files, use `include` and `exclude`:
```lang=js
{
"linters": {
"sample": {
"type": "pep8",
"include": "(\\.py$)",
"exclude": "(^third-party/)"
}
}
}
```
The `include` key is a regular expression (or list of regular expressions)
identifying paths the linter should be run on, while `exclude` is a regular
expression (or list of regular expressions) identifying paths which it should
not run on.
Thus, this configures a **pep8** linter named "sample" which will run on files
ending in ".py", unless they are inside the "third-party/" directory.
In these examples, regular expressions are written in this style:
"(example/path)"
They can be specified with any delimiters, but using `(` and `)` means you don't
have to escape slashes in the expression, so it may be more convenient to
specify them like this. If you prefer, these are all equivalent:
"(example/path)i"
"/example\\/path/i"
"@example/path@i"
You can also exclude files globally, so no linters run on them at all. Do this
by specifying `exclude` at top level:
```lang=js
{
"exclude": "(^tests/data/)",
"linters": {
"sample": {
"type": "pep8",
"include": "(\\.py$)",
"exclude": "(^third-party/)"
}
}
}
```
Here, the addition of a global `exclude` rule means no linter will be run on
files in "tests/data/".
**Running Multiple Linters**: Often, you will want to run several different
linters. Perhaps your project has a mixture of Python and Javascript code, or
you have some PHP and some JSON files. To run multiple linters, just list
them in the `linters` map:
```lang=js
{
"linters": {
"jshint": {
"type": "jshint",
"include": "(\\.js$)"
},
"xml": {
"type": "xml",
"include": "(\\.xml$)"
}
}
}
```
This will run JSHint on `.js` files, and SimpleXML on `.xml` files.
**Adjusting Message Severities**: Arcanist raises lint messages at various
severities. Each message has a different severity: for example, lint might
find a syntax error and raise an `error` about it, and find trailing whitespace
and raise a `warning` about it.
Normally, you will be presented with lint messages as you are sending code for
review. In that context, the severities behave like this:
- `error` When a file contains lint errors, they are always reported. These
- are intended to be severe problems, like a syntax error. Unresoved lint
+ are intended to be severe problems, like a syntax error. Unresolved lint
errors require you to confirm that you want to continue.
- `warning` When a file contains warnings, they are reported by default only
if they appear on lines that you have changed. They are intended to be
minor problems, like unconventional whitespace. Unresolved lint warnings
require you to confirm that you want to continue.
- `autofix` This level is like `warning`, but if the message includes patches
they will be applied automatically without prompting.
- `advice` Like warnings, these messages are only reported on changed lines.
They are intended to be very minor issues which may merit a note, like a
"TODO" comment. They do not require confirmation.
- `disabled` This level suppresses messages. They are not displayed. You can
use this to turn off a message if you don't care about the issue it
detects.
By default, Arcanist tries to select reasonable severities for each message.
However, you may want to make a message more or less severe, or disable it
entirely.
For many linters, you can do this by providing a `severity` map:
```lang=js
{
"linters": {
"sample": {
"type": "pep8",
"severity": {
"E221": "disabled",
"E401": "warning"
}
}
}
}
```
Here, the lint message `E221` (which is "multiple spaces before operator") is
disabled, so it won't be shown. The message `E401` (which is "multiple imports
on one line") is set to "warning" severity.
If you want to remap a large number of messages, you can use `severity.rules`
and specify regular expressions:
```lang=js
{
"linters": {
"sample": {
"type": "pep8",
"severity.rules": {
"(^E)": "warning",
"(^W)": "advice"
}
}
}
}
```
This adjusts the severity of all "E" codes to "warning", and all "W" codes to
"advice".
**Locating Binaries and Interpreters**: Normally, Arcanist expects to find
external linters (like `pep8`) in `$PATH`, and be able to run them without any
special qualifiers. That is, it will run a command similar to:
$ pep8 example.py
If you want to use a different copy of a linter binary, or invoke it in an
explicit way, you can use `interpreter` and `bin`. These accept strings (or
lists of strings) identifying places to look for linters. For example:
```lang=js
{
"linters": {
"sample": {
"type": "pep8",
"interpreter": ["python2.6", "python"],
"bin": ["/usr/local/bin/pep8-1.5.6", "/usr/local/bin/pep8"]
}
}
}
```
When configured like this, `arc` will walk the `interpreter` list to find an
available interpreter, then walk the `bin` list to find an available binary.
If it can locate an appropriate interpreter and binary, it will execute those
instead of the defaults. For example, this might cause it to execute a command
similar to:
$ python2.6 /usr/local/bin/pep8-1.5.6 example.py
**Additional Options**: Some linters support additional options to configure
their behavior. You can run this command get a list of these options and
descriptions of what they do and how to configure them:
$ arc linters --verbose
This will show the available options for each linter in detail.
**Running Different Rules on Different Files**: Sometimes, you may want to
run the same linter with different rulesets on different files. To do this,
create two copies of the linter and just give them different keys in the
`linters` map:
```lang=js
{
"linters": {
"pep8-relaxed": {
"type": "pep8",
"include": "(^legacy/.*\\.py$)",
"severity.rules": {
"(.*)": "advice"
}
},
"pep8-normal": {
"type": "pep8",
"include": "(\\.py$)",
"exclude": "(^legacy/)"
}
}
}
```
This example will run a relaxed version of the linter (which raises every
message as advice) on Python files in "legacy/", and a normal version everywhere
else.
**Example .arclint Files**: You can find a collection of example files in
`arcanist/resources/arclint/` to use as a starting point or refer to while
configuring your own `.arclint` file.
Advanced Configuration: Lint Engines
====================================
If you need to specify how linters execute in greater detail than is possible
with `.arclint`, you can write a lint engine in PHP to extend Arcanist. This is
an uncommon, advanced use case. The remainder of this section overviews how the
lint internals work, and discusses how to extend Arcanist with a custom lint
engine. If your needs are met by `.arclint`, you can skip to the next section
of this document.
The lint pipeline has two major components: linters and lint engines.
**Linters** are programs which detect problems in a source file. Usually a
linter is an external script, which Arcanist runs and passes a path to, like
`jshint` or `pep8`.
The script emits some messages, and Arcanist parses the output into structured
errors. A piece of glue code (like @{class@arcanist:ArcanistJSHintLinter} or
@{class@arcanist:ArcanistPEP8Linter}) handles calling the external script and
interpreting its output.
**Lint engines** coordinate linters, and decide which linters should run on
which files. For instance, you might want to run `jshint` on all your `.js`
files, and `pep8.py` on all your `.py` files. And you might not want to lint
anything in `externals/` or `third-party/`, and maybe there are other files
which you want to exclude or apply special rules for.
By default, Arcanist uses the
@{class@arcanist:ArcanistConfigurationDrivenLintEngine} engine if there is
an `.arclint` file present in the working copy. This engine reads the `.arclint`
file and uses it to decide which linters should be run on which paths. If no
`.arclint` is present, Arcanist does not select an engine by default.
You can write a custom lint engine instead, which can make a more powerful set
of decisions about which linters to run on which paths. For instructions on
writing a custom lint engine, see @{article:Arcanist User Guide: Customizing
Lint, Unit Tests and Workflows}.
To name an alternate lint engine, set `lint.engine` in your `.arcconfig` to the
name of a class which extends @{class@arcanist:ArcanistLintEngine}. For more
information on `.arcconfig`, see @{article:Arcanist User Guide: Configuring a
New Project}.
You can also set a default lint engine by setting `lint.engine` in your global
user config with `arc set-config lint.engine`, or specify one explicitly with
`arc lint --engine <engine>`. This can be useful for testing.
There are several other engines bundled with Arcanist, but they are primarily
predate `.arclint` and are effectively obsolete.
Using Lint to Improve Code Review
=================================
Code review is most valuable when it's about the big ideas in a change. It is
substantially less valuable when it devolves into nitpicking over style,
formatting, and naming conventions.
The best response to receiving a review request full of style problems is
probably to reject it immediately, point the author at your coding convention
documentation, and ask them to fix it before sending it for review. But even
this is a pretty negative experience for both parties, and less experienced
reviewers sometimes go through the whole review and point out every problem
individually.
Lint can greatly reduce the negativity of this whole experience (and the amount
of time wasted arguing about these things) by enforcing style and formatting
rules automatically. Arcanist supports linters that not only raise warnings
about these problems, but provide patches and fix the problems for the author --
before the code goes to review.
Good linter integration means that code is pretty much mechanically correct by
the time any reviewer sees it, provides clear rules about style which are
especially helpful to new authors, and has the overall effect of pushing
discussion away from stylistic nitpicks and toward useful examination of large
ideas.
It can also provide a straightforward solution to arguments about style, if you
adopt a policy like this:
- If a rule is important enough that it should be enforced, the proponent must
add it to lint so it is automatically detected or fixed in the future and
no one has to argue about it ever again.
- If it's not important enough for them to do the legwork to add it to lint,
they have to stop complaining about it.
This may or may not be an appropriate methodology to adopt at your organization,
but it generally puts the incentives in the right places.
Philosophy of Lint
==================
Some general thoughts on how to develop lint effectively, based on building
lint tools at Facebook:
- Don't write regex-based linters to enforce language rules. Use a real parser
or AST-based tool. This is not a domain you can get right at any nontrivial
complexity with raw regexes. That is not a challenge. Just don't do this.
- False positives are pretty bad and should be avoided. You should aim to
implement only rules that have very few false positives, and provide ways to
mark false positives as OK. If running lint always raises 30 warnings about
irrelevant nonsense, it greatly devalues the tool.
- Move toward autocorrect rules. Most linters do not automatically correct
the problems they detect, but Arcanist supports this and it's quite
valuable to have the linter not only say "the convention is to put a space
after comma in a function call" but to fix it for you.
= Next Steps =
Continue by:
- integrating and customizing built-in linters and lint bindings with
@{article:Arcanist User Guide: Customizing Existing Linters}; or
- use a linter that hasn't been integrated into Arcanist with
@{article:Arcanist User Guide: Script and Regex Linter}; or
- learning how to add new linters and lint engines with
@{article:Arcanist User Guide: Customizing Lint, Unit Tests and Workflows}.
diff --git a/src/docs/user/userguide/calendar_exports.diviner b/src/docs/user/userguide/calendar_exports.diviner
index 340cff863d..487de559a4 100644
--- a/src/docs/user/userguide/calendar_exports.diviner
+++ b/src/docs/user/userguide/calendar_exports.diviner
@@ -1,97 +1,97 @@
@title Calendar User Guide: Exporting Events
@group userguide
Exporting events to other calendars.
Overview
========
IMPORTANT: Calendar is a prototype application. See
@{article:User Guide: Prototype Applications}.
You can export events from Phabricator to other calendar applications like
**Google Calendar** or **Calendar.app**. This document will guide you through
how to export event data from Phabricator.
When you export events into another application, they generally will not be
editable from that application. Exporting events allows you to create one
calendar that shows all the events you care about in whatever application you
prefer (so you can keep track of everything you need to do), but does not let
you edit Phabricator events from another application.
When exporting events, you can either export individual events one at a time
or export an entire group of events (for example, all events you are attending).
Exporting a Single Event
========================
To export a single event, visit the event detail page and click
{nav Export as .ics}. This will download an `.ics` file which you can import
into most other calendar applications.
Mail you receive about events also has a copy of this `.ics` file attached to
it. You can import this `.ics` file directly.
In **Google Calendar**, use {nav Other Calendars > Import Calendar} to import
the `.ics` file.
In **Calendar.app**, use {nav File > Import...} to import the `.ics` file, or
drag the `.ics` file onto your calendar.
When you export a recurring event, the `.ics` file will contain information
about the entire event series.
If you want to update event information later, you can just repeat this
process. Calendar applications will update the existing event if you've
previously imported an older version of it.
Exporting a Group of Events
===========================
You can export a group of events matching an arbitrary query (like all events
you are attending) to keep different calendars in sync.
To export a group of events:
- Run a query in Calendar which selects the events you want to export.
- Example: All events you are attending.
- Example: All events you are invited to.
- Example: All events tagged `#meetup`.
- Select the {nav Use Results... > Export Query as .ics} action to turn
the query into an export.
- - Name the export with a descritive name.
+ - Name the export with a descriptive name.
- Select a policy mode for the export (see below for discussion).
- Click {nav Create New Export} to finish the process.
The **policy modes** for exports are:
- **Public**: Only public information (visible to logged-out users) will
be exported. This mode is not available if your install does not have
public information (per `policy.allow-public` in Config).
- **Privileged**: All event information will be exported. This means that
anyone who knows the export URI can see ALL of the related event
information, as though they were logged in with your account.
WARNING: Anyone who learns the URI for an export can see the data you choose
to export, even if they don't have a Phabricator account! Be careful about how
much data you export and treat the URI as a secret. If you accidentally share
a URI, you can disable the export.
After finishing the process, you'll see a screen with some details about the
export and an **ICS URI**. This URI allows you to import the events which match
the query into another calendar application.
In **Google Calendar**, use {nav Other Calendars > Add by URL} to import the
URI.
In **Calendar.app**, use {nav File > New Calendar Subscription...} to subscribe
to the URI.
Next Steps
==========
Continue by:
- returning to the @{article:Calendar User Guide}.
diff --git a/src/docs/user/userguide/calendar_imports.diviner b/src/docs/user/userguide/calendar_imports.diviner
index a4163e9bcd..a8fbc6ff09 100644
--- a/src/docs/user/userguide/calendar_imports.diviner
+++ b/src/docs/user/userguide/calendar_imports.diviner
@@ -1,132 +1,132 @@
@title Calendar User Guide: Importing Events
@group userguide
Importing events from other calendars.
Overview
========
IMPORTANT: Calendar is a prototype application. See
@{article:User Guide: Prototype Applications}.
You can import events into Phabricator to other calendar applications or from
-`.ics` files. This document will guide you through how to importe event data
+`.ics` files. This document will guide you through how to import event data
into Phabricator.
When you import events from another application, they can not be edited in
Phabricator. Importing events allows you to share events or keep track of
events from different sources, but does not let you edit events from other
applications in Phabricator.
Import Policies
===============
When you import events, you select a visibility policy for the import. By
default, imported events are only visible to you (the user importing them).
To share imported events with other users, make the import **Visible To**
a wider set of users, like "All Users".
Importing `.ics` Files
======================
`.ics` files contain information about events, usually either about a single
event or an entire event calendar.
If you have an event or calendar in `.ics` format, you can import it into
Phabricator in two ways:
- Navigate to {nav Calendar > Imports > Import Events > Import .ics File}.
- Drag and drop the file onto a Calendar.
This will create a copy of the event in Phabricator.
If you want to update an imported event later, just repeat this process. The
event will be updated with the latest information.
Many applications send `.ics` files as email attachments. You can import these
into Phabricator.
.ics Files: Google Calendar
===========================
In **Google Calendar**, you can generate a `.ics` file for a calendar by
clicking the dropdown menu next to the calendar and selecting
{nav Calendar Settings > Export Calendar > Export this calendar}.
.ics Files: Calendar.app
========================
In **Calendar.app**, you can generate an `.ics` file for a calendar by
selecting the calendar, then selecting {nav File > Export > Export...} and
saving the calendar as a `.ics` file.
You can also convert an individual event into an `.ics` file by dragging it
from the calendar to your desktop (or any other folder).
When you import an event using an `.ics` file, Phabricator can not
automatically keep the event up to date. You'll need to repeat the process if
there are changes to the event or calendar later, so Phabricator can learn
about the updates.
Importing .ics URIs
=====================
If you have a calendar in another application that supports publishing a
`.ics` URI, you can subscribe to it in Phabricator. This will import the entire
calendar, and can be configured to automatically keep it up to date and in sync
with the external calendar.
First, find the subscription URI for the calendar you want to import (see
below for some guidance on popular calendar applications). Then, browse to
{nav Calendar > Imports > Import Events > Import .ics URI}.
When you import a URI, you can choose to enable automatic updates. If you do,
Phabricator will periodically update the events it imports from this source.
You can stop this later by turning off the automatic updates or disabling
the import.
{icon lock} **Privacy Note**: When you import via URI, the URI often contains
sensitive information (like a username, password, or secret key) which allows
anyone who knows it to access private details about events. Anyone who can edit
the import will also be able to view and edit the URI, so make sure you don't
grant edit access to users who should not have access to the event details.
.ics URIs: Google Calendar
==========================
In **Google Calendar**, you can get the subscription URI for a calendar
by selecting {nav Calendar Settings} from the dropdown next to the calendar,
then copying the URL from the {nav ICAL} link under **Private Address**. This
URI provides access to all event details, including private information.
You may need to adjust the sharing and visibility settings for the calendar
before this option is available.
Alternatively, you can use the URI from the {nav ICAL} link under
**Calendar Address** to access a more limited set of event details. You can
configure which details are available by configuring how the calendar is
shared.
.ics URIs: Calendar.app
=======================
**Calendar.app** does not support subscriptions via `.ics` URIs.
You can export a calendar as an `.ics` file by following the steps above, but
Phabricator can not automatically keep events imported in this way up to date.
Next Steps
==========
Continue by:
- returning to the @{article:Calendar User Guide}.
diff --git a/src/docs/user/userguide/conduit_edit.diviner b/src/docs/user/userguide/conduit_edit.diviner
index 01d15873a8..5188300e6d 100644
--- a/src/docs/user/userguide/conduit_edit.diviner
+++ b/src/docs/user/userguide/conduit_edit.diviner
@@ -1,115 +1,115 @@
@title Conduit API: Using Edit Endpoints
@group conduit
Describes how to use edit endpoints to create and update objects.
Overview
========
Many applications provide `edit` endpoints, which are the primary way to
create and update objects (like tasks) using the API.
To create or edit an object, you'll build a list of //transactions// and pass
them to the endpoint. Each transaction applies a change to a field or property
on the object.
For example, a transaction might change the title of an object or add
subscribers.
When creating an object, transactions will be applied to an empty object. When
editing an object, transactions will be applied to an existing object.
The best reference for a particular `edit` endpoint is the Conduit API console.
For example, you can find the console page for `maniphest.edit` by navigating
to {nav Conduit > maniphest.edit} in the web UI. This page contains detailed
information about the endpoint and how it can be used.
Creating Objects
================
To create objects, pass a list of transactions but leave `objectIdentfier`
blank. This tells the endpoint that you want to create a new, empty object and
then apply the transactions to it.
Editing Objects
===============
To edit objects, pass a list of transactions and use `objectIdentifier` to
specify which object to apply them to. You can normally pass an ID or PHID,
-and many applicaitons also allow you to pass a monogram (for example, you can
+and many applications also allow you to pass a monogram (for example, you can
edit a task by passing `T123`).
Building Transactions
=====================
When creating or editing objects, you'll build a list of transactions to
apply. This transaction list will look something like this:
```lang=json, name="Example Transaction List"
[
{
"type": "title",
"value": "Assemble in the barnyard"
},
{
"type": "description",
"value": "All animals should assemble in the barnyard promptly."
},
{
"type": "subscribers.add",
"value": ["dog", "cat", "mouse"]
}
]
```
Applied to an empty object (say, a task), these transactions would create a new
task with the specified title, description and subscribers.
Applied to an existing object, they would retitle the task, change its
description, and add new subscribers.
The particular transactions available on each object are documented on the
Conduit API console page for that object.
Return Type
===========
WARNING: The structure of the return value from these methods is likely to
change as ApplicationEditor evolves.
Return values look something like this for now:
```lang=json, name=Example Return Value
{
"object": {
"phid": "PHID-XXXX-1111"
},
"transactions": [
{
"phid": "PHID-YYYY-1111",
},
{
"phid": "PHID-YYYY-2222",
}
]
}
```
The `object` key contains information about the object which was created or
edited.
The `transactions` key contains information about the transactions which were
actually applied. For many reasons, the transactions which actually apply may
be greater or fewer in number than the transactions you provided, or may differ
in their nature in other ways.
Next Steps
==========
Continue by:
- returning to the @{article:Conduit API Overview}.
diff --git a/src/docs/user/userguide/diffusion_api.diviner b/src/docs/user/userguide/diffusion_api.diviner
index f57d5007aa..c2508dda72 100644
--- a/src/docs/user/userguide/diffusion_api.diviner
+++ b/src/docs/user/userguide/diffusion_api.diviner
@@ -1,182 +1,182 @@
@title Diffusion User Guide: Repositories API
@group userguide
Managing repositories with the API.
Overview
========
You can create and update Diffusion repositories using the Conduit API. This
may be useful if you have a large number of existing repositories you want
to import or apply bulk actions to.
For an introduction to Conduit, see @{article:Conduit API Overview}.
In general, you'll use these API methods:
- `diffusion.repository.edit`: Create and edit repositories.
- `diffusion.uri.edit`: Create and edit repository URIs to configure
observation, mirroring, and cloning.
To create a repository, you'll generally do this:
- Call `diffusion.repository.edit` to create a new object and configure
basic information.
- Optionally, call `diffusion.uri.edit` to add URIs to observe or mirror.
- Call `diffusion.repository.edit` to activate the repository.
This workflow mirrors the workflow from the web UI. The remainder of this
document walks through this workflow in greater detail.
Create a Repository
===================
To create a repository, call `diffusion.repository.edit`, providing any
properties you want to set. For simplicity these examples will use the
builtin `arc call-conduit` client, but you can use whatever Conduit client
you prefer.
When creating a repository, you must provide a `vcs` transaction to choose
a repository type, one of: `git`, `hg` or `svn`.
You must also provide a `name`.
Other properties are optional. Review the Conduit method documentation from the
web UI for an exhaustive list.
```
$ echo '{
"transactions": [
{
"type": "vcs",
"value": "git"
},
{
"type": "name",
"value": "Poetry"
}
]
}' | arc call-conduit diffusion.repository.edit
```
If things work, you should get a result that looks something like this:
```lang=json
{
...
"response": {
"object": {
"id": 1,
"phid": "PHID-REPO-7vm42oayez2rxcmpwhuv"
},
...
}
...
}
```
If so, your new repository has been created. It hasn't been activated yet so
it will not show up in the default repository list, but you can find it in the
web UI by browsing to {nav Diffusion > All Repositories}.
Continue to the next step to configure URIs.
Configure URIs
==============
Now that the repository exists, you can add URIs to it. This is optional,
and if you're creating a //hosted// repository you may be able to skip this
step.
However, if you want Phabricator to observe an existing remote, you'll
configure it here by adding a URI in "Observe" mode. Use the PHID from the
previous step to identify the repository you want to add a URI to, and call
`diffusion.uri.edit` to create a new URI in Observe mode for the repository.
You need to provide a `repository` to add the URI to, and the `uri` itself.
To add the URI in Observe mode, provide an `io` transaction selecting
`observe` mode.
You may also want to provide a `credential`.
```
$ echo '{
"transactions": [
{
"type": "repository",
"value": "PHID-REPO-7vm42oayez2rxcmpwhuv"
},
{
"type": "uri",
"value": "https://github.com/epriestley/poems.git"
},
{
"type": "io",
"value": "observe"
}
]
}' | arc call-conduit diffusion.uri.edit
```
You should get a response that looks something like this:
```lang=json
{
...
"response": {
"object": {
"id": 1,
"phid": "PHID-RURI-zwtho5o7h3m6rjzgsgrh"
},
...
}
...
}
```
If so, your URI has been created. You can review it in the web UI, under
{nav Manage Repository > URIs}.
When satisfied, continue to the next step to activate the repository.
Activate the Repository
=======================
Now that any URIs have been configured, activate the repository with another
-call to `diffusion.repository.edit`. This time, modify the existing repostitory
+call to `diffusion.repository.edit`. This time, modify the existing repository
instead of creating a new one:
```
$ echo '{
"objectIdentifier": "PHID-REPO-7vm42oayez2rxcmpwhuv",
"transactions": [
{
"type": "status",
"value": "active"
}
]
}' | arc call-conduit diffusion.repository.edit
```
If that goes through cleanly, you should be all set. You can review the
repository from the web UI.
Editing Repositories
====================
To edit an existing repository, apply changes normally with
`diffusion.repository.edit`. For more details on using edit endpoints, see
@{article:Conduit API: Using Edit Endpoints}.
Next Steps
==========
Continue by:
- returning to the @{article:Diffusion User Guide}.
diff --git a/src/docs/user/userguide/diffusion_hosting.diviner b/src/docs/user/userguide/diffusion_hosting.diviner
index 2443b475e8..47197f52fd 100644
--- a/src/docs/user/userguide/diffusion_hosting.diviner
+++ b/src/docs/user/userguide/diffusion_hosting.diviner
@@ -1,629 +1,629 @@
@title Diffusion User Guide: Repository Hosting
@group userguide
Guide to configuring Phabricator repository hosting.
Overview
========
Phabricator can host repositories and provide authenticated read and write
access to them over HTTP and SSH. This document describes how to configure
repository hosting.
Understanding Supported Protocols
=================================
Phabricator supports hosting over these protocols:
| VCS | SSH | HTTP |
|-----|-----|------|
| Git | Supported | Supported |
| Mercurial | Supported | Supported |
| Subversion | Supported | Not Supported |
All supported protocols handle reads (pull/checkout/clone) and writes
(push/commit). Of the two protocols, SSH is generally more robust, secure and
performant, but HTTP is easier to set up and supports anonymous access.
| | SSH | HTTP |
| |-----|------|
| Reads | Yes | Yes |
| Writes | Yes | Yes |
| Authenticated Access | Yes | Yes |
| Push Logs | Yes | Yes |
| Commit Hooks | Yes | Yes |
| Anonymous Access | No | Yes |
| Security | Better (Asymmetric Key) | Okay (Password) |
| Performance | Better | Okay |
| Setup | Hard | Easy |
Each repository can be configured individually, and you can use either
protocol, or both, or a mixture across different repositories.
SSH is recommended unless you need anonymous access, or are not able to
configure it for technical reasons.
Creating System User Accounts
=============================
Phabricator uses two system user accounts, plus a third account if you
configure SSH access. This section will guide you through creating and
configuring them. These are system user accounts on the machine Phabricator
runs on, not Phabricator user accounts.
The system accounts Phabricator uses are:
- The user the webserver runs as. We'll call this `www-user`.
- The user the daemons run as. We'll call this `daemon-user`. This
user is the only user which will interact with the repositories directly.
Other accounts will `sudo` to this account in order to perform repository
operations.
- The user that humans will connect over SSH as. We'll call this `vcs-user`.
If you do not plan to make repositories available over SSH, you do not need
to create or configure this user.
To create these users:
- Create a `www-user` if one does not already exist. In most cases, this
user will already exist and you just need to identify which user it is. Run
your webserver as this user.
- Create a `daemon-user` if one does not already exist (you can call this user
whatever you want, or use an existing account). Below, you'll configure
the daemons to start as this user.
- Create a `vcs-user` if one does not already exist and you plan to set up
SSH. When users clone repositories, they will use a URI like
`vcs-user@phabricator.yourcompany.com`, so common names for this user are
`git` or `hg`.
Continue below to configure these accounts.
Configuring Phabricator
=======================
Now that you have created or identified these accounts, update the Phabricator
configuration to specify them.
First, set `phd.user` to the `daemon-user`:
```
phabricator/ $ ./bin/config set phd.user daemon-user
```
Restart the daemons to make sure this configuration works properly. They should
start as the correct user automatically.
If you're using a `vcs-user` for SSH, you should also configure that:
```
phabricator/ $ ./bin/config set diffusion.ssh-user vcs-user
```
Next, you'll set up `sudo` permissions so these users can interact with one
another.
Configuring Sudo
================
The `www-user` and `vcs-user` need to be able to `sudo` as the `daemon-user`
so they can interact with repositories.
To grant them access, edit the `sudo` system configuration. On many systems,
you will do this by modifying the `/etc/sudoers` file using `visudo` or
`sudoedit`. In some cases, you may add a new file to `/etc/sudoers.d` instead.
To give a user account `sudo` access to run a list of binaries, add a line like
this to the configuration file (this example would grant `vcs-user` permission
to run `ls` as `daemon-user`):
```
vcs-user ALL=(daemon-user) SETENV: NOPASSWD: /path/to/bin/ls
```
The `www-user` needs to be able to run these binaries as the `daemon-user`:
- `git` (if using Git)
- `git-http-backend` (if using Git)
- `hg` (if using Mercurial)
- `ssh` (if configuring clusters)
If you plan to use SSH, the `vcs-user` needs to be able to run these binaries
as the `daemon-user`:
- `git` (if using Git)
- `git-upload-pack` (if using Git)
- `git-receive-pack` (if using Git)
- `hg` (if using Mercurial)
- `svnserve` (if using Subversion)
- `ssh` (if configuring clusters)
Identify the full paths to all of these binaries on your system and add the
appropriate permissions to the `sudo` configuration.
Normally, you'll add two lines that look something like this:
```
www-user ALL=(daemon-user) SETENV: NOPASSWD: /path/to/x, /path/to/y, ...
vcs-user ALL=(daemon-user) SETENV: NOPASSWD: /path/to/x, /path/to/y, ...
```
This is just a template. In the real configuration file, you need to:
- - Replace `www-user`, `dameon-user` and `vcs-user` with the correct
+ - Replace `www-user`, `daemon-user` and `vcs-user` with the correct
usernames for your system.
- List every binary that these users need access to, as described above.
- Make sure each binary path is the full path to the correct binary location
on your system.
Before continuing, look for this line in your `sudo` configuration:
Defaults requiretty
If it's present, comment it out by putting a `#` at the beginning of the line.
With this option enabled, VCS SSH sessions won't be able to use `sudo`.
Additional SSH User Configuration
=================================
If you're planning to use SSH, you should also edit `/etc/passwd` and
`/etc/shadow` to make sure the `vcs-user` account is set up correctly.
**`/etc/shadow`**: Open `/etc/shadow` and find the line for the `vcs-user`
account.
The second field (which is the password field) must not be set to `!!`. This
value will prevent login.
If you have `usermod` on your system, you can adjust this value with:
```
$ sudo usermod -p NP vcs-user
```
If you do not have `usermod`, carefully edit the file and set the field value
to `NP` ("no password") instead of `!!`.
**`/etc/passwd`**: Open `/etc/passwd` and find the line for the `vcs-user`
account.
The last field (which is the login shell) must be set to a real shell. If it is
set to something like `/bin/false`, then `sshd` will not be able to execute
commands.
If you have `usermod` on your system, you can adjust this value with:
```
$ sudo usermod -s /bin/sh vcs-user
```
If you do not have `usermod`, carefully edit the file and change the field
to point at a real shell, usually `/bin/sh`.
Configuring HTTP
================
If you plan to serve repositories over authenticated HTTP, you need to set
`diffusion.allow-http-auth` in Config. If you don't plan to serve repositories
over HTTP (or plan to use only anonymous HTTP) you can leave this setting
disabled.
If you plan to use authenticated HTTP, you (and all other users) also need to
configure a VCS password for your account in {nav Settings > VCS Password}.
Your VCS password must be a different password than your main Phabricator
password because VCS passwords are very easy to accidentally disclose. They are
often stored in plaintext in world-readable files, observable in `ps` output,
and present in command output and logs. We strongly encourage you to use SSH
instead of HTTP to authenticate access to repositories.
Otherwise, if you've configured system accounts above, you're all set. No
additional server configuration is required to make HTTP work. You should now
be able to fetch and push repositories over HTTP. See "Cloning a Repository"
below for more details.
If you're having trouble, see "Troubleshooting HTTP" below.
Configuring SSH
===============
SSH access requires some additional setup. You will configure and run a second,
restricted copy of `sshd` on the machine, on a different port from the standard
`sshd`. This special copy of `sshd` will serve repository requests and provide
other Phabricator SSH services.
NOTE: The Phabricator `sshd` service **MUST** be 6.2 or newer, because
Phabricator relies on the `AuthorizedKeysCommand` option.
Before continuing, you must choose a strategy for which port each copy of
`sshd` will run on. The next section lays out various approaches.
SSHD Port Assignment
====================
The normal `sshd` that lets you administrate the host and the special `sshd`
which serves repositories can't run on the same port. In particular, only one
of them can run on port `22`, which will make it a bit inconvenient to access
the other one.
These instructions will walk you through configuring the alternate `sshd` on
port `2222`. This is easy to configure, but if you run the service on this port
users will clone and push to URIs like `ssh://git@host.com:2222/`, which is a
little ugly.
There are several different approaches you can use to mitigate or eliminate
this problem.
**Run on Port 2222**: You can do nothing, and just run the repository `sshd` on
port `2222` and accept the explicit port in the URIs. This is the simplest
approach, and you can always start here and clean things up later if you grow
tired of dealing with the port number.
**Use a Load Balancer**: You can configure a load balancer in front of the host
and have it forward TCP traffic on port `22` to port `2222`. Then users can
clone from `ssh://git@host.com/` without an explicit port number and you don't
need to do anything else.
This may be very easy to set up, particularly if you are hosted in AWS, and
is often the simplest and cleanest approach.
**Swap Ports**: You can move the administrative `sshd` to a new port, then run
Phabricator `sshd` on port 22. This is somewhat complicated and can be a bit
risky if you make a mistake. See "Moving the sshd Port" below for help.
**Change Client Config**: You can run on a nonstandard port, but configure SSH
on the client side so that `ssh` automatically defaults to the correct port
when connecting to the host. To do this, add a section like this to your
`~/.ssh/config`:
```
Host phabricator.corporation.com
Port 2222
```
(If you want, you can also add a default `User`.)
Command line tools like `ssh`, `git` and `hg` will now default to port
`2222` when connecting to this host.
A downside to this approach is that your users will each need to set up their
`~/.ssh/config` files individually.
This file also allows you to define short names for hosts using the `Host` and
`HostName` options. If you choose to do this, be aware that Phabricator uses
remote/clone URIs to figure out which repository it is operating in, but can
not resolve host aliases defined in your `ssh` config. If you create host
aliases they may break some features related to repository identification.
If you use this approach, you will also need to specify a port explicitly when
connecting to administrate the host. Any unit tests or other build automation
will also need to be configured or use explicit port numbers.
**Port Multiplexing**: If you have hardware access, you can power down the host
and find the network I/O pins on the motherboard (for onboard networking) or
network card.
Carefully strip and solder a short piece of copper wire between the pins for
the external interface `22` and internal `2222`, so the external interface can
receive traffic for both services.
(Make sure not to desolder the existing connection between external `22` and
internal `22` or you won't be able to connect normally to administrate the
host.)
The obvious downside to this approach is that it requires physical access to
the machine, so it won't work if you're hosted on a cloud provider.
SSHD Setup
==========
Now that you've decided how you'll handle port assignment, you're ready to
continue `sshd` setup.
If you plan to connect to a port other than `22`, you should set this port
as `diffusion.ssh-port` in your Phabricator config:
```
$ ./bin/config set diffusion.ssh-port 2222
```
This port is not special, and you are free to choose a different port, provided
you make the appropriate configuration adjustment below.
**Configure and Start Phabricator SSHD**: Now, you'll configure and start a
copy of `sshd` which will serve Phabricator services, including repositories,
over SSH.
This instance will use a special locked-down configuration that uses
Phabricator to handle authentication and command execution.
There are three major steps:
- Create a `phabricator-ssh-hook.sh` file.
- Create a `sshd_phabricator` config file.
- Start a copy of `sshd` using the new configuration.
**Create `phabricator-ssh-hook.sh`**: Copy the template in
`phabricator/resources/sshd/phabricator-ssh-hook.sh` to somewhere like
`/usr/libexec/phabricator-ssh-hook.sh` and edit it to have the correct
settings.
Both the script itself **and** the parent directory the script resides in must
be owned by `root`, and the script must have `755` permissions:
```
$ sudo chown root /path/to/somewhere/
$ sudo chown root /path/to/somewhere/phabricator-ssh-hook.sh
$ sudo chmod 755 /path/to/somewhere/phabricator-ssh-hook.sh
```
If you don't do this, `sshd` will refuse to execute the hook.
**Create `sshd_config` for Phabricator**: Copy the template in
`phabricator/resources/sshd/sshd_config.phabricator.example` to somewhere like
`/etc/ssh/sshd_config.phabricator`.
Open the file and edit the `AuthorizedKeysCommand`,
`AuthorizedKeysCommandUser`, and `AllowUsers` settings to be correct for your
system.
This configuration file also specifies the `Port` the service should run on.
If you intend to run on a non-default port, adjust it now.
**Start SSHD**: Now, start the Phabricator `sshd`:
sudo /path/to/sshd -f /path/to/sshd_config.phabricator
If you did everything correctly, you should be able to run this command:
```
$ echo {} | ssh vcs-user@phabricator.yourcompany.com conduit conduit.ping
```
...and get a response like this:
```lang=json
{"result":"phabricator.yourcompany.com","error_code":null,"error_info":null}
```
If you get an authentication error, make sure you added your public key in
{nav Settings > SSH Public Keys}. If you're having trouble, check the
troubleshooting section below.
Authentication Over SSH
=======================
To authenticate over SSH, users should add their public keys under
{nav Settings > SSH Public Keys}.
Cloning a Repository
====================
If you've already set up a hosted repository, you can try cloning it now. To
do this, browse to the repository's main screen in Diffusion. You should see
clone commands at the top of the page.
To clone the repository, just run the appropriate command.
If you don't see the commands or running them doesn't work, see below for tips
on troubleshooting.
Troubleshooting HTTP
====================
Some general tips for troubleshooting problems with HTTP:
- Make sure `diffusion.allow-http-auth` is enabled in your Phabricator config.
- Make sure HTTP serving is enabled for the repository you're trying to
clone. You can find this in {nav Edit Repository > Hosting}.
- Make sure you've configured a VCS password. This is separate from your main
account password. You can configure this in {nav Settings > VCS Password}.
- Make sure the main repository screen in Diffusion shows a clone/checkout
command for HTTP. If it doesn't, something above isn't set up correctly:
double-check your configuration. You should see a `svn checkout http://...`,
`git clone http://...` or `hg clone http://...` command. Run that command
verbatim to clone the repository.
If you're using Git, using `GIT_CURL_VERBOSE` may help assess login failures.
To do so, specify it on the command line before the `git clone` command, like
this:
$ GIT_CURL_VERBOSE=1 git clone ...
This will make `git` print out a lot more information. Particularly, the line
with the HTTP response is likely to be useful:
< HTTP/1.1 403 Invalid credentials.
In many cases, this can give you more information about what's wrong.
Troubleshooting SSH
===================
Some general tips for troubleshooting problems with SSH:
- Check that you've configured `diffusion.ssh-user`.
- Check that you've configured `phd.user`.
- Make sure SSH serving is enabled for the repository you're trying to clone.
You can change this setting from a main repository screen in Diffusion by
{nav Edit Repository >
Edit Hosting >
Host Repository on Phabricator >
Save and Continue >
SSH Read Only or Read/Write >
Save Changes}.
- Make sure you've added an SSH public key to your account. You can do this
in {nav Settings > SSH Public Keys}.
- Make sure the main repository screen in Diffusion shows a clone/checkout
command for SSH. If it doesn't, something above isn't set up correctly.
You should see an `svn checkout svn+ssh://...`, `git clone ssh://...` or
`hg clone ssh://...` command. Run that command verbatim to clone the
repository.
- Check your `phabricator-ssh-hook.sh` file for proper settings.
- Check your `sshd_config.phabricator` file for proper settings.
To troubleshoot SSH setup: connect to the server with `ssh`, without running a
command. You may need to use the `-T` flag, and will need to use `-p` if you
are running on a nonstandard port. You should see a message like this one:
$ ssh -T -p 2222 vcs-user@phabricator.yourcompany.com
phabricator-ssh-exec: Welcome to Phabricator.
You are logged in as alincoln.
You haven't specified a command to run. This means you're requesting an
interactive shell, but Phabricator does not provide an interactive shell over
SSH.
Usually, you should run a command like `git clone` or `hg push` rather than
connecting directly with SSH.
Supported commands are: conduit, git-receive-pack, git-upload-pack, hg,
svnserve.
If you see this message, all your SSH stuff is configured correctly. **If you
get a login shell instead, you've missed some major setup step: review the
documentation above.** If you get some other sort of error, double check these
settings:
- You're connecting as the `vcs-user`.
- The `vcs-user` has `NP` in `/etc/shadow`.
- The `vcs-user` has `/bin/sh` or some other valid shell in `/etc/passwd`.
- Your SSH private key is correct, and you've added the corresponding
public key to Phabricator in the Settings panel.
If you can get this far, but can't execute VCS commands like `git clone`, there
is probably an issue with your `sudoers` configuration. Check:
- Your `sudoers` file is set up as instructed above.
- You've commented out `Defaults requiretty` in `sudoers`.
- You don't have multiple copies of the VCS binaries (like `git-upload-pack`)
on your system. You may have granted sudo access to one, while the VCS user
is trying to run a different one.
- You've configured `phd.user`.
- The `phd.user` has read and write access to the repositories.
It may also be helpful to run `sshd` in debug mode:
$ /path/to/sshd -d -d -d -f /path/to/sshd_config.phabricator
This will run it in the foreground and emit a large amount of debugging
information when you connect to it.
Finally, you can usually test that `sudoers` is configured correctly by
doing something like this:
$ su vcs-user
$ sudo -E -n -u daemon-user -- /path/to/some/vcs-binary --help
That will try to run the binary via `sudo` in a manner similar to the way that
Phabricator will run it. This can give you better error messages about issues
with `sudoers` configuration.
Miscellaneous Troubleshooting
=============================
- If you're getting an error about `svnlook` not being found, add the path
where `svnlook` is located to the Phabricator configuration
`environment.append-paths` (even if it already appears in PATH). This issue
is caused by SVN wiping the environment (including PATH) when invoking
commit hooks.
Moving the sshd Port
====================
If you want to move the standard (administrative) `sshd` to a different port to
make Phabricator repository URIs cleaner, this section has some tips.
This is optional, and it is normally easier to do this by putting a load
balancer in front of Phabricator and having it accept TCP traffic on port 22
and forward it to some other port.
When moving `sshd`, be careful when editing the configuration. If you get it
wrong, you may lock yourself out of the machine. Restarting `sshd` generally
will not interrupt existing connections, but you should exercise caution. Two
strategies you can use to mitigate this risk are: smoke-test configuration by
starting a second `sshd`; and use a `screen` session which automatically
repairs configuration unless stopped.
To smoke-test a configuration, just start another `sshd` using the `-f` flag:
sudo /path/to/sshd -f /path/to/config_file.edited
You can then connect and make sure the edited config file is valid before
replacing your primary configuration file.
To automatically repair configuration, start a `screen` session with a command
like this in it:
sleep 60 ; mv sshd_config.good sshd_config ; /etc/init.d/sshd restart
The specific command may vary for your system, but the general idea is to have
the machine automatically restore configuration after some period of time if
you don't stop it. If you lock yourself out, this can fix things automatically.
Now that you're ready to edit your configuration, open up your `sshd` config
(often `/etc/ssh/sshd_config`) and change the `Port` setting to some other port,
like `222` (you can choose any port other than 22).
Port 222
Very carefully, restart `sshd`. Verify that you can connect on the new port:
ssh -p 222 ...
Now you can move the Phabricator `sshd` to port 22, then adjust the value
for `diffusion.ssh-port` in your Phabricator configuration.
No Direct Pushes
================
You may get an error about "No Direct Pushes" when trying to push. This means
you are pushing directly to the repository instead of pushing through
Phabricator. This is not supported: writes to hosted repositories must go
through Phabricator so it can perform authentication, enforce permissions,
write logs, proxy requests, apply rewriting, etc.
One way to do a direct push by mistake is to use a `file:///` URI to interact
with the repository from the same machine. This is not supported. Instead, use
one of the repository URIs provided in the web interface, even if you're
working on the same machine.
Another way to do a direct push is to misconfigure SSH (or not configure it at
all) so that none of the logic described above runs and you just connect
normally as a system user. In this case, the `ssh` test described above will
fail (you'll get a command prompt when you connect, instead of the message you
are supposed to get, as described above).
If you encounter this error: make sure you're using a remote URI given to
you by Diffusion in the web interface, then run through the troubleshooting
steps above carefully.
Sometimes users encounter this problem because they skip this whole document
assuming they don't need to configure anything. This will not work, and you
MUST configure things as described above for hosted repositories to work.
The technical reason this error occurs is that the `PHABRICATOR_USER` variable
is not defined in the environment when commit hooks run. This variable is set
by Phabricator when a request passes through the authentication layer that this
document provides instructions for configuring. Its absence indicates that the
request did not pass through Phabricator.
Next Steps
==========
Once hosted repositories are set up:
- learn about commit hooks with @{article:Diffusion User Guide: Commit Hooks}.
diff --git a/src/docs/user/userguide/diffusion_managing.diviner b/src/docs/user/userguide/diffusion_managing.diviner
index f6ab0997c4..cc03433532 100644
--- a/src/docs/user/userguide/diffusion_managing.diviner
+++ b/src/docs/user/userguide/diffusion_managing.diviner
@@ -1,385 +1,385 @@
@title Diffusion User Guide: Managing Repositories
@group userguide
Guide to configuring and managing repositories in Diffusion.
Overview
========
After you create a new repository in Diffusion or select **Manage Repository**
from the main screen if an existing repository, you'll be taken to the
repository management interface for that repository.
On this interface, you'll find many options which allow you to configure the
behavior of a repository. This document walks through the options.
Basics
======
The **Basics** section of the management interface allows you to configure
the repository name, description, and identifiers. You can also activate or
deactivate the repository here, and configure a few other miscellaneous
settings.
Basics: Name
============
The repository name is a human-readable primary name for the repository. It
does not need to be unique
Because the name is not unique and does not have any meaningful restrictions,
it's fairly ambiguous and isn't very useful as an identifier. The other basic
information (primarily callsigns and short names) gives you control over
repository identifiers.
Basics: Callsigns
=================
Each repository can optionally be identified by a "callsign", which is a short
uppercase string like "P" (for Phabricator) or "ARC" (for Arcanist).
The primary goal of callsigns is to namespace commits to SVN repositories: if
you use multiple SVN repositories, each repository has a revision 1, revision 2,
etc., so referring to them by number alone is ambiguous.
However, even for Git and Mercurial they impart additional information to human
readers and allow parsers to detect that something is a commit name with high
probability (and allow distinguishing between multiple copies of a repository).
Configuring a callsign can make interacting with a commonly-used repository
easier, but you may not want to bother assigning one to every repository if you
have some similar, templated, or rarely-used repositories.
If you choose to assign a callsign to a repository, it must be unique within an
install but do not need to be globally unique, so you are free to use the
single-letter callsigns for brevity. For example, Facebook uses "E" for the
Engineering repository, "O" for the Ops repository, "Y" for a Yum package
repository, and so on, while Phabricator uses "P", "ARC", "PHU" for libphutil,
and "J" for Javelin. Keeping callsigns brief will make them easier to use, and
the use of one-character callsigns is encouraged if they are reasonably
evocative.
If you configure a callsign like `XYZ`, Phabricator will activate callsign URIs
and activate the callsign identifier (like `rXYZ`) for the repository. These
more human-readable identifiers can make things a little easier to interact
with.
Basics: Short Name
==================
Each repository can optionally have a unique short name. Short names must be
unique and have some minor restrictions to make sure they are unambiguous and
appropriate for use as directory names and in URIs.
Basics: Description
===================
You may optionally provide a brief (or, at your discretion, excruciatingly
long) human-readable description of the repository. This description will be
shown on the main repository page.
You can also create a `README` file at the repository root (or in any
subdirectory) to provide information about the repository. These formats are
supported:
| File Name | Rendered As...
|-------------------|---------------
| `README` | Plain Text
| `README.txt` | Plain Text
| `README.remarkup` | Remarkup
| `README.md` | Remarkup
| `README.rainbow` | Rainbow
Basics: Encoding
================
Before content from the repository can be shown in the web UI or embedded in
other contexts like email, it must be converted to UTF-8.
Most source code is written in UTF-8 or a subset of UTF-8 (like plain ASCII)
already, so everything will work fine. The majority of repositories do not need
to adjust this setting.
If your repository is primarily written in some other encoding, specify it here
so Phabricator can convert from it properly when reading content to embed in
a webpage or email.
Basics: Dangerous Changes
=========================
By default, repositories are protected against dangerous changes. Dangerous
changes are operations which rewrite or destroy repository history (for
example, by deleting or rewriting branches). Normally, these take the form
of `git push --force` or similar.
It is normally a good idea to leave this protection enabled because most
scalable workflows rarely rewrite repository history and it's easy to make
mistakes which are expensive to correct if this protection is disabled.
-If you do occasionally need to rewite published history, you can treat this
+If you do occasionally need to rewrite published history, you can treat this
option like a safety: disable it, perform required rewrites, then enable it
again.
If you fully disable this at the repository level, you can still use Herald to
selectively protect certain branches or grant this power to a limited set of
users.
This option is only available in Git and Mercurial, because it is impossible
to make dangerous changes in Subversion.
This option has no effect if a repository is not hosted because Phabricator
can not prevent dangerous changes in a remote repository it is merely
observing.
Basics: Deactivate Repository
=============================
Repositories can be deactivated. Deactivating a repository has these effects:
- the repository will no longer be updated;
- users will no longer be able to clone/fetch/checkout the repository;
- users will no longer be able to push to the repository; and
- the repository will be hidden from view in default queries.
When repositories are created for the first time, they are deactivated. This
-gives you an opportuinty to customize settings, like adjusting policies or
+gives you an opportunity to customize settings, like adjusting policies or
configuring a URI to observe. You must activate a repository before it will
start working normally.
Basics: Delete Repository
=========================
Repositories can not be deleted from the web UI, so this option is always
disabled. Clicking it gives you information about how to delete a repository.
Repositories can only be deleted from the command line, with `bin/remove`:
```
$ ./bin/remove destroy <repository>
```
WARNING: This command will issue you a dire warning about the severity of the
action you are taking. Heed this warning. You are **strongly discouraged** from
destroying repositories. Instead, deactivate them.
Policies
========
The **Policies** section of the management interface allows you to review and
manage repository access policies.
You can configure granular access policies for each repository to control who
-can view, clone, administate, and push to the repository.
+can view, clone, administrate, and push to the repository.
Policies: View
==============
The view policy for a repository controls who can view the repository from
the web UI and clone, fetch, or check it out from Phabricator.
Users who can view a repository can also access the "Manage" interface to
review information about the repository and examine the edit history, but can
not make any changes.
Policies: Edit
==============
The edit policy for a repository controls who can change repository settings
using the "Manage" interface. In essence, this is permission to administrate
the repository.
You must be able to view a repository to edit it.
You do not need this permission to push changes to a repository.
Policies: Push
==============
The push policy for a repository controls who can push changes to the
repository.
This policy has no effect if Phabricator is not hosting the repository, because
it can not control who is allowed to make changes to a remote repository it is
merely observing.
You must also be able to view a repository to push to it.
You do not need to be able to edit a repository to push to it.
Further restrictions on who can push (and what they can push) can be configured
for hosted repositories with Herald, which allows you to write more
sophisticated rules that evaluate when Phabricator receives a push. To get
started with Herald, see @{article:Herald User Guide}.
Additionally, Git and Mercurial repositories have a setting which allows
you to **Prevent Dangerous Changes**. This setting is enabled by default and
will prevent any users from pushing changes which rewrite or destroy history.
URIs
====
The **URIs** panel allows you to add and manage URIs which Phabricator will
fetch from, serve from, and push to.
These options are covered in detail in @{article:Diffusion User Guide: URIs}.
Staging Area
============
The **Staging Area** panel configures staging areas, used to make proposed
changes available to build and continuous integration systems.
For more details, see @{article:Harbormaster User Guide}.
Automation
==========
The **Automation** panel configures support for allowing Phabricator to make
writes directly to the repository, so that it can perform operations like
automatically landing revisions from the web UI.
For details on repository automation, see
@{article:Drydock User Guide: Repository Automation}.
Symbols
======
The **Symbols** panel allows you to customize how symbols (like class and
function names) are linked when viewing code in the repository, and when
viewing revisions which propose code changes to the repository.
To take advantage of this feature, you need to do additional work to build
symbol indexes. For details on configuring and populating symbol indexes, see
@{article:User Guide: Symbol Indexes}.
Branches
========
The **Branches** panel allows you to configure how Phabricator interacts with
branches.
This panel is not available for Subversion repositories, because Subversion
does not have formal branches.
You can configure **Default Branch**. This controls which branch is shown by
default in the UI. If no branch is provided, Phabricator will use `master` in
Git and `default` in Mercurial.
If you want Diffusion to ignore some branches in the repository, you can
configure **Track Only**. Other branches will be ignored. If you do not specify
any branches, all branches are tracked.
When specifying branches, you should enter one branch name per line. You can
use regular expressions to match branches by wrapping an expression in
`regexp(...)`. For example:
| Example | Effect |
|---------|--------|
| `master` | Track only `master`.
| `regexp(/^release-/)` | Track all branches which start with `release-`.
| `regexp(/^(?!temp-)/)` | Do not track branches which start with `temp-`.
Actions
======
The **Actions** panel can configure notifications and publishing behavior.
Normally, Phabricator publishes notifications when it discovers new commits.
-You can disable publishing for a repository by turning off **Publish/Noitfy**.
+You can disable publishing for a repository by turning off **Publish/Notify**.
This will disable notifications, feed, and Herald (including audits and build
plans) for this repository.
When Phabricator discovers a new commit, it can automatically close associated
revisions and tasks. If you don't want Phabricator to close objects when it
discovers new commits, disable **Autoclose** for the repository.
Repository Identifiers and Names
================================
Repositories have several short identifiers which you can use to refer to the
repository. For example, if you use command-line administrative tools to
interact with a repository, you'll provide one of these identifiers:
```
$ ./bin/repository update <identifier>
```
The identifiers available for a repository depend on which options are
configured. Each repository may have several identifiers:
- An **ID** identifier, like `R123`. This is available for all repositories.
- A **callsign** identifier, like `rXY`. This is available for repositories
with a callsign.
- A **short name** identifier, like `xylophone`. This is available for
repositories with a short name.
All three identifiers can be used to refer to the repository in cases where
the intent is unambiguous, but only the first two forms work in ambiguous
contexts.
For example, if you type `R123` or `rXY` into a comment, Phabricator will
recognize them as references to the repository. If you type `xylophone`, it
assumes you mean the word "xylophone".
Only the `R123` identifier is immutable: the others can be changed later by
adjusting the callsign or short name for the repository.
Commit Identifiers
==================
Diffusion uses repository identifiers and information about the commit itself
-to generate globally unique identifers for each commit, like `rE12345`.
+to generate globally unique identifiers for each commit, like `rE12345`.
Each commit may have several identifiers:
- A repository **ID** identifier, like `R123:abcdef123...`.
- A repository **callsign** identifier, like `rXYZabcdef123...`. This only
works if a repository has a callsign.
- Any unique prefix of the commit hash.
Git and Mercurial use commit hashes to identify commits, and Phabricator will
recognize a commit if the hash prefix is unique and sufficiently long. Commit
hashes qualified with a repository identifier must be at least 5 characters
long; unqualified commit hashes must be at least 7 characters long.
In Subversion, commit identifiers are sequential integers and prefixes can not
be used to identify them.
When rendering the name of a Git or Mercurial commit hash, Phabricator tends to
shorten it to 12 characters. This "short length" is relatively long compared to
Git itself (which often uses 7 characters). See this post on the LKML for a
historical explanation of Git's occasional internal use of 7-character hashes:
https://lkml.org/lkml/2010/10/28/287
Because 7-character hashes are likely to collide for even moderately large
repositories, Diffusion generally uses either a 12-character prefix (which makes
collisions very unlikely) or the full 40-character hash (which makes collisions
astronomically unlikely).
Next Steps
==========
Continue by:
- returning to the @{article:Diffusion User Guide}.
diff --git a/src/docs/user/userguide/diffusion_uris.diviner b/src/docs/user/userguide/diffusion_uris.diviner
index d1f4b541bc..140948f55e 100644
--- a/src/docs/user/userguide/diffusion_uris.diviner
+++ b/src/docs/user/userguide/diffusion_uris.diviner
@@ -1,309 +1,309 @@
@title Diffusion User Guide: URIs
@group userguide
Guide to configuring repository URIs for fetching, cloning and mirroring.
Overview
========
Phabricator can create, host, observe, mirror, proxy, and import repositories.
For example, you can:
**Host Repositories**: Phabricator can host repositories locally. Phabricator
maintains the writable master version of the repository, and you can push and
pull the repository. This is the most straightforward kind of repository
configuration, and similar to repositories on other services like GitHub or
Bitbucket.
**Observe Repositories**: Phabricator can create a copy of an repository which
is hosted elsewhere (like GitHub or Bitbucket) and track updates to the remote
repository. This will create a read-only copy of the repository in Phabricator.
**Mirror Repositories**: Phabricator can publish any repository to mirrors,
-overwiting them with an exact copy of the repository that stays up to date as
+overwriting them with an exact copy of the repository that stays up to date as
the source changes. This works with both local repositories that Phabricator is
hosting and remote repositories that Phabricator is observing.
**Proxy Repositories**: If you are observing a repository, you can allow users
to read Phabricator's copy of the repository. Phabricator supports granular
read permissions, so this can let you open a private repository up a little
bit in a flexible way.
**Import Repositories**: If you have a repository elsewhere that you want to
host on Phabricator, you can observe the remote repository first, then turn
the tracking off once the repository fully synchronizes. This allows you to
copy an existing repository and begin hosting it in Phabricator.
You can also import repositories by creating an empty hosted repository and
then pushing everything to the repository directly.
You configure the behavior of a Phabricator repository by adding and
configuring URIs and marking them to be fetched from, mirrored to, clonable,
and so on. By configuring all the URIs that a repository should interact with
and expose to users, you configure the read, write, and mirroring behavior
of the repository.
The remainder of this document walks through this configuration in greater
detail.
Host a Repository
=================
You can create new repositories that Phabricator will host, like you would
create repositories on services like GitHub or Bitbucket. Phabricator will
serve a read-write copy of the repository and you can clone it from Phabricator
and push changes to Phabricator.
If you haven't already, you may need to configure Phabricator for hosting
before you can create your first hosted repository. For a detailed guide,
see @{article:Diffusion User Guide: Repository Hosting}.
This is the default mode for new repositories. To host a repository:
- Create a new repository.
- Activate it.
Phabricator will create an empty repository and allow you to fetch from it and
push to it.
Observe a Repository
====================
If you have an existing repository hosted on another service (like GitHub,
Bitbucket, or a private server) that you want to work with in Phabricator,
you can configure Phabricator to observe it.
When observing a repository, Phabricator will keep track of changes in the
remote repository and allow you to browse and interact with the repository from
the web UI in Diffusion and other applications, but you can continue hosting it
elsewhere.
To observe a repository:
- Create a new repository, but don't activate it yet.
- Add the remote URI you want to observe as a repository URI.
- Set the **I/O Type** for the URI to **Observe**.
- If necessary, configure a credential.
- Activate the repository.
Phabricator will perform an initial import of the repository, creating a local
read-only copy. Once this process completes, it will continue keeping track of
changes in the remote, fetching them, and reflecting them in the UI.
Mirror a Repository
===================
NOTE: Mirroring is not supported in Subversion.
You can create a read-only mirror of an existing repository. Phabricator will
continuously publish the state of the source repository to the mirror, creating
an exact copy.
For example, if you have a repository hosted in Phabricator that you want to
mirror to GitHub, you can configure Phabricator to automatically maintain the
mirror. This is how the upstream repositories are set up.
The mirror copy must be read-only for users because any writes made to the
mirror will be undone when Phabricator updates it. The mirroring process copies
the entire repository state exactly, so the remote state will be completely
replaced with an exact copy of the source repository. This may remove or
destroy information. Normally, you should only mirror to an empty repository.
You can mirror any repository, even if Phabricator is only observing it and not
hosting it directly.
To begin mirroring a repository:
- Create a hosted or observed repository by following the relevant
instructions above.
- Add the remote URI you want to mirror to as a repository URI.
- Set the **I/O Type** for the URI to **Mirror**.
- If necessary, configure a credential.
To stop mirroring:
- Disable the mirror URI; or
- Change the **I/O Type** for the URI to **None**.
Import a Repository
===================
If you have an existing repository that you want to move so it is hosted on
Phabricator, there are three ways to do it:
**Observe First**: //(Git, Mercurial)// Observe the existing repository first,
according to the instructions above. Once Phabricator's copy of the repository
is fully synchronized, change the **I/O Type** for the **Observe** URI to
**None** to stop fetching changes from the remote.
By default, this will automatically make Phabricator's copy of the repository
writable, and you can begin pushing to it. If you've adjusted URI
configuration away from the defaults, you may need to set at least one URI
to **Read/Write** mode so you can push to it.
**Push Everything**: //(Git, Mercurial, Subversion)// Create a new empty hosted
repository according to the instructions above. Once the empty repository
initializes, push your entire existing repository to it.
In Subversion, you can do this with the `svnsync` tool.
**Copy on Disk**: //(Git, Mercurial, Subversion)// Create a new empty hosted
repository according to the instructions above, but do not activate it yet.
Using the **Storage** tab, find the location of the repository's working copy
on disk, and place a working copy of the repository you wish to import there.
For Git and Mercurial, use a bare working copy for best results.
This is the only way to import a Subversion repository because only the master
copy of the repository has history.
Once you've put a working copy in the right place on disk, activate the
repository.
Builtin Clone URIs
==================
By default, Phabricator automatically exposes and activates HTTP, HTTPS and
SSH clone URIs by examining configuration.
**HTTP**: The `http://` clone URI will be available if these conditions are
satisfied:
- `diffusion.allow-http-auth` must be enabled or the repository view policy
must be "Public".
- The repository must be a Git or Mercurial repository.
- `security.require-https` must be disabled.
**HTTPS**: The `https://` clone URI will be available if these conditions are
satisfied:
- `diffusion.allow-http-auth` must be enabled or the repository view policy
must be "Public".
- The repository must be a Git or Mercurial repository.
- The `phabricator.base-uri` protocol must be `https://`.
**SSH**: The `ssh://` or `svn+ssh://` clone URI will be available if these
conditions are satisfied:
- `phd.user` must be configured.
Customizing Displayed Clone URIs
================================
If you have an unusual configuration and want the UI to offers users specific
clone URIs other than the URIs that Phabricator serves or interacts with, you
can add those URIs with the **I/O Type** set to **None** and then set their
**Display Type** to **Always**.
Likewise, you can set the **Display Type** of any URIs you do //not// want
to be visible to **Never**.
This allows you to precisely configure which clone URIs are shown to users for
a repository.
Reference: I/O Types
====================
This section details the available **I/O Type** options for URIs.
Each repository has some **builtin** URIs. These are URIs hosted by Phabricator
itself. The modes available for each URI depend primarily on whether it is a
builtin URI or not.
**Default**: This setting has Phabricator guess the correct option for the
URI.
For **builtin** URIs, the default behavior is //Read/Write// if the repository
is hosted, and //Read-Only// if the repository is observed.
For custom URIs, the default type is //None// because we can not automatically
guess if you want to ignore, observe, or mirror a URI and //None// is the
safest default.
**Observe**: Phabricator will observe this repository and regularly fetch any
changes made to it to a local read-only copy.
You can not observe builtin URIs because reading a repository from itself
does not make sense.
You can not add a URI in Observe mode if an existing builtin URI is in
//Read/Write// mode, because this would mean the repository had two different
authorities: the observed remote copy and the hosted local copy. Take the
other URI out of //Read/Write// mode first.
**Mirror**: Phabricator will push any changes made to this repository to the
remote URI, keeping a read-only mirror hosted at that URI up to date.
This works for both observed and hosted repositories.
This option is not available for builtin URIs because it does not make sense
to mirror a repository to itself.
It is possible to mirror a repository to another repository that is also
hosted by Phabricator by adding that other repository's URI, although this is
silly and probably very rarely of any use.
**None**: Phabricator will not fetch changes from or push changes to this URI.
For builtin URIs, it will not let users fetch changes from or push changes to
this URI.
You can use this mode to turn off an Observe URI after an import, stop a Mirror
URI from updating, or to add URIs that you're only using to customize which
clone URIs are displayed to the user but don't want Phabricator to interact
with directly.
**Read Only**: Phabricator will serve the repository from this URI in read-only
mode. Users will be able to fetch from it but will not be able to push to it.
Because Phabricator must be able to serve the repository from URIs configured
in this mode, this option is only available for builtin URIs.
**Read/Write**: Phabricator will serve the repository from this URI in
read/write mode. Users will be able to fetch from it and push to it.
URIs can not be set into this mode if another URI is set to //Observe// mode,
because that would mean the repository had two different authorities: the
observed remote copy and the hosted local copy. Take the other URI out of
//Observe// mode first.
Because Phabricator must be able to serve the repository from URIs configured
in this mode, this option is only available for builtin URIs.
Reference: Display Types
========================
This section details the available **Display Type** options for URIs.
**Default**: Phabricator will guess the correct option for the URI. It
guesses based on the configured **I/O Type** and whether the URI is
**builtin** or not.
For //Observe//, //Mirror// and //None// URIs, the default is //Never//.
For builtin URIs in //Read Only// or //Read/Write// mode, the most
human-readable URI defaults to //Always// and the others default to //Never//.
**Always**: This URI will be shown to users as a clone/checkout URI. You can
-add URIs in this mode to cutomize exactly what users are shown.
+add URIs in this mode to customize exactly what users are shown.
**Never**: This URI will not be shown to users. You can hide less-preferred
URIs to guide users to the URIs they should be using to interact with the
repository.
Next Steps
==========
Continue by:
- configuring Phabricator to host repositories with
@{article:Diffusion User Guide: Repository Hosting}.
diff --git a/src/docs/user/userguide/drydock.diviner b/src/docs/user/userguide/drydock.diviner
index 44a645bf8a..0d43f7f3f0 100644
--- a/src/docs/user/userguide/drydock.diviner
+++ b/src/docs/user/userguide/drydock.diviner
@@ -1,260 +1,260 @@
@title Drydock User Guide
@group userguide
Drydock, a software and hardware resource manager.
Overview
========
WARNING: Drydock is very new and has many sharp edges. Prepare yourself for
a challenging adventure in unmapped territory, not a streamlined experience
where things work properly or make sense.
Drydock is an infrastructure application that primarily helps other
applications coordinate during complex build and deployment tasks. Typically,
you will configure Drydock to enable capabilities in other applications:
- Harbormaster can use Drydock to host builds.
- Differential can use Drydock to perform server-side merges.
Users will not normally interact with Drydock directly.
If you want to get started with Drydock right away, see
@{article:Drydock User Guide: Quick Start} for specific instructions on
configuring integrations.
What Drydock Does
=================
Drydock manages working copies, hosts, and other software and hardware
resources that build and deployment processes may require in order to perform
useful work.
Many useful processes need a working copy of a repository (or some similar sort
of resource) so they can read files, perform version control operations, or
execute code.
For example, you might want to be able to automatically run unit tests, build a
binary, or generate documentation every time a new commit is pushed. Or you
might want to automatically merge a revision or cherry-pick a commit from a
development branch to a release branch. Any of these tasks need a working copy
of the repository before they can get underway.
These processes could just clone a new working copy when they started and
delete it when they finished. This works reasonably well at a small scale, but
will eventually hit limitations if you want to do things like: expand the build
tier to multiple machines; or automatically scale the tier up and down based on
usage; or reuse working copies to improve performance; or make sure things get
cleaned up after a process fails; or have jobs wait if the tier is too busy.
Solving these problems effectively requires coordination between the processes
doing the actual work.
Drydock solves these scaling problems by providing a central allocation
framework for //resources//, which are physical or virtual resources like a
host or a working copy. Processes which need to share hardware or software can
use Drydock to coordinate creation, access, and destruction of those resources.
Applications ask Drydock for resources matching a description, and it allocates
a corresponding resource by either finding a suitable unused resource or
creating a new resource. When work completes, the resource is returned to the
resource pool or destroyed.
Getting Started with Drydock
============================
In general, you will interact with Drydock by configuring blueprints, which
tell Drydock how to build resources. You can jump into this topic directly
in @{article:Drydock Blueprints}.
For help on configuring specific application features:
- to configure server-side merges from Differential, see
@{article:Differential User Guide: Automated Landing}.
You should also understand the Drydock security model before deploying it
in a production environment. See @{article:Drydock User Guide: Security}.
The remainder of this document has some additional high-level discussion about
how Drydock works and why it works that way, which may be helpful in
understanding the application as a whole.
Drydock Concepts
================
The major concepts in Drydock are **Blueprints**, **Resources**, **Leases**,
and the **Allocator**.
**Blueprints** are configuration that tells Drydock how to create resources:
where it can put them, how to access them, how many it can make at once, who is
allowed to ask for access to them, how to actually build them, how to clean
them up when they are no longer in use, and so on.
Drydock starts without any blueprints. You'll add blueprints to configure
Drydock and enable it to satisfy requests for resources. You can learn more
about blueprints in @{article:Drydock Blueprints}.
**Resources** represent things (like hosts or working copies) that Drydock has
created, is managing the lifecycle for, and can give other applications access
to.
**Leases** are requests for resources with certain qualities by other
applications. For example, Harbormaster may request a working copy of a
particular repository so it can run unit tests.
The **Allocator** is where Drydock actually does work. It works roughly like
this:
- An application creates a lease describing a resource it needs, and
uses this lease to ask Drydock for an appropriate resource.
- Drydock looks at free resources to try to find one it can use to satisfy
the request. If it finds one, it marks the resource as in use and gives
the application details about how to access it.
- If it can't find an appropriate resource that already exists, it looks at
the blueprints it has configured to try to build one. If it can, it creates
a new resource, then gives the application access to it.
- Once the application finishes using the resource, it frees it. Depending
on configuration, Drydock may reuse it, destroy it, or hold onto it and
make a decision later.
Some minor concepts in Drydock are **Slot Locks** and **Repository Operations**.
**Slot Locks** are simple optimistic locks that most Drydock blueprints use to
avoid race conditions. Their design is not particularly interesting or novel,
they're just a fairly good fit for most of the locking problems that Drydock
blueprints tend to encounter and Drydock provides APIs to make them easy to
work with.
**Repository Operations** help other applications coordinate writes to
repositories. Multiple applications perform similar kinds of writes, and these
writes require more sequencing/coordination and user feedback than other
operations.
Architecture Overview
=====================
This section describes some of Drydock's design goals and architectural
choices, so you can understand its strengths and weaknesses and which problem
domains it is well or poorly suited for.
A typical use case for Drydock is giving another application access to a
working copy in order to run a build or unit test operation. Drydock can
satisfy the request and resume execution of application code in 1-2 seconds
under reasonable conditions and with moderate tradeoffs, and can satisfy a
large number of these requests in parallel.
**Scalable**: Drydock is designed to scale easily to something in the realm of
thousands of hosts in hundreds of pools, and far beyond that with a little
work.
Drydock is intended to solve resource management problems at very large scales
-and minimzes blocking operations, locks, and artificial sequencing. Drydock is
+and minimizes blocking operations, locks, and artificial sequencing. Drydock is
designed to fully utilize an almost arbitrarily large pool of resources and
improve performance roughly linearly with available hardware.
Because the application assumes that deployment at this scale and complexity
level is typical, you may need to configure more things and do more work than
you would under the simplifying assumptions of small scale.
**Heavy Resources**: Drydock assumes that resources are relatively
heavyweight and and require a meaningful amount (a second or more) of work to
build, maintain and tear down. It also assumes that leases will often have
substantial lifespans (seconds or minutes) while performing operations.
Resources like working copies (which typically take several seconds to create
with a command like `git clone`) and VMs (which typically take several seconds
to spin up) are good fits for Drydock and for the problems it is intended to
solve.
Lease operations like running unit tests, performing builds, executing merges,
generating documentation and running temporary services (which typically last
at least a few seconds) are also good fits for Drydock.
In both cases, the general concern with lightweight resources and operations is
that Drydock operation overhead is roughly on the order of a second for many
tasks, so overhead from Drydock will be substantial if resources are built and
torn down in a few milliseconds or lease operations require only a fraction of
a second to execute.
As a rule of thumb, Drydock may be a poor fit for a problem if operations
typically take less than a second to build, execute, and destroy.
**Focus on Resource Construction**: Drydock is primarily solving a resource
construction problem: something needs a resource matching some description, so
Drydock finds or builds that resource as quickly as possible.
Drydock generally prioritizes responding to requests quickly over other
concerns, like minimizing waste or performing complex scheduling. Although you
can make adjustments to some of these behaviors, it generally assumes that
resources are cheap compared to the cost of waiting for resource construction.
This isn't to say that Drydock is grossly wasteful or has a terrible scheduler,
just that efficient utilization and efficient scheduling aren't the primary
problems the design focuses on.
This prioritization corresponds to scenarios where resources are something like
hosts or working copies, and operations are something like builds, and the cost
of hosts and storage is small compared to the cost of engineer time spent
waiting on jobs to get scheduled.
Drydock may be a weak fit for a problem if it is bounded by resource
availability and using resources as efficiently as possible is very important.
Drydock generally assumes you will respond to a resource deficit by making more
resources available (usually very cheap), rather than by paying engineers to
wait for operations to complete (usually very expensive).
**Isolation Tradeoffs**: Drydock assumes that multiple operations running at
similar levels of trust may be interested in reducing isolation to improve
performance, reduce complexity, or satisfy some other similar goal. It does not
guarantee isolation and assumes most operations will not run in total isolation.
If this isn't true for your use case, you'll need to be careful in configuring
Drydock to make sure that operations are fully isolated and can not interact.
Complete isolation will reduce the performance of the allocator as it will
generally prevent it from reusing resources, which is one of the major ways it
can improve performance.
You can find more discussion of these tradeoffs in
@{article:Drydock User Guide: Security}.
**Agentless**: Drydock does not require an agent or daemon to be installed on
hosts. It interacts with hosts over SSH.
**Very Abstract**: Drydock's design is //extremely// abstract. Resources have
very little hardcoded behavior. The allocator has essentially zero specialized
knowledge about what it is actually doing.
One aspect of this abstractness is that Drydock is composable, and solves
complex allocation problems by //asking itself// to build the pieces it needs.
To build a working copy, Drydock first asks itself for a suitable host. It
solves this allocation sub-problem, then resolves the original request.
This allows new types of resources to build on Drydock's existing knowledge of
resource construction by just saying "build one of these other things you
already know how to build, then apply a few adjustments". This also means that
you can tell Drydock about a new way to build hosts (say, bring up VMs from a
different service provider) and the rest of the pipeline can use these new
hosts interchangeably with the old hosts.
While this design theoretically makes Drydock more powerful and more flexible
than a less abstract approach, abstraction is frequently a double-edged sword.
Drydock is almost certainly at the extreme upper end of abstraction for tools
in this space, and the level of abstraction may ultimately match poorly with a
particular problem domain. Alternative approaches may give you more specialized
and useful tools for approaching a given problem.
Next Steps
==========
Continue by:
- understanding Drydock security concerns with
@{article:Drydock User Guide: Security}; or
- learning about blueprints in @{article:Drydock Blueprints}; or
- allowing Phabricator to write to repositories with
@{article:Drydock User Guide: Repository Automation}.
diff --git a/src/docs/user/userguide/drydock_blueprints.diviner b/src/docs/user/userguide/drydock_blueprints.diviner
index 21c4342fa6..c150c503d9 100644
--- a/src/docs/user/userguide/drydock_blueprints.diviner
+++ b/src/docs/user/userguide/drydock_blueprints.diviner
@@ -1,80 +1,80 @@
@title Drydock Blueprints
@group userguide
Overview of Drydock blueprint types.
Overview
========
IMPORTANT: Drydock is not a mature application and may be difficult to
configure and use for now.
Drydock builds and manages various hardware and software resources, like
hosts and repository working copies. Other applications can use these resources
to perform useful work (like running tests or builds).
For additional discussion of Drydock, see @{article:Drydock User Guide}.
Drydock can't create any resources until you configure it. You'll configure
Drydock by creating **Blueprints**. Each blueprint tells Drydock how to build
a specific kind of resource, how many it is allowed to build, where it should
build them, who is authorized to request them, and so on.
You can create a new blueprint in Drydock from the web UI:
{nav Drydock > Blueprints > New Blueprint}
Each blueprint builds resources of a specific type, like hosts or repository
working copies. Detailed topic guides are available for each resource type:
**Hosts**: Hosts are the building block for most other resources. For details,
see @{article:Drydock Blueprints: Hosts}.
**Working Copies**: Working copies allow Drydock to perform repository
operations like running tests, performing builds, and handling merges.
Authorizing Access
==================
Before objects in other applications can use a blueprint, the blueprint must
authorize them.
This mostly serves to prevent users with limited access from executing
operations on trusted hosts. For additional discussion, see
@{article:Drydock User Guide: Security}.
This also broadly prevents Drydock from surprising you by coming up with a
valid but unintended solution to an allocation problem which runs some
-operation on resources that are techincally suitable but not desirable. For
+operation on resources that are technically suitable but not desirable. For
example, you may not want your Android builds running on your iPhone build
tier, even if there's no technical reason they can't.
You can review active authorizations and pending authorization requests in
the "Active Authorizations" section of the blueprint detail screen.
To approve an authorization, click it and select {nav Approve Authorization}.
Until you do, the requesting object won't be able to access resources from
the blueprint.
You can also decline an authorization. This prevents use of resources and
removes it from the authorization approval queue.
Disabling Blueprints
====================
You can disable a blueprint by selecting {nav Disable Blueprint} from the
blueprint detail screen.
Disabled blueprints will no longer be used for new allocations. However,
existing resources will continue to function.
Next Steps
==========
Continue by:
- returning to the @{article:Drydock User Guide}.
diff --git a/src/docs/user/userguide/drydock_security.diviner b/src/docs/user/userguide/drydock_security.diviner
index f12586ab35..9a212437c7 100644
--- a/src/docs/user/userguide/drydock_security.diviner
+++ b/src/docs/user/userguide/drydock_security.diviner
@@ -1,209 +1,209 @@
@title Drydock User Guide: Security
@group userguide
Understanding security concerns in Drydock.
Overview
========
Different applications use Drydock for different things, and some of the things
they do with Drydock require different levels of trust and access. It is
important to configure Drydock properly so that less trusted code can't do
anything you aren't comfortable with.
For example, running unit tests on Drydock normally involves running relatively
untrusted code (it often has a single author and has not yet been reviewed)
that needs very few capabilities (generally, it only needs to be able to report
results back to Phabricator). In contrast, automating merge requests on Drydock
involves running trusted code that needs more access (it must be able to write
to repositories).
Drydock allows resources to be shared and reused, so it's possible to configure
Drydock in a way that gives untrusted code a lot of access by accident.
One way Drydock makes allocations faster is by sharing, reusing, and recycling
resources. When an application asks Drydock for a working copy, it will try to
satisfy the request by cleaning up a suitable existing working copy if it can,
instead of building a new one. This is faster, but it means that tasks have
some ability to interact or interfere with each other.
Similarly, Drydock may allocate multiple leases on the same host at the same
time, running as the same user. This is generally simpler to configure and less
wasteful than fully isolating leases, but means that they can interact.
Depending on your organization, environment and use cases, you might not want
this, and it may be important that different use cases are unable to interfere
with each other. For example, you might want to prevent unit tests from writing
to repositories.
**Drydock does not guarantee that resources are isolated by default**. When
resources are more isolated, they are usually also harder to configure and
slower to allocate. Because most installs will want to find a balance between
isolation and complexity/performance, Drydock does not make assumptions about
either isolation or performance having absolute priority.
You'll usually want to isolate things just enough that nothing bad can happen.
Fortunately, this is straightforward. This document describes how to make sure
you have enough isolation so that nothing you're uncomfortable with can occur.
Choosing an Isolation Policy
============================
This section provides some reasonable examples of ways you might approach
configuring Drydock.
| Isolation | Suitable For | Description
|-----------|-----|-------
| Zero | Development | Everything on one host.
| Low | Small Installs | Use a dedicated Drydock host.
| High | Most Installs | **Recommended**. Use low-trust and high-trust pools.
| Custom | Special Requirements | Use multiple pools.
| Absolute | Special Requirements | Completely isolate all resources.
**Zero Isolation**: Run Drydock operations on the same host that Phabricator
runs on. This is only suitable for developing or testing Phabricator. Any
Drydock operation can potentially compromise Phabricator. It is intentionally
difficult to configure Drydock to operate in this mode. Running Drydock
operations on the Phabricator host is strongly discouraged.
**Low Isolation**: Designate a separate Drydock host and run Drydock
operations on it. This is suitable for small installs and provides a reasonable
level of isolation. However, it will allow unit tests (which often run
lower-trust code) to interfere with repository automation operations.
**High Isolation**: Designate two Drydock host pools and run low-trust
operations (like builds) on one pool and high-trust operations (like repository
automation) on a separate pool. This provides a good balance between isolation
and performance, although tests can still potentially interfere with the
execution of unrelated tests.
**Custom Isolation**: You can continue adding pools to refine the resource
isolation model. For example, you may have higher-trust and lower-trust
repositories or do builds on a mid-trust tier which runs only reviewed code.
**Absolute Isolation**: Configure blueprints to completely initialize and
destroy hosts or containers on every request, and limit all resources to one
simultaneous lease. This will completely isolate every operation, but come at
a high performance and complexity cost.
NOTE: It is not currently possible to configure Drydock in an absolute
isolation mode.
It is usually reasonable to choose one of these approaches as a starting point
and then adjust it to fit your requirements. You can also evolve your use of
Drydock over time as your needs change.
Threat Scenarios
================
This section will help you understand the threats to a Drydock environment.
Not all threats will be concerning to all installs, and you can choose an
approach which defuses only the threats you care about.
Attackers have three primary targets:
- capturing hosts;
- compromising Phabricator; and
- compromising the integrity of other Drydock processes.
**Attacks against hosts** are the least sophisticated. In this scenario, an
attacker wants to run a program like a Bitcoin miner or botnet client on
hardware that they aren't paying for or which can't be traced to them. They
write a "unit test" or which launches this software, then send a revision
containing this "unit test" for review. If Phabricator is configured to
automatically run tests on new revisions, it may execute automatically and give
the attacker access to computing resources they did not previously control and
which can not easily be traced back to them.
This is usually only a meaningful threat for open source installs, because
there is a high probability of eventual detection and the value of these
resources is small, so employees will generally not have an incentive to
attempt this sort of attack. The easiest way to prevent this attack is to
prevent untrusted, anonymous contributors from running tests. For example,
create a "Trusted Contributors" project and only run tests if a revision author
is a member of the project.
**Attacks against Phabricator** are more sophisticated. In this scenario, an
attacker tries to compromise Phabricator itself (for example, to make themselves
an administrator or gain access to an administrator account).
This is made possible if Drydock is running on the same host as Phabricator or
runs on a privileged subnet with access to resources like Phabricator database
hosts. Most installs should be concerned about this attack.
The best way to defuse this attack is to run Drydock processes on a separate
host which is not on a privileged subnet. For example, use a
`build.mycompany.com` host or pool for Drydock processes, separate from your
`phabricator.mycompany.com` host or pool.
Even if the host is not privileged, many Drydock processes have some level of
privilege (enabling them to clone repositories, or report test results back to
Phabricator). Be aware that tests can hijack credentials they are run with,
-and potentialy hijack credentials given to other processes on the same hosts.
+and potentially hijack credentials given to other processes on the same hosts.
You should use credentials with a minimum set of privileges and assume all
processes on a host have the highest level of access that any process on the
host has.
**Attacks against Drydock** are the most sophisticated. In this scenario, an
attacker uses one Drydock process to compromise a different process: for
example, a unit test which tampers with a merge or injects code into a build.
This might allow an attacker to make changes to a repository or binary without
going through review or triggering other rules which would normally detect the
change.
These attackers could also make failing tests appear to pass, or break tests or
builds, but these attacks are generally less interesting than tampering with
a repository or binary.
This is a complex attack which you may not have to worry about unless you have
a high degree of process and control in your change pipeline. If users can push
changes directly to repositories, this often represents a faster and easier way
to achieve the same tampering.
The best way to defuse this attack is to prevent high-trust (repository
automation) processes from running on the same hosts as low-trust (unit test)
processes. For example, use an `automation.mycompany.com` host or pool for
repository automation, and a `build.mycompany.com` host or pool for tests.
Applying an Isolation Policy
============================
Designing a security and isolation policy for Drydock can take some thought,
but applying it is straightforward. Applications which want to use Drydock must
explicitly list which blueprints they are allowed to use, and they must be
approved to use them in Drydock. By default, nothing can do anything, which is
very safe and secure.
To get builds or automation running on a host, specify the host blueprint as a
usable blueprint in the build step or repository configuration. This creates a
new authorization request in Drydock which must be approved before things can
move forward.
Until the authorization is approved, the process can not use the blueprint to
create any resources, nor can it use resources previously created by the
blueprint.
You can review and approve requests from the blueprint detail view in Drydock:
find the request and click {nav Approve Authorization}. You can also revoke
approval at any time from this screen which will prevent the object from
continuing to use the blueprint (but note that this does not release any
existing leases).
Once the authorization request is approved, the build or automation process
should be able to run if everything else is configured properly.
Note that authorizations are transitive: if a build step is authorized to use
blueprint A, and blueprint A is authorized to use blueprint B, the build step
may indirectly operate on resources created by blueprint B. This should
normally be consistent with expectations.
Next Steps
==========
Continue by:
- returning to the @{article:Drydock User Guide}.
diff --git a/src/docs/user/userguide/forms.diviner b/src/docs/user/userguide/forms.diviner
index 98233e4931..034293ff29 100644
--- a/src/docs/user/userguide/forms.diviner
+++ b/src/docs/user/userguide/forms.diviner
@@ -1,515 +1,515 @@
@title User Guide: Customizing Forms
@group userguide
Guide to prefilling and customizing forms in Phabricator applications.
Overview
========
In most applications, objects are created by clicking a "Create" button from
the main list view, and edited by clicking an "Edit" link from the main detail
view. For example, you create a new task by clicking "Create Task", and edit it
by clicking "Edit Task".
The forms these workflows use can be customized to accommodate a number of
different use cases. In particular:
**Prefilling**: You can use HTTP GET parameters to prefill fields or copy
fields from another object. This is a lightweight way to create a link with
some fields set to initial values. For example, you might want to create a
link to create a task which has some default projects or subscribers.
**Custom Forms**: You can create custom forms which can have default values;
locked, hidden, and reordered fields; and additional instructions. This can let
you make specialized forms for creating certain types of objects, like a
"New Bug Report" form with extra help text or a "New Security Issue" form with
locked policies.
**"Create" Defaults**: You can change the default form available to users for
creating objects, or provide multiple default forms for them to choose between.
This can let you simplify or specialize the creation process.
**"Edit" Defaults**: You can change the default form users are given to edit
objects, which will also affect their ability to take inline actions in the
comment form if you're working in an application which supports comments. This
can streamline the edit workflow for less experienced users.
-Anyone can use prefiling, but you must have permission to configure an
+Anyone can use prefilling, but you must have permission to configure an
application in order to modify the application's forms. By default, only
administrators can configure applications.
The remainder of this document walks through configuring these features in
greater detail.
Supported Applications
======================
These applications currently support form customization:
| Application | Support |
|-------------------|---------|
| Maniphest | Full
| Owners | Full
| Paste | Full
| ApplicationEditor | Meta
This documentation is geared toward use in Maniphest because customizing task
creation flows is the most common use case for many of these features, but the
features discussed here work in any application with support.
These features first became available in December 2015. Additional applications
will support them in the future.
Internally, this infrastructure is called `ApplicationEditor`, and the main
component is `EditEngine`. You may see technical documentation, changelogs, or
internal discussion using these terms.
Prefilling
==========
You can prefill the fields in forms by providing HTTP parameters. For example,
if a form has a "Projects" field, you can generally prefill it by adding a
`projects` parameter to the URI like this:
```
https://your.install.com/application/edit/?projects=skunkworks
```
The parameters available in each application vary, and depend on which fields
the application supports.
For full documentation on a particular form, navigate to that form (by
selecting the "Create" or "Edit" action in the application) and then use
{nav Actions > Show HTTP Parameters} to see full details on which parameters
you can use and how to specify them.
You can also use the `template` parameter to copy fields from an existing
object that you have permission to see. Which fields are copied depend on the
application, but usually content fields (like a name or title) are not copied
while other fields (like projects, subscribers, and object states) are.
The {nav Show HTTP Parameters} page has a full list of which fields will be
copied.
You can combine the `template` parameter with other prefilling. The `template`
will act first, then prefilling will take effect. This allows you to overwrite
template values with prefilled values.
Some use cases for this include:
**Lightweight Integrations**: If you want to give users a way to file tasks from
an external application, this is an easy way to get a basic integration
working. For example, you might have a tool for reviewing error logs in
production that has a link to "File a Bug" about an error. The link could
prefill the `title`, `body` and `projects` fields with details about the log
message and a link back into the external tool.
**Convenience**: You can create lightweight, ad-hoc links that make taking
actions a little easier for users. For example, if you're sending out an email
about a change you just made to a lot of people, you could include instructions
like "If you run into any issues, assign a task to me with details: ..." and
include a link which prefills you as the task assignee.
**Searchbar Commands**: If you use a searchbar plugin which gives you shortcut
commands, you can write a custom shortcut so a command like "bug ..." can
quickly redirect you to a prefilled form.
Creating New Forms
==================
Beyond prefilling forms with HTTP parameters, you can create and save form
configurations. This is more heavyweight than prefilling and allows you to
customize, streamline, or structure a workflow more heavily.
You must be able to configure an application in order to manage its forms.
Form configurations can have special names (like "New Bug Report") and
additional instruction text, and may prefill, lock, hide, and reorder fields.
Prefilling and templating still work with custom form configurations, but only
apply to visible fields.
To create a new form configuration, navigate to an existing form via "Create"
or "Edit" and choose {nav Actions > View Form Configurations}. This will show
you a list of current configurations.
You can also edit existing configurations, including the default configuration.
You can use {nav Create Form} from this screen to create a new configuration.
After setting some basic information you will be able to lock, hide, and
reorder form fields, as well as set defaults.
Clicking {nav Use Form} will take you to the permanent URI for this form. You
can link to this form from elsewhere to take the user directly to your
custom flow.
You can adjust defaults using {nav Change Default Values}. These defaults are
saved with the form, and do not require HTTP parameter prefilling. However,
they work in conjunction with prefilling, and you can use prefilling or
templating to overwrite the defaults for visible fields.
If you set a default value for a field and lock or hide the field, the default
you set will still be respected and can not be overridden with templating
or prefilling. This allows you to force certain forms to create tasks with
specific field values, like projects or policies.
You can also set a view policy for a form. Only users who are able to view the
form can use it to create objects.
There are some additional options ("Mark as Create Form" and
"Mark as Edit Form") which are more complicated and explained in greater
detail later in this document.
Some use cases for this include:
**Tailoring Workflows**: If you have certain intake workflows like
"New Bug Report" or "New Security Issue", you can create forms for them with
more structure than the default form.
You can provide detailed instructions and links to documentation in the
"Preamble" for the form configuration. You might use this to remind users about
reporting guidelines, help them fill out the form correctly, or link to other
resources.
You can hide fields that aren't important to simplify the workflow, or reorder
fields to emphasize things that are important. For example, you might want to
hide the "Priority" field on a bug report form if you'd like all bugs to come
in at the default priority before they are triaged.
You can set default view and edit policies, and optionally lock or hide those
fields. This allows you to create a form that is locked to certain policy
settings.
**Simplifying Forms**: If you rarely (or never) use some object fields, you can
create a simplified form by hiding the fields you don't use regularly, or
hide these fields completely from the default form.
Changing Creation Defaults
=========================
You can control which form or forms are presented to users by default when
they go to create new objects in an application.
Using {nav Mark as "Create" Form} from the detail page for a form
configuration, you can mark a form to appear in the create menu.
When a user visits the application, Phabricator finds all the form
configurations that are:
- marked as "create" forms; and
- visible to the user based on policy configuration; and
- enabled.
If there is only one such form, Phabricator renders a single "Create" button.
(If there are zero forms, it renders the button but disables it.)
If there are several such forms, Phabricator renders a dropdown which allows
the user to choose between them.
You can reorder these forms by returning to the configuration list and using
{nav Reorder Create Forms} in the left menu.
This logic is also used to select items for the global "Quick Create" menu
in the main menu bar.
Some use cases for this include:
**Simplification**: You can modify the default form to reorder fields, add
instructions, or hide fields you never use.
**Multiple Intake Workflows**: If you have multiple distinct intake workflows
like "New Bug Report" and "New Security Issue", you can mark several forms
as "Create" forms and users will be given a choice between them when they go
to create a task.
These flows can provide different instructions and defaults to help users
provide the desired information correctly.
**Basic and Advanced Workflows**: You can create a simplified "Basic" workflow
which hides or locks some fields, and a separate "Advanced" workflow which
has all of the fields.
If you do this, you can also restrict the visibility policy for the "Advanced"
form to experienced users. If you do, newer users will see a button which
takes them to the basic form, while advanced users will be able to choose
between the basic and advanced forms.
Changing Editing Defaults
=========================
You can control which form users are taken to when they click "Edit" on an
object detail page.
Using {nav Mark as "Edit" Form} from the detail page for a form configuration,
you can mark a form as a default edit form.
When a user goes to edit an object, they are taken to the first form which is:
- marked as an "edit" form; and
- visible to them; and
- enabled.
You can reorder forms by going up one level and using {nav Reorder Edit Forms}
in the left menu. This will let you choose which forms have precedence if
a user has access to multiple edit forms.
The default edit form also controls which which actions are available inline
in the "Comment" form at the bottom of the detail page, for applications which
support comments. If you hide or lock a field, corresponding actions will not
be available.
Some use cases for this include:
**Simplification**: You can modify the default form to reorder fields, add
instructions, or hide fields you never use.
By default, applications tend to have just one form, which is both an edit form
and a create form. You can split this into two forms (one edit form and one
create form) and then simplify the create form without affecting the edit
form.
You might do this if there are some fields you still want access to that you
never modify when creating objects. For example, you might always want to
create tasks with status "Open", and just hide that field from from the create
form completely. A separate edit form can still give you access to these fields
if you want to adjust them later.
**Basic and Advanced Workflows**: You can create a basic edit form (with fewer
fields available) and an advanced edit form, then restrict access to the
advanced form to experienced users.
By ordering the forms as "Advanced", then "Basic", and applying a view policy
to the "Advanced" form, you can send experienced users to the advanced form
and less experienced users to the basic form.
For example, you might use this to hide policy controls or task priorities from
inexperienced users.
Understanding Policies
======================
IMPORTANT: Simplifying workflows by restricting access to forms and fields does
**not** enforce policy controls for those fields.
The configurations described above which simplify workflows are advisory, and
are intended to help users complete workflows quickly and correctly. A user who
has very limited access to an application through forms will generally still be
able to use other workflows (like Conduit, Herald, Workboards, email, and other
applications and integrations) to directly or indirectly modify fields.
For example, even if you lock a user out of all the forms in an application
that have a "Subscribers" field, they can still add subscribers indirectly by
using `@username` mentions.
We do not currently plan to change this or introduce enforced, platform-wide
field-level policy controls. These form customization features are generally
aimed at helping well-intentioned but inexperienced users complete workflows
quickly and correctly.
Disabling Form Configurations
=============================
You can disable a form configuration from the form configuration details screen,
by selecting {nav Disable Form}.
Disabled forms do not appear in any menus by default, and can not be used to
create or edit objects.
Use Case: Specialized Report Form
=================================
A project might want to provide a specialized bug report form for a specific
type of issue. For example, if you have an Android application, you might have
an internal link in that application for employees to "Report a Bug".
A simple way to do this would be to link to the default form and use HTTP
parameter prefilling to set a project. You might end up with a link like this
one:
```
https://your.install.com/maniphest/task/edit/?projects=android
```
A slightly more advanced method is to create a template task, then use it to
prefill the form. For example, you might set some projects, subscribers, and
custom field values on the template task. Then have the application link to
the a URI that prefills using the template:
```
https://your.install.com/maniphest/task/edit/?template=123
```
This is a little easier to use, and lets you update the template later if you
want to change anything about the defaults that the new tasks are created
with.
An even more advanced method is to create a new custom form configuration.
You could call this something like "New Android Bug Report". In addition to
setting defaults, you could lock, hide, or reorder fields so that the form
only presents the fields that are relevant to the workflow. You could also
provide instructions to help users file good reports.
After customizing your form configuration, you'd link to the {nav Use Form}
URI, like this:
```
https://your.install.com/maniphest/task/edit/form/123/
```
You can also combine this with templating or prefilling to further specialize
the flow.
Use Case: Simple Report Flow
============================
An open source project might want to give new users a simpler bug report form
with fewer fields and more instructions.
To do this, create a custom form and configure it so it has only the relevant
fields and includes any instructions. Once it looks good, mark it as a "Create"
form.
The "Create Task" button should now change into a menu and show both the
default form and the new simpler form, as well as in the global "Quick Create"
menu in the main menu bar.
If you prefer the fields appear in a different order, use
{nav Reorder Create Forms} to adjust the display order. (You could also rename
the default creation flow to something like "Create Advanced Task" to guide
users toward the best form).
Use Case: Basic and Advanced Users
==================================
An open source project or a company with a mixture of experienced and less
experienced users might want to give only some users access to adjust advanced
fields like "View Policy" and "Edit Policy" when creating tasks.
Before configuring things like this, make sure you review "Understanding
Policies" above.
To do this, first customize four forms:
- Basic Create
- Advanced Create
- Basic Edit
- Advanced Edit
You can customize these however you'd like.
The "Advanced" forms should have more fields, while the "Basic" forms should
be simpler. You may want to add additional instructions to the "Basic Create"
form.
Then:
- Mark the two "Create" forms as create forms.
- Mark the two "Edit" forms as edit forms.
- Limit the visibility of the two "Advanced" forms to only advanced users
(for example, "Members of Project: Elite Strike Force").
- Use {nav Reorder Edit Forms} to make sure the "Advanced" edit form is at
the top of the list. The first visible form on this list will be used, so
this makes sure advanced users see the advanced edit form.
Basic users should now only have access to basic fields when creating, editing,
and commenting on tasks, while advanced users will retain full access.
Use Case: Security Issues
=========================
If you want to make sure security issues are reported with the correct
policies, you can create a "New Security Issue" form. On this form, prefill the
View and Edit policies and lock or hide them, then lock or hide any additional
fields (like projects or subscribers) that you don't want users to adjust. You
might use a custom policy like this for both the View and Edit policies:
> Allow: Members of Project "Security"
> Allow: Task Author
> Deny all other users
This will make it nearly impossible for users to make policy mistakes, and will
prevent other users from observing these tasks indirectly through Herald rules.
You should review "Understanding Policies" above before pursuing this. In
particular, note that the author may still be able to leak information about
the report like this:
- if they have access to a full-power edit form, they can edit the task
//after// creating it and open the policies; or
- regardless of their edit form access, they can use the Conduit API to
change the task policy; or
- regardless of any policy controls in Phabricator, they can screenshot,
print, or forward email about the task to anyone; or
- regardless of any technical controls in any software, they can decline to
report the issue to you in the first place and sell it on the black market
instead.
This goals of this workflow are to:
- prevent other users from observing security issues improperly through
mechanisms like Herald; and
- prevent mistakes by well-meaning reporters who are unfamiliar with
the software.
It is **not** aimed at preventing reporters who are already in possession of
information from //intentionally// disclosing that information, since they have
many other channels by which to do this anyway and no software can ever prevent
it.
Use Case: Upstream
==================
This section describes the upstream configuration circa December 2015. The
current configuration may not be exactly the same as the one described below.
We run an open source project with a small core team, a moderate number
of regular contributors, and a large public userbase. Access to the upstream
Phabricator instance is open to the public.
Although our product is fairly technical, we receive many bug reports and
feature requests which are of very poor quality. Some users also ignore all the
documentation and warnings and use the upstream instance as a demo/test
instance to click as many buttons as they can.
The goals of our configuration are:
- Provide highly structured "New Bug Report" and "New Feature Request"
workflows which make things as easy as possible to get right, in order
to improve the quality of new reports.
- Separate the userbase into "basic" and "advanced" users. Give the
basic users simpler, more streamlined workflows, to make expectations
more clear, improve report quality, and limit collateral damage from
testing and fiddling.
To these ends, we've configured things like this:
**Community Project**: Advanced users are added to a "Community" project, which
gives them more advanced access. Advanced forms are "Visible To: Members of
Project Community".
**Basic and Advanced Edit**: We have basic and advanced task edit forms.
Members of the community project get access to the advanced one, while other
users only have access to the basic one.
**Bug, Feature and Advanced Create**: We have "New Bug", "New Feature" and
"New Advanced Task" creation forms.
The advanced form is the standard creation form, and is only accessible to
community members.
The basic forms have fewer fields, and each form provides tailored instructions
which point users at relevant documentation to help them provide good reports.
The basic versions of these forms also have their "Edit Policy" locked down to
members of the "Community" project and the task author. This means that users
generally can't mess around with other users' reports, but more experienced
users can still help manage and resolve tasks.
diff --git a/src/docs/user/userguide/herald.diviner b/src/docs/user/userguide/herald.diviner
index 6c5c6239cb..c5a008474d 100644
--- a/src/docs/user/userguide/herald.diviner
+++ b/src/docs/user/userguide/herald.diviner
@@ -1,140 +1,140 @@
@title Herald User Guide
@group userguide
Use Herald to get notified of changes you care about.
Overview
========
Herald allows you to write rules which run automatically when objects (like
tasks or commits) are created or updated. For instance, you might want to get
notified every time someone sends out a revision that affects some file you're
interested in, even if they didn't add you as a reviewer.
One way to think about Herald is that it is a lot like the mail rules you can
set up in most email clients to organize mail based on "To", "Subject", etc.
Herald works very similarly, but operates on Phabricator objects (like revisions
and commits) instead of emails.
For example, you can write a personal rule like this which triggers on tasks:
> When [ all of ] these conditions are met:
> [ Title ][ contains ][ quasar ]
> Take these actions [ every time ] this rule matches:
> [ Add me as a subscriber ]
This rule will automatically subscribe you to any newly created or updated
tasks that contain "quasar" in the title.
Herald rules are often used to: notify users, add reviewers, initiate audits,
classify objects, block commits, enforce CLAs, and run builds.
Working with Rules
==================
To create new Herald rules, navigate to the {nav Herald} application and select
{nav Create Herald Rule}.
Next, you'll choose an event that you want to write a rule for: for example,
a rule for when commits are discovered or a rule for when tasks are created or
updated.
After selecting an event, choose the type of rule to create. See "Rule Types"
below for a more detailed discussion.
Name the rule and provide conditions and actions. When events occur, the rule
will be evaluated automatically. If the conditions pass, the actions will be
taken.
To test rules, use {nav Herald > Test Console}. See "Testing Rules" below
for greater detail.
To review which rules did or did not trigger for a particular event (and why),
see {nav Herald > Transcripts}.
Rule Types
==========
You can create three kinds of Herald rules: personal rules, object rules, and
global rules.
- **Personal Rules** are rules owned by an individual. They're often used
to keep people informed about changes they're interested in.
- **Object Rules** are rules associated with an object (like a repository
or project). These are similar to global rules.
- **Global Rules** are apply to all objects. They're often used to block
commits or run builds.
Rule Policies
=============
All Herald rules are always visible to all users.
The edit policy for a rule depends on what type of rule it is:
- Personal rules are owned by a particular user, and can only be created or
edited by that user.
- Object rules are associated with a particular object (like a repository),
and can only be created or edited by users who can edit that object. That
is, if you can edit a repository, you can also create object rules for it
and edit existing object rules.
- Global rules are administrative and can only be created or edited by users
with the **Can Manage Global Rules** Herald application permission.
When rules are about to evaluate, they may first perform some policy tests.
- Personal rules check if the owning user can see the object which the rule
is about to run on. If the user can not see the object, the rule does not
run. This prevents individuals from writing rules which give them access
to information they don't have permission to see.
- Object and global rules **bypass policies** and always execute. This makes
them very powerful, and is why the **Can Manage Global Rules** policy is
restricted by default.
Testing Rules
=============
When you've created a rule, use the {nav Herald > Test Console} to test it out.
Enter an object name (like `D123`, `rXYZabcdef`, or `T456`) and Herald will
execute a dry run against that object, showing you which rules //would// match
had it actually been updated. Dry runs executed via the test console don't take
any actions.
Advanced Herald
===============
A few features in Herald are particularly complicated or unintuitive.
Condition **matches regexp pair**: Some conditions allow you to select the
operator "matches regexp pair". For example, you can write a rule against
revisions like this one:
> When [ all of ] these conditions are met:
> [ Changed file content ][ matches regexp pair ][ ... ]
This condition allows you to specify two regexes in JSON format. The first will
be used to match the filename of the changed file; the second will be used to
match the content. You can use these together to express conditions like
"content in Javascript files".
For example, if you want to match revisions which add or remove calls to a
"muffinize" function, //but only in JS files//, you can set the value to
`["/\\.js$/", "/muffinize/"]` or similar. This condition is satisfied only
-when the filename matches the first expression and the conent matches the
+when the filename matches the first expression and the content matches the
second expression.
**Another Herald rule**: you can create Herald rules which depend on other
rules.
This can be useful if you need to express a more complicated condition
than "all" vs "any" allows, or have a common set of conditions which you want
to share between several rules.
If a rule is only being used as a group of conditions, you can set the action
to "Do Nothing".
diff --git a/src/docs/user/userguide/jump.diviner b/src/docs/user/userguide/jump.diviner
index 413c4fa47b..0421f194a8 100644
--- a/src/docs/user/userguide/jump.diviner
+++ b/src/docs/user/userguide/jump.diviner
@@ -1,39 +1,39 @@
@title Search User Guide: Shortcuts
@group userguide
-Command reference for global search shorcuts.
+Command reference for global search shortcuts.
Overview
========
Phabricator's global search bar automatically interprets certain commands as
shortcuts to make it easy to navigate to specific places.
To use these shortcuts, just type them into the global search bar in the main
menu and press return. For example, enter `T123` to jump to the corresponding
task quickly.
Supported Commands
========
- **T** - Jump to Maniphest.
- **T123** - Jump to Maniphest Task 123.
- **D** - Jump to Differential.
- **D123** - Jump to Differential Revision 123.
- **r** - Jump to Diffusion.
- **rXYZ** - Jump to Diffusion Repository XYZ.
- **rXYZabcdef** - Jump to Diffusion Commit rXYZabcdef.
- **r <name>** - Search for repositories by name.
- **u** - Jump to People
- **u username** - Jump to username's Profile
- **p** - Jump to Project
- **p Some Project** - Jump to Project: Some Project
- **s SymbolName** - Jump to Symbol SymbolName
- **(default)** - Search for input.
Next Steps
==========
Continue by:
- returning to the @{article:Search User Guide}.
diff --git a/src/docs/user/userguide/owners.diviner b/src/docs/user/userguide/owners.diviner
index a9a52bfab8..f30fe5023c 100644
--- a/src/docs/user/userguide/owners.diviner
+++ b/src/docs/user/userguide/owners.diviner
@@ -1,154 +1,154 @@
@title Owners User Guide
@group userguide
Group files in a codebase into packages and define ownership.
Overview
========
The Owners application allows you to group files in a codebase (or across
codebases) into packages. This can make it easier to reference a module or
subsystem in other applications, like Herald.
Creating a Package
==================
To create a package, choose a name and add some files which belong to the
package. For example, you might define an "iOS Application" package by
including these paths:
/conf/ios/
/src/ios/
/shared/assets/mobile/
Any files in those directories are considered to be part of the package, and
you can now conveniently refer to them (for example, in a Herald rule) by
-refering to the package instead of copy/pasting a huge regular expression
+referring to the package instead of copy/pasting a huge regular expression
into a bunch of places.
If new source files are later added, or the scope of the package otherwise
expands or contracts, you can edit the package definition to keep things
updated.
You can use "exclude" paths to ignore subdirectories which would otherwise
be considered part of the package. For example, you might exclude a path
like this:
/conf/ios/generated/
Perhaps that directory contains some generated configuration which frequently
changes, and which you aren't concerned about.
After creating a package, files the package contains will be identified as
belonging to the package when you look at them in Diffusion, or look at changes
which affect them in Diffusion or Differential.
Dominion
========
The **Dominion** option allows you to control how ownership cascades when
multiple packages own a path. The dominion rules are:
**Strong Dominion.** This is the default. In this mode, the package will always
own all files matching its configured paths, even if another package also owns
them.
For example, if the package owns `a/`, it will always own `a/b/c.z` even if
another package owns `a/b/`. In this case, both packages will own `a/b/c.z`.
This mode prevents users from stealing files away from the package by defining
more narrow ownership rules in new packages, but enforces hierarchical
ownership rules.
**Weak Dominion.** In this mode, the package will only own files which do not
match a more specific path in another package.
For example, if the package owns `a/` but another package owns `a/b/`, the
package will no longer consider `a/b/c.z` to be a file it owns because another
package matches the path with a more specific rule.
This mode lets you to define rules without implicit hierarchical ownership,
but allows users to steal files away from a package by defining a more
specific package.
For more details on files which match multiple packages, see
"Files in Multiple Packages", below.
Auto Review
===========
You can configure **Auto Review** for packages. When a new code review is
created in Differential which affects code in a package, the package can
automatically be added as a subscriber or reviewer.
The available settings are:
- **No Autoreview**: This package will not be added to new reviews.
- **Subscribe to Changes**: This package will be added to reviews as a
subscriber. Owners will be notified of changes, but not required to act.
- **Review Changes**: This package will be added to reviews as a reviewer.
Reviews will appear on the dashboards of package owners.
- **Review Changes (Blocking)** This package will be added to reviews
as a blocking reviewer. A package owner will be required to accept changes
before they may land.
NOTE: These rules **do not trigger** if the change author is a package owner.
They only apply to changes made by users who aren't already owners.
These rules also do not trigger if the package has been archived.
The intent of this feature is to make it easy to configure simple, reasonable
behaviors. If you want more tailored or specific triggers, you can write more
powerful rules by using Herald.
Auditing
========
You can automatically trigger audits on unreviewed code by configuring
**Auditing**. The available settings are:
- **Disabled**: Do not trigger audits.
- **Enabled**: Trigger audits.
When enabled, audits are triggered for commits which:
- affect code owned by the package;
- were not authored by a package owner; and
- were not accepted by a package owner.
Audits do not trigger if the package has been archived.
The intent of this feature is to make it easy to configure simple auditing
behavior. If you want more powerful auditing behavior, you can use Herald to
write more sophisticated rules.
Files in Multiple Packages
==========================
Multiple packages may own the same file. For example, both the
"Android Application" and the "iOS Application" packages might own a path
like this, containing resources used by both:
/shared/assets/mobile/
If both packages own this directory, files in the directory are considered to
be part of both packages.
Packages do not need to have claims of equal specificity to own files. For
example, if you have a "Design Assets" package which owns this path:
/shared/assets/
...it will //also// own all of the files in the `mobile/` subdirectory. In this
configuration, these files are part of three packages: "iOS Application",
"Android Application", and "Design Assets".
(You can use an "exclude" rule if you want to make a different package with a
more specific claim the owner of a file or subdirectory. You can also change
the **Dominion** setting for a package to let it give up ownership of paths
owned by another package.)
diff --git a/src/docs/user/userguide/projects.diviner b/src/docs/user/userguide/projects.diviner
index 573c8c4c69..4e3ab3616f 100644
--- a/src/docs/user/userguide/projects.diviner
+++ b/src/docs/user/userguide/projects.diviner
@@ -1,339 +1,339 @@
@title Projects User Guide
@group userguide
Organize users and objects with projects.
Overview
========
NOTE: This document is only partially complete.
Phabricator projects are flexible, general-purpose groups of objects that you
can use to organize information. Projects have some basic information like
a name and an icon, and may optionally have members.
For example, you can create projects to provide:
- - **Organization**: Create a project to represent a product or initative,
+ - **Organization**: Create a project to represent a product or initiative,
then use it to organize related work.
- **Groups**: Create a project to represent a group of people (like a team),
then add members of the group as project members.
- **Tags**: To create a tag, just create a project without any members. Then
tag anything you want.
- **Access Control Lists**: Add members to a project, then restrict the
visibility of objects to members of that project. See "Understanding
Policies" below to understand how policies and projects interact in
more detail.
Understanding Policies
======================
An important rule to understand about projects is that **adding or removing
projects to an object never affects who can see the object**.
For example, if you tag a task with a project like {nav Backend}, that does not
change who can see the task. In particular, it does not limit visibility to
only members of the "Backend" project, nor does it allow them to see it if they
otherwise could not. Likewise, removing projects does not affect visibility.
If you're familiar with other software that works differently, this may be
unexpected, but the rule in Phabricator is simple: **adding and removing
projects never affects policies.**
Note that you //can// write policy rules which restrict capabilities to members
of a specific project or set of projects, but you do this by editing an
object's policies and adding rules based on project membership, not by tagging
or untagging the object with projects.
To manage who can seen an object, use the object's policy controls,
Spaces (see @{article:Spaces User Guide}) and Custom Forms
(see @{article:User Guide: Customizing Forms}).
For more details about rationale, see "Policies In Depth", below.
Joining Projects
================
Once you join a project, you become a member and will receive mail sent to the
project, like a mailing list. For example, if a project is added as a
subscriber on a task or a reviewer on a revision, you will receive mail about
that task or revision.
If you'd prefer not to receive mail sent to a project, you can go to
{nav Members} and select {nav Disable Mail}. If you disable mail for a project,
you will no longer receive mail sent to the project.
Watching Projects
=================
Watching a project allows you to closely follow all activity related to a
project.
You can **watch** a project by clicking {nav Watch Project} on the project
page. To stop watching a project, click {nav Unwatch Project}.
When you watch a project, you will receive a copy of mail about any objects
(like tasks or revisions) that are tagged with the project, or that the project
is a subscriber, reviewer, or auditor for. For moderately active projects, this
may be a large volume of mail.
Edit Notifications
==================
Edit notifications are generated when project details (like the project
description, name, or icon) are updated, or when users join or leave projects.
By default, these notifications are are only sent to the acting user. These
notifications are usually not very interesting, and project mail is already
complicated by members and watchers.
If you'd like to receive edit notifications for a project, you can write a
Herald rule to keep you in the loop.
Customizing Menus
=================
Projects support profile menus, which are customizable. For full details on
managing and customizing profile menus, see @{article:Profile Menu User Guide}.
Here are some examples of common ways to customize project profile menus that
may be useful:
**Link to Tasks or Repositories**: You can add a menu item for "Open Tasks" or
"Active Repositories" for a project by running the search in the appropriate
application, then adding a link to the search results to the menu.
This can let you quickly jump from a project screen to related tasks,
revisions, repositories, or other objects.
For more details on how to use search and manage queries, see
@{article:Search User Guide}.
**New Task Button**: To let users easily make a new task that is tagged with
the current project, add a link to the "New Task" form with the project
prefilled, or to a custom form with appropriate defaults.
For information on customizing and prefilling forms, see
@{article:User Guide: Customizing Forms}.
**Link to Wiki Pages**: You can add links to relevant wiki pages or other
documentation to the menu to make it easy to find and access. You could also
link to a Conpherence if you have a chatroom for a project.
**Link to External Resources**: You can link to external resources outside
of Phabricator if you have other pages which are relevant to a project.
**Set Workboard as Default**: For projects that are mostly used to organize
tasks, change the default item to the workboard instead of the profile to get
to the workboard view more easily.
**Hide Unused Items**: If you have a project which you don't expect to have
members or won't have a workboard, you can hide these items to streamline the
menu.
Subprojects and Milestones
==========================
IMPORTANT: This feature is only partially implemented.
After creating a project, you can use the
{nav icon="sitemap", name="Subprojects"} menu item to add subprojects or
milestones.
**Subprojects** are projects that are contained inside the main project. You
can use them to break large or complex groups, tags, lists, or undertakings
apart into smaller pieces.
**Milestones** are a special kind of subproject for organizing tasks into
blocks of work. You can use them to implement sprints, iterations, milestones,
versions, etc.
Subprojects and milestones have some additional special behaviors and rules,
particularly around policies and membership. See below for details.
This is a brief summary of the major differences between normal projects,
subprojects, parent projects, and milestones.
| | Normal | Parent | Subproject | Milestone |
|---|---|---|---|---|
| //Members// | Yes | Union of Subprojects | Yes | Same as Parent |
| //Policies// | Yes | Yes | Affected by Parent | Same as Parent |
| //Hashtags// | Yes | Yes | Yes | Special |
Subprojects
===========
Subprojects are full-power projects that are contained inside some parent
project. You can use them to divide a large or complex project into smaller
parts.
Subprojects have normal members and normal policies, but note that the policies
of the parent project affect the policies of the subproject (see "Parent
Projects", below).
Subprojects can have their own subprojects, milestones, or both. If a
subproject has its own subprojects, it is both a subproject and a parent
project. Thus, the parent project rules apply to it, and are stronger than the
subproject rules.
Subprojects can have normal workboards.
The maximum subproject depth is 16. This limit is intended to grossly exceed
the depth necessary in normal usage.
Objects may not be tagged with multiple projects that are ancestors or
descendants of one another. For example, a task may not be tagged with both
{nav Stonework} and {nav Stonework > Masonry}.
When a project tag is added that is the ancestor or descendant of one or more
existing tags, the old tags are replaced. For example, adding
{nav Stonework > Masonry} to a task tagged with {nav Stonework} will replace
{nav Stonework} with the newer, more specific tag.
This restriction does not apply to projects which share some common ancestor
but are not themselves mutual ancestors. For example, a task may be tagged
with both {nav Stonework > Masonry} and {nav Stonework > Sculpting}.
This restriction //does// apply when the descendant is a milestone. For
example, a task may not be tagged with both {nav Stonework} and
{nav Stonework > Iteration II}.
Milestones
==========
Milestones are simple subprojects for tracking sprints, iterations, versions,
or other similar blocks of work. Milestones make it easier to create and manage
a large number of similar subprojects (for example: {nav Sprint 1},
{nav Sprint 2}, {nav Sprint 3}, etc).
Milestones can not have direct members or policies. Instead, the membership
and policies of a milestones are always the same as the milestone's parent
project. This makes large numbers of milestones more manageable when changes
occur.
Milestones can not have subprojects, and can not have their own milestones.
By default, Milestones do not have their own hashtags.
Milestones can have normal workboards.
Objects may not be tagged with two different milestones of the same parent
project. For example, a task may not be tagged with both {nav Stonework >
Iteration III} and {nav Stonework > Iteration V}.
When a milestone tag is added to an object which already has a tag from the
same series of milestones, the old tag is removed. For example, adding the
{nav Stonework > Iteration V} tag to a task which already has the
{nav Stonework > Iteration III} tag will remove the {nav Iteration III} tag.
This restriction does not apply to milestones which are not part of the same
series. For example, a task may be tagged with both
{nav Stonework > Iteration V} and {nav Heraldry > Iteration IX}.
Parent Projects
===============
When you add the first subproject to an existing project, it is converted into
a **parent project**. Parent projects have some special rules.
**No Direct Members**: Parent projects can not have members of their own.
Instead, all of the users who are members of any subproject count as members
of the parent project. By joining (or leaving) a subproject, a user is
implicitly added to (or removed from) all ancestors of that project.
Consequently, when you add the first subproject to an existing project, all of
the project's current members are moved to become members of the subproject
instead. Implicitly, they will remain members of the parent project because the
parent project is an ancestor of the new subproject.
You can edit the project afterward to change or remove members if you want to
split membership apart in a more granular way across multiple new subprojects.
**Searching**: When you search for a parent project, results for any subproject
are returned. For example, if you search for {nav Engineering}, your query will
match results in {nav Engineering} itself, but also subprojects like
{nav Engineering > Warp Drive} and {nav Engineering > Shield Batteries}.
**Policy Effects**: To view a subproject or milestone, you must be able to
view the parent project. As a result, the parent project's view policy now
affects child projects. If you restrict the visibility of the parent, you also
restrict the visibility of the children.
In contrast, permission to edit a parent project grants permission to edit
any subproject. If a user can {nav Root Project}, they can also edit
{nav Root Project > Child} and {nav Root Project > Child > Sprint 3}.
Policies In Depth
=================
As discussed above, adding and removing projects never affects who can see an
object. This is an explicit product design choice aimed at reducing the
complexity of policy management.
Phabricator projects are a flexible, general-purpose, freeform tool. This is a
good match for many organizational use cases, but a very poor match for
policies. It is important that policies be predictable and rigid, because the
cost of making a mistake with policies is high (inadvertent disclosure of
private information).
In Phabricator, each object (like a task) can be tagged with multiple projects.
This is important in a flexible organizational tool, but is a liability in a
policy tool.
If each project potentially affected visibility, it would become more difficult
to predict the visibility of objects and easier to make mistakes with policies.
There are different, reasonable expectations about how policies might be
affected when tagging objects with projects, but these expectations are in
conflict, and different users have different expectations. For example:
- if a user adds a project like {nav Backend} to a task, their intent
might be to //open// the task up and share it with the "Backend" team;
- if a user adds a project like {nav Security Vulnerability} to a task,
their intent might be to //close// the task down and restrict it to just
the security team;
- if a user adds a project like {nav Easy Starter Task} to a task, their
intent might be to not affect policies at all;
- if a user adds {nav Secret Inner Council} to a task already tagged with
{nav Security Vulnerability}, their intent might be to //open// the task
to members of //either// project, or //close// the task to just members of
//both// projects;
- if a user adds {nav Backend} to a task already tagged with
{nav Security Vulnerability}, their intent is totally unclear;
- in all cases, users may be adding projects purely to organize objects
without intending to affect policies.
We can't distinguish between these cases without adding substantial complexity,
and even if we made an attempt to navigate this it would still be very
difficult to predict the effect of tagging an object with multiple
policy-affecting projects. Users would need to learn many rules about how these
policy types interacted to predict the policy effects of adding or removing a
project.
Because of the implied complexity, we almost certainly could not prevent some
cases where a user intends to take a purely organizational action (like adding
a {nav Needs Documentation} tag) and accidentally opens a private object to a
wide audience. The policy system is intended to make these catastrophically bad
cases very difficult, and allowing projects to affect policies would make these
mistakes much easier to make.
We believe the only reasonable way we could reduce ambiguity and complexity is
by making project policy actions explicit and rule-based. But we already have a
system for explicit, rule-based management of policies: the policy system. The
policy tools are designed for policy management and aimed at making actions
explicit and mistakes very difficult.
Many of the use cases where project-based access control seems like it might be
a good fit can be satisfied with Spaces instead (see @{article:Spaces User
Guide}). Spaces are explicit, unambiguous containers for groups of objects with
similar policies.
Form customization also provides a powerful tool for making many policy
management tasks easier (see @{article:User Guide: Customizing Forms}).
diff --git a/src/docs/user/userguide/remarkup.diviner b/src/docs/user/userguide/remarkup.diviner
index 6002867b78..28a1a31fc4 100644
--- a/src/docs/user/userguide/remarkup.diviner
+++ b/src/docs/user/userguide/remarkup.diviner
@@ -1,722 +1,722 @@
@title Remarkup Reference
@group userguide
Explains how to make bold text; this makes your words louder so you can win
arguments.
= Overview =
Phabricator uses a lightweight markup language called "Remarkup", similar to
other lightweight markup languages like Markdown and Wiki markup.
This document describes how to format text using Remarkup.
= Quick Reference =
All the syntax is explained in more detail below, but this is a quick guide to
formatting text in Remarkup.
These are inline styles, and can be applied to most text:
**bold** //italic// `monospaced` ##monospaced## ~~deleted~~ __underlined__
!!highlighted!!
D123 T123 rX123 # Link to Objects
{D123} {T123} # Link to Objects (Full Name)
{F123} # Embed Images
{M123} # Embed Pholio Mock
@username # Mention a User
#project # Mention a Project
[[wiki page]] # Link to Phriction
[[wiki page | name]] # Named link to Phriction
http://xyz/ # Link to web
[[http://xyz/ | name]] # Named link to web
[name](http://xyz/) # Alternate Link
These are block styles, and must be separated from surrounding text by
empty lines:
= Large Header =
== Smaller Header ==
## This is a Header As Well
Also a Large Header
===================
Also a Smaller Header
---------------------
> Quoted Text
Use `- ` or `* ` for bulleted lists, and `# ` for numbered lists.
Use ``` or indent two spaces for code.
Use %%% for a literal block.
Use | ... | ... for tables.
= Basic Styling =
Format **basic text styles** like this:
**bold text**
//italic text//
`monospaced text`
##monospaced text##
~~deleted text~~
__underlined text__
!!highlighted text!!
Those produce **bold text**, //italic text//, `monospaced text`, ##monospaced
text##, ~~deleted text~~, __underlined text__, and !!highlighted text!!
respectively.
= Layout =
Make **headers** like this:
= Large Header =
== Smaller Header ==
===== Very Small Header =====
Alternate Large Header
======================
Alternate Smaller Header
------------------------
You can optionally omit the trailing `=` signs -- that is, these are the same:
== Smaller Header ==
== Smaller Header
This produces headers like the ones in this document. Make sure you have an
empty line before and after the header.
Lists
=====
Make **lists** by beginning each item with a `-` or a `*`:
lang=text
- milk
- eggs
- bread
* duck
* duck
* goose
This produces a list like this:
- milk
- eggs
- bread
(Note that you need to put a space after the `-` or `*`.)
You can make numbered lists with a `#` instead of `-` or `*`:
# Articuno
# Zapdos
# Moltres
Numbered lists can also be started with `1.` or `1)`. If you use a number other
than `1`, the list will start at that number instead. For example, this:
```
200) OK
201) Created
202) Accepted
```
...produces this:
200) OK
201) Created
202) Accepted
You can also nest lists:
```- Body
- Head
- Arm
- Elbow
- Hand
# Thumb
# Index
# Middle
# Ring
# Pinkie
- Leg
- Knee
- Foot```
...which produces:
- Body
- Head
- Arm
- Elbow
- Hand
# Thumb
# Index
# Middle
# Ring
# Pinkie
- Leg
- Knee
- Foot
If you prefer, you can indent lists using multiple characters to show indent
depth, like this:
```- Tree
-- Branch
--- Twig```
As expected, this produces:
- Tree
-- Branch
--- Twig
You can add checkboxes to items by prefacing them with `[ ]` or `[X]`, like
this:
```
- [X] Preheat oven to 450 degrees.
- [ ] Zest 35 lemons.
```
When rendered, this produces:
- [X] Preheat oven to 450 degrees.
- [ ] Zest 35 lemons.
Make **code blocks** by indenting two spaces:
f(x, y);
You can also use three backticks to enclose the code block:
```f(x, y);
g(f);```
You can specify a language for syntax highlighting with `lang=xxx`:
lang=text
lang=html
<a href="#">...</a>
This will highlight the block using a highlighter for that language, if one is
available (in most cases, this means you need to configure Pygments):
lang=html
<a href="#">...</a>
You can also use a `COUNTEREXAMPLE` header to show that a block of code is
bad and shouldn't be copied:
lang=text
COUNTEREXAMPLE
function f() {
global $$variable_variable;
}
This produces a block like this:
COUNTEREXAMPLE
function f() {
global $$variable_variable;
}
You can use `lines=N` to limit the vertical size of a chunk of code, and
`name=some_name.ext` to give it a name. For example, this:
lang=text
lang=html, name=example.html, lines=12, counterexample
...
...produces this:
lang=html, name=example.html, lines=12, counterexample
<p>Apple</p>
<p>Apricot</p>
<p>Avocado</p>
<p>Banana</p>
<p>Bilberry</p>
<p>Blackberry</p>
<p>Blackcurrant</p>
<p>Blueberry</p>
<p>Currant</p>
<p>Cherry</p>
<p>Cherimoya</p>
<p>Clementine</p>
<p>Date</p>
<p>Damson</p>
<p>Durian</p>
<p>Eggplant</p>
<p>Elderberry</p>
<p>Feijoa</p>
<p>Gooseberry</p>
<p>Grape</p>
<p>Grapefruit</p>
<p>Guava</p>
<p>Huckleberry</p>
<p>Jackfruit</p>
<p>Jambul</p>
<p>Kiwi fruit</p>
<p>Kumquat</p>
<p>Legume</p>
<p>Lemon</p>
<p>Lime</p>
<p>Lychee</p>
<p>Mandarine</p>
<p>Mango</p>
<p>Mangostine</p>
<p>Melon</p>
You can use the `NOTE:`, `WARNING:` or `IMPORTANT:` elements to call attention
to an important idea.
For example, write this:
```
NOTE: Best practices in proton pack operation include not crossing the streams.
```
...to produce this:
NOTE: Best practices in proton pack operation include not crossing the streams.
Using `WARNING:` or `IMPORTANT:` at the beginning of the line changes the
color of the callout:
WARNING: Crossing the streams can result in total protonic reversal!
IMPORTANT: Don't cross the streams!
In addition, you can use `(NOTE)`, `(WARNING)`, or `(IMPORTANT)` to get the
same effect but without `(NOTE)`, `(WARNING)`, or `(IMPORTANT)` appearing in
the rendered result. For example, this callout uses `(NOTE)`:
(NOTE) Dr. Egon Spengler is the best resource for additional proton pack
questions.
Dividers
========
You can divide sections by putting three or more dashes on a line by
themselves. This creates a divider or horizontal rule similar to an `<hr />`
tag, like this one:
---
The dashes need to appear on their own line and be separated from other
content. For example, like this:
```
This section will be visually separated.
---
On an entirely different topic, ...
```
= Linking URIs =
URIs are automatically linked: http://phabricator.org/
If you have a URI with problematic characters in it, like
"`http://comma.org/,`", you can surround it with angle brackets:
<http://comma.org/,>
This will force the parser to consume the whole URI: <http://comma.org/,>
You can also use create named links, where you choose the displayed text. These
work within Phabricator or on the internet at large:
[[/herald/transcript/ | Herald Transcripts]]
[[http://www.boring-legal-documents.com/ | exciting legal documents]]
Markdown-style links are also supported:
[Toil](http://www.trouble.com)
= Linking to Objects =
You can link to Phabricator objects, such as Differential revisions, Diffusion
commits and Maniphest tasks, by mentioning the name of an object:
D123 # Link to Differential revision D123
rX123 # Link to SVN commit 123 from the "X" repository
rXaf3192cd5 # Link to Git commit "af3192cd5..." from the "X" repository.
# You must specify at least 7 characters of the hash.
T123 # Link to Maniphest task T123
You can also link directly to a comment in Maniphest and Differential (these
can be found on the date stamp of any transaction/comment):
T123#412 # Link to comment id #412 of task T123
-See the Phabricator configuraton setting `remarkup.ignored-object-names` to
+See the Phabricator configuration setting `remarkup.ignored-object-names` to
modify this behavior.
= Embedding Objects
You can also generate full-name references to some objects by using braces:
{D123} # Link to Differential revision D123 with the full name
{T123} # Link to Maniphest task T123 with the full name
These references will also show when an object changes state (for instance, a
task or revision is closed). Some types of objects support rich embedding.
== Linking to Project Tags
Projects can be linked to with the use of a hashtag `#`. This works by default
using the name of the Project (lowercase, underscored). Additionally you
can set multiple additional hashtags by editing the Project details.
#qa, #quality_assurance
== Embedding Mocks (Pholio)
You can embed a Pholio mock by using braces to refer to it:
{M123}
By default the first four images from the mock set are displayed. This behavior
can be overridden with the **image** option. With the **image** option you can
provide one or more image IDs to display.
You can set the image (or images) to display like this:
{M123, image=12345}
{M123, image=12345 & 6789}
== Embedding Pastes
You can embed a Paste using braces:
{P123}
You can adjust the embed height with the `lines` option:
{P123, lines=15}
You can highlight specific lines with the `highlight` option:
{P123, highlight=15}
{P123, highlight="23-25, 31"}
== Embedding Images
You can embed an image or other file by using braces to refer to it:
{F123}
In most interfaces, you can drag-and-drop an image from your computer into the
text area to upload and reference it.
Some browsers (e.g. Chrome) support uploading an image data just by pasting them
from clipboard into the text area.
You can set file display options like this:
{F123, layout=left, float, size=full, alt="a duckling"}
Valid options for all files are:
- **layout** left (default), center, right, inline, link (render a link
instead of a thumbnail for images)
- **name** with `layout=link` or for non-images, use this name for the link
text
- **alt** Provide alternate text for assistive technologies.
Image files support these options:
- **float** If layout is set to left or right, the image will be floated so
text wraps around it.
- **size** thumb (default), full
- **width** Scale image to a specific width.
- **height** Scale image to a specific height.
Audio and video files support these options:
- **media**: Specify the media type as `audio` or `video`. This allows you
to disambiguate how file format which may contain either audio or video
should be rendered.
- **loop**: Loop this media.
- **autoplay**: Automatically begin playing this media.
== Embedding Countdowns
You can embed a countdown by using braces:
{C123}
= Quoting Text =
To quote text, preface it with an `>`:
> This is quoted text.
This appears like this:
> This is quoted text.
= Embedding Media =
If you set a configuration flag, you can embed media directly in text:
- **remarkup.enable-embedded-youtube**: allows you to paste in YouTube videos
and have them render inline.
This option is disabled by default because it has security and/or
silliness implications. Carefully read the description before enabling it.
= Image Macros =
You can upload image macros (More Stuff -> Macro) which will replace text
strings with the image you specify. For instance, you could upload an image of a
dancing banana to create a macro named "peanutbutterjellytime", and then any
time you type that string on a separate line it will be replaced with the image
of a dancing banana.
= Memes =
You can also use image macros in the context of memes. For example, if you
have an image macro named `grumpy`, you can create a meme by doing the
following:
{meme, src = grumpy, above = toptextgoeshere, below = bottomtextgoeshere}
By default, the font used to create the text for the meme is `tuffy.ttf`. For
the more authentic feel of `impact.ttf`, you simply have to place the Impact
TrueType font in the Phabricator subfolder `/resources/font/`. If Remarkup
detects the presence of `impact.ttf`, it will automatically use it.
= Mentioning Users =
In Differential and Maniphest, you can mention another user by writing:
@username
When you submit your comment, this will add them as a CC on the revision or task
if they aren't already CC'd.
Icons
=====
You can add icons to comments using the `{icon ...}` syntax. For example:
{icon camera}
This renders: {icon camera}
You can select a color for icons:
{icon camera color=blue}
This renders: {icon camera color=blue}
For a list of available icons and colors, check the UIExamples application.
(The icons are sourced from
[[ http://fortawesome.github.io/Font-Awesome/ | FontAwesome ]], so you can also
browse the collection there.)
You can add `spin` to make the icon spin:
{icon cog spin}
This renders: {icon cog spin}
= Phriction Documents =
You can link to Phriction documents with a name or path:
Make sure you sign and date your [[legal/Letter of Marque and Reprisal]]!
By default, the link will render with the document title as the link name.
With a pipe (`|`), you can retitle the link. Use this to mislead your
opponents:
Check out these [[legal/boring_documents/ | exciting legal documents]]!
Links to pages which do not exist are shown in red. Links to pages which exist
but which the viewer does not have permission to see are shown with a lock
icon, and the link will not disclose the page title.
If you begin a link path with `./` or `../`, the remainder of the path will be
evaluated relative to the current wiki page. For example, if you are writing
content for the document `fruit/` a link to `[[./guava]]` is the same as a link
to `[[fruit/guava]]` from elsewhere.
Relative links may use `../` to transverse up the document tree. From the
`produce/vegetables/` page, you can use `[[../fruit/guava]]` to link to the
`produce/fruit/guava` page.
Relative links do not work when used outside of wiki pages. For example,
you can't use a relative link in a comment on a task, because there is no
reasonable place for the link to start resolving from.
When documents are moved, relative links are not automatically updated: they
are preserved as currently written. After moving a document, you may need to
review and adjust any relative links it contains.
= Literal Blocks =
To place text in a literal block use `%%%`:
%%%Text that won't be processed by remarkup
[[http://www.example.com | example]]
%%%
Remarkup will not process the text inside of literal blocks (other than to
escape HTML and preserve line breaks).
= Tables =
Remarkup supports simple table syntax. For example, this:
```
| Fruit | Color | Price | Peel?
| ----- | ----- | ----- | -----
| Apple | red | `$0.93` | no
| Banana | yellow | `$0.19` | **YES**
```
...produces this:
| Fruit | Color | Price | Peel?
| ----- | ----- | ----- | -----
| Apple | red | `$0.93` | no
| Banana | yellow | `$0.19` | **YES**
Remarkup also supports a simplified HTML table syntax. For example, this:
```
<table>
<tr>
<th>Fruit</th>
<th>Color</th>
<th>Price</th>
<th>Peel?</th>
</tr>
<tr>
<td>Apple</td>
<td>red</td>
<td>`$0.93`</td>
<td>no</td>
</tr>
<tr>
<td>Banana</td>
<td>yellow</td>
<td>`$0.19`</td>
<td>**YES**</td>
</tr>
</table>
```
...produces this:
<table>
<tr>
<th>Fruit</th>
<th>Color</th>
<th>Price</th>
<th>Peel?</th>
</tr>
<tr>
<td>Apple</td>
<td>red</td>
<td>`$0.93`</td>
<td>no</td>
</tr>
<tr>
<td>Banana</td>
<td>yellow</td>
<td>`$0.19`</td>
<td>**YES**</td>
</tr>
</table>
Some general notes about this syntax:
- your tags must all be properly balanced;
- your tags must NOT include attributes (`<td>` is OK, `<td style="...">` is
not);
- you can use other Remarkup rules (like **bold**, //italics//, etc.) inside
table cells.
Navigation Sequences
====================
You can use `{nav ...}` to render a stylized navigation sequence when helping
someone to locate something. This can be useful when writing documentation.
For example, you could give someone directions to purchase lemons:
{nav icon=home, name=Home >
Grocery Store >
Produce Section >
icon=lemon-o, name=Lemons}
To render this example, use this markup:
```
{nav icon=home, name=Home >
Grocery Store >
Produce Section >
icon=lemon-o, name=Lemons}
```
In general:
- Separate sections with `>`.
- Each section can just have a name to add an element to the navigation
sequence, or a list of key-value pairs.
- Supported keys are `icon`, `name`, `type` and `href`.
- The `type` option can be set to `instructions` to indicate that an element
is asking the user to make a choice or follow specific instructions.
Keystrokes
==========
You can use `{key ...}` to render a stylized keystroke. For example, this:
```
Press {key M} to view the starmap.
```
...renders this:
> Press {key M} to view the starmap.
You can also render sequences with modifier keys. This:
```
Use {key command option shift 3} to take a screenshot.
Press {key down down-right right LP} to activate the hadoken technique.
```
...renders this:
> Use {key command option shift 3} to take a screenshot.
> Press {key down down-right right LP} to activate the hadoken technique.
= Fullscreen Mode =
Remarkup editors provide a fullscreen composition mode. This can make it easier
to edit large blocks of text, or improve focus by removing distractions. You can
exit **Fullscreen** mode by clicking the button again or by pressing escape.
diff --git a/src/docs/user/userguide/search.diviner b/src/docs/user/userguide/search.diviner
index 875edc6d0f..d1c6182128 100644
--- a/src/docs/user/userguide/search.diviner
+++ b/src/docs/user/userguide/search.diviner
@@ -1,175 +1,175 @@
@title Search User Guide
@group userguide
Introduction to searching for documents in Phabricator.
Overview
========
Phabricator has two major ways to search for documents and objects (like tasks,
code reviews, users, wiki documents, and so on): **global search** and
**application search**.
**Global search** allows you to search across multiple document types at once,
but has fewer options for refining a search. It's a good general-purpose
search, and helpful if you're searching for a text string.
**Application search** allows you to search within an application (like
Maniphest) for documents of a specific type. Because application search is only
searching one type of object, it can provide more powerful options for
filtering, ordering, and displaying the results.
Both types of search share many of the same features. This document walks
through how to use search and how to take advantage of some of the advanced
options.
Global Search
=============
Global search allows you to search across multiple document types at once.
You can access global search by entering a search query in the main menu bar.
By default, global search queries search all document types: for example, they
will find matching tasks, commits, wiki documents, users, etc. You can use the
dropdown to the left of the search box to select a different search scope.
If you choose the **Current Application** scope, Phabricator will search for
open documents in the current application. For example, if you're in Maniphest
and run a search, you'll get matching tasks. If you're in Phriction and run a
search, you'll get matching wiki documents.
Some pages (like the 404 page) don't belong to an application, or belong to an
application which doesn't have any searchable documents. In these cases,
Phabricator will search all documents.
To quickly **jump to an object** like a task, enter the object's ID in the
global search box and search for it. For example, you can enter `T123` or
`D456` to quickly jump to the corresponding task or code review, or enter a Git
commit hash to jump to the corresponding commit. For a complete list of
supported commands, see @{article:Search User Guide: Shortcuts}.
After running a search, you can scroll up to add filters and refine the result
set. You can also select **Advanced Search** from the dropdown menu to jump
here immediately, or press return in the search box without entering a query.
This interface supports standard Phabricator search and filtering features,
like **saved queries** and **typeaheads**. See below for more details on using
these features.
Application Search
==================
Application search gives you a more powerful way to search one type of document,
like tasks. Most applications provide application search interfaces for the
documents or objects they let you create: these pages have queries in the left
menu, show objects or documents in the main content area, and have controls
for refining the results.
These interfaces support **saved queries** and **typeaheads**.
Saving and Sharing Queries
=============
If you have a query which you run often, you can save it for easy access.
To do this, click "Save Custom Query..." on the result screen. Choose a name
for your query and it will be saved in the left nav so you can run it again
with one click.
You can use "Edit Queries..." to reorder queries or remove saved queries you
don't use anymore.
If you drag a query to the top of the list, it will execute by default when
you load the relevant search interface. You can use this to make your default
view show the results you most often want.
You can share queries with other users by sending them the URL. This will run
the same query for them with all the parameters you've set (they may see
-different results than you do, because they may not have the same permisions).
+different results than you do, because they may not have the same permissions).
Typeaheads
==========
Typeaheads are text inputs which suggest options as you type. Typeaheads make
it easy to select users, projects, document types, and other kinds of objects
without typing their full names.
For example, if you want to find tasks that a specific user created, you can
use the "Authors:" filter in Maniphest. The filter uses a typeahead control
to let you enter authors who you want to search for.
To use a typeahead, enter the first few letters of the thing you want to
select. It will appear in a dropdown under your cursor, and you can select it
by clicking it (or using the arrow keys to highlight it, then pressing return).
If you aren't sure about the exact name of what you're looking for, click the
browse button ({nav icon=search}) to the right of the input. This will let you
browse through valid results for the control. You can filter the results from
within the browse dialog to narrow them down.
Some typeaheads support advanced selection functions which can let you build
more powerful queries. If a control supports functions, the "Browse" dialog
will show that advanced functions are available and give you a link to details
on which functions you can use.
For example, the `members()` function lets you automatically select all of the
members of a project. You could use this with the "Authors" filter to find
tasks created by anyone on a certain team.
Another useful function is the `viewer()` function, which works as though you'd
typed your own username when you run the query. However, if you send the query
to someone else, it will show results for //their// username when they run it.
This can be particularly useful when creating dashboard panels.
Fulltext Search
===============
Global search and some applications provide **fulltext search**. In
applications, this is a field called {nav Query}.
Fulltext search allows you to search the text content of objects and supports
some special syntax. These features are supported:
- Substring search with `~platypus`.
- Field search with `title:platypus`.
- Filtering out matches with `-platypus`.
- Quoted terms with `"platypus attorney"`.
- Combining features with `title:~"platypus attorney"`.
See below for more detail.
**Substrings**: Normally, query terms are searched for as words, so searching
for `read` won't find documents which only contain the word `threaded`, even
though "read" is a substring of "threaded". With the substring operator, `~`,
you can search for substrings instead: the query `~read` will match documents
which contain that text anywhere, even in the middle of a word.
**Quoted Terms**: When you search for multiple terms, documents which match
each term will be returned, even if the terms are not adjacent in the document.
For example, the query `void star` will match a document titled `A star in the
void`, because it matches both `void` and `star`. To search for an exact
sequence of terms, quote them: `"void star"`. This query will only match
documents which use those terms as written.
**Stemming**: Searching for a term like `rearming` will find documents which
contain variations of the word, like `rearm`, `rearms`, and `rearmed`. To
search for an an exact word, quote the term: `"rearming"`.
**Field Search**: By default, query terms are searched for in the title, body,
and comments. If you only want to search for a term in titles, use `title:`.
For example, `title:platypus` only finds documents with that term in the
title. This can be combined with other operators, for example `title:~platypus`
or `title:"platypus attorney"`. These scopes are also supported:
- `title:...` searches titles.
- `body:...` searches bodies (descriptions or summaries).
- `core:...` searches titles and bodies, but not comments.
- `comments:...` searches only comments.
**Filtering Matches**: You can remove documents which match certain terms from
the result set with `-`. For example: `platypus -mammal`. Documents which match
negated terms will be filtered out of the result set.
diff --git a/src/docs/user/userguide/spaces.diviner b/src/docs/user/userguide/spaces.diviner
index 212def61f3..4a748fb25b 100644
--- a/src/docs/user/userguide/spaces.diviner
+++ b/src/docs/user/userguide/spaces.diviner
@@ -1,167 +1,167 @@
@title Spaces User Guide
@group userguide
Guide to the Spaces application.
Overview
========
The Spaces application makes it easier to manage large groups of objects which
share the same access policy. For example:
- An organization might make a space for a project in order to satisfy a
contractual obligation to limit access, even internally.
- An open source organization might make a space for work related to
internal governance, to separate private and public discussions.
- A contracting company might make spaces for clients, to separate them from
one another.
- A company might create a spaces for consultants, to give them limited
access to only the resources they need to do their work.
- An ambitious manager might create a space to hide her team's work from her
enemies at the company, that she might use the element of surprise to later
expand her domain.
Phabricator's access control policies are generally powerful enough to handle
these use cases on their own, but applying the same policy to a large group
of objects requires a lot of effort and is error-prone.
Spaces build on top of policies and make it easier and more reliable to
configure, review, and manage groups of objects with similar policies.
Creating Spaces
=================
Spaces are optional, and are inactive by default. You don't need to configure
them if you don't plan to use them. You can always set them up later.
To activate Spaces, you need to create at least two spaces. Create spaces from
the web UI, by navigating to {nav Spaces > Create Space}. By default, only
administrators can create new spaces, but you can configure this in the
{nav Applications} application.
The first space you create will be a special "default" space, and all existing
objects will be shifted into this space as soon as you create it. Spaces you
create later will be normal spaces, and begin with no objects inside them.
Create the first space (you may want to name it something like "Default" or
"Global" or "Public", depending on the nature of your organization), then
create a second space. Usually, the second space will be something like
"Secret Plans" and have a more restrictive "Visible To" policy.
Using Spaces
============
Once you've created at least two spaces, you can begin using them.
Application UIs will change for users who can see at least two spaces, opening
up new controls which let them work with spaces. They will now be able to
choose which space to create new objects into, be able to move objects between
spaces, and be able to search for objects in a specific space or set of spaces.
In list and detail views, objects will show which space they're in if they're
in a non-default space.
Users with access to only one space won't see these controls, even if many
spaces exist. This simplifies the UI for users with limited access.
Space Policies
==============
-Briefly, spacess affect policies like this:
+Briefly, spaces affect policies like this:
- Spaces apply their view policy to all objects inside the space.
- Space policies are absolute, and stronger than all other policies. A
user who can not see a space can **never** see objects inside the space.
- Normal policies are still checked: spaces can only reduce access.
When you create a space, you choose a view policy for that space by using the
**Visible To** control. This policy controls both who can see the space, and
who can see objects inside the space.
Spaces apply their view policy to all objects inside the space: if you can't
see a space, you can never see objects inside it. This policy check is absolute
and stronger than all other policy rules, including policy exceptions.
For example, a user can never see a task in a space they can't see, even if
they are an admin and the author and owner of the task, and subscribed to the
task and the view and edit policies are set to "All Users", and they created
the space originally and the moon is full and they are pure of heart and
possessed of the noblest purpose. Spaces are impenetrable.
Even if a user satisfies the view policy for a space, they must still pass the
view policy on the object: the space check is a new check in addition to any
check on the object, and can only limit access.
The edit policy for a space only affects the space itself, and is not applied
to objects inside the space.
Archiving Spaces
================
If you no longer need a space, you can archive it by choosing
{nav Archive Space} from the detail view. This hides the space and all the
objects in it without deleting any data.
New objects can't be created into archived spaces, and existing objects can't
be shifted into archived spaces. The UI won't give you options to choose
these spaces when creating or editing objects.
Additionally, objects (like tasks) in archived spaces won't be shown in most
search result lists by default. If you need to find objects in an archived
space, use the `Spaces` constraint to specifically search for objects in that
space.
You can reactivate a space later by choosing {nav Activate Space}.
Application Email
=================
After activating spaces, you can choose a space when configuring inbound email
addresses in {nav Applications}.
Spaces affect policies for application email just like they do for other
objects: to see or use the address, you must be able to see the space which
contains it.
Objects created from inbound email will be created in the space the email is
associated with.
Limitations and Caveats
=======================
Some information is shared between spaces, so they do not completely isolate
users from other activity on the install. This section discusses limitations
of the isolation model. Most of these limitations are intrinsic to the policy
model Phabricator uses.
**Shared IDs**: Spaces do not have unique object IDs: there is only one `T1`,
not a separate one in each space. It can be moved between spaces, but `T1`
always refers to the same object. In most cases, this makes working with
spaces simpler and easier.
However, because IDs are shared, users in any space can look at object IDs to
determine how many objects exist in other spaces, even if they can't see those
objects. If a user creates a new task and sees that it is `T5000`, they can
know that there are 4,999 other tasks they don't have permission to see.
**Globally Unique Values**: Some values (like usernames, email addresses,
project hashtags, repository callsigns, and application emails) must be
globally unique.
As with normal policies, users may be able to determine that a `#yolo` project
exists, even if they can't see it: they can try to create a project using the
`#yolo` hashtag, and will receive an error if it is a duplicate.
**User Accounts**: Spaces do not apply to users, and can not hide the existence
of user accounts.
For example, if you are a contracting company and have Coke and Pepsi as
clients, the CEO of Coke and the CEO of Pepsi will each be able to see that the
other has an account on the install, even if all the work you are doing for
them is separated into "Coke" and "Pepsi" spaces.
diff --git a/src/docs/user/userguide/users.diviner b/src/docs/user/userguide/users.diviner
index e96bf8f354..d66f8080d3 100644
--- a/src/docs/user/userguide/users.diviner
+++ b/src/docs/user/userguide/users.diviner
@@ -1,107 +1,107 @@
@title User Guide: Account Roles
@group userguide
Describes account roles like "Administrator", "Disabled", "Bot" and "Mailing
List".
Overview
========
When you create a user account, you can set roles like "Administrator",
"Disabled", "Bot" and "Mailing List". This document explains what these roles
mean.
Administrators
==============
**Administrators** are normal users with a few extra capabilities. Their
primary role is to keep things running smoothly, and they are not all-powerful.
In Phabricator, administrators are more like //janitors//.
Administrators can create, delete, enable, disable, and approve user accounts.
Various applications have a few other capabilities which are reserved for
administrators by default, but these can be changed to provide access to more
or fewer users.
Administrators are **not** in complete control of the system. Administrators
**can not** login as other users or act on behalf of other users. They can not
destroy data or make changes without leaving an audit trail. Administrators also
can not bypass object privacy policies.
Limiting the power of administrators means that administrators can't abuse
their power (they have very little power to abuse), a malicious administrator
can't do much damage, and an attacker who compromises an administrator account
is limited in what they can accomplish.
Bot Accounts
============
**Bot** ("Robot") accounts are accounts for bots and scripts which need to
interface with the system, but are not regular users. Generally, when you write
scripts that use the Conduit API, you should create a bot account for them.
The **Bot** role for an account can not be changed after the account is
created. This prevents administrators form changing a normal user into a bot,
retrieving their Conduit certificate, and then changing them back (which
would allow administrators to gain other users' credentials).
**Bot** accounts differ from normal accounts in that:
- they can not log in to the web UI;
- administrators can access them, edit settings, and retrieve credentials;
- they do not receive email;
- they appear with lower precedence in the UI when selecting users, with
a "Bot" note (because it usually does not make sense to, for example,
assign a task to a bot).
Mailing Lists
=============
**Mailing List** accounts let you represent an existing external mailing list
(like a Google Group or a Mailman list) as a user. You can subscribe this user
to objects (like tasks) to send them mail.
Because these accounts are also user accounts, they can be added to projects
and affected by policies. The list won't receive mail about anything the
underlying user account can't see.
The **Mailing List** role for an account can not be changed after the account
is created.
Some options can be configured for mailing lists by browsing to the list user's
-profile and clicking {nav Edit Settings}. You can change the addresss for a
+profile and clicking {nav Edit Settings}. You can change the address for a
list by editing "Email Addresses" here, choose the language and format for
email the list receives, and customize which actions the list is notified about.
**Mailing List** accounts differ from normal accounts in that they:
- can not log in;
- can not access the Conduit API;
- administrators can access them and edit settings; and
- they appear with lower precedence in the UI when selecting users, with
a "Mailing List" note.
Disabled Users
==============
**Disabled Users** are accounts that are no longer active. Generally, when
someone leaves a project (e.g., leaves your company, or their internship or
contract ends) you should disable their account to terminate their access to
the system. Disabled users:
- can not login;
- can not access the Conduit API;
- do not receive email; and
- appear with lower precedence in the UI when selecting users, with a
"Disabled" note (because it usually does not make sense to, for example,
assign a task to a disabled user).
While users can also be deleted, it is strongly recommended that you disable
them instead, particularly if they interacted with any objects in the system.
If you delete a user entirely, you won't be able to find things they used to
own or restore their data later if they rejoin the project.
diff --git a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
index cb1e56711d..efe12e68b8 100644
--- a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
+++ b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php
@@ -1,328 +1,328 @@
<?php
/**
* Convenience class to perform operations on an entire field list, like reading
* all values from storage.
*
* $field_list = new PhabricatorCustomFieldList($fields);
*
*/
final class PhabricatorCustomFieldList extends Phobject {
private $fields;
private $viewer;
public function __construct(array $fields) {
assert_instances_of($fields, 'PhabricatorCustomField');
$this->fields = $fields;
}
public function getFields() {
return $this->fields;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
foreach ($this->getFields() as $field) {
$field->setViewer($viewer);
}
return $this;
}
public function readFieldsFromObject(
PhabricatorCustomFieldInterface $object) {
$fields = $this->getFields();
foreach ($fields as $field) {
$field
->setObject($object)
->readValueFromObject($object);
}
return $this;
}
/**
* Read stored values for all fields which support storage.
*
* @param PhabricatorCustomFieldInterface Object to read field values for.
* @return void
*/
public function readFieldsFromStorage(
PhabricatorCustomFieldInterface $object) {
$this->readFieldsFromObject($object);
$fields = $this->getFields();
id(new PhabricatorCustomFieldStorageQuery())
->addFields($fields)
->execute();
return $this;
}
public function appendFieldsToForm(AphrontFormView $form) {
$enabled = array();
foreach ($this->fields as $field) {
if ($field->shouldEnableForRole(PhabricatorCustomField::ROLE_EDIT)) {
$enabled[] = $field;
}
}
$phids = array();
foreach ($enabled as $field_key => $field) {
$phids[$field_key] = $field->getRequiredHandlePHIDsForEdit();
}
$all_phids = array_mergev($phids);
if ($all_phids) {
$handles = id(new PhabricatorHandleQuery())
->setViewer($this->viewer)
->withPHIDs($all_phids)
->execute();
} else {
$handles = array();
}
foreach ($enabled as $field_key => $field) {
$field_handles = array_select_keys($handles, $phids[$field_key]);
$instructions = $field->getInstructionsForEdit();
if (strlen($instructions)) {
$form->appendRemarkupInstructions($instructions);
}
$form->appendChild($field->renderEditControl($field_handles));
}
}
public function appendFieldsToPropertyList(
PhabricatorCustomFieldInterface $object,
PhabricatorUser $viewer,
PHUIPropertyListView $view) {
$this->readFieldsFromStorage($object);
$fields = $this->fields;
foreach ($fields as $field) {
$field->setViewer($viewer);
}
// Move all the blocks to the end, regardless of their configuration order,
// because it always looks silly to render a block in the middle of a list
// of properties.
$head = array();
$tail = array();
foreach ($fields as $key => $field) {
$style = $field->getStyleForPropertyView();
switch ($style) {
case 'property':
case 'header':
$head[$key] = $field;
break;
case 'block':
$tail[$key] = $field;
break;
default:
throw new Exception(
pht(
"Unknown field property view style '%s'; valid styles are ".
"'%s' and '%s'.",
$style,
'block',
'property'));
}
}
$fields = $head + $tail;
$add_header = null;
$phids = array();
foreach ($fields as $key => $field) {
$phids[$key] = $field->getRequiredHandlePHIDsForPropertyView();
}
$all_phids = array_mergev($phids);
if ($all_phids) {
$handles = id(new PhabricatorHandleQuery())
->setViewer($viewer)
->withPHIDs($all_phids)
->execute();
} else {
$handles = array();
}
foreach ($fields as $key => $field) {
$field_handles = array_select_keys($handles, $phids[$key]);
$label = $field->renderPropertyViewLabel();
$value = $field->renderPropertyViewValue($field_handles);
if ($value !== null) {
switch ($field->getStyleForPropertyView()) {
case 'header':
- // We want to hide headers if the fields the're assciated with
+ // We want to hide headers if the fields they're associated with
// don't actually produce any visible properties. For example, in a
// list like this:
//
// Header A
// Prop A: Value A
// Header B
// Prop B: Value B
//
// ...if the "Prop A" field returns `null` when rendering its
// property value and we rendered naively, we'd get this:
//
// Header A
// Header B
// Prop B: Value B
//
// This is silly. Instead, we hide "Header A".
$add_header = $value;
break;
case 'property':
if ($add_header !== null) {
// Add the most recently seen header.
$view->addSectionHeader($add_header);
$add_header = null;
}
$view->addProperty($label, $value);
break;
case 'block':
$icon = $field->getIconForPropertyView();
$view->invokeWillRenderEvent();
if ($label !== null) {
$view->addSectionHeader($label, $icon);
}
$view->addTextContent($value);
break;
}
}
}
}
public function buildFieldTransactionsFromRequest(
PhabricatorApplicationTransaction $template,
AphrontRequest $request) {
$xactions = array();
$role = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
foreach ($this->fields as $field) {
if (!$field->shouldEnableForRole($role)) {
continue;
}
$transaction_type = $field->getApplicationTransactionType();
$xaction = id(clone $template)
->setTransactionType($transaction_type);
if ($transaction_type == PhabricatorTransactions::TYPE_CUSTOMFIELD) {
// For TYPE_CUSTOMFIELD transactions only, we provide the old value
// as an input.
$old_value = $field->getOldValueForApplicationTransactions();
$xaction->setOldValue($old_value);
}
$field->readValueFromRequest($request);
$xaction
->setNewValue($field->getNewValueForApplicationTransactions());
if ($transaction_type == PhabricatorTransactions::TYPE_CUSTOMFIELD) {
// For TYPE_CUSTOMFIELD transactions, add the field key in metadata.
$xaction->setMetadataValue('customfield:key', $field->getFieldKey());
}
$metadata = $field->getApplicationTransactionMetadata();
foreach ($metadata as $key => $value) {
$xaction->setMetadataValue($key, $value);
}
$xactions[] = $xaction;
}
return $xactions;
}
/**
* Publish field indexes into index tables, so ApplicationSearch can search
* them.
*
* @return void
*/
public function rebuildIndexes(PhabricatorCustomFieldInterface $object) {
$indexes = array();
$index_keys = array();
$phid = $object->getPHID();
$role = PhabricatorCustomField::ROLE_APPLICATIONSEARCH;
foreach ($this->fields as $field) {
if (!$field->shouldEnableForRole($role)) {
continue;
}
$index_keys[$field->getFieldIndex()] = true;
foreach ($field->buildFieldIndexes() as $index) {
$index->setObjectPHID($phid);
$indexes[$index->getTableName()][] = $index;
}
}
if (!$indexes) {
return;
}
$any_index = head(head($indexes));
$conn_w = $any_index->establishConnection('w');
foreach ($indexes as $table => $index_list) {
$sql = array();
foreach ($index_list as $index) {
$sql[] = $index->formatForInsert($conn_w);
}
$indexes[$table] = $sql;
}
$any_index->openTransaction();
foreach ($indexes as $table => $sql_list) {
queryfx(
$conn_w,
'DELETE FROM %T WHERE objectPHID = %s AND indexKey IN (%Ls)',
$table,
$phid,
array_keys($index_keys));
if (!$sql_list) {
continue;
}
foreach (PhabricatorLiskDAO::chunkSQL($sql_list) as $chunk) {
queryfx(
$conn_w,
'INSERT INTO %T (objectPHID, indexKey, indexValue) VALUES %Q',
$table,
$chunk);
}
}
$any_index->saveTransaction();
}
public function updateAbstractDocument(
PhabricatorSearchAbstractDocument $document) {
$role = PhabricatorCustomField::ROLE_GLOBALSEARCH;
foreach ($this->getFields() as $field) {
if (!$field->shouldEnableForRole($role)) {
continue;
}
$field->updateAbstractDocument($document);
}
}
}
diff --git a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
index c569f0c695..a8dc5061e7 100644
--- a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
+++ b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php
@@ -1,233 +1,233 @@
<?php
final class PhabricatorWorkerTriggerQuery
extends PhabricatorPolicyAwareQuery {
// NOTE: This is a PolicyAware query so it can work with other infrastructure
// like handles; triggers themselves are low-level and do not have
- // meaninguful policies.
+ // meaningful policies.
const ORDER_ID = 'id';
const ORDER_EXECUTION = 'execution';
const ORDER_VERSION = 'version';
private $ids;
private $phids;
private $versionMin;
private $versionMax;
private $nextEpochMin;
private $nextEpochMax;
private $needEvents;
private $order = self::ORDER_ID;
public function getQueryApplicationClass() {
return null;
}
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withVersionBetween($min, $max) {
$this->versionMin = $min;
$this->versionMax = $max;
return $this;
}
public function withNextEventBetween($min, $max) {
$this->nextEpochMin = $min;
$this->nextEpochMax = $max;
return $this;
}
public function needEvents($need_events) {
$this->needEvents = $need_events;
return $this;
}
/**
* Set the result order.
*
* Note that using `ORDER_EXECUTION` will also filter results to include only
* triggers which have been scheduled to execute. You should not use this
* ordering when querying for specific triggers, e.g. by ID or PHID.
*
* @param const Result order.
* @return this
*/
public function setOrder($order) {
$this->order = $order;
return $this;
}
protected function nextPage(array $page) {
// NOTE: We don't implement paging because we don't currently ever need
- // it and paging ORDER_EXCUTION is a hassle.
+ // it and paging ORDER_EXECUTION is a hassle.
throw new PhutilMethodNotImplementedException();
}
protected function loadPage() {
$task_table = new PhabricatorWorkerTrigger();
$conn_r = $task_table->establishConnection('r');
$rows = queryfx_all(
$conn_r,
'SELECT t.* FROM %T t %Q %Q %Q %Q',
$task_table->getTableName(),
$this->buildJoinClause($conn_r),
$this->buildWhereClause($conn_r),
$this->buildOrderClause($conn_r),
$this->buildLimitClause($conn_r));
$triggers = $task_table->loadAllFromArray($rows);
if ($triggers) {
if ($this->needEvents) {
$ids = mpull($triggers, 'getID');
$events = id(new PhabricatorWorkerTriggerEvent())->loadAllWhere(
'triggerID IN (%Ld)',
$ids);
$events = mpull($events, null, 'getTriggerID');
foreach ($triggers as $key => $trigger) {
$event = idx($events, $trigger->getID());
$trigger->attachEvent($event);
}
}
foreach ($triggers as $key => $trigger) {
$clock_class = $trigger->getClockClass();
if (!is_subclass_of($clock_class, 'PhabricatorTriggerClock')) {
unset($triggers[$key]);
continue;
}
try {
$argv = array($trigger->getClockProperties());
$clock = newv($clock_class, $argv);
} catch (Exception $ex) {
unset($triggers[$key]);
continue;
}
$trigger->attachClock($clock);
}
foreach ($triggers as $key => $trigger) {
$action_class = $trigger->getActionClass();
if (!is_subclass_of($action_class, 'PhabricatorTriggerAction')) {
unset($triggers[$key]);
continue;
}
try {
$argv = array($trigger->getActionProperties());
$action = newv($action_class, $argv);
} catch (Exception $ex) {
unset($triggers[$key]);
continue;
}
$trigger->attachAction($action);
}
}
return $triggers;
}
protected function buildJoinClause(AphrontDatabaseConnection $conn_r) {
$joins = array();
if (($this->nextEpochMin !== null) ||
($this->nextEpochMax !== null) ||
($this->order == self::ORDER_EXECUTION)) {
$joins[] = qsprintf(
$conn_r,
'JOIN %T e ON e.triggerID = t.id',
id(new PhabricatorWorkerTriggerEvent())->getTableName());
}
return implode(' ', $joins);
}
protected function buildWhereClause(AphrontDatabaseConnection $conn_r) {
$where = array();
if ($this->ids !== null) {
$where[] = qsprintf(
$conn_r,
't.id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn_r,
't.phid IN (%Ls)',
$this->phids);
}
if ($this->versionMin !== null) {
$where[] = qsprintf(
$conn_r,
't.triggerVersion >= %d',
$this->versionMin);
}
if ($this->versionMax !== null) {
$where[] = qsprintf(
$conn_r,
't.triggerVersion <= %d',
$this->versionMax);
}
if ($this->nextEpochMin !== null) {
$where[] = qsprintf(
$conn_r,
'e.nextEventEpoch >= %d',
$this->nextEpochMin);
}
if ($this->nextEpochMax !== null) {
$where[] = qsprintf(
$conn_r,
'e.nextEventEpoch <= %d',
$this->nextEpochMax);
}
return $this->formatWhereClause($where);
}
private function buildOrderClause(AphrontDatabaseConnection $conn_r) {
switch ($this->order) {
case self::ORDER_ID:
return qsprintf(
$conn_r,
'ORDER BY id DESC');
case self::ORDER_EXECUTION:
return qsprintf(
$conn_r,
'ORDER BY e.nextEventEpoch ASC, e.id ASC');
case self::ORDER_VERSION:
return qsprintf(
$conn_r,
'ORDER BY t.triggerVersion ASC');
default:
throw new Exception(
pht(
'Unsupported order "%s".',
$this->order));
}
}
}
diff --git a/src/infrastructure/diff/view/PHUIDiffGraphView.php b/src/infrastructure/diff/view/PHUIDiffGraphView.php
index 76ae0b2045..85741faab7 100644
--- a/src/infrastructure/diff/view/PHUIDiffGraphView.php
+++ b/src/infrastructure/diff/view/PHUIDiffGraphView.php
@@ -1,213 +1,213 @@
<?php
final class PHUIDiffGraphView extends Phobject {
private $isHead = true;
private $isTail = true;
public function setIsHead($is_head) {
$this->isHead = $is_head;
return $this;
}
public function getIsHead() {
return $this->isHead;
}
public function setIsTail($is_tail) {
$this->isTail = $is_tail;
return $this;
}
public function getIsTail() {
return $this->isTail;
}
public function renderRawGraph(array $parents) {
// This keeps our accumulated information about each line of the
// merge/branch graph.
$graph = array();
// This holds the next commit we're looking for in each column of the
// graph.
$threads = array();
// This is the largest number of columns any row has, i.e. the width of
// the graph.
$count = 0;
foreach ($parents as $cursor => $parent_list) {
$joins = array();
$splits = array();
// Look for some thread which has this commit as the next commit. If
// we find one, this commit goes on that thread. Otherwise, this commit
// goes on a new thread.
$line = '';
$found = false;
$pos = count($threads);
$thread_count = $pos;
for ($n = 0; $n < $thread_count; $n++) {
if (empty($threads[$n])) {
$line .= ' ';
continue;
}
if ($threads[$n] == $cursor) {
if ($found) {
$line .= ' ';
$joins[] = $n;
$threads[$n] = false;
} else {
$line .= 'o';
$found = true;
$pos = $n;
}
} else {
// We render a "|" for any threads which have a commit that we haven't
// seen yet, this is later drawn as a vertical line.
$line .= '|';
}
}
// If we didn't find the thread this commit goes on, start a new thread.
// We use "o" to mark the commit for the rendering engine, or "^" to
// indicate that there's nothing after it so the line from the commit
// upward should not be drawn.
if (!$found) {
if ($this->getIsHead()) {
$line .= '^';
} else {
$line .= 'o';
foreach ($graph as $k => $meta) {
// Go back across all the lines we've already drawn and add a
// "|" to the end, since this is connected to some future commit
// we don't know about.
for ($jj = strlen($meta['line']); $jj <= $count; $jj++) {
$graph[$k]['line'] .= '|';
}
}
}
}
// Update the next commit on this thread to the commit's first parent.
// This might have the effect of making a new thread.
$threads[$pos] = head($parent_list);
// If we made a new thread, increase the thread count.
$count = max($pos + 1, $count);
// Now, deal with splits (merges). I picked this terms opposite to the
// underlying repository term to confuse you.
foreach (array_slice($parent_list, 1) as $parent) {
$found = false;
// Try to find the other parent(s) in our existing threads. If we find
// them, split to that thread.
foreach ($threads as $idx => $thread_commit) {
if ($thread_commit == $parent) {
$found = true;
$splits[] = $idx;
break;
}
}
// If we didn't find the parent, we don't know about it yet. Find the
// first free thread and add it as the "next" commit in that thread.
// This might create a new thread.
if (!$found) {
for ($n = 0; $n < $count; $n++) {
if (empty($threads[$n])) {
break;
}
}
$threads[$n] = $parent;
$splits[] = $n;
$count = max($n + 1, $count);
}
}
$graph[] = array(
'line' => $line,
'split' => $splits,
'join' => $joins,
);
}
// If this is the last page in history, replace any "o" characters at the
// bottom of columns with "x" characters so we do not draw a connecting
// line downward, and replace "^" with an "X" for repositories with
// exactly one commit.
if ($this->getIsTail() && $graph) {
$terminated = array();
foreach (array_reverse(array_keys($graph)) as $key) {
$line = $graph[$key]['line'];
$len = strlen($line);
for ($ii = 0; $ii < $len; $ii++) {
$c = $line[$ii];
if ($c == 'o') {
// If we've already terminated this thread, we don't need to add
// a terminator.
if (isset($terminated[$ii])) {
continue;
}
$terminated[$ii] = true;
- // If this thread is joinining some other node here, we don't want
+ // If this thread is joining some other node here, we don't want
// to terminate it.
if (isset($graph[$key + 1])) {
$joins = $graph[$key + 1]['join'];
if (in_array($ii, $joins)) {
continue;
}
}
$graph[$key]['line'][$ii] = 'x';
} else if ($c != ' ') {
$terminated[$ii] = true;
} else {
unset($terminated[$ii]);
}
}
}
$last = array_pop($graph);
$last['line'] = str_replace('^', 'X', $last['line']);
$graph[] = $last;
}
return array($graph, $count);
}
public function renderGraph(array $parents) {
list($graph, $count) = $this->renderRawGraph($parents);
// Render into tags for the behavior.
foreach ($graph as $k => $meta) {
$graph[$k] = javelin_tag(
'div',
array(
'sigil' => 'commit-graph',
'meta' => $meta,
),
'');
}
Javelin::initBehavior(
'diffusion-commit-graph',
array(
'count' => $count,
));
return $graph;
}
}
diff --git a/src/infrastructure/diff/view/PHUIDiffInlineCommentTableScaffold.php b/src/infrastructure/diff/view/PHUIDiffInlineCommentTableScaffold.php
index 5da25389a5..94eb91ec58 100644
--- a/src/infrastructure/diff/view/PHUIDiffInlineCommentTableScaffold.php
+++ b/src/infrastructure/diff/view/PHUIDiffInlineCommentTableScaffold.php
@@ -1,22 +1,22 @@
<?php
/**
* Wraps an inline comment row scaffold in a table.
*
* This scaffold is used to ship inlines over the wire to the client, so they
- * arrive in a form that's easy to mainipulate (a valid table node).
+ * arrive in a form that's easy to manipulate (a valid table node).
*/
final class PHUIDiffInlineCommentTableScaffold extends AphrontView {
private $rows = array();
public function addRowScaffold(PHUIDiffInlineCommentRowScaffold $row) {
$this->rows[] = $row;
return $this;
}
public function render() {
return phutil_tag('table', array(), $this->rows);
}
}
diff --git a/src/infrastructure/env/PhabricatorEnv.php b/src/infrastructure/env/PhabricatorEnv.php
index 70ddb80630..ba05d0409d 100644
--- a/src/infrastructure/env/PhabricatorEnv.php
+++ b/src/infrastructure/env/PhabricatorEnv.php
@@ -1,963 +1,963 @@
<?php
/**
* Manages the execution environment configuration, exposing APIs to read
* configuration settings and other similar values that are derived directly
* from configuration settings.
*
*
* = Reading Configuration =
*
* The primary role of this class is to provide an API for reading
* Phabricator configuration, @{method:getEnvConfig}:
*
* $value = PhabricatorEnv::getEnvConfig('some.key', $default);
*
* The class also handles some URI construction based on configuration, via
* the methods @{method:getURI}, @{method:getProductionURI},
* @{method:getCDNURI}, and @{method:getDoclink}.
*
* For configuration which allows you to choose a class to be responsible for
* some functionality (e.g., which mail adapter to use to deliver email),
* @{method:newObjectFromConfig} provides a simple interface that validates
* the configured value.
*
*
* = Unit Test Support =
*
* In unit tests, you can use @{method:beginScopedEnv} to create a temporary,
* mutable environment. The method returns a scope guard object which restores
* the environment when it is destroyed. For example:
*
* public function testExample() {
* $env = PhabricatorEnv::beginScopedEnv();
* $env->overrideEnv('some.key', 'new-value-for-this-test');
*
* // Some test which depends on the value of 'some.key'.
*
* }
*
* Your changes will persist until the `$env` object leaves scope or is
* destroyed.
*
* You should //not// use this in normal code.
*
*
* @task read Reading Configuration
* @task uri URI Validation
* @task test Unit Test Support
* @task internal Internals
*/
final class PhabricatorEnv extends Phobject {
private static $sourceStack;
private static $repairSource;
private static $overrideSource;
private static $requestBaseURI;
private static $cache;
private static $localeCode;
private static $readOnly;
private static $readOnlyReason;
const READONLY_CONFIG = 'config';
const READONLY_UNREACHABLE = 'unreachable';
const READONLY_SEVERED = 'severed';
const READONLY_MASTERLESS = 'masterless';
/**
* @phutil-external-symbol class PhabricatorStartup
*/
public static function initializeWebEnvironment() {
self::initializeCommonEnvironment(false);
}
public static function initializeScriptEnvironment($config_optional) {
self::initializeCommonEnvironment($config_optional);
// NOTE: This is dangerous in general, but we know we're in a script context
// and are not vulnerable to CSRF.
AphrontWriteGuard::allowDangerousUnguardedWrites(true);
// There are several places where we log information (about errors, events,
// service calls, etc.) for analysis via DarkConsole or similar. These are
// useful for web requests, but grow unboundedly in long-running scripts and
// daemons. Discard data as it arrives in these cases.
PhutilServiceProfiler::getInstance()->enableDiscardMode();
DarkConsoleErrorLogPluginAPI::enableDiscardMode();
DarkConsoleEventPluginAPI::enableDiscardMode();
}
private static function initializeCommonEnvironment($config_optional) {
PhutilErrorHandler::initialize();
self::resetUmask();
self::buildConfigurationSourceStack($config_optional);
// Force a valid timezone. If both PHP and Phabricator configuration are
// invalid, use UTC.
$tz = self::getEnvConfig('phabricator.timezone');
if ($tz) {
@date_default_timezone_set($tz);
}
$ok = @date_default_timezone_set(date_default_timezone_get());
if (!$ok) {
date_default_timezone_set('UTC');
}
// Prepend '/support/bin' and append any paths to $PATH if we need to.
$env_path = getenv('PATH');
$phabricator_path = dirname(phutil_get_library_root('phabricator'));
$support_path = $phabricator_path.'/support/bin';
$env_path = $support_path.PATH_SEPARATOR.$env_path;
$append_dirs = self::getEnvConfig('environment.append-paths');
if (!empty($append_dirs)) {
$append_path = implode(PATH_SEPARATOR, $append_dirs);
$env_path = $env_path.PATH_SEPARATOR.$append_path;
}
putenv('PATH='.$env_path);
// Write this back into $_ENV, too, so ExecFuture picks it up when creating
// subprocess environments.
$_ENV['PATH'] = $env_path;
// If an instance identifier is defined, write it into the environment so
// it's available to subprocesses.
$instance = self::getEnvConfig('cluster.instance');
if (strlen($instance)) {
putenv('PHABRICATOR_INSTANCE='.$instance);
$_ENV['PHABRICATOR_INSTANCE'] = $instance;
}
PhabricatorEventEngine::initialize();
// TODO: Add a "locale.default" config option once we have some reasonable
// defaults which aren't silly nonsense.
self::setLocaleCode('en_US');
}
public static function beginScopedLocale($locale_code) {
return new PhabricatorLocaleScopeGuard($locale_code);
}
public static function getLocaleCode() {
return self::$localeCode;
}
public static function setLocaleCode($locale_code) {
if (!$locale_code) {
return;
}
if ($locale_code == self::$localeCode) {
return;
}
try {
$locale = PhutilLocale::loadLocale($locale_code);
$translations = PhutilTranslation::getTranslationMapForLocale(
$locale_code);
$override = self::getEnvConfig('translation.override');
if (!is_array($override)) {
$override = array();
}
PhutilTranslator::getInstance()
->setLocale($locale)
->setTranslations($override + $translations);
self::$localeCode = $locale_code;
} catch (Exception $ex) {
// Just ignore this; the user likely has an out-of-date locale code.
}
}
private static function buildConfigurationSourceStack($config_optional) {
self::dropConfigCache();
$stack = new PhabricatorConfigStackSource();
self::$sourceStack = $stack;
$default_source = id(new PhabricatorConfigDefaultSource())
->setName(pht('Global Default'));
$stack->pushSource($default_source);
$env = self::getSelectedEnvironmentName();
if ($env) {
$stack->pushSource(
id(new PhabricatorConfigFileSource($env))
->setName(pht("File '%s'", $env)));
}
$stack->pushSource(
id(new PhabricatorConfigLocalSource())
->setName(pht('Local Config')));
// If the install overrides the database adapter, we might need to load
// the database adapter class before we can push on the database config.
// This config is locked and can't be edited from the web UI anyway.
foreach (self::getEnvConfig('load-libraries') as $library) {
phutil_load_library($library);
}
// Drop any class map caches, since they will have generated without
// any classes from libraries. Without this, preflight setup checks can
// cause generation of a setup check cache that omits checks defined in
// libraries, for example.
PhutilClassMapQuery::deleteCaches();
// If custom libraries specify config options, they won't get default
// values as the Default source has already been loaded, so we get it to
// pull in all options from non-phabricator libraries now they are loaded.
$default_source->loadExternalOptions();
// If this install has site config sources, load them now.
$site_sources = id(new PhutilClassMapQuery())
->setAncestorClass('PhabricatorConfigSiteSource')
->setSortMethod('getPriority')
->execute();
foreach ($site_sources as $site_source) {
$stack->pushSource($site_source);
}
$masters = PhabricatorDatabaseRef::getMasterDatabaseRefs();
if (!$masters) {
self::setReadOnly(true, self::READONLY_MASTERLESS);
} else {
// If any master is severed, we drop to readonly mode. In theory we
// could try to continue if we're only missing some applications, but
// this is very complex and we're unlikely to get it right.
foreach ($masters as $master) {
// Give severed masters one last chance to get healthy.
if ($master->isSevered()) {
$master->checkHealth();
}
if ($master->isSevered()) {
self::setReadOnly(true, self::READONLY_SEVERED);
break;
}
}
}
try {
$stack->pushSource(
id(new PhabricatorConfigDatabaseSource('default'))
->setName(pht('Database')));
} catch (AphrontSchemaQueryException $exception) {
// If the database is not available, just skip this configuration
// source. This happens during `bin/storage upgrade`, `bin/conf` before
// schema setup, etc.
} catch (PhabricatorClusterStrandedException $ex) {
// This means we can't connect to any database host. That's fine as
// long as we're running a setup script like `bin/storage`.
if (!$config_optional) {
throw $ex;
}
}
}
public static function repairConfig($key, $value) {
if (!self::$repairSource) {
self::$repairSource = id(new PhabricatorConfigDictionarySource(array()))
->setName(pht('Repaired Config'));
self::$sourceStack->pushSource(self::$repairSource);
}
self::$repairSource->setKeys(array($key => $value));
self::dropConfigCache();
}
public static function overrideConfig($key, $value) {
if (!self::$overrideSource) {
self::$overrideSource = id(new PhabricatorConfigDictionarySource(array()))
->setName(pht('Overridden Config'));
self::$sourceStack->pushSource(self::$overrideSource);
}
self::$overrideSource->setKeys(array($key => $value));
self::dropConfigCache();
}
public static function getUnrepairedEnvConfig($key, $default = null) {
foreach (self::$sourceStack->getStack() as $source) {
if ($source === self::$repairSource) {
continue;
}
$result = $source->getKeys(array($key));
if ($result) {
return $result[$key];
}
}
return $default;
}
public static function getSelectedEnvironmentName() {
$env_var = 'PHABRICATOR_ENV';
$env = idx($_SERVER, $env_var);
if (!$env) {
$env = getenv($env_var);
}
if (!$env) {
$env = idx($_ENV, $env_var);
}
if (!$env) {
$root = dirname(phutil_get_library_root('phabricator'));
$path = $root.'/conf/local/ENVIRONMENT';
if (Filesystem::pathExists($path)) {
$env = trim(Filesystem::readFile($path));
}
}
return $env;
}
/* -( Reading Configuration )---------------------------------------------- */
/**
* Get the current configuration setting for a given key.
*
* If the key is not found, then throw an Exception.
*
* @task read
*/
public static function getEnvConfig($key) {
if (!self::$sourceStack) {
throw new Exception(
pht(
'Trying to read configuration "%s" before configuration has been '.
'initialized.',
$key));
}
if (isset(self::$cache[$key])) {
return self::$cache[$key];
}
if (array_key_exists($key, self::$cache)) {
return self::$cache[$key];
}
$result = self::$sourceStack->getKeys(array($key));
if (array_key_exists($key, $result)) {
self::$cache[$key] = $result[$key];
return $result[$key];
} else {
throw new Exception(
pht(
"No config value specified for key '%s'.",
$key));
}
}
/**
* Get the current configuration setting for a given key. If the key
* does not exist, return a default value instead of throwing. This is
* primarily useful for migrations involving keys which are slated for
* removal.
*
* @task read
*/
public static function getEnvConfigIfExists($key, $default = null) {
try {
return self::getEnvConfig($key);
} catch (Exception $ex) {
return $default;
}
}
/**
* Get the fully-qualified URI for a path.
*
* @task read
*/
public static function getURI($path) {
return rtrim(self::getAnyBaseURI(), '/').$path;
}
/**
* Get the fully-qualified production URI for a path.
*
* @task read
*/
public static function getProductionURI($path) {
// If we're passed a URI which already has a domain, simply return it
// unmodified. In particular, files may have URIs which point to a CDN
// domain.
$uri = new PhutilURI($path);
if ($uri->getDomain()) {
return $path;
}
$production_domain = self::getEnvConfig('phabricator.production-uri');
if (!$production_domain) {
$production_domain = self::getAnyBaseURI();
}
return rtrim($production_domain, '/').$path;
}
public static function isSelfURI($raw_uri) {
$uri = new PhutilURI($raw_uri);
$host = $uri->getDomain();
if (!strlen($host)) {
return false;
}
$host = phutil_utf8_strtolower($host);
$self_map = self::getSelfURIMap();
return isset($self_map[$host]);
}
private static function getSelfURIMap() {
$self_uris = array();
$self_uris[] = self::getProductionURI('/');
$self_uris[] = self::getURI('/');
$allowed_uris = self::getEnvConfig('phabricator.allowed-uris');
foreach ($allowed_uris as $allowed_uri) {
$self_uris[] = $allowed_uri;
}
$self_map = array();
foreach ($self_uris as $self_uri) {
$host = id(new PhutilURI($self_uri))->getDomain();
if (!strlen($host)) {
continue;
}
$host = phutil_utf8_strtolower($host);
$self_map[$host] = $host;
}
return $self_map;
}
/**
* Get the fully-qualified production URI for a static resource path.
*
* @task read
*/
public static function getCDNURI($path) {
$alt = self::getEnvConfig('security.alternate-file-domain');
if (!$alt) {
$alt = self::getAnyBaseURI();
}
$uri = new PhutilURI($alt);
$uri->setPath($path);
return (string)$uri;
}
/**
* Get the fully-qualified production URI for a documentation resource.
*
* @task read
*/
public static function getDoclink($resource, $type = 'article') {
$uri = new PhutilURI('https://secure.phabricator.com/diviner/find/');
$uri->setQueryParam('name', $resource);
$uri->setQueryParam('type', $type);
$uri->setQueryParam('jump', true);
return (string)$uri;
}
/**
* Build a concrete object from a configuration key.
*
* @task read
*/
public static function newObjectFromConfig($key, $args = array()) {
$class = self::getEnvConfig($key);
return newv($class, $args);
}
public static function getAnyBaseURI() {
$base_uri = self::getEnvConfig('phabricator.base-uri');
if (!$base_uri) {
$base_uri = self::getRequestBaseURI();
}
if (!$base_uri) {
throw new Exception(
pht(
"Define '%s' in your configuration to continue.",
'phabricator.base-uri'));
}
return $base_uri;
}
public static function getRequestBaseURI() {
return self::$requestBaseURI;
}
public static function setRequestBaseURI($uri) {
self::$requestBaseURI = $uri;
}
public static function isReadOnly() {
if (self::$readOnly !== null) {
return self::$readOnly;
}
return self::getEnvConfig('cluster.read-only');
}
public static function setReadOnly($read_only, $reason) {
self::$readOnly = $read_only;
self::$readOnlyReason = $reason;
}
public static function getReadOnlyMessage() {
$reason = self::getReadOnlyReason();
switch ($reason) {
case self::READONLY_MASTERLESS:
return pht(
'Phabricator is in read-only mode (no writable database '.
'is configured).');
case self::READONLY_UNREACHABLE:
return pht(
'Phabricator is in read-only mode (unreachable master).');
case self::READONLY_SEVERED:
return pht(
'Phabricator is in read-only mode (major interruption).');
}
return pht('Phabricator is in read-only mode.');
}
public static function getReadOnlyURI() {
return urisprintf(
'/readonly/%s/',
self::getReadOnlyReason());
}
public static function getReadOnlyReason() {
if (!self::isReadOnly()) {
return null;
}
if (self::$readOnlyReason !== null) {
return self::$readOnlyReason;
}
return self::READONLY_CONFIG;
}
/* -( Unit Test Support )-------------------------------------------------- */
/**
* @task test
*/
public static function beginScopedEnv() {
return new PhabricatorScopedEnv(self::pushTestEnvironment());
}
/**
* @task test
*/
private static function pushTestEnvironment() {
self::dropConfigCache();
$source = new PhabricatorConfigDictionarySource(array());
self::$sourceStack->pushSource($source);
return spl_object_hash($source);
}
/**
* @task test
*/
public static function popTestEnvironment($key) {
self::dropConfigCache();
$source = self::$sourceStack->popSource();
$stack_key = spl_object_hash($source);
if ($stack_key !== $key) {
self::$sourceStack->pushSource($source);
throw new Exception(
pht(
'Scoped environments were destroyed in a different order than they '.
'were initialized.'));
}
}
/* -( URI Validation )----------------------------------------------------- */
/**
* Detect if a URI satisfies either @{method:isValidLocalURIForLink} or
* @{method:isValidRemoteURIForLink}, i.e. is a page on this server or the
* URI of some other resource which has a valid protocol. This rejects
* garbage URIs and URIs with protocols which do not appear in the
* `uri.allowed-protocols` configuration, notably 'javascript:' URIs.
*
* NOTE: This method is generally intended to reject URIs which it may be
* unsafe to put in an "href" link attribute.
*
* @param string URI to test.
* @return bool True if the URI identifies a web resource.
* @task uri
*/
public static function isValidURIForLink($uri) {
return self::isValidLocalURIForLink($uri) ||
self::isValidRemoteURIForLink($uri);
}
/**
* Detect if a URI identifies some page on this server.
*
* NOTE: This method is generally intended to reject URIs which it may be
* unsafe to issue a "Location:" redirect to.
*
* @param string URI to test.
* @return bool True if the URI identifies a local page.
* @task uri
*/
public static function isValidLocalURIForLink($uri) {
$uri = (string)$uri;
if (!strlen($uri)) {
return false;
}
if (preg_match('/\s/', $uri)) {
// PHP hasn't been vulnerable to header injection attacks for a bunch of
// years, but we can safely reject these anyway since they're never valid.
return false;
}
// Chrome (at a minimum) interprets backslashes in Location headers and the
// URL bar as forward slashes. This is probably intended to reduce user
// error caused by confusion over which key is "forward slash" vs "back
// slash".
//
// However, it means a URI like "/\evil.com" is interpreted like
// "//evil.com", which is a protocol relative remote URI.
//
// Since we currently never generate URIs with backslashes in them, reject
// these unconditionally rather than trying to figure out how browsers will
// interpret them.
if (preg_match('/\\\\/', $uri)) {
return false;
}
// Valid URIs must begin with '/', followed by the end of the string or some
// other non-'/' character. This rejects protocol-relative URIs like
// "//evil.com/evil_stuff/".
return (bool)preg_match('@^/([^/]|$)@', $uri);
}
/**
* Detect if a URI identifies some valid linkable remote resource.
*
* @param string URI to test.
- * @return bool True if a URI idenfies a remote resource with an allowed
+ * @return bool True if a URI identifies a remote resource with an allowed
* protocol.
* @task uri
*/
public static function isValidRemoteURIForLink($uri) {
try {
self::requireValidRemoteURIForLink($uri);
return true;
} catch (Exception $ex) {
return false;
}
}
/**
* Detect if a URI identifies a valid linkable remote resource, throwing a
* detailed message if it does not.
*
* A valid linkable remote resource can be safely linked or redirected to.
* This is primarily a protocol whitelist check.
*
* @param string URI to test.
* @return void
* @task uri
*/
public static function requireValidRemoteURIForLink($raw_uri) {
$uri = new PhutilURI($raw_uri);
$proto = $uri->getProtocol();
if (!strlen($proto)) {
throw new Exception(
pht(
'URI "%s" is not a valid linkable resource. A valid linkable '.
'resource URI must specify a protocol.',
$raw_uri));
}
$protocols = self::getEnvConfig('uri.allowed-protocols');
if (!isset($protocols[$proto])) {
throw new Exception(
pht(
'URI "%s" is not a valid linkable resource. A valid linkable '.
'resource URI must use one of these protocols: %s.',
$raw_uri,
implode(', ', array_keys($protocols))));
}
$domain = $uri->getDomain();
if (!strlen($domain)) {
throw new Exception(
pht(
'URI "%s" is not a valid linkable resource. A valid linkable '.
'resource URI must specify a domain.',
$raw_uri));
}
}
/**
* Detect if a URI identifies a valid fetchable remote resource.
*
* @param string URI to test.
* @param list<string> Allowed protocols.
* @return bool True if the URI is a valid fetchable remote resource.
* @task uri
*/
public static function isValidRemoteURIForFetch($uri, array $protocols) {
try {
self::requireValidRemoteURIForFetch($uri, $protocols);
return true;
} catch (Exception $ex) {
return false;
}
}
/**
* Detect if a URI identifies a valid fetchable remote resource, throwing
* a detailed message if it does not.
*
* A valid fetchable remote resource can be safely fetched using a request
* originating on this server. This is a primarily an address check against
* the outbound address blacklist.
*
* @param string URI to test.
* @param list<string> Allowed protocols.
* @return pair<string, string> Pre-resolved URI and domain.
* @task uri
*/
public static function requireValidRemoteURIForFetch(
$raw_uri,
array $protocols) {
$uri = new PhutilURI($raw_uri);
$proto = $uri->getProtocol();
if (!strlen($proto)) {
throw new Exception(
pht(
'URI "%s" is not a valid fetchable resource. A valid fetchable '.
'resource URI must specify a protocol.',
$raw_uri));
}
$protocols = array_fuse($protocols);
if (!isset($protocols[$proto])) {
throw new Exception(
pht(
'URI "%s" is not a valid fetchable resource. A valid fetchable '.
'resource URI must use one of these protocols: %s.',
$raw_uri,
implode(', ', array_keys($protocols))));
}
$domain = $uri->getDomain();
if (!strlen($domain)) {
throw new Exception(
pht(
'URI "%s" is not a valid fetchable resource. A valid fetchable '.
'resource URI must specify a domain.',
$raw_uri));
}
$addresses = gethostbynamel($domain);
if (!$addresses) {
throw new Exception(
pht(
'URI "%s" is not a valid fetchable resource. The domain "%s" could '.
'not be resolved.',
$raw_uri,
$domain));
}
foreach ($addresses as $address) {
if (self::isBlacklistedOutboundAddress($address)) {
throw new Exception(
pht(
'URI "%s" is not a valid fetchable resource. The domain "%s" '.
'resolves to the address "%s", which is blacklisted for '.
'outbound requests.',
$raw_uri,
$domain,
$address));
}
}
$resolved_uri = clone $uri;
$resolved_uri->setDomain(head($addresses));
return array($resolved_uri, $domain);
}
/**
* Determine if an IP address is in the outbound address blacklist.
*
* @param string IP address.
* @return bool True if the address is blacklisted.
*/
public static function isBlacklistedOutboundAddress($address) {
$blacklist = self::getEnvConfig('security.outbound-blacklist');
return PhutilCIDRList::newList($blacklist)->containsAddress($address);
}
public static function isClusterRemoteAddress() {
$cluster_addresses = self::getEnvConfig('cluster.addresses');
if (!$cluster_addresses) {
return false;
}
$address = self::getRemoteAddress();
if (!$address) {
throw new Exception(
pht(
'Unable to test remote address against cluster whitelist: '.
'REMOTE_ADDR is not defined or not valid.'));
}
return self::isClusterAddress($address);
}
public static function isClusterAddress($address) {
$cluster_addresses = self::getEnvConfig('cluster.addresses');
if (!$cluster_addresses) {
throw new Exception(
pht(
'Phabricator is not configured to serve cluster requests. '.
'Set `cluster.addresses` in the configuration to whitelist '.
'cluster hosts before sending requests that use a cluster '.
'authentication mechanism.'));
}
return PhutilCIDRList::newList($cluster_addresses)
->containsAddress($address);
}
public static function getRemoteAddress() {
$address = idx($_SERVER, 'REMOTE_ADDR');
if (!$address) {
return null;
}
try {
return PhutilIPAddress::newAddress($address);
} catch (Exception $ex) {
return null;
}
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
public static function envConfigExists($key) {
return array_key_exists($key, self::$sourceStack->getKeys(array($key)));
}
/**
* @task internal
*/
public static function getAllConfigKeys() {
return self::$sourceStack->getAllKeys();
}
public static function getConfigSourceStack() {
return self::$sourceStack;
}
/**
* @task internal
*/
public static function overrideTestEnvConfig($stack_key, $key, $value) {
$tmp = array();
// If we don't have the right key, we'll throw when popping the last
// source off the stack.
do {
$source = self::$sourceStack->popSource();
array_unshift($tmp, $source);
if (spl_object_hash($source) == $stack_key) {
$source->setKeys(array($key => $value));
break;
}
} while (true);
foreach ($tmp as $source) {
self::$sourceStack->pushSource($source);
}
self::dropConfigCache();
}
private static function dropConfigCache() {
self::$cache = array();
}
private static function resetUmask() {
// Reset the umask to the common standard umask. The umask controls default
// permissions when files are created and propagates to subprocesses.
// "022" is the most common umask, but sometimes it is set to something
// unusual by the calling environment.
// Since various things rely on this umask to work properly and we are
// not aware of any legitimate reasons to adjust it, unconditionally
// normalize it until such reasons arise. See T7475 for discussion.
umask(022);
}
/**
* Get the path to an empty directory which is readable by all of the system
* user accounts that Phabricator acts as.
*
* In some cases, a binary needs some valid HOME or CWD to continue, but not
* all user accounts have valid home directories and even if they do they
* may not be readable after a `sudo` operation.
*
* @return string Path to an empty directory suitable for use as a CWD.
*/
public static function getEmptyCWD() {
$root = dirname(phutil_get_library_root('phabricator'));
return $root.'/support/empty/';
}
}
diff --git a/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php b/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php
index 95ba57f0c0..184c2d5151 100644
--- a/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php
+++ b/src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php
@@ -1,1645 +1,1645 @@
<?php
final class PhabricatorUSEnglishTranslation
extends PhutilTranslation {
public function getLocaleCode() {
return 'en_US';
}
protected function getTranslations() {
return array(
'No daemon(s) with id(s) "%s" exist!' => array(
'No daemon with id %s exists!',
'No daemons with ids %s exist!',
),
'These %d configuration value(s) are related:' => array(
'This configuration value is related:',
'These configuration values are related:',
),
'%s Task(s)' => array('Task', 'Tasks'),
'%s ERROR(S)' => array('ERROR', 'ERRORS'),
'%d Error(s)' => array('%d Error', '%d Errors'),
'%d Warning(s)' => array('%d Warning', '%d Warnings'),
'%d Auto-Fix(es)' => array('%d Auto-Fix', '%d Auto-Fixes'),
'%d Advice(s)' => array('%d Advice', '%d Pieces of Advice'),
'%d Detail(s)' => array('%d Detail', '%d Details'),
'(%d line(s))' => array('(%d line)', '(%d lines)'),
'%d line(s)' => array('%d line', '%d lines'),
'%d path(s)' => array('%d path', '%d paths'),
'%d diff(s)' => array('%d diff', '%d diffs'),
'%s Answer(s)' => array('%s Answer', '%s Answers'),
'Show %d Comment(s)' => array('Show %d Comment', 'Show %d Comments'),
'%s DIFF LINK(S)' => array('DIFF LINK', 'DIFF LINKS'),
'You successfully created %d diff(s).' => array(
'You successfully created %d diff.',
'You successfully created %d diffs.',
),
'Diff creation failed; see body for %s error(s).' => array(
'Diff creation failed; see body for error.',
'Diff creation failed; see body for errors.',
),
'There are %d raw fact(s) in storage.' => array(
'There is %d raw fact in storage.',
'There are %d raw facts in storage.',
),
'There are %d aggregate fact(s) in storage.' => array(
'There is %d aggregate fact in storage.',
'There are %d aggregate facts in storage.',
),
'%s Commit(s) Awaiting Audit' => array(
'%s Commit Awaiting Audit',
'%s Commits Awaiting Audit',
),
'%s Problem Commit(s)' => array(
'%s Problem Commit',
'%s Problem Commits',
),
'%s Review(s) Blocking Others' => array(
'%s Review Blocking Others',
'%s Reviews Blocking Others',
),
'%s Review(s) Need Attention' => array(
'%s Review Needs Attention',
'%s Reviews Need Attention',
),
'%s Review(s) Waiting on Others' => array(
'%s Review Waiting on Others',
'%s Reviews Waiting on Others',
),
'%s Active Review(s)' => array(
'%s Active Review',
'%s Active Reviews',
),
'%s Flagged Object(s)' => array(
'%s Flagged Object',
'%s Flagged Objects',
),
'%s Object(s) Tracked' => array(
'%s Object Tracked',
'%s Objects Tracked',
),
'%s Assigned Task(s)' => array(
'%s Assigned Task',
'%s Assigned Tasks',
),
'Show %d Lint Message(s)' => array(
'Show %d Lint Message',
'Show %d Lint Messages',
),
'Hide %d Lint Message(s)' => array(
'Hide %d Lint Message',
'Hide %d Lint Messages',
),
'This is a binary file. It is %s byte(s) in length.' => array(
'This is a binary file. It is %s byte in length.',
'This is a binary file. It is %s bytes in length.',
),
'%s Action(s) Have No Effect' => array(
'Action Has No Effect',
'Actions Have No Effect',
),
'%s Action(s) With No Effect' => array(
'Action With No Effect',
'Actions With No Effect',
),
'Some of your %s action(s) have no effect:' => array(
'One of your actions has no effect:',
'Some of your actions have no effect:',
),
'Apply remaining %d action(s)?' => array(
'Apply remaining action?',
'Apply remaining actions?',
),
'Apply %d Other Action(s)' => array(
'Apply Remaining Action',
'Apply Remaining Actions',
),
'The %s action(s) you are taking have no effect:' => array(
'The action you are taking has no effect:',
'The actions you are taking have no effect:',
),
'%s edited member(s), added %d: %s; removed %d: %s.' =>
'%s edited members, added: %3$s; removed: %5$s.',
'%s added %s member(s): %s.' => array(
array(
'%s added a member: %3$s.',
'%s added members: %3$s.',
),
),
'%s removed %s member(s): %s.' => array(
array(
'%s removed a member: %3$s.',
'%s removed members: %3$s.',
),
),
'%s edited project(s), added %s: %s; removed %s: %s.' =>
'%s edited projects, added: %3$s; removed: %5$s.',
'%s added %s project(s): %s.' => array(
array(
'%s added a project: %3$s.',
'%s added projects: %3$s.',
),
),
'%s removed %s project(s): %s.' => array(
array(
'%s removed a project: %3$s.',
'%s removed projects: %3$s.',
),
),
'%s merged %s task(s): %s.' => array(
array(
'%s merged a task: %3$s.',
'%s merged tasks: %3$s.',
),
),
'%s merged %s task(s) %s into %s.' => array(
array(
'%s merged %3$s into %4$s.',
'%s merged tasks %3$s into %4$s.',
),
),
'%s added %s voting user(s): %s.' => array(
array(
'%s added a voting user: %3$s.',
'%s added voting users: %3$s.',
),
),
'%s removed %s voting user(s): %s.' => array(
array(
'%s removed a voting user: %3$s.',
'%s removed voting users: %3$s.',
),
),
'%s added %s subtask(s): %s.' => array(
array(
'%s added a subtask: %3$s.',
'%s added subtasks: %3$s.',
),
),
'%s added %s parent task(s): %s.' => array(
array(
'%s added a parent task: %3$s.',
'%s added parent tasks: %3$s.',
),
),
'%s removed %s subtask(s): %s.' => array(
array(
'%s removed a subtask: %3$s.',
'%s removed subtasks: %3$s.',
),
),
'%s removed %s parent task(s): %s.' => array(
array(
'%s removed a parent task: %3$s.',
'%s removed parent tasks: %3$s.',
),
),
'%s added %s subtask(s) for %s: %s.' => array(
array(
'%s added a subtask for %3$s: %4$s.',
'%s added subtasks for %3$s: %4$s.',
),
),
'%s added %s parent task(s) for %s: %s.' => array(
array(
'%s added a parent task for %3$s: %4$s.',
'%s added parent tasks for %3$s: %4$s.',
),
),
'%s removed %s subtask(s) for %s: %s.' => array(
array(
'%s removed a subtask for %3$s: %4$s.',
'%s removed subtasks for %3$s: %4$s.',
),
),
'%s removed %s parent task(s) for %s: %s.' => array(
array(
'%s removed a parent task for %3$s: %4$s.',
'%s removed parent tasks for %3$s: %4$s.',
),
),
'%s edited subtask(s), added %s: %s; removed %s: %s.' =>
'%s edited subtasks, added: %3$s; removed: %5$s.',
'%s edited subtask(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited subtasks for %s, added: %4$s; removed: %6$s.',
'%s edited parent task(s), added %s: %s; removed %s: %s.' =>
'%s edited parent tasks, added: %3$s; removed: %5$s.',
'%s edited parent task(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited parent tasks for %s, added: %4$s; removed: %6$s.',
'%s edited answer(s), added %s: %s; removed %d: %s.' =>
'%s edited answers, added: %3$s; removed: %5$s.',
'%s added %s answer(s): %s.' => array(
array(
'%s added an answer: %3$s.',
'%s added answers: %3$s.',
),
),
'%s removed %s answer(s): %s.' => array(
array(
'%s removed a answer: %3$s.',
'%s removed answers: %3$s.',
),
),
'%s edited question(s), added %s: %s; removed %s: %s.' =>
'%s edited questions, added: %3$s; removed: %5$s.',
'%s added %s question(s): %s.' => array(
array(
'%s added a question: %3$s.',
'%s added questions: %3$s.',
),
),
'%s removed %s question(s): %s.' => array(
array(
'%s removed a question: %3$s.',
'%s removed questions: %3$s.',
),
),
'%s edited mock(s), added %s: %s; removed %s: %s.' =>
'%s edited mocks, added: %3$s; removed: %5$s.',
'%s added %s mock(s): %s.' => array(
array(
'%s added a mock: %3$s.',
'%s added mocks: %3$s.',
),
),
'%s removed %s mock(s): %s.' => array(
array(
'%s removed a mock: %3$s.',
'%s removed mocks: %3$s.',
),
),
'%s added %s task(s): %s.' => array(
array(
'%s added a task: %3$s.',
'%s added tasks: %3$s.',
),
),
'%s removed %s task(s): %s.' => array(
array(
'%s removed a task: %3$s.',
'%s removed tasks: %3$s.',
),
),
'%s edited file(s), added %s: %s; removed %s: %s.' =>
'%s edited files, added: %3$s; removed: %5$s.',
'%s added %s file(s): %s.' => array(
array(
'%s added a file: %3$s.',
'%s added files: %3$s.',
),
),
'%s removed %s file(s): %s.' => array(
array(
'%s removed a file: %3$s.',
'%s removed files: %3$s.',
),
),
'%s edited contributor(s), added %s: %s; removed %s: %s.' =>
'%s edited contributors, added: %3$s; removed: %5$s.',
'%s added %s contributor(s): %s.' => array(
array(
'%s added a contributor: %3$s.',
'%s added contributors: %3$s.',
),
),
'%s removed %s contributor(s): %s.' => array(
array(
'%s removed a contributor: %3$s.',
'%s removed contributors: %3$s.',
),
),
'%s edited %s reviewer(s), added %s: %s; removed %s: %s.' =>
'%s edited reviewers, added: %4$s; removed: %6$s.',
'%s edited %s reviewer(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited reviewers for %3$s, added: %5$s; removed: %7$s.',
'%s added %s reviewer(s): %s.' => array(
array(
'%s added a reviewer: %3$s.',
'%s added reviewers: %3$s.',
),
),
'%s added %s reviewer(s) for %s: %s.' => array(
array(
'%s added a reviewer for %3$s: %4$s.',
'%s added reviewers for %3$s: %4$s.',
),
),
'%s removed %s reviewer(s): %s.' => array(
array(
'%s removed a reviewer: %3$s.',
'%s removed reviewers: %3$s.',
),
),
'%s removed %s reviewer(s) for %s: %s.' => array(
array(
'%s removed a reviewer for %3$s: %4$s.',
'%s removed reviewers for %3$s: %4$s.',
),
),
'%d other(s)' => array(
'1 other',
'%d others',
),
'%s edited subscriber(s), added %d: %s; removed %d: %s.' =>
'%s edited subscribers, added: %3$s; removed: %5$s.',
'%s added %d subscriber(s): %s.' => array(
array(
'%s added a subscriber: %3$s.',
'%s added subscribers: %3$s.',
),
),
'%s removed %d subscriber(s): %s.' => array(
array(
'%s removed a subscriber: %3$s.',
'%s removed subscribers: %3$s.',
),
),
'%s edited watcher(s), added %s: %s; removed %d: %s.' =>
'%s edited watchers, added: %3$s; removed: %5$s.',
'%s added %s watcher(s): %s.' => array(
array(
'%s added a watcher: %3$s.',
'%s added watchers: %3$s.',
),
),
'%s removed %s watcher(s): %s.' => array(
array(
'%s removed a watcher: %3$s.',
'%s removed watchers: %3$s.',
),
),
'%s edited participant(s), added %d: %s; removed %d: %s.' =>
'%s edited participants, added: %3$s; removed: %5$s.',
'%s added %d participant(s): %s.' => array(
array(
'%s added a participant: %3$s.',
'%s added participants: %3$s.',
),
),
'%s removed %d participant(s): %s.' => array(
array(
'%s removed a participant: %3$s.',
'%s removed participants: %3$s.',
),
),
'%s edited image(s), added %d: %s; removed %d: %s.' =>
'%s edited images, added: %3$s; removed: %5$s',
'%s added %d image(s): %s.' => array(
array(
'%s added an image: %3$s.',
'%s added images: %3$s.',
),
),
'%s removed %d image(s): %s.' => array(
array(
'%s removed an image: %3$s.',
'%s removed images: %3$s.',
),
),
'%s Line(s)' => array(
'%s Line',
'%s Lines',
),
'Indexing %d object(s) of type %s.' => array(
'Indexing %d object of type %s.',
'Indexing %d object of type %s.',
),
'Run these %d command(s):' => array(
'Run this command:',
'Run these commands:',
),
'Install these %d PHP extension(s):' => array(
'Install this PHP extension:',
'Install these PHP extensions:',
),
'The current Phabricator configuration has these %d value(s):' => array(
'The current Phabricator configuration has this value:',
'The current Phabricator configuration has these values:',
),
'The current MySQL configuration has these %d value(s):' => array(
'The current MySQL configuration has this value:',
'The current MySQL configuration has these values:',
),
'You can update these %d value(s) here:' => array(
'You can update this value here:',
'You can update these values here:',
),
'The current PHP configuration has these %d value(s):' => array(
'The current PHP configuration has this value:',
'The current PHP configuration has these values:',
),
'To update these %d value(s), edit your PHP configuration file.' => array(
'To update this %d value, edit your PHP configuration file.',
'To update these %d values, edit your PHP configuration file.',
),
'To update these %d value(s), edit your PHP configuration file, located '.
'here:' => array(
'To update this value, edit your PHP configuration file, located '.
'here:',
'To update these values, edit your PHP configuration file, located '.
'here:',
),
'PHP also loaded these %s configuration file(s):' => array(
'PHP also loaded this configuration file:',
'PHP also loaded these configuration files:',
),
'%s added %d inline comment(s).' => array(
array(
'%s added an inline comment.',
'%s added inline comments.',
),
),
'%s comment(s)' => array('%s comment', '%s comments'),
'%s rejection(s)' => array('%s rejection', '%s rejections'),
'%s update(s)' => array('%s update', '%s updates'),
'This configuration value is defined in these %d '.
'configuration source(s): %s.' => array(
'This configuration value is defined in this '.
'configuration source: %2$s.',
'This configuration value is defined in these %d '.
'configuration sources: %s.',
),
'%s Open Pull Request(s)' => array(
'%s Open Pull Request',
'%s Open Pull Requests',
),
'Stale (%s day(s))' => array(
'Stale (%s day)',
'Stale (%s days)',
),
'Old (%s day(s))' => array(
'Old (%s day)',
'Old (%s days)',
),
'%s Commit(s)' => array(
'%s Commit',
'%s Commits',
),
'%s attached %d file(s): %s.' => array(
array(
'%s attached a file: %3$s.',
'%s attached files: %3$s.',
),
),
'%s detached %d file(s): %s.' => array(
array(
'%s detached a file: %3$s.',
'%s detached files: %3$s.',
),
),
'%s changed file(s), attached %d: %s; detached %d: %s.' =>
'%s changed files, attached: %3$s; detached: %5$s.',
'%s added %s dependencie(s): %s.' => array(
array(
'%s added a dependency: %3$s.',
'%s added dependencies: %3$s.',
),
),
'%s added %s dependencie(s) for %s: %s.' => array(
array(
'%s added a dependency for %3$s: %4$s.',
'%s added dependencies for %3$s: %4$s.',
),
),
'%s removed %s dependencie(s): %s.' => array(
array(
'%s removed a dependency: %3$s.',
'%s removed dependencies: %3$s.',
),
),
'%s removed %s dependencie(s) for %s: %s.' => array(
array(
'%s removed a dependency for %3$s: %4$s.',
'%s removed dependencies for %3$s: %4$s.',
),
),
'%s edited dependencie(s), added %s: %s; removed %s: %s.' => array(
'%s edited dependencies, added: %3$s; removed: %5$s.',
),
'%s edited dependencie(s) for %s, added %s: %s; removed %s: %s.' => array(
'%s edited dependencies for %s, added: %3$s; removed: %5$s.',
),
'%s added %s dependent revision(s): %s.' => array(
array(
'%s added a dependent revision: %3$s.',
'%s added dependent revisions: %3$s.',
),
),
'%s added %s dependent revision(s) for %s: %s.' => array(
array(
'%s added a dependent revision for %3$s: %4$s.',
'%s added dependent revisions for %3$s: %4$s.',
),
),
'%s removed %s dependent revision(s): %s.' => array(
array(
'%s removed a dependent revision: %3$s.',
'%s removed dependent revisions: %3$s.',
),
),
'%s removed %s dependent revision(s) for %s: %s.' => array(
array(
'%s removed a dependent revision for %3$s: %4$s.',
'%s removed dependent revisions for %3$s: %4$s.',
),
),
'%s added %s commit(s): %s.' => array(
array(
'%s added a commit: %3$s.',
'%s added commits: %3$s.',
),
),
'%s removed %s commit(s): %s.' => array(
array(
'%s removed a commit: %3$s.',
'%s removed commits: %3$s.',
),
),
'%s edited commit(s), added %s: %s; removed %s: %s.' =>
'%s edited commits, added %3$s; removed %5$s.',
'%s added %s reverted commit(s): %s.' => array(
array(
'%s added a reverted commit: %3$s.',
'%s added reverted commits: %3$s.',
),
),
'%s removed %s reverted commit(s): %s.' => array(
array(
'%s removed a reverted commit: %3$s.',
'%s removed reverted commits: %3$s.',
),
),
'%s edited reverted commit(s), added %s: %s; removed %s: %s.' =>
'%s edited reverted commits, added %3$s; removed %5$s.',
'%s added %s reverted commit(s) for %s: %s.' => array(
array(
'%s added a reverted commit for %3$s: %4$s.',
'%s added reverted commits for %3$s: %4$s.',
),
),
'%s removed %s reverted commit(s) for %s: %s.' => array(
array(
'%s removed a reverted commit for %3$s: %4$s.',
'%s removed reverted commits for %3$s: %4$s.',
),
),
'%s edited reverted commit(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited reverted commits for %2$s, added %4$s; removed %6$s.',
'%s added %s reverting commit(s): %s.' => array(
array(
'%s added a reverting commit: %3$s.',
'%s added reverting commits: %3$s.',
),
),
'%s removed %s reverting commit(s): %s.' => array(
array(
'%s removed a reverting commit: %3$s.',
'%s removed reverting commits: %3$s.',
),
),
'%s edited reverting commit(s), added %s: %s; removed %s: %s.' =>
'%s edited reverting commits, added %3$s; removed %5$s.',
'%s added %s reverting commit(s) for %s: %s.' => array(
array(
'%s added a reverting commit for %3$s: %4$s.',
- '%s added reverting commitsi for %3$s: %4$s.',
+ '%s added reverting commits for %3$s: %4$s.',
),
),
'%s removed %s reverting commit(s) for %s: %s.' => array(
array(
'%s removed a reverting commit for %3$s: %4$s.',
'%s removed reverting commits for %3$s: %4$s.',
),
),
'%s edited reverting commit(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited reverting commits for %s, added %4$s; removed %6$s.',
'%s changed project member(s), added %d: %s; removed %d: %s.' =>
'%s changed project members, added %3$s; removed %5$s.',
'%s added %d project member(s): %s.' => array(
array(
'%s added a member: %3$s.',
'%s added members: %3$s.',
),
),
'%s removed %d project member(s): %s.' => array(
array(
'%s removed a member: %3$s.',
'%s removed members: %3$s.',
),
),
'%s project hashtag(s) are already used by other projects: %s.' => array(
'Project hashtag "%2$s" is already used by another project.',
'Some project hashtags are already used by other projects: %2$s.',
),
'%s changed project hashtag(s), added %d: %s; removed %d: %s.' =>
'%s changed project hashtags, added %3$s; removed %5$s.',
'Hashtags must contain at least one letter or number. %s '.
'project hashtag(s) are invalid: %s.' => array(
'Hashtags must contain at least one letter or number. The '.
'hashtag "%2$s" is not valid.',
'Hashtags must contain at least one letter or number. These '.
'hashtags are invalid: %2$s.',
),
'%s added %d project hashtag(s): %s.' => array(
array(
'%s added a hashtag: %3$s.',
'%s added hashtags: %3$s.',
),
),
'%s removed %d project hashtag(s): %s.' => array(
array(
'%s removed a hashtag: %3$s.',
'%s removed hashtags: %3$s.',
),
),
'%s changed %s hashtag(s), added %d: %s; removed %d: %s.' =>
'%s changed hashtags for %s, added %4$s; removed %6$s.',
'%s added %d %s hashtag(s): %s.' => array(
array(
'%s added a hashtag to %3$s: %4$s.',
'%s added hashtags to %3$s: %4$s.',
),
),
'%s removed %d %s hashtag(s): %s.' => array(
array(
'%s removed a hashtag from %3$s: %4$s.',
'%s removed hashtags from %3$s: %4$s.',
),
),
'%d User(s) Need Approval' => array(
'%d User Needs Approval',
'%d Users Need Approval',
),
'%s, %s line(s)' => array(
array(
'%s, %s line',
'%s, %s lines',
),
),
'%s pushed %d commit(s) to %s.' => array(
array(
'%s pushed a commit to %3$s.',
'%s pushed %d commits to %s.',
),
),
'%s commit(s)' => array(
'1 commit',
'%s commits',
),
'%s removed %s JIRA issue(s): %s.' => array(
array(
'%s removed a JIRA issue: %3$s.',
'%s removed JIRA issues: %3$s.',
),
),
'%s added %s JIRA issue(s): %s.' => array(
array(
'%s added a JIRA issue: %3$s.',
'%s added JIRA issues: %3$s.',
),
),
'%s added %s required legal document(s): %s.' => array(
array(
'%s added a required legal document: %3$s.',
'%s added required legal documents: %3$s.',
),
),
'%s updated JIRA issue(s): added %s %s; removed %d %s.' =>
'%s updated JIRA issues: added %3$s; removed %5$s.',
'%s edited %s task(s), added %s: %s; removed %s: %s.' =>
'%s edited tasks, added %4$s; removed %6$s.',
'%s added %s task(s) to %s: %s.' => array(
array(
'%s added a task to %3$s: %4$s.',
'%s added tasks to %3$s: %4$s.',
),
),
'%s removed %s task(s) from %s: %s.' => array(
array(
'%s removed a task from %3$s: %4$s.',
'%s removed tasks from %3$s: %4$s.',
),
),
'%s edited %s task(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited tasks for %3$s, added: %5$s; removed %7$s.',
'%s edited %s commit(s), added %s: %s; removed %s: %s.' =>
'%s edited commits, added %4$s; removed %6$s.',
'%s added %s commit(s) to %s: %s.' => array(
array(
'%s added a commit to %3$s: %4$s.',
'%s added commits to %3$s: %4$s.',
),
),
'%s removed %s commit(s) from %s: %s.' => array(
array(
'%s removed a commit from %3$s: %4$s.',
'%s removed commits from %3$s: %4$s.',
),
),
'%s edited %s commit(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited commits for %3$s, added: %5$s; removed %7$s.',
'%s added %s revision(s): %s.' => array(
array(
'%s added a revision: %3$s.',
'%s added revisions: %3$s.',
),
),
'%s removed %s revision(s): %s.' => array(
array(
'%s removed a revision: %3$s.',
'%s removed revisions: %3$s.',
),
),
'%s edited %s revision(s), added %s: %s; removed %s: %s.' =>
'%s edited revisions, added %4$s; removed %6$s.',
'%s added %s revision(s) to %s: %s.' => array(
array(
'%s added a revision to %3$s: %4$s.',
'%s added revisions to %3$s: %4$s.',
),
),
'%s removed %s revision(s) from %s: %s.' => array(
array(
'%s removed a revision from %3$s: %4$s.',
'%s removed revisions from %3$s: %4$s.',
),
),
'%s edited %s revision(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited revisions for %3$s, added: %5$s; removed %7$s.',
'%s edited %s project(s), added %s: %s; removed %s: %s.' =>
'%s edited projects, added %4$s; removed %6$s.',
'%s added %s project(s) to %s: %s.' => array(
array(
'%s added a project to %3$s: %4$s.',
'%s added projects to %3$s: %4$s.',
),
),
'%s removed %s project(s) from %s: %s.' => array(
array(
'%s removed a project from %3$s: %4$s.',
'%s removed projects from %3$s: %4$s.',
),
),
'%s edited %s project(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited projects for %3$s, added: %5$s; removed %7$s.',
'%s added %s panel(s): %s.' => array(
array(
'%s added a panel: %3$s.',
'%s added panels: %3$s.',
),
),
'%s removed %s panel(s): %s.' => array(
array(
'%s removed a panel: %3$s.',
'%s removed panels: %3$s.',
),
),
'%s edited %s panel(s), added %s: %s; removed %s: %s.' =>
'%s edited panels, added %4$s; removed %6$s.',
'%s added %s dashboard(s): %s.' => array(
array(
'%s added a dashboard: %3$s.',
'%s added dashboards: %3$s.',
),
),
'%s removed %s dashboard(s): %s.' => array(
array(
'%s removed a dashboard: %3$s.',
'%s removed dashboards: %3$s.',
),
),
'%s edited %s dashboard(s), added %s: %s; removed %s: %s.' =>
'%s edited dashboards, added %4$s; removed %6$s.',
'%s added %s edge(s): %s.' => array(
array(
'%s added an edge: %3$s.',
'%s added edges: %3$s.',
),
),
'%s added %s edge(s) to %s: %s.' => array(
array(
'%s added an edge to %3$s: %4$s.',
'%s added edges to %3$s: %4$s.',
),
),
'%s removed %s edge(s): %s.' => array(
array(
'%s removed an edge: %3$s.',
'%s removed edges: %3$s.',
),
),
'%s removed %s edge(s) from %s: %s.' => array(
array(
'%s removed an edge from %3$s: %4$s.',
'%s removed edges from %3$s: %4$s.',
),
),
'%s edited edge(s), added %s: %s; removed %s: %s.' =>
'%s edited edges, added: %3$s; removed: %5$s.',
'%s edited %s edge(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited edges for %3$s, added: %5$s; removed %7$s.',
'%s added %s member(s) for %s: %s.' => array(
array(
'%s added a member for %3$s: %4$s.',
'%s added members for %3$s: %4$s.',
),
),
'%s removed %s member(s) for %s: %s.' => array(
array(
'%s removed a member for %3$s: %4$s.',
'%s removed members for %3$s: %4$s.',
),
),
'%s edited %s member(s) for %s, added %s: %s; removed %s: %s.' =>
'%s edited members for %3$s, added: %5$s; removed %7$s.',
'%d related link(s):' => array(
'Related link:',
'Related links:',
),
'You have %d unpaid invoice(s).' => array(
'You have an unpaid invoice.',
'You have unpaid invoices.',
),
'The configurations differ in the following %s way(s):' => array(
'The configurations differ:',
'The configurations differ in these ways:',
),
'Phabricator is configured with an email domain whitelist (in %s), so '.
'only users with a verified email address at one of these %s '.
'allowed domain(s) will be able to register an account: %s' => array(
array(
'Phabricator is configured with an email domain whitelist (in %s), '.
'so only users with a verified email address at %3$s will be '.
'allowed to register an account.',
'Phabricator is configured with an email domain whitelist (in %s), '.
'so only users with a verified email address at one of these '.
'allowed domains will be able to register an account: %3$s',
),
),
'Show First %d Line(s)' => array(
'Show First Line',
'Show First %d Lines',
),
"\xE2\x96\xB2 Show %d Line(s)" => array(
"\xE2\x96\xB2 Show Line",
"\xE2\x96\xB2 Show %d Lines",
),
'Show All %d Line(s)' => array(
'Show Line',
'Show All %d Lines',
),
"\xE2\x96\xBC Show %d Line(s)" => array(
"\xE2\x96\xBC Show Line",
"\xE2\x96\xBC Show %d Lines",
),
'Show Last %d Line(s)' => array(
'Show Last Line',
'Show Last %d Lines',
),
'%s marked %s inline comment(s) as done and %s inline comment(s) as '.
'not done.' => array(
array(
array(
'%s marked an inline comment as done and an inline comment '.
'as not done.',
'%s marked an inline comment as done and %3$s inline comments '.
'as not done.',
),
array(
'%s marked %s inline comments as done and an inline comment '.
'as not done.',
'%s marked %s inline comments as done and %s inline comments '.
'as done.',
),
),
),
'%s marked %s inline comment(s) as done.' => array(
array(
'%s marked an inline comment as done.',
'%s marked %s inline comments as done.',
),
),
'%s marked %s inline comment(s) as not done.' => array(
array(
'%s marked an inline comment as not done.',
'%s marked %s inline comments as not done.',
),
),
'These %s object(s) will be destroyed forever:' => array(
'This object will be destroyed forever:',
'These objects will be destroyed forever:',
),
'Are you absolutely certain you want to destroy these %s '.
'object(s)?' => array(
'Are you absolutely certain you want to destroy this object?',
'Are you absolutely certain you want to destroy these objects?',
),
'%s added %s owner(s): %s.' => array(
array(
'%s added an owner: %3$s.',
'%s added owners: %3$s.',
),
),
'%s removed %s owner(s): %s.' => array(
array(
'%s removed an owner: %3$s.',
'%s removed owners: %3$s.',
),
),
'%s changed %s package owner(s), added %s: %s; removed %s: %s.' => array(
'%s changed package owners, added: %4$s; removed: %6$s.',
),
'Found %s book(s).' => array(
'Found %s book.',
'Found %s books.',
),
'Found %s file(s)...' => array(
'Found %s file...',
'Found %s files...',
),
'Found %s file(s) in project.' => array(
'Found %s file in project.',
'Found %s files in project.',
),
'Found %s unatomized, uncached file(s).' => array(
'Found %s unatomized, uncached file.',
'Found %s unatomized, uncached files.',
),
'Found %s file(s) to atomize.' => array(
'Found %s file to atomize.',
'Found %s files to atomize.',
),
'Atomizing %s file(s).' => array(
'Atomizing %s file.',
'Atomizing %s files.',
),
'Creating %s document(s).' => array(
'Creating %s document.',
'Creating %s documents.',
),
'Deleting %s document(s).' => array(
'Deleting %s document.',
'Deleting %s documents.',
),
'Found %s obsolete atom(s) in graph.' => array(
'Found %s obsolete atom in graph.',
'Found %s obsolete atoms in graph.',
),
'Found %s new atom(s) in graph.' => array(
'Found %s new atom in graph.',
'Found %s new atoms in graph.',
),
'This call takes %s parameter(s), but only %s are documented.' => array(
array(
'This call takes %s parameter, but only %s is documented.',
'This call takes %s parameter, but only %s are documented.',
),
array(
'This call takes %s parameters, but only %s is documented.',
'This call takes %s parameters, but only %s are documented.',
),
),
'%s Passed Test(s)' => '%s Passed',
'%s Failed Test(s)' => '%s Failed',
'%s Skipped Test(s)' => '%s Skipped',
'%s Broken Test(s)' => '%s Broken',
'%s Unsound Test(s)' => '%s Unsound',
'%s Other Test(s)' => '%s Other',
'%s Bulk Task(s)' => array(
'%s Task',
'%s Tasks',
),
'%s added %s badge(s) for %s: %s.' => array(
array(
'%s added a badge for %s: %3$s.',
'%s added badges for %s: %3$s.',
),
),
'%s added %s badge(s): %s.' => array(
array(
'%s added a badge: %3$s.',
'%s added badges: %3$s.',
),
),
'%s awarded %s recipient(s) for %s: %s.' => array(
array(
'%s awarded %3$s to %4$s.',
'%s awarded %3$s to multiple recipients: %4$s.',
),
),
'%s awarded %s recipients(s): %s.' => array(
array(
'%s awarded a recipient: %3$s.',
'%s awarded multiple recipients: %3$s.',
),
),
'%s edited badge(s) for %s, added %s: %s; revoked %s: %s.' => array(
array(
'%s edited badges for %s, added %s: %s; revoked %s: %s.',
'%s edited badges for %s, added %s: %s; revoked %s: %s.',
),
),
'%s edited badge(s), added %s: %s; revoked %s: %s.' => array(
array(
'%s edited badges, added %s: %s; revoked %s: %s.',
'%s edited badges, added %s: %s; revoked %s: %s.',
),
),
'%s edited recipient(s) for %s, awarded %s: %s; revoked %s: %s.' => array(
array(
'%s edited recipients for %s, awarded %s: %s; revoked %s: %s.',
'%s edited recipients for %s, awarded %s: %s; revoked %s: %s.',
),
),
'%s edited recipient(s), awarded %s: %s; revoked %s: %s.' => array(
array(
'%s edited recipients, awarded %s: %s; revoked %s: %s.',
'%s edited recipients, awarded %s: %s; revoked %s: %s.',
),
),
'%s revoked %s badge(s) for %s: %s.' => array(
array(
'%s revoked a badge for %3$s: %4$s.',
'%s revoked multiple badges for %3$s: %4$s.',
),
),
'%s revoked %s badge(s): %s.' => array(
array(
'%s revoked a badge: %3$s.',
'%s revoked multiple badges: %3$s.',
),
),
'%s revoked %s recipient(s) for %s: %s.' => array(
array(
'%s revoked %3$s from %4$s.',
'%s revoked multiple recipients for %3$s: %4$s.',
),
),
'%s revoked %s recipients(s): %s.' => array(
array(
'%s revoked a recipient: %3$s.',
'%s revoked multiple recipients: %3$s.',
),
),
'%s automatically subscribed target(s) were not affected: %s.' => array(
'An automatically subscribed target was not affected: %2$s.',
'Automatically subscribed targets were not affected: %2$s.',
),
'Declined to resubscribe %s target(s) because they previously '.
'unsubscribed: %s.' => array(
'Delined to resubscribe a target because they previously '.
'unsubscribed: %2$s.',
'Declined to resubscribe targets because they previously '.
'unsubscribed: %2$s.',
),
'%s target(s) are not subscribed: %s.' => array(
'A target is not subscribed: %2$s.',
'Targets are not subscribed: %2$s.',
),
'%s target(s) are already subscribed: %s.' => array(
'A target is already subscribed: %2$s.',
'Targets are already subscribed: %2$s.',
),
'Added %s subscriber(s): %s.' => array(
'Added a subscriber: %2$s.',
'Added subscribers: %2$s.',
),
'Removed %s subscriber(s): %s.' => array(
'Removed a subscriber: %2$s.',
'Removed subscribers: %2$s.',
),
'Queued email to be delivered to %s target(s): %s.' => array(
'Queued email to be delivered to target: %2$s.',
'Queued email to be delivered to targets: %2$s.',
),
'Queued email to be delivered to %s target(s), ignoring their '.
'notification preferences: %s.' => array(
'Queued email to be delivered to target, ignoring notification '.
'preferences: %2$s.',
'Queued email to be delivered to targets, ignoring notification '.
'preferences: %2$s.',
),
'%s project(s) are not associated: %s.' => array(
'A project is not associated: %2$s.',
'Projects are not associated: %2$s.',
),
'%s project(s) are already associated: %s.' => array(
'A project is already associated: %2$s.',
'Projects are already associated: %2$s.',
),
'Added %s project(s): %s.' => array(
'Added a project: %2$s.',
'Added projects: %2$s.',
),
'Removed %s project(s): %s.' => array(
'Removed a project: %2$s.',
'Removed projects: %2$s.',
),
'Added %s reviewer(s): %s.' => array(
'Added a reviewer: %2$s.',
'Added reviewers: %2$s.',
),
'Added %s blocking reviewer(s): %s.' => array(
'Added a blocking reviewer: %2$s.',
'Added blocking reviewers: %2$s.',
),
'Required %s signature(s): %s.' => array(
'Required a signature: %2$s.',
'Required signatures: %2$s.',
),
'Started %s build(s): %s.' => array(
'Started a build: %2$s.',
'Started builds: %2$s.',
),
'Added %s auditor(s): %s.' => array(
'Added an auditor: %2$s.',
'Added auditors: %2$s.',
),
'%s target(s) do not have permission to see this object: %s.' => array(
'A target does not have permission to see this object: %2$s.',
'Targets do not have permission to see this object: %2$s.',
),
'This action has no effect on %s target(s): %s.' => array(
'This action has no effect on a target: %2$s.',
'This action has no effect on targets: %2$s.',
),
'Mail sent in the last %s day(s).' => array(
'Mail sent in the last day.',
'Mail sent in the last %s days.',
),
'%s Day(s)' => array(
'%s Day',
'%s Days',
),
'%s Day(s) Ago' => array(
'%s Day Ago',
'%s Days Ago',
),
'Setting retention policy for "%s" to %s day(s).' => array(
array(
'Setting retention policy for "%s" to one day.',
'Setting retention policy for "%s" to %s days.',
),
),
'Waiting %s second(s) for lease to activate.' => array(
'Waiting a second for lease to activate.',
'Waiting %s seconds for lease to activate.',
),
'%s changed %s automation blueprint(s), added %s: %s; removed %s: %s.' =>
'%s changed automation blueprints, added: %4$s; removed: %6$s.',
'%s added %s automation blueprint(s): %s.' => array(
array(
'%s added an automation blueprint: %3$s.',
'%s added automation blueprints: %3$s.',
),
),
'%s removed %s automation blueprint(s): %s.' => array(
array(
'%s removed an automation blueprint: %3$s.',
'%s removed automation blueprints: %3$s.',
),
),
'WARNING: There are %s unapproved authorization(s)!' => array(
'WARNING: There is an unapproved authorization!',
'WARNING: There are unapproved authorizations!',
),
'Found %s Open Resource(s)' => array(
'Found %s Open Resource',
'Found %s Open Resources',
),
'%s Open Resource(s) Remain' => array(
'%s Open Resource Remain',
'%s Open Resources Remain',
),
'Found %s Blueprint(s)' => array(
'Found %s Blueprint',
'Found %s Blueprints',
),
'%s Blueprint(s) Can Allocate' => array(
'%s Blueprint Can Allocate',
'%s Blueprints Can Allocate',
),
'%s Blueprint(s) Enabled' => array(
'%s Blueprint Enabled',
'%s Blueprints Enabled',
),
'%s Event(s)' => array(
'%s Event',
'%s Events',
),
'%s Unit(s)' => array(
'%s Unit',
'%s Units',
),
'QUEUEING TASKS (%s Commit(s)):' => array(
'QUEUEING TASKS (%s Commit):',
'QUEUEING TASKS (%s Commits):',
),
'Found %s total commit(s); updating...' => array(
'Found %s total commit; updating...',
'Found %s total commits; updating...',
),
'Not enough process slots to schedule the other %s '.
'repository(s) for updates yet.' => array(
'Not enough process slots to schedule the other '.'
repository for update yet.',
'Not enough process slots to schedule the other %s '.
'repositories for updates yet.',
),
'%s updated %s, added %d: %s.' =>
'%s updated %s, added: %4$s.',
'%s updated %s, removed %s: %s.' =>
'%s updated %s, removed: %4$s.',
'%s updated %s, added %s: %s; removed %s: %s.' =>
'%s updated %s, added: %4$s; removed: %6$s.',
'%s updated %s for %s, added %d: %s.' =>
'%s updated %s for %s, added: %5$s.',
'%s updated %s for %s, removed %s: %s.' =>
'%s updated %s for %s, removed: %5$s.',
'%s updated %s for %s, added %s: %s; removed %s: %s.' =>
'%s updated %s for %s, added: %5$s; removed; %7$s.',
'Permanently destroyed %s object(s).' => array(
'Permanently destroyed %s object.',
'Permanently destroyed %s objects.',
),
'%s added %s watcher(s) for %s: %s.' => array(
array(
'%s added a watcher for %3$s: %4$s.',
'%s added watchers for %3$s: %4$s.',
),
),
'%s removed %s watcher(s) for %s: %s.' => array(
array(
'%s removed a watcher for %3$s: %4$s.',
'%s removed watchers for %3$s: %4$s.',
),
),
'%s awarded this badge to %s recipient(s): %s.' => array(
array(
'%s awarded this badge to recipient: %3$s.',
'%s awarded this badge to recipients: %3$s.',
),
),
'%s revoked this badge from %s recipient(s): %s.' => array(
array(
'%s revoked this badge from recipient: %3$s.',
'%s revoked this badge from recipients: %3$s.',
),
),
'%s awarded %s to %s recipient(s): %s.' => array(
array(
array(
'%s awarded %s to recipient: %4$s.',
'%s awarded %s to recipients: %4$s.',
),
),
),
'%s revoked %s from %s recipient(s): %s.' => array(
array(
array(
'%s revoked %s from recipient: %4$s.',
'%s revoked %s from recipients: %4$s.',
),
),
),
'%s invited %s attendee(s): %s.' =>
'%s invited: %3$s.',
'%s uninvited %s attendee(s): %s.' =>
'%s uninvited: %3$s.',
- '%s invited %s attendee(s): %s; uninvinted %s attendee(s): %s.' =>
+ '%s invited %s attendee(s): %s; uninvited %s attendee(s): %s.' =>
'%s invited: %3$s; uninvited: %5$s.',
'%s invited %s attendee(s) to %s: %s.' =>
'%s added invites for %3$s: %4$s.',
'%s uninvited %s attendee(s) to %s: %s.' =>
'%s removed invites for %3$s: %4$s.',
- '%s updated the invite list for %s, invited %s: %s; uninvinted %s: %s.' =>
+ '%s updated the invite list for %s, invited %s: %s; uninvited %s: %s.' =>
'%s updated the invite list for %s, invited: %4$s; uninvited: %6$s.',
'Restart %s build(s)?' => array(
'Restart %s build?',
'Restart %s builds?',
),
'%s is starting in %s minute(s), at %s.' => array(
array(
'%s is starting in one minute, at %3$s.',
'%s is starting in %s minutes, at %s.',
),
),
'%s added %s auditor(s): %s.' => array(
array(
'%s added an auditor: %3$s.',
'%s added auditors: %3$s.',
),
),
'%s removed %s auditor(s): %s.' => array(
array(
'%s removed an auditor: %3$s.',
'%s removed auditors: %3$s.',
),
),
'%s edited %s auditor(s), removed %s: %s; added %s: %s.' => array(
array(
'%s edited auditors, removed: %4$s; added: %6$s.',
),
),
'%s accepted this revision as %s reviewer(s): %s.' =>
'%s accepted this revision as: %3$s.',
'%s added %s merchant manager(s): %s.' => array(
array(
'%s added a merchant manager: %3$s.',
'%s added merchant managers: %3$s.',
),
),
'%s removed %s merchant manager(s): %s.' => array(
array(
'%s removed a merchant manager: %3$s.',
'%s removed merchant managers: %3$s.',
),
),
'%s added %s account manager(s): %s.' => array(
array(
'%s added an account manager: %3$s.',
'%s added account managers: %3$s.',
),
),
'%s removed %s account manager(s): %s.' => array(
array(
'%s removed an account manager: %3$s.',
'%s removed account managers: %3$s.',
),
),
);
}
}
diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
index c2b8ce04e0..9bebfe6957 100644
--- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
+++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
@@ -1,2801 +1,2801 @@
<?php
/**
* A query class which uses cursor-based paging. This paging is much more
* performant than offset-based paging in the presence of policy filtering.
*
* @task clauses Building Query Clauses
* @task appsearch Integration with ApplicationSearch
* @task customfield Integration with CustomField
* @task paging Paging
* @task order Result Ordering
* @task edgelogic Working with Edge Logic
* @task spaces Working with Spaces
*/
abstract class PhabricatorCursorPagedPolicyAwareQuery
extends PhabricatorPolicyAwareQuery {
private $afterID;
private $beforeID;
private $applicationSearchConstraints = array();
private $internalPaging;
private $orderVector;
private $groupVector;
private $builtinOrder;
private $edgeLogicConstraints = array();
private $edgeLogicConstraintsAreValid = false;
private $spacePHIDs;
private $spaceIsArchived;
private $ngrams = array();
private $ferretEngine;
private $ferretTokens = array();
private $ferretTables = array();
private $ferretQuery;
private $ferretMetadata = array();
protected function getPageCursors(array $page) {
return array(
$this->getResultCursor(head($page)),
$this->getResultCursor(last($page)),
);
}
protected function getResultCursor($object) {
if (!is_object($object)) {
throw new Exception(
pht(
'Expected object, got "%s".',
gettype($object)));
}
return $object->getID();
}
protected function nextPage(array $page) {
// See getPagingViewer() for a description of this flag.
$this->internalPaging = true;
if ($this->beforeID !== null) {
$page = array_reverse($page, $preserve_keys = true);
list($before, $after) = $this->getPageCursors($page);
$this->beforeID = $before;
} else {
list($before, $after) = $this->getPageCursors($page);
$this->afterID = $after;
}
}
final public function setAfterID($object_id) {
$this->afterID = $object_id;
return $this;
}
final protected function getAfterID() {
return $this->afterID;
}
final public function setBeforeID($object_id) {
$this->beforeID = $object_id;
return $this;
}
final protected function getBeforeID() {
return $this->beforeID;
}
final public function getFerretMetadata() {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Unable to retrieve Ferret engine metadata, this class ("%s") does '.
'not support the Ferret engine.',
get_class($this)));
}
return $this->ferretMetadata;
}
protected function loadStandardPage(PhabricatorLiskDAO $table) {
$rows = $this->loadStandardPageRows($table);
return $table->loadAllFromArray($rows);
}
protected function loadStandardPageRows(PhabricatorLiskDAO $table) {
$conn = $table->establishConnection('r');
return $this->loadStandardPageRowsWithConnection(
$conn,
$table->getTableName());
}
protected function loadStandardPageRowsWithConnection(
AphrontDatabaseConnection $conn,
$table_name) {
$rows = queryfx_all(
$conn,
'%Q FROM %T %Q %Q %Q %Q %Q %Q %Q',
$this->buildSelectClause($conn),
$table_name,
(string)$this->getPrimaryTableAlias(),
$this->buildJoinClause($conn),
$this->buildWhereClause($conn),
$this->buildGroupClause($conn),
$this->buildHavingClause($conn),
$this->buildOrderClause($conn),
$this->buildLimitClause($conn));
$rows = $this->didLoadRawRows($rows);
return $rows;
}
protected function didLoadRawRows(array $rows) {
if ($this->ferretEngine) {
foreach ($rows as $row) {
$phid = $row['phid'];
$metadata = id(new PhabricatorFerretMetadata())
->setPHID($phid)
->setEngine($this->ferretEngine)
->setRelevance(idx($row, '_ft_rank'));
$this->ferretMetadata[$phid] = $metadata;
unset($row['_ft_rank']);
}
}
return $rows;
}
/**
* Get the viewer for making cursor paging queries.
*
* NOTE: You should ONLY use this viewer to load cursor objects while
* building paging queries.
*
* Cursor paging can happen in two ways. First, the user can request a page
* like `/stuff/?after=33`, which explicitly causes paging. Otherwise, we
* can fall back to implicit paging if we filter some results out of a
* result list because the user can't see them and need to go fetch some more
* results to generate a large enough result list.
*
* In the first case, want to use the viewer's policies to load the object.
* This prevents an attacker from figuring out information about an object
* they can't see by executing queries like `/stuff/?after=33&order=name`,
* which would otherwise give them a hint about the name of the object.
* Generally, if a user can't see an object, they can't use it to page.
*
* In the second case, we need to load the object whether the user can see
* it or not, because we need to examine new results. For example, if a user
* loads `/stuff/` and we run a query for the first 100 items that they can
* see, but the first 100 rows in the database aren't visible, we need to
* be able to issue a query for the next 100 results. If we can't load the
* cursor object, we'll fail or issue the same query over and over again.
* So, generally, internal paging must bypass policy controls.
*
* This method returns the appropriate viewer, based on the context in which
- * the paging is occuring.
+ * the paging is occurring.
*
* @return PhabricatorUser Viewer for executing paging queries.
*/
final protected function getPagingViewer() {
if ($this->internalPaging) {
return PhabricatorUser::getOmnipotentUser();
} else {
return $this->getViewer();
}
}
final protected function buildLimitClause(AphrontDatabaseConnection $conn_r) {
if ($this->shouldLimitResults()) {
$limit = $this->getRawResultLimit();
if ($limit) {
return qsprintf($conn_r, 'LIMIT %d', $limit);
}
}
return '';
}
protected function shouldLimitResults() {
return true;
}
final protected function didLoadResults(array $results) {
if ($this->beforeID) {
$results = array_reverse($results, $preserve_keys = true);
}
return $results;
}
final public function executeWithCursorPager(AphrontCursorPagerView $pager) {
$limit = $pager->getPageSize();
$this->setLimit($limit + 1);
if ($pager->getAfterID()) {
$this->setAfterID($pager->getAfterID());
} else if ($pager->getBeforeID()) {
$this->setBeforeID($pager->getBeforeID());
}
$results = $this->execute();
$count = count($results);
$sliced_results = $pager->sliceResults($results);
if ($sliced_results) {
list($before, $after) = $this->getPageCursors($sliced_results);
if ($pager->getBeforeID() || ($count > $limit)) {
$pager->setNextPageID($after);
}
if ($pager->getAfterID() ||
($pager->getBeforeID() && ($count > $limit))) {
$pager->setPrevPageID($before);
}
}
return $sliced_results;
}
/**
* Return the alias this query uses to identify the primary table.
*
* Some automatic query constructions may need to be qualified with a table
* alias if the query performs joins which make column names ambiguous. If
* this is the case, return the alias for the primary table the query
* uses; generally the object table which has `id` and `phid` columns.
*
* @return string Alias for the primary table.
*/
protected function getPrimaryTableAlias() {
return null;
}
public function newResultObject() {
return null;
}
/* -( Building Query Clauses )--------------------------------------------- */
/**
* @task clauses
*/
protected function buildSelectClause(AphrontDatabaseConnection $conn) {
$parts = $this->buildSelectClauseParts($conn);
return $this->formatSelectClause($parts);
}
/**
* @task clauses
*/
protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) {
$select = array();
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$select[] = qsprintf($conn, '%T.*', $alias);
} else {
$select[] = '*';
}
$select[] = $this->buildEdgeLogicSelectClause($conn);
$select[] = $this->buildFerretSelectClause($conn);
return $select;
}
/**
* @task clauses
*/
protected function buildJoinClause(AphrontDatabaseConnection $conn) {
$joins = $this->buildJoinClauseParts($conn);
return $this->formatJoinClause($joins);
}
/**
* @task clauses
*/
protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) {
$joins = array();
$joins[] = $this->buildEdgeLogicJoinClause($conn);
$joins[] = $this->buildApplicationSearchJoinClause($conn);
$joins[] = $this->buildNgramsJoinClause($conn);
$joins[] = $this->buildFerretJoinClause($conn);
return $joins;
}
/**
* @task clauses
*/
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = $this->buildWhereClauseParts($conn);
return $this->formatWhereClause($where);
}
/**
* @task clauses
*/
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = array();
$where[] = $this->buildPagingClause($conn);
$where[] = $this->buildEdgeLogicWhereClause($conn);
$where[] = $this->buildSpacesWhereClause($conn);
$where[] = $this->buildNgramsWhereClause($conn);
$where[] = $this->buildFerretWhereClause($conn);
return $where;
}
/**
* @task clauses
*/
protected function buildHavingClause(AphrontDatabaseConnection $conn) {
$having = $this->buildHavingClauseParts($conn);
return $this->formatHavingClause($having);
}
/**
* @task clauses
*/
protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) {
$having = array();
$having[] = $this->buildEdgeLogicHavingClause($conn);
return $having;
}
/**
* @task clauses
*/
protected function buildGroupClause(AphrontDatabaseConnection $conn) {
if (!$this->shouldGroupQueryResultRows()) {
return '';
}
return qsprintf(
$conn,
'GROUP BY %Q',
$this->getApplicationSearchObjectPHIDColumn());
}
/**
* @task clauses
*/
protected function shouldGroupQueryResultRows() {
if ($this->shouldGroupEdgeLogicResultRows()) {
return true;
}
if ($this->getApplicationSearchMayJoinMultipleRows()) {
return true;
}
if ($this->shouldGroupNgramResultRows()) {
return true;
}
if ($this->shouldGroupFerretResultRows()) {
return true;
}
return false;
}
/* -( Paging )------------------------------------------------------------- */
/**
* @task paging
*/
protected function buildPagingClause(AphrontDatabaseConnection $conn) {
$orderable = $this->getOrderableColumns();
$vector = $this->getOrderVector();
if ($this->beforeID !== null) {
$cursor = $this->beforeID;
$reversed = true;
} else if ($this->afterID !== null) {
$cursor = $this->afterID;
$reversed = false;
} else {
// No paging is being applied to this query so we do not need to
// construct a paging clause.
return '';
}
$keys = array();
foreach ($vector as $order) {
$keys[] = $order->getOrderKey();
}
$value_map = $this->getPagingValueMap($cursor, $keys);
$columns = array();
foreach ($vector as $order) {
$key = $order->getOrderKey();
if (!array_key_exists($key, $value_map)) {
throw new Exception(
pht(
'Query "%s" failed to return a value from getPagingValueMap() '.
'for column "%s".',
get_class($this),
$key));
}
$column = $orderable[$key];
$column['value'] = $value_map[$key];
// If the vector component is reversed, we need to reverse whatever the
// order of the column is.
if ($order->getIsReversed()) {
$column['reverse'] = !idx($column, 'reverse', false);
}
$columns[] = $column;
}
return $this->buildPagingClauseFromMultipleColumns(
$conn,
$columns,
array(
'reversed' => $reversed,
));
}
/**
* @task paging
*/
protected function getPagingValueMap($cursor, array $keys) {
return array(
'id' => $cursor,
);
}
/**
* @task paging
*/
protected function loadCursorObject($cursor) {
$query = newv(get_class($this), array())
->setViewer($this->getPagingViewer())
->withIDs(array((int)$cursor));
$this->willExecuteCursorQuery($query);
$object = $query->executeOne();
if (!$object) {
throw new Exception(
pht(
'Cursor "%s" does not identify a valid object in query "%s".',
$cursor,
get_class($this)));
}
return $object;
}
/**
* @task paging
*/
protected function willExecuteCursorQuery(
PhabricatorCursorPagedPolicyAwareQuery $query) {
return;
}
/**
* Simplifies the task of constructing a paging clause across multiple
* columns. In the general case, this looks like:
*
* A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c)
*
* To build a clause, specify the name, type, and value of each column
* to include:
*
* $this->buildPagingClauseFromMultipleColumns(
* $conn_r,
* array(
* array(
* 'table' => 't',
* 'column' => 'title',
* 'type' => 'string',
* 'value' => $cursor->getTitle(),
* 'reverse' => true,
* ),
* array(
* 'table' => 't',
* 'column' => 'id',
* 'type' => 'int',
* 'value' => $cursor->getID(),
* ),
* ),
* array(
* 'reversed' => $is_reversed,
* ));
*
* This method will then return a composable clause for inclusion in WHERE.
*
* @param AphrontDatabaseConnection Connection query will execute on.
* @param list<map> Column description dictionaries.
- * @param map Additional constuction options.
+ * @param map Additional construction options.
* @return string Query clause.
* @task paging
*/
final protected function buildPagingClauseFromMultipleColumns(
AphrontDatabaseConnection $conn,
array $columns,
array $options) {
foreach ($columns as $column) {
PhutilTypeSpec::checkMap(
$column,
array(
'table' => 'optional string|null',
'column' => 'string',
'value' => 'wild',
'type' => 'string',
'reverse' => 'optional bool',
'unique' => 'optional bool',
'null' => 'optional string|null',
));
}
PhutilTypeSpec::checkMap(
$options,
array(
'reversed' => 'optional bool',
));
$is_query_reversed = idx($options, 'reversed', false);
$clauses = array();
$accumulated = array();
$last_key = last_key($columns);
foreach ($columns as $key => $column) {
$type = $column['type'];
$null = idx($column, 'null');
if ($column['value'] === null) {
if ($null) {
$value = null;
} else {
throw new Exception(
pht(
'Column "%s" has null value, but does not specify a null '.
'behavior.',
$key));
}
} else {
switch ($type) {
case 'int':
$value = qsprintf($conn, '%d', $column['value']);
break;
case 'float':
$value = qsprintf($conn, '%f', $column['value']);
break;
case 'string':
$value = qsprintf($conn, '%s', $column['value']);
break;
default:
throw new Exception(
pht(
'Column "%s" has unknown column type "%s".',
$column['column'],
$type));
}
}
$is_column_reversed = idx($column, 'reverse', false);
$reverse = ($is_query_reversed xor $is_column_reversed);
$clause = $accumulated;
$table_name = idx($column, 'table');
$column_name = $column['column'];
if ($table_name !== null) {
$field = qsprintf($conn, '%T.%T', $table_name, $column_name);
} else {
$field = qsprintf($conn, '%T', $column_name);
}
$parts = array();
if ($null) {
$can_page_if_null = ($null === 'head');
$can_page_if_nonnull = ($null === 'tail');
if ($reverse) {
$can_page_if_null = !$can_page_if_null;
$can_page_if_nonnull = !$can_page_if_nonnull;
}
$subclause = null;
if ($can_page_if_null && $value === null) {
$parts[] = qsprintf(
$conn,
'(%Q IS NOT NULL)',
$field);
} else if ($can_page_if_nonnull && $value !== null) {
$parts[] = qsprintf(
$conn,
'(%Q IS NULL)',
$field);
}
}
if ($value !== null) {
$parts[] = qsprintf(
$conn,
'%Q %Q %Q',
$field,
$reverse ? '>' : '<',
$value);
}
if ($parts) {
if (count($parts) > 1) {
$clause[] = '('.implode(') OR (', $parts).')';
} else {
$clause[] = head($parts);
}
}
if ($clause) {
if (count($clause) > 1) {
$clauses[] = '('.implode(') AND (', $clause).')';
} else {
$clauses[] = head($clause);
}
}
if ($value === null) {
$accumulated[] = qsprintf(
$conn,
'%Q IS NULL',
$field);
} else {
$accumulated[] = qsprintf(
$conn,
'%Q = %Q',
$field,
$value);
}
}
return '('.implode(') OR (', $clauses).')';
}
/* -( Result Ordering )---------------------------------------------------- */
/**
* Select a result ordering.
*
* This is a high-level method which selects an ordering from a predefined
* list of builtin orders, as provided by @{method:getBuiltinOrders}. These
* options are user-facing and not exhaustive, but are generally convenient
* and meaningful.
*
* You can also use @{method:setOrderVector} to specify a low-level ordering
* across individual orderable columns. This offers greater control but is
* also more involved.
*
* @param string Key of a builtin order supported by this query.
* @return this
* @task order
*/
public function setOrder($order) {
$aliases = $this->getBuiltinOrderAliasMap();
if (empty($aliases[$order])) {
throw new Exception(
pht(
'Query "%s" does not support a builtin order "%s". Supported orders '.
'are: %s.',
get_class($this),
$order,
implode(', ', array_keys($aliases))));
}
$this->builtinOrder = $aliases[$order];
$this->orderVector = null;
return $this;
}
/**
* Set a grouping order to apply before primary result ordering.
*
* This allows you to preface the query order vector with additional orders,
* so you can effect "group by" queries while still respecting "order by".
*
* This is a high-level method which works alongside @{method:setOrder}. For
* lower-level control over order vectors, use @{method:setOrderVector}.
*
* @param PhabricatorQueryOrderVector|list<string> List of order keys.
* @return this
* @task order
*/
public function setGroupVector($vector) {
$this->groupVector = $vector;
$this->orderVector = null;
return $this;
}
/**
* Get builtin orders for this class.
*
* In application UIs, we want to be able to present users with a small
* selection of meaningful order options (like "Order by Title") rather than
* an exhaustive set of column ordering options.
*
* Meaningful user-facing orders are often really orders across multiple
* columns: for example, a "title" ordering is usually implemented as a
* "title, id" ordering under the hood.
*
* Builtin orders provide a mapping from convenient, understandable
* user-facing orders to implementations.
*
* A builtin order should provide these keys:
*
* - `vector` (`list<string>`): The actual order vector to use.
* - `name` (`string`): Human-readable order name.
*
* @return map<string, wild> Map from builtin order keys to specification.
* @task order
*/
public function getBuiltinOrders() {
$orders = array(
'newest' => array(
'vector' => array('id'),
'name' => pht('Creation (Newest First)'),
'aliases' => array('created'),
),
'oldest' => array(
'vector' => array('-id'),
'name' => pht('Creation (Oldest First)'),
),
);
$object = $this->newResultObject();
if ($object instanceof PhabricatorCustomFieldInterface) {
$list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
foreach ($list->getFields() as $field) {
$index = $field->buildOrderIndex();
if (!$index) {
continue;
}
$legacy_key = 'custom:'.$field->getFieldKey();
$modern_key = $field->getModernFieldKey();
$orders[$modern_key] = array(
'vector' => array($modern_key, 'id'),
'name' => $field->getFieldName(),
'aliases' => array($legacy_key),
);
$orders['-'.$modern_key] = array(
'vector' => array('-'.$modern_key, '-id'),
'name' => pht('%s (Reversed)', $field->getFieldName()),
);
}
}
if ($this->supportsFerretEngine()) {
$orders['relevance'] = array(
'vector' => array('rank', 'fulltext-modified', 'id'),
'name' => pht('Relevance'),
);
}
return $orders;
}
public function getBuiltinOrderAliasMap() {
$orders = $this->getBuiltinOrders();
$map = array();
foreach ($orders as $key => $order) {
$keys = array();
$keys[] = $key;
foreach (idx($order, 'aliases', array()) as $alias) {
$keys[] = $alias;
}
foreach ($keys as $alias) {
if (isset($map[$alias])) {
throw new Exception(
pht(
'Two builtin orders ("%s" and "%s") define the same key or '.
'alias ("%s"). Each order alias and key must be unique and '.
'identify a single order.',
$key,
$map[$alias],
$alias));
}
$map[$alias] = $key;
}
}
return $map;
}
/**
* Set a low-level column ordering.
*
* This is a low-level method which offers granular control over column
* ordering. In most cases, applications can more easily use
* @{method:setOrder} to choose a high-level builtin order.
*
* To set an order vector, specify a list of order keys as provided by
* @{method:getOrderableColumns}.
*
* @param PhabricatorQueryOrderVector|list<string> List of order keys.
* @return this
* @task order
*/
public function setOrderVector($vector) {
$vector = PhabricatorQueryOrderVector::newFromVector($vector);
$orderable = $this->getOrderableColumns();
// Make sure that all the components identify valid columns.
$unique = array();
foreach ($vector as $order) {
$key = $order->getOrderKey();
if (empty($orderable[$key])) {
$valid = implode(', ', array_keys($orderable));
throw new Exception(
pht(
'This query ("%s") does not support sorting by order key "%s". '.
'Supported orders are: %s.',
get_class($this),
$key,
$valid));
}
$unique[$key] = idx($orderable[$key], 'unique', false);
}
// Make sure that the last column is unique so that this is a strong
// ordering which can be used for paging.
$last = last($unique);
if ($last !== true) {
throw new Exception(
pht(
'Order vector "%s" is invalid: the last column in an order must '.
'be a column with unique values, but "%s" is not unique.',
$vector->getAsString(),
last_key($unique)));
}
// Make sure that other columns are not unique; an ordering like "id, name"
// does not make sense because only "id" can ever have an effect.
array_pop($unique);
foreach ($unique as $key => $is_unique) {
if ($is_unique) {
throw new Exception(
pht(
'Order vector "%s" is invalid: only the last column in an order '.
'may be unique, but "%s" is a unique column and not the last '.
'column in the order.',
$vector->getAsString(),
$key));
}
}
$this->orderVector = $vector;
return $this;
}
/**
* Get the effective order vector.
*
* @return PhabricatorQueryOrderVector Effective vector.
* @task order
*/
protected function getOrderVector() {
if (!$this->orderVector) {
if ($this->builtinOrder !== null) {
$builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder);
$vector = $builtin_order['vector'];
} else {
$vector = $this->getDefaultOrderVector();
}
if ($this->groupVector) {
$group = PhabricatorQueryOrderVector::newFromVector($this->groupVector);
$group->appendVector($vector);
$vector = $group;
}
$vector = PhabricatorQueryOrderVector::newFromVector($vector);
// We call setOrderVector() here to apply checks to the default vector.
// This catches any errors in the implementation.
$this->setOrderVector($vector);
}
return $this->orderVector;
}
/**
* @task order
*/
protected function getDefaultOrderVector() {
return array('id');
}
/**
* @task order
*/
public function getOrderableColumns() {
$cache = PhabricatorCaches::getRequestCache();
$class = get_class($this);
$cache_key = 'query.orderablecolumns.'.$class;
$columns = $cache->getKey($cache_key);
if ($columns !== null) {
return $columns;
}
$columns = array(
'id' => array(
'table' => $this->getPrimaryTableAlias(),
'column' => 'id',
'reverse' => false,
'type' => 'int',
'unique' => true,
),
);
$object = $this->newResultObject();
if ($object instanceof PhabricatorCustomFieldInterface) {
$list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
foreach ($list->getFields() as $field) {
$index = $field->buildOrderIndex();
if (!$index) {
continue;
}
$digest = $field->getFieldIndex();
$key = $field->getModernFieldKey();
$columns[$key] = array(
'table' => 'appsearch_order_'.$digest,
'column' => 'indexValue',
'type' => $index->getIndexValueType(),
'null' => 'tail',
'customfield' => true,
'customfield.index.table' => $index->getTableName(),
'customfield.index.key' => $digest,
);
}
}
if ($this->supportsFerretEngine()) {
$columns['rank'] = array(
'table' => null,
'column' => '_ft_rank',
'type' => 'int',
);
$columns['fulltext-created'] = array(
'table' => 'ft_doc',
'column' => 'epochCreated',
'type' => 'int',
);
$columns['fulltext-modified'] = array(
'table' => 'ft_doc',
'column' => 'epochModified',
'type' => 'int',
);
}
$cache->setKey($cache_key, $columns);
return $columns;
}
/**
* @task order
*/
final protected function buildOrderClause(AphrontDatabaseConnection $conn) {
$orderable = $this->getOrderableColumns();
$vector = $this->getOrderVector();
$parts = array();
foreach ($vector as $order) {
$part = $orderable[$order->getOrderKey()];
if ($order->getIsReversed()) {
$part['reverse'] = !idx($part, 'reverse', false);
}
$parts[] = $part;
}
return $this->formatOrderClause($conn, $parts);
}
/**
* @task order
*/
protected function formatOrderClause(
AphrontDatabaseConnection $conn,
array $parts) {
$is_query_reversed = false;
if ($this->getBeforeID()) {
$is_query_reversed = !$is_query_reversed;
}
$sql = array();
foreach ($parts as $key => $part) {
$is_column_reversed = !empty($part['reverse']);
$descending = true;
if ($is_query_reversed) {
$descending = !$descending;
}
if ($is_column_reversed) {
$descending = !$descending;
}
$table = idx($part, 'table');
$column = $part['column'];
if ($table !== null) {
$field = qsprintf($conn, '%T.%T', $table, $column);
} else {
$field = qsprintf($conn, '%T', $column);
}
$null = idx($part, 'null');
if ($null) {
switch ($null) {
case 'head':
$null_field = qsprintf($conn, '(%Q IS NULL)', $field);
break;
case 'tail':
$null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field);
break;
default:
throw new Exception(
pht(
'NULL value "%s" is invalid. Valid values are "head" and '.
'"tail".',
$null));
}
if ($descending) {
$sql[] = qsprintf($conn, '%Q DESC', $null_field);
} else {
$sql[] = qsprintf($conn, '%Q ASC', $null_field);
}
}
if ($descending) {
$sql[] = qsprintf($conn, '%Q DESC', $field);
} else {
$sql[] = qsprintf($conn, '%Q ASC', $field);
}
}
return qsprintf($conn, 'ORDER BY %Q', implode(', ', $sql));
}
/* -( Application Search )------------------------------------------------- */
/**
* Constrain the query with an ApplicationSearch index, requiring field values
* contain at least one of the values in a set.
*
* This constraint can build the most common types of queries, like:
*
* - Find users with shirt sizes "X" or "XL".
* - Find shoes with size "13".
*
* @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
* @param string|list<string> One or more values to filter by.
* @return this
* @task appsearch
*/
public function withApplicationSearchContainsConstraint(
PhabricatorCustomFieldIndexStorage $index,
$value) {
$this->applicationSearchConstraints[] = array(
'type' => $index->getIndexValueType(),
'cond' => '=',
'table' => $index->getTableName(),
'index' => $index->getIndexKey(),
'value' => $value,
);
return $this;
}
/**
* Constrain the query with an ApplicationSearch index, requiring values
* exist in a given range.
*
* This constraint is useful for expressing date ranges:
*
* - Find events between July 1st and July 7th.
*
* The ends of the range are inclusive, so a `$min` of `3` and a `$max` of
* `5` will match fields with values `3`, `4`, or `5`. Providing `null` for
* either end of the range will leave that end of the constraint open.
*
* @param PhabricatorCustomFieldIndexStorage Table where the index is stored.
* @param int|null Minimum permissible value, inclusive.
* @param int|null Maximum permissible value, inclusive.
* @return this
* @task appsearch
*/
public function withApplicationSearchRangeConstraint(
PhabricatorCustomFieldIndexStorage $index,
$min,
$max) {
$index_type = $index->getIndexValueType();
if ($index_type != 'int') {
throw new Exception(
pht(
'Attempting to apply a range constraint to a field with index type '.
'"%s", expected type "%s".',
$index_type,
'int'));
}
$this->applicationSearchConstraints[] = array(
'type' => $index->getIndexValueType(),
'cond' => 'range',
'table' => $index->getTableName(),
'index' => $index->getIndexKey(),
'value' => array($min, $max),
);
return $this;
}
/**
* Get the name of the query's primary object PHID column, for constructing
* JOIN clauses. Normally (and by default) this is just `"phid"`, but it may
* be something more exotic.
*
* See @{method:getPrimaryTableAlias} if the column needs to be qualified with
* a table alias.
*
* @return string Column name.
* @task appsearch
*/
protected function getApplicationSearchObjectPHIDColumn() {
if ($this->getPrimaryTableAlias()) {
$prefix = $this->getPrimaryTableAlias().'.';
} else {
$prefix = '';
}
return $prefix.'phid';
}
/**
* Determine if the JOINs built by ApplicationSearch might cause each primary
* object to return multiple result rows. Generally, this means the query
* needs an extra GROUP BY clause.
*
* @return bool True if the query may return multiple rows for each object.
* @task appsearch
*/
protected function getApplicationSearchMayJoinMultipleRows() {
foreach ($this->applicationSearchConstraints as $constraint) {
$type = $constraint['type'];
$value = $constraint['value'];
$cond = $constraint['cond'];
switch ($cond) {
case '=':
switch ($type) {
case 'string':
case 'int':
if (count((array)$value) > 1) {
return true;
}
break;
default:
throw new Exception(pht('Unknown index type "%s"!', $type));
}
break;
case 'range':
// NOTE: It's possible to write a custom field where multiple rows
// match a range constraint, but we don't currently ship any in the
// upstream and I can't immediately come up with cases where this
// would make sense.
break;
default:
throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
}
}
return false;
}
/**
* Construct a GROUP BY clause appropriate for ApplicationSearch constraints.
*
* @param AphrontDatabaseConnection Connection executing the query.
* @return string Group clause.
* @task appsearch
*/
protected function buildApplicationSearchGroupClause(
AphrontDatabaseConnection $conn_r) {
if ($this->getApplicationSearchMayJoinMultipleRows()) {
return qsprintf(
$conn_r,
'GROUP BY %Q',
$this->getApplicationSearchObjectPHIDColumn());
} else {
return '';
}
}
/**
* Construct a JOIN clause appropriate for applying ApplicationSearch
* constraints.
*
* @param AphrontDatabaseConnection Connection executing the query.
* @return string Join clause.
* @task appsearch
*/
protected function buildApplicationSearchJoinClause(
AphrontDatabaseConnection $conn_r) {
$joins = array();
foreach ($this->applicationSearchConstraints as $key => $constraint) {
$table = $constraint['table'];
$alias = 'appsearch_'.$key;
$index = $constraint['index'];
$cond = $constraint['cond'];
$phid_column = $this->getApplicationSearchObjectPHIDColumn();
switch ($cond) {
case '=':
$type = $constraint['type'];
switch ($type) {
case 'string':
$constraint_clause = qsprintf(
$conn_r,
'%T.indexValue IN (%Ls)',
$alias,
(array)$constraint['value']);
break;
case 'int':
$constraint_clause = qsprintf(
$conn_r,
'%T.indexValue IN (%Ld)',
$alias,
(array)$constraint['value']);
break;
default:
throw new Exception(pht('Unknown index type "%s"!', $type));
}
$joins[] = qsprintf(
$conn_r,
'JOIN %T %T ON %T.objectPHID = %Q
AND %T.indexKey = %s
AND (%Q)',
$table,
$alias,
$alias,
$phid_column,
$alias,
$index,
$constraint_clause);
break;
case 'range':
list($min, $max) = $constraint['value'];
if (($min === null) && ($max === null)) {
// If there's no actual range constraint, just move on.
break;
}
if ($min === null) {
$constraint_clause = qsprintf(
$conn_r,
'%T.indexValue <= %d',
$alias,
$max);
} else if ($max === null) {
$constraint_clause = qsprintf(
$conn_r,
'%T.indexValue >= %d',
$alias,
$min);
} else {
$constraint_clause = qsprintf(
$conn_r,
'%T.indexValue BETWEEN %d AND %d',
$alias,
$min,
$max);
}
$joins[] = qsprintf(
$conn_r,
'JOIN %T %T ON %T.objectPHID = %Q
AND %T.indexKey = %s
AND (%Q)',
$table,
$alias,
$alias,
$phid_column,
$alias,
$index,
$constraint_clause);
break;
default:
throw new Exception(pht('Unknown constraint condition "%s"!', $cond));
}
}
$phid_column = $this->getApplicationSearchObjectPHIDColumn();
$orderable = $this->getOrderableColumns();
$vector = $this->getOrderVector();
foreach ($vector as $order) {
$spec = $orderable[$order->getOrderKey()];
if (empty($spec['customfield'])) {
continue;
}
$table = $spec['customfield.index.table'];
$alias = $spec['table'];
$key = $spec['customfield.index.key'];
$joins[] = qsprintf(
$conn_r,
'LEFT JOIN %T %T ON %T.objectPHID = %Q
AND %T.indexKey = %s',
$table,
$alias,
$alias,
$phid_column,
$alias,
$key);
}
return implode(' ', $joins);
}
/* -( Integration with CustomField )--------------------------------------- */
/**
* @task customfield
*/
protected function getPagingValueMapForCustomFields(
PhabricatorCustomFieldInterface $object) {
// We have to get the current field values on the cursor object.
$fields = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
$fields->setViewer($this->getViewer());
$fields->readFieldsFromStorage($object);
$map = array();
foreach ($fields->getFields() as $field) {
$map['custom:'.$field->getFieldKey()] = $field->getValueForStorage();
}
return $map;
}
/**
* @task customfield
*/
protected function isCustomFieldOrderKey($key) {
$prefix = 'custom:';
return !strncmp($key, $prefix, strlen($prefix));
}
/* -( Ferret )------------------------------------------------------------- */
public function supportsFerretEngine() {
$object = $this->newResultObject();
return ($object instanceof PhabricatorFerretInterface);
}
public function withFerretQuery(
PhabricatorFerretEngine $engine,
PhabricatorSavedQuery $query) {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Query ("%s") does not support the Ferret fulltext engine.',
get_class($this)));
}
$this->ferretEngine = $engine;
$this->ferretQuery = $query;
return $this;
}
public function getFerretTokens() {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Query ("%s") does not support the Ferret fulltext engine.',
get_class($this)));
}
return $this->ferretTokens;
}
public function withFerretConstraint(
PhabricatorFerretEngine $engine,
array $fulltext_tokens) {
if (!$this->supportsFerretEngine()) {
throw new Exception(
pht(
'Query ("%s") does not support the Ferret fulltext engine.',
get_class($this)));
}
if ($this->ferretEngine) {
throw new Exception(
pht(
'Query may not have multiple fulltext constraints.'));
}
if (!$fulltext_tokens) {
return $this;
}
$this->ferretEngine = $engine;
$this->ferretTokens = $fulltext_tokens;
$current_function = $engine->getDefaultFunctionKey();
$table_map = array();
$idx = 1;
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
$function = $raw_token->getFunction();
if ($function === null) {
$function = $current_function;
}
$raw_field = $engine->getFieldForFunction($function);
if (!isset($table_map[$function])) {
$alias = 'ftfield_'.$idx++;
$table_map[$function] = array(
'alias' => $alias,
'key' => $raw_field,
);
}
$current_function = $function;
}
// Join the title field separately so we can rank results.
$table_map['rank'] = array(
'alias' => 'ft_rank',
'key' => PhabricatorSearchDocumentFieldType::FIELD_TITLE,
);
$this->ferretTables = $table_map;
return $this;
}
protected function buildFerretSelectClause(AphrontDatabaseConnection $conn) {
$select = array();
if (!$this->supportsFerretEngine()) {
return $select;
}
if (!$this->ferretEngine) {
$select[] = '0 _ft_rank';
return $select;
}
$engine = $this->ferretEngine;
$stemmer = $engine->newStemmer();
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
$table_alias = 'ft_rank';
$parts = array();
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
$value = $raw_token->getValue();
if ($raw_token->getOperator() == $op_not) {
// Ignore "not" terms when ranking, since they aren't useful.
continue;
}
if ($raw_token->getOperator() == $op_sub) {
$is_substring = true;
} else {
$is_substring = false;
}
if ($is_substring) {
$parts[] = qsprintf(
$conn,
'IF(%T.rawCorpus LIKE %~, 2, 0)',
$table_alias,
$value);
continue;
}
if ($raw_token->isQuoted()) {
$is_quoted = true;
$is_stemmed = false;
} else {
$is_quoted = false;
$is_stemmed = true;
}
$term_constraints = array();
$term_value = $engine->newTermsCorpus($value);
$parts[] = qsprintf(
$conn,
'IF(%T.termCorpus LIKE %~, 2, 0)',
$table_alias,
$term_value);
if ($is_stemmed) {
$stem_value = $stemmer->stemToken($value);
$stem_value = $engine->newTermsCorpus($stem_value);
$parts[] = qsprintf(
$conn,
'IF(%T.normalCorpus LIKE %~, 1, 0)',
$table_alias,
$stem_value);
}
}
$parts[] = '0';
$select[] = qsprintf(
$conn,
'%Q _ft_rank',
implode(' + ', $parts));
return $select;
}
protected function buildFerretJoinClause(AphrontDatabaseConnection $conn) {
if (!$this->ferretEngine) {
return array();
}
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
$engine = $this->ferretEngine;
$stemmer = $engine->newStemmer();
$ngram_table = $engine->getNgramsTableName();
$flat = array();
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
// If this is a negated term like "-pomegranate", don't join the ngram
// table since we aren't looking for documents with this term. (We could
// LEFT JOIN the table and require a NULL row, but this is probably more
// trouble than it's worth.)
if ($raw_token->getOperator() == $op_not) {
continue;
}
$value = $raw_token->getValue();
$length = count(phutil_utf8v($value));
if ($raw_token->getOperator() == $op_sub) {
$is_substring = true;
} else {
$is_substring = false;
}
// If the user specified a substring query for a substring which is
// shorter than the ngram length, we can't use the ngram index, so
// don't do a join. We'll fall back to just doing LIKE on the full
// corpus.
if ($is_substring) {
if ($length < 3) {
continue;
}
}
if ($raw_token->isQuoted()) {
$is_stemmed = false;
} else {
$is_stemmed = true;
}
if ($is_substring) {
$ngrams = $engine->getSubstringNgramsFromString($value);
} else {
$terms_value = $engine->newTermsCorpus($value);
$ngrams = $engine->getTermNgramsFromString($terms_value);
// If this is a stemmed term, only look for ngrams present in both the
// unstemmed and stemmed variations.
if ($is_stemmed) {
// Trim the boundary space characters so the stemmer recognizes this
// is (or, at least, may be) a normal word and activates.
$terms_value = trim($terms_value, ' ');
$stem_value = $stemmer->stemToken($terms_value);
$stem_ngrams = $engine->getTermNgramsFromString($stem_value);
$ngrams = array_intersect($ngrams, $stem_ngrams);
}
}
foreach ($ngrams as $ngram) {
$flat[] = array(
'table' => $ngram_table,
'ngram' => $ngram,
);
}
}
// Remove common ngrams, like "the", which occur too frequently in
// documents to be useful in constraining the query. The best ngrams
// are obscure sequences which occur in very few documents.
if ($flat) {
$common_ngrams = queryfx_all(
$conn,
'SELECT ngram FROM %T WHERE ngram IN (%Ls)',
$engine->getCommonNgramsTableName(),
ipull($flat, 'ngram'));
$common_ngrams = ipull($common_ngrams, 'ngram', 'ngram');
foreach ($flat as $key => $spec) {
$ngram = $spec['ngram'];
if (isset($common_ngrams[$ngram])) {
unset($flat[$key]);
continue;
}
// NOTE: MySQL discards trailing whitespace in CHAR(X) columns.
$trim_ngram = rtrim($ngram, ' ');
if (isset($common_ngrams[$trim_ngram])) {
unset($flat[$key]);
continue;
}
}
}
// MySQL only allows us to join a maximum of 61 tables per query. Each
// ngram is going to cost us a join toward that limit, so if the user
// specified a very long query string, just pick 16 of the ngrams
// at random.
if (count($flat) > 16) {
shuffle($flat);
$flat = array_slice($flat, 0, 16);
}
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$phid_column = qsprintf($conn, '%T.%T', $alias, 'phid');
} else {
$phid_column = qsprintf($conn, '%T', 'phid');
}
$document_table = $engine->getDocumentTableName();
$field_table = $engine->getFieldTableName();
$joins = array();
$joins[] = qsprintf(
$conn,
'JOIN %T ft_doc ON ft_doc.objectPHID = %Q',
$document_table,
$phid_column);
$idx = 1;
foreach ($flat as $spec) {
$table = $spec['table'];
$ngram = $spec['ngram'];
$alias = 'ftngram_'.$idx++;
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.documentID = ft_doc.id AND %T.ngram = %s',
$table,
$alias,
$alias,
$alias,
$ngram);
}
foreach ($this->ferretTables as $table) {
$alias = $table['alias'];
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON ft_doc.id = %T.documentID
AND %T.fieldKey = %s',
$field_table,
$alias,
$alias,
$alias,
$table['key']);
}
return $joins;
}
protected function buildFerretWhereClause(AphrontDatabaseConnection $conn) {
if (!$this->ferretEngine) {
return array();
}
$engine = $this->ferretEngine;
$stemmer = $engine->newStemmer();
$table_map = $this->ferretTables;
$op_sub = PhutilSearchQueryCompiler::OPERATOR_SUBSTRING;
$op_not = PhutilSearchQueryCompiler::OPERATOR_NOT;
$where = array();
$current_function = 'all';
foreach ($this->ferretTokens as $fulltext_token) {
$raw_token = $fulltext_token->getToken();
$value = $raw_token->getValue();
$function = $raw_token->getFunction();
if ($function === null) {
$function = $current_function;
}
$current_function = $function;
$table_alias = $table_map[$function]['alias'];
$is_not = ($raw_token->getOperator() == $op_not);
if ($raw_token->getOperator() == $op_sub) {
$is_substring = true;
} else {
$is_substring = false;
}
// If we're doing substring search, we just match against the raw corpus
// and we're done.
if ($is_substring) {
if ($is_not) {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus NOT LIKE %~)',
$table_alias,
$value);
} else {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus LIKE %~)',
$table_alias,
$value);
}
continue;
}
// Otherwise, we need to match against the term corpus and the normal
// corpus, so that searching for "raw" does not find "strawberry".
if ($raw_token->isQuoted()) {
$is_quoted = true;
$is_stemmed = false;
} else {
$is_quoted = false;
$is_stemmed = true;
}
// Never stem negated queries, since this can exclude results users
// did not mean to exclude and generally confuse things.
if ($is_not) {
$is_stemmed = false;
}
$term_constraints = array();
$term_value = $engine->newTermsCorpus($value);
if ($is_not) {
$term_constraints[] = qsprintf(
$conn,
'(%T.termCorpus NOT LIKE %~)',
$table_alias,
$term_value);
} else {
$term_constraints[] = qsprintf(
$conn,
'(%T.termCorpus LIKE %~)',
$table_alias,
$term_value);
}
if ($is_stemmed) {
$stem_value = $stemmer->stemToken($value);
$stem_value = $engine->newTermsCorpus($stem_value);
$term_constraints[] = qsprintf(
$conn,
'(%T.normalCorpus LIKE %~)',
$table_alias,
$stem_value);
}
if ($is_not) {
$where[] = qsprintf(
$conn,
'(%Q)',
implode(' AND ', $term_constraints));
} else if ($is_quoted) {
$where[] = qsprintf(
$conn,
'(%T.rawCorpus LIKE %~ AND (%Q))',
$table_alias,
$value,
implode(' OR ', $term_constraints));
} else {
$where[] = qsprintf(
$conn,
'(%Q)',
implode(' OR ', $term_constraints));
}
}
if ($this->ferretQuery) {
$query = $this->ferretQuery;
$author_phids = $query->getParameter('authorPHIDs');
if ($author_phids) {
$where[] = qsprintf(
$conn,
'ft_doc.authorPHID IN (%Ls)',
$author_phids);
}
$with_unowned = $query->getParameter('withUnowned');
$with_any = $query->getParameter('withAnyOwner');
if ($with_any && $with_unowned) {
throw new PhabricatorEmptyQueryException(
pht(
'This query matches only unowned documents owned by anyone, '.
'which is impossible.'));
}
$owner_phids = $query->getParameter('ownerPHIDs');
if ($owner_phids && !$with_any) {
if ($with_unowned) {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IN (%Ls) OR ft_doc.ownerPHID IS NULL',
$owner_phids);
} else {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IN (%Ls)',
$owner_phids);
}
} else if ($with_unowned) {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IS NULL');
}
if ($with_any) {
$where[] = qsprintf(
$conn,
'ft_doc.ownerPHID IS NOT NULL');
}
$rel_open = PhabricatorSearchRelationship::RELATIONSHIP_OPEN;
$statuses = $query->getParameter('statuses');
$is_closed = null;
if ($statuses) {
$statuses = array_fuse($statuses);
if (count($statuses) == 1) {
if (isset($statuses[$rel_open])) {
$is_closed = 0;
} else {
$is_closed = 1;
}
}
}
if ($is_closed !== null) {
$where[] = qsprintf(
$conn,
'ft_doc.isClosed = %d',
$is_closed);
}
}
return $where;
}
protected function shouldGroupFerretResultRows() {
return (bool)$this->ferretTokens;
}
/* -( Ngrams )------------------------------------------------------------- */
protected function withNgramsConstraint(
PhabricatorSearchNgrams $index,
$value) {
if (strlen($value)) {
$this->ngrams[] = array(
'index' => $index,
'value' => $value,
'length' => count(phutil_utf8v($value)),
);
}
return $this;
}
protected function buildNgramsJoinClause(AphrontDatabaseConnection $conn) {
$flat = array();
foreach ($this->ngrams as $spec) {
$index = $spec['index'];
$value = $spec['value'];
$length = $spec['length'];
if ($length >= 3) {
$ngrams = $index->getNgramsFromString($value, 'query');
$prefix = false;
} else if ($length == 2) {
$ngrams = $index->getNgramsFromString($value, 'prefix');
$prefix = false;
} else {
$ngrams = array(' '.$value);
$prefix = true;
}
foreach ($ngrams as $ngram) {
$flat[] = array(
'table' => $index->getTableName(),
'ngram' => $ngram,
'prefix' => $prefix,
);
}
}
// MySQL only allows us to join a maximum of 61 tables per query. Each
// ngram is going to cost us a join toward that limit, so if the user
// specified a very long query string, just pick 16 of the ngrams
// at random.
if (count($flat) > 16) {
shuffle($flat);
$flat = array_slice($flat, 0, 16);
}
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$id_column = qsprintf($conn, '%T.%T', $alias, 'id');
} else {
$id_column = qsprintf($conn, '%T', 'id');
}
$idx = 1;
$joins = array();
foreach ($flat as $spec) {
$table = $spec['table'];
$ngram = $spec['ngram'];
$prefix = $spec['prefix'];
$alias = 'ngm'.$idx++;
if ($prefix) {
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.objectID = %Q AND %T.ngram LIKE %>',
$table,
$alias,
$alias,
$id_column,
$alias,
$ngram);
} else {
$joins[] = qsprintf(
$conn,
'JOIN %T %T ON %T.objectID = %Q AND %T.ngram = %s',
$table,
$alias,
$alias,
$id_column,
$alias,
$ngram);
}
}
return $joins;
}
protected function buildNgramsWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
foreach ($this->ngrams as $ngram) {
$index = $ngram['index'];
$value = $ngram['value'];
$column = $index->getColumnName();
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$column = qsprintf($conn, '%T.%T', $alias, $column);
} else {
$column = qsprintf($conn, '%T', $column);
}
$tokens = $index->tokenizeString($value);
foreach ($tokens as $token) {
$where[] = qsprintf(
$conn,
'%Q LIKE %~',
$column,
$token);
}
}
return $where;
}
protected function shouldGroupNgramResultRows() {
return (bool)$this->ngrams;
}
/* -( Edge Logic )--------------------------------------------------------- */
/**
* Convenience method for specifying edge logic constraints with a list of
* PHIDs.
*
* @param const Edge constant.
* @param const Constraint operator.
* @param list<phid> List of PHIDs.
* @return this
* @task edgelogic
*/
public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) {
$constraints = array();
foreach ($phids as $phid) {
$constraints[] = new PhabricatorQueryConstraint($operator, $phid);
}
return $this->withEdgeLogicConstraints($edge_type, $constraints);
}
/**
* @return this
* @task edgelogic
*/
public function withEdgeLogicConstraints($edge_type, array $constraints) {
assert_instances_of($constraints, 'PhabricatorQueryConstraint');
$constraints = mgroup($constraints, 'getOperator');
foreach ($constraints as $operator => $list) {
foreach ($list as $item) {
$this->edgeLogicConstraints[$edge_type][$operator][] = $item;
}
}
$this->edgeLogicConstraintsAreValid = false;
return $this;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) {
$select = array();
$this->validateEdgeLogicConstraints();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_AND:
if (count($list) > 1) {
$select[] = qsprintf(
$conn,
'COUNT(DISTINCT(%T.dst)) %T',
$alias,
$this->buildEdgeLogicTableAliasCount($alias));
}
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
// This is tricky. We have a query which specifies multiple
// projects, each of which may have an arbitrarily large number
// of descendants.
// Suppose the projects are "Engineering" and "Operations", and
// "Engineering" has subprojects X, Y and Z.
// We first use `FIELD(dst, X, Y, Z)` to produce a 0 if a row
// is not part of Engineering at all, or some number other than
// 0 if it is.
// Then we use `IF(..., idx, NULL)` to convert the 0 to a NULL and
// any other value to an index (say, 1) for the ancestor.
// We build these up for every ancestor, then use `COALESCE(...)`
// to select the non-null one, giving us an ancestor which this
// row is a member of.
// From there, we use `COUNT(DISTINCT(...))` to make sure that
// each result row is a member of all ancestors.
if (count($list) > 1) {
$idx = 1;
$parts = array();
foreach ($list as $constraint) {
$parts[] = qsprintf(
$conn,
'IF(FIELD(%T.dst, %Ls) != 0, %d, NULL)',
$alias,
(array)$constraint->getValue(),
$idx++);
}
$parts = implode(', ', $parts);
$select[] = qsprintf(
$conn,
'COUNT(DISTINCT(COALESCE(%Q))) %T',
$parts,
$this->buildEdgeLogicTableAliasAncestor($alias));
}
break;
default:
break;
}
}
}
return $select;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) {
$edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE;
$phid_column = $this->getApplicationSearchObjectPHIDColumn();
$joins = array();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
$has_null = isset($constraints[$op_null]);
// If we're going to process an only() operator, build a list of the
// acceptable set of PHIDs first. We'll only match results which have
// no edges to any other PHIDs.
$all_phids = array();
if (isset($constraints[PhabricatorQueryConstraint::OPERATOR_ONLY])) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
foreach ($list as $constraint) {
$value = (array)$constraint->getValue();
foreach ($value as $v) {
$all_phids[$v] = $v;
}
}
break;
}
}
}
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
$phids = array();
foreach ($list as $constraint) {
$value = (array)$constraint->getValue();
foreach ($value as $v) {
$phids[$v] = $v;
}
}
$phids = array_keys($phids);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_NOT:
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
AND %T.dst IN (%Ls)',
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type,
$alias,
$phids);
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
// If we're including results with no matches, we have to degrade
// this to a LEFT join. We'll use WHERE to select matching rows
// later.
if ($has_null) {
$join_type = 'LEFT';
} else {
$join_type = '';
}
$joins[] = qsprintf(
$conn,
'%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d
AND %T.dst IN (%Ls)',
$join_type,
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type,
$alias,
$phids);
break;
case PhabricatorQueryConstraint::OPERATOR_NULL:
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d',
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type);
break;
case PhabricatorQueryConstraint::OPERATOR_ONLY:
$joins[] = qsprintf(
$conn,
'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d
AND %T.dst NOT IN (%Ls)',
$edge_table,
$alias,
$phid_column,
$alias,
$alias,
$type,
$alias,
$all_phids);
break;
}
}
}
return $joins;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
$full = array();
$null = array();
$op_null = PhabricatorQueryConstraint::OPERATOR_NULL;
$has_null = isset($constraints[$op_null]);
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_NOT:
case PhabricatorQueryConstraint::OPERATOR_ONLY:
$full[] = qsprintf(
$conn,
'%T.dst IS NULL',
$alias);
break;
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
if ($has_null) {
$full[] = qsprintf(
$conn,
'%T.dst IS NOT NULL',
$alias);
}
break;
case PhabricatorQueryConstraint::OPERATOR_NULL:
$null[] = qsprintf(
$conn,
'%T.dst IS NULL',
$alias);
break;
}
}
if ($full && $null) {
$full = $this->formatWhereSubclause($full);
$null = $this->formatWhereSubclause($null);
$where[] = qsprintf($conn, '(%Q OR %Q)', $full, $null);
} else if ($full) {
foreach ($full as $condition) {
$where[] = $condition;
}
} else if ($null) {
foreach ($null as $condition) {
$where[] = $condition;
}
}
}
return $where;
}
/**
* @task edgelogic
*/
public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) {
$having = array();
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
$alias = $this->getEdgeLogicTableAlias($operator, $type);
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_AND:
if (count($list) > 1) {
$having[] = qsprintf(
$conn,
'%T = %d',
$this->buildEdgeLogicTableAliasCount($alias),
count($list));
}
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
if (count($list) > 1) {
$having[] = qsprintf(
$conn,
'%T = %d',
$this->buildEdgeLogicTableAliasAncestor($alias),
count($list));
}
break;
}
}
}
return $having;
}
/**
* @task edgelogic
*/
public function shouldGroupEdgeLogicResultRows() {
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_NOT:
case PhabricatorQueryConstraint::OPERATOR_AND:
case PhabricatorQueryConstraint::OPERATOR_OR:
if (count($list) > 1) {
return true;
}
break;
case PhabricatorQueryConstraint::OPERATOR_ANCESTOR:
// NOTE: We must always group query results rows when using an
// "ANCESTOR" operator because a single task may be related to
// two different descendants of a particular ancestor. For
// discussion, see T12753.
return true;
case PhabricatorQueryConstraint::OPERATOR_NULL:
case PhabricatorQueryConstraint::OPERATOR_ONLY:
return true;
}
}
}
return false;
}
/**
* @task edgelogic
*/
private function getEdgeLogicTableAlias($operator, $type) {
return 'edgelogic_'.$operator.'_'.$type;
}
/**
* @task edgelogic
*/
private function buildEdgeLogicTableAliasCount($alias) {
return $alias.'_count';
}
/**
* @task edgelogic
*/
private function buildEdgeLogicTableAliasAncestor($alias) {
return $alias.'_ancestor';
}
/**
* Select certain edge logic constraint values.
*
* @task edgelogic
*/
protected function getEdgeLogicValues(
array $edge_types,
array $operators) {
$values = array();
$constraint_lists = $this->edgeLogicConstraints;
if ($edge_types) {
$constraint_lists = array_select_keys($constraint_lists, $edge_types);
}
foreach ($constraint_lists as $type => $constraints) {
if ($operators) {
$constraints = array_select_keys($constraints, $operators);
}
foreach ($constraints as $operator => $list) {
foreach ($list as $constraint) {
$value = (array)$constraint->getValue();
foreach ($value as $v) {
$values[] = $v;
}
}
}
}
return $values;
}
/**
* Validate edge logic constraints for the query.
*
* @return this
* @task edgelogic
*/
private function validateEdgeLogicConstraints() {
if ($this->edgeLogicConstraintsAreValid) {
return $this;
}
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_EMPTY:
throw new PhabricatorEmptyQueryException(
pht('This query specifies an empty constraint.'));
}
}
}
// This should probably be more modular, eventually, but we only do
// project-based edge logic today.
$project_phids = $this->getEdgeLogicValues(
array(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
),
array(
PhabricatorQueryConstraint::OPERATOR_AND,
PhabricatorQueryConstraint::OPERATOR_OR,
PhabricatorQueryConstraint::OPERATOR_NOT,
PhabricatorQueryConstraint::OPERATOR_ANCESTOR,
));
if ($project_phids) {
$projects = id(new PhabricatorProjectQuery())
->setViewer($this->getViewer())
->setParentQuery($this)
->withPHIDs($project_phids)
->execute();
$projects = mpull($projects, null, 'getPHID');
foreach ($project_phids as $phid) {
if (empty($projects[$phid])) {
throw new PhabricatorEmptyQueryException(
pht(
'This query is constrained by a project you do not have '.
'permission to see.'));
}
}
}
$op_and = PhabricatorQueryConstraint::OPERATOR_AND;
$op_or = PhabricatorQueryConstraint::OPERATOR_OR;
$op_ancestor = PhabricatorQueryConstraint::OPERATOR_ANCESTOR;
foreach ($this->edgeLogicConstraints as $type => $constraints) {
foreach ($constraints as $operator => $list) {
switch ($operator) {
case PhabricatorQueryConstraint::OPERATOR_ONLY:
if (count($list) > 1) {
throw new PhabricatorEmptyQueryException(
pht(
'This query specifies only() more than once.'));
}
$have_and = idx($constraints, $op_and);
$have_or = idx($constraints, $op_or);
$have_ancestor = idx($constraints, $op_ancestor);
if (!$have_and && !$have_or && !$have_ancestor) {
throw new PhabricatorEmptyQueryException(
pht(
'This query specifies only(), but no other constraints '.
'which it can apply to.'));
}
break;
}
}
}
$this->edgeLogicConstraintsAreValid = true;
return $this;
}
/* -( Spaces )------------------------------------------------------------- */
/**
* Constrain the query to return results from only specific Spaces.
*
* Pass a list of Space PHIDs, or `null` to represent the default space. Only
* results in those Spaces will be returned.
*
* Queries are always constrained to include only results from spaces the
* viewer has access to.
*
* @param list<phid|null>
* @task spaces
*/
public function withSpacePHIDs(array $space_phids) {
$object = $this->newResultObject();
if (!$object) {
throw new Exception(
pht(
'This query (of class "%s") does not implement newResultObject(), '.
'but must implement this method to enable support for Spaces.',
get_class($this)));
}
if (!($object instanceof PhabricatorSpacesInterface)) {
throw new Exception(
pht(
'This query (of class "%s") returned an object of class "%s" from '.
'getNewResultObject(), but it does not implement the required '.
'interface ("%s"). Objects must implement this interface to enable '.
'Spaces support.',
get_class($this),
get_class($object),
'PhabricatorSpacesInterface'));
}
$this->spacePHIDs = $space_phids;
return $this;
}
public function withSpaceIsArchived($archived) {
$this->spaceIsArchived = $archived;
return $this;
}
/**
* Constrain the query to include only results in valid Spaces.
*
* This method builds part of a WHERE clause which considers the spaces the
* viewer has access to see with any explicit constraint on spaces added by
* @{method:withSpacePHIDs}.
*
* @param AphrontDatabaseConnection Database connection.
* @return string Part of a WHERE clause.
* @task spaces
*/
private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) {
$object = $this->newResultObject();
if (!$object) {
return null;
}
if (!($object instanceof PhabricatorSpacesInterface)) {
return null;
}
$viewer = $this->getViewer();
// If we have an omnipotent viewer and no formal space constraints, don't
// emit a clause. This primarily enables older migrations to run cleanly,
// without fataling because they try to match a `spacePHID` column which
// does not exist yet. See T8743, T8746.
if ($viewer->isOmnipotent()) {
if ($this->spaceIsArchived === null && $this->spacePHIDs === null) {
return null;
}
}
$space_phids = array();
$include_null = false;
$all = PhabricatorSpacesNamespaceQuery::getAllSpaces();
if (!$all) {
// If there are no spaces at all, implicitly give the viewer access to
// the default space.
$include_null = true;
} else {
// Otherwise, give them access to the spaces they have permission to
// see.
$viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces(
$viewer);
foreach ($viewer_spaces as $viewer_space) {
if ($this->spaceIsArchived !== null) {
if ($viewer_space->getIsArchived() != $this->spaceIsArchived) {
continue;
}
}
$phid = $viewer_space->getPHID();
$space_phids[$phid] = $phid;
if ($viewer_space->getIsDefaultNamespace()) {
$include_null = true;
}
}
}
// If we have additional explicit constraints, evaluate them now.
if ($this->spacePHIDs !== null) {
$explicit = array();
$explicit_null = false;
foreach ($this->spacePHIDs as $phid) {
if ($phid === null) {
$space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
} else {
$space = idx($all, $phid);
}
if ($space) {
$phid = $space->getPHID();
$explicit[$phid] = $phid;
if ($space->getIsDefaultNamespace()) {
$explicit_null = true;
}
}
}
// If the viewer can see the default space but it isn't on the explicit
// list of spaces to query, don't match it.
if ($include_null && !$explicit_null) {
$include_null = false;
}
// Include only the spaces common to the viewer and the constraints.
$space_phids = array_intersect_key($space_phids, $explicit);
}
if (!$space_phids && !$include_null) {
if ($this->spacePHIDs === null) {
throw new PhabricatorEmptyQueryException(
pht('You do not have access to any spaces.'));
} else {
throw new PhabricatorEmptyQueryException(
pht(
'You do not have access to any of the spaces this query '.
'is constrained to.'));
}
}
$alias = $this->getPrimaryTableAlias();
if ($alias) {
$col = qsprintf($conn, '%T.spacePHID', $alias);
} else {
$col = 'spacePHID';
}
if ($space_phids && $include_null) {
return qsprintf(
$conn,
'(%Q IN (%Ls) OR %Q IS NULL)',
$col,
$space_phids,
$col);
} else if ($space_phids) {
return qsprintf(
$conn,
'%Q IN (%Ls)',
$col,
$space_phids);
} else {
return qsprintf(
$conn,
'%Q IS NULL',
$col);
}
}
}
diff --git a/src/infrastructure/storage/lisk/LiskDAO.php b/src/infrastructure/storage/lisk/LiskDAO.php
index 94eef7d163..0c940ef5f0 100644
--- a/src/infrastructure/storage/lisk/LiskDAO.php
+++ b/src/infrastructure/storage/lisk/LiskDAO.php
@@ -1,2022 +1,2022 @@
<?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@libphutil: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 {
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 $inSet = null;
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, 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
*/
abstract protected function getConnectionNamespace();
/**
* Get an existing, cached connection for this object.
*
* @param 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 Connection mode.
* @param AphrontDatabaseConnection Connection to cache.
* @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 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, one of the CONFIG_* constants.
* @return mixed Option value, if configured (null if unavailable).
*
* @task config
*/
public function getConfigOption($option_name) {
static $options = null;
if (!isset($options)) {
$options = $this->getConfiguration();
}
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 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->getIDKeyForUse(),
$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 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 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... */) {
$connection = $this->establishConnection('r');
$lock_clause = '';
if ($connection->isReadLocking()) {
$lock_clause = 'FOR UPDATE';
} else if ($connection->isWriteLocking()) {
$lock_clause = 'LOCK IN SHARE MODE';
}
$args = func_get_args();
$args = array_slice($args, 1);
$pattern = 'SELECT * FROM %T WHERE '.$pattern.' %Q';
array_unshift($args, $this->getTableName());
array_push($args, $lock_clause);
array_unshift($args, $pattern);
return call_user_func_array(
array($connection, '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->getIDKeyForUse(),
$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 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) {
static $valid_properties = array();
$map = array();
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_properties[$k])) {
if (isset($valid_properties[$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_properties[$k] = $this->hasProperty($k);
if (!$valid_properties[$k]) {
continue;
}
}
$map[$k] = $v;
}
$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 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])) {
$result[$row[$id_key]] = $obj->loadFromArray($row);
} else {
$result[] = $obj->loadFromArray($row);
}
if ($this->inSet) {
$this->inSet->addToSet($obj);
}
}
return $result;
}
/**
* This method helps to prevent the 1+N queries problem. It happens when you
* execute a query for each row in a result set. Like in this code:
*
* COUNTEREXAMPLE, name=Easy to write but expensive to execute
* $diffs = id(new DifferentialDiff())->loadAllWhere(
* 'revisionID = %d',
* $revision->getID());
* foreach ($diffs as $diff) {
* $changesets = id(new DifferentialChangeset())->loadAllWhere(
* 'diffID = %d',
* $diff->getID());
* // Do something with $changesets.
* }
*
* One can solve this problem by reading all the dependent objects at once and
* assigning them later:
*
* COUNTEREXAMPLE, name=Cheaper to execute but harder to write and maintain
* $diffs = id(new DifferentialDiff())->loadAllWhere(
* 'revisionID = %d',
* $revision->getID());
* $all_changesets = id(new DifferentialChangeset())->loadAllWhere(
* 'diffID IN (%Ld)',
* mpull($diffs, 'getID'));
* $all_changesets = mgroup($all_changesets, 'getDiffID');
* foreach ($diffs as $diff) {
* $changesets = idx($all_changesets, $diff->getID(), array());
* // Do something with $changesets.
* }
*
* The method @{method:loadRelatives} abstracts this approach which allows
* writing a code which is simple and efficient at the same time:
*
* name=Easy to write and cheap to execute
* $diffs = $revision->loadRelatives(new DifferentialDiff(), 'revisionID');
* foreach ($diffs as $diff) {
* $changesets = $diff->loadRelatives(
* new DifferentialChangeset(),
* 'diffID');
* // Do something with $changesets.
* }
*
* This will load dependent objects for all diffs in the first call of
* @{method:loadRelatives} and use this result for all following calls.
*
* The method supports working with set of sets, like in this code:
*
* $diffs = $revision->loadRelatives(new DifferentialDiff(), 'revisionID');
* foreach ($diffs as $diff) {
* $changesets = $diff->loadRelatives(
* new DifferentialChangeset(),
* 'diffID');
* foreach ($changesets as $changeset) {
* $hunks = $changeset->loadRelatives(
* new DifferentialHunk(),
* 'changesetID');
* // Do something with hunks.
* }
* }
*
* This code will execute just three queries - one to load all diffs, one to
* load all their related changesets and one to load all their related hunks.
* You can try to write an equivalent code without using this method as
* a homework.
*
* The method also supports retrieving referenced objects, for example authors
* of all diffs (using shortcut @{method:loadOneRelative}):
*
* foreach ($diffs as $diff) {
* $author = $diff->loadOneRelative(
* new PhabricatorUser(),
* 'phid',
* 'getAuthorPHID');
* // Do something with author.
* }
*
* It is also possible to specify additional conditions for the `WHERE`
* clause. Similarly to @{method:loadAllWhere}, you can specify everything
* after `WHERE` (except `LIMIT`). Contrary to @{method:loadAllWhere}, it is
* allowed to pass only a constant string (`%` doesn't have a special
* meaning). This is intentional to avoid mistakes with using data from one
* row in retrieving other rows. Example of a correct usage:
*
* $status = $author->loadOneRelative(
* new PhabricatorCalendarEvent(),
* 'userPHID',
* 'getPHID',
* '(UNIX_TIMESTAMP() BETWEEN dateFrom AND dateTo)');
*
* @param LiskDAO Type of objects to load.
* @param string Name of the column in target table.
* @param string Method name in this table.
* @param string Additional constraints on returned rows. It supports no
* placeholders and requires putting the WHERE part into
* parentheses. It's not possible to use LIMIT.
* @return list Objects of type $object.
*
* @task load
*/
public function loadRelatives(
LiskDAO $object,
$foreign_column,
$key_method = 'getID',
$where = '') {
if (!$this->inSet) {
id(new LiskDAOSet())->addToSet($this);
}
$relatives = $this->inSet->loadRelatives(
$object,
$foreign_column,
$key_method,
$where);
return idx($relatives, $this->$key_method(), array());
}
/**
* Load referenced row. See @{method:loadRelatives} for details.
*
* @param LiskDAO Type of objects to load.
* @param string Name of the column in target table.
* @param string Method name in this table.
* @param string Additional constraints on returned rows. It supports no
* placeholders and requires putting the WHERE part into
* parentheses. It's not possible to use LIMIT.
* @return LiskDAO Object of type $object or null if there's no such object.
*
* @task load
*/
final public function loadOneRelative(
LiskDAO $object,
$foreign_column,
$key_method = 'getID',
$where = '') {
$relatives = $this->loadRelatives(
$object,
$foreign_column,
$key_method,
$where);
if (!$relatives) {
return null;
}
if (count($relatives) > 1) {
throw new AphrontCountQueryException(
pht(
'More than one result from %s!',
__FUNCTION__.'()'));
}
return reset($relatives);
}
final public function putInSet(LiskDAOSet $set) {
$this->inSet = $set;
return $this;
}
final protected function getInSet() {
return $this->inSet;
}
/* -( Examining Objects )-------------------------------------------------- */
/**
* Set unique ID identifying this object. You normally don't need to call this
* method unless with `IDS_MANUAL`.
*
* @param mixed Unique ID.
* @return this
* @task save
*/
public function setID($id) {
static $id_key = null;
if ($id_key === null) {
$id_key = $this->getIDKeyForUse();
}
$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() {
static $id_key = null;
if ($id_key === null) {
$id_key = $this->getIDKeyForUse();
}
return $this->$id_key;
}
public function getPHID() {
return $this->phid;
}
/**
* Test if a property exists.
*
* @param string 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() {
static $properties = null;
if (!isset($properties)) {
$class = new ReflectionClass(get_class($this));
$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']);
}
}
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) {
static $properties = null;
if ($properties === null) {
$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 'r' for read, 'w' for read/write.
* @param bool 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 colums flagged as nonmutable from the update statement.
+ // 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);
}
}
$map = implode(', ', $map);
$id = $this->getID();
$conn->query(
'UPDATE %T SET %Q WHERE %C = '.(is_int($id) ? '%d' : '%s'),
$this->getTableName(),
$map,
$this->getIDKeyForUse(),
$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 %T WHERE %C = %d',
$this->getTableName(),
$this->getIDKeyForUse(),
$this->getID());
$this->didDelete();
return $this;
}
/**
* Internal implementation of INSERT and REPLACE.
*
* @param const 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->getIDKeyForUse();
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->getIDKeyForUse();
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);
}
}
$data = implode(', ', $data);
$conn->query(
'%Q INTO %T (%LC) VALUES (%Q)',
$mode,
$this->getTableName(),
$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';
}
protected function getIDKeyForUse() {
$id_key = $this->getIDKey();
if (!$id_key) {
throw new Exception(
pht(
'This DAO does not have a single-part primary key. The method you '.
'called requires a single-part primary key.'));
}
return $id_key;
}
/**
* 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 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 Canonical field name
* @param mixed 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 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) {
$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);
}
}
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 name.
* @param list Argument vector.
* @return mixed get*() methods return the property value. set*() methods
* return $this.
* @task util
*/
public function __call($method, $args) {
// NOTE: PHP has a bug that static variables defined in __call() are shared
// across all children classes. Call a different method to work around this
// bug.
return $this->call($method, $args);
}
/**
* @task util
*/
final protected function call($method, $args) {
// 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.
static $dispatch_map = array();
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;
}
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->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 Database where the counter resides.
* @param string 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 Database where the counter resides.
* @param string 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 Database where the counter resides.
* @param string Counter name to create or overwrite.
* @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);
}
}
diff --git a/src/infrastructure/storage/lisk/__tests__/LiskFixtureTestCase.php b/src/infrastructure/storage/lisk/__tests__/LiskFixtureTestCase.php
index 065c23e2ff..92c43fcd4b 100644
--- a/src/infrastructure/storage/lisk/__tests__/LiskFixtureTestCase.php
+++ b/src/infrastructure/storage/lisk/__tests__/LiskFixtureTestCase.php
@@ -1,166 +1,166 @@
<?php
final class LiskFixtureTestCase extends PhabricatorTestCase {
protected function getPhabricatorTestCaseConfiguration() {
return array(
self::PHABRICATOR_TESTCONFIG_BUILD_STORAGE_FIXTURES => true,
);
}
public function testTransactionalIsolation1of2() {
// NOTE: These tests are verifying that data is destroyed between tests.
// If the user from either test persists, the other test will fail.
$this->assertEqual(
0,
count(id(new HarbormasterScratchTable())->loadAll()));
id(new HarbormasterScratchTable())
->setData('alincoln')
->save();
}
public function testTransactionalIsolation2of2() {
$this->assertEqual(
0,
count(id(new HarbormasterScratchTable())->loadAll()));
id(new HarbormasterScratchTable())
->setData('ugrant')
->save();
}
public function testFixturesBasicallyWork() {
$this->assertEqual(
0,
count(id(new HarbormasterScratchTable())->loadAll()));
id(new HarbormasterScratchTable())
->setData('gwashington')
->save();
$this->assertEqual(
1,
count(id(new HarbormasterScratchTable())->loadAll()));
}
public function testReadableTransactions() {
// TODO: When we have semi-durable fixtures, use those instead. This is
// extremely hacky.
LiskDAO::endIsolateAllLiskEffectsToTransactions();
try {
$data = Filesystem::readRandomCharacters(32);
$obj = new HarbormasterScratchTable();
$obj->openTransaction();
$obj->setData($data);
$obj->save();
$loaded = id(new HarbormasterScratchTable())->loadOneWhere(
'data = %s',
$data);
$obj->killTransaction();
$this->assertTrue(
($loaded !== null),
pht('Reads inside transactions should have transaction visibility.'));
LiskDAO::beginIsolateAllLiskEffectsToTransactions();
} catch (Exception $ex) {
LiskDAO::beginIsolateAllLiskEffectsToTransactions();
throw $ex;
}
}
public function testGarbageLoadCalls() {
$obj = new HarbormasterObject();
$obj->save();
$id = $obj->getID();
$load = new HarbormasterObject();
$this->assertEqual(null, $load->load(0));
$this->assertEqual(null, $load->load(-1));
$this->assertEqual(null, $load->load(9999));
$this->assertEqual(null, $load->load(''));
$this->assertEqual(null, $load->load('cow'));
$this->assertEqual(null, $load->load($id.'cow'));
$this->assertTrue((bool)$load->load((int)$id));
$this->assertTrue((bool)$load->load((string)$id));
}
public function testCounters() {
$obj = new HarbormasterObject();
$conn_w = $obj->establishConnection('w');
- // Test that the counter bascially behaves as expected.
+ // Test that the counter basically behaves as expected.
$this->assertEqual(1, LiskDAO::loadNextCounterValue($conn_w, 'a'));
$this->assertEqual(2, LiskDAO::loadNextCounterValue($conn_w, 'a'));
$this->assertEqual(3, LiskDAO::loadNextCounterValue($conn_w, 'a'));
// This first insert is primarily a test that the previous LAST_INSERT_ID()
// value does not bleed into the creation of a new counter.
$this->assertEqual(1, LiskDAO::loadNextCounterValue($conn_w, 'b'));
$this->assertEqual(2, LiskDAO::loadNextCounterValue($conn_w, 'b'));
// Test alternate access/overwrite methods.
$this->assertEqual(3, LiskDAO::loadCurrentCounterValue($conn_w, 'a'));
LiskDAO::overwriteCounterValue($conn_w, 'a', 42);
$this->assertEqual(42, LiskDAO::loadCurrentCounterValue($conn_w, 'a'));
$this->assertEqual(43, LiskDAO::loadNextCounterValue($conn_w, 'a'));
// These inserts alternate database connections. Since unit tests are
// transactional by default, we need to break out of them or we'll deadlock
// since the transactions don't normally close until we exit the test.
LiskDAO::endIsolateAllLiskEffectsToTransactions();
try {
$conn_1 = $obj->establishConnection('w', $force_new = true);
$conn_2 = $obj->establishConnection('w', $force_new = true);
$this->assertEqual(1, LiskDAO::loadNextCounterValue($conn_1, 'z'));
$this->assertEqual(2, LiskDAO::loadNextCounterValue($conn_2, 'z'));
$this->assertEqual(3, LiskDAO::loadNextCounterValue($conn_1, 'z'));
$this->assertEqual(4, LiskDAO::loadNextCounterValue($conn_2, 'z'));
$this->assertEqual(5, LiskDAO::loadNextCounterValue($conn_1, 'z'));
LiskDAO::beginIsolateAllLiskEffectsToTransactions();
} catch (Exception $ex) {
LiskDAO::beginIsolateAllLiskEffectsToTransactions();
throw $ex;
}
}
public function testNonmutableColumns() {
$object = id(new HarbormasterScratchTable())
->setData('val1')
->setNonmutableData('val1')
->save();
$object->reload();
$this->assertEqual('val1', $object->getData());
$this->assertEqual('val1', $object->getNonmutableData());
$object
->setData('val2')
->setNonmutableData('val2')
->save();
$object->reload();
$this->assertEqual('val2', $object->getData());
// NOTE: This is the important test: the nonmutable column should not have
// been affected by the update.
$this->assertEqual('val1', $object->getNonmutableData());
}
}
diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php
index a03ef41c4d..f6b120ba83 100644
--- a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php
+++ b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php
@@ -1,1225 +1,1225 @@
<?php
abstract class PhabricatorStorageManagementWorkflow
extends PhabricatorManagementWorkflow {
private $apis = array();
private $dryRun;
private $force;
private $patches;
private $didInitialize;
final public function setAPIs(array $apis) {
$this->apis = $apis;
return $this;
}
final public function getAnyAPI() {
return head($this->getAPIs());
}
final public function getMasterAPIs() {
$apis = $this->getAPIs();
$results = array();
foreach ($apis as $api) {
if ($api->getRef()->getIsMaster()) {
$results[] = $api;
}
}
if (!$results) {
throw new PhutilArgumentUsageException(
pht(
'This command only operates on database masters, but the selected '.
'database hosts do not include any masters.'));
}
return $results;
}
final public function getSingleAPI() {
$apis = $this->getAPIs();
if (count($apis) == 1) {
return head($apis);
}
throw new PhutilArgumentUsageException(
pht(
'Phabricator is configured in cluster mode, with multiple database '.
'hosts. Use "--host" to specify which host you want to operate on.'));
}
final public function getAPIs() {
return $this->apis;
}
final protected function isDryRun() {
return $this->dryRun;
}
final protected function setDryRun($dry_run) {
$this->dryRun = $dry_run;
return $this;
}
final protected function isForce() {
return $this->force;
}
final protected function setForce($force) {
$this->force = $force;
return $this;
}
public function getPatches() {
return $this->patches;
}
public function setPatches(array $patches) {
assert_instances_of($patches, 'PhabricatorStoragePatch');
$this->patches = $patches;
return $this;
}
protected function isReadOnlyWorkflow() {
return false;
}
public function execute(PhutilArgumentParser $args) {
$this->setDryRun($args->getArg('dryrun'));
$this->setForce($args->getArg('force'));
if (!$this->isReadOnlyWorkflow()) {
if (PhabricatorEnv::isReadOnly()) {
if ($this->isForce()) {
PhabricatorEnv::setReadOnly(false, null);
} else {
throw new PhutilArgumentUsageException(
pht(
'Phabricator is currently in read-only mode. Use --force to '.
'override this mode.'));
}
}
}
return $this->didExecute($args);
}
public function didExecute(PhutilArgumentParser $args) {}
private function loadSchemata(PhabricatorStorageManagementAPI $api) {
$query = id(new PhabricatorConfigSchemaQuery());
$ref = $api->getRef();
$ref_key = $ref->getRefKey();
$query->setAPIs(array($api));
$query->setRefs(array($ref));
$actual = $query->loadActualSchemata();
$expect = $query->loadExpectedSchemata();
$comp = $query->buildComparisonSchemata($expect, $actual);
return array(
$comp[$ref_key],
$expect[$ref_key],
$actual[$ref_key],
);
}
final protected function adjustSchemata(
PhabricatorStorageManagementAPI $api,
$unsafe) {
$lock = $this->lock($api);
try {
$err = $this->doAdjustSchemata($api, $unsafe);
// Analyze tables if we're not doing a dry run and adjustments are either
// all clear or have minor errors like surplus tables.
if (!$this->dryRun) {
$should_analyze = (($err == 0) || ($err == 2));
if ($should_analyze) {
$this->analyzeTables($api);
}
}
} catch (Exception $ex) {
$lock->unlock();
throw $ex;
}
$lock->unlock();
return $err;
}
final private function doAdjustSchemata(
PhabricatorStorageManagementAPI $api,
$unsafe) {
$console = PhutilConsole::getConsole();
$console->writeOut(
"%s\n",
pht(
'Verifying database schemata on "%s"...',
$api->getRef()->getRefKey()));
list($adjustments, $errors) = $this->findAdjustments($api);
if (!$adjustments) {
$console->writeOut(
"%s\n",
pht('Found no adjustments for schemata.'));
return $this->printErrors($errors, 0);
}
if (!$this->force && !$api->isCharacterSetAvailable('utf8mb4')) {
$message = pht(
"You have an old version of MySQL (older than 5.5) which does not ".
- "support the utf8mb4 character set. We strongly recomend upgrading to ".
- "5.5 or newer.\n\n".
+ "support the utf8mb4 character set. We strongly recommend upgrading ".
+ "to 5.5 or newer.\n\n".
"If you apply adjustments now and later update MySQL to 5.5 or newer, ".
"you'll need to apply adjustments again (and they will take a long ".
"time).\n\n".
"You can exit this workflow, update MySQL now, and then run this ".
"workflow again. This is recommended, but may cause a lot of downtime ".
"right now.\n\n".
"You can exit this workflow, continue using Phabricator without ".
"applying adjustments, update MySQL at a later date, and then run ".
"this workflow again. This is also a good approach, and will let you ".
"delay downtime until later.\n\n".
"You can proceed with this workflow, and then optionally update ".
"MySQL at a later date. After you do, you'll need to apply ".
"adjustments again.\n\n".
"For more information, see \"Managing Storage Adjustments\" in ".
"the documentation.");
$console->writeOut(
"\n**<bg:yellow> %s </bg>**\n\n%s\n",
pht('OLD MySQL VERSION'),
phutil_console_wrap($message));
$prompt = pht('Continue with old MySQL version?');
if (!phutil_console_confirm($prompt, $default_no = true)) {
return;
}
}
$table = id(new PhutilConsoleTable())
->addColumn('database', array('title' => pht('Database')))
->addColumn('table', array('title' => pht('Table')))
->addColumn('name', array('title' => pht('Name')))
->addColumn('info', array('title' => pht('Issues')));
foreach ($adjustments as $adjust) {
$info = array();
foreach ($adjust['issues'] as $issue) {
$info[] = PhabricatorConfigStorageSchema::getIssueName($issue);
}
$table->addRow(array(
'database' => $adjust['database'],
'table' => idx($adjust, 'table'),
'name' => idx($adjust, 'name'),
'info' => implode(', ', $info),
));
}
$console->writeOut("\n\n");
$table->draw();
if ($this->dryRun) {
$console->writeOut(
"%s\n",
pht('DRYRUN: Would apply adjustments.'));
return 0;
} else if ($this->didInitialize) {
// If we just initialized the database, continue without prompting. This
// is nicer for first-time setup and there's no reasonable reason any
// user would ever answer "no" to the prompt against an empty schema.
} else if (!$this->force) {
$console->writeOut(
"\n%s\n",
pht(
"Found %s adjustment(s) to apply, detailed above.\n\n".
"You can review adjustments in more detail from the web interface, ".
"in Config > Database Status. To better understand the adjustment ".
"workflow, see \"Managing Storage Adjustments\" in the ".
"documentation.\n\n".
"MySQL needs to copy table data to make some adjustments, so these ".
"migrations may take some time.",
phutil_count($adjustments)));
$prompt = pht('Apply these schema adjustments?');
if (!phutil_console_confirm($prompt, $default_no = true)) {
return 1;
}
}
$console->writeOut(
"%s\n",
pht('Applying schema adjustments...'));
$conn = $api->getConn(null);
if ($unsafe) {
queryfx($conn, 'SET SESSION sql_mode = %s', '');
} else {
queryfx($conn, 'SET SESSION sql_mode = %s', 'STRICT_ALL_TABLES');
}
$failed = array();
// We make changes in several phases.
$phases = array(
// Drop surplus autoincrements. This allows us to drop primary keys on
// autoincrement columns.
'drop_auto',
// Drop all keys we're going to adjust. This prevents them from
// interfering with column changes.
'drop_keys',
// Apply all database, table, and column changes.
'main',
// Restore adjusted keys.
'add_keys',
// Add missing autoincrements.
'add_auto',
);
$bar = id(new PhutilConsoleProgressBar())
->setTotal(count($adjustments) * count($phases));
foreach ($phases as $phase) {
foreach ($adjustments as $adjust) {
try {
switch ($adjust['kind']) {
case 'database':
if ($phase == 'main') {
queryfx(
$conn,
'ALTER DATABASE %T CHARACTER SET = %s COLLATE = %s',
$adjust['database'],
$adjust['charset'],
$adjust['collation']);
}
break;
case 'table':
if ($phase == 'main') {
queryfx(
$conn,
'ALTER TABLE %T.%T COLLATE = %s, ENGINE = %s',
$adjust['database'],
$adjust['table'],
$adjust['collation'],
$adjust['engine']);
}
break;
case 'column':
$apply = false;
$auto = false;
$new_auto = idx($adjust, 'auto');
if ($phase == 'drop_auto') {
if ($new_auto === false) {
$apply = true;
$auto = false;
}
} else if ($phase == 'main') {
$apply = true;
if ($new_auto === false) {
$auto = false;
} else {
$auto = $adjust['is_auto'];
}
} else if ($phase == 'add_auto') {
if ($new_auto === true) {
$apply = true;
$auto = true;
}
}
if ($apply) {
$parts = array();
if ($auto) {
$parts[] = qsprintf(
$conn,
'AUTO_INCREMENT');
}
if ($adjust['charset']) {
$parts[] = qsprintf(
$conn,
'CHARACTER SET %Q COLLATE %Q',
$adjust['charset'],
$adjust['collation']);
}
queryfx(
$conn,
'ALTER TABLE %T.%T MODIFY %T %Q %Q %Q',
$adjust['database'],
$adjust['table'],
$adjust['name'],
$adjust['type'],
implode(' ', $parts),
$adjust['nullable'] ? 'NULL' : 'NOT NULL');
}
break;
case 'key':
if (($phase == 'drop_keys') && $adjust['exists']) {
if ($adjust['name'] == 'PRIMARY') {
$key_name = 'PRIMARY KEY';
} else {
$key_name = qsprintf($conn, 'KEY %T', $adjust['name']);
}
queryfx(
$conn,
'ALTER TABLE %T.%T DROP %Q',
$adjust['database'],
$adjust['table'],
$key_name);
}
if (($phase == 'add_keys') && $adjust['keep']) {
// Different keys need different creation syntax. Notable
// special cases are primary keys and fulltext keys.
if ($adjust['name'] == 'PRIMARY') {
$key_name = 'PRIMARY KEY';
} else if ($adjust['indexType'] == 'FULLTEXT') {
$key_name = qsprintf($conn, 'FULLTEXT %T', $adjust['name']);
} else {
if ($adjust['unique']) {
$key_name = qsprintf(
$conn,
'UNIQUE KEY %T',
$adjust['name']);
} else {
$key_name = qsprintf(
$conn,
'/* NONUNIQUE */ KEY %T',
$adjust['name']);
}
}
queryfx(
$conn,
'ALTER TABLE %T.%T ADD %Q (%Q)',
$adjust['database'],
$adjust['table'],
$key_name,
implode(', ', $adjust['columns']));
}
break;
default:
throw new Exception(
pht('Unknown schema adjustment kind "%s"!', $adjust['kind']));
}
} catch (AphrontQueryException $ex) {
$failed[] = array($adjust, $ex);
}
$bar->update(1);
}
}
$bar->done();
if (!$failed) {
$console->writeOut(
"%s\n",
pht('Completed applying all schema adjustments.'));
$err = 0;
} else {
$table = id(new PhutilConsoleTable())
->addColumn('target', array('title' => pht('Target')))
->addColumn('error', array('title' => pht('Error')));
foreach ($failed as $failure) {
list($adjust, $ex) = $failure;
$pieces = array_select_keys(
$adjust,
array('database', 'table', 'name'));
$pieces = array_filter($pieces);
$target = implode('.', $pieces);
$table->addRow(
array(
'target' => $target,
'error' => $ex->getMessage(),
));
}
$console->writeOut("\n");
$table->draw();
$console->writeOut(
"\n%s\n",
pht('Failed to make some schema adjustments, detailed above.'));
$console->writeOut(
"%s\n",
pht(
'For help troubleshooting adjustments, see "Managing Storage '.
'Adjustments" in the documentation.'));
$err = 1;
}
return $this->printErrors($errors, $err);
}
private function findAdjustments(
PhabricatorStorageManagementAPI $api) {
list($comp, $expect, $actual) = $this->loadSchemata($api);
$issue_charset = PhabricatorConfigStorageSchema::ISSUE_CHARSET;
$issue_collation = PhabricatorConfigStorageSchema::ISSUE_COLLATION;
$issue_columntype = PhabricatorConfigStorageSchema::ISSUE_COLUMNTYPE;
$issue_surpluskey = PhabricatorConfigStorageSchema::ISSUE_SURPLUSKEY;
$issue_missingkey = PhabricatorConfigStorageSchema::ISSUE_MISSINGKEY;
$issue_columns = PhabricatorConfigStorageSchema::ISSUE_KEYCOLUMNS;
$issue_unique = PhabricatorConfigStorageSchema::ISSUE_UNIQUE;
$issue_longkey = PhabricatorConfigStorageSchema::ISSUE_LONGKEY;
$issue_auto = PhabricatorConfigStorageSchema::ISSUE_AUTOINCREMENT;
$issue_engine = PhabricatorConfigStorageSchema::ISSUE_ENGINE;
$adjustments = array();
$errors = array();
foreach ($comp->getDatabases() as $database_name => $database) {
foreach ($this->findErrors($database) as $issue) {
$errors[] = array(
'database' => $database_name,
'issue' => $issue,
);
}
$expect_database = $expect->getDatabase($database_name);
$actual_database = $actual->getDatabase($database_name);
if (!$expect_database || !$actual_database) {
// If there's a real issue here, skip this stuff.
continue;
}
if ($actual_database->getAccessDenied()) {
// If we can't access the database, we can't access the tables either.
continue;
}
$issues = array();
if ($database->hasIssue($issue_charset)) {
$issues[] = $issue_charset;
}
if ($database->hasIssue($issue_collation)) {
$issues[] = $issue_collation;
}
if ($issues) {
$adjustments[] = array(
'kind' => 'database',
'database' => $database_name,
'issues' => $issues,
'charset' => $expect_database->getCharacterSet(),
'collation' => $expect_database->getCollation(),
);
}
foreach ($database->getTables() as $table_name => $table) {
foreach ($this->findErrors($table) as $issue) {
$errors[] = array(
'database' => $database_name,
'table' => $table_name,
'issue' => $issue,
);
}
$expect_table = $expect_database->getTable($table_name);
$actual_table = $actual_database->getTable($table_name);
if (!$expect_table || !$actual_table) {
continue;
}
$issues = array();
if ($table->hasIssue($issue_collation)) {
$issues[] = $issue_collation;
}
if ($table->hasIssue($issue_engine)) {
$issues[] = $issue_engine;
}
if ($issues) {
$adjustments[] = array(
'kind' => 'table',
'database' => $database_name,
'table' => $table_name,
'issues' => $issues,
'collation' => $expect_table->getCollation(),
'engine' => $expect_table->getEngine(),
);
}
foreach ($table->getColumns() as $column_name => $column) {
foreach ($this->findErrors($column) as $issue) {
$errors[] = array(
'database' => $database_name,
'table' => $table_name,
'name' => $column_name,
'issue' => $issue,
);
}
$expect_column = $expect_table->getColumn($column_name);
$actual_column = $actual_table->getColumn($column_name);
if (!$expect_column || !$actual_column) {
continue;
}
$issues = array();
if ($column->hasIssue($issue_collation)) {
$issues[] = $issue_collation;
}
if ($column->hasIssue($issue_charset)) {
$issues[] = $issue_charset;
}
if ($column->hasIssue($issue_columntype)) {
$issues[] = $issue_columntype;
}
if ($column->hasIssue($issue_auto)) {
$issues[] = $issue_auto;
}
if ($issues) {
if ($expect_column->getCharacterSet() === null) {
// For non-text columns, we won't be specifying a collation or
// character set.
$charset = null;
$collation = null;
} else {
$charset = $expect_column->getCharacterSet();
$collation = $expect_column->getCollation();
}
$adjustment = array(
'kind' => 'column',
'database' => $database_name,
'table' => $table_name,
'name' => $column_name,
'issues' => $issues,
'collation' => $collation,
'charset' => $charset,
'type' => $expect_column->getColumnType(),
// NOTE: We don't adjust column nullability because it is
// dangerous, so always use the current nullability.
'nullable' => $actual_column->getNullable(),
// NOTE: This always stores the current value, because we have
// to make these updates separately.
'is_auto' => $actual_column->getAutoIncrement(),
);
if ($column->hasIssue($issue_auto)) {
$adjustment['auto'] = $expect_column->getAutoIncrement();
}
$adjustments[] = $adjustment;
}
}
foreach ($table->getKeys() as $key_name => $key) {
foreach ($this->findErrors($key) as $issue) {
$errors[] = array(
'database' => $database_name,
'table' => $table_name,
'name' => $key_name,
'issue' => $issue,
);
}
$expect_key = $expect_table->getKey($key_name);
$actual_key = $actual_table->getKey($key_name);
$issues = array();
$keep_key = true;
if ($key->hasIssue($issue_surpluskey)) {
$issues[] = $issue_surpluskey;
$keep_key = false;
}
if ($key->hasIssue($issue_missingkey)) {
$issues[] = $issue_missingkey;
}
if ($key->hasIssue($issue_columns)) {
$issues[] = $issue_columns;
}
if ($key->hasIssue($issue_unique)) {
$issues[] = $issue_unique;
}
// NOTE: We can't really fix this, per se, but we may need to remove
// the key to change the column type. In the best case, the new
// column type won't be overlong and recreating the key really will
// fix the issue. In the worst case, we get the right column type and
// lose the key, which is still better than retaining the key having
// the wrong column type.
if ($key->hasIssue($issue_longkey)) {
$issues[] = $issue_longkey;
}
if ($issues) {
$adjustment = array(
'kind' => 'key',
'database' => $database_name,
'table' => $table_name,
'name' => $key_name,
'issues' => $issues,
'exists' => (bool)$actual_key,
'keep' => $keep_key,
);
if ($keep_key) {
$adjustment += array(
'columns' => $expect_key->getColumnNames(),
'unique' => $expect_key->getUnique(),
'indexType' => $expect_key->getIndexType(),
);
}
$adjustments[] = $adjustment;
}
}
}
}
return array($adjustments, $errors);
}
private function findErrors(PhabricatorConfigStorageSchema $schema) {
$result = array();
foreach ($schema->getLocalIssues() as $issue) {
$status = PhabricatorConfigStorageSchema::getIssueStatus($issue);
if ($status == PhabricatorConfigStorageSchema::STATUS_FAIL) {
$result[] = $issue;
}
}
return $result;
}
private function printErrors(array $errors, $default_return) {
if (!$errors) {
return $default_return;
}
$console = PhutilConsole::getConsole();
$table = id(new PhutilConsoleTable())
->addColumn('target', array('title' => pht('Target')))
->addColumn('error', array('title' => pht('Error')));
$any_surplus = false;
$all_surplus = true;
$any_access = false;
$all_access = true;
foreach ($errors as $error) {
$pieces = array_select_keys(
$error,
array('database', 'table', 'name'));
$pieces = array_filter($pieces);
$target = implode('.', $pieces);
$name = PhabricatorConfigStorageSchema::getIssueName($error['issue']);
$issue = $error['issue'];
if ($issue === PhabricatorConfigStorageSchema::ISSUE_SURPLUS) {
$any_surplus = true;
} else {
$all_surplus = false;
}
if ($issue === PhabricatorConfigStorageSchema::ISSUE_ACCESSDENIED) {
$any_access = true;
} else {
$all_access = false;
}
$table->addRow(
array(
'target' => $target,
'error' => $name,
));
}
$console->writeOut("\n");
$table->draw();
$console->writeOut("\n");
$message = array();
if ($all_surplus) {
$message[] = pht(
'You have surplus schemata (extra tables or columns which Phabricator '.
'does not expect). For information on resolving these '.
'issues, see the "Surplus Schemata" section in the "Managing Storage '.
'Adjustments" article in the documentation.');
} else if ($all_access) {
$message[] = pht(
'The user you are connecting to MySQL with does not have the correct '.
'permissions, and can not access some databases or tables that it '.
'needs to be able to access. GRANT the user additional permissions.');
} else {
$message[] = pht(
'The schemata have errors (detailed above) which the adjustment '.
'workflow can not fix.');
if ($any_access) {
$message[] = pht(
'Some of these errors are caused by access control problems. '.
'The user you are connecting with does not have permission to see '.
'all of the database or tables that Phabricator uses. You need to '.
'GRANT the user more permission, or use a different user.');
}
if ($any_surplus) {
$message[] = pht(
'Some of these errors are caused by surplus schemata (extra '.
'tables or columns which Phabricator does not expect). These are '.
'not serious. For information on resolving these issues, see the '.
'"Surplus Schemata" section in the "Managing Storage Adjustments" '.
'article in the documentation.');
}
$message[] = pht(
'If you are not developing Phabricator itself, report this issue to '.
'the upstream.');
$message[] = pht(
'If you are developing Phabricator, these errors usually indicate '.
'that your schema specifications do not agree with the schemata your '.
'code actually builds.');
}
$message = implode("\n\n", $message);
if ($all_surplus) {
$console->writeOut(
"**<bg:yellow> %s </bg>**\n\n%s\n",
pht('SURPLUS SCHEMATA'),
phutil_console_wrap($message));
} else if ($all_access) {
$console->writeOut(
"**<bg:yellow> %s </bg>**\n\n%s\n",
pht('ACCESS DENIED'),
phutil_console_wrap($message));
} else {
$console->writeOut(
"**<bg:red> %s </bg>**\n\n%s\n",
pht('SCHEMATA ERRORS'),
phutil_console_wrap($message));
}
return 2;
}
final protected function upgradeSchemata(
array $apis,
$apply_only = null,
$no_quickstart = false,
$init_only = false) {
$locks = array();
foreach ($apis as $api) {
$locks[] = $this->lock($api);
}
try {
$this->doUpgradeSchemata($apis, $apply_only, $no_quickstart, $init_only);
} catch (Exception $ex) {
foreach ($locks as $lock) {
$lock->unlock();
}
throw $ex;
}
foreach ($locks as $lock) {
$lock->unlock();
}
}
final private function doUpgradeSchemata(
array $apis,
$apply_only,
$no_quickstart,
$init_only) {
$patches = $this->patches;
$is_dryrun = $this->dryRun;
$api_map = array();
foreach ($apis as $api) {
$api_map[$api->getRef()->getRefKey()] = $api;
}
foreach ($api_map as $ref_key => $api) {
$applied = $api->getAppliedPatches();
$needs_init = ($applied === null);
if (!$needs_init) {
continue;
}
if ($is_dryrun) {
echo tsprintf(
"%s\n",
pht(
'DRYRUN: Storage on host "%s" does not exist yet, so it '.
'would be created.',
$ref_key));
continue;
}
if ($apply_only) {
throw new PhutilArgumentUsageException(
pht(
'Storage on host "%s" has not been initialized yet. You must '.
'initialize storage before selectively applying patches.',
$ref_key));
}
// If we're initializing storage for the first time on any host, track
// it so that we can give the user a nicer experience during the
// subsequent adjustment phase.
$this->didInitialize = true;
$legacy = $api->getLegacyPatches($patches);
if ($legacy || $no_quickstart || $init_only) {
// If we have legacy patches, we can't quickstart.
$api->createDatabase('meta_data');
$api->createTable(
'meta_data',
'patch_status',
array(
'patch VARCHAR(255) NOT NULL PRIMARY KEY COLLATE utf8_general_ci',
'applied INT UNSIGNED NOT NULL',
));
foreach ($legacy as $patch) {
$api->markPatchApplied($patch);
}
} else {
echo tsprintf(
"%s\n",
pht(
'Loading quickstart template onto "%s"...',
$ref_key));
$root = dirname(phutil_get_library_root('phabricator'));
$sql = $root.'/resources/sql/quickstart.sql';
$api->applyPatchSQL($sql);
}
}
if ($init_only) {
echo pht('Storage initialized.')."\n";
return 0;
}
$applied_map = array();
$state_map = array();
foreach ($api_map as $ref_key => $api) {
$applied = $api->getAppliedPatches();
// If we still have nothing applied, this is a dry run and we didn't
// actually initialize storage. Here, just do nothing.
if ($applied === null) {
if ($is_dryrun) {
continue;
} else {
throw new Exception(
pht(
'Database initialization on host "%s" applied no patches!',
$ref_key));
}
}
$applied = array_fuse($applied);
$state_map[$ref_key] = $applied;
if ($apply_only) {
if (isset($applied[$apply_only])) {
if (!$this->force && !$is_dryrun) {
echo phutil_console_wrap(
pht(
'Patch "%s" has already been applied on host "%s". Are you '.
'sure you want to apply it again? This may put your storage '.
'in a state that the upgrade scripts can not automatically '.
'manage.',
$apply_only,
$ref_key));
if (!phutil_console_confirm(pht('Apply patch again?'))) {
echo pht('Cancelled.')."\n";
return 1;
}
}
// Mark this patch as not yet applied on this host.
unset($applied[$apply_only]);
}
}
$applied_map[$ref_key] = $applied;
}
// If we're applying only a specific patch, select just that patch.
if ($apply_only) {
$patches = array_select_keys($patches, array($apply_only));
}
// Apply each patch to each database. We apply patches patch-by-patch,
// not database-by-database: for each patch we apply it to every database,
// then move to the next patch.
// We must do this because ".php" patches may depend on ".sql" patches
// being up to date on all masters, and that will work fine if we put each
// patch on every host before moving on. If we try to bring database hosts
// up to date one at a time we can end up in a big mess.
$duration_map = array();
// First, find any global patches which have been applied to ANY database.
// We are just going to mark these as applied without actually running
// them. Otherwise, adding new empty masters to an existing cluster will
// try to apply them against invalid states.
foreach ($patches as $key => $patch) {
if ($patch->getIsGlobalPatch()) {
foreach ($applied_map as $ref_key => $applied) {
if (isset($applied[$key])) {
$duration_map[$key] = 1;
}
}
}
}
while (true) {
$applied_something = false;
foreach ($patches as $key => $patch) {
// First, check if any databases need this patch. We can just skip it
// if it has already been applied everywhere.
$need_patch = array();
foreach ($applied_map as $ref_key => $applied) {
if (isset($applied[$key])) {
continue;
}
$need_patch[] = $ref_key;
}
if (!$need_patch) {
unset($patches[$key]);
continue;
}
// Check if we can apply this patch yet. Before we can apply a patch,
// all of the dependencies for the patch must have been applied on all
// databases. Requiring that all databases stay in sync prevents one
// database from racing ahead if it happens to get a patch that nothing
// else has yet.
$missing_patch = null;
foreach ($patch->getAfter() as $after) {
foreach ($applied_map as $ref_key => $applied) {
if (isset($applied[$after])) {
// This database already has the patch. We can apply it to
// other databases but don't need to apply it here.
continue;
}
$missing_patch = $after;
break 2;
}
}
if ($missing_patch) {
if ($apply_only) {
echo tsprintf(
"%s\n",
pht(
'Unable to apply patch "%s" because it depends on patch '.
'"%s", which has not been applied on some hosts: %s.',
$apply_only,
$missing_patch,
implode(', ', $need_patch)));
return 1;
} else {
// Some databases are missing the dependencies, so keep trying
// other patches instead. If everything goes right, we'll apply the
// dependencies and then come back and apply this patch later.
continue;
}
}
$is_global = $patch->getIsGlobalPatch();
$patch_apis = array_select_keys($api_map, $need_patch);
foreach ($patch_apis as $ref_key => $api) {
if ($is_global) {
// If this is a global patch which we previously applied, just
// read the duration from the map without actually applying
// the patch.
$duration = idx($duration_map, $key);
} else {
$duration = null;
}
if ($duration === null) {
if ($is_dryrun) {
echo tsprintf(
"%s\n",
pht(
'DRYRUN: Would apply patch "%s" to host "%s".',
$key,
$ref_key));
} else {
echo tsprintf(
"%s\n",
pht(
'Applying patch "%s" to host "%s"...',
$key,
$ref_key));
}
$t_begin = microtime(true);
if (!$is_dryrun) {
$api->applyPatch($patch);
}
$t_end = microtime(true);
$duration = ($t_end - $t_begin);
$duration_map[$key] = $duration;
}
// If we're explicitly reapplying this patch, we don't need to
// mark it as applied.
if (!isset($state_map[$ref_key][$key])) {
if (!$is_dryrun) {
$api->markPatchApplied($key, ($t_end - $t_begin));
}
$applied_map[$ref_key][$key] = true;
}
}
// We applied this everywhere, so we're done with the patch.
unset($patches[$key]);
$applied_something = true;
}
if (!$applied_something) {
if ($patches) {
throw new Exception(
pht(
'Some patches could not be applied: %s',
implode(', ', array_keys($patches))));
} else if (!$is_dryrun && !$apply_only) {
echo pht(
'Storage is up to date. Use "%s" for details.',
'storage status')."\n";
}
break;
}
}
}
final protected function getBareHostAndPort($host) {
// Split out port information, since the command-line client requires a
// separate flag for the port.
$uri = new PhutilURI('mysql://'.$host);
if ($uri->getPort()) {
$port = $uri->getPort();
$bare_hostname = $uri->getDomain();
} else {
$port = null;
$bare_hostname = $host;
}
return array($bare_hostname, $port);
}
/**
* Acquires a @{class:PhabricatorGlobalLock}.
*
* @return PhabricatorGlobalLock
*/
final protected function lock(PhabricatorStorageManagementAPI $api) {
// Although we're holding this lock on different databases so it could
// have the same name on each as far as the database is concerned, the
// locks would be the same within this process.
$ref_key = $api->getRef()->getRefKey();
$ref_hash = PhabricatorHash::digestForIndex($ref_key);
$lock_name = 'adjust('.$ref_hash.')';
return PhabricatorGlobalLock::newLock($lock_name)
->useSpecificConnection($api->getConn(null))
->lock();
}
final protected function analyzeTables(
PhabricatorStorageManagementAPI $api) {
// Analyzing tables can sometimes have a significant effect on query
// performance, particularly for the fulltext ngrams tables. See T12819
// for some specific examples.
$conn = $api->getConn(null);
$patches = $this->getPatches();
$databases = $api->getDatabaseList($patches, true);
$this->logInfo(
pht('ANALYZE'),
pht('Analyzing tables...'));
$targets = array();
foreach ($databases as $database) {
queryfx($conn, 'USE %C', $database);
$tables = queryfx_all($conn, 'SHOW TABLE STATUS');
foreach ($tables as $table) {
$table_name = $table['Name'];
$targets[] = array(
'database' => $database,
'table' => $table_name,
);
}
}
$bar = id(new PhutilConsoleProgressBar())
->setTotal(count($targets));
foreach ($targets as $target) {
queryfx(
$conn,
'ANALYZE TABLE %T.%T',
$target['database'],
$target['table']);
$bar->update(1);
}
$bar->done();
$this->logOkay(
pht('ANALYZED'),
pht(
'Analyzed %d table(s).',
count($targets)));
}
}
diff --git a/src/infrastructure/util/PhabricatorHash.php b/src/infrastructure/util/PhabricatorHash.php
index 19c8414539..5ca4e9b53c 100644
--- a/src/infrastructure/util/PhabricatorHash.php
+++ b/src/infrastructure/util/PhabricatorHash.php
@@ -1,230 +1,230 @@
<?php
final class PhabricatorHash extends Phobject {
const INDEX_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 Input string.
- * @return string 32-byte hexidecimal SHA1+HMAC hash.
+ * @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 Phabricator configuration!",
'security.hmac-key'));
}
return hash_hmac('sha1', $string, $key);
}
/**
* Digest a string into a password hash. This is similar to @{method:digest},
* but requires a salt and iterates the hash to increase cost.
*/
public static function digestPassword(PhutilOpaqueEnvelope $envelope, $salt) {
$result = $envelope->openEnvelope();
if (!$result) {
throw new Exception(pht('Trying to digest empty password!'));
}
for ($ii = 0; $ii < 1000; $ii++) {
$result = self::weakDigest($result, $salt);
}
return $result;
}
/**
* 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 Input string.
* @return string 12-byte, case-sensitive alphanumeric hash of the string
* which
*/
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;
}
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 The string to shorten.
* @param int 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 (!strlen($key)) {
throw new Exception(
pht('HMAC-SHA256 requires a nonempty key.'));
}
$result = hash_hmac('sha256', $message, $key, $raw_output = false);
if ($result === false) {
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 (!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/infrastructure/util/password/PhabricatorPasswordHasher.php b/src/infrastructure/util/password/PhabricatorPasswordHasher.php
index 7972acc0a8..f0f30045c0 100644
--- a/src/infrastructure/util/password/PhabricatorPasswordHasher.php
+++ b/src/infrastructure/util/password/PhabricatorPasswordHasher.php
@@ -1,420 +1,420 @@
<?php
/**
* Provides a mechanism for hashing passwords, like "iterated md5", "bcrypt",
* "scrypt", etc.
*
* Hashers define suitability and strength, and the system automatically
* chooses the strongest available hasher and can prompt users to upgrade as
* soon as a stronger hasher is available.
*
* @task hasher Implementing a Hasher
* @task hashing Using Hashers
*/
abstract class PhabricatorPasswordHasher extends Phobject {
const MAXIMUM_STORAGE_SIZE = 128;
/* -( Implementing a Hasher )---------------------------------------------- */
/**
* Return a human-readable description of this hasher, like "Iterated MD5".
*
* @return string Human readable hash name.
* @task hasher
*/
abstract public function getHumanReadableName();
/**
* Return a short, unique, key identifying this hasher, like "md5" or
* "bcrypt". This identifier should not be translated.
*
* @return string Short, unique hash name.
* @task hasher
*/
abstract public function getHashName();
/**
* Return the maximum byte length of hashes produced by this hasher. This is
* used to prevent storage overflows.
*
* @return int Maximum number of bytes in hashes this class produces.
* @task hasher
*/
abstract public function getHashLength();
/**
* Return `true` to indicate that any required extensions or dependencies
* are available, and this hasher is able to perform hashing.
*
* @return bool True if this hasher can execute.
* @task hasher
*/
abstract public function canHashPasswords();
/**
* Return a human-readable string describing why this hasher is unable
* to operate. For example, "To use bcrypt, upgrade to PHP 5.5.0 or newer.".
*
* @return string Human-readable description of how to enable this hasher.
* @task hasher
*/
abstract public function getInstallInstructions();
/**
* Return an indicator of this hasher's strength. When choosing to hash
- * new passwords, the strongest available hasher which is usuable for new
+ * new passwords, the strongest available hasher which is usable for new
* passwords will be used, and the presence of a stronger hasher will
* prompt users to update their hashes.
*
* Generally, this method should return a larger number than hashers it is
* preferable to, but a smaller number than hashers which are better than it
* is. This number does not need to correspond directly with the actual hash
* strength.
*
* @return float Strength of this hasher.
* @task hasher
*/
abstract public function getStrength();
/**
* Return a short human-readable indicator of this hasher's strength, like
* "Weak", "Okay", or "Good".
*
* This is only used to help administrators make decisions about
* configuration.
*
* @return string Short human-readable description of hash strength.
* @task hasher
*/
abstract public function getHumanReadableStrength();
/**
* Produce a password hash.
*
* @param PhutilOpaqueEnvelope Text to be hashed.
* @return PhutilOpaqueEnvelope Hashed text.
* @task hasher
*/
abstract protected function getPasswordHash(PhutilOpaqueEnvelope $envelope);
/**
* Verify that a password matches a hash.
*
* The default implementation checks for equality; if a hasher embeds salt in
* hashes it should override this method and perform a salt-aware comparison.
*
* @param PhutilOpaqueEnvelope Password to compare.
* @param PhutilOpaqueEnvelope Bare password hash.
* @return bool True if the passwords match.
* @task hasher
*/
protected function verifyPassword(
PhutilOpaqueEnvelope $password,
PhutilOpaqueEnvelope $hash) {
$actual_hash = $this->getPasswordHash($password)->openEnvelope();
$expect_hash = $hash->openEnvelope();
return phutil_hashes_are_identical($actual_hash, $expect_hash);
}
/**
* Check if an existing hash created by this algorithm is upgradeable.
*
* The default implementation returns `false`. However, hash algorithms which
* have (for example) an internal cost function may be able to upgrade an
* existing hash to a stronger one with a higher cost.
*
* @param PhutilOpaqueEnvelope Bare hash.
* @return bool True if the hash can be upgraded without
* changing the algorithm (for example, to a
* higher cost).
* @task hasher
*/
protected function canUpgradeInternalHash(PhutilOpaqueEnvelope $hash) {
return false;
}
/* -( Using Hashers )------------------------------------------------------ */
/**
* Get the hash of a password for storage.
*
* @param PhutilOpaqueEnvelope Password text.
* @return PhutilOpaqueEnvelope Hashed text.
* @task hashing
*/
final public function getPasswordHashForStorage(
PhutilOpaqueEnvelope $envelope) {
$name = $this->getHashName();
$hash = $this->getPasswordHash($envelope);
$actual_len = strlen($hash->openEnvelope());
$expect_len = $this->getHashLength();
if ($actual_len > $expect_len) {
throw new Exception(
pht(
"Password hash '%s' produced a hash of length %d, but a ".
"maximum length of %d was expected.",
$name,
new PhutilNumber($actual_len),
new PhutilNumber($expect_len)));
}
return new PhutilOpaqueEnvelope($name.':'.$hash->openEnvelope());
}
/**
* Parse a storage hash into its components, like the hash type and hash
* data.
*
* @return map Dictionary of information about the hash.
* @task hashing
*/
private static function parseHashFromStorage(PhutilOpaqueEnvelope $hash) {
$raw_hash = $hash->openEnvelope();
if (strpos($raw_hash, ':') === false) {
throw new Exception(
pht(
'Malformed password hash, expected "name:hash".'));
}
list($name, $hash) = explode(':', $raw_hash);
return array(
'name' => $name,
'hash' => new PhutilOpaqueEnvelope($hash),
);
}
/**
* Get all available password hashers. This may include hashers which can not
* actually be used (for example, a required extension is missing).
*
- * @return list<PhabicatorPasswordHasher> Hasher objects.
+ * @return list<PhabricatorPasswordHasher> Hasher objects.
* @task hashing
*/
public static function getAllHashers() {
$objects = id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getHashName')
->execute();
foreach ($objects as $object) {
$name = $object->getHashName();
$potential_length = strlen($name) + $object->getHashLength() + 1;
$maximum_length = self::MAXIMUM_STORAGE_SIZE;
if ($potential_length > $maximum_length) {
throw new Exception(
pht(
'Hasher "%s" may produce hashes which are too long to fit in '.
'storage. %d characters are available, but its hashes may be '.
'up to %d characters in length.',
$name,
$maximum_length,
$potential_length));
}
}
return $objects;
}
/**
* Get all usable password hashers. This may include hashers which are
* not desirable or advisable.
*
- * @return list<PhabicatorPasswordHasher> Hasher objects.
+ * @return list<PhabricatorPasswordHasher> Hasher objects.
* @task hashing
*/
public static function getAllUsableHashers() {
$hashers = self::getAllHashers();
foreach ($hashers as $key => $hasher) {
if (!$hasher->canHashPasswords()) {
unset($hashers[$key]);
}
}
return $hashers;
}
/**
* Get the best (strongest) available hasher.
*
* @return PhabricatorPasswordHasher Best hasher.
* @task hashing
*/
public static function getBestHasher() {
$hashers = self::getAllUsableHashers();
$hashers = msort($hashers, 'getStrength');
$hasher = last($hashers);
if (!$hasher) {
throw new PhabricatorPasswordHasherUnavailableException(
pht(
'There are no password hashers available which are usable for '.
'new passwords.'));
}
return $hasher;
}
/**
- * Get the hashser for a given stored hash.
+ * Get the hasher for a given stored hash.
*
* @return PhabricatorPasswordHasher Corresponding hasher.
* @task hashing
*/
public static function getHasherForHash(PhutilOpaqueEnvelope $hash) {
$info = self::parseHashFromStorage($hash);
$name = $info['name'];
$usable = self::getAllUsableHashers();
if (isset($usable[$name])) {
return $usable[$name];
}
$all = self::getAllHashers();
if (isset($all[$name])) {
throw new PhabricatorPasswordHasherUnavailableException(
pht(
'Attempting to compare a password saved with the "%s" hash. The '.
'hasher exists, but is not currently usable. %s',
$name,
$all[$name]->getInstallInstructions()));
}
throw new PhabricatorPasswordHasherUnavailableException(
pht(
'Attempting to compare a password saved with the "%s" hash. No such '.
'hasher is known to Phabricator.',
$name));
}
/**
* Test if a password is using an weaker hash than the strongest available
* hash. This can be used to prompt users to upgrade, or automatically upgrade
* on login.
*
* @return bool True to indicate that rehashing this password will improve
* the hash strength.
* @task hashing
*/
public static function canUpgradeHash(PhutilOpaqueEnvelope $hash) {
if (!strlen($hash->openEnvelope())) {
throw new Exception(
pht('Expected a password hash, received nothing!'));
}
$current_hasher = self::getHasherForHash($hash);
$best_hasher = self::getBestHasher();
if ($current_hasher->getHashName() != $best_hasher->getHashName()) {
// If the algorithm isn't the best one, we can upgrade.
return true;
}
$info = self::parseHashFromStorage($hash);
if ($current_hasher->canUpgradeInternalHash($info['hash'])) {
// If the algorithm provides an internal upgrade, we can also upgrade.
return true;
}
// Already on the best algorithm with the best settings.
return false;
}
/**
* Generate a new hash for a password, using the best available hasher.
*
* @param PhutilOpaqueEnvelope Password to hash.
* @return PhutilOpaqueEnvelope Hashed password, using best available
* hasher.
* @task hashing
*/
public static function generateNewPasswordHash(
PhutilOpaqueEnvelope $password) {
$hasher = self::getBestHasher();
return $hasher->getPasswordHashForStorage($password);
}
/**
* Compare a password to a stored hash.
*
* @param PhutilOpaqueEnvelope Password to compare.
* @param PhutilOpaqueEnvelope Stored password hash.
* @return bool True if the passwords match.
* @task hashing
*/
public static function comparePassword(
PhutilOpaqueEnvelope $password,
PhutilOpaqueEnvelope $hash) {
$hasher = self::getHasherForHash($hash);
$parts = self::parseHashFromStorage($hash);
return $hasher->verifyPassword($password, $parts['hash']);
}
/**
* Get the human-readable algorithm name for a given hash.
*
* @param PhutilOpaqueEnvelope Storage hash.
* @return string Human-readable algorithm name.
*/
public static function getCurrentAlgorithmName(PhutilOpaqueEnvelope $hash) {
$raw_hash = $hash->openEnvelope();
if (!strlen($raw_hash)) {
return pht('None');
}
try {
$current_hasher = self::getHasherForHash($hash);
return $current_hasher->getHumanReadableName();
} catch (Exception $ex) {
$info = self::parseHashFromStorage($hash);
$name = $info['name'];
return pht('Unknown ("%s")', $name);
}
}
/**
* Get the human-readable algorithm name for the best available hash.
*
* @return string Human-readable name for best hash.
*/
public static function getBestAlgorithmName() {
try {
$best_hasher = self::getBestHasher();
return $best_hasher->getHumanReadableName();
} catch (Exception $ex) {
return pht('Unknown');
}
}
}
diff --git a/src/view/AphrontView.php b/src/view/AphrontView.php
index c014d7d50c..669742f29b 100644
--- a/src/view/AphrontView.php
+++ b/src/view/AphrontView.php
@@ -1,225 +1,225 @@
<?php
/**
* @task children Managing Children
*/
abstract class AphrontView extends Phobject
implements PhutilSafeHTMLProducerInterface {
private $viewer;
protected $children = array();
/* -( Configuration )------------------------------------------------------ */
/**
* Set the user viewing this element.
*
* @param PhabricatorUser Viewing user.
* @return this
*/
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
/**
* Get the user viewing this element.
*
* Throws an exception if no viewer has been set.
*
* @return PhabricatorUser Viewing user.
*/
public function getViewer() {
if (!$this->viewer) {
throw new PhutilInvalidStateException('setViewer');
}
return $this->viewer;
}
/**
- * Test if a viewer has been set on this elmeent.
+ * Test if a viewer has been set on this element.
*
* @return bool True if a viewer is available.
*/
public function hasViewer() {
return (bool)$this->viewer;
}
/**
* Deprecated, use @{method:setViewer}.
*
* @task config
* @deprecated
*/
public function setUser(PhabricatorUser $user) {
return $this->setViewer($user);
}
/**
* Deprecated, use @{method:getViewer}.
*
* @task config
* @deprecated
*/
protected function getUser() {
if (!$this->hasViewer()) {
return null;
}
return $this->getViewer();
}
/* -( Managing Children )-------------------------------------------------- */
/**
* Test if this View accepts children.
*
* By default, views accept children, but subclases may override this method
* to prevent children from being appended. Doing so will cause
* @{method:appendChild} to throw exceptions instead of appending children.
*
* @return bool True if the View should accept children.
* @task children
*/
protected function canAppendChild() {
return true;
}
/**
* Append a child to the list of children.
*
* This method will only work if the view supports children, which is
* determined by @{method:canAppendChild}.
*
* @param wild Something renderable.
* @return this
*/
final public function appendChild($child) {
if (!$this->canAppendChild()) {
$class = get_class($this);
throw new Exception(
pht("View '%s' does not support children.", $class));
}
$this->children[] = $child;
return $this;
}
/**
* Produce children for rendering.
*
* Historically, this method reduced children to a string representation,
* but it no longer does.
*
* @return wild Renderable children.
* @task
*/
final protected function renderChildren() {
return $this->children;
}
/**
* Test if an element has no children.
*
* @return bool True if this element has children.
* @task children
*/
final public function hasChildren() {
if ($this->children) {
$this->children = $this->reduceChildren($this->children);
}
return (bool)$this->children;
}
/**
* Reduce effectively-empty lists of children to be actually empty. This
* recursively removes `null`, `''`, and `array()` from the list of children
* so that @{method:hasChildren} can more effectively align with expectations.
*
* NOTE: Because View children are not rendered, a View which renders down
* to nothing will not be reduced by this method.
*
* @param list<wild> Renderable children.
* @return list<wild> Reduced list of children.
* @task children
*/
private function reduceChildren(array $children) {
foreach ($children as $key => $child) {
if ($child === null) {
unset($children[$key]);
} else if ($child === '') {
unset($children[$key]);
} else if (is_array($child)) {
$child = $this->reduceChildren($child);
if ($child) {
$children[$key] = $child;
} else {
unset($children[$key]);
}
}
}
return $children;
}
public function getDefaultResourceSource() {
return 'phabricator';
}
public function requireResource($symbol) {
$response = CelerityAPI::getStaticResourceResponse();
$response->requireResource($symbol, $this->getDefaultResourceSource());
return $this;
}
public function initBehavior($name, $config = array()) {
Javelin::initBehavior(
$name,
$config,
$this->getDefaultResourceSource());
return $this;
}
/* -( Rendering )---------------------------------------------------------- */
/**
* Inconsistent, unreliable pre-rendering hook.
*
* This hook //may// fire before views render. It is not fired reliably, and
* may fire multiple times.
*
* If it does fire, views might use it to register data for later loads, but
* almost no datasources support this now; this is currently only useful for
* tokenizers. This mechanism might eventually see wider support or might be
* removed.
*/
public function willRender() {
return;
}
abstract public function render();
/* -( PhutilSafeHTMLProducerInterface )------------------------------------ */
public function producePhutilSafeHTML() {
return $this->render();
}
}
diff --git a/src/view/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php
index d26cca945f..78ff716a44 100644
--- a/src/view/page/PhabricatorStandardPageView.php
+++ b/src/view/page/PhabricatorStandardPageView.php
@@ -1,914 +1,914 @@
<?php
/**
* This is a standard Phabricator page with menus, Javelin, DarkConsole, and
* basic styles.
*/
final class PhabricatorStandardPageView extends PhabricatorBarePageView
implements AphrontResponseProducerInterface {
private $baseURI;
private $applicationName;
private $glyph;
private $menuContent;
private $showChrome = true;
private $classes = array();
private $disableConsole;
private $pageObjects = array();
private $applicationMenu;
private $showFooter = true;
private $showDurableColumn = true;
private $quicksandConfig = array();
private $tabs;
private $crumbs;
private $navigation;
public function setShowFooter($show_footer) {
$this->showFooter = $show_footer;
return $this;
}
public function getShowFooter() {
return $this->showFooter;
}
public function setApplicationMenu($application_menu) {
// NOTE: For now, this can either be a PHUIListView or a
// PHUIApplicationMenuView.
$this->applicationMenu = $application_menu;
return $this;
}
public function getApplicationMenu() {
return $this->applicationMenu;
}
public function setApplicationName($application_name) {
$this->applicationName = $application_name;
return $this;
}
public function setDisableConsole($disable) {
$this->disableConsole = $disable;
return $this;
}
public function getApplicationName() {
return $this->applicationName;
}
public function setBaseURI($base_uri) {
$this->baseURI = $base_uri;
return $this;
}
public function getBaseURI() {
return $this->baseURI;
}
public function setShowChrome($show_chrome) {
$this->showChrome = $show_chrome;
return $this;
}
public function getShowChrome() {
return $this->showChrome;
}
public function addClass($class) {
$this->classes[] = $class;
return $this;
}
public function setPageObjectPHIDs(array $phids) {
$this->pageObjects = $phids;
return $this;
}
public function setShowDurableColumn($show) {
$this->showDurableColumn = $show;
return $this;
}
public function getShowDurableColumn() {
$request = $this->getRequest();
if (!$request) {
return false;
}
$viewer = $request->getUser();
if (!$viewer->isLoggedIn()) {
return false;
}
$conpherence_installed = PhabricatorApplication::isClassInstalledForViewer(
'PhabricatorConpherenceApplication',
$viewer);
if (!$conpherence_installed) {
return false;
}
if ($this->isQuicksandBlacklistURI()) {
return false;
}
return true;
}
private function isQuicksandBlacklistURI() {
$request = $this->getRequest();
if (!$request) {
return false;
}
$patterns = $this->getQuicksandURIPatternBlacklist();
$path = $request->getRequestURI()->getPath();
foreach ($patterns as $pattern) {
if (preg_match('(^'.$pattern.'$)', $path)) {
return true;
}
}
return false;
}
public function getDurableColumnVisible() {
$column_key = PhabricatorConpherenceColumnVisibleSetting::SETTINGKEY;
return (bool)$this->getUserPreference($column_key, false);
}
public function getDurableColumnMinimize() {
$column_key = PhabricatorConpherenceColumnMinimizeSetting::SETTINGKEY;
return (bool)$this->getUserPreference($column_key, false);
}
public function addQuicksandConfig(array $config) {
$this->quicksandConfig = $config + $this->quicksandConfig;
return $this;
}
public function getQuicksandConfig() {
return $this->quicksandConfig;
}
public function setCrumbs(PHUICrumbsView $crumbs) {
$this->crumbs = $crumbs;
return $this;
}
public function getCrumbs() {
return $this->crumbs;
}
public function setTabs(PHUIListView $tabs) {
$tabs->setType(PHUIListView::TABBAR_LIST);
$tabs->addClass('phabricator-standard-page-tabs');
$this->tabs = $tabs;
return $this;
}
public function getTabs() {
return $this->tabs;
}
public function setNavigation(AphrontSideNavFilterView $navigation) {
$this->navigation = $navigation;
return $this;
}
public function getNavigation() {
return $this->navigation;
}
public function getTitle() {
$glyph_key = PhabricatorTitleGlyphsSetting::SETTINGKEY;
$glyph_on = PhabricatorTitleGlyphsSetting::VALUE_TITLE_GLYPHS;
$glyph_setting = $this->getUserPreference($glyph_key, $glyph_on);
$use_glyph = ($glyph_setting == $glyph_on);
$title = parent::getTitle();
$prefix = null;
if ($use_glyph) {
$prefix = $this->getGlyph();
} else {
$application_name = $this->getApplicationName();
if (strlen($application_name)) {
$prefix = '['.$application_name.']';
}
}
if (strlen($prefix)) {
$title = $prefix.' '.$title;
}
return $title;
}
protected function willRenderPage() {
parent::willRenderPage();
if (!$this->getRequest()) {
throw new Exception(
pht(
'You must set the %s to render a %s.',
'Request',
__CLASS__));
}
$console = $this->getConsole();
require_celerity_resource('phabricator-core-css');
require_celerity_resource('phabricator-zindex-css');
require_celerity_resource('phui-button-css');
require_celerity_resource('phui-spacing-css');
require_celerity_resource('phui-form-css');
require_celerity_resource('phabricator-standard-page-view');
require_celerity_resource('conpherence-durable-column-view');
require_celerity_resource('font-lato');
Javelin::initBehavior('workflow', array());
$request = $this->getRequest();
$user = null;
if ($request) {
$user = $request->getUser();
}
if ($user) {
if ($user->isUserActivated()) {
$offset = $user->getTimeZoneOffset();
$ignore_key = PhabricatorTimezoneIgnoreOffsetSetting::SETTINGKEY;
$ignore = $user->getUserSetting($ignore_key);
Javelin::initBehavior(
'detect-timezone',
array(
'offset' => $offset,
'uri' => '/settings/timezone/',
'message' => pht(
'Your browser timezone setting differs from the timezone '.
'setting in your profile, click to reconcile.'),
'ignoreKey' => $ignore_key,
'ignore' => $ignore,
));
if ($user->getIsAdmin()) {
$server_https = $request->isHTTPS();
$server_protocol = $server_https ? 'HTTPS' : 'HTTP';
$client_protocol = $server_https ? 'HTTP' : 'HTTPS';
$doc_name = 'Configuring a Preamble Script';
$doc_href = PhabricatorEnv::getDoclink($doc_name);
Javelin::initBehavior(
'setup-check-https',
array(
'server_https' => $server_https,
'doc_name' => pht('See Documentation'),
'doc_href' => $doc_href,
'message' => pht(
'Phabricator thinks you are using %s, but your '.
- 'client is conviced that it is using %s. This is a serious '.
+ 'client is convinced that it is using %s. This is a serious '.
'misconfiguration with subtle, but significant, consequences.',
$server_protocol, $client_protocol),
));
}
}
$icon = id(new PHUIIconView())
->setIcon('fa-download')
->addClass('phui-icon-circle-icon');
$lightbox_id = celerity_generate_unique_node_id();
$download_form = phabricator_form(
$user,
array(
'action' => '#',
'method' => 'POST',
'class' => 'lightbox-download-form',
'sigil' => 'download lightbox-download-submit',
'id' => 'lightbox-download-form',
),
phutil_tag(
'a',
array(
'class' => 'lightbox-download phui-icon-circle hover-green',
'href' => '#',
),
array(
$icon,
)));
Javelin::initBehavior(
'lightbox-attachments',
array(
'lightbox_id' => $lightbox_id,
'downloadForm' => $download_form,
));
}
Javelin::initBehavior('aphront-form-disable-on-submit');
Javelin::initBehavior('toggle-class', array());
Javelin::initBehavior('history-install');
Javelin::initBehavior('phabricator-gesture');
$current_token = null;
if ($user) {
$current_token = $user->getCSRFToken();
}
Javelin::initBehavior(
'refresh-csrf',
array(
'tokenName' => AphrontRequest::getCSRFTokenName(),
'header' => AphrontRequest::getCSRFHeaderName(),
'viaHeader' => AphrontRequest::getViaHeaderName(),
'current' => $current_token,
));
Javelin::initBehavior('device');
Javelin::initBehavior(
'high-security-warning',
$this->getHighSecurityWarningConfig());
if (PhabricatorEnv::isReadOnly()) {
Javelin::initBehavior(
'read-only-warning',
array(
'message' => PhabricatorEnv::getReadOnlyMessage(),
'uri' => PhabricatorEnv::getReadOnlyURI(),
));
}
if ($console) {
require_celerity_resource('aphront-dark-console-css');
$headers = array();
if (DarkConsoleXHProfPluginAPI::isProfilerStarted()) {
$headers[DarkConsoleXHProfPluginAPI::getProfilerHeader()] = 'page';
}
if (DarkConsoleServicesPlugin::isQueryAnalyzerRequested()) {
$headers[DarkConsoleServicesPlugin::getQueryAnalyzerHeader()] = true;
}
Javelin::initBehavior(
'dark-console',
$this->getConsoleConfig());
// Change this to initBehavior when there is some behavior to initialize
require_celerity_resource('javelin-behavior-error-log');
}
if ($user) {
$viewer = $user;
} else {
$viewer = new PhabricatorUser();
}
$menu = id(new PhabricatorMainMenuView())
->setUser($viewer);
if ($this->getController()) {
$menu->setController($this->getController());
}
$application_menu = $this->getApplicationMenu();
if ($application_menu) {
if ($application_menu instanceof PHUIApplicationMenuView) {
$crumbs = $this->getCrumbs();
if ($crumbs) {
$application_menu->setCrumbs($crumbs);
}
$application_menu = $application_menu->buildListView();
}
$menu->setApplicationMenu($application_menu);
}
$this->menuContent = $menu->render();
}
protected function getHead() {
$monospaced = null;
$request = $this->getRequest();
if ($request) {
$user = $request->getUser();
if ($user) {
$monospaced = $user->getUserSetting(
PhabricatorMonospacedFontSetting::SETTINGKEY);
}
}
$response = CelerityAPI::getStaticResourceResponse();
$font_css = null;
if (!empty($monospaced)) {
// We can't print this normally because escaping quotation marks will
// break the CSS. Instead, filter it strictly and then mark it as safe.
$monospaced = new PhutilSafeHTML(
PhabricatorMonospacedFontSetting::filterMonospacedCSSRule(
$monospaced));
$font_css = hsprintf(
'<style type="text/css">'.
'.PhabricatorMonospaced, '.
'.phabricator-remarkup .remarkup-code-block '.
'.remarkup-code { font: %s !important; } '.
'</style>',
$monospaced);
}
return hsprintf(
'%s%s%s',
parent::getHead(),
$font_css,
$response->renderSingleResource('javelin-magical-init', 'phabricator'));
}
public function setGlyph($glyph) {
$this->glyph = $glyph;
return $this;
}
public function getGlyph() {
return $this->glyph;
}
protected function willSendResponse($response) {
$request = $this->getRequest();
$response = parent::willSendResponse($response);
$console = $request->getApplicationConfiguration()->getConsole();
if ($console) {
$response = PhutilSafeHTML::applyFunction(
'str_replace',
hsprintf('<darkconsole />'),
$console->render($request),
$response);
}
return $response;
}
protected function getBody() {
$user = null;
$request = $this->getRequest();
if ($request) {
$user = $request->getUser();
}
$header_chrome = null;
if ($this->getShowChrome()) {
$header_chrome = $this->menuContent;
}
$classes = array();
$classes[] = 'main-page-frame';
$developer_warning = null;
if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode') &&
DarkConsoleErrorLogPluginAPI::getErrors()) {
$developer_warning = phutil_tag_div(
'aphront-developer-error-callout',
pht(
'This page raised PHP errors. Find them in DarkConsole '.
'or the error log.'));
}
$main_page = phutil_tag(
'div',
array(
'id' => 'phabricator-standard-page',
'class' => 'phabricator-standard-page',
),
array(
$developer_warning,
$header_chrome,
phutil_tag(
'div',
array(
'id' => 'phabricator-standard-page-body',
'class' => 'phabricator-standard-page-body',
),
$this->renderPageBodyContent()),
));
$durable_column = null;
if ($this->getShowDurableColumn()) {
$is_visible = $this->getDurableColumnVisible();
$is_minimize = $this->getDurableColumnMinimize();
$durable_column = id(new ConpherenceDurableColumnView())
->setSelectedConpherence(null)
->setUser($user)
->setQuicksandConfig($this->buildQuicksandConfig())
->setVisible($is_visible)
->setMinimize($is_minimize)
->setInitialLoad(true);
if ($is_minimize) {
$this->classes[] = 'minimize-column';
}
}
Javelin::initBehavior('quicksand-blacklist', array(
'patterns' => $this->getQuicksandURIPatternBlacklist(),
));
return phutil_tag(
'div',
array(
'class' => implode(' ', $classes),
'id' => 'main-page-frame',
),
array(
$main_page,
$durable_column,
));
}
private function renderPageBodyContent() {
$console = $this->getConsole();
$body = parent::getBody();
$footer = $this->renderFooter();
$nav = $this->getNavigation();
$tabs = $this->getTabs();
if ($nav) {
$crumbs = $this->getCrumbs();
if ($crumbs) {
$nav->setCrumbs($crumbs);
}
$nav->appendChild($body);
$nav->appendFooter($footer);
$content = phutil_implode_html('', array($nav->render()));
} else {
$content = array();
$crumbs = $this->getCrumbs();
if ($crumbs) {
if ($this->getTabs()) {
$crumbs->setBorder(true);
}
$content[] = $crumbs;
}
$tabs = $this->getTabs();
if ($tabs) {
$content[] = $tabs;
}
$content[] = $body;
$content[] = $footer;
$content = phutil_implode_html('', $content);
}
return array(
($console ? hsprintf('<darkconsole />') : null),
$content,
);
}
protected function getTail() {
$request = $this->getRequest();
$user = $request->getUser();
$tail = array(
parent::getTail(),
);
$response = CelerityAPI::getStaticResourceResponse();
if ($request->isHTTPS()) {
$with_protocol = 'https';
} else {
$with_protocol = 'http';
}
$servers = PhabricatorNotificationServerRef::getEnabledClientServers(
$with_protocol);
if ($servers) {
if ($user && $user->isLoggedIn()) {
// TODO: We could tell the browser about all the servers and let it
// do random reconnects to improve reliability.
shuffle($servers);
$server = head($servers);
$client_uri = $server->getWebsocketURI();
Javelin::initBehavior(
'aphlict-listen',
array(
'websocketURI' => (string)$client_uri,
) + $this->buildAphlictListenConfigData());
}
}
$tail[] = $response->renderHTMLFooter();
return $tail;
}
protected function getBodyClasses() {
$classes = array();
if (!$this->getShowChrome()) {
$classes[] = 'phabricator-chromeless-page';
}
$agent = AphrontRequest::getHTTPHeader('User-Agent');
// Try to guess the device resolution based on UA strings to avoid a flash
// of incorrectly-styled content.
$device_guess = 'device-desktop';
if (preg_match('@iPhone|iPod|(Android.*Chrome/[.0-9]* Mobile)@', $agent)) {
$device_guess = 'device-phone device';
} else if (preg_match('@iPad|(Android.*Chrome/)@', $agent)) {
$device_guess = 'device-tablet device';
}
$classes[] = $device_guess;
if (preg_match('@Windows@', $agent)) {
$classes[] = 'platform-windows';
} else if (preg_match('@Macintosh@', $agent)) {
$classes[] = 'platform-mac';
} else if (preg_match('@X11@', $agent)) {
$classes[] = 'platform-linux';
}
if ($this->getRequest()->getStr('__print__')) {
$classes[] = 'printable';
}
if ($this->getRequest()->getStr('__aural__')) {
$classes[] = 'audible';
}
$classes[] = 'phui-theme-'.PhabricatorEnv::getEnvConfig('ui.header-color');
foreach ($this->classes as $class) {
$classes[] = $class;
}
return implode(' ', $classes);
}
private function getConsole() {
if ($this->disableConsole) {
return null;
}
return $this->getRequest()->getApplicationConfiguration()->getConsole();
}
private function getConsoleConfig() {
$user = $this->getRequest()->getUser();
$headers = array();
if (DarkConsoleXHProfPluginAPI::isProfilerStarted()) {
$headers[DarkConsoleXHProfPluginAPI::getProfilerHeader()] = 'page';
}
if (DarkConsoleServicesPlugin::isQueryAnalyzerRequested()) {
$headers[DarkConsoleServicesPlugin::getQueryAnalyzerHeader()] = true;
}
if ($user) {
$setting_tab = PhabricatorDarkConsoleTabSetting::SETTINGKEY;
$setting_visible = PhabricatorDarkConsoleVisibleSetting::SETTINGKEY;
$tab = $user->getUserSetting($setting_tab);
$visible = $user->getUserSetting($setting_visible);
} else {
$tab = null;
$visible = true;
}
return array(
// NOTE: We use a generic label here to prevent input reflection
// and mitigate compression attacks like BREACH. See discussion in
// T3684.
'uri' => pht('Main Request'),
'selected' => $tab,
'visible' => $visible,
'headers' => $headers,
);
}
private function getHighSecurityWarningConfig() {
$user = $this->getRequest()->getUser();
$show = false;
if ($user->hasSession()) {
$hisec = ($user->getSession()->getHighSecurityUntil() - time());
if ($hisec > 0) {
$show = true;
}
}
return array(
'show' => $show,
'uri' => '/auth/session/downgrade/',
'message' => pht(
'Your session is in high security mode. When you '.
'finish using it, click here to leave.'),
);
}
private function renderFooter() {
if (!$this->getShowChrome()) {
return null;
}
if (!$this->getShowFooter()) {
return null;
}
$items = PhabricatorEnv::getEnvConfig('ui.footer-items');
if (!$items) {
return null;
}
$foot = array();
foreach ($items as $item) {
$name = idx($item, 'name', pht('Unnamed Footer Item'));
$href = idx($item, 'href');
if (!PhabricatorEnv::isValidURIForLink($href)) {
$href = null;
}
if ($href !== null) {
$tag = 'a';
} else {
$tag = 'span';
}
$foot[] = phutil_tag(
$tag,
array(
'href' => $href,
),
$name);
}
$foot = phutil_implode_html(" \xC2\xB7 ", $foot);
return phutil_tag(
'div',
array(
'class' => 'phabricator-standard-page-footer grouped',
),
$foot);
}
public function renderForQuicksand() {
parent::willRenderPage();
$response = $this->renderPageBodyContent();
$response = $this->willSendResponse($response);
$extra_config = $this->getQuicksandConfig();
return array(
'content' => hsprintf('%s', $response),
) + $this->buildQuicksandConfig()
+ $extra_config;
}
private function buildQuicksandConfig() {
$viewer = $this->getRequest()->getUser();
$controller = $this->getController();
$dropdown_query = id(new AphlictDropdownDataQuery())
->setViewer($viewer);
$dropdown_query->execute();
$hisec_warning_config = $this->getHighSecurityWarningConfig();
$console_config = null;
$console = $this->getConsole();
if ($console) {
$console_config = $this->getConsoleConfig();
}
$upload_enabled = false;
if ($controller) {
$upload_enabled = $controller->isGlobalDragAndDropUploadEnabled();
}
$application_class = null;
$application_search_icon = null;
$application_help = null;
$controller = $this->getController();
if ($controller) {
$application = $controller->getCurrentApplication();
if ($application) {
$application_class = get_class($application);
if ($application->getApplicationSearchDocumentTypes()) {
$application_search_icon = $application->getIcon();
}
$help_items = $application->getHelpMenuItems($viewer);
if ($help_items) {
$help_list = id(new PhabricatorActionListView())
->setViewer($viewer);
foreach ($help_items as $help_item) {
$help_list->addAction($help_item);
}
$application_help = $help_list->getDropdownMenuMetadata();
}
}
}
return array(
'title' => $this->getTitle(),
'bodyClasses' => $this->getBodyClasses(),
'aphlictDropdownData' => array(
$dropdown_query->getNotificationData(),
$dropdown_query->getConpherenceData(),
),
'globalDragAndDrop' => $upload_enabled,
'hisecWarningConfig' => $hisec_warning_config,
'consoleConfig' => $console_config,
'applicationClass' => $application_class,
'applicationSearchIcon' => $application_search_icon,
'helpItems' => $application_help,
) + $this->buildAphlictListenConfigData();
}
private function buildAphlictListenConfigData() {
$user = $this->getRequest()->getUser();
$subscriptions = $this->pageObjects;
$subscriptions[] = $user->getPHID();
return array(
'pageObjects' => array_fill_keys($this->pageObjects, true),
'subscriptions' => $subscriptions,
);
}
private function getQuicksandURIPatternBlacklist() {
$applications = PhabricatorApplication::getAllApplications();
$blacklist = array();
foreach ($applications as $application) {
$blacklist[] = $application->getQuicksandURIPatternBlacklist();
}
return array_mergev($blacklist);
}
private function getUserPreference($key, $default = null) {
$request = $this->getRequest();
if (!$request) {
return $default;
}
$user = $request->getUser();
if (!$user) {
return $default;
}
return $user->getUserSetting($key);
}
public function produceAphrontResponse() {
$controller = $this->getController();
if (!$this->getApplicationMenu()) {
$application_menu = $controller->buildApplicationMenu();
if ($application_menu) {
$this->setApplicationMenu($application_menu);
}
}
$viewer = $this->getUser();
if ($viewer && $viewer->getPHID()) {
$object_phids = $this->pageObjects;
foreach ($object_phids as $object_phid) {
PhabricatorFeedStoryNotification::updateObjectNotificationViews(
$viewer,
$object_phid);
}
}
if ($this->getRequest()->isQuicksand()) {
$content = $this->renderForQuicksand();
$response = id(new AphrontAjaxResponse())
->setContent($content);
} else {
$content = $this->render();
$response = id(new AphrontWebpageResponse())
->setContent($content)
->setFrameable($this->getFrameable());
}
return $response;
}
}
diff --git a/src/view/phui/PHUIHeaderView.php b/src/view/phui/PHUIHeaderView.php
index dc836f3d3a..53ec096265 100644
--- a/src/view/phui/PHUIHeaderView.php
+++ b/src/view/phui/PHUIHeaderView.php
@@ -1,549 +1,549 @@
<?php
final class PHUIHeaderView extends AphrontTagView {
const PROPERTY_STATUS = 1;
private $header;
private $tags = array();
private $image;
private $imageURL = null;
private $imageEditURL = null;
private $subheader;
private $headerIcon;
private $noBackground;
private $bleedHeader;
private $profileHeader;
private $tall;
private $properties = array();
private $actionLinks = array();
private $buttonBar = null;
private $policyObject;
private $epoch;
private $actionItems = array();
private $href;
private $actionList;
private $actionListID;
public function setHeader($header) {
$this->header = $header;
return $this;
}
public function setNoBackground($nada) {
$this->noBackground = $nada;
return $this;
}
public function setTall($tall) {
$this->tall = $tall;
return $this;
}
public function addTag(PHUITagView $tag) {
$this->tags[] = $tag;
return $this;
}
public function setImage($uri) {
$this->image = $uri;
return $this;
}
public function setImageURL($url) {
$this->imageURL = $url;
return $this;
}
public function setImageEditURL($url) {
$this->imageEditURL = $url;
return $this;
}
public function setSubheader($subheader) {
$this->subheader = $subheader;
return $this;
}
public function setBleedHeader($bleed) {
$this->bleedHeader = $bleed;
return $this;
}
public function setProfileHeader($bighead) {
$this->profileHeader = $bighead;
return $this;
}
public function setHeaderIcon($icon) {
$this->headerIcon = $icon;
return $this;
}
public function setActionList(PhabricatorActionListView $list) {
$this->actionList = $list;
return $this;
}
public function setActionListID($action_list_id) {
$this->actionListID = $action_list_id;
return $this;
}
public function setPolicyObject(PhabricatorPolicyInterface $object) {
$this->policyObject = $object;
return $this;
}
public function addProperty($property, $value) {
$this->properties[$property] = $value;
return $this;
}
public function addActionLink(PHUIButtonView $button) {
$this->actionLinks[] = $button;
return $this;
}
public function addActionItem($action) {
$this->actionItems[] = $action;
return $this;
}
public function setButtonBar(PHUIButtonBarView $bb) {
$this->buttonBar = $bb;
return $this;
}
public function setStatus($icon, $color, $name) {
// TODO: Normalize "closed/archived" to constants.
if ($color == 'dark') {
$color = PHUITagView::COLOR_INDIGO;
}
$tag = id(new PHUITagView())
->setName($name)
->setIcon($icon)
->setColor($color)
->setType(PHUITagView::TYPE_SHADE);
return $this->addProperty(self::PROPERTY_STATUS, $tag);
}
public function setEpoch($epoch) {
$age = time() - $epoch;
$age = floor($age / (60 * 60 * 24));
if ($age < 1) {
$when = pht('Today');
} else if ($age == 1) {
$when = pht('Yesterday');
} else {
$when = pht('%s Day(s) Ago', new PhutilNumber($age));
}
$this->setStatus('fa-clock-o bluegrey', null, pht('Updated %s', $when));
return $this;
}
public function setHref($href) {
$this->href = $href;
return $this;
}
public function getHref() {
return $this->href;
}
protected function getTagName() {
return 'div';
}
protected function getTagAttributes() {
require_celerity_resource('phui-header-view-css');
$classes = array();
$classes[] = 'phui-header-shell';
if ($this->noBackground) {
- $classes[] = 'phui-header-no-backgound';
+ $classes[] = 'phui-header-no-background';
}
if ($this->bleedHeader) {
$classes[] = 'phui-bleed-header';
}
if ($this->profileHeader) {
$classes[] = 'phui-profile-header';
}
if ($this->properties || $this->policyObject ||
$this->subheader || $this->tall) {
$classes[] = 'phui-header-tall';
}
return array(
'class' => $classes,
);
}
protected function getTagContent() {
if ($this->actionList || $this->actionListID) {
$action_button = id(new PHUIButtonView())
->setTag('a')
->setText(pht('Actions'))
->setHref('#')
->setIcon('fa-bars')
->addClass('phui-mobile-menu');
if ($this->actionList) {
$action_button->setDropdownMenu($this->actionList);
} else if ($this->actionListID) {
$action_button->setDropdownMenuID($this->actionListID);
}
$this->addActionLink($action_button);
}
$image = null;
if ($this->image) {
$image_href = null;
if ($this->imageURL) {
$image_href = $this->imageURL;
} else if ($this->imageEditURL) {
$image_href = $this->imageEditURL;
}
$image = phutil_tag(
'span',
array(
'class' => 'phui-header-image',
'style' => 'background-image: url('.$this->image.')',
));
if ($image_href) {
$edit_view = null;
if ($this->imageEditURL) {
$edit_view = phutil_tag(
'span',
array(
'class' => 'phui-header-image-edit',
),
pht('Edit'));
}
$image = phutil_tag(
'a',
array(
'href' => $image_href,
'class' => 'phui-header-image-href',
),
array(
$image,
$edit_view,
));
}
}
$viewer = $this->getUser();
$left = array();
$right = array();
$space_header = null;
if ($viewer) {
$space_header = id(new PHUISpacesNamespaceContextView())
->setUser($viewer)
->setObject($this->policyObject);
}
if ($this->actionLinks) {
$actions = array();
foreach ($this->actionLinks as $button) {
if (!$button->getColor()) {
$button->setColor(PHUIButtonView::GREY);
}
$button->addClass(PHUI::MARGIN_SMALL_LEFT);
$button->addClass('phui-header-action-link');
$actions[] = $button;
}
$right[] = phutil_tag(
'div',
array(
'class' => 'phui-header-action-links',
),
$actions);
}
if ($this->buttonBar) {
$right[] = phutil_tag(
'div',
array(
'class' => 'phui-header-action-links',
),
$this->buttonBar);
}
if ($this->actionItems) {
$action_list = array();
if ($this->actionItems) {
foreach ($this->actionItems as $item) {
$action_list[] = phutil_tag(
'li',
array(
'class' => 'phui-header-action-item',
),
$item);
}
}
$right[] = phutil_tag(
'ul',
array(
'class' => 'phui-header-action-list',
),
$action_list);
}
$icon = null;
if ($this->headerIcon) {
$icon = id(new PHUIIconView())
->setIcon($this->headerIcon)
->addClass('phui-header-icon');
}
$header_content = $this->header;
$href = $this->getHref();
if ($href !== null) {
$header_content = phutil_tag(
'a',
array(
'href' => $href,
),
$header_content);
}
$left[] = phutil_tag(
'span',
array(
'class' => 'phui-header-header',
),
array(
$space_header,
$icon,
$header_content,
));
if ($this->subheader) {
$left[] = phutil_tag(
'div',
array(
'class' => 'phui-header-subheader',
),
array(
$this->subheader,
));
}
if ($this->properties || $this->policyObject || $this->tags) {
$property_list = array();
foreach ($this->properties as $type => $property) {
switch ($type) {
case self::PROPERTY_STATUS:
$property_list[] = $property;
break;
default:
throw new Exception(pht('Incorrect Property Passed'));
break;
}
}
if ($this->policyObject) {
$property_list[] = $this->renderPolicyProperty($this->policyObject);
}
if ($this->tags) {
$property_list[] = $this->tags;
}
$left[] = phutil_tag(
'div',
array(
'class' => 'phui-header-subheader',
),
$property_list);
}
// We here at @phabricator
$header_image = null;
if ($image) {
$header_image = phutil_tag(
'div',
array(
'class' => 'phui-header-col1',
),
$image);
}
// All really love
$header_left = phutil_tag(
'div',
array(
'class' => 'phui-header-col2',
),
$left);
// Tables and Pokemon.
$header_right = phutil_tag(
'div',
array(
'class' => 'phui-header-col3',
),
$right);
$header_row = phutil_tag(
'div',
array(
'class' => 'phui-header-row',
),
array(
$header_image,
$header_left,
$header_right,
));
return phutil_tag(
'h1',
array(
'class' => 'phui-header-view',
),
$header_row);
}
private function renderPolicyProperty(PhabricatorPolicyInterface $object) {
$viewer = $this->getUser();
$policies = PhabricatorPolicyQuery::loadPolicies($viewer, $object);
$view_capability = PhabricatorPolicyCapability::CAN_VIEW;
$policy = idx($policies, $view_capability);
if (!$policy) {
return null;
}
// If an object is in a Space with a strictly stronger (more restrictive)
// policy, we show the more restrictive policy. This better aligns the
// UI hint with the actual behavior.
// NOTE: We'll do this even if the viewer has access to only one space, and
// show them information about the existence of spaces if they click
// through.
$use_space_policy = false;
if ($object instanceof PhabricatorSpacesInterface) {
$space_phid = PhabricatorSpacesNamespaceQuery::getObjectSpacePHID(
$object);
$spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($viewer);
$space = idx($spaces, $space_phid);
if ($space) {
$space_policies = PhabricatorPolicyQuery::loadPolicies(
$viewer,
$space);
$space_policy = idx($space_policies, $view_capability);
if ($space_policy) {
if ($space_policy->isStrongerThan($policy)) {
$policy = $space_policy;
$use_space_policy = true;
}
}
}
}
$container_classes = array();
$container_classes[] = 'policy-header-callout';
$phid = $object->getPHID();
// If we're going to show the object policy, try to determine if the object
// policy differs from the default policy. If it does, we'll call it out
// as changed.
if (!$use_space_policy) {
$default_policy = PhabricatorPolicyQuery::getDefaultPolicyForObject(
$viewer,
$object,
$view_capability);
if ($default_policy) {
if ($default_policy->getPHID() != $policy->getPHID()) {
$container_classes[] = 'policy-adjusted';
if ($default_policy->isStrongerThan($policy)) {
// The policy has strictly been weakened. For example, the
// default might be "All Users" and the current policy is "Public".
$container_classes[] = 'policy-adjusted-weaker';
} else if ($policy->isStrongerThan($default_policy)) {
// The policy has strictly been strengthened, and is now more
// restrictive than the default. For example, "All Users" has
// been replaced with "No One".
$container_classes[] = 'policy-adjusted-stronger';
} else {
// The policy has been adjusted but not strictly strengthened
// or weakened. For example, "Members of X" has been replaced with
// "Members of Y".
$container_classes[] = 'policy-adjusted-different';
}
}
}
}
$policy_name = array($policy->getShortName());
$policy_icon = $policy->getIcon().' bluegrey';
if ($object instanceof PhabricatorPolicyCodexInterface) {
$codex = PhabricatorPolicyCodex::newFromObject($object, $viewer);
$codex_name = $codex->getPolicyShortName($policy, $view_capability);
if ($codex_name !== null) {
$policy_name = $codex_name;
}
$codex_icon = $codex->getPolicyIcon($policy, $view_capability);
if ($codex_icon !== null) {
$policy_icon = $codex_icon;
}
$codex_classes = $codex->getPolicyTagClasses($policy, $view_capability);
foreach ($codex_classes as $codex_class) {
$container_classes[] = $codex_class;
}
}
if (!is_array($policy_name)) {
$policy_name = (array)$policy_name;
}
$arrow = id(new PHUIIconView())
->setIcon('fa-angle-right')
->addClass('policy-tier-separator');
$policy_name = phutil_implode_html($arrow, $policy_name);
$icon = id(new PHUIIconView())
->setIcon($policy_icon);
$link = javelin_tag(
'a',
array(
'class' => 'policy-link',
'href' => '/policy/explain/'.$phid.'/'.$view_capability.'/',
'sigil' => 'workflow',
),
$policy_name);
return phutil_tag(
'span',
array(
'class' => implode(' ', $container_classes),
),
array($icon, $link));
}
}
diff --git a/webroot/rsrc/css/phui/phui-document.css b/webroot/rsrc/css/phui/phui-document.css
index e5985f8889..954a220229 100644
--- a/webroot/rsrc/css/phui/phui-document.css
+++ b/webroot/rsrc/css/phui/phui-document.css
@@ -1,105 +1,105 @@
/**
* @provides phui-document-view-css
*/
.phui-document-view {
margin-bottom: 16px;
border-radius: 3px;
position: relative;
}
.device-desktop .phui-document-view {
border: 1px solid {$lightblueborder};
max-width: 960px;
margin: 16px auto;
}
.device-desktop .phui-document-box {
max-width: 996px;
margin: 24px auto;
}
.device-desktop .phui-document-fluid .phui-document-view {
max-width: none;
margin: 16px;
}
/* Fix so that Phriction Document preview is the same width as the document */
.device-desktop .phui-remarkup-preview .phui-document-view {
width: 800px;
}
.phui-document-content .phui-header-shell {
border-top: none;
border-bottom: 1px solid {$lightblueborder};
}
.phui-document-content
- .phui-header-shell.phui-header-no-backgound {
+ .phui-header-shell.phui-header-no-background {
border-bottom: 1px solid {$thinblueborder};
margin: 0 0 16px 0;
}
.phui-document-content
- .phui-header-shell.phui-header-no-backgound
+ .phui-header-shell.phui-header-no-background
.phui-header-view {
padding: 8px 0 4px;
}
.legalpad .phui-document-content .phui-property-list-view {
border: none;
box-shadow: none;
border-radius: 3px;
margin: 16px 0 0 0;
background-color: {$bluebackground};
}
.phui-document-content {
background: {$page.content};
}
.phui-document-content .phabricator-remarkup {
padding: 16px;
font-size: 14px;
}
.phui-document-view .phui-header-action-links .phui-mobile-menu {
display: block;
}
.device-phone .phui-document-content .phabricator-remarkup {
padding: 8px;
}
.device-desktop .phui-document-content .phabricator-action-list-view {
display: none;
}
.phui-document-content .phabricator-remarkup .remarkup-code-block {
clear: both;
margin: 16px 0;
}
.phui-document-view .phui-info-severity-nodata {
background-color: {$lightgreybackground};
}
.phui-document-view .phui-property-list-section-header {
padding: 20px 24px 0px;
border-top: none;
}
.phui-document-view .phui-property-list-text-content {
padding: 0 24px 4px;
}
.phui-document-view .PhabricatorMonospaced,
.phui-document-view .phabricator-remarkup .remarkup-code-block .remarkup-code {
font: 12px/18px "Menlo", "Consolas", "Monaco", monospace;
}
.platform-windows .phui-document-view .PhabricatorMonospaced,
.platform-windows .phui-document-view .phabricator-remarkup .remarkup-code-block
.remarkup-code {
font: 13px/18px "Menlo", "Consolas", "Monaco", monospace;
}
diff --git a/webroot/rsrc/css/phui/phui-header-view.css b/webroot/rsrc/css/phui/phui-header-view.css
index 18b1464e53..478dde2153 100644
--- a/webroot/rsrc/css/phui/phui-header-view.css
+++ b/webroot/rsrc/css/phui/phui-header-view.css
@@ -1,388 +1,388 @@
/**
* @provides phui-header-view-css
*/
.phui-header-shell {
border-bottom: 1px solid {$thinblueborder};
overflow: hidden;
padding: 0 4px 12px;
}
.phui-header-view {
display: table;
width: 100%
}
.phui-header-row {
display: table-row;
}
.phui-header-col1 {
display: table-cell;
vertical-align: middle;
width: 62px;
}
.phui-header-col2 {
display: table-cell;
vertical-align: middle;
word-break: break-word;
}
.phui-header-col3 {
display: table-cell;
vertical-align: middle;
}
-body .phui-header-shell.phui-header-no-backgound {
+body .phui-header-shell.phui-header-no-background {
background-color: transparent;
border: none;
}
body .phui-header-shell.phui-bleed-header {
background-color: #fff;
border-bottom: 1px solid {$thinblueborder};
width: auto;
margin: 16px;
}
body .phui-header-shell.phui-bleed-header
.phui-header-view {
padding: 8px 24px 8px 0;
color: {$darkbluetext};
}
.phui-header-shell + .phabricator-form-view {
border-top-width: 0;
}
.phui-property-list-view + .diviner-document-section {
margin-top: -1px;
}
.phui-header-view {
position: relative;
font-size: {$normalfontsize};
}
.phui-header-header {
font-size: 16px;
line-height: 24px;
color: {$darkbluetext};
}
.phui-header-header .phui-header-icon {
margin-right: 8px;
color: {$lightbluetext};
/* This allows the header text to be triple-clicked to select it in Firefox,
see T10905 for discussion. */
display: inline;
}
.phui-object-box .phui-header-tall .phui-header-header,
.phui-document-view .phui-header-tall .phui-header-header {
font-size: 18px;
}
.phui-header-view .phui-header-header a {
color: {$darkbluetext};
}
.phui-box-blue-property .phui-header-view .phui-header-header a {
color: {$bluetext};
}
.device-desktop .phui-header-view .phui-header-header a:hover {
text-decoration: none;
color: {$blue};
}
.phui-header-view .phui-header-action-links {
float: right;
}
.phui-object-box .phui-header-view .phui-header-action-links {
margin-right: 4px;
font-size: {$normalfontsize};
}
.phui-header-action-link {
margin-bottom: 4px;
margin-top: 4px;
float: right;
}
.device-phone .phui-header-action-link .phui-button-text {
visibility: hidden;
width: 0;
margin-left: 8px;
}
.device-phone .phui-header-action-link.button .phui-icon-view {
width: 12px;
text-align: center;
}
.phui-header-divider {
margin: 0 4px;
font-weight: normal;
color: {$lightbluetext};
}
.phui-header-tags {
margin-left: 12px;
font-size: {$normalfontsize};
}
.phui-header-tags .phui-tag-view {
margin-left: 4px;
}
.phui-header-image {
display: inline-block;
background-repeat: no-repeat;
background-size: 100%;
width: 50px;
height: 50px;
border-radius: 3px;
}
.phui-header-image-href {
position: relative;
display: block;
}
.phui-header-image-edit {
display: none;
}
.device-desktop .phui-header-image-href:hover .phui-header-image-edit {
display: block;
position: absolute;
left: 0;
background: rgba({$alphablack},0.4);
color: #fff;
font-weight: normal;
bottom: 4px;
padding: 4px 8px;
font-size: 12px;
}
.device-desktop .phui-header-image-edit:hover {
text-decoration: underline;
}
.phui-header-subheader {
font-weight: normal;
font-size: {$biggerfontsize};
margin-top: 8px;
}
.phui-header-subheader .phui-icon-view {
margin-right: 4px;
}
.phui-header-subheader .phui-tag-view span.phui-icon-view,
.phui-header-subheader .policy-header-callout span.phui-icon-view {
display: inline-block;
margin: -2px 4px -2px 0;
font-size: 15px;
}
.phui-header-subheader,
.phui-header-subheader .policy-link {
color: {$darkbluetext};
}
.policy-header-callout,
.phui-header-subheader .phui-tag-core {
padding: 3px 9px;
border-radius: 3px;
background: rgba({$alphablue}, 0.1);
margin-right: 8px;
-webkit-font-smoothing: auto;
border-color: transparent;
}
.phui-header-subheader .phui-tag-view,
.phui-header-subheader .phui-tag-type-shade .phui-tag-core {
border: none;
font-weight: normal;
-webkit-font-smoothing: auto;
}
.policy-header-callout.policy-adjusted-weaker {
background: {$sh-greenbackground};
}
.policy-header-callout.policy-adjusted-weaker .policy-link,
.policy-header-callout.policy-adjusted-weaker .phui-icon-view {
color: {$sh-greentext};
}
.policy-header-callout.policy-adjusted-stronger {
background: {$sh-redbackground};
}
.policy-header-callout.policy-adjusted-stronger .policy-link,
.policy-header-callout.policy-adjusted-stronger .phui-icon-view {
color: {$sh-redtext};
}
.policy-header-callout.policy-adjusted-different {
background: {$sh-orangebackground};
}
.policy-header-callout.policy-adjusted-different .policy-link,
.policy-header-callout.policy-adjusted-different .phui-icon-view {
color: {$sh-orangetext};
}
.policy-header-callout.policy-adjusted-special {
background: {$sh-indigobackground};
}
.policy-header-callout.policy-adjusted-special .policy-link,
.policy-header-callout.policy-adjusted-special .phui-icon-view {
color: {$sh-indigotext};
}
.policy-header-callout .policy-space-container {
font-weight: bold;
color: {$sh-redtext};
}
.policy-header-callout .policy-tier-separator {
padding: 0 0 0 4px;
color: {$lightgreytext};
}
.phui-header-action-links .phui-mobile-menu {
display: none;
}
.device .phui-header-action-links .phui-mobile-menu {
display: inline-block;
}
.phui-header-action-list {
float: right;
}
.phui-header-action-list li {
margin: 0 0 0 8px;
float: right;
}
.phui-header-action-list .phui-header-action-item .phui-icon-view {
height: 18px;
width: 16px;
font-size: 16px;
line-height: 20px;
display: block;
}
.spaces-name {
color: {$lightbluetext};
}
.phui-object-box .phui-header-tall .spaces-name {
font-size: 18px;
}
.spaces-name .phui-handle,
.spaces-name a.phui-handle {
color: {$sh-redtext};
}
.device-desktop .spaces-name a.phui-handle:hover {
color: {$sh-redtext};
text-decoration: underline;
}
/*** Profile Header ***********************************************************/
.phui-profile-header {
padding: 24px 20px 20px 24px;
}
.device-phone .phui-profile-header {
padding: 12px;
}
.phui-profile-header.phui-header-shell {
margin: 0;
border: none;
}
.phui-profile-header .phui-header-image {
height: 80px;
width: 80px;
}
.phui-profile-header .phui-header-col1 {
width: 96px;
}
.phui-profile-header .phui-header-subheader {
margin-top: 12px;
}
.phui-profile-header.phui-header-shell .phui-header-header {
font-size: 24px;
color: {$blacktext};
}
.phui-profile-header.phui-header-shell .phui-header-header a {
color: {$blacktext};
}
.phui-header-view .phui-tag-indigo a {
color: {$sh-indigotext};
}
.phui-policy-section-view {
margin-bottom: 24px;
}
.phui-policy-section-view-header {
background: {$bluebackground};
border-bottom: 1px solid {$lightblueborder};
padding: 4px 8px;
color: {$darkbluetext};
margin-bottom: 8px;
}
.phui-policy-section-view-header-text {
font-weight: bold;
}
.phui-policy-section-view-header .phui-icon-view {
margin-right: 8px;
}
.phui-policy-section-view-link {
float: right;
}
.phui-policy-section-view-link .phui-icon-view {
color: {$bluetext};
}
.phui-policy-section-view-hint {
color: {$greytext};
background: {$lightbluebackground};
padding: 8px;
}
.phui-policy-section-view-body {
padding: 0 12px;
}
.phui-policy-section-view-inactive-rule {
color: {$greytext};
}
diff --git a/webroot/rsrc/js/application/conpherence/behavior-menu.js b/webroot/rsrc/js/application/conpherence/behavior-menu.js
index a5566dc5a4..43488ce4ff 100644
--- a/webroot/rsrc/js/application/conpherence/behavior-menu.js
+++ b/webroot/rsrc/js/application/conpherence/behavior-menu.js
@@ -1,443 +1,443 @@
/**
* @provides javelin-behavior-conpherence-menu
* @requires javelin-behavior
* javelin-dom
* javelin-util
* javelin-stratcom
* javelin-workflow
* javelin-behavior-device
* javelin-history
* javelin-vector
* javelin-scrollbar
* phabricator-title
* phabricator-shaped-request
* conpherence-thread-manager
*/
JX.behavior('conpherence-menu', function(config) {
/**
* State for displayed thread.
*/
var _thread = {
selected: null,
visible: null,
node: null
};
var scrollbar = null;
var cur_theme = config.theme;
// TODO - move more logic into the ThreadManager
var threadManager = new JX.ConpherenceThreadManager();
threadManager.setMessagesRootCallback(function() {
return scrollbar.getContentNode();
});
threadManager.setWillLoadThreadCallback(function() {
markThreadsLoading(true);
});
threadManager.setDidLoadThreadCallback(function(r) {
var header = JX.$H(r.header);
var search = JX.$H(r.search);
var messages = JX.$H(r.transactions);
var form = JX.$H(r.form);
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var header_root = JX.DOM.find(root, 'div', 'conpherence-header-pane');
var search_root = JX.DOM.find(root, 'div', 'conpherence-search-main');
var form_root = JX.DOM.find(root, 'div', 'conpherence-form');
JX.DOM.setContent(header_root, header);
JX.DOM.setContent(search_root, search);
JX.DOM.setContent(scrollbar.getContentNode(), messages);
JX.DOM.setContent(form_root, form);
markThreadsLoading(false);
didRedrawThread(true);
});
threadManager.setDidUpdateThreadCallback(function(r) {
_scrollMessageWindow();
});
threadManager.setWillSendMessageCallback(function () {
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var form_root = JX.DOM.find(root, 'div', 'conpherence-form');
markThreadLoading(true);
JX.DOM.alterClass(form_root, 'loading', true);
});
threadManager.setDidSendMessageCallback(function (r, non_update) {
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var form_root = JX.DOM.find(root, 'div', 'conpherence-form');
var textarea = JX.DOM.find(form_root, 'textarea');
if (!non_update) {
_scrollMessageWindow();
}
markThreadLoading(false);
setTimeout(function() { JX.DOM.focus(textarea); }, 100);
});
threadManager.start();
/**
* Current role of this behavior. The two possible roles are to show a 'list'
* of threads or a specific 'thread'. On devices, this behavior stays in the
* 'list' role indefinitely, treating clicks normally and the next page
* loads the behavior with role = 'thread'. On desktop, this behavior
* auto-loads a thread as part of the 'list' role. As the thread loads the
* role is changed to 'thread'.
*/
var _currentRole = null;
/**
* When _oldDevice is null the code is executing for the first time.
*/
var _oldDevice = null;
/**
- * Initializes this behavior based on all the configuraton jonx and the
+ * Initializes this behavior based on all the configuration jonx and the
* result of JX.Device.getDevice();
*/
function init() {
_currentRole = config.role;
if (_currentRole == 'thread') {
markThreadsLoading(true);
} else {
markThreadLoading(true);
}
markWidgetLoading(true);
onDeviceChange();
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var messages_root = JX.DOM.find(root, 'div', 'conpherence-message-pane');
var messages = JX.DOM.find(messages_root, 'div', 'conpherence-messages');
scrollbar = new JX.Scrollbar(messages);
scrollbar.setAsScrollFrame();
}
init();
/**
* Selecting threads
*/
function selectThreadByID(id, update_page_data) {
var thread = JX.$(id);
selectThread(thread, update_page_data);
}
function selectThread(node, update_page_data) {
if (_thread.node) {
JX.DOM.alterClass(_thread.node, 'phui-list-item-selected', false);
}
JX.DOM.alterClass(node, 'phui-list-item-selected', true);
JX.DOM.alterClass(node, 'hide-unread-count', true);
_thread.node = node;
var data = JX.Stratcom.getData(node);
_thread.selected = data.threadID;
if (update_page_data) {
updatePageData(data);
}
redrawThread();
}
function updatePageData(data) {
var uri = '/Z' + _thread.selected;
var new_theme = data.theme;
if (new_theme != cur_theme) {
var root = JX.$('conpherence-main-layout');
JX.DOM.alterClass(root, cur_theme, false);
JX.DOM.alterClass(root, new_theme, true);
cur_theme = new_theme;
}
JX.History.replace(uri);
if (data.title) {
JX.Title.setTitle(data.title);
} else if (_thread.node) {
var threadData = JX.Stratcom.getData(_thread.node);
JX.Title.setTitle(threadData.title);
}
}
JX.Stratcom.listen(
'conpherence-update-page-data',
null,
function (e) {
updatePageData(e.getData());
}
);
function redrawThread() {
if (!_thread.node) {
return;
}
if (_thread.visible == _thread.selected) {
return;
}
var data = JX.Stratcom.getData(_thread.node);
if (_thread.visible !== null || !config.hasThread) {
threadManager.setLoadThreadURI('/conpherence/' + data.threadID + '/');
threadManager.loadThreadByID(data.threadID);
} else if (config.hasThread) {
// we loaded the thread via the server so let the thread manager know
threadManager.setLoadedThreadID(config.selectedThreadID);
threadManager.setLoadedThreadPHID(config.selectedThreadPHID);
threadManager.setLatestTransactionID(config.latestTransactionID);
threadManager.setCanEditLoadedThread(config.canEditSelectedThread);
threadManager.cacheCurrentTransactions();
_scrollMessageWindow();
_focusTextarea();
} else {
didRedrawThread();
}
if (_thread.visible !== null || !config.hasWidgets) {
reloadWidget(data);
}
_thread.visible = _thread.selected;
}
function markThreadsLoading(loading) {
var root = JX.$('conpherence-main-layout');
JX.DOM.alterClass(root, 'loading', loading);
}
function markThreadLoading(loading) {
try {
var textarea = JX.DOM.find(form, 'textarea');
textarea.disabled = loading;
var button = JX.DOM.find(form, 'button');
button.disabled = loading;
} catch (ex) {
// haven't loaded it yet!
}
}
function markWidgetLoading(loading) {
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var widgets_root = JX.DOM.find(root, 'div', 'conpherence-participant-pane');
JX.DOM.alterClass(widgets_root, 'loading', loading);
}
function reloadWidget(data) {
markWidgetLoading(true);
if (!data.widget) {
data.widget = getDefaultWidget();
}
var widget_uri = config.baseURI + 'participant/' + data.threadID + '/';
new JX.Workflow(widget_uri, {})
.setHandler(JX.bind(null, onWidgetResponse, data.threadID, data.widget))
.start();
}
JX.Stratcom.listen(
'conpherence-reload-widget',
null,
function (e) {
var data = e.getData();
if (data.threadID != _thread.selected) {
return;
}
reloadWidget(data);
}
);
function onWidgetResponse(thread_id, widget, response) {
// we got impatient and this is no longer the right answer :/
if (_thread.selected != thread_id) {
return;
}
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var widgets_root = JX.DOM.find(root, 'div', 'conpherence-widgets-holder');
JX.DOM.setContent(widgets_root, JX.$H(response.widgets));
}
function getDefaultWidget() {
return 'widgets-people';
}
/**
* This function is a wee bit tricky. Internally, we want to scroll the
* message window and let other stuff - notably widgets - redraw / build if
* necessary. Externally, we want a hook to scroll the message window
* - notably when the widget selector is used to invoke the message pane.
* The following three functions get 'er done.
*/
function didRedrawThread(build_device_widget_selector) {
_scrollMessageWindow();
_focusTextarea();
JX.Stratcom.invoke(
'conpherence-did-redraw-thread',
null,
{
widget : getDefaultWidget(),
threadID : _thread.selected,
buildDeviceWidgetSelector : build_device_widget_selector
});
}
var _firstScroll = true;
function _scrollMessageWindow() {
if (_firstScroll) {
_firstScroll = false;
// We want to let the standard #anchor tech take over after we make sure
// we don't have to present the user with a "load older message?" dialog
if (window.location.hash) {
var hash = window.location.hash.replace(/^#/, '');
try {
JX.$('anchor-' + hash);
} catch (ex) {
var uri = '/conpherence/' +
_thread.selected + '/' + hash + '/';
threadManager.setLoadThreadURI(uri);
threadManager.loadThreadByID(_thread.selected, true);
_firstScroll = true;
return;
}
return;
}
}
scrollbar.scrollTo(scrollbar.getViewportNode().scrollHeight);
}
function _focusTextarea() {
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var form_root = JX.DOM.find(root, 'div', 'conpherence-form');
try {
var textarea = JX.DOM.find(form_root, 'textarea');
// We may have a draft so do this JS trick so we end up focused at the
// end of the draft.
var textarea_value = textarea.value;
textarea.value = '';
JX.DOM.focus(textarea);
textarea.value = textarea_value;
} catch (ex) {
// no textarea? no problem
}
}
JX.Stratcom.listen(
'conpherence-redraw-thread',
null,
function () {
_scrollMessageWindow();
}
);
JX.Stratcom.listen(
'click',
'conpherence-menu-click',
function(e) {
if (!e.isNormalClick()) {
return;
}
// On devices, just follow the link normally.
if (JX.Device.getDevice() != 'desktop') {
return;
}
e.kill();
selectThread(e.getNode('conpherence-menu-click'), true);
});
/**
* On devices, we just show a thread list, so we don't want to automatically
* select or load any threads. On desktop, we automatically select the first
* thread, changing the _currentRole from list to thread.
*/
function onDeviceChange() {
var new_device = JX.Device.getDevice();
if (new_device === _oldDevice) {
return;
}
if (_oldDevice === null) {
_oldDevice = new_device;
if (_currentRole == 'list') {
if (new_device != 'desktop') {
return;
}
} else {
loadThreads();
return;
}
}
var update_toggled_widget =
new_device == 'desktop' || _oldDevice == 'desktop';
_oldDevice = new_device;
if (_thread.visible !== null && update_toggled_widget) {
JX.Stratcom.invoke(
'conpherence-did-redraw-thread',
null,
{
widget : getDefaultWidget(),
threadID : _thread.selected
});
}
if (_currentRole == 'list' && new_device == 'desktop') {
// this selects a thread and loads it
didLoadThreads();
_currentRole = 'thread';
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
JX.DOM.alterClass(root, 'conpherence-role-list', false);
JX.DOM.alterClass(root, 'conpherence-role-thread', true);
}
}
JX.Stratcom.listen('phabricator-device-change', null, onDeviceChange);
function loadThreads() {
markThreadsLoading(true);
var uri = config.baseURI + 'thread/' + config.selectedThreadID + '/';
new JX.Workflow(uri)
.setHandler(onLoadThreadsResponse)
.start();
}
function onLoadThreadsResponse(r) {
var layout = JX.$(config.layoutID);
var menu = JX.DOM.find(layout, 'div', 'conpherence-menu-pane');
JX.DOM.setContent(menu, JX.$H(r));
config.selectedID && selectThreadByID(config.selectedID);
markThreadsLoading(false);
}
function didLoadThreads() {
// If there's no thread selected yet, select the current thread or the
// first thread.
if (!_thread.selected) {
if (config.selectedID) {
selectThreadByID(config.selectedID, true);
} else {
var layout = JX.$(config.layoutID);
var threads = JX.DOM.scry(layout, 'a', 'conpherence-menu-click');
if (threads.length) {
selectThread(threads[0]);
} else {
var nothreads = JX.DOM.find(layout, 'div', 'conpherence-no-threads');
nothreads.style.display = 'block';
markThreadLoading(false);
markWidgetLoading(false);
}
}
}
}
JX.Stratcom.listen(
['keydown'],
'conpherence-pontificate',
function (e) {
threadManager.handleDraftKeydown(e);
});
});
diff --git a/webroot/rsrc/js/application/conpherence/behavior-participant-pane.js b/webroot/rsrc/js/application/conpherence/behavior-participant-pane.js
index 8c9fc056f4..e428cbdbce 100644
--- a/webroot/rsrc/js/application/conpherence/behavior-participant-pane.js
+++ b/webroot/rsrc/js/application/conpherence/behavior-participant-pane.js
@@ -1,112 +1,112 @@
/**
* @requires javelin-behavior
* javelin-dom
* javelin-stratcom
* javelin-workflow
* javelin-util
* phabricator-notification
* conpherence-thread-manager
* @provides javelin-behavior-conpherence-participant-pane
*/
JX.behavior('conpherence-participant-pane', function() {
/**
* Generified adding new stuff to widgets technology!
*/
JX.Stratcom.listen(
['click'],
'conpherence-widget-adder',
function (e) {
e.kill();
var threadManager = JX.ConpherenceThreadManager.getInstance();
var href = threadManager._getUpdateURI();
var latest_transaction_id = threadManager.getLatestTransactionID();
var data = {
latest_transaction_id : latest_transaction_id,
action : 'add_person'
};
var workflow = new JX.Workflow(href, data)
.setHandler(function (r) {
var threadManager = JX.ConpherenceThreadManager.getInstance();
threadManager.setLatestTransactionID(r.latest_transaction_id);
var root = JX.DOM.find(document, 'div', 'conpherence-layout');
var messages = null;
try {
messages = JX.DOM.find(root, 'div', 'conpherence-messages');
} catch (ex) {
}
if (messages) {
JX.DOM.appendContent(messages, JX.$H(r.transactions));
JX.Stratcom.invoke('conpherence-redraw-thread', null, {});
}
try {
var people_root = JX.DOM.find(root, 'div', 'widgets-people');
// update the people widget
JX.DOM.setContent(
people_root,
JX.$H(r.people_widget));
} catch (ex) {
}
});
threadManager.syncWorkflow(workflow, 'submit');
}
);
JX.Stratcom.listen(
['touchstart', 'mousedown'],
'remove-person',
function (e) {
var threadManager = JX.ConpherenceThreadManager.getInstance();
var href = threadManager._getUpdateURI();
var data = e.getNodeData('remove-person');
// While the user is removing themselves, disable the notification
// update behavior. If we don't do this, the user can get an error
// when they remove themselves about permissions as the notification
- // code tries to load what jist happened.
+ // code tries to load what just happened.
var loadedPhid = threadManager.getLoadedThreadPHID();
threadManager.setLoadedThreadPHID(null);
new JX.Workflow(href, data)
.setCloseHandler(function() {
threadManager.setLoadedThreadPHID(loadedPhid);
})
// we re-direct to conpherence home so the thread manager will
// fix itself there
.setHandler(function(r) {
JX.$U(r.href).go();
})
.start();
}
);
/* settings widget */
var onsubmitSettings = function (e) {
e.kill();
var form = e.getNode('tag:form');
var button = JX.DOM.find(form, 'button');
JX.Workflow.newFromForm(form)
.setHandler(JX.bind(this, function (r) {
new JX.Notification()
.setDuration(6000)
.setContent(r)
.show();
button.disabled = '';
JX.DOM.alterClass(button, 'disabled', false);
}))
.start();
};
JX.Stratcom.listen(
['submit', 'didSyntheticSubmit'],
'notifications-update',
onsubmitSettings
);
});
diff --git a/webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js b/webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js
index 14894f6cc0..1f94f349d3 100644
--- a/webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js
+++ b/webroot/rsrc/js/application/diffusion/DiffusionLocateFileSource.js
@@ -1,293 +1,293 @@
/**
* @provides javelin-diffusion-locate-file-source
* @requires javelin-install
* javelin-dom
* javelin-typeahead-preloaded-source
* javelin-util
* @javelin
*/
JX.install('DiffusionLocateFileSource', {
extend: 'TypeaheadPreloadedSource',
construct: function(uri) {
JX.TypeaheadPreloadedSource.call(this, uri);
this.cache = {};
},
members: {
tree: null,
limit: 20,
cache: null,
ondata: function(results) {
this.tree = results.tree;
if (this.lastValue !== null) {
this.matchResults(this.lastValue);
}
this.setReady(true);
},
/**
* Match a query and show results in the typeahead.
*/
matchResults: function(value, partial) {
// For now, just pretend spaces don't exist.
var search = value.toLowerCase();
search = search.replace(' ', '');
var paths = this.findResults(search);
var nodes = [];
for (var ii = 0; ii < paths.length; ii++) {
var path = paths[ii];
var name = [];
name.push(path.path.substr(0, path.pos));
name.push(
JX.$N('strong', {}, path.path.substr(path.pos, path.score)));
var pos = path.score;
var lower = path.path.toLowerCase();
for (var jj = path.pos + path.score; jj < path.path.length; jj++) {
if (lower.charAt(jj) == search.charAt(pos)) {
pos++;
name.push(JX.$N('strong', {}, path.path.charAt(jj)));
if (pos == search.length) {
break;
}
} else {
name.push(path.path.charAt(jj));
}
}
if (jj < path.path.length - 1 ) {
name.push(path.path.substr(jj + 1));
}
var attr = {
className: 'visual-only phui-icon-view phui-font-fa fa-file'
};
var icon = JX.$N('span', attr, '');
nodes.push(
JX.$N(
'a',
{
sigil: 'typeahead-result',
className: 'jx-result diffusion-locate-file',
ref: path.path
},
[icon, name]));
}
this.invoke('resultsready', nodes, value);
if (!partial) {
this.invoke('complete');
}
},
/**
* Find the results matching a query.
*/
findResults: function(search) {
if (!search.length) {
return [];
}
// We know that the results for "abc" are always a subset of the results
// for "a" and "ab" -- and there's a good chance we already computed
// those result sets. Find the longest cached result which is a prefix
// of the search query.
var best = 0;
var start = this.tree;
for (var k in this.cache) {
if ((k.length <= search.length) &&
(k.length > best) &&
(search.substr(0, k.length) == k)) {
best = k.length;
start = this.cache[k];
}
}
var matches;
if (start === null) {
matches = null;
} else {
matches = this.matchTree(start, search, 0);
}
// Save this tree in cache; throw the cache away after a few minutes.
if (!(search in this.cache)) {
this.cache[search] = matches;
setTimeout(
JX.bind(this, function() { delete this.cache[search]; }),
1000 * 60 * 5);
}
if (!matches) {
return [];
}
var paths = [];
this.buildPaths(matches, paths, '', search, []);
paths.sort(
function(u, v) {
if (u.score != v.score) {
return (v.score - u.score);
}
if (u.pos != v.pos) {
return (u.pos - v.pos);
}
return ((u.path > v.path) ? 1 : -1);
});
var num = Math.min(paths.length, this.limit);
var results = [];
for (var ii = 0; ii < num; ii++) {
results.push(paths[ii]);
}
return results;
},
/**
* Select the subtree that matches a query.
*/
matchTree: function(tree, value, pos) {
var matches = null;
for (var k in tree) {
var p = pos;
if (p != value.length) {
p = this.matchString(k, value, pos);
}
var result;
if (p == value.length) {
result = tree[k];
} else {
if (tree == 1) {
continue;
} else {
result = this.matchTree(tree[k], value, p);
if (!result) {
continue;
}
}
}
if (!matches) {
matches = {};
}
matches[k] = result;
}
return matches;
},
/**
* Look for the needle in a string, returning how much of it was found.
*/
matchString: function(haystack, needle, pos) {
var str = haystack.toLowerCase();
var len = str.length;
for (var ii = 0; ii < len; ii++) {
if (str.charAt(ii) == needle.charAt(pos)) {
pos++;
if (pos == needle.length) {
break;
}
}
}
return pos;
},
/**
* Flatten a tree into paths.
*/
buildPaths: function(matches, paths, prefix, search) {
var first = search.charAt(0);
for (var k in matches) {
if (matches[k] == 1) {
var path = prefix + k;
var lower = path.toLowerCase();
var best = 0;
var pos = 0;
for (var jj = 0; jj < lower.length; jj++) {
if (lower.charAt(jj) != first) {
continue;
}
var score = this.scoreMatch(lower, jj, search);
if (score == -1) {
break;
}
if (score > best) {
best = score;
pos = jj;
if (best == search.length) {
break;
}
}
}
paths.push({
path: path,
score: best,
pos: pos
});
} else {
this.buildPaths(matches[k], paths, prefix + k, search);
}
}
},
/**
* Score a matching string by finding the longest prefix of the search
- * query it contains continguously.
+ * query it contains contiguously.
*/
scoreMatch: function(haystack, haypos, search) {
var pos = 0;
for (var ii = haypos; ii < haystack.length; ii++) {
if (haystack.charAt(ii) == search.charAt(pos)) {
pos++;
if (pos == search.length) {
return pos;
}
} else {
ii++;
break;
}
}
var rem = pos;
for (/* keep going */; ii < haystack.length; ii++) {
if (haystack.charAt(ii) == search.charAt(rem)) {
rem++;
if (rem == search.length) {
return pos;
}
}
}
return -1;
}
}
});
diff --git a/webroot/rsrc/js/application/pholio/behavior-pholio-mock-view.js b/webroot/rsrc/js/application/pholio/behavior-pholio-mock-view.js
index 3991f4cc4e..0a02aebce7 100644
--- a/webroot/rsrc/js/application/pholio/behavior-pholio-mock-view.js
+++ b/webroot/rsrc/js/application/pholio/behavior-pholio-mock-view.js
@@ -1,844 +1,844 @@
/**
* @provides javelin-behavior-pholio-mock-view
* @requires javelin-behavior
* javelin-util
* javelin-stratcom
* javelin-dom
* javelin-vector
* javelin-magical-init
* javelin-request
* javelin-history
* javelin-workflow
* javelin-mask
* javelin-behavior-device
* phabricator-keyboard-shortcut
*/
JX.behavior('pholio-mock-view', function(config, statics) {
var is_dragging = false;
var drag_begin;
var drag_end;
var selection_reticle;
var active_image;
var inline_comments = {};
function get_image_index(id) {
for (var ii = 0; ii < statics.images.length; ii++) {
if (statics.images[ii].id == id) {
return ii;
}
}
return null;
}
function get_image_navindex(id) {
for (var ii = 0; ii < statics.navsequence.length; ii++) {
if (statics.navsequence[ii] == id) {
return ii;
}
}
return null;
}
function get_image(id) {
var idx = get_image_index(id);
if (idx === null) {
return idx;
}
return statics.images[idx];
}
function onload_image(id) {
if (active_image.id != id) {
// The user has clicked another image before this one loaded, so just
// bail.
return;
}
active_image.tag = this;
redraw_image();
}
function switch_image(delta) {
if (!active_image) {
return;
}
var idx = get_image_navindex(active_image.id);
if (idx === null) {
return;
}
idx = (idx + delta + statics.navsequence.length) %
statics.navsequence.length;
select_image(statics.navsequence[idx]);
}
function redraw_image() {
if (!statics.enabled) {
return;
}
var new_y;
// If we don't have an image yet, just scale the stage relative to the
// entire viewport height so the jump isn't too jumpy when the image loads.
if (!active_image || !active_image.tag) {
new_y = (JX.Vector.getViewport().y * 0.80);
new_y = Math.max(320, new_y);
statics.panel.style.height = new_y + 'px';
return;
}
var tag = active_image.tag;
// If the image is too wide for the viewport, scale it down so it fits.
// If it is too tall, just let the viewport scroll.
var w = JX.Vector.getDim(statics.panel);
// Leave 24px margins on either side of the image.
w.x -= 48;
var scale = 1;
if (w.x < tag.naturalWidth) {
scale = Math.min(scale, w.x / tag.naturalWidth);
}
if (scale < 1) {
tag.width = Math.floor(scale * tag.naturalWidth);
tag.height = Math.floor(scale * tag.naturalHeight);
} else {
tag.width = tag.naturalWidth;
tag.height = tag.naturalHeight;
}
// Scale the viewport's vertical size to the image's adjusted size.
new_y = Math.max(320, tag.height + 48);
statics.panel.style.height = new_y + 'px';
statics.viewport.style.top = Math.floor((new_y - tag.height) / 2) + 'px';
statics.stage.endLoad();
JX.DOM.setContent(statics.viewport, tag);
redraw_inlines(active_image.id);
}
function select_image(image_id) {
active_image = get_image(image_id);
active_image.tag = null;
statics.stage.beginLoad();
var img = JX.$N('img', {className: 'pholio-mock-image'});
img.onload = JX.bind(img, onload_image, active_image.id);
img.src = active_image.stageURI;
var thumbs = JX.DOM.scry(
JX.$('pholio-mock-thumb-grid'),
'a',
'mock-thumbnail');
for(var k in thumbs) {
var thumb_meta = JX.Stratcom.getData(thumbs[k]);
JX.DOM.alterClass(
thumbs[k],
'pholio-mock-thumb-grid-current',
(active_image.id == thumb_meta.imageID));
}
load_inline_comments();
if (image_id != statics.selectedID) {
JX.History.replace(active_image.pageURI);
}
}
function resize_selection(min_size) {
var start = {
x: Math.min(drag_begin.x, drag_end.x),
y: Math.min(drag_begin.y, drag_end.y)
};
var end = {
x: Math.max(drag_begin.x, drag_end.x),
y: Math.max(drag_begin.y, drag_end.y)
};
var width = end.x - start.x;
var height = end.y - start.y;
var addon;
if (width < min_size) {
addon = (min_size-width)/2;
start.x = Math.max(0, start.x - addon);
end.x = Math.min(active_image.tag.naturalWidth, end.x + addon);
if (start.x === 0) {
end.x = Math.min(min_size, active_image.tag.naturalWidth);
} else if (end.x == active_image.tag.naturalWidth) {
start.x = Math.max(0, active_image.tag.naturalWidth - min_size);
}
}
if (height < min_size) {
addon = (min_size-height)/2;
start.y = Math.max(0, start.y - addon);
end.y = Math.min(active_image.tag.naturalHeight, end.y + addon);
if (start.y === 0) {
end.y = Math.min(min_size, active_image.tag.naturalHeight);
} else if (end.y == active_image.tag.naturalHeight) {
start.y = Math.max(0, active_image.tag.naturalHeight - min_size);
}
}
drag_begin = start;
drag_end = end;
redraw_selection();
}
function render_image_header(image) {
- // Render image dimensions and visible size. If we have this infomation
+ // Render image dimensions and visible size. If we have this information
// from the server we can display some of it immediately; otherwise, we need
// to wait for the image to load so we can read dimension information from
// it.
var image_x = image.width;
var image_y = image.height;
var display_x = null;
if (image.tag) {
image_x = image.tag.naturalWidth;
image_y = image.tag.naturalHeight;
display_x = image.tag.width;
}
var visible = [];
if (image_x) {
if (display_x) {
var area = Math.round(100 * (display_x / image_x));
visible.push(
JX.$N(
'span',
{className: 'pholio-visible-size'},
[area, '%']));
visible.push(' ');
}
visible.push(['(', image_x, ' \u00d7 ', image_y, ')']);
}
return visible;
}
function redraw_inlines(id) {
if (!statics.enabled) {
return;
}
if (!active_image) {
return;
}
if (active_image.id != id) {
return;
}
statics.stage.clearStage();
var comment_holder = JX.$('mock-image-description');
JX.DOM.setContent(comment_holder, render_image_info(active_image));
var image_header = JX.$('mock-image-header');
JX.DOM.setContent(image_header, render_image_header(active_image));
var inlines = inline_comments[active_image.id];
if (!inlines || !inlines.length) {
return;
}
for (var ii = 0; ii < inlines.length; ii++) {
var inline = inlines[ii];
if (!active_image.tag) {
// The image itself hasn't loaded yet, so we can't draw the inline
// reticles.
continue;
}
var classes = [];
if (!inline.transactionPHID) {
classes.push('pholio-mock-reticle-draft');
} else {
classes.push('pholio-mock-reticle-final');
}
var inline_selection = render_reticle(classes,
'pholio-mock-comment-icon phui-font-fa fa-comment');
statics.stage.addReticle(inline_selection, inline.id);
position_inline_rectangle(inline, inline_selection);
}
}
function position_inline_rectangle(inline, rect) {
var scale = get_image_scale();
JX.$V(scale * inline.x, scale * inline.y).setPos(rect);
JX.$V(scale * inline.width, scale * inline.height).setDim(rect);
}
function get_image_xy(p) {
var img = active_image.tag;
var imgp = JX.$V(img);
var scale = 1 / get_image_scale();
var x = scale * Math.max(0, Math.min(p.x - imgp.x, img.width));
var y = scale * Math.max(0, Math.min(p.y - imgp.y, img.height));
return {
x: x,
y: y
};
}
function get_image_scale() {
var img = active_image.tag;
return Math.min(
img.width / img.naturalWidth,
img.height / img.naturalHeight);
}
function redraw_selection() {
if (!statics.enabled) {
return;
}
var classes = ['pholio-mock-reticle-selection'];
selection_reticle = selection_reticle || render_reticle(classes, '');
var p = JX.$V(
Math.min(drag_begin.x, drag_end.x),
Math.min(drag_begin.y, drag_end.y));
var d = JX.$V(
Math.max(drag_begin.x, drag_end.x) - p.x,
Math.max(drag_begin.y, drag_end.y) - p.y);
var scale = get_image_scale();
p.x *= scale;
p.y *= scale;
d.x *= scale;
d.y *= scale;
statics.viewport.appendChild(selection_reticle);
p.setPos(selection_reticle);
d.setDim(selection_reticle);
}
function clear_selection() {
selection_reticle && JX.DOM.remove(selection_reticle);
selection_reticle = null;
}
function load_inline_comments() {
var id = active_image.id;
var inline_comments_uri = '/pholio/inline/list/' + id + '/';
new JX.Request(inline_comments_uri, function(r) {
inline_comments[id] = r;
redraw_inlines(id);
}).send();
}
/* -( Render )------------------------------------------------------------- */
function render_image_info(image) {
var info = [];
var buttons = [];
var classes = ['pholio-image-button'];
if (image.isViewable) {
classes.push('pholio-image-button-active');
} else {
classes.push('pholio-image-button-disabled');
}
buttons.push(
JX.$N(
'div',
{
className: classes.join(' ')
},
JX.$N(
image.isViewable ? 'a' : 'span',
{
href: image.fullURI,
target: '_blank',
className: 'pholio-image-button-link'
},
JX.$H(statics.fullIcon))));
classes = ['pholio-image-button', 'pholio-image-button-active'];
buttons.push(
JX.$N(
'form',
{
className: classes.join(' '),
action: image.downloadURI,
method: 'POST',
sigil: 'download'
},
JX.$N(
'button',
{
href: image.downloadURI,
className: 'pholio-image-button-link'
},
JX.$H(statics.downloadIcon))));
if (image.title === '') {
image.title = 'Untitled Masterpiece';
}
var title = JX.$N(
'div',
{className: 'pholio-image-title'},
image.title);
info.push(title);
if (!image.isObsolete) {
var img_len = statics.currentSetSize;
var rev = JX.$N(
'div',
{className: 'pholio-image-revision'},
JX.$H('Current Revision (' + img_len + ' images)'));
info.push(rev);
} else {
var prev = JX.$N(
'div',
{className: 'pholio-image-revision'},
JX.$H('(Previous Revision)'));
info.push(prev);
}
for (var ii = 0; ii < info.length; ii++) {
info[ii] = JX.$N('div', {className: 'pholio-image-info-item'}, info[ii]);
}
info = JX.$N('div', {className: 'pholio-image-info'}, info);
if (image.descriptionMarkup === '') {
return [buttons, info];
} else {
var desc = JX.$N(
'div',
{className: 'pholio-image-description'},
JX.$H(image.descriptionMarkup));
return [buttons, info, desc];
}
}
function render_reticle(classes, inner_classes) {
var inner = JX.$N('div', {className: inner_classes});
var outer = JX.$N(
'div',
{className: ['pholio-mock-reticle'].concat(classes).join(' ')}, inner);
return outer;
}
/* -( Device Lightbox )---------------------------------------------------- */
// On devices, we show images full-size when the user taps them instead of
// attempting to implement inlines.
var lightbox = null;
function lightbox_attach() {
JX.DOM.alterClass(document.body, 'lightbox-attached', true);
JX.Mask.show('jx-dark-mask');
lightbox = lightbox_render();
var image = JX.$N('img');
image.onload = lightbox_loaded;
setTimeout(function() {
image.src = active_image.stageURI;
}, 1000);
JX.DOM.setContent(lightbox, image);
JX.DOM.alterClass(lightbox, 'pholio-device-lightbox-loading', true);
lightbox_resize();
document.body.appendChild(lightbox);
}
function lightbox_detach() {
JX.DOM.remove(lightbox);
JX.Mask.hide();
JX.DOM.alterClass(document.body, 'lightbox-attached', false);
lightbox = null;
}
function lightbox_resize() {
if (!statics.enabled) {
return;
}
if (!lightbox) {
return;
}
JX.Vector.getScroll().setPos(lightbox);
JX.Vector.getViewport().setDim(lightbox);
}
function lightbox_loaded() {
JX.DOM.alterClass(lightbox, 'pholio-device-lightbox-loading', false);
}
function lightbox_render() {
var el = JX.$N('div', {className: 'pholio-device-lightbox'});
JX.Stratcom.addSigil(el, 'pholio-device-lightbox');
return el;
}
/* -( Preload )------------------------------------------------------------ */
function preload_next() {
var next_src = statics.preload[0];
if (!next_src) {
return;
}
statics.preload.splice(0, 1);
var img = JX.$N('img');
img.onload = preload_next;
img.onerror = preload_next;
img.src = next_src;
}
/* -( Installaton )-------------------------------------------------------- */
function update_statics(data) {
statics.enabled = true;
statics.mockID = data.mockID;
statics.commentFormID = data.commentFormID;
statics.images = data.images;
statics.selectedID = data.selectedID;
statics.loggedIn = data.loggedIn;
statics.logInLink = data.logInLink;
statics.navsequence = data.navsequence;
statics.downloadIcon = data.downloadIcon;
statics.fullIcon = data.fullIcon;
statics.currentSetSize = data.currentSetSize;
statics.stage = (function() {
var loading = false;
var stageElement = JX.$(data.panelID);
var viewElement = JX.$(data.viewportID);
var reticles = [];
function begin_load() {
if (loading) {
return;
}
loading = true;
clear_stage();
draw_loading();
}
function end_load() {
if (!loading) {
return;
}
loading = false;
draw_loading();
}
function draw_loading() {
JX.DOM.alterClass(stageElement, 'pholio-image-loading', loading);
}
function add_reticle(reticle, id) {
mark_ref(reticle, id);
reticles.push(reticle);
viewElement.appendChild(reticle);
}
function clear_stage() {
var ii;
for (ii = 0; ii < reticles.length; ii++) {
JX.DOM.remove(reticles[ii]);
}
reticles = [];
}
function mark_ref(node, id) {
JX.Stratcom.addSigil(node, 'pholio-inline-ref');
JX.Stratcom.addData(node, {inlineID: id});
}
return {
beginLoad: begin_load,
endLoad: end_load,
addReticle: add_reticle,
clearStage: clear_stage
};
})();
statics.panel = JX.$(data.panelID);
statics.viewport = JX.$(data.viewportID);
select_image(data.selectedID);
load_inline_comments();
if (data.loggedIn && data.commentFormID) {
JX.DOM.invoke(JX.$(data.commentFormID), 'shouldRefresh');
}
redraw_image();
statics.preload = [];
for (var ii = 0; ii < data.images.length; ii++) {
statics.preload.push(data.images[ii].stageURI);
}
preload_next();
}
function install_extra_listeners() {
JX.DOM.listen(statics.panel, 'gesture.swipe.end', null, function(e) {
var data = e.getData();
if (data.length <= (JX.Vector.getDim(statics.panel) / 2)) {
// If the user didn't move their finger far enough, don't switch.
return;
}
switch_image(data.direction == 'right' ? -1 : 1);
});
}
function install_mock_view() {
JX.enableDispatch(document.body, 'mouseenter');
JX.enableDispatch(document.body, 'mouseleave');
JX.Stratcom.listen(
['mouseenter', 'mouseover'],
'mock-panel',
function(e) {
JX.DOM.alterClass(e.getNode('mock-panel'), 'mock-has-cursor', true);
});
JX.Stratcom.listen('mouseleave', 'mock-panel', function(e) {
var node = e.getNode('mock-panel');
if (e.getTarget() == node) {
JX.DOM.alterClass(node, 'mock-has-cursor', false);
}
});
JX.Stratcom.listen(
'click',
'mock-thumbnail',
function(e) {
if (!e.isNormalMouseEvent()) {
return;
}
e.kill();
select_image(e.getNodeData('mock-thumbnail').imageID);
});
JX.Stratcom.listen('mousedown', 'mock-viewport', function(e) {
if (!e.isNormalMouseEvent()) {
return;
}
if (JX.Device.getDevice() != 'desktop') {
return;
}
if (JX.Stratcom.pass()) {
return;
}
if (is_dragging) {
return;
}
e.kill();
if (!active_image.isImage) {
// If this is a PDF or something like that, we eat the event but we
// don't let users add inlines to the thumbnail.
return;
}
is_dragging = true;
drag_begin = get_image_xy(JX.$V(e));
drag_end = drag_begin;
redraw_selection();
});
JX.enableDispatch(document.body, 'mousemove');
JX.Stratcom.listen('mousemove', null, function(e) {
if (!statics.enabled) {
return;
}
if (!is_dragging) {
return;
}
drag_end = get_image_xy(JX.$V(e));
redraw_selection();
});
JX.Stratcom.listen(
'mousedown',
'pholio-inline-ref',
function(e) {
e.kill();
var id = e.getNodeData('pholio-inline-ref').inlineID;
var active_id = active_image.id;
var handler = function(r) {
var inlines = inline_comments[active_id];
for (var ii = 0; ii < inlines.length; ii++) {
if (inlines[ii].id == id) {
if (r.id) {
inlines[ii] = r;
} else {
inlines.splice(ii, 1);
}
break;
}
}
redraw_inlines(active_id);
JX.DOM.invoke(JX.$(statics.commentFormID), 'shouldRefresh');
};
new JX.Workflow('/pholio/inline/' + id + '/')
.setHandler(handler)
.start();
});
JX.Stratcom.listen(
'mouseup',
null,
function(e) {
if (!statics.enabled) {
return;
}
if (!is_dragging) {
return;
}
is_dragging = false;
if (!statics.loggedIn) {
new JX.Workflow(statics.logInLink).start();
return;
}
drag_end = get_image_xy(JX.$V(e));
resize_selection(16);
var data = {
mockID: statics.mockID,
imageID: active_image.id,
startX: Math.min(drag_begin.x, drag_end.x),
startY: Math.min(drag_begin.y, drag_end.y),
endX: Math.max(drag_begin.x, drag_end.x),
endY: Math.max(drag_begin.y, drag_end.y)
};
var handler = function(r) {
if (!inline_comments[active_image.id]) {
inline_comments[active_image.id] = [];
}
inline_comments[active_image.id].push(r);
redraw_inlines(active_image.id);
JX.DOM.invoke(JX.$(statics.commentFormID), 'shouldRefresh');
};
clear_selection();
new JX.Workflow('/pholio/inline/', data)
.setHandler(handler)
.start();
});
JX.Stratcom.listen('resize', null, redraw_image);
/* Keyboard Shortcuts */
new JX.KeyboardShortcut(['j', 'right'], 'Show next image.')
.setHandler(function() {
switch_image(1);
})
.register();
new JX.KeyboardShortcut(['k', 'left'], 'Show previous image.')
.setHandler(function() {
switch_image(-1);
})
.register();
/* Lightbox listeners */
JX.Stratcom.listen('click', 'mock-viewport', function(e) {
if (!e.isNormalMouseEvent()) {
return;
}
if (JX.Device.getDevice() == 'desktop') {
return;
}
lightbox_attach();
e.kill();
});
JX.Stratcom.listen('click', 'pholio-device-lightbox', lightbox_detach);
JX.Stratcom.listen('resize', null, lightbox_resize);
JX.Stratcom.listen(
'quicksand-redraw',
null,
function (e) {
var data = e.getData();
var new_config;
if (!data.newResponse.mockViewConfig) {
statics.enabled = false;
return;
}
if (data.fromServer) {
new_config = data.newResponse.mockViewConfig;
} else {
new_config = statics.mockViewConfigCache[data.newResponseID];
}
update_statics(new_config);
if (data.fromServer) {
install_extra_listeners();
}
});
}
if (!statics.installed) {
var current_page_id = JX.Quicksand.getCurrentPageID();
statics.mockViewConfigCache = {};
statics.mockViewConfigCache[current_page_id] = config;
update_statics(config);
statics.installed = install_mock_view();
install_extra_listeners();
}
});
diff --git a/webroot/rsrc/js/core/Prefab.js b/webroot/rsrc/js/core/Prefab.js
index c5a8ef5e9d..979ad3473b 100644
--- a/webroot/rsrc/js/core/Prefab.js
+++ b/webroot/rsrc/js/core/Prefab.js
@@ -1,315 +1,315 @@
/**
* @provides phabricator-prefab
* @requires javelin-install
* javelin-util
* javelin-dom
* javelin-typeahead
* javelin-tokenizer
* javelin-typeahead-preloaded-source
* javelin-typeahead-ondemand-source
* javelin-dom
* javelin-stratcom
* javelin-util
* @javelin
*/
/**
* Utilities for client-side rendering (the greatest thing in the world).
*/
JX.install('Prefab', {
statics : {
renderSelect : function(map, selected, attrs, order) {
var select = JX.$N('select', attrs || {});
// Callers may optionally pass "order" to force options into a specific
// order. Although most browsers do retain order, maps in Javascript
// aren't technically ordered. Safari, at least, will reorder maps with
// numeric keys.
order = order || JX.keys(map);
var k;
for (var ii = 0; ii < order.length; ii++) {
k = order[ii];
select.options[select.options.length] = new Option(map[k], k);
if (k == selected) {
select.value = k;
}
}
select.value = select.value || order[0];
return select;
},
newTokenizerFromTemplate: function(markup, config) {
var template = JX.$H(markup).getFragment().firstChild;
var container = JX.DOM.find(template, 'div', 'tokenizer-container');
container.id = '';
config.root = container;
var build = JX.Prefab.buildTokenizer(config);
build.node = template;
return build;
},
/**
* Build a Phabricator tokenizer out of a configuration with application
* sorting, datasource and placeholder rules.
*
* - `id` Root tokenizer ID (alternatively, pass `root`).
* - `root` Root tokenizer node (replaces `id`).
* - `src` Datasource URI.
* - `ondemand` Optional, use an ondemand source.
* - `value` Optional, initial value.
* - `limit` Optional, token limit.
* - `placeholder` Optional, placeholder text.
* - `username` Optional, username to sort first (i.e., viewer).
* - `icons` Optional, map of icons.
*
*/
buildTokenizer : function(config) {
config.icons = config.icons || {};
var root;
try {
root = config.root || JX.$(config.id);
} catch (ex) {
// If the root element does not exist, just return without building
// anything. This happens in some cases -- like Conpherence -- where we
// may load a tokenizer but not put it in the document.
return;
}
var datasource;
// Default to an ondemand source if no alternate configuration is
// provided.
var ondemand = true;
if ('ondemand' in config) {
ondemand = config.ondemand;
}
if (ondemand) {
datasource = new JX.TypeaheadOnDemandSource(config.src);
} else {
datasource = new JX.TypeaheadPreloadedSource(config.src);
}
datasource.setSortHandler(
JX.bind(datasource, JX.Prefab.sortHandler, config));
datasource.setTransformer(JX.Prefab.transformDatasourceResults);
var typeahead = new JX.Typeahead(
root,
JX.DOM.find(root, 'input', 'tokenizer-input'));
typeahead.setDatasource(datasource);
var tokenizer = new JX.Tokenizer(root);
tokenizer.setTypeahead(typeahead);
tokenizer.setRenderTokenCallback(function(value, key, container) {
var result;
if (value && (typeof value == 'object') && ('id' in value)) {
// TODO: In this case, we've been passed the decoded wire format
// dictionary directly. Token rendering is kind of a huge mess that
// should be cleaned up and made more consistent. Just force our
// way through for now.
result = value;
} else {
result = datasource.getResult(key);
}
var icon;
var type;
var color;
if (result) {
icon = result.icon;
value = result.displayName;
type = result.tokenType;
color = result.color;
} else {
icon = (config.icons || {})[key];
type = (config.types || {})[key];
color = (config.colors || {})[key];
}
if (icon) {
icon = JX.Prefab._renderIcon(icon);
}
type = type || 'object';
JX.DOM.alterClass(container, 'jx-tokenizer-token-' + type, true);
if (color) {
JX.DOM.alterClass(container, color, true);
}
return [icon, value];
});
if (config.placeholder) {
tokenizer.setPlaceholder(config.placeholder);
}
if (config.limit) {
tokenizer.setLimit(config.limit);
}
if (config.value) {
tokenizer.setInitialValue(config.value);
}
if (config.browseURI) {
tokenizer.setBrowseURI(config.browseURI);
}
if (config.disabled) {
tokenizer.setDisabled(true);
}
JX.Stratcom.addData(root, {'tokenizer' : tokenizer});
return {
tokenizer: tokenizer
};
},
sortHandler: function(config, value, list, cmp) {
// Sort results so that the viewing user always comes up first; after
// that, prefer unixname matches to realname matches.
var priority_hits = {};
var self_hits = {};
// We'll put matches where the user's input is a prefix of the name
- // above mathches where that isn't true.
+ // above matches where that isn't true.
var prefix_hits = {};
var tokens = this.tokenize(value);
var normal = this.normalize(value);
for (var ii = 0; ii < list.length; ii++) {
var item = list[ii];
if (this.normalize(item.name).indexOf(normal) === 0) {
prefix_hits[item.id] = true;
}
if (!item.priority) {
continue;
}
if (config.username && item.priority == config.username) {
self_hits[item.id] = true;
}
}
list.sort(function(u, v) {
if (self_hits[u.id] != self_hits[v.id]) {
return self_hits[v.id] ? 1 : -1;
}
// If one result is open and one is closed, show the open result
- // first. The "!" tricks here are becaused closed values are display
+ // first. The "!" tricks here are because closed values are display
// strings, so the value is either `null` or some truthy string. If
// we compare the values directly, we'll apply this rule to two
// objects which are both closed but for different reasons, like
// "Archived" and "Disabled".
var u_open = !u.closed;
var v_open = !v.closed;
if (u_open != v_open) {
if (u_open) {
return -1;
} else {
return 1;
}
}
if (prefix_hits[u.id] != prefix_hits[v.id]) {
return prefix_hits[v.id] ? 1 : -1;
}
// Sort users ahead of other result types.
if (u.priorityType != v.priorityType) {
if (u.priorityType == 'user') {
return -1;
}
if (v.priorityType == 'user') {
return 1;
}
}
// Sort functions after other result types.
var uf = (u.tokenType == 'function');
var vf = (v.tokenType == 'function');
if (uf != vf) {
return uf ? 1 : -1;
}
return cmp(u, v);
});
},
/**
* Transform results from a wire format into a usable format in a standard
* way.
*/
transformDatasourceResults: function(fields) {
var closed = fields[9];
var closed_ui;
if (closed) {
closed_ui = JX.$N(
'div',
{className: 'tokenizer-closed'},
closed);
}
var icon = fields[8];
var icon_ui;
if (icon) {
icon_ui = JX.Prefab._renderIcon(icon);
}
var display = JX.$N(
'div',
{className: 'tokenizer-result'},
[icon_ui, fields[4] || fields[0], closed_ui]);
if (closed) {
JX.DOM.alterClass(display, 'tokenizer-result-closed', true);
}
return {
name: fields[0],
displayName: fields[4] || fields[0],
display: display,
uri: fields[1],
id: fields[2],
priority: fields[3],
priorityType: fields[7],
imageURI: fields[6],
icon: icon,
closed: closed,
type: fields[5],
sprite: fields[10],
color: fields[11],
tokenType: fields[12],
unique: fields[13] || false,
autocomplete: fields[14],
sort: JX.TypeaheadNormalizer.normalize(fields[0])
};
},
_renderIcon: function(icon) {
return JX.$N(
'span',
{className: 'phui-icon-view phui-font-fa ' + icon});
}
}
});
diff --git a/webroot/rsrc/js/core/behavior-device.js b/webroot/rsrc/js/core/behavior-device.js
index d74939a7e0..d68a46ff4e 100644
--- a/webroot/rsrc/js/core/behavior-device.js
+++ b/webroot/rsrc/js/core/behavior-device.js
@@ -1,83 +1,83 @@
/**
* @provides javelin-behavior-device
* @requires javelin-behavior
* javelin-stratcom
* javelin-dom
* javelin-vector
* javelin-install
*/
JX.install('Device', {
statics : {
_device : null,
_tabletBreakpoint: 920,
setTabletBreakpoint: function(width) {
var self = JX.Device;
self._tabletBreakpoint = width;
self.recalculate();
},
getTabletBreakpoint: function() {
return JX.Device._tabletBreakpoint;
},
recalculate: function() {
var v = JX.Vector.getViewport();
var self = JX.Device;
// Even when we emit a '<meta name="viewport" ... />' tag which tells
- // devices to fit the conent to the screen width, we'll sometimes measure
+ // devices to fit the content to the screen width, we'll sometimes measure
// a viewport dimension which is larger than the available screen width,
// particularly if we check too early.
// If the device provides a screen width and the screen width is smaller
// than the viewport width, use the screen width.
var screen_width = (window.screen && window.screen.availWidth);
if (screen_width) {
v.x = Math.min(v.x, screen_width);
}
var device = 'desktop';
if (v.x <= self._tabletBreakpoint) {
device = 'tablet';
}
if (v.x <= 480) {
device = 'phone';
}
if (device == self._device) {
return;
}
self._device = device;
var e = document.body;
JX.DOM.alterClass(e, 'device-phone', (device == 'phone'));
JX.DOM.alterClass(e, 'device-tablet', (device == 'tablet'));
JX.DOM.alterClass(e, 'device-desktop', (device == 'desktop'));
JX.DOM.alterClass(e, 'device', (device != 'desktop'));
JX.Stratcom.invoke('phabricator-device-change', null, device);
},
isDesktop: function() {
var self = JX.Device;
return (self.getDevice() == 'desktop');
},
getDevice : function() {
var self = JX.Device;
if (self._device === null) {
self.recalculate();
}
return self._device;
}
}
});
JX.behavior('device', function() {
JX.Stratcom.listen('resize', null, JX.Device.recalculate);
JX.Device.recalculate();
});
diff --git a/webroot/rsrc/js/phuix/PHUIXAutocomplete.js b/webroot/rsrc/js/phuix/PHUIXAutocomplete.js
index df4a031377..a7f719dab4 100644
--- a/webroot/rsrc/js/phuix/PHUIXAutocomplete.js
+++ b/webroot/rsrc/js/phuix/PHUIXAutocomplete.js
@@ -1,739 +1,739 @@
/**
* @provides phuix-autocomplete
* @requires javelin-install
* javelin-dom
* phuix-icon-view
* phabricator-prefab
*/
JX.install('PHUIXAutocomplete', {
construct: function() {
this._map = {};
this._datasources = {};
this._listNodes = [];
this._resultMap = {};
},
members: {
_area: null,
_active: false,
_cursorHead: null,
_cursorTail: null,
_pixelHead: null,
_pixelTail: null,
_map: null,
_datasource: null,
_datasources: null,
_value: null,
_node: null,
_echoNode: null,
_listNode: null,
_promptNode: null,
_focus: null,
_focusRef: null,
_listNodes: null,
_x: null,
_y: null,
_visible: false,
_resultMap: null,
setArea: function(area) {
this._area = area;
return this;
},
addAutocomplete: function(code, spec) {
this._map[code] = spec;
return this;
},
start: function() {
var area = this._area;
JX.DOM.listen(area, 'keypress', null, JX.bind(this, this._onkeypress));
JX.DOM.listen(
area,
['click', 'keyup', 'keydown', 'keypress'],
null,
JX.bind(this, this._update));
var select = JX.bind(this, this._onselect);
JX.DOM.listen(this._getNode(), 'mousedown', 'typeahead-result', select);
var device = JX.bind(this, this._ondevice);
JX.Stratcom.listen('phabricator-device-change', null, device);
// When the user clicks away from the textarea, deactivate.
var deactivate = JX.bind(this, this._deactivate);
JX.DOM.listen(area, 'blur', null, deactivate);
},
_getSpec: function() {
return this._map[this._active];
},
_ondevice: function() {
if (JX.Device.getDevice() != 'desktop') {
this._deactivate();
}
},
_activate: function(code) {
if (JX.Device.getDevice() != 'desktop') {
return;
}
if (!this._map[code]) {
return;
}
var area = this._area;
var range = JX.TextAreaUtils.getSelectionRange(area);
// Check the character immediately before the trigger character. We'll
// only activate the typeahead if it's something that we think a user
// might reasonably want to autocomplete after, like a space, newline,
// or open parenthesis. For example, if a user types "alincoln@",
// the prior letter will be the last "n" in "alincoln". They are probably
// typing an email address, not a username, so we don't activate the
// autocomplete.
var head = range.start;
var prior;
if (head > 1) {
prior = area.value.substring(head - 2, head - 1);
} else {
prior = '<start>';
}
switch (prior) {
case '<start>':
case ' ':
case '\n':
case '\t':
case '(': // Might be "(@username, what do you think?)".
case '-': // Might be an unnumbered list.
case '.': // Might be a numbered list.
case '|': // Might be a table cell.
case '>': // Might be a blockquote.
case '!': // Might be a blockquote attribution line.
// We'll let these autocomplete.
break;
default:
// We bail out on anything else, since the user is probably not
// typing a username or project tag.
return;
}
// Get all the text on the current line. If the line only contains
// whitespace, don't activate: the user is probably typing code or a
// numbered list.
var line = area.value.substring(0, head - 1);
line = line.split('\n');
line = line[line.length - 1];
if (line.match(/^\s+$/)) {
return;
}
this._cursorHead = head;
this._cursorTail = range.end;
this._pixelHead = JX.TextAreaUtils.getPixelDimensions(
area,
range.start,
range.end);
var spec = this._map[code];
if (!this._datasources[code]) {
var datasource = new JX.TypeaheadOnDemandSource(spec.datasourceURI);
datasource.listen(
'resultsready',
JX.bind(this, this._onresults, code));
datasource.setTransformer(JX.bind(this, this._transformresult));
datasource.setSortHandler(
JX.bind(datasource, JX.Prefab.sortHandler, {}));
this._datasources[code] = datasource;
}
this._datasource = this._datasources[code];
this._active = code;
var head_icon = new JX.PHUIXIconView()
.setIcon(spec.headerIcon)
.getNode();
var head_text = spec.headerText;
var node = this._getPromptNode();
JX.DOM.setContent(node, [head_icon, head_text]);
},
_transformresult: function(fields) {
var map = JX.Prefab.transformDatasourceResults(fields);
var icon;
if (map.icon) {
icon = new JX.PHUIXIconView()
.setIcon(map.icon)
.getNode();
}
map.display = [icon, map.displayName];
return map;
},
_deactivate: function() {
var node = this._getNode();
JX.DOM.hide(node);
this._active = false;
this._visible = false;
},
_onkeypress: function(e) {
var r = e.getRawEvent();
// NOTE: We allow events to continue with "altKey", because you need
// to press Alt to type characters like "@" on a German keyboard layout.
// The cost of misfiring autocompleters is very small since we do not
// eat the keystroke. See T10252.
if (r.metaKey || (r.ctrlKey && !r.altKey)) {
return;
}
var code = r.charCode;
if (this._map[code]) {
setTimeout(JX.bind(this, this._activate, code), 0);
}
},
_onresults: function(code, nodes, value, partial) {
// Even if these results are out of date, we still want to fill in the
// result map so we can terminate things later.
if (!partial) {
if (!this._resultMap[code]) {
this._resultMap[code] = {};
}
var hits = [];
for (var ii = 0; ii < nodes.length; ii++) {
var result = this._datasources[code].getResult(nodes[ii].rel);
if (!result) {
hits = null;
break;
}
if (!result.autocomplete || !result.autocomplete.length) {
hits = null;
break;
}
hits.push(result.autocomplete);
}
if (hits !== null) {
this._resultMap[code][value] = hits;
}
}
if (code !== this._active) {
return;
}
if (value !== this._value) {
return;
}
if (this._isTerminatedString(value)) {
if (this._hasUnrefinableResults(value)) {
this._deactivate();
return;
}
}
var list = this._getListNode();
JX.DOM.setContent(list, nodes);
this._listNodes = nodes;
var old_ref = this._focusRef;
this._clearFocus();
for (var ii = 0; ii < nodes.length; ii++) {
if (nodes[ii].rel == old_ref) {
this._setFocus(ii);
break;
}
}
if (this._focus === null && nodes.length) {
this._setFocus(0);
}
this._redraw();
},
_setFocus: function(idx) {
if (!this._listNodes[idx]) {
this._clearFocus();
return false;
}
if (this._focus !== null) {
JX.DOM.alterClass(this._listNodes[this._focus], 'focused', false);
}
this._focus = idx;
this._focusRef = this._listNodes[idx].rel;
JX.DOM.alterClass(this._listNodes[idx], 'focused', true);
return true;
},
_changeFocus: function(delta) {
if (this._focus === null) {
return false;
}
return this._setFocus(this._focus + delta);
},
_clearFocus: function() {
this._focus = null;
this._focusRef = null;
},
_onselect: function (e) {
if (!e.isNormalMouseEvent()) {
// Eat right clicks, control clicks, etc., on the results. These can
// not do anything meaningful and if we let them through they'll blur
// the field and dismiss the results.
e.kill();
return;
}
var target = e.getNode('typeahead-result');
for (var ii = 0; ii < this._listNodes.length; ii++) {
if (this._listNodes[ii] === target) {
this._setFocus(ii);
this._autocomplete();
break;
}
}
this._deactivate();
e.kill();
},
_getSuffixes: function() {
return [' ', ':', ',', ')'];
},
_getCancelCharacters: function() {
// The "." character does not cancel because of projects named
// "node.js" or "blog.mycompany.com".
return ['#', '@', ',', '!', '?', '{', '}'];
},
_getTerminators: function() {
return [' ', ':', ',', '.', '!', '?'];
},
_getIgnoreList: function() {
return this._map[this._active].ignore || [];
},
_isTerminatedString: function(string) {
var terminators = this._getTerminators();
for (var ii = 0; ii < terminators.length; ii++) {
var term = terminators[ii];
if (string.substring(string.length - term.length) == term) {
return true;
}
}
return false;
},
_hasUnrefinableResults: function(query) {
if (!this._resultMap[this._active]) {
return false;
}
var map = this._resultMap[this._active];
for (var ii = 1; ii < query.length; ii++) {
var prefix = query.substring(0, ii);
if (map.hasOwnProperty(prefix)) {
var results = map[prefix];
// If any prefix of the query has no results, the full query also
// has no results so we can not refine them.
if (!results.length) {
return true;
}
// If there is exactly one match and the it is a prefix of the query,
// we can safely assume the user just typed out the right result
// from memory and doesn't need to refine it.
if (results.length == 1) {
// Strip the first character off, like a "#" or "@".
var result = results[0].substring(1);
if (query.length >= result.length) {
if (query.substring(0, result.length) === result) {
return true;
}
}
}
}
}
return false;
},
_trim: function(str) {
var suffixes = this._getSuffixes();
for (var ii = 0; ii < suffixes.length; ii++) {
if (str.substring(str.length - suffixes[ii].length) == suffixes[ii]) {
str = str.substring(0, str.length - suffixes[ii].length);
}
}
return str;
},
_update: function(e) {
if (!this._active) {
return;
}
var special = e.getSpecialKey();
// Deactivate if the user types escape.
if (special == 'esc') {
this._deactivate();
e.kill();
return;
}
var area = this._area;
if (e.getType() == 'keydown') {
if (special == 'up' || special == 'down') {
var delta = (special == 'up') ? -1 : +1;
if (!this._changeFocus(delta)) {
this._deactivate();
}
e.kill();
return;
}
}
// Deactivate if the user moves the cursor to the left of the assist
// range. For example, they might press the "left" arrow to move the
// cursor to the left, or click in the textarea prior to the active
// range.
var range = JX.TextAreaUtils.getSelectionRange(area);
if (range.start < this._cursorHead) {
this._deactivate();
return;
}
if (special == 'tab' || special == 'return') {
var r = e.getRawEvent();
if (r.shiftKey && special == 'tab') {
// Don't treat "Shift + Tab" as an autocomplete action. Instead,
// let it through normally so the focus shifts to the previous
// control.
this._deactivate();
return;
}
// If the user hasn't typed any text yet after typing the character
// which can summon the autocomplete, deactivate and let the keystroke
// through. For example, we hit this when a line ends with an
// autocomplete character and the user is trying to type a newline.
if (range.start == this._cursorHead) {
this._deactivate();
return;
}
// If we autocomplete, we're done. Otherwise, just eat the event. This
// happens if you type too fast and try to tab complete before results
// load.
if (this._autocomplete()) {
this._deactivate();
}
e.kill();
return;
}
// Deactivate if the user moves the cursor to the right of the assist
// range. For example, they might click later in the document. If the user
// is pressing the "right" arrow key, they are not allowed to move the
// cursor beyond the existing end of the text range. If they are pressing
// other keys, assume they're typing and allow the tail to move forward
// one character.
var margin;
if (special == 'right') {
margin = 0;
} else {
margin = 1;
}
var tail = this._cursorTail;
if ((range.start > tail + margin) || (range.end > tail + margin)) {
this._deactivate();
return;
}
this._cursorTail = Math.max(this._cursorTail, range.end);
var text = area.value.substring(
this._cursorHead,
this._cursorTail);
this._value = text;
var pixels = JX.TextAreaUtils.getPixelDimensions(
area,
range.start,
range.end);
var x = this._pixelHead.start.x;
var y = Math.max(this._pixelHead.end.y, pixels.end.y) + 24;
// If the first character after the trigger is a space, just deactivate
// immediately. This occurs if a user types a numbered list using "#".
if (text.length && text[0] == ' ') {
this._deactivate();
return;
}
var trim = this._trim(text);
// Deactivate immediately if a user types a character that we are
// reasonably sure means they don't want to use the autocomplete. For
// example, "##" is almost certainly a header or monospaced text, not
// a project autocompletion.
var cancels = this._getCancelCharacters();
for (var ii = 0; ii < cancels.length; ii++) {
if (trim.indexOf(cancels[ii]) !== -1) {
this._deactivate();
return;
}
}
// Deactivate immediately if the user types an ignored token like ":)",
// the smiley face emoticon. Note that we test against "text", not
// "trim", because the ignore list and suffix list can otherwise
// interact destructively.
var ignore = this._getIgnoreList();
for (ii = 0; ii < ignore.length; ii++) {
if (text.indexOf(ignore[ii]) === 0) {
this._deactivate();
return;
}
}
// If the input is terminated by a space or another word-terminating
// punctuation mark, we're going to deactivate if the results can not
- // be refined by addding more words.
+ // be refined by adding more words.
// The idea is that if you type "@alan ab", you're allowed to keep
// editing "ab" until you type a space, period, or other terminator,
// since you might not be sure how to spell someone's last name or the
// second word of a project.
// Once you do terminate a word, if the words you have have entered match
// nothing or match only one exact match, we can safely deactivate and
// assume you're just typing text because further words could never
// refine the result set.
var force;
if (this._isTerminatedString(text)) {
if (this._hasUnrefinableResults(text)) {
this._deactivate();
return;
}
force = true;
} else {
force = false;
}
this._datasource.didChange(trim, force);
this._x = x;
this._y = y;
var hint = trim;
if (hint.length) {
// We only show the autocompleter after the user types at least one
// character. For example, "@" does not trigger it, but "@d" does.
this._visible = true;
} else {
hint = this._getSpec().hintText;
}
var echo = this._getEchoNode();
JX.DOM.setContent(echo, hint);
this._redraw();
},
_redraw: function() {
if (!this._visible) {
return;
}
var node = this._getNode();
JX.DOM.show(node);
var p = new JX.Vector(this._x, this._y);
var s = JX.Vector.getScroll();
var v = JX.Vector.getViewport();
// If the menu would run off the bottom of the screen when showing the
// maximum number of possible choices, put it above instead. We're doing
// this based on the maximum size so the menu doesn't jump up and down
// as results arrive.
var option_height = 30;
var extra_margin = 24;
if ((s.y + v.y) < (p.y + (5 * option_height) + extra_margin)) {
var d = JX.Vector.getDim(node);
p.y = p.y - d.y - 36;
}
p.setPos(node);
},
_autocomplete: function() {
if (this._focus === null) {
return false;
}
var area = this._area;
var head = this._cursorHead;
var tail = this._cursorTail;
var text = area.value;
var ref = this._focusRef;
var result = this._datasource.getResult(ref);
if (!result) {
return false;
}
ref = result.autocomplete;
if (!ref || !ref.length) {
return false;
}
// If the user types a string like "@username:" (with a trailing colon),
// then presses tab or return to pick the completion, don't destroy the
// trailing character.
var suffixes = this._getSuffixes();
var value = this._value;
var found_suffix = false;
for (var ii = 0; ii < suffixes.length; ii++) {
var last = value.substring(value.length - suffixes[ii].length);
if (last == suffixes[ii]) {
ref += suffixes[ii];
found_suffix = true;
break;
}
}
// If we didn't find an existing suffix, add a space.
if (!found_suffix) {
ref = ref + ' ';
}
area.value = text.substring(0, head - 1) + ref + text.substring(tail);
var end = head + ref.length;
JX.TextAreaUtils.setSelectionRange(area, end, end);
return true;
},
_getNode: function() {
if (!this._node) {
var head = this._getHeadNode();
var list = this._getListNode();
this._node = JX.$N(
'div',
{
className: 'phuix-autocomplete',
style: {
display: 'none'
}
},
[head, list]);
JX.DOM.hide(this._node);
document.body.appendChild(this._node);
}
return this._node;
},
_getHeadNode: function() {
if (!this._headNode) {
this._headNode = JX.$N(
'div',
{
className: 'phuix-autocomplete-head'
},
[
this._getPromptNode(),
this._getEchoNode()
]);
}
return this._headNode;
},
_getPromptNode: function() {
if (!this._promptNode) {
this._promptNode = JX.$N(
'span',
{
className: 'phuix-autocomplete-prompt',
});
}
return this._promptNode;
},
_getEchoNode: function() {
if (!this._echoNode) {
this._echoNode = JX.$N(
'span',
{
className: 'phuix-autocomplete-echo'
});
}
return this._echoNode;
},
_getListNode: function() {
if (!this._listNode) {
this._listNode = JX.$N(
'div',
{
className: 'phuix-autocomplete-list'
});
}
return this._listNode;
}
}
});
diff --git a/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js b/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js
index 3f93c1ee41..d9d86bf595 100644
--- a/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js
+++ b/webroot/rsrc/js/phuix/PHUIXDropdownMenu.js
@@ -1,236 +1,236 @@
/**
* @provides phuix-dropdown-menu
* @requires javelin-install
* javelin-util
* javelin-dom
* javelin-vector
* javelin-stratcom
* @javelin
*/
/**
* Basic interaction for a dropdown menu.
*
* The menu is unaware of the content inside it, so it can not close itself
* when an item is selected. Callers must make a call to @{method:close} after
* an item is chosen in order to close the menu.
*/
JX.install('PHUIXDropdownMenu', {
construct : function(node) {
this._node = node;
JX.DOM.listen(
this._node,
'click',
null,
JX.bind(this, this._onclick));
JX.Stratcom.listen(
'mousedown',
null,
JX.bind(this, this._onanyclick));
JX.Stratcom.listen(
'resize',
null,
JX.bind(this, this._adjustposition));
JX.Stratcom.listen('phuix.dropdown.open', null, JX.bind(this, this.close));
JX.Stratcom.listen('keydown', null, JX.bind(this, this._onkey));
JX.DOM.listen(
this._getMenuNode(),
'click',
'tag:a',
JX.bind(this, this._onlink));
},
events: ['open', 'close'],
properties: {
width: null,
align: 'right',
offsetX: 0,
offsetY: 0
},
members: {
_node: null,
_menu: null,
_open: false,
_content: null,
setContent: function(content) {
JX.DOM.setContent(this._getMenuNode(), content);
return this;
},
open: function() {
if (this._open) {
return;
}
this.invoke('open');
JX.Stratcom.invoke('phuix.dropdown.open');
this._open = true;
this._show();
return this;
},
close: function() {
if (!this._open) {
return;
}
this._open = false;
this._hide();
this.invoke('close');
return this;
},
_getMenuNode: function() {
if (!this._menu) {
var attrs = {
className: 'phuix-dropdown-menu',
role: 'button'
};
var menu = JX.$N('div', attrs);
this._menu = menu;
}
return this._menu;
},
_onclick : function(e) {
if (this._open) {
this.close();
} else {
this.open();
}
e.prevent();
},
_onlink: function(e) {
if (!e.isNormalClick()) {
return;
}
// If this action was built dynamically with PHUIXActionView, don't
- // do anything by default. The caller is repsonsible for installing a
+ // do anything by default. The caller is responsible for installing a
// handler if they want to react to clicks.
if (e.getNode('phuix-action-view')) {
return;
}
// If this item opens a submenu, we don't want to close the current
// menu. One submenu is "Edit Related Objects..." on mobile.
var link = e.getNode('tag:a');
if (JX.Stratcom.hasSigil(link, 'keep-open')) {
return;
}
this.close();
},
_onanyclick : function(e) {
if (!this._open) {
return;
}
if (JX.Stratcom.pass(e)) {
return;
}
var t = e.getTarget();
while (t) {
if (t == this._menu || t == this._node) {
return;
}
t = t.parentNode;
}
this.close();
},
_show : function() {
document.body.appendChild(this._menu);
if (this.getWidth()) {
new JX.Vector(this.getWidth(), null).setDim(this._menu);
}
this._adjustposition();
JX.DOM.alterClass(this._node, 'phuix-dropdown-open', true);
this._node.setAttribute('aria-expanded', 'true');
// Try to highlight the first link in the menu for assistive technologies.
var links = JX.DOM.scry(this._menu, 'a');
if (links[0]) {
JX.DOM.focus(links[0]);
}
},
_hide : function() {
JX.DOM.remove(this._menu);
JX.DOM.alterClass(this._node, 'phuix-dropdown-open', false);
this._node.setAttribute('aria-expanded', 'false');
},
_adjustposition : function() {
if (!this._open) {
return;
}
var m = JX.Vector.getDim(this._menu);
var v = JX.$V(this._node);
var d = JX.Vector.getDim(this._node);
switch (this.getAlign()) {
case 'right':
v = v.add(d)
.add(JX.$V(-m.x, 0));
break;
default:
v = v.add(0, d.y);
break;
}
v = v.add(this.getOffsetX(), this.getOffsetY());
v.setPos(this._menu);
},
_onkey: function(e) {
// When the user presses escape with a menu open, close the menu and
// refocus the button which activates the menu. In particular, this makes
// popups more usable with assistive technologies.
if (!this._open) {
return;
}
if (e.getSpecialKey() != 'esc') {
return;
}
this.close();
JX.DOM.focus(this._node);
e.prevent();
}
}
});

File Metadata

Mime Type
text/x-diff
Expires
Thu, May 1, 2:50 AM (1 d, 16 h)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
0f/61/bb6ef937b8390a336b5ecce13b7c
Default Alt Text
(2 MB)

Event Timeline