Page Menu
Home
styx hydra
Search
Configure Global Search
Log In
Files
F83647
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Advanced/Developer...
View Handle
View Hovercard
Size
408 KB
Referenced Files
None
Subscribers
None
View Options
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php
index 65f0aa5e4b..32a248b372 100644
--- a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php
+++ b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php
@@ -1,869 +1,868 @@
<?php
final class PhabricatorDuoAuthFactor
extends PhabricatorAuthFactor {
const PROP_CREDENTIAL = 'duo.credentialPHID';
const PROP_ENROLL = 'duo.enroll';
const PROP_USERNAMES = 'duo.usernames';
const PROP_HOSTNAME = 'duo.hostname';
public function getFactorKey() {
return 'duo';
}
public function getFactorName() {
return pht('Duo Security');
}
public function getFactorShortName() {
return pht('Duo');
}
public function getFactorCreateHelp() {
return pht('Support for Duo push authentication.');
}
public function getFactorDescription() {
return pht(
'When you need to authenticate, a request will be pushed to the '.
'Duo application on your phone.');
}
public function getEnrollDescription(
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $user) {
return pht(
'To add a Duo factor, first download and install the Duo application '.
'on your phone. Once you have launched the application and are ready '.
'to perform setup, click continue.');
}
public function canCreateNewConfiguration(
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $user) {
if ($this->loadConfigurationsForProvider($provider, $user)) {
return false;
}
return true;
}
public function getConfigurationCreateDescription(
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $user) {
$messages = array();
if ($this->loadConfigurationsForProvider($provider, $user)) {
$messages[] = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_WARNING)
->setErrors(
array(
pht(
'You already have Duo authentication attached to your account '.
'for this provider.'),
));
}
return $messages;
}
public function getConfigurationListDetails(
PhabricatorAuthFactorConfig $config,
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $viewer) {
$duo_user = $config->getAuthFactorConfigProperty('duo.username');
return pht('Duo Username: %s', $duo_user);
}
public function newEditEngineFields(
PhabricatorEditEngine $engine,
PhabricatorAuthFactorProvider $provider) {
$viewer = $engine->getViewer();
$credential_phid = $provider->getAuthFactorProviderProperty(
self::PROP_CREDENTIAL);
$hostname = $provider->getAuthFactorProviderProperty(self::PROP_HOSTNAME);
$usernames = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES);
$enroll = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL);
$credential_type = PassphrasePasswordCredentialType::CREDENTIAL_TYPE;
$provides_type = PassphrasePasswordCredentialType::PROVIDES_TYPE;
$credentials = id(new PassphraseCredentialQuery())
->setViewer($viewer)
->withIsDestroyed(false)
->withProvidesTypes(array($provides_type))
->execute();
$xaction_hostname =
PhabricatorAuthFactorProviderDuoHostnameTransaction::TRANSACTIONTYPE;
$xaction_credential =
PhabricatorAuthFactorProviderDuoCredentialTransaction::TRANSACTIONTYPE;
$xaction_usernames =
PhabricatorAuthFactorProviderDuoUsernamesTransaction::TRANSACTIONTYPE;
$xaction_enroll =
PhabricatorAuthFactorProviderDuoEnrollTransaction::TRANSACTIONTYPE;
return array(
id(new PhabricatorTextEditField())
->setLabel(pht('Duo API Hostname'))
->setKey('duo.hostname')
->setValue($hostname)
->setTransactionType($xaction_hostname)
->setIsRequired(true),
id(new PhabricatorCredentialEditField())
->setLabel(pht('Duo API Credential'))
->setKey('duo.credential')
->setValue($credential_phid)
->setTransactionType($xaction_credential)
->setCredentialType($credential_type)
->setCredentials($credentials),
id(new PhabricatorSelectEditField())
->setLabel(pht('Duo Username'))
->setKey('duo.usernames')
->setValue($usernames)
->setTransactionType($xaction_usernames)
->setOptions(
array(
'username' => pht(
'Use %s Username',
PlatformSymbols::getPlatformServerName()),
'email' => pht('Use Primary Email Address'),
)),
id(new PhabricatorSelectEditField())
->setLabel(pht('Create Accounts'))
->setKey('duo.enroll')
->setValue($enroll)
->setTransactionType($xaction_enroll)
->setOptions(
array(
'deny' => pht('Require Existing Duo Account'),
'allow' => pht('Create New Duo Account'),
)),
);
}
public function processAddFactorForm(
PhabricatorAuthFactorProvider $provider,
AphrontFormView $form,
AphrontRequest $request,
PhabricatorUser $user) {
$token = $this->loadMFASyncToken($provider, $request, $form, $user);
if ($this->isAuthResult($token)) {
$form->appendChild($this->newAutomaticControl($token));
return;
}
$enroll = $token->getTemporaryTokenProperty('duo.enroll');
$duo_id = $token->getTemporaryTokenProperty('duo.user-id');
$duo_uri = $token->getTemporaryTokenProperty('duo.uri');
$duo_user = $token->getTemporaryTokenProperty('duo.username');
$is_external = ($enroll === 'external');
$is_auto = ($enroll === 'auto');
$is_blocked = ($enroll === 'blocked');
if (!$token->getIsNewTemporaryToken()) {
if ($is_auto) {
return $this->newDuoConfig($user, $duo_user);
} else if ($is_external || $is_blocked) {
$parameters = array(
'username' => $duo_user,
);
$result = $this->newDuoFuture($provider)
->setMethod('preauth', $parameters)
->resolve();
$result_code = $result['response']['result'];
switch ($result_code) {
case 'auth':
case 'allow':
return $this->newDuoConfig($user, $duo_user);
case 'enroll':
if ($is_blocked) {
// We'll render an equivalent static control below, so skip
// rendering here. We explicitly don't want to give the user
// an enroll workflow.
break;
}
$duo_uri = $result['response']['enroll_portal_url'];
$waiting_icon = id(new PHUIIconView())
->setIcon('fa-mobile', 'red');
$waiting_control = id(new PHUIFormTimerControl())
->setIcon($waiting_icon)
->setError(pht('Not Complete'))
->appendChild(
pht(
'You have not completed Duo enrollment yet. '.
'Complete enrollment, then click continue.'));
$form->appendControl($waiting_control);
break;
default:
case 'deny':
break;
}
} else {
$parameters = array(
'user_id' => $duo_id,
'activation_code' => $duo_uri,
);
$future = $this->newDuoFuture($provider)
->setMethod('enroll_status', $parameters);
$result = $future->resolve();
$response = $result['response'];
switch ($response) {
case 'success':
return $this->newDuoConfig($user, $duo_user);
case 'waiting':
$waiting_icon = id(new PHUIIconView())
->setIcon('fa-mobile', 'red');
$waiting_control = id(new PHUIFormTimerControl())
->setIcon($waiting_icon)
->setError(pht('Not Complete'))
->appendChild(
pht(
'You have not activated this enrollment in the Duo '.
'application on your phone yet. Complete activation, then '.
'click continue.'));
$form->appendControl($waiting_control);
break;
case 'invalid':
default:
throw new Exception(
pht(
'This Duo enrollment attempt is invalid or has '.
'expired ("%s"). Cancel the workflow and try again.',
$response));
}
}
}
if ($is_blocked) {
$blocked_icon = id(new PHUIIconView())
->setIcon('fa-times', 'red');
$blocked_control = id(new PHUIFormTimerControl())
->setIcon($blocked_icon)
->appendChild(
pht(
'Your Duo account ("%s") has not completed Duo enrollment. '.
'Check your email and complete enrollment to continue.',
phutil_tag('strong', array(), $duo_user)));
$form->appendControl($blocked_control);
} else if ($is_auto) {
$auto_icon = id(new PHUIIconView())
->setIcon('fa-check', 'green');
$auto_control = id(new PHUIFormTimerControl())
->setIcon($auto_icon)
->appendChild(
pht(
'Duo account ("%s") is fully enrolled.',
phutil_tag('strong', array(), $duo_user)));
$form->appendControl($auto_control);
} else {
$duo_button = phutil_tag(
'a',
array(
'href' => $duo_uri,
'class' => 'button button-grey',
'target' => ($is_external ? '_blank' : null),
),
pht('Enroll Duo Account: %s', $duo_user));
$duo_button = phutil_tag(
'div',
array(
'class' => 'mfa-form-enroll-button',
),
$duo_button);
if ($is_external) {
$form->appendRemarkupInstructions(
pht(
'Complete enrolling your phone with Duo:'));
$form->appendControl(
id(new AphrontFormMarkupControl())
->setValue($duo_button));
} else {
$form->appendRemarkupInstructions(
pht(
'Scan this QR code with the Duo application on your mobile '.
'phone:'));
$qr_code = $this->newQRCode($duo_uri);
$form->appendChild($qr_code);
$form->appendRemarkupInstructions(
pht(
'If you are currently using your phone to view this page, '.
'click this button to open the Duo application:'));
$form->appendControl(
id(new AphrontFormMarkupControl())
->setValue($duo_button));
}
$form->appendRemarkupInstructions(
pht(
'Once you have completed setup on your phone, click continue.'));
}
}
protected function newMFASyncTokenProperties(
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $user) {
$duo_user = $this->getDuoUsername($provider, $user);
// Duo automatically normalizes usernames to lowercase. Just do that here
// so that our value agrees more closely with Duo.
$duo_user = phutil_utf8_strtolower($duo_user);
$parameters = array(
'username' => $duo_user,
);
$result = $this->newDuoFuture($provider)
->setMethod('preauth', $parameters)
->resolve();
$external_uri = null;
$result_code = $result['response']['result'];
$status_message = $result['response']['status_msg'];
switch ($result_code) {
case 'auth':
case 'allow':
// If the user already has a Duo account, they don't need to do
// anything.
return array(
'duo.enroll' => 'auto',
'duo.username' => $duo_user,
);
case 'enroll':
if (!$this->shouldAllowDuoEnrollment($provider)) {
return array(
'duo.enroll' => 'blocked',
'duo.username' => $duo_user,
);
}
$external_uri = $result['response']['enroll_portal_url'];
// Otherwise, enrollment is permitted so we're going to continue.
break;
default:
case 'deny':
return $this->newResult()
->setIsError(true)
->setErrorMessage(
pht(
'Your Duo account ("%s") is not permitted to access this '.
'system. Contact your Duo administrator for help. '.
'The Duo preauth API responded with status message ("%s"): %s',
$duo_user,
$result_code,
$status_message));
}
// Duo's "/enroll" API isn't repeatable for the same username. If we're
// the first call, great: we can do inline enrollment, which is way more
// user friendly. Otherwise, we have to send the user on an adventure.
$parameters = array(
'username' => $duo_user,
'valid_secs' => phutil_units('1 hour in seconds'),
);
try {
$result = $this->newDuoFuture($provider)
->setMethod('enroll', $parameters)
->resolve();
} catch (HTTPFutureHTTPResponseStatus $ex) {
return array(
'duo.enroll' => 'external',
'duo.username' => $duo_user,
'duo.uri' => $external_uri,
);
}
return array(
'duo.enroll' => 'inline',
'duo.uri' => $result['response']['activation_code'],
'duo.username' => $duo_user,
'duo.user-id' => $result['response']['user_id'],
);
}
protected function newIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
array $challenges) {
// If we already issued a valid challenge for this workflow and session,
// don't issue a new one.
$challenge = $this->getChallengeForCurrentContext(
$config,
$viewer,
$challenges);
if ($challenge) {
return array();
}
if (!$this->hasCSRF($config)) {
return $this->newResult()
->setIsContinue(true)
->setErrorMessage(
pht(
'An authorization request will be pushed to the Duo '.
'application on your phone.'));
}
$provider = $config->getFactorProvider();
// Otherwise, issue a new challenge.
$duo_user = (string)$config->getAuthFactorConfigProperty('duo.username');
$parameters = array(
'username' => $duo_user,
);
$response = $this->newDuoFuture($provider)
->setMethod('preauth', $parameters)
->resolve();
$response = $response['response'];
$next_step = $response['result'];
$status_message = $response['status_msg'];
switch ($next_step) {
case 'auth':
// We're good to go.
break;
case 'allow':
// Duo is telling us to bypass MFA. For now, refuse.
return $this->newResult()
->setIsError(true)
->setErrorMessage(
pht(
'Duo is not requiring a challenge, which defeats the '.
'purpose of MFA. Duo must be configured to challenge you.'));
case 'enroll':
return $this->newResult()
->setIsError(true)
->setErrorMessage(
pht(
'Your Duo account ("%s") requires enrollment. Contact your '.
'Duo administrator for help. Duo status message: %s',
$duo_user,
$status_message));
case 'deny':
default:
return $this->newResult()
->setIsError(true)
->setErrorMessage(
pht(
'Your Duo account ("%s") is not permitted to access this '.
'system. Contact your Duo administrator for help. The Duo '.
'preauth API responded with status message ("%s"): %s',
$duo_user,
$next_step,
$status_message));
}
$has_push = false;
$devices = $response['devices'];
foreach ($devices as $device) {
$capabilities = array_fuse($device['capabilities']);
if (isset($capabilities['push'])) {
$has_push = true;
break;
}
}
if (!$has_push) {
return $this->newResult()
->setIsError(true)
->setErrorMessage(
pht(
'This factor has been removed from your device, so this server '.
'can not send you a challenge. To continue, an administrator '.
'must strip this factor from your account.'));
}
$push_info = array(
pht('Domain') => $this->getInstallDisplayName(),
);
$push_info = phutil_build_http_querystring($push_info);
$parameters = array(
'username' => $duo_user,
'factor' => 'push',
'async' => '1',
// Duo allows us to specify a device, or to pass "auto" to have it pick
// the first one. For now, just let it pick.
'device' => 'auto',
// This is a hard-coded prefix for the word "... request" in the Duo UI,
// which defaults to "Login". We could pass richer information from
// workflows here, but it's not very flexible anyway.
'type' => 'Authentication',
'display_username' => $viewer->getUsername(),
'pushinfo' => $push_info,
);
$result = $this->newDuoFuture($provider)
->setMethod('auth', $parameters)
->resolve();
$duo_xaction = $result['response']['txid'];
// The Duo push timeout is 60 seconds. Set our challenge to expire slightly
// more quickly so that we'll re-issue a new challenge before Duo times out.
// This should keep users away from a dead-end where they can't respond to
// Duo but we won't issue a new challenge yet.
$ttl_seconds = 55;
return array(
$this->newChallenge($config, $viewer)
->setChallengeKey($duo_xaction)
->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),
);
}
protected function newResultFromIssuedChallenges(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
array $challenges) {
$challenge = $this->getChallengeForCurrentContext(
$config,
$viewer,
$challenges);
if ($challenge->getIsAnsweredChallenge()) {
return $this->newResult()
->setAnsweredChallenge($challenge);
}
$provider = $config->getFactorProvider();
$duo_xaction = $challenge->getChallengeKey();
$parameters = array(
'txid' => $duo_xaction,
);
// This endpoint always long-polls, so use a timeout to force it to act
// more asynchronously.
try {
$result = $this->newDuoFuture($provider)
->setHTTPMethod('GET')
->setMethod('auth_status', $parameters)
->setTimeout(3)
->resolve();
$state = $result['response']['result'];
$status = $result['response']['status'];
} catch (HTTPFutureCURLResponseStatus $exception) {
if ($exception->isTimeout()) {
$state = 'waiting';
$status = 'poll';
} else {
throw $exception;
}
}
$now = PhabricatorTime::getNow();
switch ($state) {
case 'allow':
$ttl = PhabricatorTime::getNow()
+ phutil_units('15 minutes in seconds');
$challenge
->markChallengeAsAnswered($ttl);
return $this->newResult()
->setAnsweredChallenge($challenge);
case 'waiting':
// If we didn't just issue this challenge, give the user a stronger
// hint that they need to follow the instructions.
if (!$challenge->getIsNewChallenge()) {
return $this->newResult()
->setIsContinue(true)
->setIcon(
id(new PHUIIconView())
->setIcon('fa-exclamation-triangle', 'yellow'))
->setErrorMessage(
pht(
'You must approve the challenge which was sent to your '.
'phone. Open the Duo application and confirm the challenge, '.
'then continue.'));
}
// Otherwise, we'll construct a default message later on.
break;
default:
case 'deny':
if ($status === 'timeout') {
return $this->newResult()
->setIsError(true)
->setErrorMessage(
pht(
'This request has timed out because you took too long to '.
'respond.'));
} else {
$wait_duration = ($challenge->getChallengeTTL() - $now) + 1;
return $this->newResult()
->setIsWait(true)
->setErrorMessage(
pht(
'You denied this request. Wait %s second(s) to try again.',
new PhutilNumber($wait_duration)));
}
- break;
}
return null;
}
public function renderValidateFactorForm(
PhabricatorAuthFactorConfig $config,
AphrontFormView $form,
PhabricatorUser $viewer,
PhabricatorAuthFactorResult $result) {
$control = $this->newAutomaticControl($result);
$control
->setLabel(pht('Duo'))
->setCaption(pht('Factor Name: %s', $config->getFactorName()));
$form->appendChild($control);
}
public function getRequestHasChallengeResponse(
PhabricatorAuthFactorConfig $config,
AphrontRequest $request) {
return false;
}
protected function newResultFromChallengeResponse(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request,
array $challenges) {
return $this->getResultForPrompt(
$config,
$viewer,
$request,
$challenges);
}
protected function newResultForPrompt(
PhabricatorAuthFactorConfig $config,
PhabricatorUser $viewer,
AphrontRequest $request,
array $challenges) {
$result = $this->newResult()
->setIsContinue(true)
->setErrorMessage(
pht(
'A challenge has been sent to your phone. Open the Duo '.
'application and confirm the challenge, then continue.'));
$challenge = $this->getChallengeForCurrentContext(
$config,
$viewer,
$challenges);
if ($challenge) {
$result
->setStatusChallenge($challenge)
->setIcon(
id(new PHUIIconView())
->setIcon('fa-refresh', 'green ph-spin'));
}
return $result;
}
private function newDuoFuture(PhabricatorAuthFactorProvider $provider) {
$credential_phid = $provider->getAuthFactorProviderProperty(
self::PROP_CREDENTIAL);
$omnipotent = PhabricatorUser::getOmnipotentUser();
$credential = id(new PassphraseCredentialQuery())
->setViewer($omnipotent)
->withPHIDs(array($credential_phid))
->needSecrets(true)
->executeOne();
if (!$credential) {
throw new Exception(
pht(
'Unable to load Duo API credential ("%s").',
$credential_phid));
}
$duo_key = $credential->getUsername();
$duo_secret = $credential->getSecret();
if (!$duo_secret) {
throw new Exception(
pht(
'Duo API credential ("%s") has no secret key.',
$credential_phid));
}
$duo_host = $provider->getAuthFactorProviderProperty(
self::PROP_HOSTNAME);
self::requireDuoAPIHostname($duo_host);
return id(new PhabricatorDuoFuture())
->setIntegrationKey($duo_key)
->setSecretKey($duo_secret)
->setAPIHostname($duo_host)
->setTimeout(10)
->setHTTPMethod('POST');
}
private function getDuoUsername(
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $user) {
$mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES);
switch ($mode) {
case 'username':
return $user->getUsername();
case 'email':
return $user->loadPrimaryEmailAddress();
default:
throw new Exception(
pht(
'Duo username pairing mode ("%s") is not supported.',
$mode));
}
}
private function shouldAllowDuoEnrollment(
PhabricatorAuthFactorProvider $provider) {
$mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL);
switch ($mode) {
case 'deny':
return false;
case 'allow':
return true;
default:
throw new Exception(
pht(
'Duo enrollment mode ("%s") is not supported.',
$mode));
}
}
private function newDuoConfig(PhabricatorUser $user, $duo_user) {
$config_properties = array(
'duo.username' => $duo_user,
);
$config = $this->newConfigForUser($user)
->setFactorName(pht('Duo (%s)', $duo_user))
->setProperties($config_properties);
return $config;
}
public static function requireDuoAPIHostname($hostname) {
if (preg_match('/\.duosecurity\.com\z/', $hostname)) {
return;
}
throw new Exception(
pht(
'Duo API hostname ("%s") is invalid, hostname must be '.
'"*.duosecurity.com".',
$hostname));
}
public function newChallengeStatusView(
PhabricatorAuthFactorConfig $config,
PhabricatorAuthFactorProvider $provider,
PhabricatorUser $viewer,
PhabricatorAuthChallenge $challenge) {
$duo_xaction = $challenge->getChallengeKey();
$parameters = array(
'txid' => $duo_xaction,
);
$default_result = id(new PhabricatorAuthChallengeUpdate())
->setRetry(true);
try {
$result = $this->newDuoFuture($provider)
->setHTTPMethod('GET')
->setMethod('auth_status', $parameters)
->setTimeout(5)
->resolve();
$state = $result['response']['result'];
} catch (HTTPFutureCURLResponseStatus $exception) {
// If we failed or timed out, retry. Usually, this is a timeout.
return id(new PhabricatorAuthChallengeUpdate())
->setRetry(true);
}
// For now, don't update the view for anything but an "Allow". Updates
// here are just about providing more visual feedback for user convenience.
if ($state !== 'allow') {
return id(new PhabricatorAuthChallengeUpdate())
->setRetry(false);
}
$icon = id(new PHUIIconView())
->setIcon('fa-check-circle-o', 'green');
$view = id(new PHUIFormTimerControl())
->setIcon($icon)
->appendChild(pht('You responded to this challenge correctly.'))
->newTimerView();
return id(new PhabricatorAuthChallengeUpdate())
->setState('allow')
->setRetry(false)
->setMarkup($view);
}
}
diff --git a/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php b/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php
index 92bb4883bc..39f9fdbc25 100644
--- a/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php
+++ b/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php
@@ -1,167 +1,159 @@
<?php
final class PhabricatorAuthProviderConfigTransaction
extends PhabricatorApplicationTransaction {
const TYPE_ENABLE = 'config:enable';
const TYPE_LOGIN = 'config:login';
const TYPE_REGISTRATION = 'config:registration';
const TYPE_LINK = 'config:link';
const TYPE_UNLINK = 'config:unlink';
const TYPE_TRUST_EMAILS = 'config:trustEmails';
const TYPE_AUTO_LOGIN = 'config:autoLogin';
const TYPE_PROPERTY = 'config:property';
const PROPERTY_KEY = 'auth:property';
public function getProvider() {
return $this->getObject()->getProvider();
}
public function getApplicationName() {
return 'auth';
}
public function getApplicationTransactionType() {
return PhabricatorAuthAuthProviderPHIDType::TYPECONST;
}
public function getIcon() {
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_ENABLE:
if ($new) {
return 'fa-check';
} else {
return 'fa-ban';
}
}
return parent::getIcon();
}
public function getColor() {
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_ENABLE:
if ($new) {
return 'green';
} else {
return 'indigo';
}
}
return parent::getColor();
}
public function getTitle() {
$author_phid = $this->getAuthorPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_ENABLE:
if ($old === null) {
return pht(
'%s created this provider.',
$this->renderHandleLink($author_phid));
} else if ($new) {
return pht(
'%s enabled this provider.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s disabled this provider.',
$this->renderHandleLink($author_phid));
}
- break;
case self::TYPE_LOGIN:
if ($new) {
return pht(
'%s enabled login.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s disabled login.',
$this->renderHandleLink($author_phid));
}
- break;
case self::TYPE_REGISTRATION:
if ($new) {
return pht(
'%s enabled registration.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s disabled registration.',
$this->renderHandleLink($author_phid));
}
- break;
case self::TYPE_LINK:
if ($new) {
return pht(
'%s enabled account linking.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s disabled account linking.',
$this->renderHandleLink($author_phid));
}
- break;
case self::TYPE_UNLINK:
if ($new) {
return pht(
'%s enabled account unlinking.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s disabled account unlinking.',
$this->renderHandleLink($author_phid));
}
- break;
case self::TYPE_TRUST_EMAILS:
if ($new) {
return pht(
'%s enabled email trust.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s disabled email trust.',
$this->renderHandleLink($author_phid));
}
- break;
case self::TYPE_AUTO_LOGIN:
if ($new) {
return pht(
'%s enabled auto login.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s disabled auto login.',
$this->renderHandleLink($author_phid));
}
- break;
case self::TYPE_PROPERTY:
$provider = $this->getProvider();
if ($provider) {
$title = $provider->renderConfigPropertyTransactionTitle($this);
if (phutil_nonempty_stringlike($title)) {
return $title;
}
}
return pht(
'%s edited a property of this provider.',
$this->renderHandleLink($author_phid));
- break;
}
return parent::getTitle();
}
}
diff --git a/src/applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php b/src/applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php
index 504f7d8e9e..6422b59e16 100644
--- a/src/applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php
+++ b/src/applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php
@@ -1,1820 +1,1819 @@
<?php
final class PhutilCalendarRecurrenceRule
extends PhutilCalendarRecurrenceSource {
private $startDateTime;
private $frequency;
private $frequencyScale;
private $interval = 1;
private $bySecond = array();
private $byMinute = array();
private $byHour = array();
private $byDay = array();
private $byMonthDay = array();
private $byYearDay = array();
private $byWeekNumber = array();
private $byMonth = array();
private $bySetPosition = array();
private $weekStart = self::WEEKDAY_MONDAY;
private $count;
private $until;
private $cursorSecond;
private $cursorMinute;
private $cursorHour;
private $cursorHourState;
private $cursorWeek;
private $cursorWeekday;
private $cursorWeekState;
private $cursorDay;
private $cursorDayState;
private $cursorMonth;
private $cursorYear;
private $setSeconds;
private $setMinutes;
private $setHours;
private $setDays;
private $setMonths;
private $setWeeks;
private $setYears;
private $stateSecond;
private $stateMinute;
private $stateHour;
private $stateDay;
private $stateWeek;
private $stateMonth;
private $stateYear;
private $baseYear;
private $isAllDay;
private $activeSet = array();
private $nextSet = array();
private $minimumEpoch;
const FREQUENCY_SECONDLY = 'SECONDLY';
const FREQUENCY_MINUTELY = 'MINUTELY';
const FREQUENCY_HOURLY = 'HOURLY';
const FREQUENCY_DAILY = 'DAILY';
const FREQUENCY_WEEKLY = 'WEEKLY';
const FREQUENCY_MONTHLY = 'MONTHLY';
const FREQUENCY_YEARLY = 'YEARLY';
const SCALE_SECONDLY = 1;
const SCALE_MINUTELY = 2;
const SCALE_HOURLY = 3;
const SCALE_DAILY = 4;
const SCALE_WEEKLY = 5;
const SCALE_MONTHLY = 6;
const SCALE_YEARLY = 7;
const WEEKDAY_SUNDAY = 'SU';
const WEEKDAY_MONDAY = 'MO';
const WEEKDAY_TUESDAY = 'TU';
const WEEKDAY_WEDNESDAY = 'WE';
const WEEKDAY_THURSDAY = 'TH';
const WEEKDAY_FRIDAY = 'FR';
const WEEKDAY_SATURDAY = 'SA';
const WEEKINDEX_SUNDAY = 0;
const WEEKINDEX_MONDAY = 1;
const WEEKINDEX_TUESDAY = 2;
const WEEKINDEX_WEDNESDAY = 3;
const WEEKINDEX_THURSDAY = 4;
const WEEKINDEX_FRIDAY = 5;
const WEEKINDEX_SATURDAY = 6;
public function toDictionary() {
$parts = array();
$parts['FREQ'] = $this->getFrequency();
$interval = $this->getInterval();
if ($interval != 1) {
$parts['INTERVAL'] = $interval;
}
$by_second = $this->getBySecond();
if ($by_second) {
$parts['BYSECOND'] = $by_second;
}
$by_minute = $this->getByMinute();
if ($by_minute) {
$parts['BYMINUTE'] = $by_minute;
}
$by_hour = $this->getByHour();
if ($by_hour) {
$parts['BYHOUR'] = $by_hour;
}
$by_day = $this->getByDay();
if ($by_day) {
$parts['BYDAY'] = $by_day;
}
$by_month = $this->getByMonth();
if ($by_month) {
$parts['BYMONTH'] = $by_month;
}
$by_monthday = $this->getByMonthDay();
if ($by_monthday) {
$parts['BYMONTHDAY'] = $by_monthday;
}
$by_yearday = $this->getByYearDay();
if ($by_yearday) {
$parts['BYYEARDAY'] = $by_yearday;
}
$by_weekno = $this->getByWeekNumber();
if ($by_weekno) {
$parts['BYWEEKNO'] = $by_weekno;
}
$by_setpos = $this->getBySetPosition();
if ($by_setpos) {
$parts['BYSETPOS'] = $by_setpos;
}
$wkst = $this->getWeekStart();
if ($wkst != self::WEEKDAY_MONDAY) {
$parts['WKST'] = $wkst;
}
$count = $this->getCount();
if ($count) {
$parts['COUNT'] = $count;
}
$until = $this->getUntil();
if ($until) {
$parts['UNTIL'] = $until->getISO8601();
}
return $parts;
}
public static function newFromDictionary(array $dict) {
static $expect;
if ($expect === null) {
$expect = array_fuse(
array(
'FREQ',
'INTERVAL',
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYDAY',
'BYMONTH',
'BYMONTHDAY',
'BYYEARDAY',
'BYWEEKNO',
'BYSETPOS',
'WKST',
'UNTIL',
'COUNT',
));
}
foreach ($dict as $key => $value) {
if (empty($expect[$key])) {
throw new Exception(
pht(
'RRULE dictionary includes unknown key "%s". Expected keys '.
'are: %s.',
$key,
implode(', ', array_keys($expect))));
}
}
$rrule = id(new self())
->setFrequency(idx($dict, 'FREQ'))
->setInterval(idx($dict, 'INTERVAL', 1))
->setBySecond(idx($dict, 'BYSECOND', array()))
->setByMinute(idx($dict, 'BYMINUTE', array()))
->setByHour(idx($dict, 'BYHOUR', array()))
->setByDay(idx($dict, 'BYDAY', array()))
->setByMonth(idx($dict, 'BYMONTH', array()))
->setByMonthDay(idx($dict, 'BYMONTHDAY', array()))
->setByYearDay(idx($dict, 'BYYEARDAY', array()))
->setByWeekNumber(idx($dict, 'BYWEEKNO', array()))
->setBySetPosition(idx($dict, 'BYSETPOS', array()))
->setWeekStart(idx($dict, 'WKST', self::WEEKDAY_MONDAY));
$count = idx($dict, 'COUNT');
if ($count) {
$rrule->setCount($count);
}
$until = idx($dict, 'UNTIL');
if ($until) {
$until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until);
$rrule->setUntil($until);
}
return $rrule;
}
public function toRRULE() {
$dict = $this->toDictionary();
$parts = array();
foreach ($dict as $key => $value) {
if (is_array($value)) {
$value = implode(',', $value);
}
$parts[] = "{$key}={$value}";
}
return implode(';', $parts);
}
public static function newFromRRULE($rrule) {
$parts = explode(';', $rrule);
$dict = array();
foreach ($parts as $part) {
list($key, $value) = explode('=', $part, 2);
switch ($key) {
case 'FREQ':
case 'INTERVAL':
case 'WKST':
case 'COUNT':
case 'UNTIL';
break;
default:
$value = explode(',', $value);
break;
}
$dict[$key] = $value;
}
$int_lists = array_fuse(
array(
// NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE".
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYMONTH',
'BYMONTHDAY',
'BYYEARDAY',
'BYWEEKNO',
'BYSETPOS',
));
$int_values = array_fuse(
array(
'COUNT',
'INTERVAL',
));
foreach ($dict as $key => $value) {
if (isset($int_values[$key])) {
// None of these values may be negative.
if (!preg_match('/^\d+\z/', $value)) {
throw new Exception(
pht(
'Unexpected value "%s" in "%s" RULE property: expected an '.
'integer.',
$value,
$key));
}
$dict[$key] = (int)$value;
}
if (isset($int_lists[$key])) {
foreach ($value as $k => $v) {
if (!preg_match('/^-?\d+\z/', $v)) {
throw new Exception(
pht(
'Unexpected value "%s" in "%s" RRULE property: expected '.
'only integers.',
$v,
$key));
}
$value[$k] = (int)$v;
}
$dict[$key] = $value;
}
}
return self::newFromDictionary($dict);
}
private static function getAllWeekdayConstants() {
return array_keys(self::getWeekdayIndexMap());
}
private static function getWeekdayIndexMap() {
static $map = array(
self::WEEKDAY_SUNDAY => self::WEEKINDEX_SUNDAY,
self::WEEKDAY_MONDAY => self::WEEKINDEX_MONDAY,
self::WEEKDAY_TUESDAY => self::WEEKINDEX_TUESDAY,
self::WEEKDAY_WEDNESDAY => self::WEEKINDEX_WEDNESDAY,
self::WEEKDAY_THURSDAY => self::WEEKINDEX_THURSDAY,
self::WEEKDAY_FRIDAY => self::WEEKINDEX_FRIDAY,
self::WEEKDAY_SATURDAY => self::WEEKINDEX_SATURDAY,
);
return $map;
}
private static function getWeekdayIndex($weekday) {
$map = self::getWeekdayIndexMap();
if (!isset($map[$weekday])) {
$constants = array_keys($map);
throw new Exception(
pht(
'Weekday "%s" is not a valid weekday constant. Valid constants '.
'are: %s.',
$weekday,
implode(', ', $constants)));
}
return $map[$weekday];
}
public function setStartDateTime(PhutilCalendarDateTime $start) {
$this->startDateTime = $start;
return $this;
}
public function getStartDateTime() {
return $this->startDateTime;
}
public function setCount($count) {
if ($count < 1) {
throw new Exception(
pht(
'RRULE COUNT value "%s" is invalid: count must be at least 1.',
$count));
}
$this->count = $count;
return $this;
}
public function getCount() {
return $this->count;
}
public function setUntil(PhutilCalendarDateTime $until) {
$this->until = $until;
return $this;
}
public function getUntil() {
return $this->until;
}
public function setFrequency($frequency) {
static $map = array(
self::FREQUENCY_SECONDLY => self::SCALE_SECONDLY,
self::FREQUENCY_MINUTELY => self::SCALE_MINUTELY,
self::FREQUENCY_HOURLY => self::SCALE_HOURLY,
self::FREQUENCY_DAILY => self::SCALE_DAILY,
self::FREQUENCY_WEEKLY => self::SCALE_WEEKLY,
self::FREQUENCY_MONTHLY => self::SCALE_MONTHLY,
self::FREQUENCY_YEARLY => self::SCALE_YEARLY,
);
if (empty($map[$frequency])) {
throw new Exception(
pht(
'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.',
$frequency,
implode(', ', array_keys($map))));
}
$this->frequency = $frequency;
$this->frequencyScale = $map[$frequency];
return $this;
}
public function getFrequency() {
return $this->frequency;
}
public function getFrequencyScale() {
return $this->frequencyScale;
}
public function setInterval($interval) {
if (!is_int($interval)) {
throw new Exception(
pht(
'RRULE INTERVAL "%s" is invalid: interval must be an integer.',
$interval));
}
if ($interval < 1) {
throw new Exception(
pht(
'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',
$interval));
}
$this->interval = $interval;
return $this;
}
public function getInterval() {
return $this->interval;
}
public function setBySecond(array $by_second) {
$this->assertByRange('BYSECOND', $by_second, 0, 60);
$this->bySecond = array_fuse($by_second);
return $this;
}
public function getBySecond() {
return $this->bySecond;
}
public function setByMinute(array $by_minute) {
$this->assertByRange('BYMINUTE', $by_minute, 0, 59);
$this->byMinute = array_fuse($by_minute);
return $this;
}
public function getByMinute() {
return $this->byMinute;
}
public function setByHour(array $by_hour) {
$this->assertByRange('BYHOUR', $by_hour, 0, 23);
$this->byHour = array_fuse($by_hour);
return $this;
}
public function getByHour() {
return $this->byHour;
}
public function setByDay(array $by_day) {
$constants = self::getAllWeekdayConstants();
$constants = implode('|', $constants);
$pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/';
foreach ($by_day as $key => $value) {
$matches = null;
if (!preg_match($pattern, $value, $matches)) {
throw new Exception(
pht(
'RRULE BYDAY value "%s" is invalid: rule part must be in the '.
'expected form (like "MO", "-3TH", or "+2SU").',
$value));
}
// The maximum allowed value is 53, which corresponds to "the 53rd
// Monday every year" or similar when evaluated against a YEARLY rule.
$maximum = 53;
$magnitude = (int)$matches[1];
if ($magnitude > $maximum) {
throw new Exception(
pht(
'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '.
'the maximum permitted value is "%s".',
$value,
$magnitude,
$maximum));
}
// Normalize "+3FR" into "3FR".
$by_day[$key] = ltrim($value, '+');
}
$this->byDay = array_fuse($by_day);
return $this;
}
public function getByDay() {
return $this->byDay;
}
public function setByMonthDay(array $by_month_day) {
$this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false);
$this->byMonthDay = array_fuse($by_month_day);
return $this;
}
public function getByMonthDay() {
return $this->byMonthDay;
}
public function setByYearDay($by_year_day) {
$this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false);
$this->byYearDay = array_fuse($by_year_day);
return $this;
}
public function getByYearDay() {
return $this->byYearDay;
}
public function setByMonth(array $by_month) {
$this->assertByRange('BYMONTH', $by_month, 1, 12);
$this->byMonth = array_fuse($by_month);
return $this;
}
public function getByMonth() {
return $this->byMonth;
}
public function setByWeekNumber(array $by_week_number) {
$this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false);
$this->byWeekNumber = array_fuse($by_week_number);
return $this;
}
public function getByWeekNumber() {
return $this->byWeekNumber;
}
public function setBySetPosition(array $by_set_position) {
$this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false);
$this->bySetPosition = $by_set_position;
return $this;
}
public function getBySetPosition() {
return $this->bySetPosition;
}
public function setWeekStart($week_start) {
// Make sure this is a valid weekday constant.
self::getWeekdayIndex($week_start);
$this->weekStart = $week_start;
return $this;
}
public function getWeekStart() {
return $this->weekStart;
}
public function resetSource() {
$frequency = $this->getFrequency();
if ($this->getByMonthDay()) {
switch ($frequency) {
case self::FREQUENCY_WEEKLY:
// RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the
// FREQ rule part is set to WEEKLY."
throw new Exception(
pht(
'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '.
'violates RFC5545.'));
- break;
default:
break;
}
}
if ($this->getByYearDay()) {
switch ($frequency) {
case self::FREQUENCY_DAILY:
case self::FREQUENCY_WEEKLY:
case self::FREQUENCY_MONTHLY:
// RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the
// FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
throw new Exception(
pht(
'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '.
'MONTHLY, which violates RFC5545.'));
default:
break;
}
}
// TODO
// RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric
// value when the FREQ rule part is not set to MONTHLY or YEARLY."
// RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a
// numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO
// rule part is specified."
$date = $this->getStartDateTime();
$this->cursorSecond = $date->getSecond();
$this->cursorMinute = $date->getMinute();
$this->cursorHour = $date->getHour();
$this->cursorDay = $date->getDay();
$this->cursorMonth = $date->getMonth();
$this->cursorYear = $date->getYear();
$year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart());
$key = $this->cursorMonth.'M'.$this->cursorDay.'D';
$this->cursorWeek = $year_map['info'][$key]['week'];
$this->cursorWeekday = $year_map['info'][$key]['weekday'];
$this->setSeconds = array();
$this->setMinutes = array();
$this->setHours = array();
$this->setDays = array();
$this->setMonths = array();
$this->setYears = array();
$this->stateSecond = null;
$this->stateMinute = null;
$this->stateHour = null;
$this->stateDay = null;
$this->stateWeek = null;
$this->stateMonth = null;
$this->stateYear = null;
// If we have a BYSETPOS, we need to generate the entire set before we
// can filter it and return results. Normally, we start generating at
// the start date, but we need to go back one interval to generate
// BYSETPOS events so we can make sure the entire set is generated.
if ($this->getBySetPosition()) {
$interval = $this->getInterval();
switch ($frequency) {
case self::FREQUENCY_YEARLY:
$this->cursorYear -= $interval;
break;
case self::FREQUENCY_MONTHLY:
$this->cursorMonth -= $interval;
$this->rewindMonth();
break;
case self::FREQUENCY_WEEKLY:
$this->cursorWeek -= $interval;
$this->rewindWeek();
break;
case self::FREQUENCY_DAILY:
$this->cursorDay -= $interval;
$this->rewindDay();
break;
case self::FREQUENCY_HOURLY:
$this->cursorHour -= $interval;
$this->rewindHour();
break;
case self::FREQUENCY_MINUTELY:
$this->cursorMinute -= $interval;
$this->rewindMinute();
break;
case self::FREQUENCY_SECONDLY:
default:
throw new Exception(
pht(
'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.',
$frequency));
}
}
// We can generate events from before the cursor when evaluating rules
// with BYSETPOS or FREQ=WEEKLY.
$this->minimumEpoch = $this->getStartDateTime()->getEpoch();
$cursor_state = array(
'year' => $this->cursorYear,
'month' => $this->cursorMonth,
'week' => $this->cursorWeek,
'day' => $this->cursorDay,
'hour' => $this->cursorHour,
);
$this->cursorDayState = $cursor_state;
$this->cursorWeekState = $cursor_state;
$this->cursorHourState = $cursor_state;
$by_hour = $this->getByHour();
$by_minute = $this->getByMinute();
$by_second = $this->getBySecond();
$scale = $this->getFrequencyScale();
// We return all-day events if the start date is an all-day event and we
// don't have more granular selectors or a more granular frequency.
$this->isAllDay = $date->getIsAllDay()
&& !$by_hour
&& !$by_minute
&& !$by_second
&& ($scale > self::SCALE_HOURLY);
}
public function getNextEvent($cursor) {
while (true) {
$event = $this->generateNextEvent();
if (!$event) {
break;
}
$epoch = $event->getEpoch();
if ($this->minimumEpoch) {
if ($epoch < $this->minimumEpoch) {
continue;
}
}
if ($epoch < $cursor) {
continue;
}
break;
}
return $event;
}
private function generateNextEvent() {
if ($this->activeSet) {
return array_pop($this->activeSet);
}
$this->baseYear = $this->cursorYear;
$by_setpos = $this->getBySetPosition();
if ($by_setpos) {
$old_state = $this->getSetPositionState();
}
while (!$this->activeSet) {
$this->activeSet = $this->nextSet;
$this->nextSet = array();
while (true) {
if ($this->isAllDay) {
$this->nextDay();
} else {
$this->nextSecond();
}
$result = id(new PhutilCalendarAbsoluteDateTime())
->setTimezone($this->getStartDateTime()->getTimezone())
->setViewerTimezone($this->getViewerTimezone())
->setYear($this->stateYear)
->setMonth($this->stateMonth)
->setDay($this->stateDay);
if ($this->isAllDay) {
$result->setIsAllDay(true);
} else {
$result
->setHour($this->stateHour)
->setMinute($this->stateMinute)
->setSecond($this->stateSecond);
}
// If we don't have BYSETPOS, we're all done. We put this into the
// set and will immediately return it.
if (!$by_setpos) {
$this->activeSet[] = $result;
break;
}
// Otherwise, check if we've completed a set. The set is complete if
// the state has moved past the span we were examining (for example,
// with a YEARLY event, if the state is now in the next year).
$new_state = $this->getSetPositionState();
if ($new_state == $old_state) {
$this->activeSet[] = $result;
continue;
}
$this->activeSet = $this->applySetPos($this->activeSet, $by_setpos);
$this->activeSet = array_reverse($this->activeSet);
$this->nextSet[] = $result;
$old_state = $new_state;
break;
}
}
return array_pop($this->activeSet);
}
protected function nextSecond() {
if ($this->setSeconds) {
$this->stateSecond = array_pop($this->setSeconds);
return;
}
$frequency = $this->getFrequency();
$interval = $this->getInterval();
$is_secondly = ($frequency == self::FREQUENCY_SECONDLY);
$by_second = $this->getBySecond();
while (!$this->setSeconds) {
$this->nextMinute();
if ($is_secondly || $by_second) {
$seconds = $this->newSecondsSet(
($is_secondly ? $interval : 1),
$by_second);
} else {
$seconds = array(
$this->cursorSecond,
);
}
$this->setSeconds = array_reverse($seconds);
}
$this->stateSecond = array_pop($this->setSeconds);
}
protected function nextMinute() {
if ($this->setMinutes) {
$this->stateMinute = array_pop($this->setMinutes);
return;
}
$frequency = $this->getFrequency();
$interval = $this->getInterval();
$scale = $this->getFrequencyScale();
$is_minutely = ($frequency === self::FREQUENCY_MINUTELY);
$by_minute = $this->getByMinute();
while (!$this->setMinutes) {
$this->nextHour();
if ($is_minutely || $by_minute) {
$minutes = $this->newMinutesSet(
($is_minutely ? $interval : 1),
$by_minute);
} else if ($scale < self::SCALE_MINUTELY) {
$minutes = $this->newMinutesSet(
1,
array());
} else {
$minutes = array(
$this->cursorMinute,
);
}
$this->setMinutes = array_reverse($minutes);
}
$this->stateMinute = array_pop($this->setMinutes);
}
protected function nextHour() {
if ($this->setHours) {
$this->stateHour = array_pop($this->setHours);
return;
}
$frequency = $this->getFrequency();
$interval = $this->getInterval();
$scale = $this->getFrequencyScale();
$is_hourly = ($frequency === self::FREQUENCY_HOURLY);
$by_hour = $this->getByHour();
while (!$this->setHours) {
$this->nextDay();
$is_dynamic = $is_hourly
|| $by_hour
|| ($scale < self::SCALE_HOURLY);
if ($is_dynamic) {
$hours = $this->newHoursSet(
($is_hourly ? $interval : 1),
$by_hour);
} else {
$hours = array(
$this->cursorHour,
);
}
$this->setHours = array_reverse($hours);
}
$this->stateHour = array_pop($this->setHours);
}
protected function nextDay() {
if ($this->setDays) {
$info = array_pop($this->setDays);
$this->setDayState($info);
return;
}
$frequency = $this->getFrequency();
$interval = $this->getInterval();
$scale = $this->getFrequencyScale();
$is_daily = ($frequency === self::FREQUENCY_DAILY);
$is_weekly = ($frequency === self::FREQUENCY_WEEKLY);
$by_day = $this->getByDay();
$by_monthday = $this->getByMonthDay();
$by_yearday = $this->getByYearDay();
$by_weekno = $this->getByWeekNumber();
$by_month = $this->getByMonth();
$week_start = $this->getWeekStart();
while (!$this->setDays) {
if ($is_weekly) {
$this->nextWeek();
} else {
$this->nextMonth();
}
// NOTE: We normally handle BYMONTH when iterating months, but it acts
// like a filter if FREQ=WEEKLY.
$is_dynamic = $is_daily
|| $is_weekly
|| $by_day
|| $by_monthday
|| $by_yearday
|| $by_weekno
|| ($by_month && $is_weekly)
|| ($scale < self::SCALE_DAILY);
if ($is_dynamic) {
$weeks = $this->newDaysSet(
($is_daily ? $interval : 1),
$by_day,
$by_monthday,
$by_yearday,
$by_weekno,
$by_month,
$week_start);
} else {
// The cursor day may not actually exist in the current month, so
// make sure the day is valid before we generate a set which contains
// it.
$year_map = $this->getYearMap($this->stateYear, $week_start);
if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) {
$weeks = array(
array(),
);
} else {
$key = $this->stateMonth.'M'.$this->cursorDay.'D';
$weeks = array(
array($year_map['info'][$key]),
);
}
}
// Unpack the weeks into days.
$days = array_mergev($weeks);
$this->setDays = array_reverse($days);
}
$info = array_pop($this->setDays);
$this->setDayState($info);
}
private function setDayState(array $info) {
$this->stateDay = $info['monthday'];
$this->stateWeek = $info['week'];
$this->stateMonth = $info['month'];
}
protected function nextMonth() {
if ($this->setMonths) {
$this->stateMonth = array_pop($this->setMonths);
return;
}
$frequency = $this->getFrequency();
$interval = $this->getInterval();
$scale = $this->getFrequencyScale();
$is_monthly = ($frequency === self::FREQUENCY_MONTHLY);
$by_month = $this->getByMonth();
// If we have a BYMONTHDAY, we consider that set of days in every month.
// For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every
// month", so we need to expand the month set if the constraint is present.
$by_monthday = $this->getByMonthDay();
// Likewise, we need to generate all months if we have BYYEARDAY or
// BYWEEKNO or BYDAY.
$by_yearday = $this->getByYearDay();
$by_weekno = $this->getByWeekNumber();
$by_day = $this->getByDay();
while (!$this->setMonths) {
$this->nextYear();
$is_dynamic = $is_monthly
|| $by_month
|| $by_monthday
|| $by_yearday
|| $by_weekno
|| $by_day
|| ($scale < self::SCALE_MONTHLY);
if ($is_dynamic) {
$months = $this->newMonthsSet(
($is_monthly ? $interval : 1),
$by_month);
} else {
$months = array(
$this->cursorMonth,
);
}
$this->setMonths = array_reverse($months);
}
$this->stateMonth = array_pop($this->setMonths);
}
protected function nextWeek() {
if ($this->setWeeks) {
$this->stateWeek = array_pop($this->setWeeks);
return;
}
$frequency = $this->getFrequency();
$interval = $this->getInterval();
$scale = $this->getFrequencyScale();
$by_weekno = $this->getByWeekNumber();
while (!$this->setWeeks) {
$this->nextYear();
$weeks = $this->newWeeksSet(
$interval,
$by_weekno);
$this->setWeeks = array_reverse($weeks);
}
$this->stateWeek = array_pop($this->setWeeks);
}
protected function nextYear() {
$this->stateYear = $this->cursorYear;
$frequency = $this->getFrequency();
$is_yearly = ($frequency === self::FREQUENCY_YEARLY);
if ($is_yearly) {
$interval = $this->getInterval();
} else {
$interval = 1;
}
$this->cursorYear = $this->cursorYear + $interval;
if ($this->cursorYear > ($this->baseYear + 100)) {
throw new Exception(
pht(
'RRULE evaluation failed to generate more events in the next 100 '.
'years. This RRULE is likely invalid or degenerate.'));
}
}
private function newSecondsSet($interval, $set) {
// TODO: This doesn't account for leap seconds. In theory, it probably
// should, although this shouldn't impact any real events.
$seconds_in_minute = 60;
if ($this->cursorSecond >= $seconds_in_minute) {
$this->cursorSecond -= $seconds_in_minute;
return array();
}
list($cursor, $result) = $this->newIteratorSet(
$this->cursorSecond,
$interval,
$set,
$seconds_in_minute);
$this->cursorSecond = ($cursor - $seconds_in_minute);
return $result;
}
private function newMinutesSet($interval, $set) {
// NOTE: This value is legitimately a constant! Amazing!
$minutes_in_hour = 60;
if ($this->cursorMinute >= $minutes_in_hour) {
$this->cursorMinute -= $minutes_in_hour;
return array();
}
list($cursor, $result) = $this->newIteratorSet(
$this->cursorMinute,
$interval,
$set,
$minutes_in_hour);
$this->cursorMinute = ($cursor - $minutes_in_hour);
return $result;
}
private function newHoursSet($interval, $set) {
// TODO: This doesn't account for hours caused by daylight savings time.
// It probably should, although this seems unlikely to impact any real
// events.
$hours_in_day = 24;
// If the hour cursor is behind the current time, we need to forward it in
// INTERVAL increments so we end up with the right offset.
list($skip, $this->cursorHourState) = $this->advanceCursorState(
$this->cursorHourState,
self::SCALE_HOURLY,
$interval,
$this->getWeekStart());
if ($skip) {
return array();
}
list($cursor, $result) = $this->newIteratorSet(
$this->cursorHour,
$interval,
$set,
$hours_in_day);
$this->cursorHour = ($cursor - $hours_in_day);
return $result;
}
private function newWeeksSet($interval, $set) {
$week_start = $this->getWeekStart();
list($skip, $this->cursorWeekState) = $this->advanceCursorState(
$this->cursorWeekState,
self::SCALE_WEEKLY,
$interval,
$week_start);
if ($skip) {
return array();
}
$year_map = $this->getYearMap($this->stateYear, $week_start);
$result = array();
while (true) {
if (!isset($year_map['weekMap'][$this->cursorWeek])) {
break;
}
$result[] = $this->cursorWeek;
$this->cursorWeek += $interval;
}
$this->cursorWeek -= $year_map['weekCount'];
return $result;
}
private function newDaysSet(
$interval_day,
$by_day,
$by_monthday,
$by_yearday,
$by_weekno,
$by_month,
$week_start) {
$frequency = $this->getFrequency();
$is_yearly = ($frequency == self::FREQUENCY_YEARLY);
$is_monthly = ($frequency == self::FREQUENCY_MONTHLY);
$is_weekly = ($frequency == self::FREQUENCY_WEEKLY);
$selection = array();
if ($is_weekly) {
$year_map = $this->getYearMap($this->stateYear, $week_start);
if (isset($year_map['weekMap'][$this->stateWeek])) {
foreach ($year_map['weekMap'][$this->stateWeek] as $key) {
$selection[] = $year_map['info'][$key];
}
}
} else {
// If the day cursor is behind the current year and month, we need to
// forward it in INTERVAL increments so we end up with the right offset
// in the current month.
list($skip, $this->cursorDayState) = $this->advanceCursorState(
$this->cursorDayState,
self::SCALE_DAILY,
$interval_day,
$week_start);
if (!$skip) {
$year_map = $this->getYearMap($this->stateYear, $week_start);
while (true) {
$month_idx = $this->stateMonth;
$month_days = $year_map['monthDays'][$month_idx];
if ($this->cursorDay > $month_days) {
// NOTE: The year map is now out of date, but we're about to break
// out of the loop anyway so it doesn't matter.
break;
}
$day_idx = $this->cursorDay;
$key = "{$month_idx}M{$day_idx}D";
$selection[] = $year_map['info'][$key];
$this->cursorDay += $interval_day;
}
}
}
// As a special case, BYDAY applies to relative month offsets if BYMONTH
// is present in a YEARLY rule.
if ($is_yearly) {
if ($this->getByMonth()) {
$is_yearly = false;
$is_monthly = true;
}
}
// As a special case, BYDAY makes us examine all week days. This doesn't
// check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY.
$filter_weekday = true;
if ($is_weekly) {
if ($by_day) {
$filter_weekday = false;
}
}
$weeks = array();
foreach ($selection as $key => $info) {
if ($is_weekly) {
if ($filter_weekday) {
if ($info['weekday'] != $this->cursorWeekday) {
continue;
}
}
} else {
if ($info['month'] != $this->stateMonth) {
continue;
}
}
if ($by_day) {
if (empty($by_day[$info['weekday']])) {
if ($is_yearly) {
if (empty($by_day[$info['weekday.yearly']]) &&
empty($by_day[$info['-weekday.yearly']])) {
continue;
}
} else if ($is_monthly) {
if (empty($by_day[$info['weekday.monthly']]) &&
empty($by_day[$info['-weekday.monthly']])) {
continue;
}
} else {
continue;
}
}
}
if ($by_monthday) {
if (empty($by_monthday[$info['monthday']]) &&
empty($by_monthday[$info['-monthday']])) {
continue;
}
}
if ($by_yearday) {
if (empty($by_yearday[$info['yearday']]) &&
empty($by_yearday[$info['-yearday']])) {
continue;
}
}
if ($by_weekno) {
if (empty($by_weekno[$info['week']]) &&
empty($by_weekno[$info['-week']])) {
continue;
}
}
if ($by_month) {
if (empty($by_month[$info['month']])) {
continue;
}
}
$weeks[$info['week']][] = $info;
}
return array_values($weeks);
}
private function newMonthsSet($interval, $set) {
// NOTE: This value is also a real constant! Wow!
$months_in_year = 12;
if ($this->cursorMonth > $months_in_year) {
$this->cursorMonth -= $months_in_year;
return array();
}
list($cursor, $result) = $this->newIteratorSet(
$this->cursorMonth,
$interval,
$set,
$months_in_year + 1);
$this->cursorMonth = ($cursor - $months_in_year);
return $result;
}
public static function getYearMap($year, $week_start) {
static $maps = array();
$key = "{$year}/{$week_start}";
if (isset($maps[$key])) {
return $maps[$key];
}
$map = self::newYearMap($year, $week_start);
$maps[$key] = $map;
return $maps[$key];
}
private static function newYearMap($year, $weekday_start) {
$weekday_index = self::getWeekdayIndex($weekday_start);
$is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) ||
($year % 400 === 0);
// There may be some clever way to figure out which day of the week a given
// year starts on and avoid the cost of a DateTime construction, but I
// wasn't able to turn it up and we only need to do this once per year.
$datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC'));
$weekday = (int)$datetime->format('w');
if ($is_leap) {
$max_day = 366;
} else {
$max_day = 365;
}
$month_days = array(
1 => 31,
2 => $is_leap ? 29 : 28,
3 => 31,
4 => 30,
5 => 31,
6 => 30,
7 => 31,
8 => 31,
9 => 30,
10 => 31,
11 => 30,
12 => 31,
);
// Per the spec, the first week of the year must contain at least four
// days. If the week starts on a Monday but the year starts on a Saturday,
// the first couple of days don't count as a week. In this case, the first
// week will begin on January 3.
$first_week_size = 0;
$first_weekday = $weekday;
for ($year_day = 1; $year_day <= $max_day; $year_day++) {
$first_weekday = ($first_weekday + 1) % 7;
$first_week_size++;
if ($first_weekday === $weekday_index) {
break;
}
}
if ($first_week_size >= 4) {
$week_number = 1;
} else {
$week_number = 0;
}
$info_map = array();
$weekday_map = self::getWeekdayIndexMap();
$weekday_map = array_flip($weekday_map);
$yearly_counts = array();
$monthly_counts = array();
$month_number = 1;
$month_day = 1;
for ($year_day = 1; $year_day <= $max_day; $year_day++) {
$key = "{$month_number}M{$month_day}D";
$short_day = $weekday_map[$weekday];
if (empty($yearly_counts[$short_day])) {
$yearly_counts[$short_day] = 0;
}
$yearly_counts[$short_day]++;
if (empty($monthly_counts[$month_number][$short_day])) {
$monthly_counts[$month_number][$short_day] = 0;
}
$monthly_counts[$month_number][$short_day]++;
$info = array(
'year' => $year,
'key' => $key,
'month' => $month_number,
'monthday' => $month_day,
'-monthday' => -$month_days[$month_number] + $month_day - 1,
'yearday' => $year_day,
'-yearday' => -$max_day + $year_day - 1,
'week' => $week_number,
'weekday' => $short_day,
'weekday.yearly' => $yearly_counts[$short_day],
'weekday.monthly' => $monthly_counts[$month_number][$short_day],
);
$info_map[$key] = $info;
$weekday = ($weekday + 1) % 7;
if ($weekday === $weekday_index) {
$week_number++;
}
$month_day = ($month_day + 1);
if ($month_day > $month_days[$month_number]) {
$month_day = 1;
$month_number++;
}
}
// Check how long the final week is. If it doesn't have four days, this
// is really the first week of the next year.
$final_week = array();
foreach ($info_map as $key => $info) {
if ($info['week'] == $week_number) {
$final_week[] = $key;
}
}
if (count($final_week) < 4) {
$week_number = $week_number - 1;
$next_year = self::getYearMap($year + 1, $weekday_start);
$next_year_weeks = $next_year['weekCount'];
} else {
$next_year_weeks = null;
}
if ($first_week_size < 4) {
$last_year = self::getYearMap($year - 1, $weekday_start);
$last_year_weeks = $last_year['weekCount'];
} else {
$last_year_weeks = null;
}
// Now that we know how many weeks the year has, we can compute the
// negative offsets.
foreach ($info_map as $key => $info) {
$week = $info['week'];
if ($week === 0) {
// If this day is part of the first partial week of the year, give
// it the week number of the last week of the prior year instead.
$info['week'] = $last_year_weeks;
$info['-week'] = -1;
} else if ($week > $week_number) {
// If this day is part of the last partial week of the year, give
// it week numbers from the next year.
$info['week'] = 1;
$info['-week'] = -$next_year_weeks;
} else {
$info['-week'] = -$week_number + $week - 1;
}
// Do all the arithmetic to figure out if this is the -19th Thursday
// in the year and such.
$month_number = $info['month'];
$short_day = $info['weekday'];
$monthly_count = $monthly_counts[$month_number][$short_day];
$monthly_index = $info['weekday.monthly'];
$info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1;
$info['-weekday.monthly'] .= $short_day;
$info['weekday.monthly'] .= $short_day;
$yearly_count = $yearly_counts[$short_day];
$yearly_index = $info['weekday.yearly'];
$info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1;
$info['-weekday.yearly'] .= $short_day;
$info['weekday.yearly'] .= $short_day;
$info_map[$key] = $info;
}
$week_map = array();
foreach ($info_map as $key => $info) {
$week_map[$info['week']][] = $key;
}
return array(
'info' => $info_map,
'weekCount' => $week_number,
'dayCount' => $max_day,
'monthDays' => $month_days,
'weekMap' => $week_map,
);
}
private function newIteratorSet($cursor, $interval, $set, $limit) {
if ($interval < 1) {
throw new Exception(
pht(
'Invalid iteration interval ("%d"), must be at least 1.',
$interval));
}
$result = array();
$seen = array();
$ii = $cursor;
while (true) {
if (!$set || isset($set[$ii])) {
$result[] = $ii;
}
$ii = ($ii + $interval);
if ($ii >= $limit) {
break;
}
}
sort($result);
$result = array_values($result);
return array($ii, $result);
}
private function applySetPos(array $values, array $setpos) {
$select = array();
$count = count($values);
foreach ($setpos as $pos) {
if ($pos > 0 && $pos <= $count) {
$select[] = ($pos - 1);
} else if ($pos < 0 && $pos >= -$count) {
$select[] = ($count + $pos);
}
}
sort($select);
$select = array_unique($select);
return array_select_keys($values, $select);
}
private function assertByRange(
$source,
array $values,
$min,
$max,
$allow_zero = true) {
foreach ($values as $value) {
if (!is_int($value)) {
throw new Exception(
pht(
'Value "%s" in RRULE "%s" parameter is invalid: values must be '.
'integers.',
$value,
$source));
}
if ($value < $min || $value > $max) {
throw new Exception(
pht(
'Value "%s" in RRULE "%s" parameter is invalid: it must be '.
'between %s and %s.',
$value,
$source,
$min,
$max));
}
if (!$value && !$allow_zero) {
throw new Exception(
pht(
'Value "%s" in RRULE "%s" parameter is invalid: it must not '.
'be zero.',
$value,
$source));
}
}
}
private function getSetPositionState() {
$scale = $this->getFrequencyScale();
$parts = array();
$parts[] = $this->stateYear;
if ($scale == self::SCALE_WEEKLY) {
$parts[] = $this->stateWeek;
} else {
if ($scale < self::SCALE_YEARLY) {
$parts[] = $this->stateMonth;
}
if ($scale < self::SCALE_MONTHLY) {
$parts[] = $this->stateDay;
}
if ($scale < self::SCALE_DAILY) {
$parts[] = $this->stateHour;
}
if ($scale < self::SCALE_HOURLY) {
$parts[] = $this->stateMinute;
}
}
return implode('/', $parts);
}
private function rewindMonth() {
while ($this->cursorMonth < 1) {
$this->cursorYear--;
$this->cursorMonth += 12;
}
}
private function rewindWeek() {
$week_start = $this->getWeekStart();
while ($this->cursorWeek < 1) {
$this->cursorYear--;
$year_map = $this->getYearMap($this->cursorYear, $week_start);
$this->cursorWeek += $year_map['weekCount'];
}
}
private function rewindDay() {
$week_start = $this->getWeekStart();
while ($this->cursorDay < 1) {
$year_map = $this->getYearMap($this->cursorYear, $week_start);
$this->cursorDay += $year_map['monthDays'][$this->cursorMonth];
$this->cursorMonth--;
$this->rewindMonth();
}
}
private function rewindHour() {
while ($this->cursorHour < 0) {
$this->cursorHour += 24;
$this->cursorDay--;
$this->rewindDay();
}
}
private function rewindMinute() {
while ($this->cursorMinute < 0) {
$this->cursorMinute += 60;
$this->cursorHour--;
$this->rewindHour();
}
}
private function advanceCursorState(
array $cursor,
$scale,
$interval,
$week_start) {
$state = array(
'year' => $this->stateYear,
'month' => $this->stateMonth,
'week' => $this->stateWeek,
'day' => $this->stateDay,
'hour' => $this->stateHour,
);
// In the common case when the interval is 1, we'll visit every possible
// value so we don't need to do any math and can just jump to the first
// hour, day, etc.
if ($interval == 1) {
if ($this->isCursorBehind($cursor, $state, $scale)) {
switch ($scale) {
case self::SCALE_DAILY:
$this->cursorDay = 1;
break;
case self::SCALE_HOURLY:
$this->cursorHour = 0;
break;
case self::SCALE_WEEKLY:
$this->cursorWeek = 1;
break;
}
}
return array(false, $state);
}
$year_map = $this->getYearMap($cursor['year'], $week_start);
while ($this->isCursorBehind($cursor, $state, $scale)) {
switch ($scale) {
case self::SCALE_DAILY:
$cursor['day'] += $interval;
break;
case self::SCALE_HOURLY:
$cursor['hour'] += $interval;
break;
case self::SCALE_WEEKLY:
$cursor['week'] += $interval;
break;
}
if ($scale <= self::SCALE_HOURLY) {
while ($cursor['hour'] >= 24) {
$cursor['hour'] -= 24;
$cursor['day']++;
}
}
if ($scale == self::SCALE_WEEKLY) {
while ($cursor['week'] > $year_map['weekCount']) {
$cursor['week'] -= $year_map['weekCount'];
$cursor['year']++;
$year_map = $this->getYearMap($cursor['year'], $week_start);
}
}
if ($scale <= self::SCALE_DAILY) {
while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) {
$cursor['day'] -= $year_map['monthDays'][$cursor['month']];
$cursor['month']++;
if ($cursor['month'] > 12) {
$cursor['month'] -= 12;
$cursor['year']++;
$year_map = $this->getYearMap($cursor['year'], $week_start);
}
}
}
}
switch ($scale) {
case self::SCALE_DAILY:
$this->cursorDay = $cursor['day'];
break;
case self::SCALE_HOURLY:
$this->cursorHour = $cursor['hour'];
break;
case self::SCALE_WEEKLY:
$this->cursorWeek = $cursor['week'];
break;
}
$skip = $this->isCursorBehind($state, $cursor, $scale);
return array($skip, $cursor);
}
private function isCursorBehind(array $cursor, array $state, $scale) {
if ($cursor['year'] < $state['year']) {
return true;
} else if ($cursor['year'] > $state['year']) {
return false;
}
if ($scale == self::SCALE_WEEKLY) {
return false;
}
if ($cursor['month'] < $state['month']) {
return true;
} else if ($cursor['month'] > $state['month']) {
return false;
}
if ($scale >= self::SCALE_DAILY) {
return false;
}
if ($cursor['day'] < $state['day']) {
return true;
} else if ($cursor['day'] > $state['day']) {
return false;
}
if ($scale >= self::SCALE_HOURLY) {
return false;
}
if ($cursor['hour'] < $state['hour']) {
return true;
} else if ($cursor['hour'] > $state['hour']) {
return false;
}
return false;
}
}
diff --git a/src/applications/config/storage/PhabricatorConfigTransaction.php b/src/applications/config/storage/PhabricatorConfigTransaction.php
index 94272bfb1a..b89ae7d838 100644
--- a/src/applications/config/storage/PhabricatorConfigTransaction.php
+++ b/src/applications/config/storage/PhabricatorConfigTransaction.php
@@ -1,152 +1,149 @@
<?php
final class PhabricatorConfigTransaction
extends PhabricatorApplicationTransaction {
const TYPE_EDIT = 'config:edit';
public function getApplicationName() {
return 'config';
}
public function getApplicationTransactionType() {
return PhabricatorConfigConfigPHIDType::TYPECONST;
}
public function getTitle() {
$author_phid = $this->getAuthorPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_EDIT:
// TODO: After T2213 show the actual values too; for now, we don't
// have the tools to do it without making a bit of a mess of it.
$old_del = idx($old, 'deleted');
$new_del = idx($new, 'deleted');
if ($old_del && !$new_del) {
return pht(
'%s created this configuration entry.',
$this->renderHandleLink($author_phid));
} else if (!$old_del && $new_del) {
return pht(
'%s deleted this configuration entry.',
$this->renderHandleLink($author_phid));
} else if ($old_del && $new_del) {
// This is a bug.
return pht(
'%s deleted this configuration entry (again?).',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s edited this configuration entry.',
$this->renderHandleLink($author_phid));
}
- break;
}
return parent::getTitle();
}
public function getTitleForFeed() {
$author_phid = $this->getAuthorPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_EDIT:
$old_del = idx($old, 'deleted');
$new_del = idx($new, 'deleted');
if ($old_del && !$new_del) {
return pht(
'%s created %s.',
$this->renderHandleLink($author_phid),
$this->getObject()->getConfigKey());
} else if (!$old_del && $new_del) {
return pht(
'%s deleted %s.',
$this->renderHandleLink($author_phid),
$this->getObject()->getConfigKey());
} else if ($old_del && $new_del) {
// This is a bug.
return pht(
'%s deleted %s (again?).',
$this->renderHandleLink($author_phid),
$this->getObject()->getConfigKey());
} else {
return pht(
'%s edited %s.',
$this->renderHandleLink($author_phid),
$this->getObject()->getConfigKey());
}
- break;
}
return parent::getTitle();
}
public function getIcon() {
switch ($this->getTransactionType()) {
case self::TYPE_EDIT:
return 'fa-pencil';
}
return parent::getIcon();
}
public function hasChangeDetails() {
switch ($this->getTransactionType()) {
case self::TYPE_EDIT:
return true;
}
return parent::hasChangeDetails();
}
public function renderChangeDetails(PhabricatorUser $viewer) {
$old = $this->getOldValue();
$new = $this->getNewValue();
if ($old['deleted']) {
$old_text = '';
} else {
$old_text = PhabricatorConfigJSON::prettyPrintJSON($old['value']);
}
if ($new['deleted']) {
$new_text = '';
} else {
$new_text = PhabricatorConfigJSON::prettyPrintJSON($new['value']);
}
return $this->renderTextCorpusChangeDetails(
$viewer,
$old_text,
$new_text);
}
public function getColor() {
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_EDIT:
$old_del = idx($old, 'deleted');
$new_del = idx($new, 'deleted');
if ($old_del && !$new_del) {
return PhabricatorTransactions::COLOR_GREEN;
} else if (!$old_del && $new_del) {
return PhabricatorTransactions::COLOR_RED;
} else {
return PhabricatorTransactions::COLOR_BLUE;
}
- break;
}
}
}
diff --git a/src/applications/conpherence/conduit/ConpherenceCreateThreadConduitAPIMethod.php b/src/applications/conpherence/conduit/ConpherenceCreateThreadConduitAPIMethod.php
index e751318c16..8ff324bbb6 100644
--- a/src/applications/conpherence/conduit/ConpherenceCreateThreadConduitAPIMethod.php
+++ b/src/applications/conpherence/conduit/ConpherenceCreateThreadConduitAPIMethod.php
@@ -1,77 +1,76 @@
<?php
final class ConpherenceCreateThreadConduitAPIMethod
extends ConpherenceConduitAPIMethod {
public function getAPIMethodName() {
return 'conpherence.createthread';
}
public function getMethodDescription() {
return pht('Create a new conpherence thread.');
}
public function getMethodStatus() {
return self::METHOD_STATUS_FROZEN;
}
public function getMethodStatusDescription() {
return pht(
'This method is frozen and will eventually be deprecated. New code '.
'should use "conpherence.edit" instead.');
}
protected function defineParamTypes() {
return array(
'title' => 'required string',
'topic' => 'optional string',
'message' => 'optional string',
'participantPHIDs' => 'required list<phids>',
);
}
protected function defineReturnType() {
return 'nonempty dict';
}
protected function defineErrorTypes() {
return array(
'ERR_EMPTY_PARTICIPANT_PHIDS' => pht(
'You must specify participant phids.'),
'ERR_EMPTY_TITLE' => pht(
'You must specify a title.'),
);
}
protected function execute(ConduitAPIRequest $request) {
$participant_phids = $request->getValue('participantPHIDs', array());
$message = $request->getValue('message');
$title = $request->getValue('title');
$topic = $request->getValue('topic');
list($errors, $conpherence) = ConpherenceEditor::createThread(
$request->getUser(),
$participant_phids,
$title,
$message,
$request->newContentSource(),
$topic);
if ($errors) {
foreach ($errors as $error_code) {
switch ($error_code) {
case ConpherenceEditor::ERROR_EMPTY_PARTICIPANTS:
throw new ConduitException('ERR_EMPTY_PARTICIPANT_PHIDS');
- break;
}
}
}
return array(
'conpherenceID' => $conpherence->getID(),
'conpherencePHID' => $conpherence->getPHID(),
'conpherenceURI' => $this->getConpherenceURI($conpherence),
);
}
}
diff --git a/src/applications/conpherence/controller/ConpherenceUpdateController.php b/src/applications/conpherence/controller/ConpherenceUpdateController.php
index 1b2ae3ab62..cbac0779db 100644
--- a/src/applications/conpherence/controller/ConpherenceUpdateController.php
+++ b/src/applications/conpherence/controller/ConpherenceUpdateController.php
@@ -1,462 +1,458 @@
<?php
final class ConpherenceUpdateController
extends ConpherenceController {
public function handleRequest(AphrontRequest $request) {
$user = $request->getUser();
$conpherence_id = $request->getURIData('id');
if (!$conpherence_id) {
return new Aphront404Response();
}
$need_participants = false;
$needed_capabilities = array(PhabricatorPolicyCapability::CAN_VIEW);
$action = $request->getStr('action');
switch ($action) {
case ConpherenceUpdateActions::REMOVE_PERSON:
$person_phid = $request->getStr('remove_person');
if ($person_phid != $user->getPHID()) {
$needed_capabilities[] = PhabricatorPolicyCapability::CAN_EDIT;
}
break;
case ConpherenceUpdateActions::ADD_PERSON:
$needed_capabilities[] = PhabricatorPolicyCapability::CAN_EDIT;
break;
case ConpherenceUpdateActions::LOAD:
break;
}
$conpherence = id(new ConpherenceThreadQuery())
->setViewer($user)
->withIDs(array($conpherence_id))
->needParticipants($need_participants)
->requireCapabilities($needed_capabilities)
->executeOne();
$latest_transaction_id = null;
$response_mode = $request->isAjax() ? 'ajax' : 'redirect';
$error_view = null;
$e_file = array();
$errors = array();
$delete_draft = false;
$xactions = array();
if ($request->isFormPost() || ($action == ConpherenceUpdateActions::LOAD)) {
$editor = id(new ConpherenceEditor())
->setContinueOnNoEffect($request->isContinueRequest())
->setContentSourceFromRequest($request)
->setActor($user);
switch ($action) {
case ConpherenceUpdateActions::DRAFT:
$draft = PhabricatorDraft::newFromUserAndKey(
$user,
$conpherence->getPHID());
$draft->setDraft($request->getStr('text'));
$draft->replaceOrDelete();
return new AphrontAjaxResponse();
case ConpherenceUpdateActions::JOIN_ROOM:
$xactions[] = id(new ConpherenceTransaction())
->setTransactionType(
ConpherenceThreadParticipantsTransaction::TRANSACTIONTYPE)
->setNewValue(array('+' => array($user->getPHID())));
$delete_draft = true;
$message = $request->getStr('text');
if ($message) {
$message_xactions = $editor->generateTransactionsFromText(
$user,
$conpherence,
$message);
$xactions = array_merge($xactions, $message_xactions);
}
// for now, just redirect back to the conpherence so everything
// will work okay...!
$response_mode = 'redirect';
break;
case ConpherenceUpdateActions::MESSAGE:
$message = $request->getStr('text');
if (strlen($message)) {
$xactions = $editor->generateTransactionsFromText(
$user,
$conpherence,
$message);
$xaction_comment = PhabricatorTransactions::findOneByType(
$xactions,
PhabricatorTransactions::TYPE_COMMENT);
$text_metadata = $request->getStr('text_metadata');
if ($text_metadata) {
$text_metadata = phutil_json_decode($text_metadata);
$attached_file_phids = idx(
$text_metadata,
'attachedFilePHIDs',
array());
if ($attached_file_phids) {
$metadata_object = array(
'remarkup.control' => array(
'attachedFilePHIDs' => $attached_file_phids,
),
);
$xaction_comment->setMetadata($metadata_object);
}
}
$delete_draft = true;
} else {
$action = ConpherenceUpdateActions::LOAD;
$updated = false;
$response_mode = 'ajax';
}
break;
case ConpherenceUpdateActions::ADD_PERSON:
$person_phids = $request->getArr('add_person');
if (!empty($person_phids)) {
$xactions[] = id(new ConpherenceTransaction())
->setTransactionType(
ConpherenceThreadParticipantsTransaction::TRANSACTIONTYPE)
->setNewValue(array('+' => $person_phids));
}
break;
case ConpherenceUpdateActions::REMOVE_PERSON:
if (!$request->isContinueRequest()) {
// do nothing; we'll display a confirmation dialog instead
break;
}
$person_phid = $request->getStr('remove_person');
if ($person_phid) {
$xactions[] = id(new ConpherenceTransaction())
->setTransactionType(
ConpherenceThreadParticipantsTransaction::TRANSACTIONTYPE)
->setNewValue(array('-' => array($person_phid)));
$response_mode = 'go-home';
}
break;
case ConpherenceUpdateActions::LOAD:
$updated = false;
$response_mode = 'ajax';
break;
default:
throw new Exception(pht('Unknown action: %s', $action));
- break;
}
if ($xactions) {
try {
$xactions = $editor->applyTransactions($conpherence, $xactions);
if ($delete_draft) {
$draft = PhabricatorDraft::newFromUserAndKey(
$user,
$conpherence->getPHID());
$draft->delete();
}
} catch (PhabricatorApplicationTransactionNoEffectException $ex) {
return id(new PhabricatorApplicationTransactionNoEffectResponse())
->setCancelURI($this->getApplicationURI($conpherence_id.'/'))
->setException($ex);
}
// xactions had no effect...!
if (empty($xactions)) {
$errors[] = pht(
'That was a non-update. Try cancel.');
}
}
if ($xactions || ($action == ConpherenceUpdateActions::LOAD)) {
switch ($response_mode) {
case 'ajax':
$latest_transaction_id = $request->getInt('latest_transaction_id');
$content = $this->loadAndRenderUpdates(
$action,
$conpherence_id,
$latest_transaction_id);
return id(new AphrontAjaxResponse())
->setContent($content);
- break;
case 'go-home':
$content = array(
'href' => $this->getApplicationURI(),
);
return id(new AphrontAjaxResponse())
->setContent($content);
- break;
case 'redirect':
default:
return id(new AphrontRedirectResponse())
->setURI('/'.$conpherence->getMonogram());
- break;
}
}
}
if ($errors) {
$error_view = id(new PHUIInfoView())
->setErrors($errors);
}
switch ($action) {
case ConpherenceUpdateActions::ADD_PERSON:
$dialog = $this->renderAddPersonDialog($conpherence);
break;
case ConpherenceUpdateActions::REMOVE_PERSON:
$dialog = $this->renderRemovePersonDialog($conpherence);
break;
}
return
$dialog
->setUser($user)
->setWidth(AphrontDialogView::WIDTH_FORM)
->setSubmitURI($this->getApplicationURI('update/'.$conpherence_id.'/'))
->addSubmitButton()
->addCancelButton($this->getApplicationURI($conpherence->getID().'/'));
}
private function renderAddPersonDialog(
ConpherenceThread $conpherence) {
$request = $this->getRequest();
$user = $request->getUser();
$add_person = $request->getStr('add_person');
$form = id(new AphrontFormView())
->setUser($user)
->setFullWidth(true)
->appendControl(
id(new AphrontFormTokenizerControl())
->setName('add_person')
->setUser($user)
->setDatasource(new PhabricatorPeopleDatasource()));
$view = id(new AphrontDialogView())
->setTitle(pht('Add Participants'))
->addHiddenInput('action', 'add_person')
->addHiddenInput(
'latest_transaction_id',
$request->getInt('latest_transaction_id'))
->appendForm($form);
return $view;
}
private function renderRemovePersonDialog(
ConpherenceThread $conpherence) {
$request = $this->getRequest();
$viewer = $request->getUser();
$remove_person = $request->getStr('remove_person');
$participants = $conpherence->getParticipants();
$removed_user = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withPHIDs(array($remove_person))
->executeOne();
if (!$removed_user) {
return new Aphront404Response();
}
$is_self = ($viewer->getPHID() == $removed_user->getPHID());
$is_last = (count($participants) == 1);
$test_conpherence = clone $conpherence;
$test_conpherence->attachParticipants(array());
$still_visible = PhabricatorPolicyFilter::hasCapability(
$removed_user,
$test_conpherence,
PhabricatorPolicyCapability::CAN_VIEW);
$body = array();
if ($is_self) {
$title = pht('Leave Room');
$body[] = pht(
'Are you sure you want to leave this room?');
} else {
$title = pht('Remove Participant');
$body[] = pht(
'Remove %s from this room?',
phutil_tag('strong', array(), $removed_user->getUsername()));
}
if ($still_visible) {
if ($is_self) {
$body[] = pht(
'You will be able to rejoin the room later.');
} else {
$body[] = pht(
'They will be able to rejoin the room later.');
}
} else {
if ($is_self) {
if ($is_last) {
$body[] = pht(
'You are the last member, so you will never be able to rejoin '.
'the room.');
} else {
$body[] = pht(
'You will not be able to rejoin the room on your own, but '.
'someone else can invite you later.');
}
} else {
$body[] = pht(
'They will not be able to rejoin the room unless invited '.
'again.');
}
}
$dialog = id(new AphrontDialogView())
->setTitle($title)
->addHiddenInput('action', 'remove_person')
->addHiddenInput('remove_person', $remove_person)
->addHiddenInput(
'latest_transaction_id',
$request->getInt('latest_transaction_id'))
->addHiddenInput('__continue__', true);
foreach ($body as $paragraph) {
$dialog->appendParagraph($paragraph);
}
return $dialog;
}
private function loadAndRenderUpdates(
$action,
$conpherence_id,
$latest_transaction_id) {
$need_transactions = false;
switch ($action) {
case ConpherenceUpdateActions::LOAD:
$need_transactions = true;
break;
case ConpherenceUpdateActions::MESSAGE:
case ConpherenceUpdateActions::ADD_PERSON:
$need_transactions = true;
break;
case ConpherenceUpdateActions::REMOVE_PERSON:
default:
break;
}
$user = $this->getRequest()->getUser();
$conpherence = id(new ConpherenceThreadQuery())
->setViewer($user)
->setAfterTransactionID($latest_transaction_id)
->needProfileImage(true)
->needParticipants(true)
->needTransactions($need_transactions)
->withIDs(array($conpherence_id))
->executeOne();
$non_update = false;
// The User is always available. The Participant may not. See:
// User: it's you, lurking the Chat (maybe it's a public chat).
// Participant: it's you, if you are a Chat Member.
// https://we.phorge.it/T15497
$participant = $conpherence->getParticipantIfExists($user->getPHID());
if ($need_transactions && $conpherence->getTransactions()) {
$data = ConpherenceTransactionRenderer::renderTransactions(
$user,
$conpherence);
$key = PhabricatorConpherenceColumnMinimizeSetting::SETTINGKEY;
$minimized = $user->getUserSetting($key);
if (!$minimized && $participant) {
$participant->markUpToDate($conpherence);
}
} else if ($need_transactions) {
$non_update = true;
$data = array();
} else {
$data = array();
}
$rendered_transactions = idx($data, 'transactions');
$new_latest_transaction_id = idx($data, 'latest_transaction_id');
$update_uri = $this->getApplicationURI('update/'.$conpherence->getID().'/');
$nav_item = null;
$header = null;
$people_widget = null;
switch ($action) {
case ConpherenceUpdateActions::ADD_PERSON:
$people_widget = id(new ConpherenceParticipantView())
->setUser($user)
->setConpherence($conpherence)
->setUpdateURI($update_uri);
$people_widget = hsprintf('%s', $people_widget->render());
break;
case ConpherenceUpdateActions::REMOVE_PERSON:
default:
break;
}
$data = $conpherence->getDisplayData($user);
$dropdown_query = id(new AphlictDropdownDataQuery())
->setViewer($user);
$dropdown_query->execute();
$map = ConpherenceRoomSettings::getSoundMap();
$default_receive = ConpherenceRoomSettings::DEFAULT_RECEIVE_SOUND;
$receive_sound = $map[$default_receive]['rsrc'];
$mention_sound = null;
// Get the user's defaults if logged in
if ($participant) {
$sounds = $this->getSoundForParticipant($user, $participant);
$receive_sound = $sounds[ConpherenceRoomSettings::SOUND_RECEIVE];
$mention_sound = $sounds[ConpherenceRoomSettings::SOUND_MENTION];
}
$content = array(
'non_update' => $non_update,
'transactions' => hsprintf('%s', $rendered_transactions),
'conpherence_title' => (string)$data['title'],
'latest_transaction_id' => $new_latest_transaction_id,
'nav_item' => $nav_item,
'conpherence_phid' => $conpherence->getPHID(),
'header' => $header,
'people_widget' => $people_widget,
'aphlictDropdownData' => array(
$dropdown_query->getNotificationData(),
$dropdown_query->getConpherenceData(),
),
'sound' => array(
'receive' => $receive_sound,
'mention' => $mention_sound,
),
);
return $content;
}
protected function getSoundForParticipant(
PhabricatorUser $user,
ConpherenceParticipant $participant) {
$sound_key = PhabricatorConpherenceSoundSetting::SETTINGKEY;
$sound_default = $user->getUserSetting($sound_key);
$settings = $participant->getSettings();
$sounds = idx($settings, 'sounds', array());
$map = PhabricatorConpherenceSoundSetting::getDefaultSound($sound_default);
$receive = idx($sounds,
ConpherenceRoomSettings::SOUND_RECEIVE,
$map[ConpherenceRoomSettings::SOUND_RECEIVE]);
$mention = idx($sounds,
ConpherenceRoomSettings::SOUND_MENTION,
$map[ConpherenceRoomSettings::SOUND_MENTION]);
$sound_map = ConpherenceRoomSettings::getSoundMap();
return array(
ConpherenceRoomSettings::SOUND_RECEIVE => $sound_map[$receive]['rsrc'],
ConpherenceRoomSettings::SOUND_MENTION => $sound_map[$mention]['rsrc'],
);
}
}
diff --git a/src/applications/conpherence/storage/ConpherenceThread.php b/src/applications/conpherence/storage/ConpherenceThread.php
index 7a5f97ed41..a2150662c6 100644
--- a/src/applications/conpherence/storage/ConpherenceThread.php
+++ b/src/applications/conpherence/storage/ConpherenceThread.php
@@ -1,348 +1,347 @@
<?php
final class ConpherenceThread extends ConpherenceDAO
implements
PhabricatorPolicyInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorMentionableInterface,
PhabricatorDestructibleInterface,
PhabricatorNgramsInterface {
protected $title;
protected $topic;
protected $profileImagePHID;
protected $messageCount;
protected $mailKey;
protected $viewPolicy;
protected $editPolicy;
protected $joinPolicy;
private $participants = self::ATTACHABLE;
private $transactions = self::ATTACHABLE;
private $profileImageFile = self::ATTACHABLE;
private $handles = self::ATTACHABLE;
public static function initializeNewRoom(PhabricatorUser $sender) {
$default_policy = id(new ConpherenceThreadMembersPolicyRule())
->getObjectPolicyFullKey();
return id(new ConpherenceThread())
->setMessageCount(0)
->setTitle('')
->setTopic('')
->attachParticipants(array())
->setViewPolicy($default_policy)
->setEditPolicy($default_policy)
->setJoinPolicy('');
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'title' => 'text255?',
'topic' => 'text255',
'messageCount' => 'uint64',
'mailKey' => 'text20',
'joinPolicy' => 'policy',
'profileImagePHID' => 'phid?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorConpherenceThreadPHIDType::TYPECONST);
}
public function save() {
if (!$this->getMailKey()) {
$this->setMailKey(Filesystem::readRandomCharacters(20));
}
return parent::save();
}
public function getMonogram() {
return 'Z'.$this->getID();
}
public function getURI() {
return '/'.$this->getMonogram();
}
public function attachParticipants(array $participants) {
assert_instances_of($participants, 'ConpherenceParticipant');
$this->participants = $participants;
return $this;
}
public function getParticipants() {
return $this->assertAttached($this->participants);
}
public function getParticipant($phid) {
$participants = $this->getParticipants();
return $participants[$phid];
}
public function getParticipantIfExists($phid, $default = null) {
$participants = $this->getParticipants();
return idx($participants, $phid, $default);
}
public function getParticipantPHIDs() {
$participants = $this->getParticipants();
return array_keys($participants);
}
public function attachHandles(array $handles) {
assert_instances_of($handles, 'PhabricatorObjectHandle');
$this->handles = $handles;
return $this;
}
public function getHandles() {
return $this->assertAttached($this->handles);
}
public function attachTransactions(array $transactions) {
assert_instances_of($transactions, 'ConpherenceTransaction');
$this->transactions = $transactions;
return $this;
}
public function getTransactions($assert_attached = true) {
return $this->assertAttached($this->transactions);
}
public function hasAttachedTransactions() {
return $this->transactions !== self::ATTACHABLE;
}
public function getTransactionsFrom($begin = 0, $amount = null) {
$length = count($this->transactions);
return array_slice(
$this->getTransactions(),
$length - $begin - $amount,
$amount);
}
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);
}
/**
* Get a thread title which doesn't require handles to be attached.
*
* This is a less rich title than @{method:getDisplayTitle}, but does not
* require handles to be attached. We use it to build thread handles without
* risking cycles or recursion while querying.
*
* @return string Lower quality human-readable title.
*/
public function getStaticTitle() {
$title = $this->getTitle();
if (strlen($title)) {
return $title;
}
return pht('Private Room');
}
public function getDisplayData(PhabricatorUser $viewer) {
$handles = $this->getHandles();
if ($this->hasAttachedTransactions()) {
$transactions = $this->getTransactions();
} else {
$transactions = array();
}
$img_src = $this->getProfileImageURI();
$message_transaction = null;
foreach ($transactions as $transaction) {
if ($message_transaction) {
break;
}
switch ($transaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$message_transaction = $transaction;
break;
default:
break;
}
}
if ($message_transaction) {
$message_handle = $handles[$message_transaction->getAuthorPHID()];
$subtitle = sprintf(
'%s: %s',
$message_handle->getName(),
id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(60)
->truncateString(
$message_transaction->getComment()->getContent()));
} else {
// Kinda lame, but maybe add last message to cache?
$subtitle = pht('No recent messages');
}
$user_participation = $this->getParticipantIfExists($viewer->getPHID());
$theme = ConpherenceRoomSettings::COLOR_LIGHT;
if ($user_participation) {
$user_seen_count = $user_participation->getSeenMessageCount();
$participant = $this->getParticipant($viewer->getPHID());
$settings = $participant->getSettings();
$theme = idx($settings, 'theme', $theme);
} else {
$user_seen_count = 0;
}
$unread_count = $this->getMessageCount() - $user_seen_count;
$theme_class = ConpherenceRoomSettings::getThemeClass($theme);
$title = $this->getTitle();
$topic = $this->getTopic();
return array(
'title' => $title,
'topic' => $topic,
'subtitle' => $subtitle,
'unread_count' => $unread_count,
'epoch' => $this->getDateModified(),
'image' => $img_src,
'theme' => $theme_class,
);
}
/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
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();
}
return PhabricatorPolicies::POLICY_NOONE;
}
public function hasAutomaticCapability($capability, PhabricatorUser $user) {
// this bad boy isn't even created yet so go nuts $user
if (!$this->getID()) {
return true;
}
switch ($capability) {
case PhabricatorPolicyCapability::CAN_EDIT:
return false;
}
$participants = $this->getParticipants();
return isset($participants[$user->getPHID()]);
}
public function describeAutomaticCapability($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return pht('Participants in a room can always view it.');
- break;
}
}
public static function loadViewPolicyObjects(
PhabricatorUser $viewer,
array $conpherences) {
assert_instances_of($conpherences, __CLASS__);
$policies = array();
foreach ($conpherences as $room) {
$policies[$room->getViewPolicy()] = 1;
}
$policy_objects = array();
if ($policies) {
$policy_objects = id(new PhabricatorPolicyQuery())
->setViewer($viewer)
->withPHIDs(array_keys($policies))
->execute();
}
return $policy_objects;
}
public function getPolicyIconName(array $policy_objects) {
assert_instances_of($policy_objects, 'PhabricatorPolicy');
$icon = $policy_objects[$this->getViewPolicy()]->getIcon();
return $icon;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new ConpherenceEditor();
}
public function getApplicationTransactionTemplate() {
return new ConpherenceTransaction();
}
/* -( PhabricatorNgramInterface )------------------------------------------ */
public function newNgrams() {
return array(
id(new ConpherenceThreadTitleNgrams())
->setValue($this->getTitle()),
);
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$this->delete();
$participants = id(new ConpherenceParticipant())
->loadAllWhere('conpherencePHID = %s', $this->getPHID());
foreach ($participants as $participant) {
$participant->delete();
}
$this->saveTransaction();
}
}
diff --git a/src/applications/conpherence/view/ConpherenceTransactionView.php b/src/applications/conpherence/view/ConpherenceTransactionView.php
index d976fb604f..cf054133cd 100644
--- a/src/applications/conpherence/view/ConpherenceTransactionView.php
+++ b/src/applications/conpherence/view/ConpherenceTransactionView.php
@@ -1,244 +1,243 @@
<?php
final class ConpherenceTransactionView extends AphrontView {
private $conpherenceThread;
private $conpherenceTransaction;
private $handles;
private $markupEngine;
private $classes = array();
private $searchResult;
private $timeOnly;
public function setConpherenceThread(ConpherenceThread $t) {
$this->conpherenceThread = $t;
return $this;
}
private function getConpherenceThread() {
return $this->conpherenceThread;
}
public function setConpherenceTransaction(ConpherenceTransaction $tx) {
$this->conpherenceTransaction = $tx;
return $this;
}
private function getConpherenceTransaction() {
return $this->conpherenceTransaction;
}
public function setHandles(array $handles) {
assert_instances_of($handles, 'PhabricatorObjectHandle');
$this->handles = $handles;
return $this;
}
public function getHandles() {
return $this->handles;
}
public function setMarkupEngine(PhabricatorMarkupEngine $markup_engine) {
$this->markupEngine = $markup_engine;
return $this;
}
private function getMarkupEngine() {
return $this->markupEngine;
}
public function addClass($class) {
$this->classes[] = $class;
return $this;
}
public function setSearchResult($result) {
$this->searchResult = $result;
return $this;
}
public function render() {
$viewer = $this->getUser();
if (!$viewer) {
throw new PhutilInvalidStateException('setUser');
}
require_celerity_resource('conpherence-transaction-css');
$transaction = $this->getConpherenceTransaction();
switch ($transaction->getTransactionType()) {
case ConpherenceThreadDateMarkerTransaction::TRANSACTIONTYPE:
return javelin_tag(
'div',
array(
'class' => 'conpherence-transaction-view date-marker',
'sigil' => 'conpherence-transaction-view',
'meta' => array(
'id' => $transaction->getID() + 0.5,
),
),
array(
phutil_tag(
'span',
array(
'class' => 'date',
),
phabricator_format_local_time(
$transaction->getDateCreated(),
$viewer,
'M jS, Y')),
));
- break;
}
$info = $this->renderTransactionInfo();
$actions = $this->renderTransactionActions();
$image = $this->renderTransactionImage();
$content = $this->renderTransactionContent();
$classes = implode(' ', $this->classes);
$transaction_dom_id = 'anchor-'.$transaction->getID();
$header = phutil_tag_div(
'conpherence-transaction-header grouped',
array($actions, $info));
return javelin_tag(
'div',
array(
'class' => 'conpherence-transaction-view '.$classes,
'id' => $transaction_dom_id,
'sigil' => 'conpherence-transaction-view',
'meta' => array(
'id' => $transaction->getID(),
),
),
array(
$image,
phutil_tag_div('conpherence-transaction-detail grouped',
array($header, $content)),
));
}
private function renderTransactionInfo() {
$viewer = $this->getUser();
$thread = $this->getConpherenceThread();
$transaction = $this->getConpherenceTransaction();
$info = array();
Javelin::initBehavior('phabricator-tooltips');
$tip = phabricator_datetime($transaction->getDateCreated(), $viewer);
$label = phabricator_time($transaction->getDateCreated(), $viewer);
$width = 360;
Javelin::initBehavior('phabricator-watch-anchor');
$anchor = id(new PhabricatorAnchorView())
->setAnchorName($transaction->getID())
->render();
if ($this->searchResult) {
$uri = $thread->getMonogram();
$info[] = hsprintf(
'%s',
javelin_tag(
'a',
array(
'href' => '/'.$uri.'#'.$transaction->getID(),
'class' => 'transaction-date',
'sigil' => 'conpherence-search-result-jump',
),
$tip));
} else {
$info[] = hsprintf(
'%s%s',
$anchor,
javelin_tag(
'a',
array(
'href' => '#'.$transaction->getID(),
'class' => 'transaction-date anchor-link',
'sigil' => 'has-tooltip',
'meta' => array(
'tip' => $tip,
'size' => $width,
),
),
$label));
}
return phutil_tag(
'span',
array(
'class' => 'conpherence-transaction-info',
),
$info);
}
private function renderTransactionActions() {
$transaction = $this->getConpherenceTransaction();
switch ($transaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$handles = $this->getHandles();
$author = $handles[$transaction->getAuthorPHID()];
$actions = array($author->renderLink());
break;
default:
$actions = null;
break;
}
return $actions;
}
private function renderTransactionImage() {
$image = null;
$transaction = $this->getConpherenceTransaction();
switch ($transaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$handles = $this->getHandles();
$author = $handles[$transaction->getAuthorPHID()];
$image_uri = $author->getImageURI();
$image = phutil_tag(
'span',
array(
'class' => 'conpherence-transaction-image',
'style' => 'background-image: url('.$image_uri.');',
));
break;
}
return $image;
}
private function renderTransactionContent() {
$transaction = $this->getConpherenceTransaction();
$content = null;
$content_class = null;
$content = null;
$handles = $this->getHandles();
switch ($transaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$this->addClass('conpherence-comment');
$author = $handles[$transaction->getAuthorPHID()];
$comment = $transaction->getComment();
$content = $this->getMarkupEngine()->getOutput(
$comment,
PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT);
$content_class = 'conpherence-message';
break;
default:
$content = $transaction->getTitle();
$this->addClass('conpherence-edited');
break;
}
$view = phutil_tag(
'div',
array(
'class' => $content_class,
),
$content);
return phutil_tag_div('conpherence-transaction-content', $view);
}
}
diff --git a/src/applications/differential/conduit/DifferentialCreateCommentConduitAPIMethod.php b/src/applications/differential/conduit/DifferentialCreateCommentConduitAPIMethod.php
index e85c0db198..c179c9fa70 100644
--- a/src/applications/differential/conduit/DifferentialCreateCommentConduitAPIMethod.php
+++ b/src/applications/differential/conduit/DifferentialCreateCommentConduitAPIMethod.php
@@ -1,117 +1,116 @@
<?php
final class DifferentialCreateCommentConduitAPIMethod
extends DifferentialConduitAPIMethod {
public function getAPIMethodName() {
return 'differential.createcomment';
}
public function getMethodDescription() {
return pht('Add a comment to a Differential revision.');
}
public function getMethodStatus() {
return self::METHOD_STATUS_FROZEN;
}
public function getMethodStatusDescription() {
return pht(
'This method is frozen and will eventually be deprecated. New code '.
'should use "differential.revision.edit" instead.');
}
protected function defineParamTypes() {
return array(
'revision_id' => 'required revisionid',
'message' => 'optional string',
'action' => 'optional string',
'silent' => 'optional bool',
'attach_inlines' => 'optional bool',
);
}
protected function defineReturnType() {
return 'nonempty dict';
}
protected function defineErrorTypes() {
return array(
'ERR_BAD_REVISION' => pht('Bad revision ID.'),
);
}
protected function execute(ConduitAPIRequest $request) {
$viewer = $request->getUser();
$revision = id(new DifferentialRevisionQuery())
->setViewer($viewer)
->withIDs(array($request->getValue('revision_id')))
->needReviewers(true)
->needReviewerAuthority(true)
->needActiveDiffs(true)
->executeOne();
if (!$revision) {
throw new ConduitException('ERR_BAD_REVISION');
}
$xactions = array();
$modular_map = array(
'accept' => DifferentialRevisionAcceptTransaction::TRANSACTIONTYPE,
'reject' => DifferentialRevisionRejectTransaction::TRANSACTIONTYPE,
'resign' => DifferentialRevisionResignTransaction::TRANSACTIONTYPE,
'request_review' =>
DifferentialRevisionRequestReviewTransaction::TRANSACTIONTYPE,
'rethink' => DifferentialRevisionPlanChangesTransaction::TRANSACTIONTYPE,
);
$action = $request->getValue('action');
if (isset($modular_map[$action])) {
$xactions[] = id(new DifferentialTransaction())
->setTransactionType($modular_map[$action])
->setNewValue(true);
} else if ($action) {
switch ($action) {
case 'comment':
case 'none':
break;
default:
throw new Exception(
pht(
'Unsupported action "%s".',
$action));
- break;
}
}
$content = $request->getValue('message');
if (strlen($content)) {
$xactions[] = id(new DifferentialTransaction())
->setTransactionType(PhabricatorTransactions::TYPE_COMMENT)
->attachComment(
id(new DifferentialTransactionComment())
->setContent($content));
}
// NOTE: The legacy "attach_inlines" flag is now ignored and has no
// effect. See T13513.
// NOTE: The legacy "silent" flag is now ignored and has no effect. See
// T13042.
$editor = id(new DifferentialTransactionEditor())
->setActor($viewer)
->setContentSource($request->newContentSource())
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true);
$editor->applyTransactions($revision, $xactions);
return array(
'revisionid' => $revision->getID(),
'uri' => PhabricatorEnv::getURI('/D'.$revision->getID()),
);
}
}
diff --git a/src/applications/differential/storage/DifferentialTransaction.php b/src/applications/differential/storage/DifferentialTransaction.php
index d60fdc4bbd..54ece849d0 100644
--- a/src/applications/differential/storage/DifferentialTransaction.php
+++ b/src/applications/differential/storage/DifferentialTransaction.php
@@ -1,622 +1,619 @@
<?php
final class DifferentialTransaction
extends PhabricatorModularTransaction {
private $isCommandeerSideEffect;
const TYPE_INLINE = 'differential:inline';
const TYPE_ACTION = 'differential:action';
const MAILTAG_REVIEWERS = 'differential-reviewers';
const MAILTAG_CLOSED = 'differential-committed';
const MAILTAG_CC = 'differential-cc';
const MAILTAG_COMMENT = 'differential-comment';
const MAILTAG_UPDATED = 'differential-updated';
const MAILTAG_REVIEW_REQUEST = 'differential-review-request';
const MAILTAG_OTHER = 'differential-other';
public function getBaseTransactionClass() {
return 'DifferentialRevisionTransactionType';
}
protected function newFallbackModularTransactionType() {
// TODO: This allows us to render modern strings for older transactions
// without doing a migration. At some point, we should do a migration and
// throw this away.
// NOTE: Old reviewer edits are raw edge transactions. They could be
// migrated to modular transactions when the rest of this migrates.
$xaction_type = $this->getTransactionType();
if ($xaction_type == PhabricatorTransactions::TYPE_CUSTOMFIELD) {
switch ($this->getMetadataValue('customfield:key')) {
case 'differential:title':
return new DifferentialRevisionTitleTransaction();
case 'differential:test-plan':
return new DifferentialRevisionTestPlanTransaction();
case 'differential:repository':
return new DifferentialRevisionRepositoryTransaction();
}
}
return parent::newFallbackModularTransactionType();
}
public function setIsCommandeerSideEffect($is_side_effect) {
$this->isCommandeerSideEffect = $is_side_effect;
return $this;
}
public function getIsCommandeerSideEffect() {
return $this->isCommandeerSideEffect;
}
public function getApplicationName() {
return 'differential';
}
public function getApplicationTransactionType() {
return DifferentialRevisionPHIDType::TYPECONST;
}
public function getApplicationTransactionCommentObject() {
return new DifferentialTransactionComment();
}
public function shouldHide() {
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case DifferentialRevisionRequestReviewTransaction::TRANSACTIONTYPE:
// Don't hide the initial "X requested review: ..." transaction from
// mail or feed even when it occurs during creation. We need this
// transaction to survive so we'll generate mail and feed stories when
// revisions immediately leave the draft state. See T13035 for
// discussion.
return false;
}
return parent::shouldHide();
}
public function shouldHideForMail(array $xactions) {
switch ($this->getTransactionType()) {
case DifferentialRevisionReviewersTransaction::TRANSACTIONTYPE:
// Don't hide the initial "X added reviewers: ..." transaction during
// object creation from mail. See T12118 and PHI54.
return false;
}
return parent::shouldHideForMail($xactions);
}
public function isInlineCommentTransaction() {
switch ($this->getTransactionType()) {
case self::TYPE_INLINE:
return true;
}
return parent::isInlineCommentTransaction();
}
public function getRequiredHandlePHIDs() {
$phids = parent::getRequiredHandlePHIDs();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_ACTION:
if ($new == DifferentialAction::ACTION_CLOSE &&
$this->getMetadataValue('isCommitClose')) {
$phids[] = $this->getMetadataValue('commitPHID');
if ($this->getMetadataValue('committerPHID')) {
$phids[] = $this->getMetadataValue('committerPHID');
}
if ($this->getMetadataValue('authorPHID')) {
$phids[] = $this->getMetadataValue('authorPHID');
}
}
break;
}
return $phids;
}
public function getActionStrength() {
switch ($this->getTransactionType()) {
case self::TYPE_ACTION:
return 300;
}
return parent::getActionStrength();
}
public function getActionName() {
switch ($this->getTransactionType()) {
case self::TYPE_INLINE:
return pht('Commented On');
case self::TYPE_ACTION:
$map = array(
DifferentialAction::ACTION_ACCEPT => pht('Accepted'),
DifferentialAction::ACTION_REJECT => pht('Requested Changes To'),
DifferentialAction::ACTION_RETHINK => pht('Planned Changes To'),
DifferentialAction::ACTION_ABANDON => pht('Abandoned'),
DifferentialAction::ACTION_CLOSE => pht('Closed'),
DifferentialAction::ACTION_REQUEST => pht('Requested A Review Of'),
DifferentialAction::ACTION_RESIGN => pht('Resigned From'),
DifferentialAction::ACTION_ADDREVIEWERS => pht('Added Reviewers'),
DifferentialAction::ACTION_CLAIM => pht('Commandeered'),
DifferentialAction::ACTION_REOPEN => pht('Reopened'),
);
$name = idx($map, $this->getNewValue());
if ($name !== null) {
return $name;
}
break;
}
return parent::getActionName();
}
public function getMailTags() {
$tags = array();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBSCRIBERS;
$tags[] = self::MAILTAG_CC;
break;
case self::TYPE_ACTION:
switch ($this->getNewValue()) {
case DifferentialAction::ACTION_CLOSE:
$tags[] = self::MAILTAG_CLOSED;
break;
}
break;
case DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE:
$old = $this->getOldValue();
if ($old === null) {
$tags[] = self::MAILTAG_REVIEW_REQUEST;
} else {
$tags[] = self::MAILTAG_UPDATED;
}
break;
case PhabricatorTransactions::TYPE_COMMENT:
case self::TYPE_INLINE:
$tags[] = self::MAILTAG_COMMENT;
break;
case DifferentialRevisionReviewersTransaction::TRANSACTIONTYPE:
$tags[] = self::MAILTAG_REVIEWERS;
break;
case DifferentialRevisionCloseTransaction::TRANSACTIONTYPE:
$tags[] = self::MAILTAG_CLOSED;
break;
}
if (!$tags) {
$tags[] = self::MAILTAG_OTHER;
}
return $tags;
}
public function getTitle() {
$author_phid = $this->getAuthorPHID();
$author_handle = $this->renderHandleLink($author_phid);
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case self::TYPE_INLINE:
return pht(
'%s added inline comments.',
$author_handle);
case self::TYPE_ACTION:
switch ($new) {
case DifferentialAction::ACTION_CLOSE:
if (!$this->getMetadataValue('isCommitClose')) {
return DifferentialAction::getBasicStoryText(
$new,
$author_handle);
}
$commit_name = $this->renderHandleLink(
$this->getMetadataValue('commitPHID'));
$committer_phid = $this->getMetadataValue('committerPHID');
$author_phid = $this->getMetadataValue('authorPHID');
if ($this->getHandleIfExists($committer_phid)) {
$committer_name = $this->renderHandleLink($committer_phid);
} else {
$committer_name = $this->getMetadataValue('committerName');
}
if ($this->getHandleIfExists($author_phid)) {
$author_name = $this->renderHandleLink($author_phid);
} else {
$author_name = $this->getMetadataValue('authorName');
}
if ($committer_name && ($committer_name != $author_name)) {
return pht(
'Closed by commit %s (authored by %s, committed by %s).',
$commit_name,
$author_name,
$committer_name);
} else {
return pht(
'Closed by commit %s (authored by %s).',
$commit_name,
$author_name);
}
- break;
default:
return DifferentialAction::getBasicStoryText($new, $author_handle);
}
- break;
}
return parent::getTitle();
}
public function renderExtraInformationLink() {
if ($this->getMetadataValue('revisionMatchData')) {
$details_href =
'/differential/revision/closedetails/'.$this->getPHID().'/';
$details_link = javelin_tag(
'a',
array(
'href' => $details_href,
'sigil' => 'workflow',
),
pht('Explain Why'));
return $details_link;
}
return parent::renderExtraInformationLink();
}
public function getTitleForFeed() {
$author_phid = $this->getAuthorPHID();
$object_phid = $this->getObjectPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
$author_link = $this->renderHandleLink($author_phid);
$object_link = $this->renderHandleLink($object_phid);
switch ($this->getTransactionType()) {
case self::TYPE_INLINE:
return pht(
'%s added inline comments to %s.',
$author_link,
$object_link);
case self::TYPE_ACTION:
switch ($new) {
case DifferentialAction::ACTION_ACCEPT:
return pht(
'%s accepted %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_REJECT:
return pht(
'%s requested changes to %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_RETHINK:
return pht(
'%s planned changes to %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_ABANDON:
return pht(
'%s abandoned %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_CLOSE:
if (!$this->getMetadataValue('isCommitClose')) {
return pht(
'%s closed %s.',
$author_link,
$object_link);
} else {
$commit_name = $this->renderHandleLink(
$this->getMetadataValue('commitPHID'));
$committer_phid = $this->getMetadataValue('committerPHID');
$author_phid = $this->getMetadataValue('authorPHID');
if ($this->getHandleIfExists($committer_phid)) {
$committer_name = $this->renderHandleLink($committer_phid);
} else {
$committer_name = $this->getMetadataValue('committerName');
}
if ($this->getHandleIfExists($author_phid)) {
$author_name = $this->renderHandleLink($author_phid);
} else {
$author_name = $this->getMetadataValue('authorName');
}
// Check if the committer and author are the same. They're the
// same if both resolved and are the same user, or if neither
// resolved and the text is identical.
if ($committer_phid && $author_phid) {
$same_author = ($committer_phid == $author_phid);
} else if (!$committer_phid && !$author_phid) {
$same_author = ($committer_name == $author_name);
} else {
$same_author = false;
}
if ($committer_name && !$same_author) {
return pht(
'%s closed %s by committing %s (authored by %s).',
$author_link,
$object_link,
$commit_name,
$author_name);
} else {
return pht(
'%s closed %s by committing %s.',
$author_link,
$object_link,
$commit_name);
}
}
- break;
case DifferentialAction::ACTION_REQUEST:
return pht(
'%s requested review of %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_RECLAIM:
return pht(
'%s reclaimed %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_RESIGN:
return pht(
'%s resigned from %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_CLAIM:
return pht(
'%s commandeered %s.',
$author_link,
$object_link);
case DifferentialAction::ACTION_REOPEN:
return pht(
'%s reopened %s.',
$author_link,
$object_link);
}
break;
}
return parent::getTitleForFeed();
}
public function getIcon() {
switch ($this->getTransactionType()) {
case self::TYPE_INLINE:
return 'fa-comment';
case self::TYPE_ACTION:
switch ($this->getNewValue()) {
case DifferentialAction::ACTION_CLOSE:
return 'fa-check';
case DifferentialAction::ACTION_ACCEPT:
return 'fa-check-circle-o';
case DifferentialAction::ACTION_REJECT:
return 'fa-times-circle-o';
case DifferentialAction::ACTION_ABANDON:
return 'fa-plane';
case DifferentialAction::ACTION_RETHINK:
return 'fa-headphones';
case DifferentialAction::ACTION_REQUEST:
return 'fa-refresh';
case DifferentialAction::ACTION_RECLAIM:
case DifferentialAction::ACTION_REOPEN:
return 'fa-bullhorn';
case DifferentialAction::ACTION_RESIGN:
return 'fa-flag';
case DifferentialAction::ACTION_CLAIM:
return 'fa-flag';
}
case PhabricatorTransactions::TYPE_EDGE:
switch ($this->getMetadataValue('edge:type')) {
case DifferentialRevisionHasReviewerEdgeType::EDGECONST:
return 'fa-user';
}
}
return parent::getIcon();
}
public function shouldDisplayGroupWith(array $group) {
// Never group status changes with other types of actions, they're indirect
// and don't make sense when combined with direct actions.
if ($this->isStatusTransaction($this)) {
return false;
}
foreach ($group as $xaction) {
if ($this->isStatusTransaction($xaction)) {
return false;
}
}
return parent::shouldDisplayGroupWith($group);
}
private function isStatusTransaction($xaction) {
$status_type = DifferentialRevisionStatusTransaction::TRANSACTIONTYPE;
if ($xaction->getTransactionType() == $status_type) {
return true;
}
return false;
}
public function getColor() {
switch ($this->getTransactionType()) {
case self::TYPE_ACTION:
switch ($this->getNewValue()) {
case DifferentialAction::ACTION_CLOSE:
return PhabricatorTransactions::COLOR_INDIGO;
case DifferentialAction::ACTION_ACCEPT:
return PhabricatorTransactions::COLOR_GREEN;
case DifferentialAction::ACTION_REJECT:
return PhabricatorTransactions::COLOR_RED;
case DifferentialAction::ACTION_ABANDON:
return PhabricatorTransactions::COLOR_INDIGO;
case DifferentialAction::ACTION_RETHINK:
return PhabricatorTransactions::COLOR_RED;
case DifferentialAction::ACTION_REQUEST:
return PhabricatorTransactions::COLOR_SKY;
case DifferentialAction::ACTION_RECLAIM:
return PhabricatorTransactions::COLOR_SKY;
case DifferentialAction::ACTION_REOPEN:
return PhabricatorTransactions::COLOR_SKY;
case DifferentialAction::ACTION_RESIGN:
return PhabricatorTransactions::COLOR_ORANGE;
case DifferentialAction::ACTION_CLAIM:
return PhabricatorTransactions::COLOR_YELLOW;
}
}
return parent::getColor();
}
public function getNoEffectDescription() {
switch ($this->getTransactionType()) {
case self::TYPE_ACTION:
switch ($this->getNewValue()) {
case DifferentialAction::ACTION_CLOSE:
return pht('This revision is already closed.');
case DifferentialAction::ACTION_ABANDON:
return pht('This revision has already been abandoned.');
case DifferentialAction::ACTION_RECLAIM:
return pht(
'You can not reclaim this revision because his revision is '.
'not abandoned.');
case DifferentialAction::ACTION_REOPEN:
return pht(
'You can not reopen this revision because this revision is '.
'not closed.');
case DifferentialAction::ACTION_RETHINK:
return pht('This revision already requires changes.');
case DifferentialAction::ACTION_CLAIM:
return pht(
'You can not commandeer this revision because you already own '.
'it.');
}
break;
}
return parent::getNoEffectDescription();
}
public function renderAsTextForDoorkeeper(
DoorkeeperFeedStoryPublisher $publisher,
PhabricatorFeedStory $story,
array $xactions) {
$body = parent::renderAsTextForDoorkeeper($publisher, $story, $xactions);
$inlines = array();
foreach ($xactions as $xaction) {
if ($xaction->getTransactionType() == self::TYPE_INLINE) {
$inlines[] = $xaction;
}
}
// TODO: This is a bit gross, but far less bad than it used to be. It
// could be further cleaned up at some point.
if ($inlines) {
$engine = PhabricatorMarkupEngine::newMarkupEngine(array())
->setConfig('viewer', new PhabricatorUser())
->setMode(PhutilRemarkupEngine::MODE_TEXT);
$body .= "\n\n";
$body .= pht('Inline Comments');
$body .= "\n";
$changeset_ids = array();
foreach ($inlines as $inline) {
$changeset_ids[] = $inline->getComment()->getChangesetID();
}
$changesets = id(new DifferentialChangeset())->loadAllWhere(
'id IN (%Ld)',
$changeset_ids);
foreach ($inlines as $inline) {
$comment = $inline->getComment();
$changeset = idx($changesets, $comment->getChangesetID());
if (!$changeset) {
continue;
}
$filename = $changeset->getDisplayFilename();
$linenumber = $comment->getLineNumber();
$inline_text = $engine->markupText($comment->getContent());
$inline_text = rtrim($inline_text);
$body .= "{$filename}:{$linenumber} {$inline_text}\n";
}
}
return $body;
}
public function newWarningForTransactions($object, array $xactions) {
$warning = new PhabricatorTransactionWarning();
switch ($this->getTransactionType()) {
case self::TYPE_INLINE:
$warning->setTitleText(pht('Warning: Editing Inlines'));
$warning->setContinueActionText(pht('Save Inlines and Continue'));
$count = phutil_count($xactions);
$body = array();
$body[] = pht(
'You are currently editing %s inline comment(s) on this '.
'revision.',
$count);
$body[] = pht(
'These %s inline comment(s) will be saved and published.',
$count);
$warning->setWarningParagraphs($body);
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$warning->setTitleText(pht('Warning: Draft Revision'));
$warning->setContinueActionText(pht('Tell No One'));
$body = array();
$body[] = pht(
'This is a draft revision that will not publish any '.
'notifications until the author requests review.');
$body[] = pht('Mentioned or subscribed users will not be notified.');
$warning->setWarningParagraphs($body);
break;
}
return $warning;
}
}
diff --git a/src/applications/diffusion/conduit/DiffusionQueryConduitAPIMethod.php b/src/applications/diffusion/conduit/DiffusionQueryConduitAPIMethod.php
index cd6a45360b..d797635985 100644
--- a/src/applications/diffusion/conduit/DiffusionQueryConduitAPIMethod.php
+++ b/src/applications/diffusion/conduit/DiffusionQueryConduitAPIMethod.php
@@ -1,170 +1,169 @@
<?php
abstract class DiffusionQueryConduitAPIMethod
extends DiffusionConduitAPIMethod {
public function shouldAllowPublic() {
return true;
}
private $diffusionRequest;
private $repository;
protected function setDiffusionRequest(DiffusionRequest $request) {
$this->diffusionRequest = $request;
return $this;
}
protected function getDiffusionRequest() {
return $this->diffusionRequest;
}
protected function getRepository(ConduitAPIRequest $request) {
return $this->getDiffusionRequest()->getRepository();
}
final protected function defineErrorTypes() {
return $this->defineCustomErrorTypes() +
array(
'ERR-UNKNOWN-REPOSITORY' =>
pht('There is no matching repository.'),
'ERR-UNKNOWN-VCS-TYPE' =>
pht('Unknown repository VCS type.'),
'ERR-UNSUPPORTED-VCS' =>
pht('VCS is not supported for this method.'),
);
}
/**
* Subclasses should override this to specify custom error types.
*/
protected function defineCustomErrorTypes() {
return array();
}
final protected function defineParamTypes() {
return $this->defineCustomParamTypes() +
array(
'callsign' => 'optional string (deprecated)',
'repository' => 'optional string',
'branch' => 'optional string',
);
}
/**
* Subclasses should override this to specify custom param types.
*/
protected function defineCustomParamTypes() {
return array();
}
/**
* Subclasses should override these methods with the proper result for the
* pertinent version control system, e.g. getGitResult for Git.
*
* If the result is not supported for that VCS, do not implement it. e.g.
* Subversion (SVN) does not support branches.
*/
protected function getGitResult(ConduitAPIRequest $request) {
throw new ConduitException('ERR-UNSUPPORTED-VCS');
}
protected function getSVNResult(ConduitAPIRequest $request) {
throw new ConduitException('ERR-UNSUPPORTED-VCS');
}
protected function getMercurialResult(ConduitAPIRequest $request) {
throw new ConduitException('ERR-UNSUPPORTED-VCS');
}
/**
* This method is final because most queries will need to construct a
* @{class:DiffusionRequest} and use it. Consolidating this codepath and
* enforcing @{method:getDiffusionRequest} works when we need it is good.
*
* @{method:getResult} should be overridden by subclasses as necessary, e.g.
* there is a common operation across all version control systems that
* should occur after @{method:getResult}, like formatting a timestamp.
*/
final protected function execute(ConduitAPIRequest $request) {
$drequest = $this->getDiffusionRequest();
// We pass this flag on to prevent proxying of any other Conduit calls
// which we need to make in order to respond to this one. Although we
// could safely proxy them, we take a big performance hit in the common
// case, and doing more proxying wouldn't exercise any additional code so
// we wouldn't gain a testability/predictability benefit.
$is_cluster_request = $request->getIsClusterRequest();
$drequest->setIsClusterRequest($is_cluster_request);
$viewer = $request->getViewer();
$repository = $drequest->getRepository();
// TODO: Allow web UI queries opt out of this if they don't care about
// fetching the most up-to-date data? Synchronization can be slow, and a
// lot of web reads are probably fine if they're a few seconds out of
// date.
id(new DiffusionRepositoryClusterEngine())
->setViewer($viewer)
->setRepository($repository)
->synchronizeWorkingCopyBeforeRead();
return $this->getResult($request);
}
protected function newConduitCallProxyClient(ConduitAPIRequest $request) {
$viewer = $request->getViewer();
$identifier = $request->getValue('repository');
if ($identifier === null) {
$identifier = $request->getValue('callsign');
}
$drequest = DiffusionRequest::newFromDictionary(
array(
'user' => $viewer,
'repository' => $identifier,
'branch' => $request->getValue('branch'),
'path' => $request->getValue('path'),
'commit' => $request->getValue('commit'),
));
if (!$drequest) {
throw new Exception(
pht(
'Repository "%s" is not a valid repository.',
$identifier));
}
$repository = $drequest->getRepository();
$client = $repository->newConduitClientForRequest($request);
if ($client) {
return $client;
}
$this->setDiffusionRequest($drequest);
return null;
}
protected function getResult(ConduitAPIRequest $request) {
$repository = $this->getRepository($request);
$result = null;
switch ($repository->getVersionControlSystem()) {
case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT:
$result = $this->getGitResult($request);
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL:
$result = $this->getMercurialResult($request);
break;
case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN:
$result = $this->getSVNResult($request);
break;
default:
throw new ConduitException('ERR-UNKNOWN-VCS-TYPE');
- break;
}
return $result;
}
}
diff --git a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php
index e4e8fd2293..27f7410090 100644
--- a/src/applications/diffusion/engine/DiffusionCommitHookEngine.php
+++ b/src/applications/diffusion/engine/DiffusionCommitHookEngine.php
@@ -1,1473 +1,1472 @@
<?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_REQUEST = 'PHABRICATOR_REQUEST';
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 $requestIdentifier;
private $transactionKey;
private $mercurialHook;
private $mercurialCommits = array();
private $gitCommits = array();
private $startTime;
private $heraldViewerProjects;
private $rejectCode = PhabricatorRepositoryPushLog::REJECT_BROKEN;
private $rejectDetails;
private $emailPHIDs = array();
private $changesets = array();
private $changesetsSize = 0;
private $filesizeCache = 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 setRequestIdentifier($request_identifier) {
$this->requestIdentifier = $request_identifier;
return $this;
}
public function getRequestIdentifier() {
return $this->requestIdentifier;
}
public function setStartTime($start_time) {
$this->startTime = $start_time;
return $this;
}
public function getStartTime() {
return $this->startTime;
}
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;
}
$content_updates = $this->findContentUpdates($ref_updates);
$all_updates = array_merge($ref_updates, $content_updates);
// If this is an "initial import" (a sizable push to a previously empty
// repository) we'll allow enormous changes and disable Herald rules.
// These rulesets can consume a large amount of time and memory and are
// generally not relevant when importing repository history.
$is_initial_import = $this->isInitialImport($all_updates);
if (!$is_initial_import) {
$this->applyHeraldRefRules($ref_updates);
}
try {
if (!$is_initial_import) {
$this->rejectOversizedFiles($content_updates);
}
} catch (DiffusionCommitHookRejectException $ex) {
// If we're rejecting oversized files, flag everything.
$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_OVERSIZED;
throw $ex;
}
try {
if (!$is_initial_import) {
$this->rejectCommitsAffectingTooManyPaths($content_updates);
}
} catch (DiffusionCommitHookRejectException $ex) {
$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_TOUCHES;
throw $ex;
}
try {
if (!$is_initial_import) {
$this->rejectEnormousChanges($content_updates);
}
} catch (DiffusionCommitHookRejectException $ex) {
// If we're rejecting enormous changes, flag everything.
$this->rejectCode = PhabricatorRepositoryPushLog::REJECT_ENORMOUS;
throw $ex;
}
if (!$is_initial_import) {
$this->applyHeraldContentRules($content_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->saveWithLogs($all_updates);
if ($caught) {
throw $caught;
}
// If this went through cleanly and was an import, set the importing flag
// on the repository. It will be cleared once we fully process everything.
if ($is_initial_import) {
$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) {
$this->applyHeraldRules(
$ref_updates,
new HeraldPreCommitRefAdapter());
}
private function applyHeraldContentRules(array $content_updates) {
$this->applyHeraldRules(
$content_updates,
new HeraldPreCommitContentAdapter());
}
private function applyHeraldRules(
array $updates,
HeraldAdapter $adapter_template) {
if (!$updates) {
return;
}
$viewer = $this->getViewer();
$adapter_template
->setHookEngine($this)
->setActingAsPHID($viewer->getPHID());
$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 {
$ref_type = PhabricatorRepositoryPushLog::REFTYPE_REF;
}
$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 %s %s --not --all --',
'--format=%H',
gitsprintf('%s', $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_REQUEST => $this->getRequestIdentifier(),
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(PhabricatorTime::getNow());
}
private function newPushEvent() {
$viewer = $this->getViewer();
$hook_start = $this->getStartTime();
$event = PhabricatorRepositoryPushEvent::initializeNewEvent($viewer)
->setRepositoryPHID($this->getRepository()->getPHID())
->setRemoteAddress($this->getRemoteAddress())
->setRemoteProtocol($this->getRemoteProtocol())
->setEpoch(PhabricatorTime::getNow())
->setHookWait(phutil_microseconds_since($hook_start));
$identifier = $this->getRequestIdentifier();
if ($identifier !== null && strlen($identifier)) {
$event->setRequestIdentifier($identifier);
}
return $event;
}
private function rejectEnormousChanges(array $content_updates) {
$repository = $this->getRepository();
if ($repository->shouldAllowEnormousChanges()) {
return;
}
// See T13142. Don't cache more than 64MB of changesets. For normal small
// pushes, caching everything here can let us hit the cache from Herald if
// we need to run content rules, which speeds things up a bit. For large
// pushes, we may not be able to hold everything in memory.
$cache_limit = 1024 * 1024 * 64;
foreach ($content_updates as $update) {
$identifier = $update->getRefNew();
try {
$info = $this->loadChangesetsForCommit($identifier);
list($changesets, $size) = $info;
if ($this->changesetsSize + $size <= $cache_limit) {
$this->changesets[$identifier] = $changesets;
$this->changesetsSize += $size;
}
} catch (Exception $ex) {
$this->changesets[$identifier] = $ex;
$message = pht(
'ENORMOUS CHANGE'.
"\n".
'Enormous change protection is enabled for this repository, but '.
'you are pushing an enormous change ("%s"). Edit the repository '.
'configuration before making enormous changes.'.
"\n\n".
"Content Exception: %s",
$identifier,
$ex->getMessage());
throw new DiffusionCommitHookRejectException($message);
}
}
}
private 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 ("%s") is enormous (larger than %s '.
'bytes).',
$identifier,
new PhutilNumber($byte_limit)));
}
if (!strlen($raw_diff)) {
// If the commit is actually empty, just return no changesets.
return array(array(), 0);
}
$parser = new ArcanistDiffParser();
$changes = $parser->parseDiff($raw_diff);
$diff = DifferentialDiff::newEphemeralFromRawChanges(
$changes);
$changesets = $diff->getChangesets();
$size = strlen($raw_diff);
return array($changesets, $size);
}
public function getChangesetsForCommit($identifier) {
if (isset($this->changesets[$identifier])) {
$cached = $this->changesets[$identifier];
if ($cached instanceof Exception) {
throw $cached;
}
return $cached;
}
$info = $this->loadChangesetsForCommit($identifier);
list($changesets, $size) = $info;
return $changesets;
}
private function rejectOversizedFiles(array $content_updates) {
$repository = $this->getRepository();
$limit = $repository->getFilesizeLimit();
if (!$limit) {
return;
}
foreach ($content_updates as $update) {
$identifier = $update->getRefNew();
$sizes = $this->getFileSizesForCommit($identifier);
foreach ($sizes as $path => $size) {
if ($size <= $limit) {
continue;
}
$message = pht(
'OVERSIZED FILE'.
"\n".
'This repository ("%s") is configured with a maximum individual '.
'file size limit, but you are pushing a change ("%s") which causes '.
'the size of a file ("%s") to exceed the limit. The commit makes '.
'the file %s bytes long, but the limit for this repository is '.
'%s bytes.',
$repository->getDisplayName(),
$identifier,
$path,
new PhutilNumber($size),
new PhutilNumber($limit));
throw new DiffusionCommitHookRejectException($message);
}
}
}
private function rejectCommitsAffectingTooManyPaths(array $content_updates) {
$repository = $this->getRepository();
$limit = $repository->getTouchLimit();
if (!$limit) {
return;
}
foreach ($content_updates as $update) {
$identifier = $update->getRefNew();
$sizes = $this->getFileSizesForCommit($identifier);
if (count($sizes) > $limit) {
$message = pht(
'COMMIT AFFECTS TOO MANY PATHS'.
"\n".
'This repository ("%s") is configured with a touched files limit '.
'that caps the maximum number of paths any single commit may '.
'affect. You are pushing a change ("%s") which exceeds this '.
'limit: it affects %s paths, but the largest number of paths any '.
'commit may affect is %s paths.',
$repository->getDisplayName(),
$identifier,
phutil_count($sizes),
new PhutilNumber($limit));
throw new DiffusionCommitHookRejectException($message);
}
}
}
public function getFileSizesForCommit($identifier) {
if (!isset($this->filesizeCache[$identifier])) {
$file_sizes = $this->loadFileSizesForCommit($identifier);
$this->filesizeCache[$identifier] = $file_sizes;
}
return $this->filesizeCache[$identifier];
}
private function loadFileSizesForCommit($identifier) {
$repository = $this->getRepository();
return id(new DiffusionLowLevelFilesizeQuery())
->setRepository($repository)
->withIdentifier($identifier)
->execute();
}
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
// 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/feed/story/PhabricatorFeedStory.php b/src/applications/feed/story/PhabricatorFeedStory.php
index 2d0ed8836b..9ef05bddea 100644
--- a/src/applications/feed/story/PhabricatorFeedStory.php
+++ b/src/applications/feed/story/PhabricatorFeedStory.php
@@ -1,492 +1,491 @@
<?php
/**
* Manages rendering and aggregation of a story. A story is an event (like a
* user adding a comment) which may be represented in different forms on
* different channels (like feed, notifications and realtime alerts).
*
* @task load Loading Stories
* @task policy Policy Implementation
*/
abstract class PhabricatorFeedStory
extends Phobject
implements
PhabricatorPolicyInterface,
PhabricatorMarkupInterface {
private $data;
private $hasViewed;
private $hovercard = false;
private $renderingTarget = PhabricatorApplicationTransaction::TARGET_HTML;
private $handles = array();
private $objects = array();
private $projectPHIDs = array();
private $markupFieldOutput = array();
/* -( Loading Stories )---------------------------------------------------- */
/**
* Given @{class:PhabricatorFeedStoryData} rows, load them into objects and
* construct appropriate @{class:PhabricatorFeedStory} wrappers for each
* data row.
*
* @param list<dict> $rows List of @{class:PhabricatorFeedStoryData} rows
* from the database.
* @param PhabricatorUser $viewer
* @return list<PhabricatorFeedStory> List of @{class:PhabricatorFeedStory}
* objects.
* @task load
*/
public static function loadAllFromRows(array $rows, PhabricatorUser $viewer) {
$stories = array();
$data = id(new PhabricatorFeedStoryData())->loadAllFromArray($rows);
foreach ($data as $story_data) {
$class = $story_data->getStoryType();
try {
$ok =
class_exists($class) &&
is_subclass_of($class, __CLASS__);
} catch (PhutilMissingSymbolException $ex) {
$ok = false;
}
// If the story type isn't a valid class or isn't a subclass of
// PhabricatorFeedStory, decline to load it.
if (!$ok) {
continue;
}
$key = $story_data->getChronologicalKey();
$stories[$key] = newv($class, array($story_data));
}
$object_phids = array();
$key_phids = array();
foreach ($stories as $key => $story) {
$phids = array();
foreach ($story->getRequiredObjectPHIDs() as $phid) {
$phids[$phid] = true;
}
if ($story->getPrimaryObjectPHID()) {
$phids[$story->getPrimaryObjectPHID()] = true;
}
$key_phids[$key] = $phids;
$object_phids += $phids;
}
$object_query = id(new PhabricatorObjectQuery())
->setViewer($viewer)
->withPHIDs(array_keys($object_phids));
$objects = $object_query->execute();
foreach ($key_phids as $key => $phids) {
if (!$phids) {
continue;
}
$story_objects = array_select_keys($objects, array_keys($phids));
if (count($story_objects) != count($phids)) {
// An object this story requires either does not exist or is not visible
// to the user. Decline to render the story.
unset($stories[$key]);
unset($key_phids[$key]);
continue;
}
$stories[$key]->setObjects($story_objects);
}
// If stories are about PhabricatorProjectInterface objects, load the
// projects the objects are a part of so we can render project tags
// on the stories.
$project_phids = array();
foreach ($objects as $object) {
if ($object instanceof PhabricatorProjectInterface) {
$project_phids[$object->getPHID()] = array();
}
}
if ($project_phids) {
$edge_query = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array_keys($project_phids))
->withEdgeTypes(
array(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
));
$edge_query->execute();
foreach ($project_phids as $phid => $ignored) {
$project_phids[$phid] = $edge_query->getDestinationPHIDs(array($phid));
}
}
$handle_phids = array();
foreach ($stories as $key => $story) {
foreach ($story->getRequiredHandlePHIDs() as $phid) {
$key_phids[$key][$phid] = true;
}
if ($story->getAuthorPHID()) {
$key_phids[$key][$story->getAuthorPHID()] = true;
}
$object_phid = $story->getPrimaryObjectPHID();
$object_project_phids = idx($project_phids, $object_phid, array());
$story->setProjectPHIDs($object_project_phids);
foreach ($object_project_phids as $dst) {
$key_phids[$key][$dst] = true;
}
$handle_phids += $key_phids[$key];
}
// NOTE: This setParentQuery() is a little sketchy. Ideally, this whole
// method should be inside FeedQuery and it should be the parent query of
// both subqueries. We're just trying to share the workspace cache.
$handles = id(new PhabricatorHandleQuery())
->setViewer($viewer)
->setParentQuery($object_query)
->withPHIDs(array_keys($handle_phids))
->execute();
foreach ($key_phids as $key => $phids) {
if (!$phids) {
continue;
}
$story_handles = array_select_keys($handles, array_keys($phids));
$stories[$key]->setHandles($story_handles);
}
// Load and process story markup blocks.
$engine = new PhabricatorMarkupEngine();
$engine->setViewer($viewer);
foreach ($stories as $story) {
foreach ($story->getFieldStoryMarkupFields() as $field) {
$engine->addObject($story, $field);
}
}
$engine->process();
foreach ($stories as $story) {
foreach ($story->getFieldStoryMarkupFields() as $field) {
$story->setMarkupFieldOutput(
$field,
$engine->getOutput($story, $field));
}
}
return $stories;
}
public function setMarkupFieldOutput($field, $output) {
$this->markupFieldOutput[$field] = $output;
return $this;
}
public function getMarkupFieldOutput($field) {
if (!array_key_exists($field, $this->markupFieldOutput)) {
throw new Exception(
pht(
'Trying to retrieve markup field key "%s", but this feed story '.
'did not request it be rendered.',
$field));
}
return $this->markupFieldOutput[$field];
}
public function setHovercard($hover) {
$this->hovercard = $hover;
return $this;
}
public function setRenderingTarget($target) {
$this->validateRenderingTarget($target);
$this->renderingTarget = $target;
return $this;
}
public function getRenderingTarget() {
return $this->renderingTarget;
}
private function validateRenderingTarget($target) {
switch ($target) {
case PhabricatorApplicationTransaction::TARGET_HTML:
case PhabricatorApplicationTransaction::TARGET_TEXT:
break;
default:
throw new Exception(pht('Unknown rendering target: %s', $target));
- break;
}
}
public function setObjects(array $objects) {
$this->objects = $objects;
return $this;
}
public function getObject($phid) {
$object = idx($this->objects, $phid);
if (!$object) {
throw new Exception(
pht(
"Story is asking for an object it did not request ('%s')!",
$phid));
}
return $object;
}
public function getPrimaryObject() {
$phid = $this->getPrimaryObjectPHID();
if (!$phid) {
throw new Exception(pht('Story has no primary object!'));
}
return $this->getObject($phid);
}
public function getPrimaryObjectPHID() {
return null;
}
final public function __construct(PhabricatorFeedStoryData $data) {
$this->data = $data;
}
abstract public function renderView();
public function renderAsTextForDoorkeeper(
DoorkeeperFeedStoryPublisher $publisher) {
// TODO: This (and text rendering) should be properly abstract and
// universal. However, this is far less bad than it used to be, and we
// need to clean up more old feed code to really make this reasonable.
return pht(
'(Unable to render story of class %s for Doorkeeper.)',
get_class($this));
}
public function getRequiredHandlePHIDs() {
return array();
}
public function getRequiredObjectPHIDs() {
return array();
}
public function setHasViewed($has_viewed) {
$this->hasViewed = $has_viewed;
return $this;
}
public function getHasViewed() {
return $this->hasViewed;
}
final public function setHandles(array $handles) {
assert_instances_of($handles, 'PhabricatorObjectHandle');
$this->handles = $handles;
return $this;
}
final protected function getObjects() {
return $this->objects;
}
final protected function getHandles() {
return $this->handles;
}
final protected function getHandle($phid) {
if (isset($this->handles[$phid])) {
if ($this->handles[$phid] instanceof PhabricatorObjectHandle) {
return $this->handles[$phid];
}
}
$handle = new PhabricatorObjectHandle();
$handle->setPHID($phid);
$handle->setName(pht("Unloaded Object '%s'", $phid));
return $handle;
}
final public function getStoryData() {
return $this->data;
}
final public function getEpoch() {
return $this->getStoryData()->getEpoch();
}
final public function getChronologicalKey() {
return $this->getStoryData()->getChronologicalKey();
}
final public function getValue($key, $default = null) {
return $this->getStoryData()->getValue($key, $default);
}
final public function getAuthorPHID() {
return $this->getStoryData()->getAuthorPHID();
}
final protected function renderHandleList(array $phids) {
$items = array();
foreach ($phids as $phid) {
$items[] = $this->linkTo($phid);
}
$list = null;
switch ($this->getRenderingTarget()) {
case PhabricatorApplicationTransaction::TARGET_TEXT:
$list = implode(', ', $items);
break;
case PhabricatorApplicationTransaction::TARGET_HTML:
$list = phutil_implode_html(', ', $items);
break;
}
return $list;
}
final protected function linkTo($phid) {
$handle = $this->getHandle($phid);
switch ($this->getRenderingTarget()) {
case PhabricatorApplicationTransaction::TARGET_TEXT:
return $handle->getLinkName();
}
return $handle->renderLink();
}
final protected function renderString($str) {
switch ($this->getRenderingTarget()) {
case PhabricatorApplicationTransaction::TARGET_TEXT:
return $str;
case PhabricatorApplicationTransaction::TARGET_HTML:
return phutil_tag('strong', array(), $str);
}
}
final public function renderSummary($text, $len = 128) {
if ($len) {
$text = id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs($len)
->truncateString($text);
}
switch ($this->getRenderingTarget()) {
case PhabricatorApplicationTransaction::TARGET_HTML:
$text = phutil_escape_html_newlines($text);
break;
}
return $text;
}
public function getNotificationAggregations() {
return array();
}
protected function newStoryView() {
$view = id(new PHUIFeedStoryView())
->setChronologicalKey($this->getChronologicalKey())
->setEpoch($this->getEpoch())
->setViewed($this->getHasViewed());
$project_phids = $this->getProjectPHIDs();
if ($project_phids) {
$view->setTags($this->renderHandleList($project_phids));
}
return $view;
}
public function setProjectPHIDs(array $phids) {
$this->projectPHIDs = $phids;
return $this;
}
public function getProjectPHIDs() {
return $this->projectPHIDs;
}
public function getFieldStoryMarkupFields() {
return array();
}
public function isVisibleInFeed() {
return true;
}
public function isVisibleInNotifications() {
return true;
}
/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
public function getPHID() {
return null;
}
/**
* @task policy
*/
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
);
}
/**
* @task policy
*/
public function getPolicy($capability) {
// NOTE: We enforce that a user can see all the objects a story is about
// when loading it, so we don't need to perform a equivalent secondary
// policy check later.
return PhabricatorPolicies::getMostOpenPolicy();
}
/**
* @task policy
*/
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
/* -( PhabricatorMarkupInterface Implementation )--------------------------- */
public function getMarkupFieldKey($field) {
return 'feed:'.$this->getChronologicalKey().':'.$field;
}
public function newMarkupEngine($field) {
return PhabricatorMarkupEngine::getEngine('feed');
}
public function getMarkupText($field) {
throw new PhutilMethodNotImplementedException();
}
public function didMarkupText(
$field,
$output,
PhutilMarkupEngine $engine) {
return $output;
}
public function shouldUseMarkupCache($field) {
return true;
}
}
diff --git a/src/applications/files/management/PhabricatorFilesManagementRebuildWorkflow.php b/src/applications/files/management/PhabricatorFilesManagementRebuildWorkflow.php
index e577a04d55..4e6dd4302a 100644
--- a/src/applications/files/management/PhabricatorFilesManagementRebuildWorkflow.php
+++ b/src/applications/files/management/PhabricatorFilesManagementRebuildWorkflow.php
@@ -1,144 +1,143 @@
<?php
final class PhabricatorFilesManagementRebuildWorkflow
extends PhabricatorFilesManagementWorkflow {
protected function didConstruct() {
$arguments = $this->newIteratorArguments();
$arguments[] = array(
'name' => 'dry-run',
'help' => pht('Show what would be updated.'),
);
$arguments[] = array(
'name' => 'rebuild-mime',
'help' => pht('Rebuild MIME information.'),
);
$arguments[] = array(
'name' => 'rebuild-dimensions',
'help' => pht('Rebuild image dimension information.'),
);
$this
->setName('rebuild')
->setSynopsis(pht('Rebuild metadata of old files.'))
->setArguments($arguments);
}
public function execute(PhutilArgumentParser $args) {
$console = PhutilConsole::getConsole();
$iterator = $this->buildIterator($args);
$update = array(
'mime' => $args->getArg('rebuild-mime'),
'dimensions' => $args->getArg('rebuild-dimensions'),
);
// If the user didn't select anything, rebuild everything.
if (!array_filter($update)) {
foreach ($update as $key => $ignored) {
$update[$key] = true;
}
}
$is_dry_run = $args->getArg('dry-run');
$failed = array();
foreach ($iterator as $file) {
$fid = 'F'.$file->getID();
if ($update['mime']) {
$tmp = new TempFile();
Filesystem::writeFile($tmp, $file->loadFileData());
$new_type = Filesystem::getMimeType($tmp);
if ($new_type == $file->getMimeType()) {
$console->writeOut(
"%s\n",
pht(
'%s: Mime type not changed (%s).',
$fid,
$new_type));
} else {
if ($is_dry_run) {
$console->writeOut(
"%s\n",
pht(
"%s: Would update Mime type: '%s' -> '%s'.",
$fid,
$file->getMimeType(),
$new_type));
} else {
$console->writeOut(
"%s\n",
pht(
"%s: Updating Mime type: '%s' -> '%s'.",
$fid,
$file->getMimeType(),
$new_type));
$file->setMimeType($new_type);
$file->save();
}
}
}
if ($update['dimensions']) {
if (!$file->isViewableImage()) {
$console->writeOut(
"%s\n",
pht('%s: Not an image file.', $fid));
continue;
}
$metadata = $file->getMetadata();
$image_width = idx($metadata, PhabricatorFile::METADATA_IMAGE_WIDTH);
$image_height = idx($metadata, PhabricatorFile::METADATA_IMAGE_HEIGHT);
if ($image_width && $image_height) {
$console->writeOut(
"%s\n",
pht('%s: Image dimensions already exist.', $fid));
continue;
}
if ($is_dry_run) {
$console->writeOut(
"%s\n",
pht('%s: Would update file dimensions (dry run)', $fid));
continue;
}
$console->writeOut(
pht('%s: Updating metadata... ', $fid));
try {
$file->updateDimensions();
$console->writeOut("%s\n", pht('Done.'));
} catch (Exception $ex) {
$console->writeOut("%s\n", pht('Failed!'));
$console->writeErr("%s\n", (string)$ex);
$failed[] = $file;
}
}
}
if ($failed) {
$console->writeOut("**%s**\n", pht('Failures!'));
$ids = array();
foreach ($failed as $file) {
$ids[] = 'F'.$file->getID();
}
$console->writeOut("%s\n", implode(', ', $ids));
return 1;
} else {
$console->writeOut("**%s**\n", pht('Success!'));
return 0;
}
- return 0;
}
}
diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php
index 16681973e0..3da430d95c 100644
--- a/src/applications/files/storage/PhabricatorFile.php
+++ b/src/applications/files/storage/PhabricatorFile.php
@@ -1,1853 +1,1850 @@
<?php
/**
* Parameters
* ==========
*
* When creating a new file using a method like @{method:newFromFileData}, these
* parameters are supported:
*
* | name | Human readable filename.
* | authorPHID | User PHID of uploader.
* | ttl.absolute | Temporary file lifetime as an epoch timestamp.
* | ttl.relative | Temporary file lifetime, relative to now, in seconds.
* | viewPolicy | File visibility policy.
* | isExplicitUpload | Used to show users files they explicitly uploaded.
* | canCDN | Allows the file to be cached and delivered over a CDN.
* | profile | Marks the file as a profile image.
* | format | Internal encoding format.
* | mime-type | Optional, explicit file MIME type.
* | builtin | Optional filename, identifies this as a builtin.
*
*/
final class PhabricatorFile extends PhabricatorFileDAO
implements
PhabricatorApplicationTransactionInterface,
PhabricatorTokenReceiverInterface,
PhabricatorSubscribableInterface,
PhabricatorFlaggableInterface,
PhabricatorPolicyInterface,
PhabricatorDestructibleInterface,
PhabricatorConduitResultInterface,
PhabricatorIndexableInterface,
PhabricatorNgramsInterface {
const METADATA_IMAGE_WIDTH = 'width';
const METADATA_IMAGE_HEIGHT = 'height';
const METADATA_CAN_CDN = 'canCDN';
const METADATA_BUILTIN = 'builtin';
const METADATA_PARTIAL = 'partial';
const METADATA_PROFILE = 'profile';
const METADATA_STORAGE = 'storage';
const METADATA_INTEGRITY = 'integrity';
const METADATA_CHUNK = 'chunk';
const METADATA_ALT_TEXT = 'alt';
const STATUS_ACTIVE = 'active';
const STATUS_DELETED = 'deleted';
protected $name;
protected $mimeType;
protected $byteSize;
protected $authorPHID;
protected $secretKey;
protected $contentHash;
protected $metadata = array();
protected $mailKey;
protected $builtinKey;
protected $storageEngine;
protected $storageFormat;
protected $storageHandle;
protected $ttl;
protected $isExplicitUpload = 1;
protected $viewPolicy = PhabricatorPolicies::POLICY_USER;
protected $isPartial = 0;
protected $isDeleted = 0;
private $objects = self::ATTACHABLE;
private $objectPHIDs = self::ATTACHABLE;
private $originalFile = self::ATTACHABLE;
private $transforms = self::ATTACHABLE;
public static function initializeNewFile() {
$app = id(new PhabricatorApplicationQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withClasses(array('PhabricatorFilesApplication'))
->executeOne();
$view_policy = $app->getPolicy(
FilesDefaultViewCapability::CAPABILITY);
return id(new PhabricatorFile())
->setViewPolicy($view_policy)
->setIsPartial(0)
->attachOriginalFile(null)
->attachObjects(array())
->attachObjectPHIDs(array());
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'metadata' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'name' => 'sort255?',
'mimeType' => 'text255?',
'byteSize' => 'uint64',
'storageEngine' => 'text32',
'storageFormat' => 'text32',
'storageHandle' => 'text255',
'authorPHID' => 'phid?',
'secretKey' => 'bytes20?',
'contentHash' => 'bytes64?',
'ttl' => 'epoch?',
'isExplicitUpload' => 'bool?',
'mailKey' => 'bytes20',
'isPartial' => 'bool',
'builtinKey' => 'text64?',
'isDeleted' => 'bool',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'authorPHID' => array(
'columns' => array('authorPHID'),
),
'contentHash' => array(
'columns' => array('contentHash'),
),
'key_ttl' => array(
'columns' => array('ttl'),
),
'key_dateCreated' => array(
'columns' => array('dateCreated'),
),
'key_partial' => array(
'columns' => array('authorPHID', 'isPartial'),
),
'key_builtin' => array(
'columns' => array('builtinKey'),
'unique' => true,
),
'key_engine' => array(
'columns' => array('storageEngine', 'storageHandle(64)'),
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorFileFilePHIDType::TYPECONST);
}
public function save() {
if (!$this->getSecretKey()) {
$this->setSecretKey($this->generateSecretKey());
}
if (!$this->getMailKey()) {
$this->setMailKey(Filesystem::readRandomCharacters(20));
}
return parent::save();
}
public function saveAndIndex() {
$this->save();
if ($this->isIndexableFile()) {
PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID());
}
return $this;
}
private function isIndexableFile() {
if ($this->getIsChunk()) {
return false;
}
return true;
}
/**
* Get file monogram in the format of "F123"
* @return string
*/
public function getMonogram() {
return 'F'.$this->getID();
}
public function scrambleSecret() {
return $this->setSecretKey($this->generateSecretKey());
}
public static function readUploadedFileData($spec) {
if (!$spec) {
throw new Exception(pht('No file was uploaded!'));
}
$err = idx($spec, 'error');
if ($err) {
throw new PhabricatorFileUploadException($err);
}
$tmp_name = idx($spec, 'tmp_name');
// NOTE: If we parsed the request body ourselves, the files we wrote will
// not be registered in the `is_uploaded_file()` list. It's fine to skip
// this check: it just protects against sloppy code from the long ago era
// of "register_globals".
if (ini_get('enable_post_data_reading')) {
$is_valid = @is_uploaded_file($tmp_name);
if (!$is_valid) {
throw new Exception(pht('File is not an uploaded file.'));
}
}
$file_data = Filesystem::readFile($tmp_name);
$file_size = idx($spec, 'size');
if (strlen($file_data) != $file_size) {
throw new Exception(pht('File size disagrees with uploaded size.'));
}
return $file_data;
}
public static function newFromPHPUpload($spec, array $params = array()) {
$file_data = self::readUploadedFileData($spec);
$file_name = nonempty(
idx($params, 'name'),
idx($spec, 'name'));
$params = array(
'name' => $file_name,
) + $params;
return self::newFromFileData($file_data, $params);
}
public static function newFromXHRUpload($data, array $params = array()) {
return self::newFromFileData($data, $params);
}
public static function newFileFromContentHash($hash, array $params) {
if ($hash === null) {
return null;
}
// Check to see if a file with same hash already exists.
$file = id(new PhabricatorFile())->loadOneWhere(
'contentHash = %s LIMIT 1',
$hash);
if (!$file) {
return null;
}
$copy_of_storage_engine = $file->getStorageEngine();
$copy_of_storage_handle = $file->getStorageHandle();
$copy_of_storage_format = $file->getStorageFormat();
$copy_of_storage_properties = $file->getStorageProperties();
$copy_of_byte_size = $file->getByteSize();
$copy_of_mime_type = $file->getMimeType();
$new_file = self::initializeNewFile();
$new_file->setByteSize($copy_of_byte_size);
$new_file->setContentHash($hash);
$new_file->setStorageEngine($copy_of_storage_engine);
$new_file->setStorageHandle($copy_of_storage_handle);
$new_file->setStorageFormat($copy_of_storage_format);
$new_file->setStorageProperties($copy_of_storage_properties);
$new_file->setMimeType($copy_of_mime_type);
$new_file->copyDimensions($file);
$new_file->readPropertiesFromParameters($params);
$new_file->saveAndIndex();
return $new_file;
}
public static function newChunkedFile(
PhabricatorFileStorageEngine $engine,
$length,
array $params) {
$file = self::initializeNewFile();
$file->setByteSize($length);
// NOTE: Once we receive the first chunk, we'll detect its MIME type and
// update the parent file if a MIME type hasn't been provided. This matters
// for large media files like video.
$mime_type = idx($params, 'mime-type', '');
if (!strlen($mime_type)) {
$file->setMimeType('application/octet-stream');
}
$chunked_hash = idx($params, 'chunkedHash');
// Get rid of this parameter now; we aren't passing it any further down
// the stack.
unset($params['chunkedHash']);
if ($chunked_hash) {
$file->setContentHash($chunked_hash);
} else {
// See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some
// discussion of this.
$seed = Filesystem::readRandomBytes(64);
$hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput(
$seed);
$file->setContentHash($hash);
}
$file->setStorageEngine($engine->getEngineIdentifier());
$file->setStorageHandle(PhabricatorFileChunk::newChunkHandle());
// Chunked files are always stored raw because they do not actually store
// data. The chunks do, and can be individually formatted.
$file->setStorageFormat(PhabricatorFileRawStorageFormat::FORMATKEY);
$file->setIsPartial(1);
$file->readPropertiesFromParameters($params);
return $file;
}
private static function buildFromFileData($data, array $params = array()) {
if (isset($params['storageEngines'])) {
$engines = $params['storageEngines'];
} else {
$size = strlen($data);
$engines = PhabricatorFileStorageEngine::loadStorageEngines($size);
if (!$engines) {
throw new Exception(
pht(
'No configured storage engine can store this file. See '.
'"Configuring File Storage" in the documentation for '.
'information on configuring storage engines.'));
}
}
assert_instances_of($engines, 'PhabricatorFileStorageEngine');
if (!$engines) {
throw new Exception(pht('No valid storage engines are available!'));
}
$file = self::initializeNewFile();
$aes_type = PhabricatorFileAES256StorageFormat::FORMATKEY;
$has_aes = PhabricatorKeyring::getDefaultKeyName($aes_type);
if ($has_aes !== null) {
$default_key = PhabricatorFileAES256StorageFormat::FORMATKEY;
} else {
$default_key = PhabricatorFileRawStorageFormat::FORMATKEY;
}
$key = idx($params, 'format', $default_key);
// Callers can pass in an object explicitly instead of a key. This is
// primarily useful for unit tests.
if ($key instanceof PhabricatorFileStorageFormat) {
$format = clone $key;
} else {
$format = clone PhabricatorFileStorageFormat::requireFormat($key);
}
$format->setFile($file);
$properties = $format->newStorageProperties();
$file->setStorageFormat($format->getStorageFormatKey());
$file->setStorageProperties($properties);
$data_handle = null;
$engine_identifier = null;
$integrity_hash = null;
$exceptions = array();
foreach ($engines as $engine) {
$engine_class = get_class($engine);
try {
$result = $file->writeToEngine(
$engine,
$data,
$params);
list($engine_identifier, $data_handle, $integrity_hash) = $result;
// We stored the file somewhere so stop trying to write it to other
// places.
break;
} catch (PhabricatorFileStorageConfigurationException $ex) {
// If an engine is outright misconfigured (or misimplemented), raise
// that immediately since it probably needs attention.
throw $ex;
} catch (Exception $ex) {
phlog($ex);
// If an engine doesn't work, keep trying all the other valid engines
// in case something else works.
$exceptions[$engine_class] = $ex;
}
}
if (!$data_handle) {
throw new PhutilAggregateException(
pht('All storage engines failed to write file:'),
$exceptions);
}
$file->setByteSize(strlen($data));
$hash = self::hashFileContent($data);
$file->setContentHash($hash);
$file->setStorageEngine($engine_identifier);
$file->setStorageHandle($data_handle);
$file->setIntegrityHash($integrity_hash);
$file->readPropertiesFromParameters($params);
if (!$file->getMimeType()) {
$tmp = new TempFile();
Filesystem::writeFile($tmp, $data);
$file->setMimeType(Filesystem::getMimeType($tmp));
unset($tmp);
}
try {
$file->updateDimensions(false);
} catch (Exception $ex) {
// Do nothing.
}
$file->saveAndIndex();
return $file;
}
public static function newFromFileData($data, array $params = array()) {
$hash = self::hashFileContent($data);
if ($hash !== null) {
$file = self::newFileFromContentHash($hash, $params);
if ($file) {
return $file;
}
}
return self::buildFromFileData($data, $params);
}
public function migrateToEngine(
PhabricatorFileStorageEngine $engine,
$make_copy) {
if (!$this->getID() || !$this->getStorageHandle()) {
throw new Exception(
pht("You can not migrate a file which hasn't yet been saved."));
}
$data = $this->loadFileData();
$params = array(
'name' => $this->getName(),
);
list($new_identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
$engine,
$data,
$params);
$old_engine = $this->instantiateStorageEngine();
$old_identifier = $this->getStorageEngine();
$old_handle = $this->getStorageHandle();
$this->setStorageEngine($new_identifier);
$this->setStorageHandle($new_handle);
$this->setIntegrityHash($integrity_hash);
$this->save();
if (!$make_copy) {
$this->deleteFileDataIfUnused(
$old_engine,
$old_identifier,
$old_handle);
}
return $this;
}
public function migrateToStorageFormat(PhabricatorFileStorageFormat $format) {
if (!$this->getID() || !$this->getStorageHandle()) {
throw new Exception(
pht("You can not migrate a file which hasn't yet been saved."));
}
$data = $this->loadFileData();
$params = array(
'name' => $this->getName(),
);
$engine = $this->instantiateStorageEngine();
$old_handle = $this->getStorageHandle();
$properties = $format->newStorageProperties();
$this->setStorageFormat($format->getStorageFormatKey());
$this->setStorageProperties($properties);
list($identifier, $new_handle, $integrity_hash) = $this->writeToEngine(
$engine,
$data,
$params);
$this->setStorageHandle($new_handle);
$this->setIntegrityHash($integrity_hash);
$this->save();
$this->deleteFileDataIfUnused(
$engine,
$identifier,
$old_handle);
return $this;
}
public function cycleMasterStorageKey(PhabricatorFileStorageFormat $format) {
if (!$this->getID() || !$this->getStorageHandle()) {
throw new Exception(
pht("You can not cycle keys for a file which hasn't yet been saved."));
}
$properties = $format->cycleStorageProperties();
$this->setStorageProperties($properties);
$this->save();
return $this;
}
private function writeToEngine(
PhabricatorFileStorageEngine $engine,
$data,
array $params) {
$engine_class = get_class($engine);
$format = $this->newStorageFormat();
$data_iterator = array($data);
$formatted_iterator = $format->newWriteIterator($data_iterator);
$formatted_data = $this->loadDataFromIterator($formatted_iterator);
$integrity_hash = $engine->newIntegrityHash($formatted_data, $format);
$data_handle = $engine->writeFile($formatted_data, $params);
if (!$data_handle || strlen($data_handle) > 255) {
// This indicates an improperly implemented storage engine.
throw new PhabricatorFileStorageConfigurationException(
pht(
"Storage engine '%s' executed %s but did not return a valid ".
"handle ('%s') to the data: it must be nonempty and no longer ".
"than 255 characters.",
$engine_class,
'writeFile()',
$data_handle));
}
$engine_identifier = $engine->getEngineIdentifier();
if (!$engine_identifier || strlen($engine_identifier) > 32) {
throw new PhabricatorFileStorageConfigurationException(
pht(
"Storage engine '%s' returned an improper engine identifier '{%s}': ".
"it must be nonempty and no longer than 32 characters.",
$engine_class,
$engine_identifier));
}
return array($engine_identifier, $data_handle, $integrity_hash);
}
/**
* Download a remote resource over HTTP and save the response body as a file.
*
* This method respects `security.outbound-blacklist`, and protects against
* HTTP redirection (by manually following "Location" headers and verifying
* each destination). It does not protect against DNS rebinding. See
* discussion in T6755.
*/
public static function newFromFileDownload($uri, array $params = array()) {
$timeout = 5;
$redirects = array();
$current = $uri;
while (true) {
try {
if (count($redirects) > 10) {
throw new Exception(
pht('Too many redirects trying to fetch remote URI.'));
}
$resolved = PhabricatorEnv::requireValidRemoteURIForFetch(
$current,
array(
'http',
'https',
));
list($resolved_uri, $resolved_domain) = $resolved;
$current = new PhutilURI($current);
if ($current->getProtocol() == 'http') {
// For HTTP, we can use a pre-resolved URI to defuse DNS rebinding.
$fetch_uri = $resolved_uri;
$fetch_host = $resolved_domain;
} else {
// For HTTPS, we can't: cURL won't verify the SSL certificate if
// the domain has been replaced with an IP. But internal services
// presumably will not have valid certificates for rebindable
// domain names on attacker-controlled domains, so the DNS rebinding
// attack should generally not be possible anyway.
$fetch_uri = $current;
$fetch_host = null;
}
$future = id(new HTTPSFuture($fetch_uri))
->setFollowLocation(false)
->setTimeout($timeout);
if ($fetch_host !== null) {
$future->addHeader('Host', $fetch_host);
}
list($status, $body, $headers) = $future->resolve();
if ($status->isRedirect()) {
// This is an HTTP 3XX status, so look for a "Location" header.
$location = null;
foreach ($headers as $header) {
list($name, $value) = $header;
if (phutil_utf8_strtolower($name) == 'location') {
$location = $value;
break;
}
}
// HTTP 3XX status with no "Location" header, just treat this like
// a normal HTTP error.
if ($location === null) {
throw $status;
}
if (isset($redirects[$location])) {
throw new Exception(
pht('Encountered loop while following redirects.'));
}
$redirects[$location] = $location;
$current = $location;
// We'll fall off the bottom and go try this URI now.
} else if ($status->isError()) {
// This is something other than an HTTP 2XX or HTTP 3XX status, so
// just bail out.
throw $status;
} else {
// This is HTTP 2XX, so use the response body to save the file data.
// Provide a default name based on the URI, truncating it if the URI
// is exceptionally long.
$default_name = basename($uri);
$default_name = id(new PhutilUTF8StringTruncator())
->setMaximumBytes(64)
->truncateString($default_name);
$params = $params + array(
'name' => $default_name,
);
return self::newFromFileData($body, $params);
}
} catch (Exception $ex) {
if ($redirects) {
throw new PhutilProxyException(
pht(
'Failed to fetch remote URI "%s" after following %s redirect(s) '.
'(%s): %s',
$uri,
phutil_count($redirects),
implode(' > ', array_keys($redirects)),
$ex->getMessage()),
$ex);
} else {
throw $ex;
}
}
}
}
public static function normalizeFileName($file_name) {
$pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@";
$file_name = preg_replace($pattern, '_', $file_name);
$file_name = preg_replace('@_+@', '_', $file_name);
$file_name = trim($file_name, '_');
$disallowed_filenames = array(
'.' => 'dot',
'..' => 'dotdot',
'' => 'file',
);
$file_name = idx($disallowed_filenames, $file_name, $file_name);
return $file_name;
}
public function delete() {
// We want to delete all the rows which mark this file as the transformation
// of some other file (since we're getting rid of it). We also delete all
// the transformations of this file, so that a user who deletes an image
// doesn't need to separately hunt down and delete a bunch of thumbnails and
// resizes of it.
$outbound_xforms = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withTransforms(
array(
array(
'originalPHID' => $this->getPHID(),
'transform' => true,
),
))
->execute();
foreach ($outbound_xforms as $outbound_xform) {
$outbound_xform->delete();
}
$inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere(
'transformedPHID = %s',
$this->getPHID());
$this->openTransaction();
foreach ($inbound_xforms as $inbound_xform) {
$inbound_xform->delete();
}
$ret = parent::delete();
$this->saveTransaction();
$this->deleteFileDataIfUnused(
$this->instantiateStorageEngine(),
$this->getStorageEngine(),
$this->getStorageHandle());
return $ret;
}
/**
* Destroy stored file data if there are no remaining files which reference
* it.
*/
public function deleteFileDataIfUnused(
PhabricatorFileStorageEngine $engine,
$engine_identifier,
$handle) {
// Check to see if any files are using storage.
$usage = id(new PhabricatorFile())->loadAllWhere(
'storageEngine = %s AND storageHandle = %s LIMIT 1',
$engine_identifier,
$handle);
// If there are no files using the storage, destroy the actual storage.
if (!$usage) {
try {
$engine->deleteFile($handle);
} catch (Exception $ex) {
// In the worst case, we're leaving some data stranded in a storage
// engine, which is not a big deal.
phlog($ex);
}
}
}
public static function hashFileContent($data) {
// NOTE: Hashing can fail if the algorithm isn't available in the current
// build of PHP. It's fine if we're unable to generate a content hash:
// it just means we'll store extra data when users upload duplicate files
// instead of being able to deduplicate it.
$hash = hash('sha256', $data, $raw_output = false);
if ($hash === false) {
return null;
}
return $hash;
}
public function loadFileData() {
$iterator = $this->getFileDataIterator();
return $this->loadDataFromIterator($iterator);
}
/**
* Return an iterable which emits file content bytes.
*
* @param int? $begin Offset for the start of data.
* @param int? $end Offset for the end of data.
* @return Iterable Iterable object which emits requested data.
*/
public function getFileDataIterator($begin = null, $end = null) {
$engine = $this->instantiateStorageEngine();
$format = $this->newStorageFormat();
$iterator = $engine->getRawFileDataIterator(
$this,
$begin,
$end,
$format);
return $iterator;
}
/**
* Get file URI in the format of "/F123"
* @return string
*/
public function getURI() {
return $this->getInfoURI();
}
/**
* Get file view URI in the format of
* https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename
* @return string
*/
public function getViewURI() {
if (!$this->getPHID()) {
throw new Exception(
pht('You must save a file before you can generate a view URI.'));
}
return $this->getCDNURI('data');
}
/**
* Get file view URI in the format of
* https://phorge.example.com/file/data/foo/PHID-FILE-bar/filename or
* https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
* @return string
*/
public function getCDNURI($request_kind) {
if (($request_kind !== 'data') &&
($request_kind !== 'download')) {
throw new Exception(
pht(
'Unknown file content request kind "%s".',
$request_kind));
}
$name = self::normalizeFileName($this->getName());
$name = phutil_escape_uri($name);
$parts = array();
$parts[] = 'file';
$parts[] = $request_kind;
// If this is an instanced install, add the instance identifier to the URI.
// Instanced configurations behind a CDN may not be able to control the
// request domain used by the CDN (as with AWS CloudFront). Embedding the
// instance identity in the path allows us to distinguish between requests
// originating from different instances but served through the same CDN.
$instance = PhabricatorEnv::getEnvConfig('cluster.instance');
if (phutil_nonempty_string($instance)) {
$parts[] = '@'.$instance;
}
$parts[] = $this->getSecretKey();
$parts[] = $this->getPHID();
$parts[] = $name;
$path = '/'.implode('/', $parts);
// If this file is only partially uploaded, we're just going to return a
// local URI to make sure that Ajax works, since the page is inevitably
// going to give us an error back.
if ($this->getIsPartial()) {
return PhabricatorEnv::getURI($path);
} else {
return PhabricatorEnv::getCDNURI($path);
}
}
/**
* Get file info URI in the format of "/F123"
* @return string
*/
public function getInfoURI() {
return '/'.$this->getMonogram();
}
public function getBestURI() {
if ($this->isViewableInBrowser()) {
return $this->getViewURI();
} else {
return $this->getInfoURI();
}
}
/**
* Get file view URI in the format of
* https://phorge.example.com/file/download/foo/PHID-FILE-bar/filename
* @return string
*/
public function getDownloadURI() {
return $this->getCDNURI('download');
}
public function getURIForTransform(PhabricatorFileTransform $transform) {
return $this->getTransformedURI($transform->getTransformKey());
}
private function getTransformedURI($transform) {
$parts = array();
$parts[] = 'file';
$parts[] = 'xform';
$instance = PhabricatorEnv::getEnvConfig('cluster.instance');
if (phutil_nonempty_string($instance)) {
$parts[] = '@'.$instance;
}
$parts[] = $transform;
$parts[] = $this->getPHID();
$parts[] = $this->getSecretKey();
$path = implode('/', $parts);
$path = $path.'/';
return PhabricatorEnv::getCDNURI($path);
}
/**
* Whether the file can be viewed in a browser
* @return bool True if MIME type of the file is listed in the
* files.viewable-mime-types setting
*/
public function isViewableInBrowser() {
return ($this->getViewableMimeType() !== null);
}
/**
* Whether the file is an image viewable in the browser
* @return bool True if MIME type of the file is listed in the
* files.image-mime-types setting and file is viewable in the browser
*/
public function isViewableImage() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.image-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
/**
* Whether the file is an audio file
* @return bool True if MIME type of the file is listed in the
* files.audio-mime-types setting and file is viewable in the browser
*/
public function isAudio() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.audio-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
/**
* Whether the file is a video file
* @return bool True if MIME type of the file is listed in the
* files.video-mime-types setting and file is viewable in the browser
*/
public function isVideo() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = PhabricatorEnv::getEnvConfig('files.video-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
/**
* Whether the file is a PDF file
* @return bool True if MIME type of the file is application/pdf and file is
* viewable in the browser
*/
public function isPDF() {
if (!$this->isViewableInBrowser()) {
return false;
}
$mime_map = array(
'application/pdf' => 'application/pdf',
);
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type);
}
public function isTransformableImage() {
// NOTE: The way the 'gd' extension works in PHP is that you can install it
// with support for only some file types, so it might be able to handle
// PNG but not JPEG. Try to generate thumbnails for whatever we can. Setup
// warns you if you don't have complete support.
$matches = null;
$ok = false;
if ($this->getViewableMimeType() !== null) {
$ok = preg_match(
'@^image/(gif|png|jpe?g)@',
$this->getViewableMimeType(),
$matches);
}
if (!$ok) {
return false;
}
switch ($matches[1]) {
case 'jpg';
case 'jpeg':
return function_exists('imagejpeg');
- break;
case 'png':
return function_exists('imagepng');
- break;
case 'gif':
return function_exists('imagegif');
- break;
default:
throw new Exception(pht('Unknown type matched as image MIME type.'));
}
}
public static function getTransformableImageFormats() {
$supported = array();
if (function_exists('imagejpeg')) {
$supported[] = 'jpg';
}
if (function_exists('imagepng')) {
$supported[] = 'png';
}
if (function_exists('imagegif')) {
$supported[] = 'gif';
}
return $supported;
}
public function getDragAndDropDictionary() {
return array(
'id' => $this->getID(),
'phid' => $this->getPHID(),
'uri' => $this->getBestURI(),
);
}
public function instantiateStorageEngine() {
return self::buildEngine($this->getStorageEngine());
}
public static function buildEngine($engine_identifier) {
$engines = self::buildAllEngines();
foreach ($engines as $engine) {
if ($engine->getEngineIdentifier() == $engine_identifier) {
return $engine;
}
}
throw new Exception(
pht(
"Storage engine '%s' could not be located!",
$engine_identifier));
}
public static function buildAllEngines() {
return id(new PhutilClassMapQuery())
->setAncestorClass('PhabricatorFileStorageEngine')
->execute();
}
/**
* Whether the file is listed as a viewable MIME type
* @return bool True if MIME type of the file is listed in the
* files.viewable-mime-types setting
*/
public function getViewableMimeType() {
$mime_map = PhabricatorEnv::getEnvConfig('files.viewable-mime-types');
$mime_type = $this->getMimeType();
$mime_parts = explode(';', $mime_type);
$mime_type = trim(reset($mime_parts));
return idx($mime_map, $mime_type);
}
public function getDisplayIconForMimeType() {
$mime_map = PhabricatorEnv::getEnvConfig('files.icon-mime-types');
$mime_type = $this->getMimeType();
return idx($mime_map, $mime_type, 'fa-file-o');
}
public function validateSecretKey($key) {
return ($key == $this->getSecretKey());
}
public function generateSecretKey() {
return Filesystem::readRandomCharacters(20);
}
public function setStorageProperties(array $properties) {
$this->metadata[self::METADATA_STORAGE] = $properties;
return $this;
}
public function getStorageProperties() {
return idx($this->metadata, self::METADATA_STORAGE, array());
}
public function getStorageProperty($key, $default = null) {
$properties = $this->getStorageProperties();
return idx($properties, $key, $default);
}
public function loadDataFromIterator($iterator) {
$result = '';
foreach ($iterator as $chunk) {
$result .= $chunk;
}
return $result;
}
public function updateDimensions($save = true) {
if (!$this->isViewableImage()) {
throw new Exception(pht('This file is not a viewable image.'));
}
if (!function_exists('imagecreatefromstring')) {
throw new Exception(pht('Cannot retrieve image information.'));
}
if ($this->getIsChunk()) {
throw new Exception(
pht('Refusing to assess image dimensions of file chunk.'));
}
$engine = $this->instantiateStorageEngine();
if ($engine->isChunkEngine()) {
throw new Exception(
pht('Refusing to assess image dimensions of chunked file.'));
}
$data = $this->loadFileData();
$img = @imagecreatefromstring($data);
if ($img === false) {
throw new Exception(pht('Error when decoding image.'));
}
$this->metadata[self::METADATA_IMAGE_WIDTH] = imagesx($img);
$this->metadata[self::METADATA_IMAGE_HEIGHT] = imagesy($img);
if ($save) {
$this->save();
}
return $this;
}
public function copyDimensions(PhabricatorFile $file) {
$metadata = $file->getMetadata();
$width = idx($metadata, self::METADATA_IMAGE_WIDTH);
if ($width) {
$this->metadata[self::METADATA_IMAGE_WIDTH] = $width;
}
$height = idx($metadata, self::METADATA_IMAGE_HEIGHT);
if ($height) {
$this->metadata[self::METADATA_IMAGE_HEIGHT] = $height;
}
return $this;
}
/**
* Load (or build) the {@class:PhabricatorFile} objects for builtin file
* resources. The builtin mechanism allows files shipped with Phabricator
* to be treated like normal files so that APIs do not need to special case
* things like default images or deleted files.
*
* Builtins are located in `resources/builtin/` and identified by their
* name.
*
* @param PhabricatorUser $user Viewing user.
* @param list<PhabricatorFilesBuiltinFile> $builtins List of builtin file
* specs.
* @return dict<string, PhabricatorFile> Dictionary of named builtins.
*/
public static function loadBuiltins(PhabricatorUser $user, array $builtins) {
$builtins = mpull($builtins, null, 'getBuiltinFileKey');
// NOTE: Anyone is allowed to access builtin files.
$files = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withBuiltinKeys(array_keys($builtins))
->execute();
$results = array();
foreach ($files as $file) {
$builtin_key = $file->getBuiltinName();
if ($builtin_key !== null) {
$results[$builtin_key] = $file;
}
}
$build = array();
foreach ($builtins as $key => $builtin) {
if (isset($results[$key])) {
continue;
}
$data = $builtin->loadBuiltinFileData();
$params = array(
'name' => $builtin->getBuiltinDisplayName(),
'canCDN' => true,
'builtin' => $key,
);
$unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
try {
$file = self::newFromFileData($data, $params);
} catch (AphrontDuplicateKeyQueryException $ex) {
$file = id(new PhabricatorFileQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withBuiltinKeys(array($key))
->executeOne();
if (!$file) {
throw new Exception(
pht(
'Collided mid-air when generating builtin file "%s", but '.
'then failed to load the object we collided with.',
$key));
}
}
unset($unguarded);
$file->attachObjectPHIDs(array());
$file->attachObjects(array());
$results[$key] = $file;
}
return $results;
}
/**
* Convenience wrapper for @{method:loadBuiltins}.
*
* @param PhabricatorUser $user Viewing user.
* @param string $name Single builtin name to load.
* @return PhabricatorFile Corresponding builtin file.
*/
public static function loadBuiltin(PhabricatorUser $user, $name) {
$builtin = id(new PhabricatorFilesOnDiskBuiltinFile())
->setName($name);
$key = $builtin->getBuiltinFileKey();
return idx(self::loadBuiltins($user, array($builtin)), $key);
}
public function getObjects() {
return $this->assertAttached($this->objects);
}
public function attachObjects(array $objects) {
$this->objects = $objects;
return $this;
}
public function getObjectPHIDs() {
return $this->assertAttached($this->objectPHIDs);
}
public function attachObjectPHIDs(array $object_phids) {
$this->objectPHIDs = $object_phids;
return $this;
}
public function getOriginalFile() {
return $this->assertAttached($this->originalFile);
}
public function attachOriginalFile(PhabricatorFile $file = null) {
$this->originalFile = $file;
return $this;
}
public function getImageHeight() {
if (!$this->isViewableImage()) {
return null;
}
return idx($this->metadata, self::METADATA_IMAGE_HEIGHT);
}
public function getImageWidth() {
if (!$this->isViewableImage()) {
return null;
}
return idx($this->metadata, self::METADATA_IMAGE_WIDTH);
}
public function getAltText() {
$alt = $this->getCustomAltText();
if (phutil_nonempty_string($alt)) {
return $alt;
}
return $this->getDefaultAltText();
}
public function getCustomAltText() {
return idx($this->metadata, self::METADATA_ALT_TEXT);
}
public function setCustomAltText($value) {
$value = phutil_string_cast($value);
if (!strlen($value)) {
$value = null;
}
if ($value === null) {
unset($this->metadata[self::METADATA_ALT_TEXT]);
} else {
$this->metadata[self::METADATA_ALT_TEXT] = $value;
}
return $this;
}
public function getDefaultAltText() {
$parts = array();
$name = $this->getName();
if (strlen($name)) {
$parts[] = $name;
}
$stats = array();
$image_x = $this->getImageHeight();
$image_y = $this->getImageWidth();
if ($image_x && $image_y) {
$stats[] = pht(
"%d\xC3\x97%d px",
new PhutilNumber($image_x),
new PhutilNumber($image_y));
}
$bytes = $this->getByteSize();
if ($bytes) {
$stats[] = phutil_format_bytes($bytes);
}
if ($stats) {
$parts[] = pht('(%s)', implode(', ', $stats));
}
if (!$parts) {
return null;
}
return implode(' ', $parts);
}
public function getCanCDN() {
if (!$this->isViewableImage()) {
return false;
}
return idx($this->metadata, self::METADATA_CAN_CDN);
}
public function setCanCDN($can_cdn) {
$this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0;
return $this;
}
public function isBuiltin() {
return ($this->getBuiltinName() !== null);
}
public function getBuiltinName() {
return idx($this->metadata, self::METADATA_BUILTIN);
}
public function setBuiltinName($name) {
$this->metadata[self::METADATA_BUILTIN] = $name;
return $this;
}
public function getIsProfileImage() {
return idx($this->metadata, self::METADATA_PROFILE);
}
public function setIsProfileImage($value) {
$this->metadata[self::METADATA_PROFILE] = $value;
return $this;
}
public function getIsChunk() {
return idx($this->metadata, self::METADATA_CHUNK);
}
public function setIsChunk($value) {
$this->metadata[self::METADATA_CHUNK] = $value;
return $this;
}
public function setIntegrityHash($integrity_hash) {
$this->metadata[self::METADATA_INTEGRITY] = $integrity_hash;
return $this;
}
public function getIntegrityHash() {
return idx($this->metadata, self::METADATA_INTEGRITY);
}
public function newIntegrityHash() {
$engine = $this->instantiateStorageEngine();
if ($engine->isChunkEngine()) {
return null;
}
$format = $this->newStorageFormat();
$storage_handle = $this->getStorageHandle();
$data = $engine->readFile($storage_handle);
return $engine->newIntegrityHash($data, $format);
}
/**
* Write the policy edge between this file and some object.
* This method is successful even if the file is already attached.
*
* @param phid $phid Object PHID to attach to.
* @return this
*/
public function attachToObject($phid) {
self::attachFileToObject($this->getPHID(), $phid);
return $this;
}
/**
* Write the policy edge between a file and some object.
* This method is successful even if the file is already attached.
* NOTE: Please avoid to use this static method directly.
* Instead, use PhabricatorFile#attachToObject(phid).
*
* @param phid $file_phid File PHID to attach from.
* @param phid $object_phid Object PHID to attach to.
* @return void
*/
public static function attachFileToObject($file_phid, $object_phid) {
// It can be easy to confuse the two arguments. Be strict.
if (phid_get_type($file_phid) !== PhabricatorFileFilePHIDType::TYPECONST) {
throw new Exception(pht('The first argument must be a phid of a file.'));
}
$attachment_table = new PhabricatorFileAttachment();
$attachment_conn = $attachment_table->establishConnection('w');
queryfx(
$attachment_conn,
'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
attacherPHID, dateCreated, dateModified)
VALUES (%s, %s, %s, %ns, %d, %d)
ON DUPLICATE KEY UPDATE
attachmentMode = VALUES(attachmentMode),
attacherPHID = VALUES(attacherPHID),
dateModified = VALUES(dateModified)',
$attachment_table,
$object_phid,
$file_phid,
PhabricatorFileAttachment::MODE_ATTACH,
null,
PhabricatorTime::getNow(),
PhabricatorTime::getNow());
}
/**
* Configure a newly created file object according to specified parameters.
*
* This method is called both when creating a file from fresh data, and
* when creating a new file which reuses existing storage.
*
* @param map<string, wild> $params Bag of parameters, see
* @{class:PhabricatorFile} for documentation.
* @return this
*/
private function readPropertiesFromParameters(array $params) {
PhutilTypeSpec::checkMap(
$params,
array(
'name' => 'optional string',
'authorPHID' => 'optional string',
'ttl.relative' => 'optional int',
'ttl.absolute' => 'optional int',
'viewPolicy' => 'optional string',
'isExplicitUpload' => 'optional bool',
'canCDN' => 'optional bool',
'profile' => 'optional bool',
'format' => 'optional string|PhabricatorFileStorageFormat',
'mime-type' => 'optional string',
'builtin' => 'optional string',
'storageEngines' => 'optional list<PhabricatorFileStorageEngine>',
'chunk' => 'optional bool',
));
$file_name = idx($params, 'name');
$this->setName($file_name);
$author_phid = idx($params, 'authorPHID');
$this->setAuthorPHID($author_phid);
$absolute_ttl = idx($params, 'ttl.absolute');
$relative_ttl = idx($params, 'ttl.relative');
if ($absolute_ttl !== null && $relative_ttl !== null) {
throw new Exception(
pht(
'Specify an absolute TTL or a relative TTL, but not both.'));
} else if ($absolute_ttl !== null) {
if ($absolute_ttl < PhabricatorTime::getNow()) {
throw new Exception(
pht(
'Absolute TTL must be in the present or future, but TTL "%s" '.
'is in the past.',
$absolute_ttl));
}
$this->setTtl($absolute_ttl);
} else if ($relative_ttl !== null) {
if ($relative_ttl < 0) {
throw new Exception(
pht(
'Relative TTL must be zero or more seconds, but "%s" is '.
'negative.',
$relative_ttl));
}
$max_relative = phutil_units('365 days in seconds');
if ($relative_ttl > $max_relative) {
throw new Exception(
pht(
'Relative TTL must not be more than "%s" seconds, but TTL '.
'"%s" was specified.',
$max_relative,
$relative_ttl));
}
$absolute_ttl = PhabricatorTime::getNow() + $relative_ttl;
$this->setTtl($absolute_ttl);
}
$view_policy = idx($params, 'viewPolicy');
if ($view_policy) {
$this->setViewPolicy($params['viewPolicy']);
}
$is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0);
$this->setIsExplicitUpload($is_explicit);
$can_cdn = idx($params, 'canCDN');
if ($can_cdn) {
$this->setCanCDN(true);
}
$builtin = idx($params, 'builtin');
if ($builtin) {
$this->setBuiltinName($builtin);
$this->setBuiltinKey($builtin);
}
$profile = idx($params, 'profile');
if ($profile) {
$this->setIsProfileImage(true);
}
$mime_type = idx($params, 'mime-type');
if ($mime_type) {
$this->setMimeType($mime_type);
}
$is_chunk = idx($params, 'chunk');
if ($is_chunk) {
$this->setIsChunk(true);
}
return $this;
}
public function getRedirectResponse() {
$uri = $this->getBestURI();
// TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI
// (if the file is a viewable image) and sometimes a local URI (if not).
// For now, just detect which one we got and configure the response
// appropriately. In the long run, if this endpoint is served from a CDN
// domain, we can't issue a local redirect to an info URI (which is not
// present on the CDN domain). We probably never actually issue local
// redirects here anyway, since we only ever transform viewable images
// right now.
$is_external = strlen(id(new PhutilURI($uri))->getDomain());
return id(new AphrontRedirectResponse())
->setIsExternal($is_external)
->setURI($uri);
}
public function newDownloadResponse() {
// We're cheating a little bit here and relying on the fact that
// getDownloadURI() always returns a fully qualified URI with a complete
// domain.
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setCloseDialogBeforeRedirect(true)
->setURI($this->getDownloadURI());
}
public function attachTransforms(array $map) {
$this->transforms = $map;
return $this;
}
public function getTransform($key) {
return $this->assertAttachedKey($this->transforms, $key);
}
public function newStorageFormat() {
$key = $this->getStorageFormat();
$template = PhabricatorFileStorageFormat::requireFormat($key);
$format = id(clone $template)
->setFile($this);
return $format;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorFileEditor();
}
public function getApplicationTransactionTemplate() {
return new PhabricatorFileTransaction();
}
/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
if ($this->isBuiltin()) {
return PhabricatorPolicies::getMostOpenPolicy();
}
if ($this->getIsProfileImage()) {
return PhabricatorPolicies::getMostOpenPolicy();
}
return $this->getViewPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return PhabricatorPolicies::POLICY_NOONE;
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
$viewer_phid = $viewer->getPHID();
if ($viewer_phid) {
if ($this->getAuthorPHID() == $viewer_phid) {
return true;
}
}
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
// If you can see the file this file is a transform of, you can see
// this file.
if ($this->getOriginalFile()) {
return true;
}
// If you can see any object this file is attached to, you can see
// the file.
return (count($this->getObjects()) > 0);
}
return false;
}
public function describeAutomaticCapability($capability) {
$out = array();
$out[] = pht('The user who uploaded a file can always view and edit it.');
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
$out[] = pht(
'Files attached to objects are visible to users who can view '.
'those objects.');
$out[] = pht(
'Thumbnails are visible only to users who can view the original '.
'file.');
break;
}
return $out;
}
/* -( PhabricatorSubscribableInterface Implementation )-------------------- */
public function isAutomaticallySubscribed($phid) {
return ($this->authorPHID == $phid);
}
/* -( PhabricatorTokenReceiverInterface )---------------------------------- */
public function getUsersToNotifyOfTokenGiven() {
return array(
$this->getAuthorPHID(),
);
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$attachments = id(new PhabricatorFileAttachment())->loadAllWhere(
'filePHID = %s',
$this->getPHID());
foreach ($attachments as $attachment) {
$attachment->delete();
}
$this->delete();
$this->saveTransaction();
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('name')
->setType('string')
->setDescription(pht('The name of the file.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('uri')
->setType('uri')
->setDescription(pht('View URI for the file.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('dataURI')
->setType('uri')
->setDescription(pht('Download URI for the file data.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('size')
->setType('int')
->setDescription(pht('File size, in bytes.')),
);
}
public function getFieldValuesForConduit() {
return array(
'name' => $this->getName(),
'uri' => PhabricatorEnv::getURI($this->getURI()),
'dataURI' => $this->getCDNURI('data'),
'size' => (int)$this->getByteSize(),
'alt' => array(
'custom' => $this->getCustomAltText(),
'default' => $this->getDefaultAltText(),
),
);
}
public function getConduitSearchAttachments() {
return array();
}
/* -( PhabricatorNgramInterface )------------------------------------------ */
public function newNgrams() {
return array(
id(new PhabricatorFileNameNgrams())
->setValue($this->getName()),
);
}
}
diff --git a/src/applications/flag/query/PhabricatorFlagQuery.php b/src/applications/flag/query/PhabricatorFlagQuery.php
index 07b3e09d10..f96e64ea81 100644
--- a/src/applications/flag/query/PhabricatorFlagQuery.php
+++ b/src/applications/flag/query/PhabricatorFlagQuery.php
@@ -1,179 +1,178 @@
<?php
final class PhabricatorFlagQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
const GROUP_COLOR = 'color';
const GROUP_NONE = 'none';
private $ids;
private $ownerPHIDs;
private $types;
private $objectPHIDs;
private $colors;
private $groupBy = self::GROUP_NONE;
private $needHandles;
private $needObjects;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withOwnerPHIDs(array $owner_phids) {
$this->ownerPHIDs = $owner_phids;
return $this;
}
public function withTypes(array $types) {
$this->types = $types;
return $this;
}
public function withObjectPHIDs(array $object_phids) {
$this->objectPHIDs = $object_phids;
return $this;
}
public function withColors(array $colors) {
$this->colors = $colors;
return $this;
}
/**
* NOTE: this is done in PHP and not in MySQL, which means its inappropriate
* for large datasets. Pragmatically, this is fine for user flags which are
* typically well under 100 flags per user.
*/
public function setGroupBy($group) {
$this->groupBy = $group;
return $this;
}
public function needHandles($need) {
$this->needHandles = $need;
return $this;
}
public function needObjects($need) {
$this->needObjects = $need;
return $this;
}
public static function loadUserFlag(PhabricatorUser $user, $object_phid) {
// Specifying the type in the query allows us to use a key.
return id(new PhabricatorFlagQuery())
->setViewer($user)
->withOwnerPHIDs(array($user->getPHID()))
->withTypes(array(phid_get_type($object_phid)))
->withObjectPHIDs(array($object_phid))
->executeOne();
}
protected function loadPage() {
$table = new PhabricatorFlag();
$conn_r = $table->establishConnection('r');
$data = queryfx_all(
$conn_r,
'SELECT * FROM %T flag %Q %Q %Q',
$table->getTableName(),
$this->buildWhereClause($conn_r),
$this->buildOrderClause($conn_r),
$this->buildLimitClause($conn_r));
return $table->loadAllFromArray($data);
}
protected function willFilterPage(array $flags) {
if ($this->needObjects) {
$objects = id(new PhabricatorObjectQuery())
->setViewer($this->getViewer())
->withPHIDs(mpull($flags, 'getObjectPHID'))
->execute();
$objects = mpull($objects, null, 'getPHID');
foreach ($flags as $key => $flag) {
$object = idx($objects, $flag->getObjectPHID());
if ($object) {
$flags[$key]->attachObject($object);
} else {
unset($flags[$key]);
}
}
}
if ($this->needHandles) {
$handles = id(new PhabricatorHandleQuery())
->setViewer($this->getViewer())
->withPHIDs(mpull($flags, 'getObjectPHID'))
->execute();
foreach ($flags as $flag) {
$flag->attachHandle($handles[$flag->getObjectPHID()]);
}
}
switch ($this->groupBy) {
case self::GROUP_COLOR:
$flags = msort($flags, 'getColor');
break;
case self::GROUP_NONE:
break;
default:
throw new Exception(
pht('Unknown groupBy parameter: %s', $this->groupBy));
- break;
}
return $flags;
}
protected function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'flag.id IN (%Ld)',
$this->ids);
}
if ($this->ownerPHIDs) {
$where[] = qsprintf(
$conn,
'flag.ownerPHID IN (%Ls)',
$this->ownerPHIDs);
}
if ($this->types) {
$where[] = qsprintf(
$conn,
'flag.type IN (%Ls)',
$this->types);
}
if ($this->objectPHIDs) {
$where[] = qsprintf(
$conn,
'flag.objectPHID IN (%Ls)',
$this->objectPHIDs);
}
if ($this->colors) {
$where[] = qsprintf(
$conn,
'flag.color IN (%Ld)',
$this->colors);
}
$where[] = $this->buildPagingClause($conn);
return $this->formatWhereClause($conn, $where);
}
public function getQueryApplicationClass() {
return PhabricatorFlagsApplication::class;
}
}
diff --git a/src/applications/herald/field/HeraldField.php b/src/applications/herald/field/HeraldField.php
index cdfdd7e518..f4cc9fd442 100644
--- a/src/applications/herald/field/HeraldField.php
+++ b/src/applications/herald/field/HeraldField.php
@@ -1,306 +1,305 @@
<?php
abstract class HeraldField extends Phobject {
private $adapter;
const STANDARD_BOOL = 'standard.bool';
const STANDARD_TEXT = 'standard.text';
const STANDARD_TEXT_LIST = 'standard.text.list';
const STANDARD_TEXT_MAP = 'standard.text.map';
const STANDARD_PHID = 'standard.phid';
const STANDARD_PHID_LIST = 'standard.phid.list';
const STANDARD_PHID_BOOL = 'standard.phid.bool';
const STANDARD_PHID_NULLABLE = 'standard.phid.nullable';
abstract public function getHeraldFieldName();
abstract public function getHeraldFieldValue($object);
public function getFieldGroupKey() {
return null;
}
public function getRequiredAdapterStates() {
return array();
}
protected function getHeraldFieldStandardType() {
throw new PhutilMethodNotImplementedException();
}
protected function getDatasource() {
throw new PhutilMethodNotImplementedException();
}
protected function getDatasourceValueMap() {
return null;
}
public function getHeraldFieldConditions() {
$standard_type = $this->getHeraldFieldStandardType();
switch ($standard_type) {
case self::STANDARD_BOOL:
return array(
HeraldAdapter::CONDITION_IS_TRUE,
HeraldAdapter::CONDITION_IS_FALSE,
);
case self::STANDARD_TEXT:
return array(
HeraldAdapter::CONDITION_CONTAINS,
HeraldAdapter::CONDITION_NOT_CONTAINS,
HeraldAdapter::CONDITION_IS,
HeraldAdapter::CONDITION_IS_NOT,
HeraldAdapter::CONDITION_REGEXP,
HeraldAdapter::CONDITION_NOT_REGEXP,
);
case self::STANDARD_PHID:
return array(
HeraldAdapter::CONDITION_IS_ANY,
HeraldAdapter::CONDITION_IS_NOT_ANY,
);
case self::STANDARD_PHID_LIST:
return array(
HeraldAdapter::CONDITION_INCLUDE_ALL,
HeraldAdapter::CONDITION_INCLUDE_ANY,
HeraldAdapter::CONDITION_INCLUDE_NONE,
HeraldAdapter::CONDITION_EXISTS,
HeraldAdapter::CONDITION_NOT_EXISTS,
);
case self::STANDARD_PHID_BOOL:
return array(
HeraldAdapter::CONDITION_EXISTS,
HeraldAdapter::CONDITION_NOT_EXISTS,
);
case self::STANDARD_PHID_NULLABLE:
return array(
HeraldAdapter::CONDITION_IS_ANY,
HeraldAdapter::CONDITION_IS_NOT_ANY,
HeraldAdapter::CONDITION_EXISTS,
HeraldAdapter::CONDITION_NOT_EXISTS,
);
case self::STANDARD_TEXT_LIST:
return array(
HeraldAdapter::CONDITION_CONTAINS,
HeraldAdapter::CONDITION_NOT_CONTAINS,
HeraldAdapter::CONDITION_REGEXP,
HeraldAdapter::CONDITION_NOT_REGEXP,
HeraldAdapter::CONDITION_EXISTS,
HeraldAdapter::CONDITION_NOT_EXISTS,
);
case self::STANDARD_TEXT_MAP:
return array(
HeraldAdapter::CONDITION_CONTAINS,
HeraldAdapter::CONDITION_NOT_CONTAINS,
HeraldAdapter::CONDITION_REGEXP,
HeraldAdapter::CONDITION_NOT_REGEXP,
HeraldAdapter::CONDITION_REGEXP_PAIR,
);
}
throw new Exception(
pht(
'Herald field "%s" has unknown standard type "%s".',
get_class($this),
$standard_type));
}
public function getHeraldFieldValueType($condition) {
// NOTE: The condition type may be "null" to indicate that the caller
// wants a generic field value type. This is used when rendering field
// values in the object transcript.
$standard_type = $this->getHeraldFieldStandardType();
switch ($standard_type) {
case self::STANDARD_BOOL:
case self::STANDARD_PHID_BOOL:
return new HeraldBoolFieldValue();
case self::STANDARD_TEXT:
case self::STANDARD_TEXT_LIST:
case self::STANDARD_TEXT_MAP:
switch ($condition) {
case HeraldAdapter::CONDITION_EXISTS:
case HeraldAdapter::CONDITION_NOT_EXISTS:
return new HeraldEmptyFieldValue();
default:
return new HeraldTextFieldValue();
}
case self::STANDARD_PHID:
case self::STANDARD_PHID_NULLABLE:
case self::STANDARD_PHID_LIST:
switch ($condition) {
case HeraldAdapter::CONDITION_EXISTS:
case HeraldAdapter::CONDITION_NOT_EXISTS:
return new HeraldEmptyFieldValue();
default:
$tokenizer = id(new HeraldTokenizerFieldValue())
->setKey($this->getHeraldFieldName())
->setDatasource($this->getDatasource());
$value_map = $this->getDatasourceValueMap();
if ($value_map !== null) {
$tokenizer->setValueMap($value_map);
}
return $tokenizer;
}
- break;
}
throw new Exception(
pht(
'Herald field "%s" has unknown standard type "%s".',
get_class($this),
$standard_type));
}
abstract public function supportsObject($object);
public function getFieldsForObject($object) {
return array($this->getFieldConstant() => $this);
}
public function renderConditionValue(
PhabricatorUser $viewer,
$condition,
$value) {
$value_type = $this->getHeraldFieldValueType($condition);
$value_type->setViewer($viewer);
return $value_type->renderFieldValue($value);
}
public function getEditorValue(
PhabricatorUser $viewer,
$condition,
$value) {
$value_type = $this->getHeraldFieldValueType($condition);
$value_type->setViewer($viewer);
return $value_type->renderEditorValue($value);
}
public function renderTranscriptValue(
PhabricatorUser $viewer,
$field_value) {
$value_type = $this->getHeraldFieldValueType($condition_type = null);
$value_type->setViewer($viewer);
return $value_type->renderTranscriptValue($field_value);
}
public function getPHIDsAffectedByCondition(HeraldCondition $condition) {
try {
$standard_type = $this->getHeraldFieldStandardType();
} catch (PhutilMethodNotImplementedException $ex) {
$standard_type = null;
}
switch ($standard_type) {
case self::STANDARD_PHID:
case self::STANDARD_PHID_NULLABLE:
case self::STANDARD_PHID_LIST:
$phids = $condition->getValue();
if (!is_array($phids)) {
$phids = array();
}
return $phids;
}
return array();
}
final public function setAdapter(HeraldAdapter $adapter) {
$this->adapter = $adapter;
return $this;
}
final public function getAdapter() {
return $this->adapter;
}
final public function getFieldConstant() {
return $this->getPhobjectClassConstant(
'FIELDCONST',
self::getFieldConstantByteLimit());
}
final public static function getFieldConstantByteLimit() {
return 64;
}
final public static function getAllFields() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getFieldConstant')
->execute();
}
final protected function hasAppliedTransactionOfType($type) {
$xactions = $this->getAdapter()->getAppliedTransactions();
if (!$xactions) {
return false;
}
foreach ($xactions as $xaction) {
if ($xaction->getTransactionType() === $type) {
return true;
}
}
return false;
}
final protected function getAppliedTransactionsOfTypes(array $types) {
$types = array_fuse($types);
$xactions = $this->getAdapter()->getAppliedTransactions();
$result = array();
foreach ($xactions as $key => $xaction) {
$xaction_type = $xaction->getTransactionType();
if (isset($types[$xaction_type])) {
$result[$key] = $xaction;
}
}
return $result;
}
final protected function getAppliedEdgeTransactionOfType($edge_type) {
$edge_xactions = $this->getAppliedTransactionsOfTypes(
array(
PhabricatorTransactions::TYPE_EDGE,
));
$results = array();
foreach ($edge_xactions as $edge_xaction) {
$xaction_edge_type = $edge_xaction->getMetadataValue('edge:type');
if ($xaction_edge_type == $edge_type) {
$results[] = $edge_xaction;
}
}
if (count($results) > 1) {
throw new Exception(
pht(
'Found more than one ("%s") applied edge transactions with given '.
'edge type ("%s"); expected zero or one.',
phutil_count($results),
$edge_type));
}
if ($results) {
return head($results);
}
return null;
}
public function isFieldAvailable() {
return true;
}
}
diff --git a/src/applications/legalpad/storage/LegalpadDocumentBody.php b/src/applications/legalpad/storage/LegalpadDocumentBody.php
index 001e38ebf3..82809b201a 100644
--- a/src/applications/legalpad/storage/LegalpadDocumentBody.php
+++ b/src/applications/legalpad/storage/LegalpadDocumentBody.php
@@ -1,77 +1,76 @@
<?php
final class LegalpadDocumentBody extends LegalpadDAO
implements
PhabricatorMarkupInterface {
const MARKUP_FIELD_TEXT = 'markup:text ';
protected $phid;
protected $creatorPHID;
protected $documentPHID;
protected $version;
protected $title;
protected $text;
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'version' => 'uint32',
'title' => 'text255',
'text' => 'text?',
),
self::CONFIG_KEY_SCHEMA => array(
'key_document' => array(
'columns' => array('documentPHID', 'version'),
'unique' => true,
),
),
) + parent::getConfiguration();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorPHIDConstants::PHID_TYPE_LEGB);
}
/* -( PhabricatorMarkupInterface )----------------------------------------- */
public function getMarkupFieldKey($field) {
$content = $this->getMarkupText($field);
return PhabricatorMarkupEngine::digestRemarkupContent($this, $content);
}
public function newMarkupEngine($field) {
return PhabricatorMarkupEngine::newMarkupEngine(array());
}
public function getMarkupText($field) {
switch ($field) {
case self::MARKUP_FIELD_TEXT:
$text = $this->getText();
break;
default:
throw new Exception(pht('Unknown field: %s', $field));
- break;
}
return $text;
}
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 (bool)$this->getID();
}
}
diff --git a/src/applications/maniphest/storage/ManiphestTransaction.php b/src/applications/maniphest/storage/ManiphestTransaction.php
index 297bb970d1..54a1e47f6c 100644
--- a/src/applications/maniphest/storage/ManiphestTransaction.php
+++ b/src/applications/maniphest/storage/ManiphestTransaction.php
@@ -1,225 +1,224 @@
<?php
final class ManiphestTransaction
extends PhabricatorModularTransaction {
const MAILTAG_STATUS = 'maniphest-status';
const MAILTAG_OWNER = 'maniphest-owner';
const MAILTAG_PRIORITY = 'maniphest-priority';
const MAILTAG_CC = 'maniphest-cc';
const MAILTAG_PROJECTS = 'maniphest-projects';
const MAILTAG_COMMENT = 'maniphest-comment';
const MAILTAG_COLUMN = 'maniphest-column';
const MAILTAG_UNBLOCK = 'maniphest-unblock';
const MAILTAG_OTHER = 'maniphest-other';
public function getApplicationName() {
return 'maniphest';
}
public function getApplicationTransactionType() {
return ManiphestTaskPHIDType::TYPECONST;
}
public function getApplicationTransactionCommentObject() {
return new ManiphestTransactionComment();
}
public function getBaseTransactionClass() {
return 'ManiphestTaskTransactionType';
}
public function shouldGenerateOldValue() {
switch ($this->getTransactionType()) {
case ManiphestTaskEdgeTransaction::TRANSACTIONTYPE:
case ManiphestTaskUnblockTransaction::TRANSACTIONTYPE:
return false;
}
return parent::shouldGenerateOldValue();
}
public function getRequiredHandlePHIDs() {
$phids = parent::getRequiredHandlePHIDs();
$new = $this->getNewValue();
$old = $this->getOldValue();
switch ($this->getTransactionType()) {
case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE:
if ($new) {
$phids[] = $new;
}
if ($old) {
$phids[] = $old;
}
break;
case ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE:
$phids[] = $new;
break;
case ManiphestTaskMergedFromTransaction::TRANSACTIONTYPE:
$phids = array_merge($phids, $new);
break;
case ManiphestTaskEdgeTransaction::TRANSACTIONTYPE:
$phids = array_mergev(
array(
$phids,
array_keys(nonempty($old, array())),
array_keys(nonempty($new, array())),
));
break;
case ManiphestTaskAttachTransaction::TRANSACTIONTYPE:
$old = nonempty($old, array());
$new = nonempty($new, array());
$phids = array_mergev(
array(
$phids,
array_keys(idx($new, 'FILE', array())),
array_keys(idx($old, 'FILE', array())),
));
break;
case ManiphestTaskUnblockTransaction::TRANSACTIONTYPE:
foreach (array_keys($new) as $phid) {
$phids[] = $phid;
}
break;
case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:
$commit_phid = $this->getMetadataValue('commitPHID');
if ($commit_phid) {
$phids[] = $commit_phid;
}
break;
}
return $phids;
}
public function getActionName() {
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COLUMNS:
return pht('Changed Project Column');
}
return parent::getActionName();
}
public function getIcon() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COLUMNS:
return 'fa-columns';
}
return parent::getIcon();
}
public function getTitle() {
$author_phid = $this->getAuthorPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBTYPE:
return pht(
'%s changed the subtype of this task from "%s" to "%s".',
$this->renderHandleLink($author_phid),
$this->renderSubtypeName($old),
$this->renderSubtypeName($new));
- break;
}
return parent::getTitle();
}
public function getTitleForFeed() {
$author_phid = $this->getAuthorPHID();
$object_phid = $this->getObjectPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_SUBTYPE:
return pht(
'%s changed the subtype of %s from "%s" to "%s".',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
$this->renderSubtypeName($old),
$this->renderSubtypeName($new));
}
return parent::getTitleForFeed();
}
public function getMailTags() {
$tags = array();
switch ($this->getTransactionType()) {
case ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE:
case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:
$tags[] = self::MAILTAG_STATUS;
break;
case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE:
$tags[] = self::MAILTAG_OWNER;
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$tags[] = self::MAILTAG_CC;
break;
case PhabricatorTransactions::TYPE_EDGE:
switch ($this->getMetadataValue('edge:type')) {
case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST:
$tags[] = self::MAILTAG_PROJECTS;
break;
default:
$tags[] = self::MAILTAG_OTHER;
break;
}
break;
case ManiphestTaskPriorityTransaction::TRANSACTIONTYPE:
$tags[] = self::MAILTAG_PRIORITY;
break;
case ManiphestTaskUnblockTransaction::TRANSACTIONTYPE:
$tags[] = self::MAILTAG_UNBLOCK;
break;
case PhabricatorTransactions::TYPE_COLUMNS:
$tags[] = self::MAILTAG_COLUMN;
break;
case PhabricatorTransactions::TYPE_COMMENT:
$tags[] = self::MAILTAG_COMMENT;
break;
default:
$tags[] = self::MAILTAG_OTHER;
break;
}
return $tags;
}
public function getNoEffectDescription() {
switch ($this->getTransactionType()) {
case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:
return pht('The task already has the selected status.');
case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE:
return pht('The task already has the selected owner.');
case ManiphestTaskPriorityTransaction::TRANSACTIONTYPE:
return pht('The task already has the selected priority.');
}
return parent::getNoEffectDescription();
}
public function renderSubtypeName($value) {
$object = $this->getObject();
$map = $object->newEditEngineSubtypeMap();
if (!$map->isValidSubtype($value)) {
return $value;
}
return $map->getSubtype($value)->getName();
}
}
diff --git a/src/applications/nuance/github/NuanceGitHubRawEvent.php b/src/applications/nuance/github/NuanceGitHubRawEvent.php
index 8652dca9ad..d203bd52b9 100644
--- a/src/applications/nuance/github/NuanceGitHubRawEvent.php
+++ b/src/applications/nuance/github/NuanceGitHubRawEvent.php
@@ -1,391 +1,386 @@
<?php
final class NuanceGitHubRawEvent extends Phobject {
private $raw;
private $type;
const TYPE_ISSUE = 'issue';
const TYPE_REPOSITORY = 'repository';
public static function newEvent($type, array $raw) {
$event = new self();
$event->type = $type;
$event->raw = $raw;
return $event;
}
public function getRepositoryFullName() {
return $this->getRepositoryFullRawName();
}
public function isIssueEvent() {
if ($this->isPullRequestEvent()) {
return false;
}
if ($this->type == self::TYPE_ISSUE) {
return true;
}
switch ($this->getIssueRawKind()) {
case 'IssuesEvent':
return true;
case 'IssueCommentEvent':
if (!$this->getRawPullRequestData()) {
return true;
}
break;
}
return false;
}
public function isPullRequestEvent() {
if ($this->type == self::TYPE_ISSUE) {
// TODO: This is wrong, some of these are pull events.
return false;
}
$raw = $this->raw;
switch ($this->getIssueRawKind()) {
case 'PullRequestEvent':
return true;
case 'IssueCommentEvent':
if ($this->getRawPullRequestData()) {
return true;
}
break;
}
return false;
}
public function getIssueNumber() {
if (!$this->isIssueEvent()) {
return null;
}
return $this->getRawIssueNumber();
}
public function getPullRequestNumber() {
if (!$this->isPullRequestEvent()) {
return null;
}
return $this->getRawIssueNumber();
}
public function getID() {
$raw = $this->raw;
$id = idx($raw, 'id');
if ($id) {
return (int)$id;
}
return null;
}
public function getComment() {
if (!$this->isIssueEvent() && !$this->isPullRequestEvent()) {
return null;
}
$raw = $this->raw;
return idxv($raw, array('payload', 'comment', 'body'));
}
public function getURI() {
$raw = $this->raw;
if ($this->isIssueEvent() || $this->isPullRequestEvent()) {
if ($this->type == self::TYPE_ISSUE) {
$uri = idxv($raw, array('issue', 'html_url'));
$uri = $uri.'#event-'.$this->getID();
} else {
// The format of pull request events varies so we need to fish around
// a bit to find the correct URI.
$uri = idxv($raw, array('payload', 'pull_request', 'html_url'));
$need_anchor = true;
// For comments, we get a different anchor to link to the comment. In
// this case, the URI comes with an anchor already.
if (!$uri) {
$uri = idxv($raw, array('payload', 'comment', 'html_url'));
$need_anchor = false;
}
if (!$uri) {
$uri = idxv($raw, array('payload', 'issue', 'html_url'));
$need_anchor = true;
}
if ($need_anchor) {
$uri = $uri.'#event-'.$this->getID();
}
}
} else {
switch ($this->getIssueRawKind()) {
case 'CreateEvent':
$ref = idxv($raw, array('payload', 'ref'));
$repo = $this->getRepositoryFullRawName();
return "https://github.com/{$repo}/commits/{$ref}";
case 'PushEvent':
// These don't really have a URI since there may be multiple commits
// involved and GitHub doesn't bundle the push as an object on its
// own. Just try to find the URI for the log. The API also does
// not return any HTML URI for these events.
$head = idxv($raw, array('payload', 'head'));
if ($head === null) {
return null;
}
$repo = $this->getRepositoryFullRawName();
return "https://github.com/{$repo}/commits/{$head}";
case 'WatchEvent':
// These have no reasonable URI.
return null;
default:
return null;
}
}
return $uri;
}
private function getRepositoryFullRawName() {
$raw = $this->raw;
$full = idxv($raw, array('repo', 'name'));
if (phutil_nonempty_string($full)) {
return $full;
}
// For issue events, the repository is not identified explicitly in the
// response body. Parse it out of the URI.
$matches = null;
$ok = preg_match(
'(/repos/((?:[^/]+)/(?:[^/]+))/issues/events/)',
idx($raw, 'url'),
$matches);
if ($ok) {
return $matches[1];
}
return null;
}
private function getIssueRawKind() {
$raw = $this->raw;
return idxv($raw, array('type'));
}
private function getRawIssueNumber() {
$raw = $this->raw;
if ($this->type == self::TYPE_ISSUE) {
return idxv($raw, array('issue', 'number'));
}
if ($this->type == self::TYPE_REPOSITORY) {
$issue_number = idxv($raw, array('payload', 'issue', 'number'));
if ($issue_number) {
return $issue_number;
}
$pull_number = idxv($raw, array('payload', 'number'));
if ($pull_number) {
return $pull_number;
}
}
return null;
}
private function getRawPullRequestData() {
$raw = $this->raw;
return idxv($raw, array('payload', 'issue', 'pull_request'));
}
public function getEventFullTitle() {
switch ($this->type) {
case self::TYPE_ISSUE:
$title = $this->getRawIssueEventTitle();
break;
case self::TYPE_REPOSITORY:
$title = $this->getRawRepositoryEventTitle();
break;
default:
$title = pht('Unknown Event Type ("%s")', $this->type);
break;
}
return pht(
'GitHub %s %s (%s)',
$this->getRepositoryFullRawName(),
$this->getTargetObjectName(),
$title);
}
public function getActorGitHubUserID() {
$raw = $this->raw;
return (int)idxv($raw, array('actor', 'id'));
}
private function getTargetObjectName() {
if ($this->isPullRequestEvent()) {
$number = $this->getRawIssueNumber();
return pht('Pull Request #%d', $number);
} else if ($this->isIssueEvent()) {
$number = $this->getRawIssueNumber();
return pht('Issue #%d', $number);
} else if ($this->type == self::TYPE_REPOSITORY) {
$raw = $this->raw;
$type = idx($raw, 'type');
switch ($type) {
case 'CreateEvent':
$ref = idxv($raw, array('payload', 'ref'));
$ref_type = idxv($raw, array('payload', 'ref_type'));
switch ($ref_type) {
case 'branch':
return pht('Branch %s', $ref);
case 'tag':
return pht('Tag %s', $ref);
default:
return pht('Ref %s', $ref);
}
- break;
case 'PushEvent':
$ref = idxv($raw, array('payload', 'ref'));
if (preg_match('(^refs/heads/)', $ref)) {
return pht('Branch %s', substr($ref, strlen('refs/heads/')));
} else {
return pht('Ref %s', $ref);
}
- break;
case 'WatchEvent':
$actor = idxv($raw, array('actor', 'login'));
return pht('User %s', $actor);
}
return pht('Unknown Object');
} else {
return pht('Unknown Object');
}
}
private function getRawIssueEventTitle() {
$raw = $this->raw;
$action = idxv($raw, array('event'));
switch ($action) {
case 'assigned':
$assignee = idxv($raw, array('assignee', 'login'));
$title = pht('Assigned: %s', $assignee);
break;
case 'closed':
$title = pht('Closed');
break;
case 'demilestoned':
$milestone = idxv($raw, array('milestone', 'title'));
$title = pht('Removed Milestone: %s', $milestone);
break;
case 'labeled':
$label = idxv($raw, array('label', 'name'));
$title = pht('Added Label: %s', $label);
break;
case 'locked':
$title = pht('Locked');
break;
case 'milestoned':
$milestone = idxv($raw, array('milestone', 'title'));
$title = pht('Added Milestone: %s', $milestone);
break;
case 'renamed':
$title = pht('Renamed');
break;
case 'reopened':
$title = pht('Reopened');
break;
case 'unassigned':
$assignee = idxv($raw, array('assignee', 'login'));
$title = pht('Unassigned: %s', $assignee);
break;
case 'unlabeled':
$label = idxv($raw, array('label', 'name'));
$title = pht('Removed Label: %s', $label);
break;
case 'unlocked':
$title = pht('Unlocked');
break;
default:
$title = pht('"%s"', $action);
break;
}
return $title;
}
private function getRawRepositoryEventTitle() {
$raw = $this->raw;
$type = idx($raw, 'type');
switch ($type) {
case 'CreateEvent':
return pht('Created');
case 'PushEvent':
$head = idxv($raw, array('payload', 'head'));
$head = substr($head, 0, 12);
return pht('Pushed: %s', $head);
case 'IssuesEvent':
$action = idxv($raw, array('payload', 'action'));
switch ($action) {
case 'closed':
return pht('Closed');
case 'opened':
return pht('Created');
case 'reopened':
return pht('Reopened');
default:
return pht('"%s"', $action);
}
- break;
case 'IssueCommentEvent':
$action = idxv($raw, array('payload', 'action'));
switch ($action) {
case 'created':
return pht('Comment');
default:
return pht('"%s"', $action);
}
- break;
case 'PullRequestEvent':
$action = idxv($raw, array('payload', 'action'));
switch ($action) {
case 'opened':
return pht('Created');
default:
return pht('"%s"', $action);
}
- break;
case 'WatchEvent':
return pht('Watched');
}
return pht('"%s"', $type);
}
}
diff --git a/src/applications/phame/storage/PhamePost.php b/src/applications/phame/storage/PhamePost.php
index 9b4a6ba01a..a7d3129f81 100644
--- a/src/applications/phame/storage/PhamePost.php
+++ b/src/applications/phame/storage/PhamePost.php
@@ -1,406 +1,405 @@
<?php
final class PhamePost extends PhameDAO
implements
PhabricatorPolicyInterface,
PhabricatorMarkupInterface,
PhabricatorFlaggableInterface,
PhabricatorProjectInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorSubscribableInterface,
PhabricatorDestructibleInterface,
PhabricatorTokenReceiverInterface,
PhabricatorConduitResultInterface,
PhabricatorEditEngineLockableInterface,
PhabricatorFulltextInterface,
PhabricatorFerretInterface {
const MARKUP_FIELD_BODY = 'markup:body';
protected $bloggerPHID;
protected $title;
protected $subtitle;
protected $phameTitle;
protected $body;
protected $visibility;
protected $configData;
protected $datePublished;
protected $blogPHID;
protected $mailKey;
protected $headerImagePHID;
protected $interactPolicy;
private $blog = self::ATTACHABLE;
private $headerImageFile = self::ATTACHABLE;
public static function initializePost(
PhabricatorUser $blogger,
PhameBlog $blog) {
$post = id(new PhamePost())
->setBloggerPHID($blogger->getPHID())
->setBlogPHID($blog->getPHID())
->attachBlog($blog)
->setDatePublished(PhabricatorTime::getNow())
->setVisibility(PhameConstants::VISIBILITY_PUBLISHED)
->setInteractPolicy(
id(new PhameInheritBlogPolicyRule())
->getObjectPolicyFullKey());
return $post;
}
public function attachBlog(PhameBlog $blog) {
$this->blog = $blog;
return $this;
}
public function getBlog() {
return $this->assertAttached($this->blog);
}
public function getMonogram() {
return 'J'.$this->getID();
}
public function getLiveURI() {
$blog = $this->getBlog();
$is_draft = $this->isDraft();
$is_archived = $this->isArchived();
if (phutil_nonempty_string($blog->getDomain()) &&
!$is_draft && !$is_archived) {
return $this->getExternalLiveURI();
} else {
return $this->getInternalLiveURI();
}
}
public function getExternalLiveURI() {
$id = $this->getID();
$slug = $this->getSlug();
$path = "/post/{$id}/{$slug}/";
$domain = $this->getBlog()->getDomain();
return (string)id(new PhutilURI('http://'.$domain))
->setPath($path);
}
public function getInternalLiveURI() {
$id = $this->getID();
$slug = $this->getSlug();
$blog_id = $this->getBlog()->getID();
return "/phame/live/{$blog_id}/post/{$id}/{$slug}/";
}
public function getViewURI() {
$id = $this->getID();
$slug = $this->getSlug();
return "/phame/post/view/{$id}/{$slug}/";
}
public function getBestURI($is_live, $is_external) {
if ($is_live) {
if ($is_external) {
return $this->getExternalLiveURI();
} else {
return $this->getInternalLiveURI();
}
} else {
return $this->getViewURI();
}
}
public function getEditURI() {
return '/phame/post/edit/'.$this->getID().'/';
}
public function isDraft() {
return ($this->getVisibility() == PhameConstants::VISIBILITY_DRAFT);
}
public function isArchived() {
return ($this->getVisibility() == PhameConstants::VISIBILITY_ARCHIVED);
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'configData' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'title' => 'text255',
'subtitle' => 'text64',
'phameTitle' => 'sort64?',
'visibility' => 'uint32',
'mailKey' => 'bytes20',
'headerImagePHID' => 'phid?',
// T6203/NULLABILITY
// These seem like they should always be non-null?
'blogPHID' => 'phid?',
'body' => 'text?',
'configData' => 'text?',
// T6203/NULLABILITY
// This one probably should be nullable?
'datePublished' => 'epoch',
'interactPolicy' => 'policy',
),
self::CONFIG_KEY_SCHEMA => array(
'key_phid' => null,
'phid' => array(
'columns' => array('phid'),
'unique' => true,
),
'bloggerPosts' => array(
'columns' => array(
'bloggerPHID',
'visibility',
'datePublished',
'id',
),
),
),
) + parent::getConfiguration();
}
public function save() {
if (!$this->getMailKey()) {
$this->setMailKey(Filesystem::readRandomCharacters(20));
}
return parent::save();
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorPhamePostPHIDType::TYPECONST);
}
public function getSlug() {
return PhabricatorSlug::normalizeProjectSlug($this->getTitle());
}
public function getHeaderImageURI() {
return $this->getHeaderImageFile()->getBestURI();
}
public function attachHeaderImageFile(PhabricatorFile $file) {
$this->headerImageFile = $file;
return $this;
}
public function getHeaderImageFile() {
return $this->assertAttached($this->headerImageFile);
}
/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
PhabricatorPolicyCapability::CAN_INTERACT,
);
}
public function getPolicy($capability) {
// Draft and archived posts are visible only to the author and other
// users who can edit the blog. Published posts are visible to whoever
// the blog is visible to.
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
if (!$this->isDraft() && !$this->isArchived() && $this->getBlog()) {
return $this->getBlog()->getViewPolicy();
} else if ($this->getBlog()) {
return $this->getBlog()->getEditPolicy();
} else {
return PhabricatorPolicies::POLICY_NOONE;
}
- break;
case PhabricatorPolicyCapability::CAN_EDIT:
if ($this->getBlog()) {
return $this->getBlog()->getEditPolicy();
} else {
return PhabricatorPolicies::POLICY_NOONE;
}
case PhabricatorPolicyCapability::CAN_INTERACT:
return $this->getInteractPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $user) {
// A blog post's author can always view it.
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
case PhabricatorPolicyCapability::CAN_EDIT:
return ($user->getPHID() == $this->getBloggerPHID());
case PhabricatorPolicyCapability::CAN_INTERACT:
return false;
}
}
public function describeAutomaticCapability($capability) {
return pht('The author of a blog post can always view and edit it.');
}
/* -( PhabricatorMarkupInterface Implementation )-------------------------- */
public function getMarkupFieldKey($field) {
$content = $this->getMarkupText($field);
return PhabricatorMarkupEngine::digestRemarkupContent($this, $content);
}
public function newMarkupEngine($field) {
return PhabricatorMarkupEngine::newPhameMarkupEngine();
}
public function getMarkupText($field) {
switch ($field) {
case self::MARKUP_FIELD_BODY:
return $this->getBody();
}
return null;
}
public function didMarkupText(
$field,
$output,
PhutilMarkupEngine $engine) {
return $output;
}
public function shouldUseMarkupCache($field) {
return (bool)$this->getPHID();
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhamePostEditor();
}
public function getApplicationTransactionTemplate() {
return new PhamePostTransaction();
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$this->delete();
$this->saveTransaction();
}
/* -( PhabricatorTokenReceiverInterface )---------------------------------- */
public function getUsersToNotifyOfTokenGiven() {
return array(
$this->getBloggerPHID(),
);
}
/* -( PhabricatorSubscribableInterface Implementation )-------------------- */
public function isAutomaticallySubscribed($phid) {
return ($this->bloggerPHID == $phid);
}
/* -( PhabricatorConduitResultInterface )---------------------------------- */
public function getFieldSpecificationsForConduit() {
return array(
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('title')
->setType('string')
->setDescription(pht('Title of the post.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('slug')
->setType('string')
->setDescription(pht('Slug for the post.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('blogPHID')
->setType('phid')
->setDescription(pht('PHID of the blog that the post belongs to.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('authorPHID')
->setType('phid')
->setDescription(pht('PHID of the author of the post.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('body')
->setType('string')
->setDescription(pht('Body of the post.')),
id(new PhabricatorConduitSearchFieldSpecification())
->setKey('datePublished')
->setType('epoch?')
->setDescription(pht('Publish date, if the post has been published.')),
);
}
public function getFieldValuesForConduit() {
if ($this->isDraft()) {
$date_published = null;
} else if ($this->isArchived()) {
$date_published = null;
} else {
$date_published = (int)$this->getDatePublished();
}
return array(
'title' => $this->getTitle(),
'slug' => $this->getSlug(),
'blogPHID' => $this->getBlogPHID(),
'authorPHID' => $this->getBloggerPHID(),
'body' => $this->getBody(),
'datePublished' => $date_published,
);
}
public function getConduitSearchAttachments() {
return array();
}
/* -( PhabricatorFulltextInterface )--------------------------------------- */
public function newFulltextEngine() {
return new PhamePostFulltextEngine();
}
/* -( PhabricatorFerretInterface )----------------------------------------- */
public function newFerretEngine() {
return new PhamePostFerretEngine();
}
/* -( PhabricatorEditEngineLockableInterface )----------------------------- */
public function newEditEngineLock() {
return new PhamePostEditEngineLock();
}
}
diff --git a/src/applications/phrequent/storage/PhrequentTimeBlock.php b/src/applications/phrequent/storage/PhrequentTimeBlock.php
index 8649b4f67a..5051c050d7 100644
--- a/src/applications/phrequent/storage/PhrequentTimeBlock.php
+++ b/src/applications/phrequent/storage/PhrequentTimeBlock.php
@@ -1,324 +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
// 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>> $ranges 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/settings/setting/PhabricatorConpherenceSoundSetting.php b/src/applications/settings/setting/PhabricatorConpherenceSoundSetting.php
index 77ff6eac46..e5ec008f77 100644
--- a/src/applications/settings/setting/PhabricatorConpherenceSoundSetting.php
+++ b/src/applications/settings/setting/PhabricatorConpherenceSoundSetting.php
@@ -1,81 +1,78 @@
<?php
final class PhabricatorConpherenceSoundSetting
extends PhabricatorSelectSetting {
const SETTINGKEY = 'conpherence-sound';
const VALUE_CONPHERENCE_SILENT = '0';
const VALUE_CONPHERENCE_MENTION = '1';
const VALUE_CONPHERENCE_ALL = '2';
public function getSettingName() {
return pht('Conpherence Sound');
}
public function getSettingPanelKey() {
return PhabricatorConpherencePreferencesSettingsPanel::PANELKEY;
}
protected function getControlInstructions() {
return pht(
'Choose the default sound behavior for new Conpherence rooms.');
}
protected function isEnabledForViewer(PhabricatorUser $viewer) {
return PhabricatorApplication::isClassInstalledForViewer(
'PhabricatorConpherenceApplication',
$viewer);
}
public function getSettingDefaultValue() {
return self::VALUE_CONPHERENCE_ALL;
}
protected function getSelectOptions() {
return self::getOptionsMap();
}
public static function getSettingLabel($key) {
$labels = self::getOptionsMap();
return idx($labels, $key, pht('Unknown ("%s")', $key));
}
public static function getDefaultSound($value) {
switch ($value) {
case self::VALUE_CONPHERENCE_ALL:
return array(
ConpherenceRoomSettings::SOUND_RECEIVE =>
ConpherenceRoomSettings::DEFAULT_RECEIVE_SOUND,
ConpherenceRoomSettings::SOUND_MENTION =>
ConpherenceRoomSettings::DEFAULT_MENTION_SOUND,
);
- break;
case self::VALUE_CONPHERENCE_MENTION:
return array(
ConpherenceRoomSettings::SOUND_RECEIVE =>
ConpherenceRoomSettings::DEFAULT_NO_SOUND,
ConpherenceRoomSettings::SOUND_MENTION =>
ConpherenceRoomSettings::DEFAULT_MENTION_SOUND,
);
- break;
case self::VALUE_CONPHERENCE_SILENT:
return array(
ConpherenceRoomSettings::SOUND_RECEIVE =>
ConpherenceRoomSettings::DEFAULT_NO_SOUND,
ConpherenceRoomSettings::SOUND_MENTION =>
ConpherenceRoomSettings::DEFAULT_NO_SOUND,
);
- break;
}
}
private static function getOptionsMap() {
return array(
self::VALUE_CONPHERENCE_SILENT => pht('No Sounds'),
// self::VALUE_CONPHERENCE_MENTION => pht('Mentions Only'),
self::VALUE_CONPHERENCE_ALL => pht('All Messages'),
);
}
}
diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionValueController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionValueController.php
index ef5e168898..d844465b45 100644
--- a/src/applications/transactions/controller/PhabricatorApplicationTransactionValueController.php
+++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionValueController.php
@@ -1,73 +1,72 @@
<?php
final class PhabricatorApplicationTransactionValueController
extends PhabricatorApplicationTransactionController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$phid = $request->getURIData('phid');
$type = $request->getURIData('value');
$xaction = id(new PhabricatorObjectQuery())
->setViewer($viewer)
->withPHIDs(array($phid))
->executeOne();
if (!$xaction) {
return new Aphront404Response();
}
// For now, this pathway only supports policy transactions
// to show the details of custom policies. If / when this pathway
// supports more transaction types, rendering coding should be moved
// into PhabricatorTransactions e.g. feed rendering code.
// TODO: This should be some kind of "hey do you support this?" thing on
// the transactions themselves.
switch ($xaction->getTransactionType()) {
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorRepositoryPushPolicyTransaction::TRANSACTIONTYPE:
case PhabricatorApplicationPolicyChangeTransaction::TRANSACTIONTYPE:
break;
default:
return new Aphront404Response();
- break;
}
if ($type == 'old') {
$value = $xaction->getOldValue();
} else {
$value = $xaction->getNewValue();
}
$policy = id(new PhabricatorPolicyQuery())
->setViewer($viewer)
->withPHIDs(array($value))
->executeOne();
if (!$policy) {
return new Aphront404Response();
}
if ($policy->getType() != PhabricatorPolicyType::TYPE_CUSTOM) {
return new Aphront404Response();
}
$rules_view = id(new PhabricatorPolicyRulesView())
->setViewer($viewer)
->setPolicy($policy);
$cancel_uri = $this->guessCancelURI($viewer, $xaction);
return $this->newDialog()
->setTitle($policy->getFullName())
->setWidth(AphrontDialogView::WIDTH_FORM)
->appendChild($rules_view)
->addCancelButton($cancel_uri, pht('Close'));
}
}
diff --git a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
index 5c70f59a89..ccf279e416 100644
--- a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
+++ b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php
@@ -1,2054 +1,2046 @@
<?php
abstract class PhabricatorApplicationTransaction
extends PhabricatorLiskDAO
implements
PhabricatorPolicyInterface,
PhabricatorDestructibleInterface {
const TARGET_TEXT = 'text';
const TARGET_HTML = 'html';
protected $phid;
protected $objectPHID;
protected $authorPHID;
protected $viewPolicy;
protected $editPolicy;
protected $commentPHID;
protected $commentVersion = 0;
protected $transactionType;
protected $oldValue;
protected $newValue;
protected $metadata = array();
protected $contentSource;
private $comment;
private $commentNotLoaded;
private $handles;
private $renderingTarget = self::TARGET_HTML;
private $transactionGroup = array();
private $viewer = self::ATTACHABLE;
private $object = self::ATTACHABLE;
private $oldValueHasBeenSet = false;
private $ignoreOnNoEffect;
/**
* Flag this transaction as a pure side-effect which should be ignored when
* applying transactions if it has no effect, even if transaction application
* would normally fail. This both provides users with better error messages
* and allows transactions to perform optional side effects.
*/
public function setIgnoreOnNoEffect($ignore) {
$this->ignoreOnNoEffect = $ignore;
return $this;
}
public function getIgnoreOnNoEffect() {
return $this->ignoreOnNoEffect;
}
public function shouldGenerateOldValue() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
case PhabricatorTransactions::TYPE_INLINESTATE:
return false;
}
return true;
}
abstract public function getApplicationTransactionType();
private function getApplicationObjectTypeName() {
$types = PhabricatorPHIDType::getAllTypes();
$type = idx($types, $this->getApplicationTransactionType());
if ($type) {
return $type->getTypeName();
}
return pht('Object');
}
public function getApplicationTransactionCommentObject() {
return null;
}
public function getMetadataValue($key, $default = null) {
return idx($this->metadata, $key, $default);
}
public function setMetadataValue($key, $value) {
$this->metadata[$key] = $value;
return $this;
}
public function generatePHID() {
$type = PhabricatorApplicationTransactionTransactionPHIDType::TYPECONST;
$subtype = $this->getApplicationTransactionType();
return PhabricatorPHID::generateNewPHID($type, $subtype);
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'oldValue' => self::SERIALIZATION_JSON,
'newValue' => self::SERIALIZATION_JSON,
'metadata' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'commentPHID' => 'phid?',
'commentVersion' => 'uint32',
'contentSource' => 'text',
'transactionType' => 'text32',
),
self::CONFIG_KEY_SCHEMA => array(
'key_object' => array(
'columns' => array('objectPHID'),
),
),
) + parent::getConfiguration();
}
public function setContentSource(PhabricatorContentSource $content_source) {
$this->contentSource = $content_source->serialize();
return $this;
}
public function getContentSource() {
return PhabricatorContentSource::newFromSerialized($this->contentSource);
}
public function hasComment() {
$comment = $this->getComment();
if (!$comment) {
return false;
}
if ($comment->isEmptyComment()) {
return false;
}
return true;
}
public function getComment() {
if ($this->commentNotLoaded) {
throw new Exception(pht('Comment for this transaction was not loaded.'));
}
return $this->comment;
}
public function setIsCreateTransaction($create) {
return $this->setMetadataValue('core.create', $create);
}
public function getIsCreateTransaction() {
return (bool)$this->getMetadataValue('core.create', false);
}
public function setIsDefaultTransaction($default) {
return $this->setMetadataValue('core.default', $default);
}
public function getIsDefaultTransaction() {
return (bool)$this->getMetadataValue('core.default', false);
}
public function setIsSilentTransaction($silent) {
return $this->setMetadataValue('core.silent', $silent);
}
public function getIsSilentTransaction() {
return (bool)$this->getMetadataValue('core.silent', false);
}
public function setIsMFATransaction($mfa) {
return $this->setMetadataValue('core.mfa', $mfa);
}
public function getIsMFATransaction() {
return (bool)$this->getMetadataValue('core.mfa', false);
}
public function setIsLockOverrideTransaction($override) {
return $this->setMetadataValue('core.lock-override', $override);
}
public function getIsLockOverrideTransaction() {
return (bool)$this->getMetadataValue('core.lock-override', false);
}
public function setTransactionGroupID($group_id) {
return $this->setMetadataValue('core.groupID', $group_id);
}
public function getTransactionGroupID() {
return $this->getMetadataValue('core.groupID', null);
}
public function attachComment(
PhabricatorApplicationTransactionComment $comment) {
$this->comment = $comment;
$this->commentNotLoaded = false;
return $this;
}
public function setCommentNotLoaded($not_loaded) {
$this->commentNotLoaded = $not_loaded;
return $this;
}
public function attachObject($object) {
$this->object = $object;
return $this;
}
public function getObject() {
return $this->assertAttached($this->object);
}
public function getRemarkupChanges() {
$changes = $this->newRemarkupChanges();
assert_instances_of($changes, 'PhabricatorTransactionRemarkupChange');
// Convert older-style remarkup blocks into newer-style remarkup changes.
// This builds changes that do not have the correct "old value", so rules
// that operate differently against edits (like @user mentions) won't work
// properly.
foreach ($this->getRemarkupBlocks() as $block) {
$changes[] = $this->newRemarkupChange()
->setOldValue(null)
->setNewValue($block);
}
$comment = $this->getComment();
if ($comment) {
if ($comment->hasOldComment()) {
$old_value = $comment->getOldComment()->getContent();
} else {
$old_value = null;
}
$new_value = $comment->getContent();
$changes[] = $this->newRemarkupChange()
->setOldValue($old_value)
->setNewValue($new_value);
}
$metadata = $this->getMetadataValue('remarkup.control');
if (!is_array($metadata)) {
$metadata = array();
}
foreach ($changes as $change) {
if (!$change->getMetadata()) {
$change->setMetadata($metadata);
}
}
return $changes;
}
protected function newRemarkupChanges() {
return array();
}
protected function newRemarkupChange() {
return id(new PhabricatorTransactionRemarkupChange())
->setTransaction($this);
}
/**
* @deprecated
*/
public function getRemarkupBlocks() {
$blocks = array();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getTransactionCustomField();
if ($field) {
$custom_blocks = $field->getApplicationTransactionRemarkupBlocks(
$this);
foreach ($custom_blocks as $custom_block) {
$blocks[] = $custom_block;
}
}
break;
}
return $blocks;
}
public function setOldValue($value) {
$this->oldValueHasBeenSet = true;
$this->writeField('oldValue', $value);
return $this;
}
public function hasOldValue() {
return $this->oldValueHasBeenSet;
}
public function newChronologicalSortVector() {
return id(new PhutilSortVector())
->addInt((int)$this->getDateCreated())
->addInt((int)$this->getID());
}
/* -( Rendering )---------------------------------------------------------- */
public function setRenderingTarget($rendering_target) {
$this->renderingTarget = $rendering_target;
return $this;
}
public function getRenderingTarget() {
return $this->renderingTarget;
}
public function attachViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->assertAttached($this->viewer);
}
public function getRequiredHandlePHIDs() {
$phids = array();
$old = $this->getOldValue();
$new = $this->getNewValue();
$phids[] = array($this->getAuthorPHID());
$phids[] = array($this->getObjectPHID());
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getTransactionCustomField();
if ($field) {
$phids[] = $field->getApplicationTransactionRequiredHandlePHIDs(
$this);
}
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$phids[] = $old;
$phids[] = $new;
break;
case PhabricatorTransactions::TYPE_FILE:
$phids[] = array_keys($old + $new);
break;
case PhabricatorTransactions::TYPE_EDGE:
$record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
$phids[] = $record->getChangedPHIDs();
break;
case PhabricatorTransactions::TYPE_COLUMNS:
foreach ($new as $move) {
$phids[] = array(
$move['columnPHID'],
$move['boardPHID'],
);
$phids[] = $move['fromColumnPHIDs'];
}
break;
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
if (!PhabricatorPolicyQuery::isSpecialPolicy($old)) {
$phids[] = array($old);
}
if (!PhabricatorPolicyQuery::isSpecialPolicy($new)) {
$phids[] = array($new);
}
break;
case PhabricatorTransactions::TYPE_SPACE:
if ($old) {
$phids[] = array($old);
}
if ($new) {
$phids[] = array($new);
}
break;
case PhabricatorTransactions::TYPE_TOKEN:
break;
}
if ($this->getComment()) {
$phids[] = array($this->getComment()->getAuthorPHID());
}
return array_mergev($phids);
}
public function setHandles(array $handles) {
$this->handles = $handles;
return $this;
}
public function getHandle($phid) {
if (empty($this->handles[$phid])) {
throw new Exception(
pht(
'Transaction ("%s", of type "%s") requires a handle ("%s") that it '.
'did not load.',
$this->getPHID(),
$this->getTransactionType(),
$phid));
}
return $this->handles[$phid];
}
public function getHandleIfExists($phid) {
return idx($this->handles, $phid);
}
public function getHandles() {
if ($this->handles === null) {
throw new Exception(
pht('Transaction requires handles and it did not load them.'));
}
return $this->handles;
}
public function renderHandleLink($phid) {
if ($this->renderingTarget == self::TARGET_HTML) {
return $this->getHandle($phid)->renderHovercardLink();
} else {
return $this->getHandle($phid)->getLinkName();
}
}
public function renderHandleList(array $phids) {
$links = array();
foreach ($phids as $phid) {
$links[] = $this->renderHandleLink($phid);
}
if ($this->renderingTarget == self::TARGET_HTML) {
return phutil_implode_html(', ', $links);
} else {
return implode(', ', $links);
}
}
private function renderSubscriberList(array $phids, $change_type) {
if ($this->getRenderingTarget() == self::TARGET_TEXT) {
return $this->renderHandleList($phids);
} else {
$handles = array_select_keys($this->getHandles(), $phids);
return id(new SubscriptionListStringBuilder())
->setHandles($handles)
->setObjectPHID($this->getPHID())
->buildTransactionString($change_type);
}
}
protected function renderPolicyName($phid, $state = 'old') {
$policy = PhabricatorPolicy::newFromPolicyAndHandle(
$phid,
$this->getHandleIfExists($phid));
$ref = $policy->newRef($this->getViewer());
if ($this->renderingTarget == self::TARGET_HTML) {
$output = $ref->newTransactionLink($state, $this);
} else {
$output = $ref->getPolicyDisplayName();
}
return $output;
}
public function getIcon() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$comment = $this->getComment();
if ($comment && $comment->getIsRemoved()) {
return 'fa-trash';
}
return 'fa-comment';
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$old = $this->getOldValue();
$new = $this->getNewValue();
$add = array_diff($new, $old);
$rem = array_diff($old, $new);
if ($add && $rem) {
return 'fa-user';
} else if ($add) {
return 'fa-user-plus';
} else if ($rem) {
return 'fa-user-times';
} else {
return 'fa-user';
}
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
return 'fa-lock';
case PhabricatorTransactions::TYPE_EDGE:
switch ($this->getMetadataValue('edge:type')) {
case DiffusionCommitRevertedByCommitEdgeType::EDGECONST:
return 'fa-undo';
case DiffusionCommitRevertsCommitEdgeType::EDGECONST:
return 'fa-ambulance';
}
return 'fa-link';
case PhabricatorTransactions::TYPE_TOKEN:
return 'fa-trophy';
case PhabricatorTransactions::TYPE_SPACE:
return 'fa-th-large';
case PhabricatorTransactions::TYPE_COLUMNS:
return 'fa-columns';
case PhabricatorTransactions::TYPE_MFA:
return 'fa-vcard';
}
return 'fa-pencil';
}
public function getToken() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_TOKEN:
$old = $this->getOldValue();
$new = $this->getNewValue();
if ($new) {
$icon = substr($new, 10);
} else {
$icon = substr($old, 10);
}
return array($icon, !$this->getNewValue());
}
return array(null, null);
}
public function getColor() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT;
$comment = $this->getComment();
if ($comment && $comment->getIsRemoved()) {
return 'grey';
}
break;
case PhabricatorTransactions::TYPE_EDGE:
switch ($this->getMetadataValue('edge:type')) {
case DiffusionCommitRevertedByCommitEdgeType::EDGECONST:
return 'pink';
case DiffusionCommitRevertsCommitEdgeType::EDGECONST:
return 'sky';
}
break;
case PhabricatorTransactions::TYPE_MFA;
return 'pink';
}
return null;
}
protected function getTransactionCustomField() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$key = $this->getMetadataValue('customfield:key');
if (!$key) {
return null;
}
$object = $this->getObject();
if (!($object instanceof PhabricatorCustomFieldInterface)) {
return null;
}
$field = PhabricatorCustomField::getObjectField(
$object,
PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
$key);
if (!$field) {
return null;
}
$field->setViewer($this->getViewer());
return $field;
}
return null;
}
public function shouldHide() {
// Never hide comments.
if ($this->hasComment()) {
return false;
}
$xaction_type = $this->getTransactionType();
// Always hide requests for object history.
if ($xaction_type === PhabricatorTransactions::TYPE_HISTORY) {
return true;
}
// Always hide file attach/detach transactions.
if ($xaction_type === PhabricatorTransactions::TYPE_FILE) {
if ($this->getMetadataValue('attach.implicit')) {
return true;
}
}
// Hide creation transactions if the old value is empty. These are
// transactions like "alice set the task title to: ...", which are
// essentially never interesting.
if ($this->getIsCreateTransaction()) {
switch ($xaction_type) {
case PhabricatorTransactions::TYPE_CREATE:
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
case PhabricatorTransactions::TYPE_SPACE:
break;
case PhabricatorTransactions::TYPE_SUBTYPE:
return true;
default:
$old = $this->getOldValue();
if (is_array($old) && !$old) {
return true;
}
if (!is_array($old)) {
if ($old === '' || $old === null) {
return true;
}
// The integer 0 is also uninteresting by default; this is often
// an "off" flag for something like "All Day Event".
if ($old === 0) {
return true;
}
}
break;
}
}
// Hide creation transactions setting values to defaults, even if
// the old value is not empty. For example, tasks may have a global
// default view policy of "All Users", but a particular form sets the
// policy to "Administrators". The transaction corresponding to this
// change is not interesting, since it is the default behavior of the
// form.
if ($this->getIsCreateTransaction()) {
if ($this->getIsDefaultTransaction()) {
return true;
}
}
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
case PhabricatorTransactions::TYPE_SPACE:
if ($this->getIsCreateTransaction()) {
break;
}
// TODO: Remove this eventually, this is handling old changes during
// object creation prior to the introduction of "create" and "default"
// transaction display flags.
// NOTE: We can also hit this case with Space transactions that later
// update a default space (`null`) to an explicit space, so handling
// the Space case may require some finesse.
if ($this->getOldValue() === null) {
return true;
} else {
return false;
}
- break;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getTransactionCustomField();
if ($field) {
return $field->shouldHideInApplicationTransactions($this);
}
break;
case PhabricatorTransactions::TYPE_COLUMNS:
return !$this->getInterestingMoves($this->getNewValue());
case PhabricatorTransactions::TYPE_EDGE:
$edge_type = $this->getMetadataValue('edge:type');
switch ($edge_type) {
case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
case ManiphestTaskHasDuplicateTaskEdgeType::EDGECONST:
case ManiphestTaskIsDuplicateOfTaskEdgeType::EDGECONST:
case PhabricatorMutedEdgeType::EDGECONST:
case PhabricatorMutedByEdgeType::EDGECONST:
return true;
case PhabricatorObjectMentionedByObjectEdgeType::EDGECONST:
$record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
$add = $record->getAddedPHIDs();
$add_value = reset($add);
$add_handle = $this->getHandle($add_value);
if ($add_handle->getPolicyFiltered()) {
return true;
}
return false;
- break;
default:
break;
}
break;
case PhabricatorTransactions::TYPE_INLINESTATE:
list($done, $undone) = $this->getInterestingInlineStateChangeCounts();
if (!$done && !$undone) {
return true;
}
break;
}
return false;
}
public function shouldHideForMail(array $xactions) {
if ($this->isSelfSubscription()) {
return true;
}
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_TOKEN:
return true;
case PhabricatorTransactions::TYPE_EDGE:
$edge_type = $this->getMetadataValue('edge:type');
switch ($edge_type) {
case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
case PhabricatorObjectMentionedByObjectEdgeType::EDGECONST:
case DifferentialRevisionDependsOnRevisionEdgeType::EDGECONST:
case DifferentialRevisionDependedOnByRevisionEdgeType::EDGECONST:
case ManiphestTaskHasCommitEdgeType::EDGECONST:
case DiffusionCommitHasTaskEdgeType::EDGECONST:
case DiffusionCommitHasRevisionEdgeType::EDGECONST:
case DifferentialRevisionHasCommitEdgeType::EDGECONST:
return true;
case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST:
// When an object is first created, we hide any corresponding
// project transactions in the web UI because you can just look at
// the UI element elsewhere on screen to see which projects it
// is tagged with. However, in mail there's no other way to get
// this information, and it has some amount of value to users, so
// we keep the transaction. See T10493.
return false;
default:
break;
}
break;
}
if ($this->isInlineCommentTransaction()) {
$inlines = array();
// If there's a normal comment, we don't need to publish the inline
// transaction, since the normal comment covers things.
foreach ($xactions as $xaction) {
if ($xaction->isInlineCommentTransaction()) {
$inlines[] = $xaction;
continue;
}
// We found a normal comment, so hide this inline transaction.
if ($xaction->hasComment()) {
return true;
}
}
// If there are several inline comments, only publish the first one.
if ($this !== head($inlines)) {
return true;
}
}
return $this->shouldHide();
}
public function shouldHideForFeed() {
if ($this->isSelfSubscription()) {
return true;
}
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_TOKEN:
case PhabricatorTransactions::TYPE_MFA:
return true;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
// See T8952. When an application (usually Herald) modifies
// subscribers, this tends to be very uninteresting.
if ($this->isApplicationAuthor()) {
return true;
}
break;
case PhabricatorTransactions::TYPE_EDGE:
$edge_type = $this->getMetadataValue('edge:type');
switch ($edge_type) {
case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
case PhabricatorObjectMentionedByObjectEdgeType::EDGECONST:
case DifferentialRevisionDependsOnRevisionEdgeType::EDGECONST:
case DifferentialRevisionDependedOnByRevisionEdgeType::EDGECONST:
case ManiphestTaskHasCommitEdgeType::EDGECONST:
case DiffusionCommitHasTaskEdgeType::EDGECONST:
case DiffusionCommitHasRevisionEdgeType::EDGECONST:
case DifferentialRevisionHasCommitEdgeType::EDGECONST:
return true;
default:
break;
}
break;
case PhabricatorTransactions::TYPE_INLINESTATE:
return true;
}
return $this->shouldHide();
}
public function shouldHideForNotifications() {
return $this->shouldHideForFeed();
}
private function getTitleForMailWithRenderingTarget($new_target) {
$old_target = $this->getRenderingTarget();
try {
$this->setRenderingTarget($new_target);
$result = $this->getTitleForMail();
} catch (Exception $ex) {
$this->setRenderingTarget($old_target);
throw $ex;
}
$this->setRenderingTarget($old_target);
return $result;
}
public function getTitleForMail() {
return $this->getTitle();
}
public function getTitleForTextMail() {
return $this->getTitleForMailWithRenderingTarget(self::TARGET_TEXT);
}
public function getTitleForHTMLMail() {
// TODO: For now, rendering this with TARGET_HTML generates links with
// bad targets ("/x/y/" instead of "https://dev.example.com/x/y/"). Throw
// a rug over the issue for the moment. See T12921.
$title = $this->getTitleForMailWithRenderingTarget(self::TARGET_TEXT);
if ($title === null) {
return null;
}
if ($this->hasChangeDetails()) {
$details_uri = $this->getChangeDetailsURI();
$details_uri = PhabricatorEnv::getProductionURI($details_uri);
$show_details = phutil_tag(
'a',
array(
'href' => $details_uri,
),
pht('(Show Details)'));
$title = array($title, ' ', $show_details);
}
return $title;
}
public function getChangeDetailsURI() {
return '/transactions/detail/'.$this->getPHID().'/';
}
public function getBodyForMail() {
if ($this->isInlineCommentTransaction()) {
// We don't return inline comment content as mail body content, because
// applications need to contextualize it (by adding line numbers, for
// example) in order for it to make sense.
return null;
}
$comment = $this->getComment();
if ($comment && strlen($comment->getContent())) {
return $comment->getContent();
}
return null;
}
public function getNoEffectDescription() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
return pht('You can not post an empty comment.');
case PhabricatorTransactions::TYPE_VIEW_POLICY:
return pht(
'This %s already has that view policy.',
$this->getApplicationObjectTypeName());
case PhabricatorTransactions::TYPE_EDIT_POLICY:
return pht(
'This %s already has that edit policy.',
$this->getApplicationObjectTypeName());
case PhabricatorTransactions::TYPE_JOIN_POLICY:
return pht(
'This %s already has that join policy.',
$this->getApplicationObjectTypeName());
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
return pht(
'This %s already has that interact policy.',
$this->getApplicationObjectTypeName());
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return pht(
'All users are already subscribed to this %s.',
$this->getApplicationObjectTypeName());
case PhabricatorTransactions::TYPE_SPACE:
return pht('This object is already in that space.');
case PhabricatorTransactions::TYPE_EDGE:
return pht('Edges already exist; transaction has no effect.');
case PhabricatorTransactions::TYPE_COLUMNS:
return pht(
'You have not moved this object to any columns it is not '.
'already in.');
case PhabricatorTransactions::TYPE_MFA:
return pht(
'You can not sign a transaction group that has no other '.
'effects.');
}
return pht(
'Transaction (of type "%s") has no effect.',
$this->getTransactionType());
}
public function getTitle() {
$author_phid = $this->getAuthorPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_CREATE:
return pht(
'%s created this object.',
$this->renderHandleLink($author_phid));
case PhabricatorTransactions::TYPE_COMMENT:
return pht(
'%s added a comment.',
$this->renderHandleLink($author_phid));
case PhabricatorTransactions::TYPE_VIEW_POLICY:
if ($this->getIsCreateTransaction()) {
return pht(
'%s created this object with visibility "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($new, 'new'));
} else {
return pht(
'%s changed the visibility from "%s" to "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($old, 'old'),
$this->renderPolicyName($new, 'new'));
}
case PhabricatorTransactions::TYPE_EDIT_POLICY:
if ($this->getIsCreateTransaction()) {
return pht(
'%s created this object with edit policy "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($new, 'new'));
} else {
return pht(
'%s changed the edit policy from "%s" to "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($old, 'old'),
$this->renderPolicyName($new, 'new'));
}
case PhabricatorTransactions::TYPE_JOIN_POLICY:
if ($this->getIsCreateTransaction()) {
return pht(
'%s created this object with join policy "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($new, 'new'));
} else {
return pht(
'%s changed the join policy from "%s" to "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($old, 'old'),
$this->renderPolicyName($new, 'new'));
}
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
if ($this->getIsCreateTransaction()) {
return pht(
'%s created this object with interact policy "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($new, 'new'));
} else {
return pht(
'%s changed the interact policy from "%s" to "%s".',
$this->renderHandleLink($author_phid),
$this->renderPolicyName($old, 'old'),
$this->renderPolicyName($new, 'new'));
}
case PhabricatorTransactions::TYPE_SPACE:
if ($this->getIsCreateTransaction()) {
return pht(
'%s created this object in space %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($new));
} else {
return pht(
'%s shifted this object from the %s space to the %s space.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($old),
$this->renderHandleLink($new));
}
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$add = array_diff($new, $old);
$rem = array_diff($old, $new);
if ($add && $rem) {
return pht(
'%s edited subscriber(s), added %d: %s; removed %d: %s.',
$this->renderHandleLink($author_phid),
count($add),
$this->renderSubscriberList($add, 'add'),
count($rem),
$this->renderSubscriberList($rem, 'rem'));
} else if ($add) {
if ($this->isSelfSubscription()) {
return pht(
'%s subscribed.',
$this->renderHandleLink($author_phid));
}
return pht(
'%s added %d subscriber(s): %s.',
$this->renderHandleLink($author_phid),
count($add),
$this->renderSubscriberList($add, 'add'));
} else if ($rem) {
if ($this->isSelfSubscription()) {
return pht(
'%s unsubscribed.',
$this->renderHandleLink($author_phid));
}
return pht(
'%s removed %d subscriber(s): %s.',
$this->renderHandleLink($author_phid),
count($rem),
$this->renderSubscriberList($rem, 'rem'));
} else {
// This is used when rendering previews, before the user actually
// selects any CCs.
return pht(
'%s updated subscribers...',
$this->renderHandleLink($author_phid));
}
- break;
case PhabricatorTransactions::TYPE_FILE:
$add = array_diff_key($new, $old);
$add = array_keys($add);
$rem = array_diff_key($old, $new);
$rem = array_keys($rem);
$mod = array();
foreach ($old + $new as $key => $ignored) {
if (!isset($old[$key])) {
continue;
}
if (!isset($new[$key])) {
continue;
}
if ($old[$key] === $new[$key]) {
continue;
}
$mod[] = $key;
}
// Specialize the specific case of only modifying files and upgrading
// references to attachments. This is accessible via the UI and can
// be shown more clearly than the generic default transaction shows
// it.
$mode_reference = PhabricatorFileAttachment::MODE_REFERENCE;
$mode_attach = PhabricatorFileAttachment::MODE_ATTACH;
$is_refattach = false;
if ($mod && !$add && !$rem) {
$all_refattach = true;
foreach ($mod as $phid) {
if (idx($old, $phid) !== $mode_reference) {
$all_refattach = false;
break;
}
if (idx($new, $phid) !== $mode_attach) {
$all_refattach = false;
break;
}
}
$is_refattach = $all_refattach;
}
if ($is_refattach) {
return pht(
'%s attached %s referenced file(s): %s.',
$this->renderHandleLink($author_phid),
phutil_count($mod),
$this->renderHandleList($mod));
} else if ($add && $rem && $mod) {
return pht(
'%s updated %s attached file(s), added %s: %s; removed %s: %s; '.
'modified %s: %s.',
$this->renderHandleLink($author_phid),
new PhutilNumber(count($add) + count($rem)),
phutil_count($add),
$this->renderHandleList($add),
phutil_count($rem),
$this->renderHandleList($rem),
phutil_count($mod),
$this->renderHandleList($mod));
} else if ($add && $rem) {
return pht(
'%s updated %s attached file(s), added %s: %s; removed %s: %s.',
$this->renderHandleLink($author_phid),
new PhutilNumber(count($add) + count($rem)),
phutil_count($add),
$this->renderHandleList($add),
phutil_count($rem),
$this->renderHandleList($rem));
} else if ($add && $mod) {
return pht(
'%s updated %s attached file(s), added %s: %s; modified %s: %s.',
$this->renderHandleLink($author_phid),
new PhutilNumber(count($add) + count($mod)),
phutil_count($add),
$this->renderHandleList($add),
phutil_count($mod),
$this->renderHandleList($mod));
} else if ($rem && $mod) {
return pht(
'%s updated %s attached file(s), removed %s: %s; modified %s: %s.',
$this->renderHandleLink($author_phid),
new PhutilNumber(count($rem) + count($mod)),
phutil_count($rem),
$this->renderHandleList($rem),
phutil_count($mod),
$this->renderHandleList($mod));
} else if ($add) {
return pht(
'%s attached %s file(s): %s.',
$this->renderHandleLink($author_phid),
phutil_count($add),
$this->renderHandleList($add));
} else if ($rem) {
return pht(
'%s removed %s attached file(s): %s.',
$this->renderHandleLink($author_phid),
phutil_count($rem),
$this->renderHandleList($rem));
} else if ($mod) {
return pht(
'%s modified %s attached file(s): %s.',
$this->renderHandleLink($author_phid),
phutil_count($mod),
$this->renderHandleList($mod));
} else {
return pht(
'%s attached files...',
$this->renderHandleLink($author_phid));
}
- break;
case PhabricatorTransactions::TYPE_EDGE:
$record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
$add = $record->getAddedPHIDs();
$rem = $record->getRemovedPHIDs();
$type = $this->getMetadata('edge:type');
$type = head($type);
try {
$type_obj = PhabricatorEdgeType::getByConstant($type);
} catch (Exception $ex) {
// Recover somewhat gracefully from edge transactions which
// we don't have the classes for.
return pht(
'%s edited an edge.',
$this->renderHandleLink($author_phid));
}
if ($add && $rem) {
return $type_obj->getTransactionEditString(
$this->renderHandleLink($author_phid),
new PhutilNumber(count($add) + count($rem)),
phutil_count($add),
$this->renderHandleList($add),
phutil_count($rem),
$this->renderHandleList($rem));
} else if ($add) {
return $type_obj->getTransactionAddString(
$this->renderHandleLink($author_phid),
phutil_count($add),
$this->renderHandleList($add));
} else if ($rem) {
return $type_obj->getTransactionRemoveString(
$this->renderHandleLink($author_phid),
phutil_count($rem),
$this->renderHandleList($rem));
} else {
return $type_obj->getTransactionPreviewString(
$this->renderHandleLink($author_phid));
}
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getTransactionCustomField();
if ($field) {
return $field->getApplicationTransactionTitle($this);
} else {
$developer_mode = 'phabricator.developer-mode';
$is_developer = PhabricatorEnv::getEnvConfig($developer_mode);
if ($is_developer) {
return pht(
'%s edited a custom field (with key "%s").',
$this->renderHandleLink($author_phid),
$this->getMetadata('customfield:key'));
} else {
return pht(
'%s edited a custom field.',
$this->renderHandleLink($author_phid));
}
}
case PhabricatorTransactions::TYPE_TOKEN:
if ($old && $new) {
return pht(
'%s updated a token.',
$this->renderHandleLink($author_phid));
} else if ($old) {
return pht(
'%s rescinded a token.',
$this->renderHandleLink($author_phid));
} else {
return pht(
'%s awarded a token.',
$this->renderHandleLink($author_phid));
}
case PhabricatorTransactions::TYPE_INLINESTATE:
list($done, $undone) = $this->getInterestingInlineStateChangeCounts();
if ($done && $undone) {
return pht(
'%s marked %s inline comment(s) as done and %s inline comment(s) '.
'as not done.',
$this->renderHandleLink($author_phid),
new PhutilNumber($done),
new PhutilNumber($undone));
} else if ($done) {
return pht(
'%s marked %s inline comment(s) as done.',
$this->renderHandleLink($author_phid),
new PhutilNumber($done));
} else {
return pht(
'%s marked %s inline comment(s) as not done.',
$this->renderHandleLink($author_phid),
new PhutilNumber($undone));
}
- break;
case PhabricatorTransactions::TYPE_COLUMNS:
$moves = $this->getInterestingMoves($new);
if (count($moves) == 1) {
$move = head($moves);
$from_columns = $move['fromColumnPHIDs'];
$to_column = $move['columnPHID'];
$board_phid = $move['boardPHID'];
if (count($from_columns) == 1) {
return pht(
'%s moved this task from %s to %s on the %s board.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink(head($from_columns)),
$this->renderHandleLink($to_column),
$this->renderHandleLink($board_phid));
} else {
return pht(
'%s moved this task to %s on the %s board.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($to_column),
$this->renderHandleLink($board_phid));
}
} else {
$fragments = array();
foreach ($moves as $move) {
$to_column = $move['columnPHID'];
$board_phid = $move['boardPHID'];
$fragments[] = pht(
'%s (%s)',
$this->renderHandleLink($board_phid),
$this->renderHandleLink($to_column));
}
return pht(
'%s moved this task on %s board(s): %s.',
$this->renderHandleLink($author_phid),
phutil_count($moves),
phutil_implode_html(', ', $fragments));
}
- break;
-
case PhabricatorTransactions::TYPE_MFA:
return pht(
'%s signed these changes with MFA.',
$this->renderHandleLink($author_phid));
default:
// In developer mode, provide a better hint here about which string
// we're missing.
$developer_mode = 'phabricator.developer-mode';
$is_developer = PhabricatorEnv::getEnvConfig($developer_mode);
if ($is_developer) {
return pht(
'%s edited this object (transaction type "%s").',
$this->renderHandleLink($author_phid),
$this->getTransactionType());
} else {
return pht(
'%s edited this %s.',
$this->renderHandleLink($author_phid),
$this->getApplicationObjectTypeName());
}
}
}
public function getTitleForFeed() {
$author_phid = $this->getAuthorPHID();
$object_phid = $this->getObjectPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_CREATE:
return pht(
'%s created %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
case PhabricatorTransactions::TYPE_COMMENT:
return pht(
'%s added a comment to %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
case PhabricatorTransactions::TYPE_VIEW_POLICY:
return pht(
'%s changed the visibility for %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
case PhabricatorTransactions::TYPE_EDIT_POLICY:
return pht(
'%s changed the edit policy for %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
case PhabricatorTransactions::TYPE_JOIN_POLICY:
return pht(
'%s changed the join policy for %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
return pht(
'%s changed the interact policy for %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return pht(
'%s updated subscribers of %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
case PhabricatorTransactions::TYPE_SPACE:
if ($this->getIsCreateTransaction()) {
return pht(
'%s created %s in the %s space.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
$this->renderHandleLink($new));
} else {
return pht(
'%s shifted %s from the %s space to the %s space.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
$this->renderHandleLink($old),
$this->renderHandleLink($new));
}
case PhabricatorTransactions::TYPE_EDGE:
$record = PhabricatorEdgeChangeRecord::newFromTransaction($this);
$add = $record->getAddedPHIDs();
$rem = $record->getRemovedPHIDs();
$type = $this->getMetadata('edge:type');
$type = head($type);
$type_obj = PhabricatorEdgeType::getByConstant($type);
if ($add && $rem) {
return $type_obj->getFeedEditString(
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
new PhutilNumber(count($add) + count($rem)),
phutil_count($add),
$this->renderHandleList($add),
phutil_count($rem),
$this->renderHandleList($rem));
} else if ($add) {
return $type_obj->getFeedAddString(
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
phutil_count($add),
$this->renderHandleList($add));
} else if ($rem) {
return $type_obj->getFeedRemoveString(
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
phutil_count($rem),
$this->renderHandleList($rem));
} else {
return pht(
'%s edited edge metadata for %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
}
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getTransactionCustomField();
if ($field) {
return $field->getApplicationTransactionTitleForFeed($this);
} else {
return pht(
'%s edited a custom field on %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
}
case PhabricatorTransactions::TYPE_COLUMNS:
$moves = $this->getInterestingMoves($new);
if (count($moves) == 1) {
$move = head($moves);
$from_columns = $move['fromColumnPHIDs'];
$to_column = $move['columnPHID'];
$board_phid = $move['boardPHID'];
if (count($from_columns) == 1) {
return pht(
'%s moved %s from %s to %s on the %s board.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
$this->renderHandleLink(head($from_columns)),
$this->renderHandleLink($to_column),
$this->renderHandleLink($board_phid));
} else {
return pht(
'%s moved %s to %s on the %s board.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
$this->renderHandleLink($to_column),
$this->renderHandleLink($board_phid));
}
} else {
$fragments = array();
foreach ($moves as $move) {
$to_column = $move['columnPHID'];
$board_phid = $move['boardPHID'];
$fragments[] = pht(
'%s (%s)',
$this->renderHandleLink($board_phid),
$this->renderHandleLink($to_column));
}
return pht(
'%s moved %s on %s board(s): %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid),
phutil_count($moves),
phutil_implode_html(', ', $fragments));
}
- break;
case PhabricatorTransactions::TYPE_MFA:
return null;
}
return $this->getTitle();
}
public function getMarkupFieldsForFeed(PhabricatorFeedStory $story) {
$fields = array();
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$text = $this->getComment()->getContent();
if (strlen($text)) {
$fields[] = 'comment/'.$this->getID();
}
break;
}
return $fields;
}
public function getMarkupTextForFeed(PhabricatorFeedStory $story, $field) {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$text = $this->getComment()->getContent();
return PhabricatorMarkupEngine::summarize($text);
}
return null;
}
public function getBodyForFeed(PhabricatorFeedStory $story) {
$remarkup = $this->getRemarkupBodyForFeed($story);
if ($remarkup !== null) {
$remarkup = PhabricatorMarkupEngine::summarize($remarkup);
return new PHUIRemarkupView($this->viewer, $remarkup);
}
$old = $this->getOldValue();
$new = $this->getNewValue();
$body = null;
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
$text = $this->getComment()->getContent();
if (strlen($text)) {
$body = $story->getMarkupFieldOutput('comment/'.$this->getID());
}
break;
}
return $body;
}
public function getRemarkupBodyForFeed(PhabricatorFeedStory $story) {
return null;
}
public function getActionStrength() {
if ($this->isInlineCommentTransaction()) {
return 25;
}
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
return 50;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
if ($this->isSelfSubscription()) {
// Make this weaker than TYPE_COMMENT.
return 25;
}
// In other cases, subscriptions are more interesting than comments
// (which are shown anyway) but less interesting than any other type of
// transaction.
return 75;
case PhabricatorTransactions::TYPE_MFA:
// We want MFA signatures to render at the top of transaction groups,
// on top of the things they signed.
return 1000;
}
return 100;
}
/**
* Whether the transaction concerns a comment (e.g. add, edit, remove)
* @return bool True if the transaction concerns a comment
*/
public function isCommentTransaction() {
if ($this->hasComment()) {
return true;
}
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
return true;
}
return false;
}
public function isInlineCommentTransaction() {
return false;
}
public function getActionName() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_COMMENT:
return pht('Commented On');
case PhabricatorTransactions::TYPE_VIEW_POLICY:
case PhabricatorTransactions::TYPE_EDIT_POLICY:
case PhabricatorTransactions::TYPE_JOIN_POLICY:
case PhabricatorTransactions::TYPE_INTERACT_POLICY:
return pht('Changed Policy');
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
return pht('Changed Subscribers');
case PhabricatorTransactions::TYPE_CREATE:
return pht('Created');
default:
return pht('Updated');
}
}
public function getMailTags() {
return array();
}
public function hasChangeDetails() {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_FILE:
return true;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getTransactionCustomField();
if ($field) {
return $field->getApplicationTransactionHasChangeDetails($this);
}
break;
}
return false;
}
public function hasChangeDetailsForMail() {
return $this->hasChangeDetails();
}
public function renderChangeDetailsForMail(PhabricatorUser $viewer) {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_FILE:
return false;
}
$view = $this->renderChangeDetails($viewer);
if ($view instanceof PhabricatorApplicationTransactionTextDiffDetailView) {
return $view->renderForMail();
}
return null;
}
public function renderChangeDetails(PhabricatorUser $viewer) {
switch ($this->getTransactionType()) {
case PhabricatorTransactions::TYPE_FILE:
return $this->newFileTransactionChangeDetails($viewer);
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getTransactionCustomField();
if ($field) {
return $field->getApplicationTransactionChangeDetails($this, $viewer);
}
break;
}
return $this->renderTextCorpusChangeDetails(
$viewer,
$this->getOldValue(),
$this->getNewValue());
}
public function renderTextCorpusChangeDetails(
PhabricatorUser $viewer,
$old,
$new) {
return id(new PhabricatorApplicationTransactionTextDiffDetailView())
->setUser($viewer)
->setOldText($old)
->setNewText($new);
}
public function attachTransactionGroup(array $group) {
assert_instances_of($group, __CLASS__);
$this->transactionGroup = $group;
return $this;
}
public function getTransactionGroup() {
return $this->transactionGroup;
}
/**
* Should this transaction be visually grouped with an existing transaction
* group?
*
* @param list<PhabricatorApplicationTransaction> $group List of transactions.
* @return bool True to display in a group with the other transactions.
*/
public function shouldDisplayGroupWith(array $group) {
$this_source = null;
if ($this->getContentSource()) {
$this_source = $this->getContentSource()->getSource();
}
$type_mfa = PhabricatorTransactions::TYPE_MFA;
foreach ($group as $xaction) {
// Don't group transactions by different authors.
if ($xaction->getAuthorPHID() != $this->getAuthorPHID()) {
return false;
}
// Don't group transactions for different objects.
if ($xaction->getObjectPHID() != $this->getObjectPHID()) {
return false;
}
// Don't group anything into a group which already has a comment.
if ($xaction->isCommentTransaction()) {
return false;
}
// Don't group transactions from different content sources.
$other_source = null;
if ($xaction->getContentSource()) {
$other_source = $xaction->getContentSource()->getSource();
}
if ($other_source != $this_source) {
return false;
}
// Don't group transactions which happened more than 2 minutes apart.
$apart = abs($xaction->getDateCreated() - $this->getDateCreated());
if ($apart > (60 * 2)) {
return false;
}
// Don't group silent and nonsilent transactions together.
$is_silent = $this->getIsSilentTransaction();
if ($is_silent != $xaction->getIsSilentTransaction()) {
return false;
}
// Don't group MFA and non-MFA transactions together.
$is_mfa = $this->getIsMFATransaction();
if ($is_mfa != $xaction->getIsMFATransaction()) {
return false;
}
// Don't group two "Sign with MFA" transactions together.
if ($this->getTransactionType() === $type_mfa) {
if ($xaction->getTransactionType() === $type_mfa) {
return false;
}
}
// Don't group lock override and non-override transactions together.
$is_override = $this->getIsLockOverrideTransaction();
if ($is_override != $xaction->getIsLockOverrideTransaction()) {
return false;
}
}
return true;
}
public function renderExtraInformationLink() {
$herald_xscript_id = $this->getMetadataValue('herald:transcriptID');
if ($herald_xscript_id) {
return phutil_tag(
'a',
array(
'href' => '/herald/transcript/'.$herald_xscript_id.'/',
),
pht('View Herald Transcript'));
}
return null;
}
public function renderAsTextForDoorkeeper(
DoorkeeperFeedStoryPublisher $publisher,
PhabricatorFeedStory $story,
array $xactions) {
$text = array();
$body = array();
foreach ($xactions as $xaction) {
$xaction_body = $xaction->getBodyForMail();
if ($xaction_body !== null) {
$body[] = $xaction_body;
}
if ($xaction->shouldHideForMail($xactions)) {
continue;
}
$old_target = $xaction->getRenderingTarget();
$new_target = self::TARGET_TEXT;
$xaction->setRenderingTarget($new_target);
if ($publisher->getRenderWithImpliedContext()) {
$text[] = $xaction->getTitle();
} else {
$text[] = $xaction->getTitleForFeed();
}
$xaction->setRenderingTarget($old_target);
}
$text = implode("\n", $text);
$body = implode("\n\n", $body);
return rtrim($text."\n\n".$body);
}
/**
* Test if this transaction is just a user subscribing or unsubscribing
* themselves.
*/
private function isSelfSubscription() {
$type = $this->getTransactionType();
if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
return false;
}
$old = $this->getOldValue();
$new = $this->getNewValue();
$add = array_diff($old, $new);
$rem = array_diff($new, $old);
if ((count($add) + count($rem)) != 1) {
// More than one user affected.
return false;
}
$affected_phid = head(array_merge($add, $rem));
if ($affected_phid != $this->getAuthorPHID()) {
// Affected user is someone else.
return false;
}
return true;
}
private function isApplicationAuthor() {
$author_phid = $this->getAuthorPHID();
$author_type = phid_get_type($author_phid);
$application_type = PhabricatorApplicationApplicationPHIDType::TYPECONST;
return ($author_type == $application_type);
}
private function getInterestingMoves(array $moves) {
// Remove moves which only shift the position of a task within a column.
foreach ($moves as $key => $move) {
$from_phids = array_fuse($move['fromColumnPHIDs']);
if (isset($from_phids[$move['columnPHID']])) {
unset($moves[$key]);
}
}
return $moves;
}
private function getInterestingInlineStateChangeCounts() {
// See PHI995. Newer inline state transactions have additional details
// which we use to tailor the rendering behavior. These details are not
// present on older transactions.
$details = $this->getMetadataValue('inline.details', array());
$new = $this->getNewValue();
$done = 0;
$undone = 0;
foreach ($new as $phid => $state) {
$is_done = ($state == PhabricatorInlineComment::STATE_DONE);
// See PHI995. If you're marking your own inline comments as "Done",
// don't count them when rendering a timeline story. In the case where
// you're only affecting your own comments, this will hide the
// "alice marked X comments as done" story entirely.
// Usually, this happens when you pre-mark inlines as "done" and submit
// them yourself. We'll still generate an "alice added inline comments"
// story (in most cases/contexts), but the state change story is largely
// just clutter and slightly confusing/misleading.
$inline_details = idx($details, $phid, array());
$inline_author_phid = idx($inline_details, 'authorPHID');
if ($inline_author_phid) {
if ($inline_author_phid == $this->getAuthorPHID()) {
if ($is_done) {
continue;
}
}
}
if ($is_done) {
$done++;
} else {
$undone++;
}
}
return array($done, $undone);
}
public function newGlobalSortVector() {
return id(new PhutilSortVector())
->addInt(-$this->getDateCreated())
->addString($this->getPHID());
}
public function newActionStrengthSortVector() {
return id(new PhutilSortVector())
->addInt(-$this->getActionStrength());
}
private function newFileTransactionChangeDetails(PhabricatorUser $viewer) {
$old = $this->getOldValue();
$new = $this->getNewValue();
$phids = array_keys($old + $new);
$handles = $viewer->loadHandles($phids);
$names = array(
PhabricatorFileAttachment::MODE_REFERENCE => pht('Referenced'),
PhabricatorFileAttachment::MODE_ATTACH => pht('Attached'),
);
$rows = array();
foreach ($old + $new as $phid => $ignored) {
$handle = $handles[$phid];
$old_mode = idx($old, $phid);
$new_mode = idx($new, $phid);
if ($old_mode === null) {
$old_name = pht('None');
} else if (isset($names[$old_mode])) {
$old_name = $names[$old_mode];
} else {
$old_name = pht('Unknown ("%s")', $old_mode);
}
if ($new_mode === null) {
$new_name = pht('Detached');
} else if (isset($names[$new_mode])) {
$new_name = $names[$new_mode];
} else {
$new_name = pht('Unknown ("%s")', $new_mode);
}
$rows[] = array(
$handle->renderLink(),
$old_name,
$new_name,
);
}
$table = id(new AphrontTableView($rows))
->setHeaders(
array(
pht('File'),
pht('Old Mode'),
pht('New Mode'),
))
->setColumnClasses(
array(
'pri',
));
return id(new PHUIBoxView())
->addMargin(PHUI::MARGIN_SMALL)
->appendChild($table);
}
/* -( PhabricatorPolicyInterface Implementation )-------------------------- */
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 $viewer) {
return ($viewer->getPHID() == $this->getAuthorPHID());
}
public function describeAutomaticCapability($capability) {
return pht(
'Transactions are visible to users that can see the object which was '.
'acted upon. Some transactions - in particular, comments - are '.
'editable by the transaction author.');
}
public function getModularType() {
return null;
}
public function setForceNotifyPHIDs(array $phids) {
$this->setMetadataValue('notify.force', $phids);
return $this;
}
public function getForceNotifyPHIDs() {
return $this->getMetadataValue('notify.force', array());
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$comment_template = $this->getApplicationTransactionCommentObject();
if ($comment_template) {
$comments = $comment_template->loadAllWhere(
'transactionPHID = %s',
$this->getPHID());
foreach ($comments as $comment) {
$engine->destroyObject($comment);
}
}
$this->delete();
$this->saveTransaction();
}
}
diff --git a/src/view/phui/PHUIHeaderView.php b/src/view/phui/PHUIHeaderView.php
index 8b8774c4cf..a2fdcaa81a 100644
--- a/src/view/phui/PHUIHeaderView.php
+++ b/src/view/phui/PHUIHeaderView.php
@@ -1,540 +1,539 @@
<?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;
private $collapsible;
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;
}
/**
* Render PHUIHeaderView as a <summary> instead of a <div> HTML tag.
* To be used for collapse/expand in combination with PHUIBoxView.
*
* @param bool $collapsible True to wrap in <summary> instead of <div> HTML
* tag.
*/
public function setCollapsible($collapsible) {
$this->collapsible = $collapsible;
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() {
if ($this->collapsible) {
return 'summary';
}
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-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) {
if ($this->headerIcon instanceof PHUIIconView) {
$icon = id(clone $this->headerIcon)
->addClass('phui-header-icon');
} else {
$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();
$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));
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Mar 16, 9:14 AM (1 d, 8 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
72073
Default Alt Text
(408 KB)
Attached To
Mode
R1 hydra
Attached
Detach File
Event Timeline
Log In to Comment