diff --git a/src/applications/files/PhabricatorImageTransformer.php b/src/applications/files/PhabricatorImageTransformer.php index c3a10e4816..0ec46960cb 100644 --- a/src/applications/files/PhabricatorImageTransformer.php +++ b/src/applications/files/PhabricatorImageTransformer.php @@ -1,420 +1,426 @@ <?php final class PhabricatorImageTransformer { public function executeMemeTransform( PhabricatorFile $file, $upper_text, $lower_text) { $image = $this->applyMemeTo($file, $upper_text, $lower_text); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'meme-'.$file->getName(), 'ttl' => time() + 60 * 60 * 24, )); } public function executeThumbTransform( PhabricatorFile $file, $x, $y) { $image = $this->crudelyScaleTo($file, $x, $y); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'thumb-'.$file->getName(), )); } public function executeProfileTransform( PhabricatorFile $file, $x, $min_y, $max_y) { $image = $this->crudelyCropTo($file, $x, $min_y, $max_y); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'profile-'.$file->getName(), )); } public function executePreviewTransform( PhabricatorFile $file, $size) { $image = $this->generatePreview($file, $size); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'preview-'.$file->getName(), )); } public function executeConpherenceTransform( PhabricatorFile $file, $top, $left, $width, $height) { $image = $this->crasslyCropTo( $file, $top, $left, $width, $height); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'conpherence-'.$file->getName(), )); } private function crudelyCropTo(PhabricatorFile $file, $x, $min_y, $max_y) { $data = $file->loadFileData(); $img = imagecreatefromstring($data); $sx = imagesx($img); $sy = imagesy($img); $scaled_y = ($x / $sx) * $sy; if ($scaled_y > $max_y) { // This image is very tall and thin. $scaled_y = $max_y; } else if ($scaled_y < $min_y) { // This image is very short and wide. $scaled_y = $min_y; } $cropped = $this->applyScaleWithImagemagick($file, $x, $scaled_y); if ($cropped != null) { return $cropped; } $img = $this->applyScaleTo( $file, $x, $scaled_y); return $this->saveImageDataInAnyFormat($img, $file->getMimeType()); } private function crasslyCropTo(PhabricatorFile $file, $top, $left, $w, $h) { $data = $file->loadFileData(); $src = imagecreatefromstring($data); $dst = $this->getBlankDestinationFile($w, $h); $scale = self::getScaleForCrop($file, $w, $h); $orig_x = $left / $scale; $orig_y = $top / $scale; $orig_w = $w / $scale; $orig_h = $h / $scale; imagecopyresampled( $dst, $src, 0, 0, $orig_x, $orig_y, $w, $h, $orig_w, $orig_h); return $this->saveImageDataInAnyFormat($dst, $file->getMimeType()); } /** * Very crudely scale an image up or down to an exact size. */ private function crudelyScaleTo(PhabricatorFile $file, $dx, $dy) { $scaled = $this->applyScaleWithImagemagick($file, $dx, $dy); if ($scaled != null) { return $scaled; } $dst = $this->applyScaleTo($file, $dx, $dy); return $this->saveImageDataInAnyFormat($dst, $file->getMimeType()); } private function getBlankDestinationFile($dx, $dy) { $dst = imagecreatetruecolor($dx, $dy); imagesavealpha($dst, true); imagefill($dst, 0, 0, imagecolorallocatealpha($dst, 255, 255, 255, 127)); return $dst; } private function applyScaleTo(PhabricatorFile $file, $dx, $dy) { $data = $file->loadFileData(); $src = imagecreatefromstring($data); $x = imagesx($src); $y = imagesy($src); $scale = min(($dx / $x), ($dy / $y), 1); $sdx = $scale * $x; $sdy = $scale * $y; $dst = $this->getBlankDestinationFile($dx, $dy); imagesavealpha($dst, true); imagefill($dst, 0, 0, imagecolorallocatealpha($dst, 255, 255, 255, 127)); imagecopyresampled( $dst, $src, ($dx - $sdx) / 2, ($dy - $sdy) / 2, 0, 0, $sdx, $sdy, $x, $y); return $dst; } public static function getPreviewDimensions(PhabricatorFile $file, $size) { - $data = $file->loadFileData(); - $src = imagecreatefromstring($data); + $metadata = $file->getMetadata(); + $x = idx($metadata, PhabricatorFile::METADATA_IMAGE_WIDTH); + $y = idx($metadata, PhabricatorFile::METADATA_IMAGE_HEIGHT); - $x = imagesx($src); - $y = imagesy($src); + if (!$x || !$y) { + $data = $file->loadFileData(); + $src = imagecreatefromstring($data); + + $x = imagesx($src); + $y = imagesy($src); + } $scale = min($size / $x, $size / $y, 1); $dx = max($size / 4, $scale * $x); $dy = max($size / 4, $scale * $y); $sdx = $scale * $x; $sdy = $scale * $y; return array( 'x' => $x, 'y' => $y, 'dx' => $dx, 'dy' => $dy, 'sdx' => $sdx, 'sdy' => $sdy ); } public static function getScaleForCrop( PhabricatorFile $file, $des_width, $des_height) { $metadata = $file->getMetadata(); $width = $metadata[PhabricatorFile::METADATA_IMAGE_WIDTH]; $height = $metadata[PhabricatorFile::METADATA_IMAGE_HEIGHT]; if ($height < $des_height) { $scale = $height / $des_height; } else if ($width < $des_width) { $scale = $width / $des_width; } else { $scale_x = $des_width / $width; $scale_y = $des_height / $height; $scale = max($scale_x, $scale_y); } return $scale; } private function generatePreview(PhabricatorFile $file, $size) { $data = $file->loadFileData(); $src = imagecreatefromstring($data); $dimensions = self::getPreviewDimensions($file, $size); $x = $dimensions['x']; $y = $dimensions['y']; $dx = $dimensions['dx']; $dy = $dimensions['dy']; $sdx = $dimensions['sdx']; $sdy = $dimensions['sdy']; $dst = $this->getBlankDestinationFile($dx, $dy); imagecopyresampled( $dst, $src, ($dx - $sdx) / 2, ($dy - $sdy) / 2, 0, 0, $sdx, $sdy, $x, $y); return $this->saveImageDataInAnyFormat($dst, $file->getMimeType()); } private function applyMemeTo( PhabricatorFile $file, $upper_text, $lower_text) { $data = $file->loadFileData(); $img = imagecreatefromstring($data); $phabricator_root = dirname(phutil_get_library_root('phabricator')); $font_root = $phabricator_root.'/resources/font/'; $font_path = $font_root.'tuffy.ttf'; if (Filesystem::pathExists($font_root.'impact.ttf')) { $font_path = $font_root.'impact.ttf'; } $text_color = imagecolorallocate($img, 255, 255, 255); $border_color = imagecolorallocatealpha($img, 0, 0, 0, 110); $border_width = 4; $font_max = 200; $font_min = 5; for ($i = $font_max; $i > $font_min; $i--) { $fit = $this->doesTextBoundingBoxFitInImage( $img, $upper_text, $i, $font_path); if ($fit['doesfit']) { $x = ($fit['imgwidth'] - $fit['txtwidth']) / 2; $y = $fit['txtheight'] + 10; $this->makeImageWithTextBorder($img, $i, $x, $y, $text_color, $border_color, $border_width, $font_path, $upper_text); break; } } for ($i = $font_max; $i > $font_min; $i--) { $fit = $this->doesTextBoundingBoxFitInImage($img, $lower_text, $i, $font_path); if ($fit['doesfit']) { $x = ($fit['imgwidth'] - $fit['txtwidth']) / 2; $y = $fit['imgheight'] - 10; $this->makeImageWithTextBorder( $img, $i, $x, $y, $text_color, $border_color, $border_width, $font_path, $lower_text); break; } } return $this->saveImageDataInAnyFormat($img, $file->getMimeType()); } private function makeImageWithTextBorder($img, $font_size, $x, $y, $color, $stroke_color, $bw, $font, $text) { $angle = 0; $bw = abs($bw); for ($c1 = $x - $bw; $c1 <= $x + $bw; $c1++) { for ($c2 = $y - $bw; $c2 <= $y + $bw; $c2++) { if (!(($c1 == $x - $bw || $x + $bw) && $c2 == $y - $bw || $c2 == $y + $bw)) { $bg = imagettftext($img, $font_size, $angle, $c1, $c2, $stroke_color, $font, $text); } } } imagettftext($img, $font_size, $angle, $x , $y, $color , $font, $text); } private function doesTextBoundingBoxFitInImage($img, $text, $font_size, $font_path) { // Default Angle = 0 $angle = 0; $bbox = imagettfbbox($font_size, $angle, $font_path, $text); $text_height = abs($bbox[3] - $bbox[5]); $text_width = abs($bbox[0] - $bbox[2]); return array( "doesfit" => ($text_height * 1.05 <= imagesy($img) / 2 && $text_width * 1.05 <= imagesx($img)), "txtwidth" => $text_width, "txtheight" => $text_height, "imgwidth" => imagesx($img), "imgheight" => imagesy($img), ); } private function saveImageDataInAnyFormat($data, $preferred_mime = '') { switch ($preferred_mime) { case 'image/gif': // Gif doesn't support true color case 'image/png': if (function_exists('imagepng')) { ob_start(); imagepng($data); return ob_get_clean(); } break; } $img = null; if (function_exists('imagejpeg')) { ob_start(); imagejpeg($data); $img = ob_get_clean(); } else if (function_exists('imagepng')) { ob_start(); imagepng($data); $img = ob_get_clean(); } else if (function_exists('imagegif')) { ob_start(); imagegif($data); $img = ob_get_clean(); } else { throw new Exception("No image generation functions exist!"); } return $img; } private function applyScaleWithImagemagick(PhabricatorFile $file, $dx, $dy) { $img_type = $file->getMimeType(); $imagemagick = PhabricatorEnv::getEnvConfig('files.enable-imagemagick'); if ($img_type != 'image/gif' || $imagemagick == false) { return null; } $data = $file->loadFileData(); $src = imagecreatefromstring($data); $x = imagesx($src); $y = imagesy($src); $scale = min(($dx / $x), ($dy / $y), 1); $sdx = $scale * $x; $sdy = $scale * $y; $input = new TempFile(); Filesystem::writeFile($input, $data); $resized = new TempFile(); list($err) = exec_manual( 'convert %s -coalesce -resize %sX%s\! %s' , $input, $sdx, $sdy, $resized); if (!$err) { $new_data = Filesystem::readFile($resized); return $new_data; } else { return null; } } } diff --git a/src/applications/files/controller/PhabricatorFileTransformController.php b/src/applications/files/controller/PhabricatorFileTransformController.php index 90f926439a..3cf5adeece 100644 --- a/src/applications/files/controller/PhabricatorFileTransformController.php +++ b/src/applications/files/controller/PhabricatorFileTransformController.php @@ -1,146 +1,149 @@ <?php final class PhabricatorFileTransformController extends PhabricatorFileController { private $transform; private $phid; private $key; public function willProcessRequest(array $data) { $this->transform = $data['transform']; $this->phid = $data['phid']; $this->key = $data['key']; } public function shouldRequireLogin() { return false; } public function processRequest() { $file = id(new PhabricatorFile())->loadOneWhere('phid = %s', $this->phid); if (!$file) { return new Aphront404Response(); } if (!$file->validateSecretKey($this->key)) { return new Aphront403Response(); } $xform = id(new PhabricatorTransformedFile()) ->loadOneWhere( 'originalPHID = %s AND transform = %s', $this->phid, $this->transform); if ($xform) { return $this->buildTransformedFileResponse($xform); } $type = $file->getMimeType(); if (!$file->isViewableInBrowser() || !$file->isTransformableImage()) { return $this->buildDefaultTransformation($file); } // We're essentially just building a cache here and don't need CSRF // protection. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); switch ($this->transform) { case 'thumb-220x165': $xformed_file = $this->executeThumbTransform($file, 220, 165); break; + case 'preview-140': + $xformed_file = $this->executePreviewTransform($file, 140); + break; case 'preview-220': $xformed_file = $this->executePreviewTransform($file, 220); break; case 'thumb-160x120': $xformed_file = $this->executeThumbTransform($file, 160, 120); break; case 'thumb-60x45': $xformed_file = $this->executeThumbTransform($file, 60, 45); break; default: return new Aphront400Response(); } if (!$xformed_file) { return new Aphront400Response(); } $xform = new PhabricatorTransformedFile(); $xform->setOriginalPHID($this->phid); $xform->setTransform($this->transform); $xform->setTransformedPHID($xformed_file->getPHID()); $xform->save(); return $this->buildTransformedFileResponse($xform); } private function buildDefaultTransformation(PhabricatorFile $file) { static $regexps = array( '@application/zip@' => 'zip', '@image/@' => 'image', '@application/pdf@' => 'pdf', '@.*@' => 'default', ); $type = $file->getMimeType(); $prefix = 'default'; foreach ($regexps as $regexp => $implied_prefix) { if (preg_match($regexp, $type)) { $prefix = $implied_prefix; break; } } switch ($this->transform) { case 'thumb-160x120': $suffix = '160x120'; break; case 'thumb-60x45': $suffix = '60x45'; break; default: throw new Exception("Unsupported transformation type!"); } $path = celerity_get_resource_uri( "/rsrc/image/icon/fatcow/thumbnails/{$prefix}{$suffix}.png"); return id(new AphrontRedirectResponse()) ->setURI($path); } private function buildTransformedFileResponse( PhabricatorTransformedFile $xform) { $file = id(new PhabricatorFile())->loadOneWhere( 'phid = %s', $xform->getTransformedPHID()); if ($file) { $uri = $file->getBestURI(); } else { $bad_phid = $xform->getTransformedPHID(); throw new Exception( "Unable to load file with phid {$bad_phid}." ); } // TODO: We could just delegate to the file view controller instead, // which would save the client a roundtrip, but is slightly more complex. return id(new AphrontRedirectResponse())->setURI($uri); } private function executePreviewTransform(PhabricatorFile $file, $size) { $xformer = new PhabricatorImageTransformer(); return $xformer->executePreviewTransform($file, $size); } private function executeThumbTransform(PhabricatorFile $file, $x, $y) { $xformer = new PhabricatorImageTransformer(); return $xformer->executeThumbTransform($file, $x, $y); } } diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php index 1ddbb34c7a..1f3c46e20d 100644 --- a/src/applications/files/storage/PhabricatorFile.php +++ b/src/applications/files/storage/PhabricatorFile.php @@ -1,656 +1,662 @@ <?php final class PhabricatorFile extends PhabricatorFileDAO implements 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 $storageEngine; protected $storageFormat; protected $storageHandle; protected $ttl; 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( PhabricatorPHIDConstants::PHID_TYPE_FILE); } 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->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); 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) { return preg_replace('/[^a-zA-Z0-9.~_-]/', '_', $file_name); } public function delete() { // delete all records of this file in transformedfile $trans_files = id(new PhabricatorTransformedFile())->loadAllWhere( 'TransformedPHID = %s', $this->getPHID()); $this->openTransaction(); foreach ($trans_files as $trans_file) { $trans_file->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 (count($other_file) == 0) { $engine = $this->instantiateStorageEngine(); $engine->deleteFile($this->getStorageHandle()); } 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 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 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 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 validateSecretKey($key) { return ($key == $this->getSecretKey()); } public function save() { if (!$this->getSecretKey()) { $this->setSecretKey($this->generateSecretKey()); } return parent::save(); } 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 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; } /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { // TODO: Implement proper per-object policies. return PhabricatorPolicies::POLICY_USER; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } } diff --git a/src/applications/pholio/view/PholioMockImagesView.php b/src/applications/pholio/view/PholioMockImagesView.php index b473e351b7..efa3917d14 100644 --- a/src/applications/pholio/view/PholioMockImagesView.php +++ b/src/applications/pholio/view/PholioMockImagesView.php @@ -1,97 +1,110 @@ <?php final class PholioMockImagesView extends AphrontView { private $mock; public function setMock(PholioMock $mock) { $this->mock = $mock; return $this; } public function render() { if (!$this->mock) { throw new Exception("Call setMock() before render()!"); } $main_image_id = celerity_generate_unique_node_id(); require_celerity_resource('javelin-behavior-pholio-mock-view'); $config = array( 'mainID' => $main_image_id, 'mockID' => $this->mock->getID()); Javelin::initBehavior('pholio-mock-view', $config); - $mockview = ""; + $mockview = ''; $main_image = head($this->mock->getImages()); $main_image_tag = javelin_tag( 'img', array( 'id' => $main_image_id, 'src' => $main_image->getFile()->getBestURI(), 'sigil' => 'mock-image', 'class' => 'pholio-mock-image', 'meta' => array( 'fullSizeURI' => $main_image->getFile()->getBestURI(), 'imageID' => $main_image->getID(), ), )); $main_image_tag = javelin_tag( 'div', array( 'id' => 'mock-wrapper', 'sigil' => 'mock-wrapper', 'class' => 'pholio-mock-wrapper' ), $main_image_tag); $inline_comments_holder = javelin_tag( 'div', array( 'id' => 'mock-inline-comments', 'sigil' => 'mock-inline-comments', 'class' => 'pholio-mock-inline-comments' ), - ""); + ''); $mockview[] = phutil_tag( 'div', array( 'class' => 'pholio-mock-image-container', 'id' => 'pholio-mock-image-container' ), array($main_image_tag, $inline_comments_holder)); if (count($this->mock->getImages()) > 1) { $thumbnails = array(); foreach ($this->mock->getImages() as $image) { $thumbfile = $image->getFile(); - $tag = javelin_tag( + $dimensions = PhabricatorImageTransformer::getPreviewDimensions( + $thumbfile, + 140); + + $tag = phutil_tag( 'img', array( - 'src' => $thumbfile->getThumb160x120URI(), - 'sigil' => 'mock-thumbnail', + 'width' => $dimensions['sdx'], + 'height' => $dimensions['sdy'], + 'src' => $thumbfile->getPreview140URI(), 'class' => 'pholio-mock-carousel-thumbnail', + 'style' => 'top: '.floor((140 - $dimensions['sdy'] ) / 2).'px', + )); + + $thumbnails[] = javelin_tag( + 'div', + array( + 'sigil' => 'mock-thumbnail', + 'class' => 'pholio-mock-carousel-thumb-item', 'meta' => array( 'fullSizeURI' => $thumbfile->getBestURI(), 'imageID' => $image->getID() ), - )); - $thumbnails[] = $tag; + ), + $tag); } $mockview[] = phutil_tag( 'div', array( 'class' => 'pholio-mock-carousel', ), $thumbnails); } return $this->renderSingleView($mockview); } } diff --git a/webroot/rsrc/css/application/pholio/pholio.css b/webroot/rsrc/css/application/pholio/pholio.css index c9438de7b4..9e851b2cd2 100644 --- a/webroot/rsrc/css/application/pholio/pholio.css +++ b/webroot/rsrc/css/application/pholio/pholio.css @@ -1,78 +1,96 @@ /** * @provides pholio-css */ .pholio-mock-image-container { background-color: #282828; text-align: center; vertical-align: middle; } .pholio-mock-carousel { - background-color: #282828; + background-color: #202020; text-align: center; + border-top: 1px solid #101010; } -.pholio-mock-carousel-thumbnail { - margin-right: 5px; +.pholio-mock-carousel-thumb-item { display: inline-block; cursor: pointer; + width: 140px; + height: 140px; + padding: 5px; + margin: 3px; + background: #282828; + vertical-align: middle; + border: 1px solid #383838; + position: relative; +} + +.device-desktop .pholio-mock-carousel-thumb-item:hover { + background: #383838; + border-color: #686868; +} + +.pholio-mock-carousel-thumbnail { + margin: auto; + position: relative; } .pholio-mock-image { display: inline-block; } .pholio-mock-select-border { position: absolute; background: #ffffff; opacity: 0.25; box-sizing: border-box; border: 1px solid #000000; } .pholio-mock-select-fill { position: absolute; border: 1px dashed #ffffff; box-sizing: border-box; } .pholio-mock-wrapper { position: relative; display: inline-block; cursor: crosshair; padding: 0px; margin: 10px 0px; } .pholio-mock-inline-comments { display: inline-block; margin-left: 10px; text-align: left; padding-bottom: 10px; } .pholio-inline-comment { border: 1px solid #aa8; background: #f9f9f1; margin-bottom: 2px; padding: 8px 10px; } .pholio-inline-comment-header { border-bottom: 1px solid #cca; color: #333; font-weight: bold; padding-bottom: 6px; margin-bottom: 4px; } .pholio-mock-inline-comment-highlight { background-color: #F0B160; } .pholio-inline-head-links a { font-weight: normal; margin-left: 5px; }