X-Git-Url: https://git.mxchange.org/?a=blobdiff_plain;f=classes%2FFile.php;h=65414977d3114ed5b8fdcd3b045a87ca1d806184;hb=2a7d45c98649bc9b2479e0ef2cf04fa404fa5358;hp=36ffff585c58c6f3b56762bde062847e14b8572b;hpb=d36f0707a488b6e2513193a0a2f72811435553ec;p=quix0rs-gnu-social.git diff --git a/classes/File.php b/classes/File.php index 36ffff585c..65414977d3 100644 --- a/classes/File.php +++ b/classes/File.php @@ -17,23 +17,13 @@ * along with this program. If not, see . */ -if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } - -require_once INSTALLDIR.'/classes/Memcached_DataObject.php'; -require_once INSTALLDIR.'/classes/File_redirection.php'; -require_once INSTALLDIR.'/classes/File_oembed.php'; -require_once INSTALLDIR.'/classes/File_thumbnail.php'; -require_once INSTALLDIR.'/classes/File_to_post.php'; -//require_once INSTALLDIR.'/classes/File_redirection.php'; +if (!defined('GNUSOCIAL')) { exit(1); } /** * Table Definition for file */ -class File extends Memcached_DataObject +class File extends Managed_DataObject { - ###START_AUTOCODE - /* the code below is auto generated do not remove the above tag */ - public $__table = 'file'; // table name public $id; // int(4) primary_key not_null public $url; // varchar(255) unique_key @@ -43,35 +33,38 @@ class File extends Memcached_DataObject public $date; // int(4) public $protected; // int(4) public $filename; // varchar(255) + public $width; // int(4) + public $height; // int(4) public $modified; // timestamp() not_null default_CURRENT_TIMESTAMP - /* Static get */ - function staticGet($k,$v=NULL) { return Memcached_DataObject::staticGet('File',$k,$v); } - - /* the code above is auto generated do not remove the tag below */ - ###END_AUTOCODE + public static function schemaDef() + { + return array( + 'fields' => array( + 'id' => array('type' => 'serial', 'not null' => true), + 'url' => array('type' => 'varchar', 'length' => 255, 'description' => 'destination URL after following redirections'), + 'mimetype' => array('type' => 'varchar', 'length' => 50, 'description' => 'mime type of resource'), + 'size' => array('type' => 'int', 'description' => 'size of resource when available'), + 'title' => array('type' => 'varchar', 'length' => 255, 'description' => 'title of resource when available'), + 'date' => array('type' => 'int', 'description' => 'date of resource according to http query'), + 'protected' => array('type' => 'int', 'description' => 'true when URL is private (needs login)'), + 'filename' => array('type' => 'varchar', 'length' => 255, 'description' => 'if a local file, name of the file'), + 'width' => array('type' => 'int', 'description' => 'width in pixels, if it can be described as such and data is available'), + 'height' => array('type' => 'int', 'description' => 'height in pixels, if it can be described as such and data is available'), + + 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'), + ), + 'primary key' => array('id'), + 'unique keys' => array( + 'file_url_key' => array('url'), + ), + ); + } function isProtected($url) { return 'http://www.facebook.com/login.php' === $url; } - /** - * Get the attachments for a particlar notice. - * - * @param int $post_id - * @return array of File objects - */ - static function getAttachments($post_id) { - $file = new File(); - $query = "select file.* from file join file_to_post on (file_id = file.id) where post_id = " . $file->escape($post_id); - $file = Memcached_DataObject::cachedQuery('File', $query); - $att = array(); - while ($file->fetch()) { - $att[] = clone($file); - } - return $att; - } - /** * Save a new file record. * @@ -79,52 +72,32 @@ class File extends Memcached_DataObject * @param string $given_url * @return File */ - function saveNew(array $redir_data, $given_url) { - $x = new File; - $x->url = $given_url; - if (!empty($redir_data['protected'])) $x->protected = $redir_data['protected']; - if (!empty($redir_data['title'])) $x->title = $redir_data['title']; - if (!empty($redir_data['type'])) $x->mimetype = $redir_data['type']; - if (!empty($redir_data['size'])) $x->size = intval($redir_data['size']); - if (isset($redir_data['time']) && $redir_data['time'] > 0) $x->date = intval($redir_data['time']); - $file_id = $x->insert(); - - $x->saveOembed($redir_data, $given_url); - return $x; - } + public static function saveNew(array $redir_data, $given_url) { - /** - * Save embedding information for this file, if applicable. - * - * Normally this won't need to be called manually, as File::saveNew() - * takes care of it. - * - * @param array $redir_data lookup data eg from File_redirection::where() - * @param string $given_url - * @return boolean success - */ - public function saveOembed($redir_data, $given_url) - { - if (isset($redir_data['type']) - && (('text/html' === substr($redir_data['type'], 0, 9) || 'application/xhtml+xml' === substr($redir_data['type'], 0, 21))) - && ($oembed_data = File_oembed::_getOembed($given_url))) { - - $fo = File_oembed::staticGet('file_id', $this->id); + // I don't know why we have to keep doing this but I'm adding this last check to avoid + // uniqueness bugs. - if (empty($fo)) { - File_oembed::saveNew($oembed_data, $this->id); - return true; - } else { - common_log(LOG_WARNING, "Strangely, a File_oembed object exists for new file $file_id", __FILE__); - } + $file = File::getKV('url', $given_url); + + if (!$file instanceof File) { + $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']); + $file_id = $file->insert(); } - return false; + + Event::handle('EndFileSaveNew', array($file, $redir_data, $given_url)); + assert ($file instanceof File); + return $file; } /** * Go look at a URL and possibly save data about it if it's new: * - follow redirect chains and store them in file_redirection - * - look up oEmbed data and save it in file_oembed * - if a thumbnail is available, save it in file_thumbnail * - save file record with basic info * - optionally save a file_to_post record @@ -137,16 +110,32 @@ class File extends Memcached_DataObject * * @return mixed File on success, -1 on some errors * - * @throws ServerException on some errors + * @throws ServerException on failure */ - public function processNew($given_url, $notice_id=null, $followRedirects=true) { - if (empty($given_url)) return -1; // error, no url to process + public static function processNew($given_url, $notice_id=null, $followRedirects=true) { + if (empty($given_url)) { + throw new ServerException('No given URL to process'); + } + $given_url = File_redirection::_canonUrl($given_url); - if (empty($given_url)) return -1; // error, no url to process - $file = File::staticGet('url', $given_url); - if (empty($file)) { - $file_redir = File_redirection::staticGet('url', $given_url); - if (empty($file_redir)) { + if (empty($given_url)) { + throw new ServerException('No canonical URL from given URL to process'); + } + + $file = File::getKV('url', $given_url); + if (!$file instanceof File) { + // First check if we have a lookup trace for this URL already + $file_redir = File_redirection::getKV('url', $given_url); + if ($file_redir instanceof File_redirection) { + $file = File::getKV('id', $file_redir->file_id); + if (!$file instanceof File) { + // File did not exist, let's clean up the File_redirection entry + $file_redir->delete(); + } + } + + // If we still don't have a File object, let's create one now! + if (!$file instanceof File) { // @fixme for new URLs this also looks up non-redirect data // such as target content type, size, etc, which we need // for File::saveNew(); so we call it even if not following @@ -161,10 +150,11 @@ class File extends Memcached_DataObject // TRANS: Server exception thrown when a URL cannot be processed. throw new ServerException(sprintf(_("Cannot process URL '%s'"), $given_url)); } + // TODO: max field length if ($redir_url === $given_url || strlen($redir_url) > 255 || !$followRedirects) { - $x = File::saveNew($redir_data, $given_url); - $file_id = $x->id; + // Save the File object based on our lookup trace + $file = File::saveNew($redir_data, $given_url); } else { // This seems kind of messed up... for now skipping this part // if we're already under a redirect, so we don't go into @@ -174,94 +164,103 @@ class File extends Memcached_DataObject // // Seen in the wild with clojure.org, which redirects through // wikispaces for auth and appends session data in the URL params. - $x = File::processNew($redir_url, $notice_id, /*followRedirects*/false); - $file_id = $x->id; - File_redirection::saveNew($redir_data, $file_id, $given_url); + $file = self::processNew($redir_url, $notice_id, /*followRedirects*/false); + File_redirection::saveNew($redir_data, $file->id, $given_url); } - } else { - $file_id = $file_redir->file_id; } - } else { - $file_id = $file->id; - $x = $file; - } - if (empty($x)) { - $x = File::staticGet($file_id); - if (empty($x)) { - // @todo FIXME: This could possibly be a clearer message :) - // TRANS: Server exception thrown when... Robin thinks something is impossible! - throw new ServerException(_('Robin thinks something is impossible.')); + if (!$file instanceof File) { + // This should only happen if File::saveNew somehow did not return a File object, + // though we have an assert for that in case the event there might've gone wrong. + // If anything else goes wrong, there should've been an exception thrown. + throw new ServerException('URL processing failed without new File object'); } } if (!empty($notice_id)) { - File_to_post::processNew($file_id, $notice_id); + File_to_post::processNew($file->id, $notice_id); } - return $x; + return $file; } - function isRespectsQuota($user,$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); + + $fileQuota = common_config('attachments', 'file_quota'); // TRANS: Message given if an upload is larger than the configured maximum. - // TRANS: %1$d is the byte limit for uploads, %2$d is the byte count for the uploaded file. - // TRANS: %1$s is used for plural. - return sprintf(_m('No file may be larger than %1$d byte and the file you sent was %2$d bytes. Try to upload a smaller version.', - 'No file may be larger than %1$d bytes and the file you sent was %2$d bytes. Try to upload a smaller version.', - common_config('attachments', 'file_quota')), - common_config('attachments', 'file_quota'), $fileSize); - } - - $query = "select sum(size) as total from file join file_to_post on file_to_post.file_id = file.id join notice on file_to_post.post_id = notice.id where profile_id = {$user->id} and file.url like '%/notice/%/file'"; - $this->query($query); - $this->fetch(); - $total = $this->total + $fileSize; + // TRANS: %1$d (used for plural) is the byte limit for uploads, + // 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)); + } + + $file = new File; + + $query = "select sum(size) as total from file join file_to_post on file_to_post.file_id = file.id join notice on file_to_post.post_id = notice.id where profile_id = {$scoped->id} and file.url like '%/notice/%/file'"; + $file->query($query); + $file->fetch(); + $total = $file->total + $fileSize; if ($total > common_config('attachments', 'user_quota')) { // TRANS: Message given if an upload would exceed user quota. // TRANS: %d (number) is the user quota in bytes and is used for plural. - return sprintf(_m('A file this large would exceed your user quota of %d byte.', + 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')); + 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())'; - $this->query($query); - $this->fetch(); - $total = $this->total + $fileSize; + $file->query($query); + $file->fetch(); + $total = $file->total + $fileSize; if ($total > common_config('attachments', 'monthly_quota')) { // 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. - return sprintf(_m('A file this large would exceed your monthly quota of %d byte.', + 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')); + common_config('attachments', 'monthly_quota'))); } return true; } // where should the file go? - static function filename($profile, $basename, $mimetype) + static function filename(Profile $profile, $origname, $mimetype) { - require_once 'MIME/Type/Extension.php'; - - // We have to temporarily disable auto handling of PEAR errors... - PEAR::staticPushErrorHandling(PEAR_ERROR_RETURN); - - $mte = new MIME_Type_Extension(); - $ext = $mte->getExtension($mimetype); - if (PEAR::isError($ext)) { - $ext = strtolower(preg_replace('/\W/', '', $mimetype)); + try { + $ext = common_supported_mime_to_ext($mimetype); + } catch (Exception $e) { + // We don't support this mimetype, but let's guess the extension + $ext = substr(strrchr($mimetype, '/'), 1); } - // Restore error handling. - PEAR::staticPopErrorHandling(); - - $nickname = $profile->nickname; - $datestamp = strftime('%Y%m%dT%H%M%S', time()); - $random = strtolower(common_confirmation_code(32)); - return "$nickname-$datestamp-$random.$ext"; + // Normalize and make the original filename more URL friendly. + $origname = basename($origname, ".$ext"); + if (class_exists('Normalizer')) { + // http://php.net/manual/en/class.normalizer.php + // http://www.unicode.org/reports/tr15/ + $origname = Normalizer::normalize($origname, Normalizer::FORM_KC); + } + $origname = preg_replace('/[^A-Za-z0-9\.\_]/', '_', $origname); + + $nickname = $profile->getNickname(); + $datestamp = strftime('%Y%m%d', time()); + do { + // generate new random strings until we don't run into a filename collision. + $random = strtolower(common_confirmation_code(16)); + $filename = "$nickname-$datestamp-$origname-$random.$ext"; + } while (file_exists(self::path($filename))); + return $filename; } /** @@ -304,7 +303,7 @@ class File extends Memcached_DataObject } - if (StatusNet::isHTTPS()) { + if (StatusNet::useHTTPS()) { $sslserver = common_config('attachments', 'sslserver'); @@ -353,70 +352,140 @@ class File extends Memcached_DataObject function getEnclosure(){ $enclosure = (object) array(); - $enclosure->title=$this->title; - $enclosure->url=$this->url; - $enclosure->title=$this->title; - $enclosure->date=$this->date; - $enclosure->modified=$this->modified; - $enclosure->size=$this->size; - $enclosure->mimetype=$this->mimetype; - - if(! isset($this->filename)){ - $notEnclosureMimeTypes = array(null,'text/html','application/xhtml+xml'); - $mimetype = $this->mimetype; - if($mimetype != null){ - $mimetype = strtolower($this->mimetype); - } - $semicolon = strpos($mimetype,';'); - if($semicolon){ - $mimetype = substr($mimetype,0,$semicolon); - } - if(in_array($mimetype,$notEnclosureMimeTypes)){ - // Never treat generic HTML links as an enclosure type! - // But if we have oEmbed info, we'll consider it golden. - $oembed = File_oembed::staticGet('file_id',$this->id); - if($oembed && in_array($oembed->type, array('photo', 'video'))){ - $mimetype = strtolower($oembed->mimetype); - $semicolon = strpos($mimetype,';'); - if($semicolon){ - $mimetype = substr($mimetype,0,$semicolon); - } - // @fixme uncertain if this is right. - // we want to expose things like YouTube videos as - // viewable attachments, but don't expose them as - // downloadable enclosures.....? - //if (in_array($mimetype, $notEnclosureMimeTypes)) { - // return false; - //} else { - if($oembed->mimetype) $enclosure->mimetype=$oembed->mimetype; - if($oembed->url) $enclosure->url=$oembed->url; - if($oembed->title) $enclosure->title=$oembed->title; - if($oembed->modified) $enclosure->modified=$oembed->modified; - unset($oembed->size); - //} - } else { - return false; - } - } + foreach (array('title', 'url', 'date', 'modified', 'size', 'mimetype') as $key) { + $enclosure->$key = $this->$key; } - return $enclosure; - } - // quick back-compat hack, since there's still code using this - function isEnclosure() - { - $enclosure = $this->getEnclosure(); - return !empty($enclosure); + $needMoreMetadataMimetypes = array(null, 'application/xhtml+xml'); + + if (!isset($this->filename) && in_array(common_bare_mime($enclosure->mimetype), $needMoreMetadataMimetypes)) { + // This fetches enclosure metadata for non-local links with unset/HTML mimetypes, + // which may be enriched through oEmbed or similar (implemented as plugins) + Event::handle('FileEnclosureMetadata', array($this, &$enclosure)); + } + if (empty($enclosure->mimetype) || in_array(common_bare_mime($enclosure->mimetype), $needMoreMetadataMimetypes)) { + // This means we either don't know what it is, so it can't + // be shown as an enclosure, or it is an HTML link which + // does not link to a resource with further metadata. + throw new ServerException('Unknown enclosure mimetype, not enough metadata'); + } + return $enclosure; } /** * Get the attachment's thumbnail record, if any. + * Make sure you supply proper 'int' typed variables (or null). + * + * @param $width int Max width of thumbnail in pixels. (if null, use common_config values) + * @param $height int Max height of thumbnail in pixels. (if null, square-crop to $width) + * @param $crop bool Crop to the max-values' aspect ratio * * @return File_thumbnail */ - function getThumbnail() + public function getThumbnail($width=null, $height=null, $crop=false) + { + // Get some more information about this file through our ImageFile class + $image = ImageFile::fromFileObject($this); + if ($image->animated && !common_config('image', 'resize_animated')) { + throw new UseFileAsThumbnailException($this->id); + } + + if ($width === null) { + $width = common_config('thumbnail', 'width'); + $height = common_config('thumbnail', 'height'); + $crop = common_config('thumbnail', 'crop'); + } + + if ($height === null) { + $height = $width; + $crop = true; + } + + // Get proper aspect ratio width and height before lookup + // We have to do it through an ImageFile object because of orientation etc. + // Only other solution would've been to rotate + rewrite uploaded files. + list($width, $height, $x, $y, $w, $h) = + $image->scaleToFit($width, $height, $crop); + + $params = array('file_id'=> $this->id, + 'width' => $width, + 'height' => $height); + $thumb = File_thumbnail::pkeyGet($params); + if ($thumb instanceof File_thumbnail) { + return $thumb; + } + + // throws exception on failure to generate thumbnail + $outname = "thumb-{$width}x{$height}-" . $image->filename; + $outpath = self::path($outname); + + // The boundary box for our resizing + $box = array('width'=>$width, 'height'=>$height, + 'x'=>$x, 'y'=>$y, + 'w'=>$w, 'h'=>$h); + + // Doublecheck that parameters are sane and integers. + if ($box['width'] < 1 || $box['width'] > common_config('thumbnail', 'maxsize') + || $box['height'] < 1 || $box['height'] > common_config('thumbnail', 'maxsize') + || $box['w'] < 1 || $box['x'] >= $image->width + || $box['h'] < 1 || $box['y'] >= $image->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 {$image->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->id, $width, $height)); + // Perform resize and store into file + $image->resizeTo($outpath, $box); + + // Avoid deleting the original + if ($image->getPath() != self::path($image->filename)) { + $image->unlink(); + } + return File_thumbnail::saveThumbnail($this->id, + self::url($outname), + $width, $height, + $outname); + } + + public function getPath() + { + return self::path($this->filename); + } + + public function getUrl() + { + if (!empty($this->filename)) { + // A locally stored file, so let's generate a URL for our instance. + $url = self::url($this->filename); + if ($url != $this->url) { + // For indexing purposes, in case we do a lookup on the 'url' field. + // also we're fixing possible changes from http to https, or paths + $this->updateUrl($url); + } + return $url; + } + + // No local filename available, return the URL we have stored + return $this->url; + } + + public function updateUrl($url) { - return File_thumbnail::staticGet('file_id', $this->id); + $file = File::getKV('url', $url); + if ($file instanceof File) { + throw new ServerException('URL already exists in DB'); + } + $sql = 'UPDATE %1$s SET url=%2$s WHERE url=%3$s;'; + $result = $this->query(sprintf($sql, $this->__table, + $this->_quote((string)$url), + $this->_quote((string)$this->url))); + if ($result === false) { + common_log_db_error($this, 'UPDATE', __FILE__); + throw new ServerException("Could not UPDATE {$this->__table}.url"); + } + + return $result; } /** @@ -472,4 +541,41 @@ class File extends Memcached_DataObject return $count; } + + public function isLocal() + { + return !empty($this->filename); + } + + public function delete($useWhere=false) + { + // Delete the file, if it exists locally + if (!empty($this->filename) && file_exists(self::path($this->filename))) { + $deleted = @unlink(self::path($this->filename)); + if (!$deleted) { + common_log(LOG_ERR, sprintf('Could not unlink existing file: "%s"', self::path($this->filename))); + } + } + + // Clear out related things in the database and filesystem, such as thumbnails + if (Event::handle('FileDeleteRelated', array($this))) { + $thumbs = new File_thumbnail(); + $thumbs->file_id = $this->id; + if ($thumbs->find()) { + while ($thumbs->fetch()) { + $thumbs->delete(); + } + } + } + + // And finally remove the entry from the database + return parent::delete($useWhere); + } + + public function getTitle() + { + $title = $this->title ?: $this->filename; + + return $title ?: null; + } }