]> git.mxchange.org Git - quix0rs-gnu-social.git/blobdiff - classes/File.php
Select the first generated thumbnail, which should be the proper size
[quix0rs-gnu-social.git] / classes / File.php
index 977c02bce68ef2d57a3a8c17bd64a86c2d1003c9..7bd7da27ba5b96b7a8fcefbc0d9b12f54c444b85 100644 (file)
@@ -31,10 +31,10 @@ class File extends Managed_DataObject
     public $filehash;                        // varchar(64)     indexed
     public $mimetype;                        // varchar(50)
     public $size;                            // int(4)
-    public $title;                           // varchar(191)   not 255 because utf8mb4 takes more space
+    public $title;                           // text()
     public $date;                            // int(4)
     public $protected;                       // int(4)
-    public $filename;                        // varchar(191)   not 255 because utf8mb4 takes more space
+    public $filename;                        // text()
     public $width;                           // int(4)
     public $height;                          // int(4)
     public $modified;                        // timestamp()   not_null default_CURRENT_TIMESTAMP
@@ -52,10 +52,10 @@ class File extends Managed_DataObject
                 'filehash' => array('type' => 'varchar', 'length' => 64, 'not null' => false, 'description' => 'sha256 of the file contents, only for locally stored files of course'),
                 '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' => 191, 'description' => 'title of resource when available'),
+                'title' => array('type' => 'text', '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' => 191, 'description' => 'if a local file, name of the file'),
+                'filename' => array('type' => 'text', 'description' => 'if file is stored locally (too) this is the filename'),
                 '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'),
 
@@ -71,8 +71,20 @@ class File extends Managed_DataObject
         );
     }
 
