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..61e611a124 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 @@ -71,20 +80,20 @@ class File extends Managed_DataObject ); } - public static function isProtected($url) { + public static function isProtected($url) + { + $protected_urls_exps = array( + 'https://www.facebook.com/login.php', + common_path('main/login') + ); - $protected_urls_exps = array( - 'https://www.facebook.com/login.php', - common_path('main/login') - ); + foreach ($protected_urls_exps as $protected_url_exp) { + if (preg_match('!^'.preg_quote($protected_url_exp).'(.*)$!i', $url) === 1) { + return true; + } + } - foreach ($protected_urls_exps as $protected_url_exp) { - if (preg_match('!^'.preg_quote($protected_url_exp).'(.*)$!i', $url) === 1) { - return true; - } - } - - return false; + return false; } /** @@ -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) { @@ -142,16 +152,27 @@ class File extends Managed_DataObject $file = new File; $file->url = $given_url; - if (!empty($redir_data['protected'])) $file->protected = $redir_data['protected']; - if (!empty($redir_data['title'])) $file->title = $redir_data['title']; - if (!empty($redir_data['type'])) $file->mimetype = $redir_data['type']; - if (!empty($redir_data['size'])) $file->size = intval($redir_data['size']); - if (isset($redir_data['time']) && $redir_data['time'] > 0) $file->date = intval($redir_data['time']); + if (!empty($redir_data['protected'])) { + $file->protected = $redir_data['protected']; + } + if (!empty($redir_data['title'])) { + $file->title = $redir_data['title']; + } + if (!empty($redir_data['type'])) { + $file->mimetype = $redir_data['type']; + } + if (!empty($redir_data['size'])) { + $file->size = intval($redir_data['size']); + } + if (isset($redir_data['time']) && $redir_data['time'] > 0) { + $file->date = intval($redir_data['time']); + } $file->saveFile(); return $file; } - public function saveFile() { + public function saveFile() + { $this->urlhash = self::hashurl($this->url); if (!Event::handle('StartFileSaveNew', array(&$this))) { @@ -183,7 +204,8 @@ class File extends Managed_DataObject * * @throws ServerException on failure */ - public static function processNew($given_url, Notice $notice=null, $followRedirects=true) { + public static function processNew($given_url, Notice $notice=null, $followRedirects=true) + { if (empty($given_url)) { throw new ServerException('No given URL to process'); } @@ -212,12 +234,13 @@ class File extends Managed_DataObject return $file; } - public static function respectsQuota(Profile $scoped, $fileSize) { + public static function respectsQuota(Profile $scoped, $fileSize) + { if ($fileSize > common_config('attachments', 'file_quota')) { // TRANS: Message used to be inserted as %2$s in the text "No file may // TRANS: be larger than %1$d byte and the file you sent was %2$s.". // TRANS: %1$d is the number of bytes of an uploaded file. - $fileSizeText = sprintf(_m('%1$d byte','%1$d bytes',$fileSize),$fileSize); + $fileSizeText = sprintf(_m('%1$d byte', '%1$d bytes', $fileSize), $fileSize); $fileQuota = common_config('attachments', 'file_quota'); // TRANS: Message given if an upload is larger than the configured maximum. @@ -225,10 +248,16 @@ class File extends Managed_DataObject // TRANS: %2$s is the proper form of "n bytes". This is the only ways to have // TRANS: gettext support multiple plurals in the same message, unfortunately... throw new ClientException( - sprintf(_m('No file may be larger than %1$d byte and the file you sent was %2$s. Try to upload a smaller version.', - 'No file may be larger than %1$d bytes and the file you sent was %2$s. Try to upload a smaller version.', - $fileQuota), - $fileQuota, $fileSizeText)); + sprintf( + _m( + 'No file may be larger than %1$d byte and the file you sent was %2$s. Try to upload a smaller version.', + 'No file may be larger than %1$d bytes and the file you sent was %2$s. Try to upload a smaller version.', + $fileQuota + ), + $fileQuota, + $fileSizeText + ) + ); } $file = new File; @@ -241,10 +270,15 @@ class File extends Managed_DataObject // TRANS: Message given if an upload would exceed user quota. // TRANS: %d (number) is the user quota in bytes and is used for plural. throw new ClientException( - sprintf(_m('A file this large would exceed your user quota of %d byte.', - 'A file this large would exceed your user quota of %d bytes.', - common_config('attachments', 'user_quota')), - common_config('attachments', 'user_quota'))); + sprintf( + _m( + 'A file this large would exceed your user quota of %d byte.', + 'A file this large would exceed your user quota of %d bytes.', + common_config('attachments', 'user_quota') + ), + common_config('attachments', 'user_quota') + ) + ); } $query .= ' AND EXTRACT(month FROM file.modified) = EXTRACT(month FROM now()) and EXTRACT(year FROM file.modified) = EXTRACT(year FROM now())'; $file->query($query); @@ -254,10 +288,15 @@ class File extends Managed_DataObject // TRANS: Message given id an upload would exceed a user's monthly quota. // TRANS: $d (number) is the monthly user quota in bytes and is used for plural. throw new ClientException( - sprintf(_m('A file this large would exceed your monthly quota of %d byte.', - 'A file this large would exceed your monthly quota of %d bytes.', - common_config('attachments', 'monthly_quota')), - common_config('attachments', 'monthly_quota'))); + sprintf( + _m( + 'A file this large would exceed your monthly quota of %d byte.', + 'A file this large would exceed your monthly quota of %d bytes.', + common_config('attachments', 'monthly_quota') + ), + common_config('attachments', 'monthly_quota') + ) + ); } return true; } @@ -274,7 +313,7 @@ class File extends Managed_DataObject // where should the file go? - static function filename(Profile $profile, $origname, $mimetype) + public static function filename(Profile $profile, $origname, $mimetype) { $ext = self::guessMimeExtension($mimetype, $origname); @@ -298,10 +337,12 @@ 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) + public static function guessMimeExtension($mimetype, $filename=null) { try { // first see if we know the extension for our mimetype @@ -349,16 +390,17 @@ class File extends Managed_DataObject /** * Validation for as-saved base filenames + * @param $filename + * @return false|int */ - static function validFilename($filename) + public static function validFilename($filename) { return preg_match('/^[A-Za-z0-9._-]+$/', $filename); } - static function tryFilename($filename) + public static function tryFilename($filename) { - if (!self::validFilename($filename)) - { + if (!self::validFilename($filename)) { throw new InvalidFilenameException($filename); } // if successful, return the filename for easy if-statementing @@ -366,9 +408,11 @@ class File extends Managed_DataObject } /** - * @throws ClientException on invalid filename + * @param $filename + * @return string + * @throws InvalidFilenameException */ - static function path($filename) + public static function path($filename) { self::tryFilename($filename); @@ -381,19 +425,18 @@ class File extends Managed_DataObject return $dir . $filename; } - static function url($filename) + public static function url($filename) { self::tryFilename($filename); - if (common_config('site','private')) { - - return common_local_url('getfile', - array('filename' => $filename)); - + if (common_config('site', 'private')) { + return common_local_url( + 'getfile', + array('filename' => $filename) + ); } if (GNUsocial::useHTTPS()) { - $sslserver = common_config('attachments', 'sslserver'); if (empty($sslserver)) { @@ -402,7 +445,7 @@ class File extends Managed_DataObject if (is_string(common_config('site', 'sslserver')) && mb_strlen(common_config('site', 'sslserver')) > 0) { $server = common_config('site', 'sslserver'); - } else if (common_config('site', 'server')) { + } elseif (common_config('site', 'server')) { $server = common_config('site', 'server'); } $path = common_config('site', 'path') . '/file/'; @@ -439,9 +482,10 @@ class File extends Managed_DataObject return $protocol.'://'.$server.$path.$filename; } - static $_enclosures = array(); + public static $_enclosures = array(); - function getEnclosure(){ + public function getEnclosure() + { if (isset(self::$_enclosures[$this->getID()])) { return self::$_enclosures[$this->getID()]; } @@ -515,8 +559,12 @@ class File extends Managed_DataObject } } - return $image->getFileThumbnail($width, $height, $crop, - !is_null($upscale) ? $upscale : common_config('thumbnail', 'upscale')); + return $image->getFileThumbnail( + $width, + $height, + $crop, + !is_null($upscale) ? $upscale : common_config('thumbnail', 'upscale') + ); } public function getPath() @@ -534,7 +582,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) { @@ -554,7 +604,7 @@ class File extends Managed_DataObject return $this->url; } - static public function getByUrl($url) + public static function getByUrl($url) { $file = new File(); $file->urlhash = self::hashurl($url); @@ -565,9 +615,11 @@ 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) + public static function getByHash($hashstr) { $file = new File(); $file->filehash = strtolower($hashstr); @@ -584,10 +636,13 @@ class File extends Managed_DataObject throw new ServerException('URL already exists in DB'); } $sql = 'UPDATE %1$s SET urlhash=%2$s, url=%3$s WHERE urlhash=%4$s;'; - $result = $this->query(sprintf($sql, $this->tableName(), - $this->_quote((string)self::hashurl($url)), - $this->_quote((string)$url), - $this->_quote((string)$this->urlhash))); + $result = $this->query(sprintf( + $sql, + $this->tableName(), + $this->_quote((string)self::hashurl($url)), + $this->_quote((string)$url), + $this->_quote((string)$this->urlhash) + )); if ($result === false) { common_log_db_error($this, 'UPDATE', __FILE__); throw new ServerException("Could not UPDATE {$this->tableName()}.url"); @@ -604,7 +659,7 @@ class File extends Managed_DataObject * @return void */ - function blowCache($last=false) + public function blowCache($last=false) { self::blow('file:notice-ids:%s', $this->id); if ($last) { @@ -624,7 +679,7 @@ class File extends Managed_DataObject * @return array ids of notices that link to this file */ - function stream($offset=0, $limit=NOTICES_PER_PAGE, $since_id=0, $max_id=0) + public function stream($offset=0, $limit=NOTICES_PER_PAGE, $since_id=0, $max_id=0) { // FIXME: Try to get the Profile::current() here in some other way to avoid mixing // the current session user with possibly background/queue processing. @@ -632,14 +687,13 @@ class File extends Managed_DataObject return $stream->getNotices($offset, $limit, $since_id, $max_id); } - function noticeCount() + public function noticeCount() { $cacheKey = sprintf('file:notice-count:%d', $this->id); $count = self::cacheGet($cacheKey); if ($count === false) { - $f2p = new File_to_post(); $f2p->file_id = $this->id; @@ -647,7 +701,7 @@ class File extends Managed_DataObject $count = $f2p->count(); self::cacheSet($cacheKey, $count); - } + } return $count; } @@ -704,7 +758,7 @@ class File extends Managed_DataObject return $this->update($orig); } - static public function hashurl($url) + public static function hashurl($url) { if (empty($url)) { throw new Exception('No URL provided to hash algorithm.'); @@ -712,7 +766,7 @@ class File extends Managed_DataObject return hash(self::URLHASH_ALG, $url); } - static public function beforeSchemaUpdate() + public static function beforeSchemaUpdate() { $table = strtolower(get_called_class()); $schema = Schema::get(); @@ -745,7 +799,7 @@ class File extends Managed_DataObject $dupfile->update($orig); print "\nDeleting duplicate entries of too long URL on $table id: {$file->id} ["; // only start deleting with this fetch. - while($dupfile->fetch()) { + while ($dupfile->fetch()) { common_log(LOG_INFO, sprintf('Deleting duplicate File entry of %1$d: %2$d (original URL: %3$s collides with these first 191 characters: %4$s', $dupfile->id, $file->id, $origurl, $file->shortenedurl)); print "."; $dupfile->delete(); @@ -761,13 +815,13 @@ class File extends Managed_DataObject echo "\n...now running hacky pre-schemaupdate change for $table:"; // We have to create a urlhash that is _not_ the primary key, // transfer data and THEN run checkSchema - $schemadef['fields']['urlhash'] = array ( + $schemadef['fields']['urlhash'] = array( 'type' => 'varchar', 'length' => 64, 'not null' => false, // this is because when adding column, all entries will _be_ NULL! 'description' => 'sha256 of destination URL (url field)', ); - $schemadef['fields']['url'] = array ( + $schemadef['fields']['url'] = array( 'type' => 'text', 'description' => 'destination URL after following possible redirections', ); @@ -780,11 +834,13 @@ class File extends Managed_DataObject // urlhash is hash('sha256', $url) in the File table echo "Updating urlhash fields in $table table..."; // Maybe very MySQL specific :( - $tablefix->query(sprintf('UPDATE %1$s SET %2$s=%3$s;', - $schema->quoteIdentifier($table), - 'urlhash', + $tablefix->query(sprintf( + 'UPDATE %1$s SET %2$s=%3$s;', + $schema->quoteIdentifier($table), + 'urlhash', // The line below is "result of sha256 on column `url`" - 'SHA2(url, 256)')); + 'SHA2(url, 256)' + )); echo "DONE.\n"; echo "Resuming core schema upgrade..."; } 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..6794971fbb 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 + 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 - protected $fileRecord = null; - - function __construct($id, $filepath) + 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,14 @@ 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 +175,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; + return parent::fromUpload($param, $scoped); + } - 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.')); + /** + * 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; } - - $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 new ImageFile(null, $_FILES[$param]['tmp_name']); + // 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,8 +222,13 @@ 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) + public function copyTo($outpath) { return new ImageFile(null, $this->resizeTo($outpath)); } @@ -234,36 +237,34 @@ 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()) + public 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 +279,6 @@ class ImageFile } } - if (Event::handle('StartResizeImageFile', array($this, $outpath, $box))) { $this->resizeToFile($outpath, $box); } @@ -294,8 +294,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 +331,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 +340,45 @@ 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'] + ); - switch ($this->preferredType()) { + $type = $this->preferredType(); + $ext = image_type_to_extension($type, true); + $outpath = preg_replace("/\.[^\.]+$/", $ext, $outpath); + + switch ($type) { case IMAGETYPE_GIF: imagegif($image_dest, $outpath); break; @@ -361,92 +390,32 @@ 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); + return self::getScalingValues( + $this->width, + $this->height, + $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 +426,21 @@ 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) - { + public static function getScalingValues( + $width, + $height, + $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 +455,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 +489,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)); } /** @@ -539,7 +515,7 @@ class ImageFile // We read through the file til we reach the end of the file, or we've found // at least 2 frame headers - while(!feof($fh) && $count < 2) { + while (!feof($fh) && $count < 2) { $chunk = fread($fh, 1024 * 100); //read 100kb at a time $count += preg_match_all('#\x00\x21\xF9\x04.{4}\x00\x2C#s', $chunk, $matches); // rewind in case we ended up in the middle of the header, but avoid @@ -560,9 +536,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 +565,7 @@ class ImageFile 'file_id'=> $this->fileRecord->getID(), 'width' => $width, 'height' => $height, - )); + )); if ($thumb instanceof File_thumbnail) { return $thumb; } @@ -610,11 +586,16 @@ class ImageFile || $box['w'] < 1 || $box['x'] >= $this->width || $box['h'] < 1 || $box['y'] >= $this->height) { // Fail on bad width parameter. If this occurs, it's due to algorithm in ImageFile->scaleToFit - common_debug("Boundary box parameters for resize of {$this->filepath} : ".var_export($box,true)); + common_debug("Boundary box parameters for resize of {$this->filepath} : ".var_export($box, true)); 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); @@ -628,106 +609,14 @@ class ImageFile // $this->getPath() says the file doesn't exist anyway, so no point in trying to delete it! } - return File_thumbnail::saveThumbnail($this->fileRecord->getID(), - null, // no url since we generated it ourselves and can dynamically generate the url - $width, $height, - $outname); + return File_thumbnail::saveThumbnail( + $this->fileRecord->getID(), + // 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..dd8f82720c 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); } +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; - $this->fileurl = common_local_url('attachment', - array('attachment' => $this->fileRecord->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->maybeAddRedir($this->fileRecord->id, $this->fileurl); - $this->short_fileurl = common_shorten_url($this->fileurl); - $this->maybeAddRedir($this->fileRecord->id, $this->short_fileurl); + $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); + } } public function attachToNotice(Notice $notice) @@ -65,20 +102,19 @@ class MediaFile return File::path($this->filename); } - function shortUrl() + public function shortUrl() { return $this->short_fileurl; } - function getEnclosure() + public function getEnclosure() { return $this->getFile()->getEnclosure(); } - function delete() + public function delete() { - $filepath = File::path($this->filename); - @unlink($filepath); + @unlink($this->filepath); } public function getFile() @@ -90,18 +126,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 +174,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) { @@ -158,15 +213,24 @@ class MediaFile return $file; } - function rememberFile($file, $short) + public function rememberFile($file, $short) { $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 +244,80 @@ 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; } } - static function fromUpload($param='media', Profile $scoped=null) + /** + * The maximum allowed file size, as a string + */ + public 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); + } elseif ($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); + } + } + + /** + * The maximum allowed file size, as an int + */ + public 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; + // no break + case 'M': + $num *= 1024; + // no break + 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 +328,17 @@ 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 +352,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()); + $filepath = $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()); - $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,24 +376,38 @@ class MediaFile } $mimetype = self::getUploadedMimeType($_FILES[$param]['tmp_name'], $_FILES[$param]['name']); + $media = common_get_mime_media($mimetype); + $basename = basename($_FILES[$param]['name']); - - $filename = strtolower($filehash) . '.' . File::guessMimeExtension($mimetype, $basename); + $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) { + public static function fromFilehandle($fh, Profile $scoped=null) + { $stream = stream_get_meta_data($fh); // So far we're only handling filehandles originating from tmpfile(), // so we can always do hash_file on $stream['uri'] as far as I can tell! @@ -310,7 +440,6 @@ class MediaFile $filename = basename($file->getPath()); $mimetype = $file->mimetype; - } catch (NoResultException $e) { if ($scoped instanceof Profile) { File::respectsQuota($scoped, filesize($stream['uri'])); @@ -327,7 +456,7 @@ class MediaFile common_log(LOG_ERR, 'File could not be moved (or chmodded) from '._ve($stream['uri']) . ' to ' . _ve($filepath)); // 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.' )); + throw new ClientException(_('File could not be moved to destination directory.')); } } @@ -336,19 +465,105 @@ 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) { + public 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")