From b224d9309821922f48fec2314ef6388e2e43db66 Mon Sep 17 00:00:00 2001 From: Miguel Dantas Date: Fri, 7 Jun 2019 14:08:27 +0100 Subject: [PATCH] [MEDIA] ImageFile now extends MediaFile and validates images more aggressively. Default supported files need to use consistent names. Bumped version to 1.20.0 ImageFile has been changed to extend MediaFile and rely on it to partially validate files. This validation has been extended to not rely solely on Fileinfo, as it is disabled on some places. Now it'll try to use the shell command `file`, if Fileinfo isn't available. ImageFile now converts every new upload to PNG, except JPEG and GIF, which are kept, but still resized (to the same size), to remove possible scripts embedded therein. MediaFile::fromUpload will return an ImageFile if the uploaded file is an image or a MediaFile otherwise. MediaFile can be constructed with an id with value -1 to denote a temporary object, which is not added to the DB. This is useful to create a temporary object for representing images, so it can be used to rescale them. The supported attachment array needs to be populated with the result of calling `image_type_to_extension` for the appropriate image type, in the case of images. This is important so all parts of the code see the same extension for each image type (jpg vs jpeg). Added documentation to classes/File.php and to lib/MediaFile and lib/ImageFile --- .../SYSTEM_ADMINISTRATORS/CONFIGURE.md | 11 +- README.md | 2 +- classes/File.php | 44 +- lib/default.php | 20 +- lib/framework.php | 2 +- lib/imagefile.php | 448 ++++++------------ lib/mediafile.php | 371 +++++++++++---- 7 files changed, 496 insertions(+), 402 deletions(-) diff --git a/DOCUMENTATION/SYSTEM_ADMINISTRATORS/CONFIGURE.md b/DOCUMENTATION/SYSTEM_ADMINISTRATORS/CONFIGURE.md index 02f0db7a6d..5346f1f378 100644 --- a/DOCUMENTATION/SYSTEM_ADMINISTRATORS/CONFIGURE.md +++ b/DOCUMENTATION/SYSTEM_ADMINISTRATORS/CONFIGURE.md @@ -649,7 +649,16 @@ detection. * `supported`: an array of mime types you accept to store and distribute, like 'image/gif', 'video/mpeg', 'audio/mpeg', etc. Make sure you setup your server to properly recognize the types you want to - support. + support. It's important to use the result of calling `image_type_to_extension` + for the appropriate image type, in the case of images. This is so all parts of + the code see the same extension for each image type (jpg vs jpeg). + For example, to enable BMP uploads, add this to the config.php file: + $config['attachments']['supported'][image_type_to_mime_type(IMAGETYPE_GIF)] + = image_type_to_extension(IMAGETYPE_GIF); + See https://www.php.net/manual/en/function.image-type-to-mime-type.php for a + list of such constants. If a filetype is not listed there, it's possible to add + the mimetype and the extension by hand, but they need to match those returned by + the file command. * `uploads`: false to disable uploading files with notices (true by default). diff --git a/README.md b/README.md index 63a4d1abde..b04a465e04 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GNU social 1.19.x +# GNU social 1.20.x (c) 2010-2019 Free Software Foundation, Inc This is the README file for GNU social, the free diff --git a/classes/File.php b/classes/File.php index 67b87efd0d..a9ef3125de 100644 --- a/classes/File.php +++ b/classes/File.php @@ -1,23 +1,32 @@ . + * along with this program. If not, see . + * + * @category Files + * @package GNUsocial + * @author Mikael Nordfeldth + * @author Miguel Dantas + * @copyright 2008-2009, 2019 Free Software Foundation http://fsf.org + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link https://www.gnu.org/software/social/ */ -if (!defined('GNUSOCIAL')) { exit(1); } +defined('GNUSOCIAL') || die(); /** * Table Definition for file @@ -93,6 +102,7 @@ class File extends Managed_DataObject * @param array $redir_data lookup data eg from File_redirection::where() * @param string $given_url * @return File + * @throws ServerException */ public static function saveNew(array $redir_data, $given_url) { @@ -298,8 +308,10 @@ class File extends Managed_DataObject } /** - * @param $mimetype The mimetype we've discovered for this file. - * @param $filename An optional filename which we can use on failure. + * @param $mimetype string The mimetype we've discovered for this file. + * @param $filename string An optional filename which we can use on failure. + * @return mixed|string + * @throws ClientException */ static function guessMimeExtension($mimetype, $filename=null) { @@ -349,6 +361,8 @@ class File extends Managed_DataObject /** * Validation for as-saved base filenames + * @param $filename + * @return false|int */ static function validFilename($filename) { @@ -366,7 +380,9 @@ class File extends Managed_DataObject } /** - * @throws ClientException on invalid filename + * @param $filename + * @return string + * @throws InvalidFilenameException */ static function path($filename) { @@ -534,7 +550,9 @@ class File extends Managed_DataObject } /** - * @param mixed $use_local true means require local, null means prefer local, false means use whatever is stored + * @param mixed $use_local true means require local, null means prefer local, false means use whatever is stored + * @return string + * @throws FileNotStoredLocallyException */ public function getUrl($use_local=null) { @@ -565,7 +583,9 @@ class File extends Managed_DataObject } /** - * @param string $hashstr String of (preferrably lower case) hexadecimal characters, same as result of 'hash_file(...)' + * @param string $hashstr String of (preferrably lower case) hexadecimal characters, same as result of 'hash_file(...)' + * @return File + * @throws NoResultException */ static public function getByHash($hashstr) { diff --git a/lib/default.php b/lib/default.php index cacb9d88cb..c73f8fccd2 100644 --- a/lib/default.php +++ b/lib/default.php @@ -1,10 +1,7 @@ - * @copyright 2008-9 StatusNet, Inc. + * @copyright 2008-2009, 2019 Free Software Foundation http://fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://www.gnu.org/software/social/ + * @link https://www.gnu.org/software/social/ */ $default = @@ -253,11 +250,11 @@ $default = 'application/x-go-sgf' => 'sgf', 'application/xml' => 'xml', 'application/gpx+xml' => 'gpx', - 'image/png' => 'png', - 'image/jpeg' => 'jpg', - 'image/gif' => 'gif', - 'image/svg+xml' => 'svg', - 'image/vnd.microsoft.icon' => 'ico', + image_type_to_mime_type(IMAGETYPE_PNG) => image_type_to_extension(IMAGETYPE_PNG), + image_type_to_mime_type(IMAGETYPE_JPEG) => image_type_to_extension(IMAGETYPE_JPEG), + image_type_to_mime_type(IMAGETYPE_GIF) => image_type_to_extension(IMAGETYPE_GIF), + 'image/svg+xml' => 'svg', // No built-in constant + image_type_to_mime_type(IMAGETYPE_ICO) => image_type_to_extension(IMAGETYPE_ICO), 'audio/ogg' => 'ogg', 'audio/mpeg' => 'mpg', 'audio/x-speex' => 'spx', @@ -280,6 +277,7 @@ $default = 'php' => 'phps', // this turns .php into .phps 'exe' => false, // this would deny any uploads to keep the "exe" file extension ], + 'memory_limit' => '1024M' // PHP's memory limit to use temporarily when handling images ), 'thumbnail' => [ 'dir' => null, // falls back to File::path('thumb') (equivalent to ['attachments']['dir'] . '/thumb/') diff --git a/lib/framework.php b/lib/framework.php index 0d6a2c44c2..2d74695354 100644 --- a/lib/framework.php +++ b/lib/framework.php @@ -22,7 +22,7 @@ if (!defined('GNUSOCIAL')) { exit(1); } define('GNUSOCIAL_ENGINE', 'GNU social'); define('GNUSOCIAL_ENGINE_URL', 'https://www.gnu.org/software/social/'); -define('GNUSOCIAL_BASE_VERSION', '1.19.4'); +define('GNUSOCIAL_BASE_VERSION', '1.20.0'); define('GNUSOCIAL_LIFECYCLE', 'rc0'); // 'dev', 'alpha[0-9]+', 'beta[0-9]+', 'rc[0-9]+', 'release' define('GNUSOCIAL_VERSION', GNUSOCIAL_BASE_VERSION . '-' . GNUSOCIAL_LIFECYCLE); diff --git a/lib/imagefile.php b/lib/imagefile.php index 80bc90f125..bc5712056f 100644 --- a/lib/imagefile.php +++ b/lib/imagefile.php @@ -1,11 +1,9 @@ . * * @category Image - * @package StatusNet + * @package GNUsocial * @author Evan Prodromou * @author Zach Copley - * @copyright 2008-2009 StatusNet, Inc. + * @author Mikael Nordfeldth + * @author Miguel Dantas + * @copyright 2008, 2019 Free Software Foundation http://fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ + * @link https://www.gnu.org/software/social/ */ -if (!defined('GNUSOCIAL')) { exit(1); } +defined('GNUSOCIAL') || die(); /** - * A wrapper on uploaded files + * A wrapper on uploaded images * * Makes it slightly easier to accept an image file from upload. * * @category Image - * @package StatusNet + * @package GNUsocial + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @author Evan Prodromou * @author Zach Copley - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ + * @link https://www.gnu.org/software/social/ */ - -class ImageFile +class ImageFile extends MediaFile { - var $id; - var $filepath; - var $filename; - var $type; - var $height; - var $width; - var $rotate=0; // degrees to rotate for properly oriented image (extrapolated from EXIF etc.) - var $animated = null; // Animated image? (has more than 1 frame). null means untested - var $mimetype = null; // The _ImageFile_ mimetype, _not_ the originating File object - - protected $fileRecord = null; - - function __construct($id, $filepath) + public $type; + public $height; + public $width; + public $rotate = 0; // degrees to rotate for properly oriented image (extrapolated from EXIF etc.) + public $animated = null; // Animated image? (has more than 1 frame). null means untested + public $mimetype = null; // The _ImageFile_ mimetype, _not_ the originating File object + + public function __construct($id, string $filepath) { - $this->id = $id; - if (!empty($this->id)) { - $this->fileRecord = new File(); - $this->fileRecord->id = $this->id; - if (!$this->fileRecord->find(true)) { - // If we have set an ID, we need that ID to exist! - throw new NoResultException($this->fileRecord); - } - } - // These do not have to be the same as fileRecord->filename for example, // since we may have generated an image source file from something else! $this->filepath = $filepath; @@ -76,16 +60,14 @@ class ImageFile $info = @getimagesize($this->filepath); - if (!( - ($info[2] == IMAGETYPE_GIF && function_exists('imagecreatefromgif')) || - ($info[2] == IMAGETYPE_JPEG && function_exists('imagecreatefromjpeg')) || - $info[2] == IMAGETYPE_BMP || - ($info[2] == IMAGETYPE_WBMP && function_exists('imagecreatefromwbmp')) || - ($info[2] == IMAGETYPE_XBM && function_exists('imagecreatefromxbm')) || - ($info[2] == IMAGETYPE_PNG && function_exists('imagecreatefrompng')))) { - + if (!(($info[2] == IMAGETYPE_GIF && function_exists('imagecreatefromgif')) || + ($info[2] == IMAGETYPE_JPEG && function_exists('imagecreatefromjpeg')) || + ($info[2] == IMAGETYPE_BMP && function_exists('imagecreatefrombmp')) || + ($info[2] == IMAGETYPE_WBMP && function_exists('imagecreatefromwbmp')) || + ($info[2] == IMAGETYPE_XBM && function_exists('imagecreatefromxbm')) || + ($info[2] == IMAGETYPE_PNG && function_exists('imagecreatefrompng')))) { // TRANS: Exception thrown when trying to upload an unsupported image file format. - throw new UnsupportedMediaException(_('Unsupported image format.'), $this->filepath); + throw new UnsupportedMediaException(_m('Unsupported image format.'), $this->filepath); } $this->width = $info[0]; @@ -93,11 +75,18 @@ class ImageFile $this->type = $info[2]; $this->mimetype = $info['mime']; + parent::__construct( + $filepath, + $this->mimetype, + null /* filehash, MediaFile will calculate it */, + $id + ); + if ($this->type === IMAGETYPE_JPEG && function_exists('exif_read_data')) { // Orientation value to rotate thumbnails properly $exif = @exif_read_data($this->filepath); if (is_array($exif) && isset($exif['Orientation'])) { - switch ((int)$exif['Orientation']) { + switch (intval($exif['Orientation'])) { case 1: // top is top $this->rotate = 0; break; @@ -126,7 +115,7 @@ class ImageFile $media = common_get_mime_media($file->mimetype); if (Event::handle('CreateFileImageThumbnailSource', array($file, &$imgPath, $media))) { if (empty($file->filename) && !file_exists($imgPath)) { - throw new UnsupportedMediaException(_('File without filename could not get a thumbnail source.')); + throw new UnsupportedMediaException(_m('File without filename could not get a thumbnail source.')); } // First some mimetype specific exceptions @@ -141,7 +130,7 @@ class ImageFile $imgPath = $file->getPath(); break; default: - throw new UnsupportedMediaException(_('Unsupported media format.'), $file->getPath()); + throw new UnsupportedMediaException(_m('Unsupported media format.'), $file->getPath()); } } @@ -155,7 +144,8 @@ class ImageFile // Avoid deleting the original try { if (strlen($imgPath) > 0 && $imgPath !== $file->getPath()) { - common_debug(__METHOD__.': Deleting temporary file that was created as image file thumbnail source: '._ve($imgPath)); + common_debug(__METHOD__.': Deleting temporary file that was created as image file' . + 'thumbnail source: '._ve($imgPath)); @unlink($imgPath); } } catch (FileNotFoundException $e) { @@ -163,7 +153,9 @@ class ImageFile // doesn't exist anyway, so it's safe to delete $imgPath @unlink($imgPath); } - common_debug(sprintf('Exception %s caught when creating ImageFile for File id==%s and imgPath==%s: %s', get_class($e), _ve($file->id), _ve($imgPath), _ve($e->getMessage()))); + common_debug(sprintf('Exception %s caught when creating ImageFile for File id==%s ' . + 'and imgPath==%s: %s', get_class($e), _ve($file->id), + _ve($imgPath), _ve($e->getMessage()))); throw $e; } return $image; @@ -178,42 +170,43 @@ class ImageFile return $this->filepath; } - static function fromUpload($param='upload') + /** + * Process a file upload + * + * Uses MediaFile's `fromUpload` to do the majority of the work and reencodes the image, + * to mitigate injection attacks. + * @param string $param + * @param Profile|null $scoped + * @return ImageFile|MediaFile + * @throws ClientException + * @throws NoResultException + * @throws NoUploadedMediaException + * @throws ServerException + * @throws UnsupportedMediaException + * @throws UseFileAsThumbnailException + */ + public static function fromUpload(string $param='upload', Profile $scoped = null) { - switch ($_FILES[$param]['error']) { - case UPLOAD_ERR_OK: // success, jump out - break; - - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - // TRANS: Exception thrown when too large a file is uploaded. - // TRANS: %s is the maximum file size, for example "500b", "10kB" or "2MB". - throw new Exception(sprintf(_('That file is too big. The maximum file size is %s.'), ImageFile::maxFileSize())); - - case UPLOAD_ERR_PARTIAL: - @unlink($_FILES[$param]['tmp_name']); - // TRANS: Exception thrown when uploading an image and that action could not be completed. - throw new Exception(_('Partial upload.')); - - case UPLOAD_ERR_NO_FILE: - // No file; probably just a non-AJAX submission. - throw new ClientException(_('No file uploaded.')); - - default: - common_log(LOG_ERR, __METHOD__ . ": Unknown upload error " . $_FILES[$param]['error']); - // TRANS: Exception thrown when uploading an image fails for an unknown reason. - throw new Exception(_('System error uploading file.')); - } - - $info = @getimagesize($_FILES[$param]['tmp_name']); - - if (!$info) { - @unlink($_FILES[$param]['tmp_name']); - // TRANS: Exception thrown when uploading a file as image that is not an image or is a corrupt file. - throw new UnsupportedMediaException(_('Not an image or corrupt file.'), '[deleted]'); - } + return parent::fromUpload($param, $scoped); + } - return new ImageFile(null, $_FILES[$param]['tmp_name']); + /** + * Several obscure file types should be normalized to PNG on resize. + * + * Keeps only PNG, JPEG and GIF + * + * @return int + */ + public function preferredType() + { + // Keep only JPEG and GIF in their orignal format + if ($this->type === IMAGETYPE_JPEG || $this->type === IMAGETYPE_GIF) { + return $this->type; + } + // We don't want to save some formats as they are rare, inefficient and antiquated + // thus we can't guarantee clients will support + // So just save it as PNG + return IMAGETYPE_PNG; } /** @@ -224,6 +217,11 @@ class ImageFile * * @param string $outpath * @return ImageFile the image stored at target path + * @throws ClientException + * @throws NoResultException + * @throws ServerException + * @throws UnsupportedMediaException + * @throws UseFileAsThumbnailException */ function copyTo($outpath) { @@ -234,36 +232,35 @@ class ImageFile * Create and save a thumbnail image. * * @param string $outpath - * @param array $box width, height, boundary box (x,y,w,h) defaults to full image + * @param array $box width, height, boundary box (x,y,w,h) defaults to full image * @return string full local filesystem filename + * @throws UnsupportedMediaException + * @throws UseFileAsThumbnailException */ function resizeTo($outpath, array $box=array()) { - $box['width'] = isset($box['width']) ? intval($box['width']) : $this->width; + $box['width'] = isset($box['width']) ? intval($box['width']) : $this->width; $box['height'] = isset($box['height']) ? intval($box['height']) : $this->height; - $box['x'] = isset($box['x']) ? intval($box['x']) : 0; - $box['y'] = isset($box['y']) ? intval($box['y']) : 0; - $box['w'] = isset($box['w']) ? intval($box['w']) : $this->width; - $box['h'] = isset($box['h']) ? intval($box['h']) : $this->height; + $box['x'] = isset($box['x']) ? intval($box['x']) : 0; + $box['y'] = isset($box['y']) ? intval($box['y']) : 0; + $box['w'] = isset($box['w']) ? intval($box['w']) : $this->width; + $box['h'] = isset($box['h']) ? intval($box['h']) : $this->height; if (!file_exists($this->filepath)) { // TRANS: Exception thrown during resize when image has been registered as present, but is no longer there. - throw new Exception(_('Lost our file.')); + throw new Exception(_m('Lost our file.')); } // Don't rotate/crop/scale if it isn't necessary - if ($box['width'] === $this->width - && $box['height'] === $this->height - && $box['x'] === 0 - && $box['y'] === 0 - && $box['w'] === $this->width - && $box['h'] === $this->height - && $this->type == $this->preferredType()) { - if ($this->rotate == 0) { - // No rotational difference, just copy it as-is - @copy($this->filepath, $outpath); - return $outpath; - } elseif (abs($this->rotate) == 90) { + if ($box['width'] === $this->width + && $box['height'] === $this->height + && $box['x'] === 0 + && $box['y'] === 0 + && $box['w'] === $this->width + && $box['h'] === $this->height + && $this->type === $this->preferredType()) { + + if (abs($this->rotate) == 90) { // Box is rotated 90 degrees in either direction, // so we have to redefine x to y and vice versa. $tmp = $box['width']; @@ -278,7 +275,6 @@ class ImageFile } } - if (Event::handle('StartResizeImageFile', array($this, $outpath, $box))) { $this->resizeToFile($outpath, $box); } @@ -294,8 +290,22 @@ class ImageFile return $outpath; } + /** + * Resizes a file. If $box is omitted, the size is not changed, but this is still useful, + * because it will reencode the image in the `self::prefferedType()` format. This only + * applies henceforward, not retroactively + * + * Increases the 'memory_limit' to the one in the 'attachments' section in the config, to + * enable the handling of bigger images, which can cause a peak of memory consumption, while + * encoding + * @param $outpath + * @param array $box + * @throws Exception + */ protected function resizeToFile($outpath, array $box) { + $old_limit = ini_set('memory_limit', common_config('attachments', 'memory_limit')); + $image_src = null; switch ($this->type) { case IMAGETYPE_GIF: $image_src = imagecreatefromgif($this->filepath); @@ -317,7 +327,7 @@ class ImageFile break; default: // TRANS: Exception thrown when trying to resize an unknown file type. - throw new Exception(_('Unknown file type')); + throw new Exception(_m('Unknown file type')); } if ($this->rotate != 0) { @@ -326,30 +336,34 @@ class ImageFile $image_dest = imagecreatetruecolor($box['width'], $box['height']); - if ($this->type == IMAGETYPE_GIF || $this->type == IMAGETYPE_PNG || $this->type == IMAGETYPE_BMP) { + if ($this->type == IMAGETYPE_PNG || $this->type == IMAGETYPE_BMP) { $transparent_idx = imagecolortransparent($image_src); - if ($transparent_idx >= 0) { - + if ($transparent_idx >= 0 && $transparent_idx < 255) { $transparent_color = imagecolorsforindex($image_src, $transparent_idx); - $transparent_idx = imagecolorallocate($image_dest, $transparent_color['red'], $transparent_color['green'], $transparent_color['blue']); + $transparent_idx = imagecolorallocate($image_dest, $transparent_color['red'], + $transparent_color['green'], + $transparent_color['blue']); imagefill($image_dest, 0, 0, $transparent_idx); imagecolortransparent($image_dest, $transparent_idx); } elseif ($this->type == IMAGETYPE_PNG) { - imagealphablending($image_dest, false); $transparent = imagecolorallocatealpha($image_dest, 0, 0, 0, 127); imagefill($image_dest, 0, 0, $transparent); imagesavealpha($image_dest, true); - } } - imagecopyresampled($image_dest, $image_src, 0, 0, $box['x'], $box['y'], $box['width'], $box['height'], $box['w'], $box['h']); + imagecopyresampled($image_dest, $image_src, 0, 0, $box['x'], $box['y'], + $box['width'], $box['height'], $box['w'], $box['h']); + + $type = $this->preferredType(); + $ext = image_type_to_extension($type, true); + $outpath = preg_replace("/\.[^\.]+$/", $ext, $outpath); - switch ($this->preferredType()) { + switch ($type) { case IMAGETYPE_GIF: imagegif($image_dest, $outpath); break; @@ -361,92 +375,26 @@ class ImageFile break; default: // TRANS: Exception thrown when trying resize an unknown file type. - throw new Exception(_('Unknown file type')); + throw new Exception(_m('Unknown file type')); } imagedestroy($image_src); imagedestroy($image_dest); + ini_set('memory_limit', $old_limit); // Restore the old memory limit } - - /** - * Several obscure file types should be normalized to PNG on resize. - * - * @fixme consider flattening anything not GIF or JPEG to PNG - * @return int - */ - function preferredType() - { - if($this->type == IMAGETYPE_BMP) { - //we don't want to save BMP... it's an inefficient, rare, antiquated format - //save png instead - return IMAGETYPE_PNG; - } else if($this->type == IMAGETYPE_WBMP) { - //we don't want to save WBMP... it's a rare format that we can't guarantee clients will support - //save png instead - return IMAGETYPE_PNG; - } else if($this->type == IMAGETYPE_XBM) { - //we don't want to save XBM... it's a rare format that we can't guarantee clients will support - //save png instead - return IMAGETYPE_PNG; - } - return $this->type; - } - - function unlink() + public function unlink() { @unlink($this->filepath); } - static function maxFileSize() - { - $value = ImageFile::maxFileSizeInt(); - - if ($value > 1024 * 1024) { - $value = $value/(1024*1024); - // TRANS: Number of megabytes. %d is the number. - return sprintf(_m('%dMB','%dMB',$value),$value); - } else if ($value > 1024) { - $value = $value/1024; - // TRANS: Number of kilobytes. %d is the number. - return sprintf(_m('%dkB','%dkB',$value),$value); - } else { - // TRANS: Number of bytes. %d is the number. - return sprintf(_m('%dB','%dB',$value),$value); - } - } - - static function maxFileSizeInt() - { - return min(ImageFile::strToInt(ini_get('post_max_size')), - ImageFile::strToInt(ini_get('upload_max_filesize')), - ImageFile::strToInt(ini_get('memory_limit'))); - } - - static function strToInt($str) - { - $unit = substr($str, -1); - $num = substr($str, 0, -1); - - switch(strtoupper($unit)){ - case 'G': - $num *= 1024; - case 'M': - $num *= 1024; - case 'K': - $num *= 1024; - } - - return $num; - } - public function scaleToFit($maxWidth=null, $maxHeight=null, $crop=null) { return self::getScalingValues($this->width, $this->height, - $maxWidth, $maxHeight, $crop, $this->rotate); + $maxWidth, $maxHeight, $crop, $this->rotate); } - /* + /** * Gets scaling values for images of various types. Cropping can be enabled. * * Values will scale _up_ to fit max values if cropping is enabled! @@ -457,14 +405,17 @@ class ImageFile * @param $maxW int Resulting max width * @param $maxH int Resulting max height * @param $crop int Crop to the size (not preserving aspect ratio) + * @param int $rotate + * @return array + * @throws ServerException */ public static function getScalingValues($width, $height, - $maxW=null, $maxH=null, - $crop=null, $rotate=0) + $maxW=null, $maxH=null, + $crop=null, $rotate=0) { $maxW = $maxW ?: common_config('thumbnail', 'width'); $maxH = $maxH ?: common_config('thumbnail', 'height'); - + if ($maxW < 1 || ($maxH !== null && $maxH < 1)) { throw new ServerException('Bad parameters for ImageFile::getScalingValues'); } elseif ($maxH === null) { @@ -479,14 +430,14 @@ class ImageFile $width = $height; $height = $tmp; } - + // Cropping data (for original image size). Default values, 0 and null, // imply no cropping and with preserved aspect ratio (per axis). $cx = 0; // crop x $cy = 0; // crop y $cw = null; // crop area width $ch = null; // crop area height - + if ($crop) { $s_ar = $width / $height; $t_ar = $maxW / $maxH; @@ -513,9 +464,9 @@ class ImageFile } } return array(intval($rw), intval($rh), - intval($cx), intval($cy), - is_null($cw) ? $width : intval($cw), - is_null($ch) ? $height : intval($ch)); + intval($cx), intval($cy), + is_null($cw) ? $width : intval($cw), + is_null($ch) ? $height : intval($ch)); } /** @@ -560,9 +511,9 @@ class ImageFile } if ($width === null) { - $width = common_config('thumbnail', 'width'); + $width = common_config('thumbnail', 'width'); $height = common_config('thumbnail', 'height'); - $crop = common_config('thumbnail', 'crop'); + $crop = common_config('thumbnail', 'crop'); } if (!$upscale) { @@ -589,7 +540,7 @@ class ImageFile 'file_id'=> $this->fileRecord->getID(), 'width' => $width, 'height' => $height, - )); + )); if ($thumb instanceof File_thumbnail) { return $thumb; } @@ -614,7 +565,8 @@ class ImageFile throw new ServerException('Bad thumbnail size parameters.'); } - common_debug(sprintf('Generating a thumbnail of File id==%u of size %ux%u', $this->fileRecord->getID(), $width, $height)); + common_debug(sprintf('Generating a thumbnail of File id==%u of size %ux%u', + $this->fileRecord->getID(), $width, $height)); // Perform resize and store into file $this->resizeTo($outpath, $box); @@ -629,105 +581,9 @@ class ImageFile } return File_thumbnail::saveThumbnail($this->fileRecord->getID(), - null, // no url since we generated it ourselves and can dynamically generate the url - $width, $height, - $outname); + // no url since we generated it ourselves and can dynamically + // generate the url + null, + $width, $height, $outname); } } - -//PHP doesn't (as of 2/24/2010) have an imagecreatefrombmp so conditionally define one -if(!function_exists('imagecreatefrombmp')){ - //taken shamelessly from http://www.php.net/manual/en/function.imagecreatefromwbmp.php#86214 - function imagecreatefrombmp($p_sFile) - { - // Load the image into a string - $file = fopen($p_sFile,"rb"); - $read = fread($file,10); - while(!feof($file)&&($read<>"")) - $read .= fread($file,1024); - - $temp = unpack("H*",$read); - $hex = $temp[1]; - $header = substr($hex,0,108); - - // Process the header - // Structure: http://www.fastgraph.com/help/bmp_header_format.html - if (substr($header,0,4)=="424d") - { - // Cut it in parts of 2 bytes - $header_parts = str_split($header,2); - - // Get the width 4 bytes - $width = hexdec($header_parts[19].$header_parts[18]); - - // Get the height 4 bytes - $height = hexdec($header_parts[23].$header_parts[22]); - - // Unset the header params - unset($header_parts); - } - - // Define starting X and Y - $x = 0; - $y = 1; - - // Create newimage - $image = imagecreatetruecolor($width,$height); - - // Grab the body from the image - $body = substr($hex,108); - - // Calculate if padding at the end-line is needed - // Divided by two to keep overview. - // 1 byte = 2 HEX-chars - $body_size = (strlen($body)/2); - $header_size = ($width*$height); - - // Use end-line padding? Only when needed - $usePadding = ($body_size>($header_size*3)+4); - - // Using a for-loop with index-calculation instaid of str_split to avoid large memory consumption - // Calculate the next DWORD-position in the body - for ($i=0;$i<$body_size;$i+=3) - { - // Calculate line-ending and padding - if ($x>=$width) - { - // If padding needed, ignore image-padding - // Shift i to the ending of the current 32-bit-block - if ($usePadding) - $i += $width%4; - - // Reset horizontal position - $x = 0; - - // Raise the height-position (bottom-up) - $y++; - - // Reached the image-height? Break the for-loop - if ($y>$height) - break; - } - - // Calculation of the RGB-pixel (defined as BGR in image-data) - // Define $i_pos as absolute position in the body - $i_pos = $i*2; - $r = hexdec($body[$i_pos+4].$body[$i_pos+5]); - $g = hexdec($body[$i_pos+2].$body[$i_pos+3]); - $b = hexdec($body[$i_pos].$body[$i_pos+1]); - - // Calculate and draw the pixel - $color = imagecolorallocate($image,$r,$g,$b); - imagesetpixel($image,$x,$height-$y,$color); - - // Raise the horizontal position - $x++; - } - - // Unset the body / free the memory - unset($body); - - // Return image-object - return $image; - } -} // if(!function_exists('imagecreatefrombmp')) diff --git a/lib/mediafile.php b/lib/mediafile.php index 803cbe0a4c..bed3300d36 100644 --- a/lib/mediafile.php +++ b/lib/mediafile.php @@ -1,12 +1,8 @@ . * * @category Media - * @package StatusNet + * @package GNUsocial * @author Robin Millette + * @author Miguel Dantas * @author Zach Copley - * @copyright 2008-2009 StatusNet, Inc. + * @author Mikael Nordfeldth + * @copyright 2008-2009, 2019 Free Software Foundation http://fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ + * @link https://www.gnu.org/software/social/ */ if (!defined('GNUSOCIAL')) { exit(1); } + +/** + * Class responsible for abstracting media files + */ class MediaFile { - var $filename = null; - var $fileRecord = null; - var $fileurl = null; - var $short_fileurl = null; - var $mimetype = null; + public $id = null; + public $filepath = null; + public $filename = null; + public $fileRecord = null; + public $fileurl = null; + public $short_fileurl = null; + public $mimetype = null; - function __construct($filename = null, $mimetype = null, $filehash = null) + /** + * @param string $filepath The path of the file this media refers to. Required + * @param string $mimetype The mimetype of the file. Required + * @param $filehash The hash of the file, if known. Optional + * @param int|null $id The DB id of the file. Int if known, null if not. + * If null, it searches for it. If -1, it skips all DB + * interactions (useful for temporary objects) + * @throws ClientException + * @throws NoResultException + * @throws ServerException + */ + public function __construct(string $filepath, string $mimetype, $filehash = null, $id = null) { - $this->filename = $filename; - $this->mimetype = $mimetype; - $this->filehash = $filehash; - $this->fileRecord = $this->storeFile(); + $this->filepath = $filepath; + $this->filename = basename($this->filepath); + $this->mimetype = $mimetype; + $this->filehash = self::getHashOfFile($this->filepath, $filehash); + $this->id = $id; + + // If id is -1, it means we're dealing with a temporary object and don't want to store it in the DB, + // or add redirects + if ($this->id !== -1) { + if (!empty($this->id)) { + // If we have an id, load it + $this->fileRecord = new File(); + $this->fileRecord->id = $this->id; + if (!$this->fileRecord->find(true)) { + // If we have set an ID, we need that ID to exist! + throw new NoResultException($this->fileRecord); + } + } else { + // Otherwise, store it + $this->fileRecord = $this->storeFile(); + } - $this->fileurl = common_local_url('attachment', - array('attachment' => $this->fileRecord->id)); + $this->fileurl = common_local_url( + 'attachment', + array('attachment' => $this->fileRecord->id) + ); - $this->maybeAddRedir($this->fileRecord->id, $this->fileurl); - $this->short_fileurl = common_shorten_url($this->fileurl); - $this->maybeAddRedir($this->fileRecord->id, $this->short_fileurl); + $this->maybeAddRedir($this->fileRecord->id, $this->fileurl); + $this->short_fileurl = common_shorten_url($this->fileurl); + $this->maybeAddRedir($this->fileRecord->id, $this->short_fileurl); + } } public function attachToNotice(Notice $notice) @@ -77,8 +112,7 @@ class MediaFile function delete() { - $filepath = File::path($this->filename); - @unlink($filepath); + @unlink($this->filepath); } public function getFile() @@ -90,18 +124,38 @@ class MediaFile return $this->fileRecord; } - protected function storeFile() + /** + * Calculate the hash of a file. + * + * This won't work for files >2GiB because PHP uses only 32bit. + * @param string $filepath + * @param string|null $filehash + * @return string + * @throws ServerException + */ + public static function getHashOfFile(string $filepath, $filehash = null) { - $filepath = File::path($this->filename); - if (!empty($this->filename) && $this->filehash === null) { + assert(!empty($filepath), __METHOD__ . ": filepath cannot be null"); + if ($filehash === null) { // Calculate if we have an older upload method somewhere (Qvitter) that // doesn't do this before calling new MediaFile on its local files... - $this->filehash = hash_file(File::FILEHASH_ALG, $filepath); - if ($this->filehash === false) { + $filehash = hash_file(File::FILEHASH_ALG, $filepath); + if ($filehash === false) { throw new ServerException('Could not read file for hashing'); } } + return $filehash; + } + /** + * Retrieve or insert as a file in the DB + * + * @return object File + * @throws ClientException + * @throws ServerException + */ + protected function storeFile() + { try { $file = File::getByHash($this->filehash); // We're done here. Yes. Already. We assume sha256 won't collide on us anytime soon. @@ -118,14 +172,13 @@ class MediaFile $file->urlhash = File::hashurl($fileurl); $file->url = $fileurl; $file->filehash = $this->filehash; - $file->size = filesize($filepath); + $file->size = filesize($this->filepath); if ($file->size === false) { throw new ServerException('Could not read file to get its size'); } $file->date = time(); $file->mimetype = $this->mimetype; - $file_id = $file->insert(); if ($file_id===false) { @@ -163,10 +216,19 @@ class MediaFile $this->maybeAddRedir($file->id, $short); } - function maybeAddRedir($file_id, $url) + /** + * Adds Redir if needed. + * + * @param $file_id + * @param $url + * @return bool false if no need to add, true if added + * @throws ClientException If failed adding + */ + public function maybeAddRedir($file_id, $url) { try { - $file_redir = File_redirection::getByUrl($url); + File_redirection::getByUrl($url); + return false; } catch (NoResultException $e) { $file_redir = new File_redirection; $file_redir->urlhash = File::hashurl($url); @@ -180,10 +242,76 @@ class MediaFile // TRANS: Client exception thrown when a database error was thrown during a file upload operation. throw new ClientException(_('There was a database error while saving your file. Please try again.')); } + return $result; + } + } + + /** + * The maximum allowed file size, as a string + */ + static function maxFileSize() + { + $value = self::maxFileSizeInt(); + if ($value > 1024 * 1024) { + $value = $value/(1024*1024); + // TRANS: Number of megabytes. %d is the number. + return sprintf(_m('%dMB','%dMB',$value),$value); + } else if ($value > 1024) { + $value = $value/1024; + // TRANS: Number of kilobytes. %d is the number. + return sprintf(_m('%dkB','%dkB',$value),$value); + } else { + // TRANS: Number of bytes. %d is the number. + return sprintf(_m('%dB','%dB',$value),$value); } } - static function fromUpload($param='media', Profile $scoped=null) + /** + * The maximum allowed file size, as an int + */ + static function maxFileSizeInt() + { + return min(self::sizeStrToInt(ini_get('post_max_size')), + self::sizeStrToInt(ini_get('upload_max_filesize')), + self::sizeStrToInt(ini_get('memory_limit'))); + } + + /** + * Convert a string representing a file size (with units), to an int + * @param $str + * @return bool|int|string + */ + public static function sizeStrToInt($str) + { + $unit = substr($str, -1); + $num = substr($str, 0, -1); + switch(strtoupper($unit)){ + case 'G': + $num *= 1024; + case 'M': + $num *= 1024; + case 'K': + $num *= 1024; + } + return $num; + } + + /** + * Create a new MediaFile or ImageFile object from an upload + * + * Tries to set the mimetype correctly, using the most secure method available and rejects the file otherwise. + * In case the upload is an image, this function returns an new ImageFile (which extends MediaFile) + * @param string $param + * @param Profile|null $scoped + * @return ImageFile|MediaFile + * @throws ClientException + * @throws NoResultException + * @throws NoUploadedMediaException + * @throws ServerException + * @throws UnsupportedMediaException + * @throws UseFileAsThumbnailException + */ + public static function fromUpload(string $param='media', Profile $scoped=null) { // The existence of the "error" element means PHP has processed it properly even if it was ok. if (!isset($_FILES[$param]) || !isset($_FILES[$param]['error'])) { @@ -194,19 +322,15 @@ class MediaFile case UPLOAD_ERR_OK: // success, jump out break; case UPLOAD_ERR_INI_SIZE: - // TRANS: Client exception thrown when an uploaded file is larger than set in php.ini. - throw new ClientException(_('The uploaded file exceeds the ' . - 'upload_max_filesize directive in php.ini.')); case UPLOAD_ERR_FORM_SIZE: - throw new ClientException( - // TRANS: Client exception. - _('The uploaded file exceeds the MAX_FILE_SIZE directive' . - ' that was specified in the HTML form.')); + // TRANS: Exception thrown when too large a file is uploaded. + // TRANS: %s is the maximum file size, for example "500b", "10kB" or "2MB". + throw new ClientException(sprintf(_('That file is too big. The maximum file size is %s.'), + self::maxFileSize())); case UPLOAD_ERR_PARTIAL: @unlink($_FILES[$param]['tmp_name']); // TRANS: Client exception. - throw new ClientException(_('The uploaded file was only' . - ' partially uploaded.')); + throw new ClientException(_('The uploaded file was only partially uploaded.')); case UPLOAD_ERR_NO_FILE: // No file; probably just a non-AJAX submission. throw new NoUploadedMediaException($param); @@ -220,39 +344,23 @@ class MediaFile // TRANS: Client exception thrown when a file upload operation has been stopped by an extension. throw new ClientException(_('File upload stopped by extension.')); default: - common_log(LOG_ERR, __METHOD__ . ": Unknown upload error " . - $_FILES[$param]['error']); + common_log(LOG_ERR, __METHOD__ . ": Unknown upload error " . $_FILES[$param]['error']); // TRANS: Client exception thrown when a file upload operation has failed with an unknown reason. throw new ClientException(_('System error uploading file.')); } - // TODO: Make documentation clearer that this won't work for files >2GiB because - // PHP is stupid in its 32bit head. But noone accepts 2GiB files with PHP - // anyway... I hope. - $filehash = hash_file(File::FILEHASH_ALG, $_FILES[$param]['tmp_name']); + $filehash = strtolower(self::getHashOfFile($_FILES[$param]['tmp_name'])); try { $file = File::getByHash($filehash); // If no exception is thrown the file exists locally, so we'll use that and just add redirections. // but if the _actual_ locally stored file doesn't exist, getPath will throw FileNotFoundException - $filename = basename($file->getPath()); - $mimetype = $file->mimetype; - - } catch (FileNotFoundException $e) { - // The file does not exist in our local filesystem, so store this upload. - - if (!move_uploaded_file($_FILES[$param]['tmp_name'], $e->path)) { - // TRANS: Client exception thrown when a file upload operation fails because the file could - // TRANS: not be moved from the temporary folder to the permanent file location. - throw new ClientException(_('File could not be moved to destination directory.')); - } - - $filename = basename($file->getPath()); + $filepath = $file->getPath(); $mimetype = $file->mimetype; - - } catch (NoResultException $e) { + // XXX PHP: Upgrade to PHP 7.1 + // catch (FileNotFoundException | NoResultException $e) + } catch (Exception $e) { // We have to save the upload as a new local file. This is the normal course of action. - if ($scoped instanceof Profile) { // Throws exception if additional size does not respect quota // This test is only needed, of course, if we're uploading something new. @@ -260,21 +368,34 @@ class MediaFile } $mimetype = self::getUploadedMimeType($_FILES[$param]['tmp_name'], $_FILES[$param]['name']); - $basename = basename($_FILES[$param]['name']); + $media = common_get_mime_media($mimetype); - $filename = strtolower($filehash) . '.' . File::guessMimeExtension($mimetype, $basename); + $basename = basename($_FILES[$param]['name']); + $filename = $filehash . '.' . File::guessMimeExtension($mimetype, $basename); $filepath = File::path($filename); - $result = move_uploaded_file($_FILES[$param]['tmp_name'], $filepath); if (!$result) { // TRANS: Client exception thrown when a file upload operation fails because the file could // TRANS: not be moved from the temporary folder to the permanent file location. + // UX: too specific throw new ClientException(_('File could not be moved to destination directory.')); } - } - return new MediaFile($filename, $mimetype, $filehash); + if ($media === 'image') { + // Use -1 for the id to avoid adding this temporary file to the DB + $img = new ImageFile(-1, $filepath); + // Validate the image by reencoding it. Additionally normalizes old formats to PNG, + // keeping JPEG and GIF untouched + $outpath = $img->resizeTo($img->filepath); + $ext = image_type_to_extension($img->preferredType()); + $filename = $filehash . $ext; + $filepath = File::path($filename); + $result = rename($outpath, $filepath); + return new ImageFile(null, $filepath); + } + } + return new MediaFile($filepath, $mimetype, $filehash); } static function fromFilehandle($fh, Profile $scoped=null) { @@ -336,19 +457,109 @@ class MediaFile /** * Attempt to identify the content type of a given file. - * + * * @param string $filepath filesystem path as string (file must exist) - * @param string $originalFilename (optional) for extension-based detection + * @param bool $originalFilename (optional) for extension-based detection * @return string - * - * @fixme this seems to tie a front-end error message in, kinda confusing - * + * * @throws ClientException if type is known, but not supported for local uploads + * @throws ServerException + * @fixme this seems to tie a front-end error message in, kinda confusing + * */ - static function getUploadedMimeType($filepath, $originalFilename=false) { + static function getUploadedMimeType(string $filepath, $originalFilename=false) { // We only accept filenames to existing files - $mimelookup = new finfo(FILEINFO_MIME_TYPE); - $mimetype = $mimelookup->file($filepath); + + $mimetype = null; + + // From CodeIgniter + // We'll need this to validate the MIME info string (e.g. text/plain; charset=us-ascii) + $regexp = '/^([a-z\-]+\/[a-z0-9\-\.\+]+)(;\s.+)?$/'; + /** + * Fileinfo extension - most reliable method + * + * Apparently XAMPP, CentOS, cPanel and who knows what + * other PHP distribution channels EXPLICITLY DISABLE + * ext/fileinfo, which is otherwise enabled by default + * since PHP 5.3 ... + */ + if (function_exists('finfo_file')) + { + $finfo = @finfo_open(FILEINFO_MIME); + // It is possible that a FALSE value is returned, if there is no magic MIME database + // file found on the system + if (is_resource($finfo)) + { + $mime = @finfo_file($finfo, $filepath); + finfo_close($finfo); + /* According to the comments section of the PHP manual page, + * it is possible that this function returns an empty string + * for some files (e.g. if they don't exist in the magic MIME database) + */ + if (is_string($mime) && preg_match($regexp, $mime, $matches)) + { + $mimetype = $matches[1]; + } + } + } + /* This is an ugly hack, but UNIX-type systems provide a "native" way to detect the file type, + * which is still more secure than depending on the value of $_FILES[$field]['type'], and as it + * was reported in issue #750 (https://github.com/EllisLab/CodeIgniter/issues/750) - it's better + * than mime_content_type() as well, hence the attempts to try calling the command line with + * three different functions. + * + * Notes: + * - the DIRECTORY_SEPARATOR comparison ensures that we're not on a Windows system + * - many system admins would disable the exec(), shell_exec(), popen() and similar functions + * due to security concerns, hence the function_usable() checks + */ + if (DIRECTORY_SEPARATOR !== '\\') { + $cmd = 'file --brief --mime '.escapeshellarg($filepath).' 2>&1'; + if (function_exists('exec')) { + /* This might look confusing, as $mime is being populated with all of the output + * when set in the second parameter. However, we only need the last line, which is + * the actual return value of exec(), and as such - it overwrites anything that could + * already be set for $mime previously. This effectively makes the second parameter a + * dummy value, which is only put to allow us to get the return status code. + */ + $mime = @exec($cmd, $mime, $return_status); + if ($return_status === 0 && is_string($mime) && preg_match($regexp, $mime, $matches)) { + $mimetype = $matches[1]; + } + } + if (function_exists('shell_exec')) { + $mime = @shell_exec($cmd); + if (strlen($mime) > 0) { + $mime = explode("\n", trim($mime)); + if (preg_match($regexp, $mime[(count($mime) - 1)], $matches)) { + $mimetype = $matches[1]; + } + } + } + if (function_exists('popen')) { + $proc = @popen($cmd, 'r'); + if (is_resource($proc)) { + $mime = @fread($proc, 512); + @pclose($proc); + if ($mime !== false) { + $mime = explode("\n", trim($mime)); + if (preg_match($regexp, $mime[(count($mime) - 1)], $matches)) { + $mimetype = $matches[1]; + } + } + } + } + } + // Fall back to mime_content_type(), if available (still better than $_FILES[$field]['type']) + if (function_exists('mime_content_type')) + { + $mimetype = @mime_content_type($filepath); + // It's possible that mime_content_type() returns FALSE or an empty string + if ($mimetype == false && strlen($mimetype) > 0) + { + throw new ServerException(_m('Could not determine file\'s MIME type.')); + } + } // Unclear types are such that we can't really tell by the auto // detect what they are (.bin, .exe etc. are just "octet-stream") -- 2.39.5