-    function isProtected($url) {
-        return 'http://www.facebook.com/login.php' === $url;
+    public static function isProtected($url) {
+
+               $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;
+                       }
+               }
+
+               return false;
     }
 
     /**
@@ -94,26 +106,65 @@ class File extends Managed_DataObject
             // We don't have the file's URL since before, so let's continue.
         }
 
-        if (!Event::handle('StartFileSaveNew', array(&$redir_data, $given_url))) {
-            throw new ServerException('File not saved due to an aborted StartFileSaveNew event.');
+        // if the given url is an local attachment url and the id already exists, don't
+        // save a new file record. This should never happen, but let's make it foolproof
+        // FIXME: how about attachments servers?
+        $u = parse_url($given_url);
+        if (isset($u['host']) && $u['host'] === common_config('site', 'server')) {
+            $r = Router::get();
+            // Skip the / in the beginning or $r->map won't match
+            try {
+                $args = $r->map(mb_substr($u['path'], 1));
+                if ($args['action'] === 'attachment') {
+                    try {
+                        // $args['attachment'] should always be set if action===attachment, given our routing rules
+                        $file = File::getByID($args['attachment']);
+                        return $file;
+                    } catch (EmptyPkeyValueException $e) {
+                        // ...but $args['attachment'] can also be 0...
+                    } catch (NoResultException $e) {
+                        // apparently this link goes to us, but is _not_ an existing attachment (File) ID?
+                    }
+                }
+            } catch (Exception $e) {
+                // Some other exception was thrown from $r->map, likely a
+                // ClientException (404) because of some malformed link to
+                // our own instance. It's still a valid URL however, so we
+                // won't abort anything... I noticed this when linking:
+                // https://social.umeahackerspace.se/mmn/foaf' (notice the
+                // apostrophe in the end, making it unrecognizable for our
+                // URL routing.
+                // That specific issue (the apostrophe being part of a link
+                // is something that may or may not have been fixed since,
+                // in lib/util.php in common_replace_urls_callback().
+            }
         }
 
         $file = new File;
-        $file->urlhash = self::hashurl($given_url);
         $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();
+        $file->saveFile();
+        return $file;
+    }
 
-        if ($file_id === false) {
+    public function saveFile() {
+        $this->urlhash = self::hashurl($this->url);
+
+        if (!Event::handle('StartFileSaveNew', array(&$this))) {
+            throw new ServerException('File not saved due to an aborted StartFileSaveNew event.');
+        }
+
+        $this->id = $this->insert();
+
+        if ($this->id === false) {
             throw new ServerException('File/URL metadata could not be saved to the database.');
         }
 
-        Event::handle('EndFileSaveNew', array($file, $redir_data, $given_url));
-        return $file;
+        Event::handle('EndFileSaveNew', array($this));
     }
 
     /**
@@ -124,7 +175,6 @@ class File extends Managed_DataObject
      * - optionally save a file_to_post record
      * - return the File object with the full reference
      *
-     * @fixme refactor this mess, it's gotten pretty scary.
      * @param string $given_url the URL we're looking at
      * @param Notice $notice (optional)
      * @param bool $followRedirects defaults to true
@@ -143,69 +193,18 @@ class File extends Managed_DataObject
             throw new ServerException('No canonical URL from given URL to process');
         }
 
-        $file = null;
+        $redir = File_redirection::where($given_url);
+        $file = $redir->getFile();
 
-        try {
-            $file = File::getByUrl($given_url);
-        } catch (NoResultException $e) {
-            // First check if we have a lookup trace for this URL already
-            try {
-                $file_redir = File_redirection::getByUrl($given_url);
-                $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();
-                }
-            } catch (NoResultException $e) {
-                // We just wanted to doublecheck whether a File_thumbnail we might've had
-                // actually referenced an existing File object.
-            }
-        }
-
-        // 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
-            // new redirects.
-            $redir_data = File_redirection::where($given_url);
-            if (is_array($redir_data)) {
-                $redir_url = $redir_data['url'];
-            } elseif (is_string($redir_data)) {
-                $redir_url = $redir_data;
-                $redir_data = array();
-            } else {
-                // TRANS: Server exception thrown when a URL cannot be processed.
-                throw new ServerException(sprintf(_("Cannot process URL '%s'"), $given_url));
-            }
-
-            if ($redir_url === $given_url || !$followRedirects) {
-                // 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
-                // horrible infinite loops if we've been given an unstable
-                // redirect (where the final destination of the first request
-                // doesn't match what we get when we ask for it again).
-                //
-                // Seen in the wild with clojure.org, which redirects through
-                // wikispaces for auth and appends session data in the URL params.
-                $file = self::processNew($redir_url, $notice, /*followRedirects*/false);
-                File_redirection::saveNew($redir_data, $file->id, $given_url);
-            }
-
-            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 (!$file instanceof File || empty($file->id)) {
+            // This should not happen
+            throw new ServerException('URL processing failed without new File object');
         }
 
         if ($notice instanceof Notice) {
             File_to_post::processNew($file, $notice);
         }
+
         return $file;
     }
 
@@ -261,18 +260,19 @@ class File extends Managed_DataObject
 
     public function getFilename()
     {
-        if (!self::validFilename($this->filename)) {
-            // TRANS: Client exception thrown if a file upload does not have a valid name.
-            throw new ClientException(_("Invalid filename."));
-        }
-        return $this->filename;
+        return self::tryFilename($this->filename);
+    }
+
+    public function getSize()
+    {
+        return intval($this->size);
     }
 
     // where should the file go?
 
     static function filename(Profile $profile, $origname, $mimetype)
     {
-        $ext = self::guessMimeExtension($mimetype);
+        $ext = self::guessMimeExtension($mimetype, $origname);
 
         // Normalize and make the original filename more URL friendly.
         $origname = basename($origname, ".$ext");
@@ -293,15 +293,54 @@ class File extends Managed_DataObject
         return $filename;
     }
 
