diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php index fd50087c95..fdd2a0641f 100644 --- a/src/applications/files/storage/PhabricatorFile.php +++ b/src/applications/files/storage/PhabricatorFile.php @@ -1,930 +1,949 @@ true, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorFilePHIDTypeFile::TYPECONST); } public function save() { if (!$this->getSecretKey()) { $this->setSecretKey($this->generateSecretKey()); } if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); } return parent::save(); } public static function readUploadedFileData($spec) { if (!$spec) { throw new Exception("No file was uploaded!"); } $err = idx($spec, 'error'); if ($err) { throw new PhabricatorFileUploadException($err); } $tmp_name = idx($spec, 'tmp_name'); $is_valid = @is_uploaded_file($tmp_name); if (!$is_valid) { throw new Exception("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("File size disagrees with uploaded size."); } self::validateFileSize(strlen($file_data)); 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()) { self::validateFileSize(strlen($data)); return self::newFromFileData($data, $params); } private static function validateFileSize($size) { $limit = PhabricatorEnv::getEnvConfig('storage.upload-size-limit'); if (!$limit) { return; } $limit = phabricator_parse_bytes($limit); if ($size > $limit) { throw new PhabricatorFileUploadException(-1000); } } /** * Given a block of data, try to load an existing file with the same content * if one exists. If it does not, build a new file. * * This method is generally used when we have some piece of semi-trusted data * like a diff or a file from a repository that we want to show to the user. * We can't just dump it out because it may be dangerous for any number of * reasons; instead, we need to serve it through the File abstraction so it * ends up on the CDN domain if one is configured and so on. However, if we * simply wrote a new file every time we'd potentially end up with a lot * of redundant data in file storage. * * To solve these problems, we use file storage as a cache and reuse the * same file again if we've previously written it. * * NOTE: This method unguards writes. * * @param string Raw file data. * @param dict Dictionary of file information. */ public static function buildFromFileDataOrHash( $data, array $params = array()) { $file = id(new PhabricatorFile())->loadOneWhere( 'name = %s AND contentHash = %s LIMIT 1', self::normalizeFileName(idx($params, 'name')), self::hashFileContent($data)); if (!$file) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $file = PhabricatorFile::newFromFileData($data, $params); unset($unguarded); } return $file; } public static function newFileFromContentHash($hash, $params) { // Check to see if a file with same contentHash exist $file = id(new PhabricatorFile())->loadOneWhere( 'contentHash = %s LIMIT 1', $hash); if ($file) { // copy storageEngine, storageHandle, storageFormat $copy_of_storage_engine = $file->getStorageEngine(); $copy_of_storage_handle = $file->getStorageHandle(); $copy_of_storage_format = $file->getStorageFormat(); $copy_of_byteSize = $file->getByteSize(); $copy_of_mimeType = $file->getMimeType(); $file_name = idx($params, 'name'); $file_name = self::normalizeFileName($file_name); $file_ttl = idx($params, 'ttl'); $authorPHID = idx($params, 'authorPHID'); $new_file = new PhabricatorFile(); $new_file->setName($file_name); $new_file->setByteSize($copy_of_byteSize); $new_file->setAuthorPHID($authorPHID); $new_file->setTtl($file_ttl); $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->setMimeType($copy_of_mimeType); $new_file->copyDimensions($file); $new_file->save(); return $new_file; } return $file; } private static function buildFromFileData($data, array $params = array()) { $selector = PhabricatorEnv::newObjectFromConfig('storage.engine-selector'); if (isset($params['storageEngines'])) { $engines = $params['storageEngines']; } else { $selector = PhabricatorEnv::newObjectFromConfig( 'storage.engine-selector'); $engines = $selector->selectStorageEngines($data, $params); } assert_instances_of($engines, 'PhabricatorFileStorageEngine'); if (!$engines) { throw new Exception("No valid storage engines are available!"); } $file = new PhabricatorFile(); $data_handle = null; $engine_identifier = null; $exceptions = array(); foreach ($engines as $engine) { $engine_class = get_class($engine); try { list($engine_identifier, $data_handle) = $file->writeToEngine( $engine, $data, $params); // 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( "All storage engines failed to write file:", $exceptions); } $file_name = idx($params, 'name'); $file_name = self::normalizeFileName($file_name); $file_ttl = idx($params, 'ttl'); // If for whatever reason, authorPHID isn't passed as a param // (always the case with newFromFileDownload()), store a '' $authorPHID = idx($params, 'authorPHID'); $file->setName($file_name); $file->setByteSize(strlen($data)); $file->setAuthorPHID($authorPHID); $file->setTtl($file_ttl); $file->setContentHash(self::hashFileContent($data)); $file->setStorageEngine($engine_identifier); $file->setStorageHandle($data_handle); // TODO: This is probably YAGNI, but allows for us to do encryption or // compression later if we want. $file->setStorageFormat(self::STORAGE_FORMAT_RAW); $file->setIsExplicitUpload(idx($params, 'isExplicitUpload') ? 1 : 0); if (isset($params['mime-type'])) { $file->setMimeType($params['mime-type']); } else { $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); $file->setMimeType(Filesystem::getMimeType($tmp)); } try { $file->updateDimensions(false); } catch (Exception $ex) { // Do nothing } $file->save(); return $file; } public static function newFromFileData($data, array $params = array()) { $hash = self::hashFileContent($data); $file = self::newFileFromContentHash($hash, $params); if ($file) { return $file; } return self::buildFromFileData($data, $params); } public function migrateToEngine(PhabricatorFileStorageEngine $engine) { if (!$this->getID() || !$this->getStorageHandle()) { throw new Exception( "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) = $this->writeToEngine( $engine, $data, $params); $old_engine = $this->instantiateStorageEngine(); $old_handle = $this->getStorageHandle(); $this->setStorageEngine($new_identifier); $this->setStorageHandle($new_handle); $this->save(); $old_engine->deleteFile($old_handle); return $this; } private function writeToEngine( PhabricatorFileStorageEngine $engine, $data, array $params) { $engine_class = get_class($engine); $data_handle = $engine->writeFile($data, $params); if (!$data_handle || strlen($data_handle) > 255) { // This indicates an improperly implemented storage engine. throw new PhabricatorFileStorageConfigurationException( "Storage engine '{$engine_class}' executed writeFile() but did ". "not return a valid handle ('{$data_handle}') to the data: it ". "must be nonempty and no longer than 255 characters."); } $engine_identifier = $engine->getEngineIdentifier(); if (!$engine_identifier || strlen($engine_identifier) > 32) { throw new PhabricatorFileStorageConfigurationException( "Storage engine '{$engine_class}' returned an improper engine ". "identifier '{$engine_identifier}': it must be nonempty ". "and no longer than 32 characters."); } return array($engine_identifier, $data_handle); } public static function newFromFileDownload($uri, array $params = array()) { // Make sure we're allowed to make a request first if (!PhabricatorEnv::getEnvConfig('security.allow-outbound-http')) { throw new Exception("Outbound HTTP requests are disabled!"); } $uri = new PhutilURI($uri); $protocol = $uri->getProtocol(); switch ($protocol) { case 'http': case 'https': break; default: // Make sure we are not accessing any file:// URIs or similar. return null; } $timeout = 5; list($file_data) = id(new HTTPSFuture($uri)) ->setTimeout($timeout) ->resolvex(); $params = $params + array( 'name' => basename($uri), ); return self::newFromFileData($file_data, $params); } 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(); // Check to see if other files are using storage $other_file = id(new PhabricatorFile())->loadAllWhere( 'storageEngine = %s AND storageHandle = %s AND storageFormat = %s AND id != %d LIMIT 1', $this->getStorageEngine(), $this->getStorageHandle(), $this->getStorageFormat(), $this->getID()); // If this is the only file using the storage, delete storage if (!$other_file) { $engine = $this->instantiateStorageEngine(); try { $engine->deleteFile($this->getStorageHandle()); } catch (Exception $ex) { // In the worst case, we're leaving some data stranded in a storage // engine, which is fine. phlog($ex); } } return $ret; } public static function hashFileContent($data) { return sha1($data); } public function loadFileData() { $engine = $this->instantiateStorageEngine(); $data = $engine->readFile($this->getStorageHandle()); switch ($this->getStorageFormat()) { case self::STORAGE_FORMAT_RAW: $data = $data; break; default: throw new Exception("Unknown storage format."); } return $data; } public function getViewURI() { if (!$this->getPHID()) { throw new Exception( "You must save a file before you can generate a view URI."); } $name = phutil_escape_uri($this->getName()); $path = '/file/data/'.$this->getSecretKey().'/'.$this->getPHID().'/'.$name; return PhabricatorEnv::getCDNURI($path); } public function getInfoURI() { return '/file/info/'.$this->getPHID().'/'; } public function getBestURI() { if ($this->isViewableInBrowser()) { return $this->getViewURI(); } else { return $this->getInfoURI(); } } public function getDownloadURI() { $uri = id(new PhutilURI($this->getViewURI())) ->setQueryParam('download', true); return (string) $uri; } public function getProfileThumbURI() { $path = '/file/xform/thumb-profile/'.$this->getPHID().'/' .$this->getSecretKey().'/'; return PhabricatorEnv::getCDNURI($path); } public function getThumb60x45URI() { $path = '/file/xform/thumb-60x45/'.$this->getPHID().'/' .$this->getSecretKey().'/'; return PhabricatorEnv::getCDNURI($path); } public function getThumb160x120URI() { $path = '/file/xform/thumb-160x120/'.$this->getPHID().'/' .$this->getSecretKey().'/'; return PhabricatorEnv::getCDNURI($path); } public function getPreview140URI() { $path = '/file/xform/preview-140/'.$this->getPHID().'/' .$this->getSecretKey().'/'; return PhabricatorEnv::getCDNURI($path); } public function getPreview220URI() { $path = '/file/xform/preview-220/'.$this->getPHID().'/' .$this->getSecretKey().'/'; return PhabricatorEnv::getCDNURI($path); } public function getThumb220x165URI() { $path = '/file/xform/thumb-220x165/'.$this->getPHID().'/' .$this->getSecretKey().'/'; return PhabricatorEnv::getCDNURI($path); } public function getThumb280x210URI() { $path = '/file/xform/thumb-280x210/'.$this->getPHID().'/' .$this->getSecretKey().'/'; return PhabricatorEnv::getCDNURI($path); } public function isViewableInBrowser() { return ($this->getViewableMimeType() !== null); } 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); } 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); } 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 = 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('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; } protected 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( "Storage engine '{$engine_identifier}' could not be located!"); } public static function buildAllEngines() { $engines = id(new PhutilSymbolLoader()) ->setType('class') ->setConcreteOnly(true) ->setAncestorClass('PhabricatorFileStorageEngine') ->selectAndLoadSymbols(); $results = array(); foreach ($engines as $engine_class) { $results[] = newv($engine_class['name'], array()); } return $results; } 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, 'docs_file'); } public function validateSecretKey($key) { return ($key == $this->getSecretKey()); } public function generateSecretKey() { return Filesystem::readRandomCharacters(20); } public function updateDimensions($save = true) { if (!$this->isViewableImage()) { throw new Exception( "This file is not a viewable image."); } if (!function_exists("imagecreatefromstring")) { throw new Exception( "Cannot retrieve image information."); } $data = $this->loadFileData(); $img = imagecreatefromstring($data); if ($img === false) { throw new Exception( "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; } public static function getMetadataName($metadata) { switch ($metadata) { case self::METADATA_IMAGE_WIDTH: $name = pht('Width'); break; case self::METADATA_IMAGE_HEIGHT: $name = pht('Height'); break; default: $name = ucfirst($metadata); break; } return $name; } /** * 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 Viewing user. * @param list List of builtin file names. * @return dict Dictionary of named builtins. */ public static function loadBuiltins(PhabricatorUser $user, array $names) { $specs = array(); foreach ($names as $name) { $specs[] = array( 'originalPHID' => PhabricatorPHIDConstants::PHID_VOID, 'transform' => 'builtin:'.$name, ); } $files = id(new PhabricatorFileQuery()) ->setViewer($user) ->withTransforms($specs) ->execute(); $files = mpull($files, null, 'getName'); $root = dirname(phutil_get_library_root('phabricator')); $root = $root.'/resources/builtin/'; $build = array(); foreach ($names as $name) { if (isset($files[$name])) { continue; } // This is just a sanity check to prevent loading arbitrary files. if (basename($name) != $name) { throw new Exception("Invalid builtin name '{$name}'!"); } $path = $root.$name; if (!Filesystem::pathExists($path)) { throw new Exception("Builtin '{$path}' does not exist!"); } $data = Filesystem::readFile($path); $params = array( 'name' => $name, 'ttl' => time() + (60 * 60 * 24 * 7), ); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $file = PhabricatorFile::newFromFileData($data, $params); $xform = id(new PhabricatorTransformedFile()) ->setOriginalPHID(PhabricatorPHIDConstants::PHID_VOID) ->setTransform('builtin:'.$name) ->setTransformedPHID($file->getPHID()) ->save(); unset($unguarded); $file->attachObjectPHIDs(array()); $file->attachObjects(array()); $files[$name] = $file; } return $files; } /** * Convenience wrapper for @{method:loadBuiltins}. * * @param PhabricatorUser Viewing user. * @param string Single builtin name to load. * @return PhabricatorFile Corresponding builtin file. */ public static function loadBuiltin(PhabricatorUser $user, $name) { return idx(self::loadBuiltins($user, array($name)), $name); } 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 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); } + /** + * Write the policy edge between this file and some object. + * + * @param PhabricatorUser Acting user. + * @param phid Object PHID to attach to. + * @return this + */ + public function attachToObject(PhabricatorUser $actor, $phid) { + $edge_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_FILE; + + id(new PhabricatorEdgeEditor()) + ->setActor($actor) + ->setSuppressEvents(true) + ->addEdge($phid, $edge_type, $this->getPHID()) + ->save(); + + return $this; + } + /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { // TODO: Implement proper per-object policies. return PhabricatorPolicies::POLICY_USER; } 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 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.'); break; } return $out; } /* -( PhabricatorSubscribableInterface Implementation )-------------------- */ public function isAutomaticallySubscribed($phid) { return ($this->authorPHID == $phid); } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array( $this->getAuthorPHID(), ); } } diff --git a/src/applications/macro/editor/PhabricatorMacroEditor.php b/src/applications/macro/editor/PhabricatorMacroEditor.php index d37e111e66..cbf7ccec83 100644 --- a/src/applications/macro/editor/PhabricatorMacroEditor.php +++ b/src/applications/macro/editor/PhabricatorMacroEditor.php @@ -1,150 +1,162 @@ getTransactionType()) { case PhabricatorMacroTransactionType::TYPE_NAME: return $object->getName(); case PhabricatorMacroTransactionType::TYPE_DISABLED: return $object->getIsDisabled(); case PhabricatorMacroTransactionType::TYPE_FILE: return $object->getFilePHID(); case PhabricatorMacroTransactionType::TYPE_AUDIO: return $object->getAudioPHID(); case PhabricatorMacroTransactionType::TYPE_AUDIO_BEHAVIOR: return $object->getAudioBehavior(); } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorMacroTransactionType::TYPE_NAME: case PhabricatorMacroTransactionType::TYPE_DISABLED: case PhabricatorMacroTransactionType::TYPE_FILE: case PhabricatorMacroTransactionType::TYPE_AUDIO: case PhabricatorMacroTransactionType::TYPE_AUDIO_BEHAVIOR: return $xaction->getNewValue(); } } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorMacroTransactionType::TYPE_NAME: $object->setName($xaction->getNewValue()); break; case PhabricatorMacroTransactionType::TYPE_DISABLED: $object->setIsDisabled($xaction->getNewValue()); break; case PhabricatorMacroTransactionType::TYPE_FILE: $object->setFilePHID($xaction->getNewValue()); break; case PhabricatorMacroTransactionType::TYPE_AUDIO: $object->setAudioPHID($xaction->getNewValue()); break; case PhabricatorMacroTransactionType::TYPE_AUDIO_BEHAVIOR: $object->setAudioBehavior($xaction->getNewValue()); break; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return; } + protected function extractFilePHIDsFromCustomTransaction( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case PhabricatorMacroTransactionType::TYPE_FILE: + return array($xaction->getNewValue()); + } + + return array(); + } + protected function mergeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $type = $u->getTransactionType(); switch ($type) { case PhabricatorMacroTransactionType::TYPE_NAME: case PhabricatorMacroTransactionType::TYPE_DISABLED: case PhabricatorMacroTransactionType::TYPE_FILE: case PhabricatorMacroTransactionType::TYPE_AUDIO: case PhabricatorMacroTransactionType::TYPE_AUDIO_BEHAVIOR: return $v; } return parent::mergeTransactions($u, $v); } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorMacroTransactionType::TYPE_NAME; return ($xaction->getOldValue() !== null); default: break; } } return true; } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new PhabricatorMacroReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $name = $object->getName(); $name = 'Image Macro "'.$name.'"'; return id(new PhabricatorMetaMTAMail()) ->setSubject($name) ->addHeader('Thread-Topic', $name); } protected function getMailTo(PhabricatorLiskDAO $object) { return array( $this->requireActor()->getPHID(), ); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); $body->addTextSection( pht('MACRO DETAIL'), PhabricatorEnv::getProductionURI('/macro/view/'.$object->getID().'/')); return $body; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.macro.subject-prefix'); } protected function supportsFeed() { return true; } } diff --git a/src/applications/paste/conduit/ConduitAPI_paste_create_Method.php b/src/applications/paste/conduit/ConduitAPI_paste_create_Method.php index 114aea698f..a8cbe4c5f1 100644 --- a/src/applications/paste/conduit/ConduitAPI_paste_create_Method.php +++ b/src/applications/paste/conduit/ConduitAPI_paste_create_Method.php @@ -1,65 +1,69 @@ 'required string', 'title' => 'optional string', 'language' => 'optional string', ); } public function defineReturnType() { return 'nonempty dict'; } public function defineErrorTypes() { return array( 'ERR-NO-PASTE' => 'Paste may not be empty.', ); } protected function execute(ConduitAPIRequest $request) { $content = $request->getValue('content'); $title = $request->getValue('title'); $language = $request->getValue('language'); if (!strlen($content)) { throw new ConduitException('ERR-NO-PASTE'); } $title = nonempty($title, 'Masterwork From Distant Lands'); $language = nonempty($language, ''); $user = $request->getUser(); $paste_file = PhabricatorFile::newFromFileData( $content, array( 'name' => $title, 'mime-type' => 'text/plain; charset=utf-8', 'authorPHID' => $user->getPHID(), )); + // TODO: This should use PhabricatorPasteEditor. + $paste = new PhabricatorPaste(); $paste->setTitle($title); $paste->setLanguage($language); $paste->setFilePHID($paste_file->getPHID()); $paste->setAuthorPHID($user->getPHID()); $paste->setViewPolicy(PhabricatorPolicies::POLICY_USER); $paste->save(); + $paste_file->attachToObject($user, $paste->getPHID()); + $paste->attachRawContent($content); return $this->buildPasteInfoDictionary($paste); } } diff --git a/src/applications/paste/editor/PhabricatorPasteEditor.php b/src/applications/paste/editor/PhabricatorPasteEditor.php index 20da125257..cb3d8a5e71 100644 --- a/src/applications/paste/editor/PhabricatorPasteEditor.php +++ b/src/applications/paste/editor/PhabricatorPasteEditor.php @@ -1,150 +1,166 @@ getTransactionType()) { case PhabricatorPasteTransaction::TYPE_CREATE: return null; case PhabricatorPasteTransaction::TYPE_TITLE: return $object->getTitle(); case PhabricatorPasteTransaction::TYPE_LANGUAGE: return $object->getLanguage(); } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorPasteTransaction::TYPE_CREATE: // this was set via applyInitialEffects return $object->getFilePHID(); case PhabricatorPasteTransaction::TYPE_TITLE: case PhabricatorPasteTransaction::TYPE_LANGUAGE: return $xaction->getNewValue(); } } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorPasteTransaction::TYPE_TITLE: $object->setTitle($xaction->getNewValue()); break; case PhabricatorPasteTransaction::TYPE_LANGUAGE: $object->setLanguage($xaction->getNewValue()); break; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { } protected function shouldApplyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == PhabricatorPasteTransaction::TYPE_CREATE) { return true; } } return false; } protected function applyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorPasteTransaction::TYPE_CREATE: $data = $xaction->getNewValue(); $paste_file = PhabricatorFile::newFromFileData( $data['text'], array( 'name' => $data['title'], 'mime-type' => 'text/plain; charset=utf-8', 'authorPHID' => $this->getActor()->getPHID(), )); $object->setFilePHID($paste_file->getPHID()); + + $this->pasteFile = $paste_file; break; } } } + protected function applyFinalEffects( + PhabricatorLiskDAO $object, + array $xactions) { + + // TODO: This should use extractFilePHIDs() instead, but the way + // the transactions work right now makes pretty messy. + + if ($this->pasteFile) { + $this->pasteFile->attachToObject( + $this->getActor(), + $object->getPHID()); + } + } + + protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorPasteTransaction::TYPE_CREATE: return false; default: break; } } return true; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.paste.subject-prefix'); } protected function getMailTo(PhabricatorLiskDAO $object) { return array( $object->getAuthorPHID(), $this->requireActor()->getPHID(), ); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new PasteReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $name = $object->getTitle(); return id(new PhabricatorMetaMTAMail()) ->setSubject("P{$id}: {$name}") ->addHeader('Thread-Topic', "P{$id}"); } protected function supportsFeed() { return true; } protected function supportsSearch() { return false; } } diff --git a/src/applications/paste/storage/PhabricatorPasteTransaction.php b/src/applications/paste/storage/PhabricatorPasteTransaction.php index 96e5e5827f..453c13802c 100644 --- a/src/applications/paste/storage/PhabricatorPasteTransaction.php +++ b/src/applications/paste/storage/PhabricatorPasteTransaction.php @@ -1,134 +1,131 @@ getTransactionType()) { case self::TYPE_CREATE: $phids[] = $this->getObjectPHID(); break; } return $phids; } public function shouldHide() { $old = $this->getOldValue(); switch ($this->getTransactionType()) { case self::TYPE_TITLE: case self::TYPE_LANGUAGE: return $old === null; } return parent::shouldHide(); } public function getIcon() { switch ($this->getTransactionType()) { case self::TYPE_CREATE: return 'create'; break; case self::TYPE_TITLE: case self::TYPE_LANGUAGE: return 'edit'; break; } return parent::getIcon(); } public function getTitle() { $author_phid = $this->getAuthorPHID(); $object_phid = $this->getObjectPHID(); $old = $this->getOldValue(); $new = $this->getNewValue(); $type = $this->getTransactionType(); switch ($type) { case PhabricatorPasteTransaction::TYPE_CREATE: return pht( '%s created "%s".', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); break; case PhabricatorPasteTransaction::TYPE_TITLE: return pht( '%s updated the paste\'s title to "%s".', $this->renderHandleLink($author_phid), $new); break; case PhabricatorPasteTransaction::TYPE_LANGUAGE: return pht( "%s updated the paste's language.", $this->renderHandleLink($author_phid)); break; } return parent::getTitle(); } public function getTitleForFeed(PhabricatorFeedStory $story) { $author_phid = $this->getAuthorPHID(); $object_phid = $this->getObjectPHID(); $old = $this->getOldValue(); $new = $this->getNewValue(); $type = $this->getTransactionType(); switch ($type) { case PhabricatorPasteTransaction::TYPE_CREATE: return pht( '%s created %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); break; case PhabricatorPasteTransaction::TYPE_TITLE: return pht( '%s updated the title for %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); break; case PhabricatorPasteTransaction::TYPE_LANGUAGE: return pht( '%s update the language for %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); break; } return parent::getTitleForFeed($story); } public function getColor() { $old = $this->getOldValue(); $new = $this->getNewValue(); switch ($this->getTransactionType()) { case PhabricatorPasteTransaction::TYPE_CREATE: return PhabricatorTransactions::COLOR_GREEN; } return parent::getColor(); } } diff --git a/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php b/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php index d16a4ab4a6..169b90911a 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php @@ -1,307 +1,308 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $viewer = $request->getUser(); $user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withIDs(array($this->id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$user) { return new Aphront404Response(); } $profile_uri = '/p/'.$user->getUsername().'/'; $supported_formats = PhabricatorFile::getTransformableImageFormats(); $e_file = true; $errors = array(); if ($request->isFormPost()) { $phid = $request->getStr('phid'); $is_default = false; if ($phid == PhabricatorPHIDConstants::PHID_VOID) { $phid = null; $is_default = true; } else if ($phid) { $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($phid)) ->executeOne(); } else { if ($request->getFileExists('picture')) { $file = PhabricatorFile::newFromPHPUpload( $_FILES['picture'], array( 'authorPHID' => $viewer->getPHID(), )); } else { $e_file = pht('Required'); $errors[] = pht( 'You must choose a file when uploading a new profile picture.'); } } if (!$errors && !$is_default) { if (!$file->isTransformableImage()) { $e_file = pht('Not Supported'); $errors[] = pht( 'This server only supports these image formats: %s.', implode(', ', $supported_formats)); } else { $xformer = new PhabricatorImageTransformer(); $xformed = $xformer->executeProfileTransform( $file, $width = 50, $min_height = 50, $max_height = 50); } } if (!$errors) { if ($is_default) { $user->setProfileImagePHID(null); } else { $user->setProfileImagePHID($xformed->getPHID()); + $xformed->attachToObject($viewer, $user->getPHID()); } $user->save(); return id(new AphrontRedirectResponse())->setURI($profile_uri); } } $title = pht('Edit Profile Picture'); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addCrumb( id(new PhabricatorCrumbView()) ->setName($user->getUsername()) ->setHref($profile_uri)); $crumbs->addCrumb( id(new PhabricatorCrumbView()) ->setName($title)); $form = id(new PHUIFormLayoutView()) ->setUser($viewer); $default_image = PhabricatorFile::loadBuiltin($viewer, 'profile.png'); $images = array(); $current = $user->getProfileImagePHID(); $has_current = false; if ($current) { $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($current)) ->execute(); if ($files) { $file = head($files); if ($file->isTransformableImage()) { $has_current = true; $images[$current] = array( 'uri' => $file->getBestURI(), 'tip' => pht('Current Picture'), ); } } } // Try to add external account images for any associated external accounts. $accounts = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withUserPHIDs(array($user->getPHID())) ->needImages(true) ->execute(); foreach ($accounts as $account) { $file = $account->getProfileImageFile(); if ($account->getProfileImagePHID() != $file->getPHID()) { // This is a default image, just skip it. continue; } $provider = PhabricatorAuthProvider::getEnabledProviderByKey( $account->getProviderKey()); if ($provider) { $tip = pht('Picture From %s', $provider->getProviderName()); } else { $tip = pht('Picture From External Account'); } if ($file->isTransformableImage()) { $images[$file->getPHID()] = array( 'uri' => $file->getBestURI(), 'tip' => $tip, ); } } // Try to add Gravatar images for any email addresses associated with the // account. if (PhabricatorEnv::getEnvConfig('security.allow-outbound-http')) { $emails = id(new PhabricatorUserEmail())->loadAllWhere( 'userPHID = %s ORDER BY address', $viewer->getPHID()); $futures = array(); foreach ($emails as $email_object) { $email = $email_object->getAddress(); $hash = md5(strtolower(trim($email))); $uri = id(new PhutilURI("https://secure.gravatar.com/avatar/{$hash}")) ->setQueryParams( array( 'size' => 200, 'default' => '404', 'rating' => 'x', )); $futures[$email] = new HTTPSFuture($uri); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); foreach (Futures($futures) as $email => $future) { try { list($body) = $future->resolvex(); $file = PhabricatorFile::newFromFileData( $body, array( 'name' => 'profile-gravatar', 'ttl' => (60 * 60 * 4), )); if ($file->isTransformableImage()) { $images[$file->getPHID()] = array( 'uri' => $file->getBestURI(), 'tip' => pht('Gravatar for %s', $email), ); } } catch (Exception $ex) { // Just continue. } } unset($unguarded); } $images[PhabricatorPHIDConstants::PHID_VOID] = array( 'uri' => $default_image->getBestURI(), 'tip' => pht('Default Picture'), ); require_celerity_resource('people-profile-css'); Javelin::initBehavior('phabricator-tooltips', array()); $buttons = array(); foreach ($images as $phid => $spec) { $button = javelin_tag( 'button', array( 'class' => 'grey profile-image-button', 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => $spec['tip'], 'size' => 300, ), ), phutil_tag( 'img', array( 'height' => 50, 'width' => 50, 'src' => $spec['uri'], ))); $button = array( phutil_tag( 'input', array( 'type' => 'hidden', 'name' => 'phid', 'value' => $phid, )), $button); $button = phabricator_form( $viewer, array( 'class' => 'profile-image-form', 'method' => 'POST', ), $button); $buttons[] = $button; } if ($has_current) { $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Current Picture')) ->setValue(array_shift($buttons))); } $form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Use Picture')) ->setValue($buttons)); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setFormError($errors) ->setForm($form); $upload_form = id(new AphrontFormView()) ->setUser($user) ->setEncType('multipart/form-data') ->appendChild( id(new AphrontFormFileControl()) ->setName('picture') ->setLabel(pht('Upload Picture')) ->setError($e_file) ->setCaption( pht('Supported formats: %s', implode(', ', $supported_formats)))) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton($profile_uri) ->setValue(pht('Upload Picture'))); if ($errors) { $errors = id(new AphrontErrorView())->setErrors($errors); } $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setFormError($errors) ->setForm($form); $upload_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Upload New Picture')) ->setForm($upload_form); return $this->buildApplicationPage( array( $crumbs, $form_box, $upload_box, ), array( 'title' => $title, 'device' => true, )); } } diff --git a/src/applications/pholio/editor/PholioMockEditor.php b/src/applications/pholio/editor/PholioMockEditor.php index 30e82b8fe2..8cf5784cc4 100644 --- a/src/applications/pholio/editor/PholioMockEditor.php +++ b/src/applications/pholio/editor/PholioMockEditor.php @@ -1,444 +1,464 @@ newImages = $new_images; return $this; } private function getNewImages() { return $this->newImages; } public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_EDGE; $types[] = PhabricatorTransactions::TYPE_COMMENT; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; $types[] = PholioTransactionType::TYPE_NAME; $types[] = PholioTransactionType::TYPE_DESCRIPTION; $types[] = PholioTransactionType::TYPE_INLINE; $types[] = PholioTransactionType::TYPE_IMAGE_FILE; $types[] = PholioTransactionType::TYPE_IMAGE_NAME; $types[] = PholioTransactionType::TYPE_IMAGE_DESCRIPTION; $types[] = PholioTransactionType::TYPE_IMAGE_REPLACE; $types[] = PholioTransactionType::TYPE_IMAGE_SEQUENCE; return $types; } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_NAME: return $object->getName(); case PholioTransactionType::TYPE_DESCRIPTION: return $object->getDescription(); case PholioTransactionType::TYPE_IMAGE_FILE: $images = $object->getImages(); return mpull($images, 'getPHID'); case PholioTransactionType::TYPE_IMAGE_NAME: $name = null; $phid = null; $image = $this->getImageForXaction($object, $xaction); if ($image) { $name = $image->getName(); $phid = $image->getPHID(); } return array($phid => $name); case PholioTransactionType::TYPE_IMAGE_DESCRIPTION: $description = null; $phid = null; $image = $this->getImageForXaction($object, $xaction); if ($image) { $description = $image->getDescription(); $phid = $image->getPHID(); } return array($phid => $description); case PholioTransactionType::TYPE_IMAGE_REPLACE: $raw = $xaction->getNewValue(); return $raw->getReplacesImagePHID(); case PholioTransactionType::TYPE_IMAGE_SEQUENCE: $sequence = null; $phid = null; $image = $this->getImageForXaction($object, $xaction); if ($image) { $sequence = $image->getSequence(); $phid = $image->getPHID(); } return array($phid => $sequence); } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_NAME: case PholioTransactionType::TYPE_DESCRIPTION: case PholioTransactionType::TYPE_IMAGE_NAME: case PholioTransactionType::TYPE_IMAGE_DESCRIPTION: case PholioTransactionType::TYPE_IMAGE_SEQUENCE: return $xaction->getNewValue(); case PholioTransactionType::TYPE_IMAGE_REPLACE: $raw = $xaction->getNewValue(); return $raw->getPHID(); case PholioTransactionType::TYPE_IMAGE_FILE: $raw_new_value = $xaction->getNewValue(); $new_value = array(); foreach ($raw_new_value as $key => $images) { $new_value[$key] = mpull($images, 'getPHID'); } $xaction->setNewValue($new_value); return $this->getPHIDTransactionNewValue($xaction); } } + protected function extractFilePHIDsFromCustomTransaction( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case PholioTransactionType::TYPE_IMAGE_FILE: + $new = $xaction->getNewValue(); + $phids = array(); + foreach ($new as $key => $images) { + $phids[] = mpull($images, 'getFilePHID'); + } + return array_mergev($phids); + case PholioTransactionType::TYPE_IMAGE_REPLACE: + return array($xaction->getNewValue()->getFilePHID()); + } + + return array(); + } + + protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_INLINE: return true; } return parent::transactionHasEffect($object, $xaction); } protected function shouldApplyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_IMAGE_FILE: case PholioTransactionType::TYPE_IMAGE_REPLACE: return true; break; } } return false; } protected function applyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { $new_images = array(); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_IMAGE_FILE: $new_value = $xaction->getNewValue(); foreach ($new_value as $key => $txn_images) { if ($key != '+') { continue; } foreach ($txn_images as $image) { $image->save(); $new_images[] = $image; } } break; case PholioTransactionType::TYPE_IMAGE_REPLACE: $image = $xaction->getNewValue(); $image->save(); $new_images[] = $image; break; } } $this->setNewImages($new_images); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_NAME: $object->setName($xaction->getNewValue()); if ($object->getOriginalName() === null) { $object->setOriginalName($xaction->getNewValue()); } break; case PholioTransactionType::TYPE_DESCRIPTION: $object->setDescription($xaction->getNewValue()); break; } } private function getImageForXaction( PholioMock $mock, PhabricatorApplicationTransaction $xaction) { $raw_new_value = $xaction->getNewValue(); $image_phid = key($raw_new_value); $images = $mock->getImages(); foreach ($images as $image) { if ($image->getPHID() == $image_phid) { return $image; } } return null; } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_IMAGE_FILE: $old_map = array_fuse($xaction->getOldValue()); $new_map = array_fuse($xaction->getNewValue()); $obsolete_map = array_diff_key($old_map, $new_map); $images = $object->getImages(); foreach ($images as $seq => $image) { if (isset($obsolete_map[$image->getPHID()])) { $image->setIsObsolete(1); $image->save(); unset($images[$seq]); } } $object->attachImages($images); break; case PholioTransactionType::TYPE_IMAGE_REPLACE: $old = $xaction->getOldValue(); $images = $object->getImages(); foreach ($images as $seq => $image) { if ($image->getPHID() == $old) { $image->setIsObsolete(1); $image->save(); unset($images[$seq]); } } $object->attachImages($images); break; case PholioTransactionType::TYPE_IMAGE_NAME: $image = $this->getImageForXaction($object, $xaction); $value = (string) head($xaction->getNewValue()); $image->setName($value); $image->save(); break; case PholioTransactionType::TYPE_IMAGE_DESCRIPTION: $image = $this->getImageForXaction($object, $xaction); $value = (string) head($xaction->getNewValue()); $image->setDescription($value); $image->save(); break; case PholioTransactionType::TYPE_IMAGE_SEQUENCE: $image = $this->getImageForXaction($object, $xaction); $value = (int) head($xaction->getNewValue()); $image->setSequence($value); $image->save(); break; } } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { $images = $this->getNewImages(); foreach ($images as $image) { $image->setMockID($object->getID()); $image->save(); } } protected function mergeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $type = $u->getTransactionType(); switch ($type) { case PholioTransactionType::TYPE_NAME: case PholioTransactionType::TYPE_DESCRIPTION: return $v; case PholioTransactionType::TYPE_IMAGE_REPLACE: if ($u->getNewValue() == $v->getOldValue()) { return $v; } case PholioTransactionType::TYPE_IMAGE_FILE: return $this->mergePHIDOrEdgeTransactions($u, $v); case PholioTransactionType::TYPE_IMAGE_NAME: case PholioTransactionType::TYPE_IMAGE_DESCRIPTION: case PholioTransactionType::TYPE_IMAGE_SEQUENCE: $raw_new_value_u = $u->getNewValue(); $raw_new_value_v = $v->getNewValue(); $phid_u = key($raw_new_value_u); $phid_v = key($raw_new_value_v); if ($phid_u == $phid_v) { return $v; } break; } return parent::mergeTransactions($u, $v); } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new PholioReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $name = $object->getName(); $original_name = $object->getOriginalName(); return id(new PhabricatorMetaMTAMail()) ->setSubject("M{$id}: {$name}") ->addHeader('Thread-Topic', "M{$id}: {$original_name}"); } protected function getMailTo(PhabricatorLiskDAO $object) { return array( $object->getAuthorPHID(), $this->requireActor()->getPHID(), ); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = new PhabricatorMetaMTAMailBody(); $headers = array(); $comments = array(); $inline_comments = array(); foreach ($xactions as $xaction) { if ($xaction->shouldHide()) { continue; } $comment = $xaction->getComment(); switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_INLINE: if ($comment && strlen($comment->getContent())) { $inline_comments[] = $comment; } break; case PhabricatorTransactions::TYPE_COMMENT: if ($comment && strlen($comment->getContent())) { $comments[] = $comment->getContent(); } // fallthrough default: $headers[] = id(clone $xaction) ->setRenderingTarget('text') ->getTitle(); break; } } $body->addRawSection(implode("\n", $headers)); foreach ($comments as $comment) { $body->addRawSection($comment); } if ($inline_comments) { $body->addRawSection(pht('INLINE COMMENTS')); foreach ($inline_comments as $comment) { $text = pht( 'Image %d: %s', $comment->getImageID(), $comment->getContent()); $body->addRawSection($text); } } $body->addTextSection( pht('MOCK DETAIL'), PhabricatorEnv::getProductionURI('/M'.$object->getID())); return $body; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.pholio.subject-prefix'); } protected function supportsFeed() { return true; } protected function supportsSearch() { return true; } protected function supportsHerald() { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new HeraldPholioMockAdapter()) ->setMock($object); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { $cc_phids = $adapter->getCcPHIDs(); if ($cc_phids) { id(new PhabricatorSubscriptionsEditor()) ->setObject($object) ->setActor($this->requireActor()) ->subscribeImplicit($cc_phids) ->save(); } } protected function sortTransactions(array $xactions) { $head = array(); $tail = array(); // Move inline comments to the end, so the comments precede them. foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); if ($type == PholioTransactionType::TYPE_INLINE) { $tail[] = $xaction; } else { $head[] = $xaction; } } return array_values(array_merge($head, $tail)); } protected function shouldImplyCC( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PholioTransactionType::TYPE_INLINE: return true; } return parent::shouldImplyCC($object, $xaction); } } diff --git a/src/applications/project/controller/PhabricatorProjectProfileEditController.php b/src/applications/project/controller/PhabricatorProjectProfileEditController.php index d482afbc5c..5f7aa0824f 100644 --- a/src/applications/project/controller/PhabricatorProjectProfileEditController.php +++ b/src/applications/project/controller/PhabricatorProjectProfileEditController.php @@ -1,244 +1,247 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withIDs(array($this->id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->needProfiles(true) ->executeOne(); if (!$project) { return new Aphront404Response(); } $profile = $project->getProfile(); $img_src = $profile->getProfileImageURI(); $options = PhabricatorProjectStatus::getStatusMap(); $supported_formats = PhabricatorFile::getTransformableImageFormats(); $e_name = true; $e_image = null; $errors = array(); if ($request->isFormPost()) { try { $xactions = array(); $xaction = new PhabricatorProjectTransaction(); $xaction->setTransactionType( PhabricatorProjectTransactionType::TYPE_NAME); $xaction->setNewValue($request->getStr('name')); $xactions[] = $xaction; $xaction = new PhabricatorProjectTransaction(); $xaction->setTransactionType( PhabricatorProjectTransactionType::TYPE_STATUS); $xaction->setNewValue($request->getStr('status')); $xactions[] = $xaction; $xaction = new PhabricatorProjectTransaction(); $xaction->setTransactionType( PhabricatorProjectTransactionType::TYPE_CAN_VIEW); $xaction->setNewValue($request->getStr('can_view')); $xactions[] = $xaction; $xaction = new PhabricatorProjectTransaction(); $xaction->setTransactionType( PhabricatorProjectTransactionType::TYPE_CAN_EDIT); $xaction->setNewValue($request->getStr('can_edit')); $xactions[] = $xaction; $xaction = new PhabricatorProjectTransaction(); $xaction->setTransactionType( PhabricatorProjectTransactionType::TYPE_CAN_JOIN); $xaction->setNewValue($request->getStr('can_join')); $xactions[] = $xaction; $editor = new PhabricatorProjectEditor($project); $editor->setActor($user); $editor->applyTransactions($xactions); } catch (PhabricatorProjectNameCollisionException $ex) { $e_name = pht('Not Unique'); $errors[] = $ex->getMessage(); } $profile->setBlurb($request->getStr('blurb')); if (!strlen($project->getName())) { $e_name = pht('Required'); $errors[] = pht('Project name is required.'); } else { $e_name = null; } $default_image = $request->getExists('default_image'); if ($default_image) { $profile->setProfileImagePHID(null); } else if (!empty($_FILES['image'])) { $err = idx($_FILES['image'], 'error'); if ($err != UPLOAD_ERR_NO_FILE) { $file = PhabricatorFile::newFromPHPUpload( $_FILES['image'], array( 'authorPHID' => $user->getPHID(), )); $okay = $file->isTransformableImage(); if ($okay) { $xformer = new PhabricatorImageTransformer(); $xformed = $xformer->executeThumbTransform( $file, $x = 50, $y = 50); + $profile->setProfileImagePHID($xformed->getPHID()); + $xformed->attachToObject($user, $project->getPHID()); + } else { $e_image = pht('Not Supported'); $errors[] = pht('This server only supports these image formats:').' '. implode(', ', $supported_formats).'.'; } } } if (!$errors) { $project->save(); $profile->setProjectPHID($project->getPHID()); $profile->save(); return id(new AphrontRedirectResponse()) ->setURI('/project/view/'.$project->getID().'/'); } } $error_view = null; if ($errors) { $error_view = new AphrontErrorView(); $error_view->setTitle(pht('Form Errors')); $error_view->setErrors($errors); } $header_name = pht('Edit Project'); $title = pht('Edit Project'); $action = '/project/edit/'.$project->getID().'/'; $policies = id(new PhabricatorPolicyQuery()) ->setViewer($user) ->setObject($project) ->execute(); $form = new AphrontFormView(); $form ->setID('project-edit-form') ->setUser($user) ->setAction($action) ->setEncType('multipart/form-data') ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Name')) ->setName('name') ->setValue($project->getName()) ->setError($e_name)) ->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('Project Status')) ->setName('status') ->setOptions($options) ->setValue($project->getStatus())) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel(pht('Blurb')) ->setName('blurb') ->setValue($profile->getBlurb())) ->appendChild(hsprintf( '

%s

', pht( 'NOTE: Policy settings are not yet fully implemented. '. 'Some interfaces still ignore these settings, '. 'particularly "Visible To".'))) ->appendChild( id(new AphrontFormPolicyControl()) ->setUser($user) ->setName('can_view') ->setCaption(pht('Members can always view a project.')) ->setPolicyObject($project) ->setPolicies($policies) ->setCapability(PhabricatorPolicyCapability::CAN_VIEW)) ->appendChild( id(new AphrontFormPolicyControl()) ->setUser($user) ->setName('can_edit') ->setPolicyObject($project) ->setPolicies($policies) ->setCapability(PhabricatorPolicyCapability::CAN_EDIT)) ->appendChild( id(new AphrontFormPolicyControl()) ->setUser($user) ->setName('can_join') ->setCaption( pht('Users who can edit a project can always join a project.')) ->setPolicyObject($project) ->setPolicies($policies) ->setCapability(PhabricatorPolicyCapability::CAN_JOIN)) ->appendChild( id(new AphrontFormMarkupControl()) ->setLabel(pht('Profile Image')) ->setValue( phutil_tag( 'img', array( 'src' => $img_src, )))) ->appendChild( id(new AphrontFormImageControl()) ->setLabel(pht('Change Image')) ->setName('image') ->setError($e_image) ->setCaption( pht('Supported formats:').' '.implode(', ', $supported_formats))) ->appendChild( id(new AphrontFormSubmitControl()) ->addCancelButton('/project/view/'.$project->getID().'/') ->setValue(pht('Save'))); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setFormError($error_view) ->setForm($form); $crumbs = $this->buildApplicationCrumbs($this->buildSideNavView()); $crumbs->addCrumb( id(new PhabricatorCrumbView()) ->setName($project->getName()) ->setHref('/project/view/'.$project->getID().'/')); $crumbs->addCrumb( id(new PhabricatorCrumbView()) ->setName(pht('Edit Project')) ->setHref($this->getApplicationURI())); return $this->buildApplicationPage( array( $crumbs, $form_box, ), array( 'title' => $title, 'device' => true, )); } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index 5cb35a926c..f1bb0db57c 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1,1630 +1,1646 @@ continueOnNoEffect = $continue; return $this; } public function getContinueOnNoEffect() { return $this->continueOnNoEffect; } /** * When the editor tries to apply transactions which don't populate all of * an object's required fields, should it raise an exception (default) or * drop them and continue? * * For example, if a user adds a new required custom field (like "Severity") * to a task, all existing tasks won't have it populated. When users * manually edit existing tasks, it's usually desirable to have them provide * a severity. However, other operations (like batch editing just the * owner of a task) will fail by default. * * By setting this flag for edit operations which apply to specific fields * (like the priority, batch, and merge editors in Maniphest), these * operations can continue to function even if an object is outdated. * * @param bool True to continue when transactions don't completely satisfy * all required fields. * @return this */ public function setContinueOnMissingFields($continue_on_missing_fields) { $this->continueOnMissingFields = $continue_on_missing_fields; return $this; } public function getContinueOnMissingFields() { return $this->continueOnMissingFields; } /** * Not strictly necessary, but reply handlers ideally set this value to * make email threading work better. */ public function setParentMessageID($parent_message_id) { $this->parentMessageID = $parent_message_id; return $this; } public function getParentMessageID() { return $this->parentMessageID; } protected function getIsNewObject() { return $this->isNewObject; } protected function getMentionedPHIDs() { return $this->mentionedPHIDs; } public function setIsPreview($is_preview) { $this->isPreview = $is_preview; return $this; } public function getIsPreview() { return $this->isPreview; } public function getTransactionTypes() { $types = array(); if ($this->object instanceof PhabricatorSubscribableInterface) { $types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS; } if ($this->object instanceof PhabricatorCustomFieldInterface) { $types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD; } return $types; } private function adjustTransactionValues( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $this->getTransactionOldValue($object, $xaction); $xaction->setOldValue($old); $new = $this->getTransactionNewValue($object, $xaction); $xaction->setNewValue($new); } private function getTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return array_values($this->subscribers); case PhabricatorTransactions::TYPE_VIEW_POLICY: return $object->getViewPolicy(); case PhabricatorTransactions::TYPE_EDIT_POLICY: return $object->getEditPolicy(); case PhabricatorTransactions::TYPE_EDGE: $edge_type = $xaction->getMetadataValue('edge:type'); if (!$edge_type) { throw new Exception("Edge transaction has no 'edge:type'!"); } $old_edges = array(); if ($object->getPHID()) { $edge_src = $object->getPHID(); $old_edges = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($edge_src)) ->withEdgeTypes(array($edge_type)) ->needEdgeData(true) ->execute(); $old_edges = $old_edges[$edge_src][$edge_type]; } return $old_edges; case PhabricatorTransactions::TYPE_CUSTOMFIELD: // NOTE: Custom fields have their old value pre-populated when they are // built by PhabricatorCustomFieldList. return $xaction->getOldValue(); default: return $this->getCustomTransactionOldValue($object, $xaction); } } private function getTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->getPHIDTransactionNewValue($xaction); case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: return $xaction->getNewValue(); case PhabricatorTransactions::TYPE_EDGE: return $this->getEdgeTransactionNewValue($xaction); case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getNewValueFromApplicationTransactions($xaction); default: return $this->getCustomTransactionNewValue($object, $xaction); } } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception("Capability not supported!"); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception("Capability not supported!"); } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COMMENT: return $xaction->hasComment(); case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getApplicationTransactionHasEffect($xaction); } return ($xaction->getOldValue() !== $xaction->getNewValue()); } protected function shouldApplyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function applyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { throw new Exception('Not implemented.'); } private function applyInternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_VIEW_POLICY: $object->setViewPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: $object->setEditPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionInternalEffects($xaction); } return $this->applyCustomInternalTransaction($object, $xaction); } private function applyExternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: $subeditor = id(new PhabricatorSubscriptionsEditor()) ->setObject($object) ->setActor($this->requireActor()); $old_map = array_fuse($xaction->getOldValue()); $new_map = array_fuse($xaction->getNewValue()); $subeditor->unsubscribe( array_keys( array_diff_key($old_map, $new_map))); $subeditor->subscribeExplicit( array_keys( array_diff_key($new_map, $old_map))); $subeditor->save(); // for the rest of these edits, subscribers should include those just // added as well as those just removed. $subscribers = array_unique(array_merge( $this->subscribers, $xaction->getOldValue(), $xaction->getNewValue())); $this->subscribers = $subscribers; break; case PhabricatorTransactions::TYPE_EDGE: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $src = $object->getPHID(); $type = $xaction->getMetadataValue('edge:type'); foreach ($new as $dst_phid => $edge) { $new[$dst_phid]['src'] = $src; } $editor = id(new PhabricatorEdgeEditor()) ->setActor($this->getActor()); foreach ($old as $dst_phid => $edge) { if (!empty($new[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $editor->removeEdge($src, $type, $dst_phid); } foreach ($new as $dst_phid => $edge) { if (!empty($old[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $data = array( 'data' => $edge['data'], ); $editor->addEdge($src, $type, $dst_phid, $data); } $editor->save(); break; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionExternalEffects($xaction); } return $this->applyCustomExternalTransaction($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception("Capability not supported!"); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception("Capability not supported!"); } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function setContentSourceFromRequest(AphrontRequest $request) { return $this->setContentSource( PhabricatorContentSource::newFromRequest($request)); } public function getContentSource() { return $this->contentSource; } final public function applyTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->object = $object; $this->xactions = $xactions; $this->isNewObject = ($object->getPHID() === null); $this->validateEditParameters($object, $xactions); $actor = $this->requireActor(); $this->loadSubscribers($object); $xactions = $this->applyImplicitCC($object, $xactions); $mention_xaction = $this->buildMentionTransaction($object, $xactions); if ($mention_xaction) { $xactions[] = $mention_xaction; } $xactions = $this->combineTransactions($xactions); foreach ($xactions as $xaction) { // TODO: This needs to be more sophisticated once we have meta-policies. $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC); $xaction->setEditPolicy($actor->getPHID()); $xaction->setAuthorPHID($actor->getPHID()); $xaction->setContentSource($this->getContentSource()); $xaction->attachViewer($this->getActor()); } $is_preview = $this->getIsPreview(); $read_locking = false; $transaction_open = false; if (!$is_preview) { $errors = array(); $type_map = mgroup($xactions, 'getTransactionType'); foreach ($this->getTransactionTypes() as $type) { $type_xactions = idx($type_map, $type, array()); $errors[] = $this->validateTransaction($object, $type, $type_xactions); } $errors = array_mergev($errors); $continue_on_missing = $this->getContinueOnMissingFields(); foreach ($errors as $key => $error) { if ($continue_on_missing && $error->getIsMissingFieldError()) { unset($errors[$key]); } } if ($errors) { throw new PhabricatorApplicationTransactionValidationException($errors); } $file_phids = $this->extractFilePHIDs($object, $xactions); if ($object->getID()) { foreach ($xactions as $xaction) { // If any of the transactions require a read lock, hold one and // reload the object. We need to do this fairly early so that the // call to `adjustTransactionValues()` (which populates old values) // is based on the synchronized state of the object, which may differ // from the state when it was originally loaded. if ($this->shouldReadLock($object, $xaction)) { $object->openTransaction(); $object->beginReadLocking(); $transaction_open = true; $read_locking = true; $object->reload(); break; } } } if ($this->shouldApplyInitialEffects($object, $xactions)) { if (!$transaction_open) { $object->openTransaction(); $transaction_open = true; } } } if ($this->shouldApplyInitialEffects($object, $xactions)) { $this->applyInitialEffects($object, $xactions); } foreach ($xactions as $xaction) { $this->adjustTransactionValues($object, $xaction); } $xactions = $this->filterTransactions($object, $xactions); if (!$xactions) { if ($read_locking) { $object->endReadLocking(); $read_locking = false; } if ($transaction_open) { $object->killTransaction(); $transaction_open = false; } return array(); } $xactions = $this->sortTransactions($xactions); if ($is_preview) { $this->loadHandles($xactions); return $xactions; } $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($actor) ->setContentSource($this->getContentSource()); if (!$transaction_open) { $object->openTransaction(); } foreach ($xactions as $xaction) { $this->applyInternalEffects($object, $xaction); } $object->save(); foreach ($xactions as $xaction) { $xaction->setObjectPHID($object->getPHID()); if ($xaction->getComment()) { $xaction->setPHID($xaction->generatePHID()); $comment_editor->applyEdit($xaction, $xaction->getComment()); } else { $xaction->save(); } } if ($file_phids) { $this->attachFiles($object, $file_phids); } foreach ($xactions as $xaction) { $this->applyExternalEffects($object, $xaction); } if ($this->supportsHerald()) { $this->applyHeraldRules($object, $xactions); } $this->applyFinalEffects($object, $xactions); if ($read_locking) { $object->endReadLocking(); $read_locking = false; } $object->saveTransaction(); $this->loadHandles($xactions); $mail = null; if ($this->shouldSendMail($object, $xactions)) { $mail = $this->sendMail($object, $xactions); } if ($this->supportsSearch()) { id(new PhabricatorSearchIndexer()) ->indexDocumentByPHID($object->getPHID()); } if ($this->supportsFeed()) { $mailed = array(); if ($mail) { $mailed = $mail->buildRecipientList(); } $this->publishFeedStory( $object, $xactions, $mailed); } $this->didApplyTransactions($xactions); if ($object instanceof PhabricatorCustomFieldInterface) { // Maybe this makes more sense to move into the search index itself? For // now I'm putting it here since I think we might end up with things that // need it to be up to date once the next page loads, but if we don't go // there we we could move it into search once search moves to the daemons. $fields = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); $fields->readFieldsFromStorage($object); $fields->rebuildIndexes($object); } return $xactions; } protected function didApplyTransactions(array $xactions) { // Hook for subclasses. return; } /** * Determine if the editor should hold a read lock on the object while * applying a transaction. * * If the editor does not hold a lock, two editors may read an object at the * same time, then apply their changes without any synchronization. For most * transactions, this does not matter much. However, it is important for some * transactions. For example, if an object has a transaction count on it, both * editors may read the object with `count = 23`, then independently update it * and save the object with `count = 24` twice. This will produce the wrong * state: the object really has 25 transactions, but the count is only 24. * * Generally, transactions fall into one of four buckets: * * - Append operations: Actions like adding a comment to an object purely * add information to its state, and do not depend on the current object * state in any way. These transactions never need to hold locks. * - Overwrite operations: Actions like changing the title or description * of an object replace the current value with a new value, so the end * state is consistent without a lock. We currently do not lock these * transactions, although we may in the future. * - Edge operations: Edge and subscription operations have internal * synchronization which limits the damage race conditions can cause. * We do not currently lock these transactions, although we may in the * future. * - Update operations: Actions like incrementing a count on an object. * These operations generally should use locks, unless it is not * important that the state remain consistent in the presence of races. * * @param PhabricatorLiskDAO Object being updated. * @param PhabricatorApplicationTransaction Transaction being applied. * @return bool True to synchronize the edit with a lock. */ protected function shouldReadLock( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return false; } private function loadHandles(array $xactions) { $phids = array(); foreach ($xactions as $key => $xaction) { $phids[$key] = $xaction->getRequiredHandlePHIDs(); } $handles = array(); $merged = array_mergev($phids); if ($merged) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($merged) ->execute(); } foreach ($xactions as $key => $xaction) { $xaction->setHandles(array_select_keys($handles, $phids[$key])); } } private function loadSubscribers(PhabricatorLiskDAO $object) { if ($object->getPHID() && ($object instanceof PhabricatorSubscribableInterface)) { $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID( $object->getPHID()); $this->subscribers = array_fuse($subs); } else { $this->subscribers = array(); } } private function validateEditParameters( PhabricatorLiskDAO $object, array $xactions) { if (!$this->getContentSource()) { throw new Exception( "Call setContentSource() before applyTransactions()!"); } // Do a bunch of sanity checks that the incoming transactions are fresh. // They should be unsaved and have only "transactionType" and "newValue" // set. $types = array_fill_keys($this->getTransactionTypes(), true); assert_instances_of($xactions, 'PhabricatorApplicationTransaction'); foreach ($xactions as $xaction) { if ($xaction->getPHID() || $xaction->getID()) { throw new Exception( "You can not apply transactions which already have IDs/PHIDs!"); } if ($xaction->getObjectPHID()) { throw new Exception( "You can not apply transactions which already have objectPHIDs!"); } if ($xaction->getAuthorPHID()) { throw new Exception( "You can not apply transactions which already have authorPHIDs!"); } if ($xaction->getCommentPHID()) { throw new Exception( "You can not apply transactions which already have commentPHIDs!"); } if ($xaction->getCommentVersion() !== 0) { throw new Exception( "You can not apply transactions which already have commentVersions!"); } $exempt_types = array( // CustomField logic currently prefills these before we enter the // transaction editor. PhabricatorTransactions::TYPE_CUSTOMFIELD => true, // TODO: Remove this, this edge type is encumbered with a bunch of // legacy nonsense. ManiphestTransaction::TYPE_EDGE => true, ); if (empty($exempt_types[$xaction->getTransactionType()])) { if ($xaction->getOldValue() !== null) { throw new Exception( "You can not apply transactions which already have oldValue!"); } } $type = $xaction->getTransactionType(); if (empty($types[$type])) { throw new Exception("Transaction has unknown type '{$type}'."); } } // The actor must have permission to view and edit the object. $actor = $this->requireActor(); PhabricatorPolicyFilter::requireCapability( $actor, $object, PhabricatorPolicyCapability::CAN_VIEW); // TODO: This should be "$object", not "$xaction", but probably breaks a // lot of stuff if fixed -- you don't need to be able to edit in order to // comment. Instead, transactions should specify the capabilities they // require. /* PhabricatorPolicyFilter::requireCapability( $actor, $xaction, PhabricatorPolicyCapability::CAN_EDIT); */ } private function buildMentionTransaction( PhabricatorLiskDAO $object, array $xactions) { if (!($object instanceof PhabricatorSubscribableInterface)) { return null; } $texts = array(); foreach ($xactions as $xaction) { $texts[] = $this->getRemarkupBlocksFromTransaction($xaction); } $texts = array_mergev($texts); $phids = PhabricatorMarkupEngine::extractPHIDsFromMentions($texts); $this->mentionedPHIDs = $phids; if ($object->getPHID()) { // Don't try to subscribe already-subscribed mentions: we want to generate // a dialog about an action having no effect if the user explicitly adds // existing CCs, but not if they merely mention existing subscribers. $phids = array_diff($phids, $this->subscribers); } foreach ($phids as $key => $phid) { if ($object->isAutomaticallySubscribed($phid)) { unset($phids[$key]); } } $phids = array_values($phids); if (!$phids) { return null; } $xaction = newv(get_class(head($xactions)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); $xaction->setNewValue(array('+' => $phids)); return $xaction; } protected function getRemarkupBlocksFromTransaction( PhabricatorApplicationTransaction $transaction) { $texts = array(); if ($transaction->getComment()) { $texts[] = $transaction->getComment()->getContent(); } return $texts; } protected function mergeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $type = $u->getTransactionType(); switch ($type) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->mergePHIDOrEdgeTransactions($u, $v); case PhabricatorTransactions::TYPE_EDGE: $u_type = $u->getMetadataValue('edge:type'); $v_type = $v->getMetadataValue('edge:type'); if ($u_type == $v_type) { return $this->mergePHIDOrEdgeTransactions($u, $v); } return null; } // By default, do not merge the transactions. return null; } /** * Attempt to combine similar transactions into a smaller number of total * transactions. For example, two transactions which edit the title of an * object can be merged into a single edit. */ private function combineTransactions(array $xactions) { $stray_comments = array(); $result = array(); $types = array(); foreach ($xactions as $key => $xaction) { $type = $xaction->getTransactionType(); if (isset($types[$type])) { foreach ($types[$type] as $other_key) { $merged = $this->mergeTransactions($result[$other_key], $xaction); if ($merged) { $result[$other_key] = $merged; if ($xaction->getComment() && ($xaction->getComment() !== $merged->getComment())) { $stray_comments[] = $xaction->getComment(); } if ($result[$other_key]->getComment() && ($result[$other_key]->getComment() !== $merged->getComment())) { $stray_comments[] = $result[$other_key]->getComment(); } // Move on to the next transaction. continue 2; } } } $result[$key] = $xaction; $types[$type][] = $key; } // If we merged any comments away, restore them. foreach ($stray_comments as $comment) { $xaction = newv(get_class(head($result)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT); $xaction->setComment($comment); $result[] = $xaction; } return array_values($result); } protected function mergePHIDOrEdgeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $result = $u->getNewValue(); foreach ($v->getNewValue() as $key => $value) { $result[$key] = array_merge($value, idx($result, $key, array())); } $u->setNewValue($result); return $u; } protected function getPHIDTransactionNewValue( PhabricatorApplicationTransaction $xaction) { $old = array_fuse($xaction->getOldValue()); $new = $xaction->getNewValue(); $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); if ($new_set !== null) { $new_set = array_fuse($new_set); } unset($new['=']); if ($new) { throw new Exception( "Invalid 'new' value for PHID transaction. Value should contain only ". "keys '+' (add PHIDs), '-' (remove PHIDs) and '=' (set PHIDS)."); } $result = array(); foreach ($old as $phid) { if ($new_set !== null && empty($new_set[$phid])) { continue; } $result[$phid] = $phid; } if ($new_set !== null) { foreach ($new_set as $phid) { $result[$phid] = $phid; } } foreach ($new_add as $phid) { $result[$phid] = $phid; } foreach ($new_rem as $phid) { unset($result[$phid]); } return array_values($result); } protected function getEdgeTransactionNewValue( PhabricatorApplicationTransaction $xaction) { $new = $xaction->getNewValue(); $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); unset($new['=']); if ($new) { throw new Exception( "Invalid 'new' value for Edge transaction. Value should contain only ". "keys '+' (add edges), '-' (remove edges) and '=' (set edges)."); } $old = $xaction->getOldValue(); $lists = array($new_set, $new_add, $new_rem); foreach ($lists as $list) { $this->checkEdgeList($list); } $result = array(); foreach ($old as $dst_phid => $edge) { if ($new_set !== null && empty($new_set[$dst_phid])) { continue; } $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge); } if ($new_set !== null) { foreach ($new_set as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge); } } foreach ($new_add as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge); } foreach ($new_rem as $dst_phid => $edge) { unset($result[$dst_phid]); } return $result; } private function checkEdgeList($list) { if (!$list) { return; } foreach ($list as $key => $item) { if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) { throw new Exception( "Edge transactions must have destination PHIDs as in edge ". "lists (found key '{$key}')."); } if (!is_array($item) && $item !== $key) { throw new Exception( "Edge transactions must have PHIDs or edge specs as values ". "(found value '{$item}')."); } } } protected function normalizeEdgeTransactionValue( PhabricatorApplicationTransaction $xaction, $edge) { if (!is_array($edge)) { $edge = array( 'dst' => $edge, ); } $edge_type = $xaction->getMetadataValue('edge:type'); if (empty($edge['type'])) { $edge['type'] = $edge_type; } else { if ($edge['type'] != $edge_type) { $this_type = $edge['type']; throw new Exception( "Edge transaction includes edge of type '{$this_type}', but ". "transaction is of type '{$edge_type}'. Each edge transaction must ". "alter edges of only one type."); } } if (!isset($edge['data'])) { $edge['data'] = null; } return $edge; } protected function sortTransactions(array $xactions) { $head = array(); $tail = array(); // Move bare comments to the end, so the actions precede them. foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); if ($type == PhabricatorTransactions::TYPE_COMMENT) { $tail[] = $xaction; } else { $head[] = $xaction; } } return array_values(array_merge($head, $tail)); } protected function filterTransactions( PhabricatorLiskDAO $object, array $xactions) { $type_comment = PhabricatorTransactions::TYPE_COMMENT; $no_effect = array(); $has_comment = false; $any_effect = false; foreach ($xactions as $key => $xaction) { if ($this->transactionHasEffect($object, $xaction)) { if ($xaction->getTransactionType() != $type_comment) { $any_effect = true; } } else { $no_effect[$key] = $xaction; } if ($xaction->hasComment()) { $has_comment = true; } } if (!$no_effect) { return $xactions; } if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) { throw new PhabricatorApplicationTransactionNoEffectException( $no_effect, $any_effect, $has_comment); } if (!$any_effect && !$has_comment) { // If we only have empty comment transactions, just drop them all. return array(); } foreach ($no_effect as $key => $xaction) { if ($xaction->getComment()) { $xaction->setTransactionType($type_comment); $xaction->setOldValue(null); $xaction->setNewValue(null); } else { unset($xactions[$key]); } } return $xactions; } /** * Hook for validating transactions. This callback will be invoked for each * available transaction type, even if an edit does not apply any transactions * of that type. This allows you to raise exceptions when required fields are * missing, by detecting that the object has no field value and there is no * transaction which sets one. * * @param PhabricatorLiskDAO Object being edited. * @param string Transaction type to validate. * @param list Transactions of given type, * which may be empty if the edit does not apply any transactions of the * given type. * @return list List of * validation errors. */ protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = array(); switch ($type) { case PhabricatorTransactions::TYPE_CUSTOMFIELD: $groups = array(); foreach ($xactions as $xaction) { $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction; } $field_list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_EDIT); $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS; foreach ($field_list->getFields() as $field) { if (!$field->shouldEnableForRole($role_xactions)) { continue; } $errors[] = $field->validateApplicationTransactions( $this, $type, idx($groups, $field->getFieldKey(), array())); } break; } return array_mergev($errors); } /* -( Implicit CCs )------------------------------------------------------- */ /** * When a user interacts with an object, we might want to add them to CC. */ final public function applyImplicitCC( PhabricatorLiskDAO $object, array $xactions) { if (!($object instanceof PhabricatorSubscribableInterface)) { // If the object isn't subscribable, we can't CC them. return $xactions; } $actor_phid = $this->requireActor()->getPHID(); if ($object->isAutomaticallySubscribed($actor_phid)) { // If they're auto-subscribed, don't CC them. return $xactions; } $should_cc = false; foreach ($xactions as $xaction) { if ($this->shouldImplyCC($object, $xaction)) { $should_cc = true; break; } } if (!$should_cc) { // Only some types of actions imply a CC (like adding a comment). return $xactions; } if ($object->getPHID()) { if (isset($this->subscribers[$actor_phid])) { // If the user is already subscribed, don't implicitly CC them. return $xactions; } $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorEdgeConfig::TYPE_OBJECT_HAS_UNSUBSCRIBER); $unsub = array_fuse($unsub); if (isset($unsub[$actor_phid])) { // If the user has previously unsubscribed from this object explicitly, // don't implicitly CC them. return $xactions; } } $xaction = newv(get_class(head($xactions)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); $xaction->setNewValue(array('+' => array($actor_phid))); array_unshift($xactions, $xaction); return $xactions; } protected function shouldImplyCC( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COMMENT: return true; default: return false; } } /* -( Sending Mail )------------------------------------------------------- */ /** * @task mail */ protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return false; } /** * @task mail */ protected function sendMail( PhabricatorLiskDAO $object, array $xactions) { $email_to = array_filter(array_unique($this->getMailTo($object))); $email_cc = array_filter(array_unique($this->getMailCC($object))); $phids = array_merge($email_to, $email_cc); $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($phids) ->execute(); $template = $this->buildMailTemplate($object); $body = $this->buildMailBody($object, $xactions); $mail_tags = $this->getMailTags($object, $xactions); $action = $this->getStrongestAction($object, $xactions)->getActionName(); $template ->setFrom($this->requireActor()->getPHID()) ->setSubjectPrefix($this->getMailSubjectPrefix()) ->setVarySubjectPrefix('['.$action.']') ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject()) ->setRelatedPHID($object->getPHID()) ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setMailTags($mail_tags) ->setIsBulk(true) ->setBody($body->render()); if ($this->getParentMessageID()) { $template->setParentMessageID($this->getParentMessageID()); } $mails = $this ->buildReplyHandler($object) ->multiplexMail( $template, array_select_keys($handles, $email_to), array_select_keys($handles, $email_cc)); foreach ($mails as $mail) { $mail->saveAndSend(); } $template->addTos($email_to); $template->addCCs($email_cc); return $template; } protected function getMailThreadID(PhabricatorLiskDAO $object) { return $object->getPHID(); } /** * @task mail */ protected function getStrongestAction( PhabricatorLiskDAO $object, array $xactions) { return last(msort($xactions, 'getActionStrength')); } /** * @task mail */ protected function buildReplyHandler(PhabricatorLiskDAO $object) { throw new Exception("Capability not supported."); } /** * @task mail */ protected function getMailSubjectPrefix() { throw new Exception("Capability not supported."); } /** * @task mail */ protected function getMailTags( PhabricatorLiskDAO $object, array $xactions) { $tags = array(); foreach ($xactions as $xaction) { $tags[] = $xaction->getMailTags(); } return array_mergev($tags); } /** * @task mail */ protected function buildMailTemplate(PhabricatorLiskDAO $object) { throw new Exception("Capability not supported."); } /** * @task mail */ protected function getMailTo(PhabricatorLiskDAO $object) { throw new Exception("Capability not supported."); } /** * @task mail */ protected function getMailCC(PhabricatorLiskDAO $object) { if ($object instanceof PhabricatorSubscribableInterface) { return $this->subscribers; } throw new Exception("Capability not supported."); } /** * @task mail */ protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $headers = array(); $comments = array(); foreach ($xactions as $xaction) { if ($xaction->shouldHideForMail()) { continue; } $headers[] = id(clone $xaction)->setRenderingTarget('text')->getTitle(); $comment = $xaction->getComment(); if ($comment && strlen($comment->getContent())) { $comments[] = $comment->getContent(); } } $body = new PhabricatorMetaMTAMailBody(); $body->addRawSection(implode("\n", $headers)); foreach ($comments as $comment) { $body->addRawSection($comment); } return $body; } /* -( Publishing Feed Stories )-------------------------------------------- */ /** * @task feed */ protected function supportsFeed() { return false; } /** * @task feed */ protected function getFeedStoryType() { return 'PhabricatorApplicationTransactionFeedStory'; } /** * @task feed */ protected function getFeedRelatedPHIDs( PhabricatorLiskDAO $object, array $xactions) { return array( $object->getPHID(), $this->requireActor()->getPHID(), ); } /** * @task feed */ protected function getFeedNotifyPHIDs( PhabricatorLiskDAO $object, array $xactions) { return array_unique(array_merge( $this->getMailTo($object), $this->getMailCC($object))); } /** * @task feed */ protected function getFeedStoryData( PhabricatorLiskDAO $object, array $xactions) { $xactions = msort($xactions, 'getActionStrength'); $xactions = array_reverse($xactions); return array( 'objectPHID' => $object->getPHID(), 'transactionPHIDs' => mpull($xactions, 'getPHID'), ); } /** * @task feed */ protected function publishFeedStory( PhabricatorLiskDAO $object, array $xactions, array $mailed_phids) { $related_phids = $this->getFeedRelatedPHIDs($object, $xactions); $subscribed_phids = $this->getFeedNotifyPHIDs($object, $xactions); $story_type = $this->getFeedStoryType(); $story_data = $this->getFeedStoryData($object, $xactions); id(new PhabricatorFeedStoryPublisher()) ->setStoryType($story_type) ->setStoryData($story_data) ->setStoryTime(time()) ->setStoryAuthorPHID($this->requireActor()->getPHID()) ->setRelatedPHIDs($related_phids) ->setPrimaryObjectPHID($object->getPHID()) ->setSubscribedPHIDs($subscribed_phids) ->setMailRecipientPHIDs($mailed_phids) ->publish(); } /* -( Search Index )------------------------------------------------------- */ /** * @task search */ protected function supportsSearch() { return false; } /* -( Herald Integration )-------------------------------------------------- */ protected function supportsHerald() { return false; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { throw new Exception('No herald adapter specified.'); } private function setHeraldAdapter(HeraldAdapter $adapter) { $this->heraldAdapter = $adapter; return $this; } protected function getHeraldAdapter() { return $this->heraldAdapter; } private function setHeraldTranscript(HeraldTranscript $transcript) { $this->heraldTranscript = $transcript; return $this; } protected function getHeraldTranscript() { return $this->heraldTranscript; } private function applyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { $adapter = $this->buildHeraldAdapter($object, $xactions); $adapter->setContentSource($this->getContentSource()); $xscript = HeraldEngine::loadAndApplyRules($adapter); $this->setHeraldAdapter($adapter); $this->setHeraldTranscript($xscript); $this->didApplyHeraldRules($object, $adapter, $xscript); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { } /* -( Custom Fields )------------------------------------------------------ */ /** * @task customfield */ private function getCustomFieldForTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $field_key = $xaction->getMetadataValue('customfield:key'); if (!$field_key) { throw new Exception( "Custom field transaction has no 'customfield:key'!"); } $field = PhabricatorCustomField::getObjectField( $object, PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS, $field_key); if (!$field) { throw new Exception( "Custom field transaction has invalid 'customfield:key'; field ". "'{$field_key}' is disabled or does not exist."); } if (!$field->shouldAppearInApplicationTransactions()) { throw new Exception( "Custom field transaction '{$field_key}' does not implement ". "integration for ApplicationTransactions."); } return $field; } /* -( Files )-------------------------------------------------------------- */ /** * Extract the PHIDs of any files which these transactions attach. * * @task files */ private function extractFilePHIDs( PhabricatorLiskDAO $object, array $xactions) { $blocks = array(); foreach ($xactions as $xaction) { $blocks[] = $this->getRemarkupBlocksFromTransaction($xaction); } $blocks = array_mergev($blocks); - if (!$blocks) { - return array(); + + $phids = array(); + if ($blocks) { + $phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( + $blocks); } - $phids = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( - $blocks); + foreach ($xactions as $xaction) { + $phids[] = $this->extractFilePHIDsFromCustomTransaction( + $object, + $xaction); + } + $phids = array_unique(array_filter(array_mergev($phids))); if (!$phids) { return array(); } // Only let a user attach files they can actually see, since this would // otherwise let you access any file by attaching it to an object you have // view permission on. $files = id(new PhabricatorFileQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); return mpull($files, 'getPHID'); } + /** + * @task files + */ + protected function extractFilePHIDsFromCustomTransaction( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + return array(); + } + /** * @task files */ private function attachFiles( PhabricatorLiskDAO $object, array $file_phids) { if (!$file_phids) { return; } $editor = id(new PhabricatorEdgeEditor()) ->setActor($this->getActor()); // TODO: Edge-based events were almost certainly a terrible idea. If we // don't suppress this event, the Maniphest listener reenters and adds // more transactions. Just suppress it until that can get cleaned up. $editor->setSuppressEvents(true); $src = $object->getPHID(); $type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_FILE; foreach ($file_phids as $dst) { $editor->addEdge($src, $type, $dst); } $editor->save(); } }