diff --git a/src/applications/files/PhabricatorImageTransformer.php b/src/applications/files/PhabricatorImageTransformer.php index 95cf1fac7a..0bbcdb9b31 100644 --- a/src/applications/files/PhabricatorImageTransformer.php +++ b/src/applications/files/PhabricatorImageTransformer.php @@ -1,661 +1,591 @@ applyMemeToFile($file, $upper_text, $lower_text); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'meme-'.$file->getName(), 'ttl' => time() + 60 * 60 * 24, 'canCDN' => true, )); } public function executeThumbTransform( PhabricatorFile $file, $x, $y) { $image = $this->crudelyScaleTo($file, $x, $y); return PhabricatorFile::newFromFileData( $image, array( 'name' => 'thumb-'.$file->getName(), 'canCDN' => true, )); } 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(), 'canCDN' => true, )); } - public function executePreviewTransform( - PhabricatorFile $file, - $size) { - - $image = $this->generatePreview($file, $size); - - return PhabricatorFile::newFromFileData( - $image, - array( - 'name' => 'preview-'.$file->getName(), - 'canCDN' => true, - )); - } - 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(), 'canCDN' => true, )); } 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 self::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 self::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 self::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) { - $metadata = $file->getMetadata(); - $x = idx($metadata, PhabricatorFile::METADATA_IMAGE_WIDTH); - $y = idx($metadata, PhabricatorFile::METADATA_IMAGE_HEIGHT); - - 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 self::saveImageDataInAnyFormat($dst, $file->getMimeType()); - } - private function applyMemeToFile( PhabricatorFile $file, $upper_text, $lower_text) { $data = $file->loadFileData(); $img_type = $file->getMimeType(); $imagemagick = PhabricatorEnv::getEnvConfig('files.enable-imagemagick'); if ($img_type != 'image/gif' || $imagemagick == false) { return $this->applyMemeTo( $data, $upper_text, $lower_text, $img_type); } $data = $file->loadFileData(); $input = new TempFile(); Filesystem::writeFile($input, $data); list($out) = execx('convert %s info:', $input); $split = phutil_split_lines($out); if (count($split) > 1) { return $this->applyMemeWithImagemagick( $input, $upper_text, $lower_text, count($split), $img_type); } else { return $this->applyMemeTo($data, $upper_text, $lower_text, $img_type); } } private function applyMemeTo( $data, $upper_text, $lower_text, $mime_type) { $img = imagecreatefromstring($data); // Some PNGs have color palettes, and allocating the dark border color // fails and gives us whatever's first in the color table. Copy the image // to a fresh truecolor canvas before working with it. $truecolor = imagecreatetruecolor(imagesx($img), imagesy($img)); imagecopy($truecolor, $img, 0, 0, 0, 0, imagesx($img), imagesy($img)); $img = $truecolor; $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 self::saveImageDataInAnyFormat($img, $mime_type); } 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 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); if (self::isEnormousGIF($x, $y)) { return null; } $scale = min(($dx / $x), ($dy / $y), 1); $sdx = $scale * $x; $sdy = $scale * $y; $input = new TempFile(); Filesystem::writeFile($input, $data); $resized = new TempFile(); $future = new ExecFuture( 'convert %s -coalesce -resize %sX%s%s %s', $input, $sdx, $sdy, '!', $resized); // Don't spend more than 10 seconds resizing; just fail if it takes longer // than that. $future->setTimeout(10)->resolvex(); return Filesystem::readFile($resized); } private function applyMemeWithImagemagick( $input, $above, $below, $count, $img_type) { $output = new TempFile(); $future = new ExecFuture( 'convert %s -coalesce +adjoin %s_%s', $input, $input, '%09d'); $future->setTimeout(10)->resolvex(); $output_files = array(); for ($ii = 0; $ii < $count; $ii++) { $frame_name = sprintf('%s_%09d', $input, $ii); $output_name = sprintf('%s_%09d', $output, $ii); $output_files[] = $output_name; $frame_data = Filesystem::readFile($frame_name); $memed_frame_data = $this->applyMemeTo( $frame_data, $above, $below, $img_type); Filesystem::writeFile($output_name, $memed_frame_data); } $future = new ExecFuture('convert -loop 0 %Ls %s', $output_files, $output); $future->setTimeout(10)->resolvex(); return Filesystem::readFile($output); } /* -( Detecting Enormous Files )------------------------------------------- */ /** * Determine if an image is enormous (too large to transform). * * Attackers can perform a denial of service attack by uploading highly * compressible images with enormous dimensions but a very small filesize. * Transforming them (e.g., into thumbnails) may consume huge quantities of * memory and CPU relative to the resources required to transmit the file. * * In general, we respond to these images by declining to transform them, and * using a default thumbnail instead. * * @param int Width of the image, in pixels. * @param int Height of the image, in pixels. * @return bool True if this image is enormous (too large to transform). * @task enormous */ public static function isEnormousImage($x, $y) { // This is just a sanity check, but if we don't have valid dimensions we // shouldn't be trying to transform the file. if (($x <= 0) || ($y <= 0)) { return true; } return ($x * $y) > (4096 * 4096); } /** * Determine if a GIF is enormous (too large to transform). * * For discussion, see @{method:isEnormousImage}. We need to be more * careful about GIFs, because they can also have a large number of frames * despite having a very small filesize. We're more conservative about * calling GIFs enormous than about calling images in general enormous. * * @param int Width of the GIF, in pixels. * @param int Height of the GIF, in pixels. * @return bool True if this image is enormous (too large to transform). * @task enormous */ public static function isEnormousGIF($x, $y) { if (self::isEnormousImage($x, $y)) { return true; } return ($x * $y) > (800 * 800); } /* -( Saving Image Data )-------------------------------------------------- */ /** * Save an image resource to a string representation suitable for storage or * transmission as an image file. * * Optionally, you can specify a preferred MIME type like `"image/png"`. * Generally, you should specify the MIME type of the original file if you're * applying file transformations. The MIME type may not be honored if * Phabricator can not encode images in the given format (based on available * extensions), but can save images in another format. * * @param resource GD image resource. * @param string? Optionally, preferred mime type. * @return string Bytes of an image file. * @task save */ public static function saveImageDataInAnyFormat($data, $preferred_mime = '') { $preferred = null; switch ($preferred_mime) { case 'image/gif': $preferred = self::saveImageDataAsGIF($data); break; case 'image/png': $preferred = self::saveImageDataAsPNG($data); break; } if ($preferred !== null) { return $preferred; } $data = self::saveImageDataAsJPG($data); if ($data !== null) { return $data; } $data = self::saveImageDataAsPNG($data); if ($data !== null) { return $data; } $data = self::saveImageDataAsGIF($data); if ($data !== null) { return $data; } throw new Exception(pht('Failed to save image data into any format.')); } /** * Save an image in PNG format, returning the file data as a string. * * @param resource GD image resource. * @return string|null PNG file as a string, or null on failure. * @task save */ private static function saveImageDataAsPNG($image) { if (!function_exists('imagepng')) { return null; } ob_start(); $result = imagepng($image, null, 9); $output = ob_get_clean(); if (!$result) { return null; } return $output; } /** * Save an image in GIF format, returning the file data as a string. * * @param resource GD image resource. * @return string|null GIF file as a string, or null on failure. * @task save */ private static function saveImageDataAsGIF($image) { if (!function_exists('imagegif')) { return null; } ob_start(); $result = imagegif($image); $output = ob_get_clean(); if (!$result) { return null; } return $output; } /** * Save an image in JPG format, returning the file data as a string. * * @param resource GD image resource. * @return string|null JPG file as a string, or null on failure. * @task save */ private static function saveImageDataAsJPG($image) { if (!function_exists('imagejpeg')) { return null; } ob_start(); $result = imagejpeg($image); $output = ob_get_clean(); if (!$result) { return null; } return $output; } } diff --git a/src/applications/files/controller/PhabricatorFileTransformController.php b/src/applications/files/controller/PhabricatorFileTransformController.php index dd7c355061..ae31990c06 100644 --- a/src/applications/files/controller/PhabricatorFileTransformController.php +++ b/src/applications/files/controller/PhabricatorFileTransformController.php @@ -1,194 +1,180 @@ getViewer(); // NOTE: This is a public/CDN endpoint, and permission to see files is // controlled by knowing the secret key, not by authentication. $is_regenerate = $request->getBool('regenerate'); $source_phid = $request->getURIData('phid'); $file = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($source_phid)) ->executeOne(); if (!$file) { return new Aphront404Response(); } $secret_key = $request->getURIData('key'); if (!$file->validateSecretKey($secret_key)) { return new Aphront403Response(); } $transform = $request->getURIData('transform'); $xform = id(new PhabricatorTransformedFile()) ->loadOneWhere( 'originalPHID = %s AND transform = %s', $source_phid, $transform); if ($xform) { if ($is_regenerate) { $this->destroyTransform($xform); } else { return $this->buildTransformedFileResponse($xform); } } $type = $file->getMimeType(); if (!$file->isViewableInBrowser() || !$file->isTransformableImage()) { return $this->buildDefaultTransformation($file, $transform); } // We're essentially just building a cache here and don't need CSRF // protection. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $xformed_file = null; $xforms = PhabricatorFileTransform::getAllTransforms(); if (isset($xforms[$transform])) { $xform = $xforms[$transform]; if ($xform->canApplyTransform($file)) { try { $xformed_file = $xforms[$transform]->applyTransform($file); } catch (Exception $ex) { // In normal transform mode, we ignore failures and generate a // default transform below. If we're explicitly regenerating the // thumbnail, rethrow the exception. if ($is_regenerate) { throw $ex; } } } if (!$xformed_file) { $xformed_file = $xform->getDefaultTransform($file); } } if (!$xformed_file) { switch ($transform) { case 'thumb-profile': $xformed_file = $this->executeThumbTransform($file, 50, 50); break; case 'thumb-280x210': $xformed_file = $this->executeThumbTransform($file, 280, 210); break; - case 'preview-100': - $xformed_file = $this->executePreviewTransform($file, 100); - break; - case 'preview-220': - $xformed_file = $this->executePreviewTransform($file, 220); - break; default: return new Aphront400Response(); } } if (!$xformed_file) { return new Aphront400Response(); } $xform = id(new PhabricatorTransformedFile()) ->setOriginalPHID($source_phid) ->setTransform($transform) ->setTransformedPHID($xformed_file->getPHID()) ->save(); return $this->buildTransformedFileResponse($xform); } private function buildDefaultTransformation( PhabricatorFile $file, $transform) { 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 ($transform) { case 'thumb-280x210': $suffix = '280x210'; break; - case 'preview-100': - $suffix = '.p100'; - 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 PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($xform->getTransformedPHID())) ->executeOne(); if (!$file) { return new Aphront404Response(); } // TODO: We could just delegate to the file view controller instead, // which would save the client a roundtrip, but is slightly more complex. return $file->getRedirectResponse(); } - 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); } private function destroyTransform(PhabricatorTransformedFile $xform) { $file = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($xform->getTransformedPHID())) ->executeOne(); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); if (!$file) { $xform->delete(); } else { $engine = new PhabricatorDestructionEngine(); $engine->destroyObject($file); } unset($unguarded); } } diff --git a/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php b/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php index 87d611c0ef..5ffb70be7f 100644 --- a/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php +++ b/src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php @@ -1,228 +1,234 @@ getEngine(); $viewer = $engine->getConfig('viewer'); $objects = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withIDs($ids) ->execute(); $phids_key = self::KEY_EMBED_FILE_PHIDS; $phids = $engine->getTextMetadata($phids_key, array()); foreach (mpull($objects, 'getPHID') as $phid) { $phids[] = $phid; } $engine->setTextMetadata($phids_key, $phids); return $objects; } protected function renderObjectEmbed( $object, PhabricatorObjectHandle $handle, $options) { $options = $this->getFileOptions($options) + array( 'name' => $object->getName(), ); $is_viewable_image = $object->isViewableImage(); $is_audio = $object->isAudio(); $force_link = ($options['layout'] == 'link'); $options['viewable'] = ($is_viewable_image || $is_audio); if ($is_viewable_image && !$force_link) { return $this->renderImageFile($object, $handle, $options); } else if ($is_audio && !$force_link) { return $this->renderAudioFile($object, $handle, $options); } else { return $this->renderFileLink($object, $handle, $options); } } private function getFileOptions($option_string) { $options = array( 'size' => null, 'layout' => 'left', 'float' => false, 'width' => null, 'height' => null, 'alt' => null, ); if ($option_string) { $option_string = trim($option_string, ', '); $parser = new PhutilSimpleOptions(); $options = $parser->parse($option_string) + $options; } return $options; } private function renderImageFile( PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { require_celerity_resource('lightbox-attachment-css'); $attrs = array(); $image_class = null; $use_size = true; if (!$options['size']) { $width = $this->parseDimension($options['width']); $height = $this->parseDimension($options['height']); if ($width || $height) { $use_size = false; $attrs += array( 'src' => $file->getBestURI(), 'width' => $width, 'height' => $height, ); } } if ($use_size) { switch ((string)$options['size']) { case 'full': $attrs += array( 'src' => $file->getBestURI(), 'height' => $file->getImageHeight(), 'width' => $file->getImageWidth(), ); $image_class = 'phabricator-remarkup-embed-image-full'; break; case 'thumb': default: - $attrs['src'] = $file->getPreview220URI(); - $dimensions = - PhabricatorImageTransformer::getPreviewDimensions($file, 220); - $attrs['width'] = $dimensions['dx']; - $attrs['height'] = $dimensions['dy']; + $preview_key = PhabricatorFileThumbnailTransform::TRANSFORM_PREVIEW; + $xform = PhabricatorFileTransform::getTransformByKey($preview_key); + $attrs['src'] = $file->getURIForTransform($xform); + + $dimensions = $xform->getTransformedDimensions($file); + if ($dimensions) { + list($x, $y) = $dimensions; + $attrs['width'] = $x; + $attrs['height'] = $y; + } + $image_class = 'phabricator-remarkup-embed-image'; break; } } if (isset($options['alt'])) { $attrs['alt'] = $options['alt']; } $img = phutil_tag('img', $attrs); $embed = javelin_tag( 'a', array( 'href' => $file->getBestURI(), 'class' => $image_class, 'sigil' => 'lightboxable', 'meta' => array( 'phid' => $file->getPHID(), 'uri' => $file->getBestURI(), 'dUri' => $file->getDownloadURI(), 'viewable' => true, ), ), $img); switch ($options['layout']) { case 'right': case 'center': case 'inline': case 'left': $layout_class = 'phabricator-remarkup-embed-layout-'.$options['layout']; break; default: $layout_class = 'phabricator-remarkup-embed-layout-left'; break; } if ($options['float']) { switch ($options['layout']) { case 'center': case 'inline': break; case 'right': $layout_class .= ' phabricator-remarkup-embed-float-right'; break; case 'left': default: $layout_class .= ' phabricator-remarkup-embed-float-left'; break; } } return phutil_tag( ($options['layout'] == 'inline' ? 'span' : 'div'), array( 'class' => $layout_class, ), $embed); } private function renderAudioFile( PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { if (idx($options, 'autoplay')) { $preload = 'auto'; $autoplay = 'autoplay'; } else { $preload = 'none'; $autoplay = null; } return $this->newTag( 'audio', array( 'controls' => 'controls', 'preload' => $preload, 'autoplay' => $autoplay, 'loop' => idx($options, 'loop') ? 'loop' : null, ), $this->newTag( 'source', array( 'src' => $file->getBestURI(), 'type' => $file->getMimeType(), ))); } private function renderFileLink( PhabricatorFile $file, PhabricatorObjectHandle $handle, array $options) { return id(new PhabricatorFileLinkView()) ->setFilePHID($file->getPHID()) ->setFileName($this->assertFlatText($options['name'])) ->setFileDownloadURI($file->getDownloadURI()) ->setFileViewURI($file->getBestURI()) ->setFileViewable((bool)$options['viewable']); } private function parseDimension($string) { $string = trim($string); if (preg_match('/^(?:\d*\\.)?\d+%?$/', $string)) { return $string; } return null; } } diff --git a/src/applications/files/storage/PhabricatorFile.php b/src/applications/files/storage/PhabricatorFile.php index c4aa7dc42c..9ff75cce17 100644 --- a/src/applications/files/storage/PhabricatorFile.php +++ b/src/applications/files/storage/PhabricatorFile.php @@ -1,1384 +1,1376 @@ setViewer(PhabricatorUser::getOmnipotentUser()) ->withClasses(array('PhabricatorFilesApplication')) ->executeOne(); $view_policy = $app->getPolicy( FilesDefaultViewCapability::CAPABILITY); return id(new PhabricatorFile()) ->setViewPolicy($view_policy) ->setIsPartial(0) ->attachOriginalFile(null) ->attachObjects(array()) ->attachObjectPHIDs(array()); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text255?', 'mimeType' => 'text255?', 'byteSize' => 'uint64', 'storageEngine' => 'text32', 'storageFormat' => 'text32', 'storageHandle' => 'text255', 'authorPHID' => 'phid?', 'secretKey' => 'bytes20?', 'contentHash' => 'bytes40?', 'ttl' => 'epoch?', 'isExplicitUpload' => 'bool?', 'mailKey' => 'bytes20', 'isPartial' => 'bool', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'authorPHID' => array( 'columns' => array('authorPHID'), ), 'contentHash' => array( 'columns' => array('contentHash'), ), 'key_ttl' => array( 'columns' => array('ttl'), ), 'key_dateCreated' => array( 'columns' => array('dateCreated'), ), 'key_partial' => array( 'columns' => array('authorPHID', 'isPartial'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorFileFilePHIDType::TYPECONST); } public function save() { if (!$this->getSecretKey()) { $this->setSecretKey($this->generateSecretKey()); } if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); } return parent::save(); } public function getMonogram() { return 'F'.$this->getID(); } 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.'); } return $file_data; } public static function newFromPHPUpload($spec, array $params = array()) { $file_data = self::readUploadedFileData($spec); $file_name = nonempty( idx($params, 'name'), idx($spec, 'name')); $params = array( 'name' => $file_name, ) + $params; return self::newFromFileData($file_data, $params); } public static function newFromXHRUpload($data, array $params = array()) { return self::newFromFileData($data, $params); } /** * 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', 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, array $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_byte_size = $file->getByteSize(); $copy_of_mime_type = $file->getMimeType(); $new_file = PhabricatorFile::initializeNewFile(); $new_file->setByteSize($copy_of_byte_size); $new_file->setContentHash($hash); $new_file->setStorageEngine($copy_of_storage_engine); $new_file->setStorageHandle($copy_of_storage_handle); $new_file->setStorageFormat($copy_of_storage_format); $new_file->setMimeType($copy_of_mime_type); $new_file->copyDimensions($file); $new_file->readPropertiesFromParameters($params); $new_file->save(); return $new_file; } return $file; } public static function newChunkedFile( PhabricatorFileStorageEngine $engine, $length, array $params) { $file = PhabricatorFile::initializeNewFile(); $file->setByteSize($length); // TODO: We might be able to test the first chunk in order to figure // this out more reliably, since MIME detection usually examines headers. // However, enormous files are probably always either actually raw data // or reasonable to treat like raw data. $file->setMimeType('application/octet-stream'); $chunked_hash = idx($params, 'chunkedHash'); if ($chunked_hash) { $file->setContentHash($chunked_hash); } else { // See PhabricatorChunkedFileStorageEngine::getChunkedHash() for some // discussion of this. $seed = Filesystem::readRandomBytes(64); $hash = PhabricatorChunkedFileStorageEngine::getChunkedHashForInput( $seed); $file->setContentHash($hash); } $file->setStorageEngine($engine->getEngineIdentifier()); $file->setStorageHandle(PhabricatorFileChunk::newChunkHandle()); $file->setStorageFormat(self::STORAGE_FORMAT_RAW); $file->setIsPartial(1); $file->readPropertiesFromParameters($params); return $file; } private static function buildFromFileData($data, array $params = array()) { if (isset($params['storageEngines'])) { $engines = $params['storageEngines']; } else { $size = strlen($data); $engines = PhabricatorFileStorageEngine::loadStorageEngines($size); if (!$engines) { throw new Exception( pht( 'No configured storage engine can store this file. See '. '"Configuring File Storage" in the documentation for '. 'information on configuring storage engines.')); } } assert_instances_of($engines, 'PhabricatorFileStorageEngine'); if (!$engines) { throw new Exception(pht('No valid storage engines are available!')); } $file = PhabricatorFile::initializeNewFile(); $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->setByteSize(strlen($data)); $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->readPropertiesFromParameters($params); if (!$file->getMimeType()) { $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_identifier = $this->getStorageEngine(); $old_handle = $this->getStorageHandle(); $this->setStorageEngine($new_identifier); $this->setStorageHandle($new_handle); $this->save(); $this->deleteFileDataIfUnused( $old_engine, $old_identifier, $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); } /** * Download a remote resource over HTTP and save the response body as a file. * * This method respects `security.outbound-blacklist`, and protects against * HTTP redirection (by manually following "Location" headers and verifying * each destination). It does not protect against DNS rebinding. See * discussion in T6755. */ public static function newFromFileDownload($uri, array $params = array()) { $timeout = 5; $redirects = array(); $current = $uri; while (true) { try { if (count($redirects) > 10) { throw new Exception( pht('Too many redirects trying to fetch remote URI.')); } $resolved = PhabricatorEnv::requireValidRemoteURIForFetch( $current, array( 'http', 'https', )); list($resolved_uri, $resolved_domain) = $resolved; $current = new PhutilURI($current); if ($current->getProtocol() == 'http') { // For HTTP, we can use a pre-resolved URI to defuse DNS rebinding. $fetch_uri = $resolved_uri; $fetch_host = $resolved_domain; } else { // For HTTPS, we can't: cURL won't verify the SSL certificate if // the domain has been replaced with an IP. But internal services // presumably will not have valid certificates for rebindable // domain names on attacker-controlled domains, so the DNS rebinding // attack should generally not be possible anyway. $fetch_uri = $current; $fetch_host = null; } $future = id(new HTTPSFuture($fetch_uri)) ->setFollowLocation(false) ->setTimeout($timeout); if ($fetch_host !== null) { $future->addHeader('Host', $fetch_host); } list($status, $body, $headers) = $future->resolve(); if ($status->isRedirect()) { // This is an HTTP 3XX status, so look for a "Location" header. $location = null; foreach ($headers as $header) { list($name, $value) = $header; if (phutil_utf8_strtolower($name) == 'location') { $location = $value; break; } } // HTTP 3XX status with no "Location" header, just treat this like // a normal HTTP error. if ($location === null) { throw $status; } if (isset($redirects[$location])) { throw new Exception( pht( 'Encountered loop while following redirects.')); } $redirects[$location] = $location; $current = $location; // We'll fall off the bottom and go try this URI now. } else if ($status->isError()) { // This is something other than an HTTP 2XX or HTTP 3XX status, so // just bail out. throw $status; } else { // This is HTTP 2XX, so use the the response body to save the // file data. $params = $params + array( 'name' => basename($uri), ); return self::newFromFileData($body, $params); } } catch (Exception $ex) { if ($redirects) { throw new PhutilProxyException( pht( 'Failed to fetch remote URI "%s" after following %s redirect(s) '. '(%s): %s', $uri, new PhutilNumber(count($redirects)), implode(' > ', array_keys($redirects)), $ex->getMessage()), $ex); } else { throw $ex; } } } } public static function normalizeFileName($file_name) { $pattern = "@[\\x00-\\x19#%&+!~'\$\"\/=\\\\?<> ]+@"; $file_name = preg_replace($pattern, '_', $file_name); $file_name = preg_replace('@_+@', '_', $file_name); $file_name = trim($file_name, '_'); $disallowed_filenames = array( '.' => 'dot', '..' => 'dotdot', '' => 'file', ); $file_name = idx($disallowed_filenames, $file_name, $file_name); return $file_name; } public function delete() { // We want to delete all the rows which mark this file as the transformation // of some other file (since we're getting rid of it). We also delete all // the transformations of this file, so that a user who deletes an image // doesn't need to separately hunt down and delete a bunch of thumbnails and // resizes of it. $outbound_xforms = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTransforms( array( array( 'originalPHID' => $this->getPHID(), 'transform' => true, ), )) ->execute(); foreach ($outbound_xforms as $outbound_xform) { $outbound_xform->delete(); } $inbound_xforms = id(new PhabricatorTransformedFile())->loadAllWhere( 'transformedPHID = %s', $this->getPHID()); $this->openTransaction(); foreach ($inbound_xforms as $inbound_xform) { $inbound_xform->delete(); } $ret = parent::delete(); $this->saveTransaction(); $this->deleteFileDataIfUnused( $this->instantiateStorageEngine(), $this->getStorageEngine(), $this->getStorageHandle()); return $ret; } /** * Destroy stored file data if there are no remaining files which reference * it. */ public function deleteFileDataIfUnused( PhabricatorFileStorageEngine $engine, $engine_identifier, $handle) { // Check to see if any files are using storage. $usage = id(new PhabricatorFile())->loadAllWhere( 'storageEngine = %s AND storageHandle = %s LIMIT 1', $engine_identifier, $handle); // If there are no files using the storage, destroy the actual storage. if (!$usage) { try { $engine->deleteFile($handle); } catch (Exception $ex) { // In the worst case, we're leaving some data stranded in a storage // engine, which is not a big deal. phlog($ex); } } } public static function hashFileContent($data) { 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; } /** * Return an iterable which emits file content bytes. * * @param int Offset for the start of data. * @param int Offset for the end of data. * @return Iterable Iterable object which emits requested data. */ public function getFileDataIterator($begin = null, $end = null) { $engine = $this->instantiateStorageEngine(); return $engine->getFileDataIterator($this, $begin, $end); } public function getViewURI() { if (!$this->getPHID()) { throw new Exception( 'You must save a file before you can generate a view URI.'); } return $this->getCDNURI(null); } private function getCDNURI($token) { $name = self::normalizeFileName($this->getName()); $name = phutil_escape_uri($name); $parts = array(); $parts[] = 'file'; $parts[] = 'data'; // If this is an instanced install, add the instance identifier to the URI. // Instanced configurations behind a CDN may not be able to control the // request domain used by the CDN (as with AWS CloudFront). Embedding the // instance identity in the path allows us to distinguish between requests // originating from different instances but served through the same CDN. $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); if (strlen($instance)) { $parts[] = '@'.$instance; } $parts[] = $this->getSecretKey(); $parts[] = $this->getPHID(); if ($token) { $parts[] = $token; } $parts[] = $name; $path = '/'.implode('/', $parts); // If this file is only partially uploaded, we're just going to return a // local URI to make sure that Ajax works, since the page is inevitably // going to give us an error back. if ($this->getIsPartial()) { return PhabricatorEnv::getURI($path); } else { return PhabricatorEnv::getCDNURI($path); } } /** * Get the CDN URI for this file, including a one-time-use security token. * */ public function getCDNURIWithToken() { if (!$this->getPHID()) { throw new Exception( 'You must save a file before you can generate a CDN URI.'); } return $this->getCDNURI($this->generateOneTimeToken()); } public function getInfoURI() { return '/'.$this->getMonogram(); } 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 getURIForTransform(PhabricatorFileTransform $transform) { return $this->getTransformedURI($transform->getTransformKey()); } private function getTransformedURI($transform) { $parts = array(); $parts[] = 'file'; $parts[] = 'xform'; $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); if (strlen($instance)) { $parts[] = '@'.$instance; } $parts[] = $transform; $parts[] = $this->getPHID(); $parts[] = $this->getSecretKey(); $path = implode('/', $parts); $path = $path.'/'; return PhabricatorEnv::getCDNURI($path); } public function getProfileThumbURI() { return $this->getTransformedURI('thumb-profile'); } - public function getPreview100URI() { - return $this->getTransformedURI('preview-100'); - } - - public function getPreview220URI() { - return $this->getTransformedURI('preview-220'); - } - public function getThumb280x210URI() { return $this->getTransformedURI('thumb-280x210'); } 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; } public function instantiateStorageEngine() { return self::buildEngine($this->getStorageEngine()); } public static function buildEngine($engine_identifier) { $engines = self::buildAllEngines(); foreach ($engines as $engine) { if ($engine->getEngineIdentifier() == $engine_identifier) { return $engine; } } throw new Exception( "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, 'fa-file-o'); } 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; } /** * Load (or build) the {@class:PhabricatorFile} objects for builtin file * resources. The builtin mechanism allows files shipped with Phabricator * to be treated like normal files so that APIs do not need to special case * things like default images or deleted files. * * Builtins are located in `resources/builtin/` and identified by their * name. * * @param PhabricatorUser Viewing user. * @param list List of builtin file names. * @return dict Dictionary of named builtins. */ public static function loadBuiltins(PhabricatorUser $user, array $names) { $specs = array(); foreach ($names as $name) { $specs[] = array( 'originalPHID' => PhabricatorPHIDConstants::PHID_VOID, 'transform' => 'builtin:'.$name, ); } // NOTE: Anyone is allowed to access builtin files. $files = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->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), 'canCDN' => true, 'builtin' => $name, ); $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 getOriginalFile() { return $this->assertAttached($this->originalFile); } public function attachOriginalFile(PhabricatorFile $file = null) { $this->originalFile = $file; return $this; } public function getImageHeight() { if (!$this->isViewableImage()) { return null; } return idx($this->metadata, self::METADATA_IMAGE_HEIGHT); } public function getImageWidth() { if (!$this->isViewableImage()) { return null; } return idx($this->metadata, self::METADATA_IMAGE_WIDTH); } public function getCanCDN() { if (!$this->isViewableImage()) { return false; } return idx($this->metadata, self::METADATA_CAN_CDN); } public function setCanCDN($can_cdn) { $this->metadata[self::METADATA_CAN_CDN] = $can_cdn ? 1 : 0; return $this; } public function isBuiltin() { return ($this->getBuiltinName() !== null); } public function getBuiltinName() { return idx($this->metadata, self::METADATA_BUILTIN); } public function setBuiltinName($name) { $this->metadata[self::METADATA_BUILTIN] = $name; return $this; } protected function generateOneTimeToken() { $key = Filesystem::readRandomCharacters(16); // Save the new secret. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $token = id(new PhabricatorAuthTemporaryToken()) ->setObjectPHID($this->getPHID()) ->setTokenType(self::ONETIME_TEMPORARY_TOKEN_TYPE) ->setTokenExpires(time() + phutil_units('1 hour in seconds')) ->setTokenCode(PhabricatorHash::digest($key)) ->save(); unset($unguarded); return $key; } public function validateOneTimeToken($token_code) { $token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withObjectPHIDs(array($this->getPHID())) ->withTokenTypes(array(self::ONETIME_TEMPORARY_TOKEN_TYPE)) ->withExpired(false) ->withTokenCodes(array(PhabricatorHash::digest($token_code))) ->executeOne(); return $token; } /** * Write the policy edge between this file and some object. * * @param phid Object PHID to attach to. * @return this */ public function attachToObject($phid) { $edge_type = PhabricatorObjectHasFileEdgeType::EDGECONST; id(new PhabricatorEdgeEditor()) ->addEdge($phid, $edge_type, $this->getPHID()) ->save(); return $this; } /** * Remove the policy edge between this file and some object. * * @param phid Object PHID to detach from. * @return this */ public function detachFromObject($phid) { $edge_type = PhabricatorObjectHasFileEdgeType::EDGECONST; id(new PhabricatorEdgeEditor()) ->removeEdge($phid, $edge_type, $this->getPHID()) ->save(); return $this; } /** * Configure a newly created file object according to specified parameters. * * This method is called both when creating a file from fresh data, and * when creating a new file which reuses existing storage. * * @param map Bag of parameters, see @{class:PhabricatorFile} * for documentation. * @return this */ private function readPropertiesFromParameters(array $params) { $file_name = idx($params, 'name'); $this->setName($file_name); $author_phid = idx($params, 'authorPHID'); $this->setAuthorPHID($author_phid); $file_ttl = idx($params, 'ttl'); $this->setTtl($file_ttl); $view_policy = idx($params, 'viewPolicy'); if ($view_policy) { $this->setViewPolicy($params['viewPolicy']); } $is_explicit = (idx($params, 'isExplicitUpload') ? 1 : 0); $this->setIsExplicitUpload($is_explicit); $can_cdn = idx($params, 'canCDN'); if ($can_cdn) { $this->setCanCDN(true); } $builtin = idx($params, 'builtin'); if ($builtin) { $this->setBuiltinName($builtin); } $mime_type = idx($params, 'mime-type'); if ($mime_type) { $this->setMimeType($mime_type); } return $this; } public function getRedirectResponse() { $uri = $this->getBestURI(); // TODO: This is a bit iffy. Sometimes, getBestURI() returns a CDN URI // (if the file is a viewable image) and sometimes a local URI (if not). // For now, just detect which one we got and configure the response // appropriately. In the long run, if this endpoint is served from a CDN // domain, we can't issue a local redirect to an info URI (which is not // present on the CDN domain). We probably never actually issue local // redirects here anyway, since we only ever transform viewable images // right now. $is_external = strlen(id(new PhutilURI($uri))->getDomain()); return id(new AphrontRedirectResponse()) ->setIsExternal($is_external) ->setURI($uri); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorFileEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorFileTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if ($this->isBuiltin()) { return PhabricatorPolicies::getMostOpenPolicy(); } return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_NOONE; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { $viewer_phid = $viewer->getPHID(); if ($viewer_phid) { if ($this->getAuthorPHID() == $viewer_phid) { return true; } } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: // If you can see the file this file is a transform of, you can see // this file. if ($this->getOriginalFile()) { return true; } // If you can see any object this file is attached to, you can see // the file. return (count($this->getObjects()) > 0); } return false; } public function describeAutomaticCapability($capability) { $out = array(); $out[] = pht('The user who uploaded a file can always view and edit it.'); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $out[] = pht( 'Files attached to objects are visible to users who can view '. 'those objects.'); $out[] = pht( 'Thumbnails are visible only to users who can view the original '. 'file.'); break; } return $out; } /* -( PhabricatorSubscribableInterface Implementation )-------------------- */ public function isAutomaticallySubscribed($phid) { return ($this->authorPHID == $phid); } public function shouldShowSubscribersProperty() { return true; } public function shouldAllowSubscription($phid) { return true; } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array( $this->getAuthorPHID(), ); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } } diff --git a/src/applications/files/transform/PhabricatorFileImageTransform.php b/src/applications/files/transform/PhabricatorFileImageTransform.php index 3c44825dd5..250aa887dd 100644 --- a/src/applications/files/transform/PhabricatorFileImageTransform.php +++ b/src/applications/files/transform/PhabricatorFileImageTransform.php @@ -1,348 +1,358 @@ |null Width and height, if available. + */ + public function getTransformedDimensions(PhabricatorFile $file) { + return null; + } + public function canApplyTransform(PhabricatorFile $file) { if (!$file->isViewableImage()) { return false; } if (!$file->isTransformableImage()) { return false; } return true; } protected function willTransformFile(PhabricatorFile $file) { $this->file = $file; $this->data = null; $this->image = null; $this->imageX = null; $this->imageY = null; } protected function applyCropAndScale( $dst_w, $dst_h, $src_x, $src_y, $src_w, $src_h, $use_w, $use_h) { // Figure out the effective destination width, height, and offsets. We // never want to scale images up, so if we're copying a very small source // image we're just going to center it in the destination image. $cpy_w = min($dst_w, $src_w, $use_w); $cpy_h = min($dst_h, $src_h, $use_h); $off_x = ($dst_w - $cpy_w) / 2; $off_y = ($dst_h - $cpy_h) / 2; if ($this->shouldUseImagemagick()) { $argv = array(); $argv[] = '-coalesce'; $argv[] = '-shave'; $argv[] = $src_x.'x'.$src_y; $argv[] = '-resize'; $argv[] = $dst_w.'x'.$dst_h.'>'; $argv[] = '-bordercolor'; $argv[] = 'rgba(255, 255, 255, 0)'; $argv[] = '-border'; $argv[] = $off_x.'x'.$off_y; return $this->applyImagemagick($argv); } $src = $this->getImage(); $dst = $this->newEmptyImage($dst_w, $dst_h); $trap = new PhutilErrorTrap(); $ok = @imagecopyresampled( $dst, $src, $off_x, $off_y, $src_x, $src_y, $cpy_w, $cpy_h, $src_w, $src_h); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($ok === false) { throw new Exception( pht( 'Failed to imagecopyresampled() image: %s', $errors)); } $data = PhabricatorImageTransformer::saveImageDataInAnyFormat( $dst, $this->file->getMimeType()); return $this->newFileFromData($data); } protected function applyImagemagick(array $argv) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $this->getData()); $out = new TempFile(); $future = new ExecFuture('convert %s %Ls %s', $tmp, $argv, $out); // Don't spend more than 10 seconds resizing; just fail if it takes longer // than that. $future->setTimeout(10)->resolvex(); $data = Filesystem::readFile($out); return $this->newFileFromData($data); } /** * Create a new @{class:PhabricatorFile} from raw data. * * @param string Raw file data. */ protected function newFileFromData($data) { $name = $this->getTransformKey().'-'.$this->file->getName(); return PhabricatorFile::newFromFileData( $data, array( 'name' => $name, 'canCDN' => true, )); } /** * Create a new image filled with transparent pixels. * * @param int Desired image width. * @param int Desired image height. * @return resource New image resource. */ protected function newEmptyImage($w, $h) { $w = (int)$w; $h = (int)$h; if (($w <= 0) || ($h <= 0)) { throw new Exception( pht('Can not create an image with nonpositive dimensions.')); } $trap = new PhutilErrorTrap(); $img = @imagecreatetruecolor($w, $h); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($img === false) { throw new Exception( pht( 'Unable to imagecreatetruecolor() a new empty image: %s', $errors)); } $trap = new PhutilErrorTrap(); $ok = @imagesavealpha($img, true); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($ok === false) { throw new Exception( pht( 'Unable to imagesavealpha() a new empty image: %s', $errors)); } $trap = new PhutilErrorTrap(); $color = @imagecolorallocatealpha($img, 255, 255, 255, 127); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($color === false) { throw new Exception( pht( 'Unable to imagecolorallocatealpha() a new empty image: %s', $errors)); } $trap = new PhutilErrorTrap(); $ok = @imagefill($img, 0, 0, $color); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($ok === false) { throw new Exception( pht( 'Unable to imagefill() a new empty image: %s', $errors)); } return $img; } /** * Get the pixel dimensions of the image being transformed. * * @return list Width and height of the image. */ protected function getImageDimensions() { if ($this->imageX === null) { $image = $this->getImage(); $trap = new PhutilErrorTrap(); $x = @imagesx($image); $y = @imagesy($image); $errors = $trap->getErrorsAsString(); $trap->destroy(); if (($x === false) || ($y === false) || ($x <= 0) || ($y <= 0)) { throw new Exception( pht( 'Unable to determine image dimensions with '. 'imagesx()/imagesy(): %s', $errors)); } $this->imageX = $x; $this->imageY = $y; } return array($this->imageX, $this->imageY); } /** * Get the raw file data for the image being transformed. * * @return string Raw file data. */ protected function getData() { if ($this->data !== null) { return $this->data; } $file = $this->file; $max_size = (1024 * 1024 * 4); $img_size = $file->getByteSize(); if ($img_size > $max_size) { throw new Exception( pht( 'This image is too large to transform. The transform limit is %s '. 'bytes, but the image size is %s bytes.', new PhutilNumber($max_size), new PhutilNumber($img_size))); } $data = $file->loadFileData(); $this->data = $data; return $this->data; } /** * Get the GD image resource for the image being transformed. * * @return resource GD image resource. */ protected function getImage() { if ($this->image !== null) { return $this->image; } if (!function_exists('imagecreatefromstring')) { throw new Exception( pht( 'Unable to transform image: the imagecreatefromstring() function '. 'is not available. Install or enable the "gd" extension for PHP.')); } $data = $this->getData(); $data = (string)$data; // First, we're going to write the file to disk and use getimagesize() // to determine its dimensions without actually loading the pixel data // into memory. For very large images, we'll bail out. // In particular, this defuses a resource exhaustion attack where the // attacker uploads a 40,000 x 40,000 pixel PNGs of solid white. These // kinds of files compress extremely well, but require a huge amount // of memory and CPU to process. $tmp = new TempFile(); Filesystem::writeFile($tmp, $data); $tmp_path = (string)$tmp; $trap = new PhutilErrorTrap(); $info = @getimagesize($tmp_path); $errors = $trap->getErrorsAsString(); $trap->destroy(); unset($tmp); if ($info === false) { throw new Exception( pht( 'Unable to get image information with getimagesize(): %s', $errors)); } list($width, $height) = $info; if (($width <= 0) || ($height <= 0)) { throw new Exception( pht( 'Unable to determine image width and height with getimagesize().')); } $max_pixels = (4096 * 4096); $img_pixels = ($width * $height); if ($img_pixels > $max_pixels) { throw new Exception( pht( 'This image (with dimensions %spx x %spx) is too large to '. 'transform. The image has %s pixels, but transforms are limited '. 'to images with %s or fewer pixels.', new PhutilNumber($width), new PhutilNumber($height), new PhutilNumber($img_pixels), new PhutilNumber($max_pixels))); } $trap = new PhutilErrorTrap(); $image = @imagecreatefromstring($data); $errors = $trap->getErrorsAsString(); $trap->destroy(); if ($image === false) { throw new Exception( pht( 'Unable to load image data with imagecreatefromstring(): %s', $errors)); } $this->image = $image; return $this->image; } private function shouldUseImagemagick() { if (!PhabricatorEnv::getEnvConfig('files.enable-imagemagick')) { return false; } if ($this->file->getMimeType() != 'image/gif') { return false; } // Don't try to preserve the animation in huge GIFs. list($x, $y) = $this->getImageDimensions(); if (($x * $y) > (512 * 512)) { return false; } return true; } } diff --git a/src/applications/files/transform/PhabricatorFileThumbnailTransform.php b/src/applications/files/transform/PhabricatorFileThumbnailTransform.php index 45d7ad670a..3a72b5cf9e 100644 --- a/src/applications/files/transform/PhabricatorFileThumbnailTransform.php +++ b/src/applications/files/transform/PhabricatorFileThumbnailTransform.php @@ -1,140 +1,193 @@ name = $name; return $this; } public function setKey($key) { $this->key = $key; return $this; } public function setDimensions($x, $y) { $this->dstX = $x; $this->dstY = $y; return $this; } public function getTransformName() { return $this->name; } public function getTransformKey() { return $this->key; } public function generateTransforms() { return array( id(new PhabricatorFileThumbnailTransform()) ->setName(pht("Profile (100px \xC3\x97 100px)")) ->setKey(self::TRANSFORM_PROFILE) ->setDimensions(100, 100), id(new PhabricatorFileThumbnailTransform()) ->setName(pht("Pinboard (280px \xC3\x97 210px)")) ->setKey(self::TRANSFORM_PINBOARD) ->setDimensions(280, 210), id(new PhabricatorFileThumbnailTransform()) ->setName(pht('Thumbgrid (100px)')) ->setKey(self::TRANSFORM_THUMBGRID) ->setDimensions(100, null), id(new PhabricatorFileThumbnailTransform()) ->setName(pht('Preview (220px)')) ->setKey(self::TRANSFORM_PREVIEW) ->setDimensions(220, null), ); } public function applyTransform(PhabricatorFile $file) { $this->willTransformFile($file); list($src_x, $src_y) = $this->getImageDimensions(); $dst_x = $this->dstX; $dst_y = $this->dstY; + $dimensions = $this->computeDimensions( + $src_x, + $src_y, + $dst_x, + $dst_y); + + $copy_x = $dimensions['copy_x']; + $copy_y = $dimensions['copy_y']; + $use_x = $dimensions['use_x']; + $use_y = $dimensions['use_y']; + $dst_x = $dimensions['dst_x']; + $dst_y = $dimensions['dst_y']; + + return $this->applyCropAndScale( + $dst_x, + $dst_y, + ($src_x - $copy_x) / 2, + ($src_y - $copy_y) / 2, + $copy_x, + $copy_y, + $use_x, + $use_y); + } + + + public function getTransformedDimensions(PhabricatorFile $file) { + $dst_x = $this->dstX; + $dst_y = $this->dstY; + + // If this is transform has fixed dimensions, we can trivially predict + // the dimensions of the transformed file. + if ($dst_y !== null) { + return array($dst_x, $dst_y); + } + + $src_x = $file->getImageWidth(); + $src_y = $file->getImageHeight(); + + if (!$src_x || !$src_y) { + return null; + } + + $dimensions = $this->computeDimensions( + $src_x, + $src_y, + $dst_x, + $dst_y); + + return array($dimensions['dst_x'], $dimensions['dst_y']); + } + + + private function computeDimensions($src_x, $src_y, $dst_x, $dst_y) { if ($dst_y === null) { // If we only have one dimension, it represents a maximum dimension. // The other dimension of the transform is scaled appropriately, except // that we never generate images with crazily extreme aspect ratios. if ($src_x < $src_y) { // This is a tall, narrow image. Use the maximum dimension for the // height and scale the width. $use_y = $dst_x; $dst_y = $dst_x; $use_x = $dst_y * ($src_x / $src_y); $dst_x = max($dst_y / 4, $use_x); } else { // This is a short, wide image. Use the maximum dimension for the width // and scale the height. $use_x = $dst_x; $use_y = $dst_x * ($src_y / $src_x); $dst_y = max($dst_x / 4, $use_y); } // In this mode, we always copy the entire source image. We may generate // margins in the output. $copy_x = $src_x; $copy_y = $src_y; } else { // Otherwise, both dimensions are fixed. Figure out how much we'd have to // scale the image down along each dimension to get the entire thing to // fit. $scale_x = min(($dst_x / $src_x), 1); $scale_y = min(($dst_y / $src_y), 1); if ($scale_x > $scale_y) { // This image is relatively tall and narrow. We're going to crop off the // top and bottom. $copy_x = $src_x; $copy_y = min($src_y, $dst_y / $scale_x); } else { // This image is relatively short and wide. We're going to crop off the // left and right. $copy_x = min($src_x, $dst_x / $scale_y); $copy_y = $src_y; } // In this mode, we always use the entire destination image. We may // crop the source input. $use_x = $dst_x; $use_y = $dst_y; } - return $this->applyCropAndScale( - $dst_x, - $dst_y, - ($src_x - $copy_x) / 2, - ($src_y - $copy_y) / 2, - $copy_x, - $copy_y, - $use_x, - $use_y); + return array( + 'copy_x' => $copy_x, + 'copy_y' => $copy_y, + 'use_x' => $use_x, + 'use_y' => $use_y, + 'dst_x' => $dst_x, + 'dst_y' => $dst_y, + ); } + public function getDefaultTransform(PhabricatorFile $file) { $x = (int)$this->dstX; $y = (int)$this->dstY; $name = 'image-'.$x.'x'.nonempty($y, $x).'.png'; $root = dirname(phutil_get_library_root('phabricator')); $data = Filesystem::readFile($root.'/resources/builtin/'.$name); return $this->newFileFromData($data); } } diff --git a/src/applications/files/transform/PhabricatorFileTransform.php b/src/applications/files/transform/PhabricatorFileTransform.php index f12aefd3ed..633a80887a 100644 --- a/src/applications/files/transform/PhabricatorFileTransform.php +++ b/src/applications/files/transform/PhabricatorFileTransform.php @@ -1,48 +1,62 @@ setAncestorClass(__CLASS__) ->loadObjects(); $result = array(); foreach ($xforms as $xform_template) { foreach ($xform_template->generateTransforms() as $xform) { $key = $xform->getTransformKey(); if (isset($result[$key])) { throw new Exception( pht( 'Two %s objects define the same transform key ("%s"), but '. 'each transform must have a unique key.', __CLASS__, $key)); } $result[$key] = $xform; } } $map = $result; } return $map; } + public static function getTransformByKey($key) { + $all = self::getAllTransforms(); + + $xform = idx($all, $key); + if (!$xform) { + throw new Exception( + pht( + 'No file transform with key "%s" exists.', + $key)); + } + + return $xform; + } + } diff --git a/src/applications/pholio/view/PholioMockThumbGridView.php b/src/applications/pholio/view/PholioMockThumbGridView.php index df9fe1aa06..8e9d3007c5 100644 --- a/src/applications/pholio/view/PholioMockThumbGridView.php +++ b/src/applications/pholio/view/PholioMockThumbGridView.php @@ -1,171 +1,177 @@ mock = $mock; return $this; } public function render() { $mock = $this->mock; $all_images = $mock->getAllImages(); $all_images = mpull($all_images, null, 'getPHID'); $history = mpull($all_images, 'getReplacesImagePHID', 'getPHID'); $replaced = array(); foreach ($history as $phid => $replaces_phid) { if ($replaces_phid) { $replaced[$replaces_phid] = true; } } // Figure out the columns. Start with all the active images. $images = mpull($mock->getImages(), null, 'getPHID'); // Now, find deleted images: obsolete images which were not replaced. foreach ($mock->getAllImages() as $image) { if (!$image->getIsObsolete()) { // Image is current. continue; } if (isset($replaced[$image->getPHID()])) { // Image was replaced. continue; } // This is an obsolete image which was not replaced, so it must be // a deleted image. $images[$image->getPHID()] = $image; } $cols = array(); $depth = 0; foreach ($images as $image) { $phid = $image->getPHID(); $col = array(); // If this is a deleted image, null out the final column. if ($image->getIsObsolete()) { $col[] = null; } $col[] = $phid; while ($phid && isset($history[$phid])) { $col[] = $history[$phid]; $phid = $history[$phid]; } $cols[] = $col; $depth = max($depth, count($col)); } $grid = array(); $jj = $depth; for ($ii = 0; $ii < $depth; $ii++) { $row = array(); if ($depth == $jj) { $row[] = phutil_tag( 'th', array( 'valign' => 'middle', 'class' => 'pholio-history-header', ), pht('Current Revision')); } else { $row[] = phutil_tag('th', array(), null); } foreach ($cols as $col) { if (empty($col[$ii])) { $row[] = phutil_tag('td', array(), null); } else { $thumb = $this->renderThumbnail($all_images[$col[$ii]]); $row[] = phutil_tag('td', array(), $thumb); } } $grid[] = phutil_tag('tr', array(), $row); $jj--; } $grid = phutil_tag( 'table', array( 'id' => 'pholio-mock-thumb-grid', 'class' => 'pholio-mock-thumb-grid', ), $grid); $grid = id(new PHUIBoxView()) ->addClass('pholio-mock-thumb-grid-container') ->appendChild($grid); return id(new PHUIObjectBoxView()) ->setHeaderText(pht('Mock History')) ->appendChild($grid); } private function renderThumbnail(PholioImage $image) { $thumbfile = $image->getFile(); + $preview_key = PhabricatorFileThumbnailTransform::TRANSFORM_THUMBGRID; + $xform = PhabricatorFileTransform::getTransformByKey($preview_key); + + $attributes = array( + 'class' => 'pholio-mock-thumb-grid-image', + 'src' => $thumbfile->getURIForTransform($xform), + ); + if ($image->getFile()->isViewableImage()) { - $dimensions = PhabricatorImageTransformer::getPreviewDimensions( - $thumbfile, - 100); + $dimensions = $xform->getTransformedDimensions($thumbfile); + if ($dimensions) { + list($x, $y) = $dimensions; + $attributes += array( + 'width' => $x, + 'height' => $y, + 'style' => 'top: '.floor((100 - $y) / 2).'px', + ); + } } else { // If this is a PDF or a text file or something, we'll end up using a // generic thumbnail which is always sized correctly. - $dimensions = array( - 'sdx' => 100, - 'sdy' => 100, + $attributes += array( + 'width' => 100, + 'height' => 100, ); } - $tag = phutil_tag( - 'img', - array( - 'width' => $dimensions['sdx'], - 'height' => $dimensions['sdy'], - 'src' => $thumbfile->getPreview100URI(), - 'class' => 'pholio-mock-thumb-grid-image', - 'style' => 'top: '.floor((100 - $dimensions['sdy'] ) / 2).'px', - )); + $tag = phutil_tag('img', $attributes); $classes = array('pholio-mock-thumb-grid-item'); if ($image->getIsObsolete()) { $classes[] = 'pholio-mock-thumb-grid-item-obsolete'; } $inline_count = null; if ($image->getInlineComments()) { $inline_count[] = phutil_tag( 'span', array( 'class' => 'pholio-mock-thumb-grid-comment-count', ), pht('%s', new PhutilNumber(count($image->getInlineComments())))); } return javelin_tag( 'a', array( 'sigil' => 'mock-thumbnail', 'class' => implode(' ', $classes), 'href' => '#', 'meta' => array( 'imageID' => $image->getID(), ), ), array( $tag, $inline_count, )); } } diff --git a/webroot/rsrc/image/icon/fatcow/thumbnails/default.p100.png b/webroot/rsrc/image/icon/fatcow/thumbnails/default.p100.png deleted file mode 100644 index f713c2398b..0000000000 Binary files a/webroot/rsrc/image/icon/fatcow/thumbnails/default.p100.png and /dev/null differ diff --git a/webroot/rsrc/image/icon/fatcow/thumbnails/image.p100.png b/webroot/rsrc/image/icon/fatcow/thumbnails/image.p100.png deleted file mode 100644 index f5fa35ab08..0000000000 Binary files a/webroot/rsrc/image/icon/fatcow/thumbnails/image.p100.png and /dev/null differ diff --git a/webroot/rsrc/image/icon/fatcow/thumbnails/pdf.p100.png b/webroot/rsrc/image/icon/fatcow/thumbnails/pdf.p100.png deleted file mode 100644 index ad3a39b490..0000000000 Binary files a/webroot/rsrc/image/icon/fatcow/thumbnails/pdf.p100.png and /dev/null differ diff --git a/webroot/rsrc/image/icon/fatcow/thumbnails/zip.p100.png b/webroot/rsrc/image/icon/fatcow/thumbnails/zip.p100.png deleted file mode 100644 index 86fa739b3b..0000000000 Binary files a/webroot/rsrc/image/icon/fatcow/thumbnails/zip.p100.png and /dev/null differ