@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement new profile transform with amazing "error handling" feature

Summary:
Ref T7707. Ref T4406. Ref T2479. This implements the profile-style (fixed width and height) transforms in a modern way.

- Added a "regnerate" feature to the support UI to make testing easier and surface errors.
- Laboriously check errors from everything.
- Fix the profile thumbnailing so it crops properly instead of leaving margins.
- Also defuses the "gigantic white PNG" attack.

This doesn't handle the imagemagick case (for animated GIFs) yet.

Test Plan:
- Uploaded a variety of wide/narrow/small/large files and converted them into sensible profile pictures.
- Tried to thumbnail some text files.
- Set the pixel-size and file-size limits artificially small and hit them.
- Used "regenerate" a bunch while testing the rest of this stuff.
- Verified that non-regenerate flows still produce a default/placeholder image.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T4406, T2479, T7707

Differential Revision: https://secure.phabricator.com/D12811

+352 -21
+30 -4
src/applications/files/controller/PhabricatorFileTransformController.php
··· 13 13 // NOTE: This is a public/CDN endpoint, and permission to see files is 14 14 // controlled by knowing the secret key, not by authentication. 15 15 16 + $is_regenerate = $request->getBool('regenerate'); 17 + 16 18 $source_phid = $request->getURIData('phid'); 17 19 $file = id(new PhabricatorFileQuery()) 18 20 ->setViewer(PhabricatorUser::getOmnipotentUser()) ··· 35 37 $transform); 36 38 37 39 if ($xform) { 38 - return $this->buildTransformedFileResponse($xform); 40 + if ($is_regenerate) { 41 + $this->destroyTransform($xform); 42 + } else { 43 + return $this->buildTransformedFileResponse($xform); 44 + } 39 45 } 40 46 41 47 $type = $file->getMimeType(); ··· 57 63 try { 58 64 $xformed_file = $xforms[$transform]->applyTransform($file); 59 65 } catch (Exception $ex) { 60 - // TODO: Provide a diagnostic mode to surface these to the viewer. 61 - 62 66 // In normal transform mode, we ignore failures and generate a 63 - // default transform instead. 67 + // default transform below. If we're explicitly regenerating the 68 + // thumbnail, rethrow the exception. 69 + if ($is_regenerate) { 70 + throw $ex; 71 + } 64 72 } 65 73 } 66 74 ··· 163 171 private function executeThumbTransform(PhabricatorFile $file, $x, $y) { 164 172 $xformer = new PhabricatorImageTransformer(); 165 173 return $xformer->executeThumbTransform($file, $x, $y); 174 + } 175 + 176 + private function destroyTransform(PhabricatorTransformedFile $xform) { 177 + $file = id(new PhabricatorFileQuery()) 178 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 179 + ->withPHIDs(array($xform->getTransformedPHID())) 180 + ->executeOne(); 181 + 182 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 183 + 184 + if (!$file) { 185 + $xform->delete(); 186 + } else { 187 + $engine = new PhabricatorDestructionEngine(); 188 + $engine->destroyObject($file); 189 + } 190 + 191 + unset($unguarded); 166 192 } 167 193 168 194 }
+6 -5
src/applications/files/controller/PhabricatorFileTransformListController.php
··· 58 58 59 59 if ($xform->canApplyTransform($file)) { 60 60 $can_apply = pht('Yes'); 61 + 61 62 $view_href = $file->getURIForTransform($xform); 62 - if ($dst_phid) { 63 - $view_text = pht('View Transform'); 64 - } else { 65 - $view_text = pht('Generate Transform'); 66 - } 63 + $view_href = new PhutilURI($view_href); 64 + $view_href->setQueryParam('regenerate', 'true'); 65 + 66 + $view_text = pht('Regenerate'); 67 + 67 68 $view_link = phutil_tag( 68 69 'a', 69 70 array(
+284
src/applications/files/transform/PhabricatorFileImageTransform.php
··· 2 2 3 3 abstract class PhabricatorFileImageTransform extends PhabricatorFileTransform { 4 4 5 + private $file; 6 + private $data; 7 + private $image; 8 + private $imageX; 9 + private $imageY; 10 + 5 11 public function canApplyTransform(PhabricatorFile $file) { 6 12 if (!$file->isViewableImage()) { 7 13 return false; ··· 12 18 } 13 19 14 20 return true; 21 + } 22 + 23 + protected function willTransformFile(PhabricatorFile $file) { 24 + $this->file = $file; 25 + $this->data = null; 26 + $this->image = null; 27 + $this->imageX = null; 28 + $this->imageY = null; 29 + } 30 + 31 + protected function applyCropAndScale( 32 + $dst_w, $dst_h, 33 + $src_x, $src_y, 34 + $src_w, $src_h) { 35 + 36 + // Figure out the effective destination width, height, and offsets. We 37 + // never want to scale images up, so if we're copying a very small source 38 + // image we're just going to center it in the destination image. 39 + $cpy_w = min($dst_w, $src_w); 40 + $cpy_h = min($dst_h, $src_h); 41 + $off_x = ($dst_w - $cpy_w) / 2; 42 + $off_y = ($dst_h - $cpy_h) / 2; 43 + 44 + // TODO: Support imagemagick for animated GIFs. 45 + 46 + $src = $this->getImage(); 47 + $dst = $this->newEmptyImage($dst_w, $dst_h); 48 + 49 + $trap = new PhutilErrorTrap(); 50 + $ok = @imagecopyresampled( 51 + $dst, 52 + $src, 53 + $off_x, $off_y, 54 + $src_x, $src_y, 55 + $cpy_w, $cpy_h, 56 + $src_w, $src_h); 57 + $errors = $trap->getErrorsAsString(); 58 + $trap->destroy(); 59 + 60 + if ($ok === false) { 61 + throw new Exception( 62 + pht( 63 + 'Failed to imagecopyresampled() image: %s', 64 + $errors)); 65 + } 66 + 67 + $data = PhabricatorImageTransformer::saveImageDataInAnyFormat( 68 + $dst, 69 + $this->file->getMimeType()); 70 + 71 + return $this->newFileFromData($data); 72 + } 73 + 74 + 75 + /** 76 + * Create a new @{class:PhabricatorFile} from raw data. 77 + * 78 + * @param string Raw file data. 79 + */ 80 + protected function newFileFromData($data) { 81 + $name = $this->getTransformKey().'-'.$this->file->getName(); 82 + 83 + return PhabricatorFile::newFromFileData( 84 + $data, 85 + array( 86 + 'name' => $name, 87 + 'canCDN' => true, 88 + )); 89 + } 90 + 91 + 92 + /** 93 + * Create a new image filled with transparent pixels. 94 + * 95 + * @param int Desired image width. 96 + * @param int Desired image height. 97 + * @return resource New image resource. 98 + */ 99 + protected function newEmptyImage($w, $h) { 100 + $w = (int)$w; 101 + $h = (int)$h; 102 + 103 + if (($w <= 0) || ($h <= 0)) { 104 + throw new Exception( 105 + pht('Can not create an image with nonpositive dimensions.')); 106 + } 107 + 108 + $trap = new PhutilErrorTrap(); 109 + $img = @imagecreatetruecolor($w, $h); 110 + $errors = $trap->getErrorsAsString(); 111 + $trap->destroy(); 112 + if ($img === false) { 113 + throw new Exception( 114 + pht( 115 + 'Unable to imagecreatetruecolor() a new empty image: %s', 116 + $errors)); 117 + } 118 + 119 + $trap = new PhutilErrorTrap(); 120 + $ok = @imagesavealpha($img, true); 121 + $errors = $trap->getErrorsAsString(); 122 + $trap->destroy(); 123 + if ($ok === false) { 124 + throw new Exception( 125 + pht( 126 + 'Unable to imagesavealpha() a new empty image: %s', 127 + $errors)); 128 + } 129 + 130 + $trap = new PhutilErrorTrap(); 131 + $color = @imagecolorallocatealpha($img, 255, 255, 255, 127); 132 + $errors = $trap->getErrorsAsString(); 133 + $trap->destroy(); 134 + if ($color === false) { 135 + throw new Exception( 136 + pht( 137 + 'Unable to imagecolorallocatealpha() a new empty image: %s', 138 + $errors)); 139 + } 140 + 141 + $trap = new PhutilErrorTrap(); 142 + $ok = @imagefill($img, 0, 0, $color); 143 + $errors = $trap->getErrorsAsString(); 144 + $trap->destroy(); 145 + if ($ok === false) { 146 + throw new Exception( 147 + pht( 148 + 'Unable to imagefill() a new empty image: %s', 149 + $errors)); 150 + } 151 + 152 + return $img; 153 + } 154 + 155 + 156 + /** 157 + * Get the pixel dimensions of the image being transformed. 158 + * 159 + * @return list<int, int> Width and height of the image. 160 + */ 161 + protected function getImageDimensions() { 162 + if ($this->imageX === null) { 163 + $image = $this->getImage(); 164 + 165 + $trap = new PhutilErrorTrap(); 166 + $x = @imagesx($image); 167 + $y = @imagesy($image); 168 + $errors = $trap->getErrorsAsString(); 169 + $trap->destroy(); 170 + 171 + if (($x === false) || ($y === false) || ($x <= 0) || ($y <= 0)) { 172 + throw new Exception( 173 + pht( 174 + 'Unable to determine image dimensions with '. 175 + 'imagesx()/imagesy(): %s', 176 + $errors)); 177 + } 178 + 179 + $this->imageX = $x; 180 + $this->imageY = $y; 181 + } 182 + 183 + return array($this->imageX, $this->imageY); 184 + } 185 + 186 + 187 + /** 188 + * Get the raw file data for the image being transformed. 189 + * 190 + * @return string Raw file data. 191 + */ 192 + protected function getData() { 193 + if ($this->data !== null) { 194 + return $this->data; 195 + } 196 + 197 + $file = $this->file; 198 + 199 + $max_size = (1024 * 1024 * 4); 200 + $img_size = $file->getByteSize(); 201 + if ($img_size > $max_size) { 202 + throw new Exception( 203 + pht( 204 + 'This image is too large to transform. The transform limit is %s '. 205 + 'bytes, but the image size is %s bytes.', 206 + new PhutilNumber($max_size), 207 + new PhutilNumber($img_size))); 208 + } 209 + 210 + $data = $file->loadFileData(); 211 + $this->data = $data; 212 + return $this->data; 213 + } 214 + 215 + 216 + /** 217 + * Get the GD image resource for the image being transformed. 218 + * 219 + * @return resource GD image resource. 220 + */ 221 + protected function getImage() { 222 + if ($this->image !== null) { 223 + return $this->image; 224 + } 225 + 226 + if (!function_exists('imagecreatefromstring')) { 227 + throw new Exception( 228 + pht( 229 + 'Unable to transform image: the imagecreatefromstring() function '. 230 + 'is not available. Install or enable the "gd" extension for PHP.')); 231 + } 232 + 233 + $data = $this->getData(); 234 + $data = (string)$data; 235 + 236 + // First, we're going to write the file to disk and use getimagesize() 237 + // to determine its dimensions without actually loading the pixel data 238 + // into memory. For very large images, we'll bail out. 239 + 240 + // In particular, this defuses a resource exhaustion attack where the 241 + // attacker uploads a 40,000 x 40,000 pixel PNGs of solid white. These 242 + // kinds of files compress extremely well, but require a huge amount 243 + // of memory and CPU to process. 244 + 245 + $tmp = new TempFile(); 246 + Filesystem::writeFile($tmp, $data); 247 + $tmp_path = (string)$tmp; 248 + 249 + $trap = new PhutilErrorTrap(); 250 + $info = @getimagesize($tmp_path); 251 + $errors = $trap->getErrorsAsString(); 252 + $trap->destroy(); 253 + 254 + unset($tmp); 255 + 256 + if ($info === false) { 257 + throw new Exception( 258 + pht( 259 + 'Unable to get image information with getimagesize(): %s', 260 + $errors)); 261 + } 262 + 263 + list($width, $height) = $info; 264 + if (($width <= 0) || ($height <= 0)) { 265 + throw new Exception( 266 + pht( 267 + 'Unable to determine image width and height with getimagesize().')); 268 + } 269 + 270 + $max_pixels = (4096 * 4096); 271 + $img_pixels = ($width * $height); 272 + 273 + if ($img_pixels > $max_pixels) { 274 + throw new Exception( 275 + pht( 276 + 'This image (with dimensions %spx x %spx) is too large to '. 277 + 'transform. The image has %s pixels, but transforms are limited '. 278 + 'to images with %s or fewer pixels.', 279 + new PhutilNumber($width), 280 + new PhutilNumber($height), 281 + new PhutilNumber($img_pixels), 282 + new PhutilNumber($max_pixels))); 283 + } 284 + 285 + $trap = new PhutilErrorTrap(); 286 + $image = @imagecreatefromstring($data); 287 + $errors = $trap->getErrorsAsString(); 288 + $trap->destroy(); 289 + 290 + if ($image === false) { 291 + throw new Exception( 292 + pht( 293 + 'Unable to load image data with imagecreatefromstring(): %s', 294 + $errors)); 295 + } 296 + 297 + $this->image = $image; 298 + return $this->image; 15 299 } 16 300 17 301 }
+32 -12
src/applications/files/transform/PhabricatorFileThumbnailTransform.php
··· 59 59 } 60 60 61 61 public function applyTransform(PhabricatorFile $file) { 62 - $x = $this->dstX; 63 - $y = $this->dstY; 62 + $xformer = new PhabricatorImageTransformer(); 63 + if ($this->dstY === null) { 64 + return $xformer->executePreviewTransform($file, $this->dstX); 65 + } 64 66 65 - $xformer = new PhabricatorImageTransformer(); 67 + $this->willTransformFile($file); 66 68 67 - if ($y === null) { 68 - return $xformer->executePreviewTransform($file, $x); 69 + list($src_x, $src_y) = $this->getImageDimensions(); 70 + $dst_x = $this->dstX; 71 + $dst_y = $this->dstY; 72 + 73 + // Figure out how much we'd have to scale the image down along each 74 + // dimension to get the entire thing to fit. 75 + $scale_x = min(($dst_x / $src_x), 1); 76 + $scale_y = min(($dst_y / $src_y), 1); 77 + 78 + if ($scale_x > $scale_y) { 79 + // This image is relatively tall and narrow. We're going to crop off the 80 + // top and bottom. 81 + $copy_x = $src_x; 82 + $copy_y = min($src_y, $dst_y / $scale_x); 69 83 } else { 70 - return $xformer->executeThumbTransform($file, $x, $y); 84 + // This image is relatively short and wide. We're going to crop off the 85 + // left and right. 86 + $copy_x = min($src_x, $dst_x / $scale_y); 87 + $copy_y = $src_y; 71 88 } 89 + 90 + return $this->applyCropAndScale( 91 + $dst_x, 92 + $dst_y, 93 + ($src_x - $copy_x) / 2, 94 + ($src_y - $copy_y) / 2, 95 + $copy_x, 96 + $copy_y); 72 97 } 73 98 74 99 public function getDefaultTransform(PhabricatorFile $file) { ··· 76 101 $y = (int)$this->dstY; 77 102 $name = 'image-'.$x.'x'.nonempty($y, $x).'.png'; 78 103 79 - $params = array( 80 - 'name' => $name, 81 - 'canCDN' => true, 82 - ); 83 - 84 104 $root = dirname(phutil_get_library_root('phabricator')); 85 105 $data = Filesystem::readFile($root.'/resources/builtin/'.$name); 86 106 87 - return PhabricatorFile::newFromFileData($data, $params); 107 + return $this->newFileFromData($data); 88 108 } 89 109 90 110 }