diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php
index fd50087c9..fdd2a0641 100644
--- a/src/applications/files/storage/PhabricatorFile.php
+++ b/src/applications/files/storage/PhabricatorFile.php
@@ -1,930 +1,949 @@
 <?php
 
 /**
  * @group file
  */
 final class PhabricatorFile extends PhabricatorFileDAO
   implements
     PhabricatorTokenReceiverInterface,
     PhabricatorSubscribableInterface,
     PhabricatorPolicyInterface {
 
   const STORAGE_FORMAT_RAW  = 'raw';
 
   const METADATA_IMAGE_WIDTH  = 'width';
   const METADATA_IMAGE_HEIGHT = 'height';
 
   protected $phid;
   protected $name;
   protected $mimeType;
   protected $byteSize;
   protected $authorPHID;
   protected $secretKey;
   protected $contentHash;
   protected $metadata = array();
   protected $mailKey;
 
   protected $storageEngine;
   protected $storageFormat;
   protected $storageHandle;
 
   protected $ttl;
   protected $isExplicitUpload = 1;
   protected $viewPolicy = PhabricatorPolicies::POLICY_USER;
 
   private $objects = self::ATTACHABLE;
   private $objectPHIDs = self::ATTACHABLE;
 
   public function getConfiguration() {
     return array(
       self::CONFIG_AUX_PHID => 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<string>                   List of builtin file names.
    * @return dict<string, PhabricatorFile>  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 d37e111e6..cbf7ccec8 100644
--- a/src/applications/macro/editor/PhabricatorMacroEditor.php
+++ b/src/applications/macro/editor/PhabricatorMacroEditor.php
@@ -1,150 +1,162 @@
 <?php
 
 final class PhabricatorMacroEditor
   extends PhabricatorApplicationTransactionEditor {
 
   public function getTransactionTypes() {
     $types = parent::getTransactionTypes();
 
     $types[] = PhabricatorTransactions::TYPE_COMMENT;
     $types[] = PhabricatorMacroTransactionType::TYPE_NAME;
     $types[] = PhabricatorMacroTransactionType::TYPE_DISABLED;
     $types[] = PhabricatorMacroTransactionType::TYPE_FILE;
     $types[] = PhabricatorMacroTransactionType::TYPE_AUDIO;
     $types[] = PhabricatorMacroTransactionType::TYPE_AUDIO_BEHAVIOR;
 
     return $types;
   }
 
   protected function getCustomTransactionOldValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->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 114aea698..a8cbe4c5f 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 @@
 <?php
 
 /**
  * @group conduit
  */
 final class ConduitAPI_paste_create_Method extends ConduitAPI_paste_Method {
 
   public function getMethodDescription() {
     return 'Create a new paste.';
   }
 
   public function defineParamTypes() {
     return array(
       'content'   => '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 20da12525..cb3d8a5e7 100644
--- a/src/applications/paste/editor/PhabricatorPasteEditor.php
+++ b/src/applications/paste/editor/PhabricatorPasteEditor.php
@@ -1,150 +1,166 @@
 <?php
 
-/**
- * @group paste
- */
 final class PhabricatorPasteEditor
   extends PhabricatorApplicationTransactionEditor {
 
+  private $pasteFile;
+
   public function getTransactionTypes() {
     $types = parent::getTransactionTypes();
 
     $types[] = PhabricatorPasteTransaction::TYPE_CREATE;
     $types[] = PhabricatorPasteTransaction::TYPE_TITLE;
     $types[] = PhabricatorPasteTransaction::TYPE_LANGUAGE;
     $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
     $types[] = PhabricatorTransactions::TYPE_COMMENT;
 
     return $types;
   }
 
   protected function getCustomTransactionOldValue(
     PhabricatorLiskDAO $object,
     PhabricatorApplicationTransaction $xaction) {
 
     switch ($xaction->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 96e5e5827..453c13802 100644
--- a/src/applications/paste/storage/PhabricatorPasteTransaction.php
+++ b/src/applications/paste/storage/PhabricatorPasteTransaction.php
@@ -1,134 +1,131 @@
 <?php
 
-/**
- * @group paste
- */
 final class PhabricatorPasteTransaction
   extends PhabricatorApplicationTransaction {
 
   const TYPE_CREATE = 'paste.create';
   const TYPE_TITLE = 'paste.title';
   const TYPE_LANGUAGE = 'paste.language';
 
   public function getApplicationName() {
     return 'pastebin';
   }
 
   public function getApplicationTransactionType() {
     return PhabricatorPastePHIDTypePaste::TYPECONST;
   }
 
   public function getApplicationTransactionCommentObject() {
     return new PhabricatorPasteTransactionComment();
   }
 
   public function getRequiredHandlePHIDs() {
     $phids = parent::getRequiredHandlePHIDs();
 
     switch ($this->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 d16a4ab4a..169b90911 100644
--- a/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php
+++ b/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php
@@ -1,307 +1,308 @@
 <?php
 
 final class PhabricatorPeopleProfilePictureController
   extends PhabricatorPeopleController {
 
   private $id;
 
   public function shouldRequireAdmin() {
     return false;
   }
 
   public function willProcessRequest(array $data) {
     $this->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 30e82b8fe..8cf5784cc 100644
--- a/src/applications/pholio/editor/PholioMockEditor.php
+++ b/src/applications/pholio/editor/PholioMockEditor.php
@@ -1,444 +1,464 @@
 <?php
 
 /**
  * @group pholio
  */
 final class PholioMockEditor extends PhabricatorApplicationTransactionEditor {
 
   private $newImages = array();
   private function setNewImages(array $new_images) {
     assert_instances_of($new_images, 'PholioImage');
     $this->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 d482afbc5..5f7aa0824 100644
--- a/src/applications/project/controller/PhabricatorProjectProfileEditController.php
+++ b/src/applications/project/controller/PhabricatorProjectProfileEditController.php
@@ -1,244 +1,247 @@
 <?php
 
 final class PhabricatorProjectProfileEditController
   extends PhabricatorProjectController {
 
   private $id;
 
   public function willProcessRequest(array $data) {
     $this->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(
         '<p class="aphront-form-instructions">%s</p>',
         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 5cb35a926..f1bb0db57 100644
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -1,1630 +1,1646 @@
 <?php
 
 /**
  * @task mail   Sending Mail
  * @task feed   Publishing Feed Stories
  * @task search Search Index
  * @task files  Integration with Files
  */
 abstract class PhabricatorApplicationTransactionEditor
   extends PhabricatorEditor {
 
   private $contentSource;
   private $object;
   private $xactions;
 
   private $isNewObject;
   private $mentionedPHIDs;
   private $continueOnNoEffect;
   private $continueOnMissingFields;
   private $parentMessageID;
   private $heraldAdapter;
   private $heraldTranscript;
   private $subscribers;
 
   private $isPreview;
 
   /**
    * When the editor tries to apply transactions that have no effect, should
    * it raise an exception (default) or drop them and continue?
    *
    * Generally, you will set this flag for edits coming from "Edit" interfaces,
    * and leave it cleared for edits coming from "Comment" interfaces, so the
    * user will get a useful error if they try to submit a comment that does
    * nothing (e.g., empty comment with a status change that has already been
    * performed by another user).
    *
    * @param bool  True to drop transactions without effect and continue.
    * @return this
    */
   public function setContinueOnNoEffect($continue) {
     $this->continueOnNoEffect = $continue;
     return $this;
   }
 
   public function getContinueOnNoEffect() {
     return $this->continueOnNoEffect;
   }
 
 
   /**
    * When the editor tries to apply transactions which don't populate all of
    * an object's required fields, should it raise an exception (default) or
    * drop them and continue?
    *
    * For example, if a user adds a new required custom field (like "Severity")
    * to a task, all existing tasks won't have it populated. When users
    * manually edit existing tasks, it's usually desirable to have them provide
    * a severity. However, other operations (like batch editing just the
    * owner of a task) will fail by default.
    *
    * By setting this flag for edit operations which apply to specific fields
    * (like the priority, batch, and merge editors in Maniphest), these
    * operations can continue to function even if an object is outdated.
    *
    * @param bool  True to continue when transactions don't completely satisfy
    *              all required fields.
    * @return this
    */
   public function setContinueOnMissingFields($continue_on_missing_fields) {
     $this->continueOnMissingFields = $continue_on_missing_fields;
     return $this;
   }
 
   public function getContinueOnMissingFields() {
     return $this->continueOnMissingFields;
   }
 
 
   /**
    * Not strictly necessary, but reply handlers ideally set this value to
    * make email threading work better.
    */
   public function setParentMessageID($parent_message_id) {
     $this->parentMessageID = $parent_message_id;
     return $this;
   }
   public function getParentMessageID() {
     return $this->parentMessageID;
   }
 
   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<PhabricatorApplicationTransaction> Transactions of given type,
    *   which may be empty if the edit does not apply any transactions of the
    *   given type.
    * @return list<PhabricatorApplicationTransactionValidationError> List of
    *   validation errors.
    */
   protected function validateTransaction(
     PhabricatorLiskDAO $object,
     $type,
     array $xactions) {
 
     $errors = array();
     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();
   }
 
 }