-    static function guessMimeExtension($mimetype)
+    /**
+     * @param $mimetype The mimetype we've discovered for this file.
+     * @param $filename An optional filename which we can use on failure.
+     */
+    static function guessMimeExtension($mimetype, $filename=null)
     {
         try {
+            // first see if we know the extension for our mimetype
             $ext = common_supported_mime_to_ext($mimetype);
+            // we do, so use it!
+            return $ext;
+        } catch (UnknownMimeExtensionException $e) {
+            // We don't know the extension for this mimetype, but let's guess.
+
+            // If we can't recognize the extension from the MIME, we try
+            // to guess based on filename, if one was supplied.
+            if (!is_null($filename) && preg_match('/^.+\.([A-Za-z0-9]+)$/', $filename, $matches)) {
+                // we matched on a file extension, so let's see if it means something.
+                $ext = mb_strtolower($matches[1]);
+
+                $blacklist = common_config('attachments', 'extblacklist');
+                // If we got an extension from $filename we want to check if it's in a blacklist
+                // so we avoid people uploading .php files etc.
+                if (array_key_exists($ext, $blacklist)) {
+                    if (!is_string($blacklist[$ext])) {
+                        // we don't have a safe replacement extension
+                        throw new ClientException(_('Blacklisted file extension.'));
+                    }
+                    common_debug('Found replaced extension for filename '._ve($filename).': '._ve($ext));
+
+                    // return a safe replacement extension ('php' => 'phps' for example)
+                    return $blacklist[$ext];
+                }
+                // the attachment extension based on its filename was not blacklisted so it's ok to use it
+                return $ext;
+            }
         } catch (Exception $e) {
-            // We don't support this mimetype, but let's guess the extension
-            $ext = substr(strrchr($mimetype, '/'), 1);
+            common_log(LOG_INFO, 'Problem when figuring out extension for mimetype: '._ve($e));
         }
-        return strtolower($ext);
+
+        // If nothing else has given us a result, try to extract it from
+        // the mimetype value (this turns .jpg to .jpeg for example...)
+        $matches = array();
+        // FIXME: try to build a regexp that will get jpeg from image/jpeg as well as json from application/jrd+json
+        if (!preg_match('/\/([a-z0-9]+)/', mb_strtolower($mimetype), $matches)) {
+            throw new Exception('Malformed mimetype: '.$mimetype);
+        }
+        return mb_strtolower($matches[1]);
     }
 
     /**
@@ -312,19 +351,27 @@ class File extends Managed_DataObject
         return preg_match('/^[A-Za-z0-9._-]+$/', $filename);
     }
 
+    static function tryFilename($filename)
+    {
+        if (!self::validFilename($filename))
+        {
+            throw new InvalidFilenameException($filename);
+        }
+        // if successful, return the filename for easy if-statementing
+        return $filename;
+    }
+
     /**
      * @throws ClientException on invalid filename
      */
     static function path($filename)
     {
-        if (!self::validFilename($filename)) {
-            // TRANS: Client exception thrown if a file upload does not have a valid name.
-            throw new ClientException(_("Invalid filename."));
-        }
+        self::tryFilename($filename);
+
         $dir = common_config('attachments', 'dir');
 
-        if ($dir[strlen($dir)-1] != '/') {
-            $dir .= '/';
+        if (!in_array($dir[mb_strlen($dir)-1], ['/', '\\'])) {
+            $dir .= DIRECTORY_SEPARATOR;
         }
 
         return $dir . $filename;
@@ -332,10 +379,7 @@ class File extends Managed_DataObject
 
     static function url($filename)
     {
-        if (!self::validFilename($filename)) {
-            // TRANS: Client exception thrown if a file upload does not have a valid name.
-            throw new ClientException(_("Invalid filename."));
-        }
+        self::tryFilename($filename);
 
         if (common_config('site','private')) {
 
@@ -391,28 +435,48 @@ class File extends Managed_DataObject
         return $protocol.'://'.$server.$path.$filename;
     }
 
+    static $_enclosures = array();
+
     function getEnclosure(){
+        if (isset(self::$_enclosures[$this->getID()])) {
+            return self::$_enclosures[$this->getID()];
+        }
+
         $enclosure = (object) array();
-        foreach (array('title', 'url', 'date', 'modified', 'size', 'mimetype') as $key) {
-            $enclosure->$key = $this->$key;
+        foreach (array('title', 'url', 'date', 'modified', 'size', 'mimetype', 'width', 'height') as $key) {
+            if ($this->$key !== '') {
+                $enclosure->$key = $this->$key;
+            }
         }
 
-        $needMoreMetadataMimetypes = array(null, 'application/xhtml+xml');
+        $needMoreMetadataMimetypes = array(null, 'application/xhtml+xml', 'text/html');
 
         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)) {
+        if (empty($enclosure->mimetype)) {
             // 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');
         }
+
+        self::$_enclosures[$this->getID()] = $enclosure;
         return $enclosure;
     }
 
+    public function hasThumbnail()
+    {
+        try {
+            $this->getThumbnail();
+        } catch (Exception $e) {
+            return false;
+        }
+        return true;
+    }
+
     /**
      * Get the attachment's thumbnail record, if any.
      * Make sure you supply proper 'int' typed variables (or null).
@@ -420,6 +484,8 @@ class File extends Managed_DataObject
      * @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
+     * @param $force_still  bool    Don't allow fallback to showing original (such as animated GIF)
+     * @param $upscale      mixed   Whether or not to scale smaller images up to larger thumbnail sizes. (null = site default)
      *
      * @return File_thumbnail
      *
@@ -427,7 +493,7 @@ class File extends Managed_DataObject
      * @throws UnsupportedMediaException    if, despite trying, we can't understand how to make a thumbnail for this format
      * @throws ServerException              on various other errors
      */
-    public function getThumbnail($width=null, $height=null, $crop=false, $force_still=true)
+    public function getThumbnail($width=null, $height=null, $crop=false, $force_still=true, $upscale=null)
     {
         // Get some more information about this file through our ImageFile class
         $image = ImageFile::fromFileObject($this);
@@ -435,11 +501,18 @@ class File extends Managed_DataObject
             // null  means "always use file as thumbnail"
             // false means you get choice between frozen frame or original when calling getThumbnail
             if (is_null(common_config('thumbnail', 'animated')) || !$force_still) {
-                throw new UseFileAsThumbnailException($this->id);
+                try {
+                    // remote files with animated GIFs as thumbnails will match this
+                    return File_thumbnail::byFile($this);
+                } catch (NoResultException $e) {
+                    // and if it's not a remote file, it'll be safe to use the locally stored File
+                    throw new UseFileAsThumbnailException($this);
+                }
             }
         }
 
-        return $image->getFileThumbnail($width, $height, $crop);
+        return $image->getFileThumbnail($width, $height, $crop,
+                                        !is_null($upscale) ? $upscale : common_config('thumbnail', 'upscale'));
     }
 
     public function getPath()
@@ -451,19 +524,28 @@ class File extends Managed_DataObject
         return $filepath;
     }
 
-    public function getUrl()
+    public function getAttachmentUrl()
+    {
+        return common_local_url('attachment', array('attachment'=>$this->getID()));
+    }
+
+    /**
+     *  @param  mixed   $use_local  true means require local, null means prefer local, false means use whatever is stored
+     */
+    public function getUrl($use_local=null)
     {
-        if (!empty($this->filename)) {
-            // A locally stored file, so let's generate a URL for our instance.
-            $url = self::url($this->filename);
-            if (self::hashurl($url) !== $this->urlhash) {
-                // 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);
+        if ($use_local !== false) {
+            if (is_string($this->filename) || !empty($this->filename)) {
+                // A locally stored file, so let's generate a URL for our instance.
+                return self::url($this->getFilename());
+            }
+            if ($use_local) {
+                // if the file wasn't stored locally (has filename) and we require a local URL
+                throw new FileNotStoredLocallyException($this);
             }
-            return $url;
         }
 
+
         // No local filename available, return the URL we have stored
         return $this->url;
     }
@@ -540,7 +622,9 @@ class File extends Managed_DataObject
 
     function stream($offset=0, $limit=NOTICES_PER_PAGE, $since_id=0, $max_id=0)
     {
-        $stream = new FileNoticeStream($this);
+        // FIXME: Try to get the Profile::current() here in some other way to avoid mixing
+        // the current session user with possibly background/queue processing.
+        $stream = new FileNoticeStream($this, Profile::current());
         return $stream->getNotices($offset, $limit, $since_id, $max_id);
     }