From 6028175bfc320c6010a8cf651a037ab73ca13da4 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Mon, 21 Sep 2020 21:54:23 +0100 Subject: [PATCH] [Media] Fix issues with database file storage Fixed file quota as well. There can be more than one file for the same filehash IF the url are different. Possible states: - A file with no url and with filename is a local file. - A file with an url but no filename is a remote file that wasn't fetched, not even the thumbnail. - A file with an url and filename is a fetched remote file (maybe just a thumbnail of it). - A file with no filename nor url is a redirect. Routes: Given these states, updated routes so that an attachment can only be retrieved by id and a file by filehash. Major API changes: File::getByHash now returns a yield of files Major UI changes: - Now remote non stored files are presented. - /view became preferred - Redirects to remote originals are preferred. Many other minor bug fixes... --- actions/attachment.php | 45 +++-- actions/attachment_download.php | 37 +++- actions/attachment_thumbnail.php | 27 ++- actions/attachment_view.php | 19 +- actions/newnotice.php | 142 +++++++------- actions/redirecturl.php | 2 +- classes/File.php | 131 ++++++------- classes/File_thumbnail.php | 107 ++++++---- lib/media/attachment.php | 2 +- lib/media/attachmentlist.php | 6 - lib/media/attachmentlistitem.php | 182 +++++++++--------- lib/media/imagefile.php | 95 +++------ lib/media/inlineattachmentlistitem.php | 8 +- lib/media/mediafile.php | 153 ++++++++++++--- lib/util/router.php | 18 +- plugins/Embed/EmbedPlugin.php | 14 +- plugins/Embed/actions/oembed.php | 2 +- plugins/OStatus/classes/Ostatus_profile.php | 2 +- .../StoreRemoteMediaPlugin.php | 1 + 19 files changed, 578 insertions(+), 415 deletions(-) diff --git a/actions/attachment.php b/actions/attachment.php index 6e6179c4ae..7572688227 100644 --- a/actions/attachment.php +++ b/actions/attachment.php @@ -19,11 +19,11 @@ defined('GNUSOCIAL') || die(); /** * Show notice attachments * - * @category Personal - * @package StatusNet - * @author Evan Prodromou - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ + * @category Personal + * @package GNUsocial + * @author Evan Prodromou + * @copyright 2008-2009 StatusNet, Inc. + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ class AttachmentAction extends ManagedAction { @@ -58,9 +58,11 @@ class AttachmentAction extends ManagedAction try { if (!empty($id = $this->trimmed('attachment'))) { - $this->attachment = File::getByID($id); + $this->attachment = File::getByID((int) $id); } elseif (!empty($this->filehash = $this->trimmed('filehash'))) { - $this->attachment = File::getByHash($this->filehash); + $file = File::getByHash($this->filehash); + $file->fetch(); + $this->attachment = $file; } } catch (Exception $e) { // Not found @@ -70,13 +72,22 @@ class AttachmentAction extends ManagedAction $this->clientError(_m('No such attachment.'), 404); } - $this->filepath = $this->attachment->getFileOrThumbnailPath(); - if (empty($this->filepath)) { - $this->clientError(_m('Requested local URL for a file that is not stored locally.'), 404); + $this->filesize = $this->attachment->size; + $this->mimetype = $this->attachment->mimetype; + $this->filename = $this->attachment->filename; + + if ($this->attachment->isLocal()) { + $this->filepath = $this->attachment->getFileOrThumbnailPath(); + if (empty($this->filepath)) { + $this->clientError( + _m('Requested local URL for a file that is not stored locally.'), + 404 + ); + } + $this->filesize = $this->attachment->getFileOrThumbnailSize(); + $this->mimetype = $this->attachment->getFileOrThumbnailMimetype(); + $this->filename = MediaFile::getDisplayName($this->attachment); } - $this->filesize = $this->attachment->getFileOrThumbnailSize(); - $this->mimetype = $this->attachment->getFileOrThumbnailMimetype(); - $this->filename = MediaFile::getDisplayName($this->attachment); return true; } @@ -104,8 +115,12 @@ class AttachmentAction extends ManagedAction public function showPage(): void { - if (empty($this->filepath)) { - // if it's not a local file, gtfo + if ( + !$this->attachment->isLocal() + || empty($this->filepath) + || !file_exists($this->filepath) + ) { + // If it's not a locally stored file, get lost common_redirect($this->attachment->getUrl(), 303); } diff --git a/actions/attachment_download.php b/actions/attachment_download.php index 6922be3197..3436bc81c2 100644 --- a/actions/attachment_download.php +++ b/actions/attachment_download.php @@ -1,15 +1,29 @@ . -if (!defined('GNUSOCIAL')) { exit(1); } +defined('GNUSOCIAL') || die(); /** * Download notice attachment * - * @category Personal - * @package GNUsocial - * @author Mikael Nordfeldth - * @license https://www.gnu.org/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link https:/gnu.io/social + * @category Personal + * @package GNUsocial + * @author Mikael Nordfeldth + * @copyright 2016 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or late */ class Attachment_downloadAction extends AttachmentAction { @@ -20,6 +34,15 @@ class Attachment_downloadAction extends AttachmentAction // script execution, and we don't want to have any more errors until then, so don't reset it @ini_set('display_errors', 0); - common_send_file($this->filepath, $this->mimetype, $this->filename, 'attachment'); + if ($this->attachment->isLocal()) { + common_send_file( + $this->filepath, + $this->mimetype, + $this->filename, + 'attachment' + ); + } else { + common_redirect($this->attachment->getUrl(), 303); + } } } diff --git a/actions/attachment_thumbnail.php b/actions/attachment_thumbnail.php index f3000005a3..e1a87faf79 100644 --- a/actions/attachment_thumbnail.php +++ b/actions/attachment_thumbnail.php @@ -19,13 +19,13 @@ defined('GNUSOCIAL') || die(); /** * Show notice attachments * - * @category Personal - * @package StatusNet - * @author Evan Prodromou - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ + * @category Personal + * @package GNUsocial + * @author Evan Prodromou + * @copyright 2008-2009 StatusNet, Inc. + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ -class Attachment_thumbnailAction extends AttachmentAction +class Attachment_thumbnailAction extends Attachment_viewAction { protected $thumb_w = null; // max width protected $thumb_h = null; // max height @@ -52,14 +52,21 @@ class Attachment_thumbnailAction extends AttachmentAction public function showPage(): void { // Returns a File_thumbnail object or throws exception if not available + $filename = $this->filename; + $filepath = $this->filepath; try { $thumbnail = $this->attachment->getThumbnail($this->thumb_w, $this->thumb_h, $this->thumb_c); - $file = $thumbnail->getFile(); + $filename = $thumbnail->getFilename(); + $filepath = $thumbnail->getPath(); } catch (UseFileAsThumbnailException $e) { - // With this exception, the file exists locally - $file = $e->file; + // With this exception, the file exists locally $e->file; } catch (FileNotFoundException $e) { $this->clientError(_m('No such attachment'), 404); + } catch (Exception $e) { + if (is_null($filepath)) { + $this->clientError(_m('No such thumbnail'), 404); + } + // Remote file } // Disable errors, to not mess with the file contents (suppress errors in case access to this @@ -67,6 +74,6 @@ class Attachment_thumbnailAction extends AttachmentAction // script execution, and we don't want to have any more errors until then, so don't reset it @ini_set('display_errors', 0); - common_send_file($this->filepath, $this->mimetype, $this->filename, 'inline'); + common_send_file($filepath, $this->mimetype, $filename, 'inline'); } } diff --git a/actions/attachment_view.php b/actions/attachment_view.php index c7d980eba5..780717cd58 100644 --- a/actions/attachment_view.php +++ b/actions/attachment_view.php @@ -19,9 +19,10 @@ defined('GNUSOCIAL') || die(); /** * View notice attachment * - * @package GNUsocial - * @author Miguel Dantas - * @license https://www.gnu.org/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @package GNUsocial + * @author Miguel Dantas + * @copyright 2019 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ class Attachment_viewAction extends AttachmentAction { @@ -32,7 +33,15 @@ class Attachment_viewAction extends AttachmentAction // script execution, and we don't want to have any more errors until then, so don't reset it @ini_set('display_errors', 0); - $disposition = in_array(common_get_mime_media($this->mimetype), ['image', 'video']) ? 'inline' : 'attachment'; - common_send_file($this->filepath, $this->mimetype, $this->filename, $disposition); + if ($this->attachment->isLocal()) { + $disposition = 'attachment'; + if (in_array(common_get_mime_media($this->mimetype), ['image', 'video'])) { + $disposition = 'inline'; + } + common_send_file($this->filepath, $this->mimetype, +$this->filename, $disposition); + } else { + common_redirect($this->attachment->getUrl(), 303); + } } } diff --git a/actions/newnotice.php b/actions/newnotice.php index 170e5bcdf8..1ea7f84b85 100644 --- a/actions/newnotice.php +++ b/actions/newnotice.php @@ -1,47 +1,43 @@ . + /** - * StatusNet, the distributed open-source microblogging tool - * * Handler for posting new notices * - * PHP version 5 - * - * LICENCE: This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * * @category Personal - * @package StatusNet + * @package GNUsocial * @author Evan Prodromou * @author Zach Copley * @author Sarven Capadisli * @copyright 2008-2009 StatusNet, Inc. - * @copyright 2013 Free Software Foundation, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ + * @copyright 2013 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ -if (!defined('GNUSOCIAL')) { exit(1); } +defined('GNUSOCIAL') || die(); /** * Action for posting new notices * - * @category Personal - * @package StatusNet - * @author Evan Prodromou - * @author Zach Copley - * @author Sarven Capadisli - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ + * @category Personal + * @package GNUsocial + * @author Evan Prodromou + * @author Zach Copley + * @author Sarven Capadisli + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ class NewnoticeAction extends FormAction { @@ -56,7 +52,7 @@ class NewnoticeAction extends FormAction * * @return string page title */ - function title() + public function title() { if ($this->getInfo() && $this->stored instanceof Notice) { // TRANS: Page title after sending a notice. @@ -66,12 +62,12 @@ class NewnoticeAction extends FormAction return _m('TITLE', 'New reply'); } // TRANS: Page title for sending a new notice. - return _m('TITLE','New notice'); + return _m('TITLE', 'New notice'); } protected function doPreparation() { - foreach(array('inreplyto') as $opt) { + foreach (['inreplyto'] as $opt) { if ($this->trimmed($opt)) { $this->formOpts[$opt] = $this->trimmed($opt); } @@ -106,26 +102,6 @@ class NewnoticeAction extends FormAction $options = array('source' => 'web'); Event::handle('StartSaveNewNoticeWeb', array($this, $user, &$content, &$options)); - $upload = null; - try { - // throws exception on failure - $upload = MediaFile::fromUpload('attach', $this->scoped); - if (Event::handle('StartSaveNewNoticeAppendAttachment', array($this, $upload, &$content, &$options))) { - $content .= ($content==='' ? '' : ' ') . $upload->shortUrl(); - } - Event::handle('EndSaveNewNoticeAppendAttachment', array($this, $upload, &$content, &$options)); - - // We could check content length here if the URL was added, but I'll just let it slide for now... - - $act->enclosures[] = $upload->getEnclosure(); - } catch (NoUploadedMediaException $e) { - // simply no attached media to the new notice - if (empty($content)) { - // TRANS: Client error displayed trying to send a notice without content. - throw new ClientException(_('No content!')); - } - } - $inter = new CommandInterpreter(); $cmd = $inter->handle_command($user, $content); @@ -144,15 +120,37 @@ class NewnoticeAction extends FormAction $act->time = time(); $act->actor = $this->scoped->asActivityObject(); + $upload = null; + try { + // throws exception on failure + $upload = MediaFile::fromUpload('attach', $this->scoped); + if (Event::handle('StartSaveNewNoticeAppendAttachment', array($this, $upload, &$content, &$options))) { + $content .= ($content==='' ? '' : ' ') . $upload->shortUrl(); + } + Event::handle('EndSaveNewNoticeAppendAttachment', array($this, $upload, &$content, &$options)); + + // We could check content length here if the URL was added, but I'll just let it slide for now... + + $act->enclosures[] = $upload->getEnclosure(); + } catch (NoUploadedMediaException $e) { + // simply no attached media to the new notice + if (empty($content)) { + // TRANS: Client error displayed trying to send a notice without content. + throw new ClientException(_m('No content!')); + } + } + // Reject notice if it is too long (without the HTML) // This is done after MediaFile::fromUpload etc. just to act the same as the ApiStatusesUpdateAction if (Notice::contentTooLong($content)) { // TRANS: Client error displayed when the parameter "status" is missing. // TRANS: %d is the maximum number of character for a notice. - throw new ClientException(sprintf(_m('That\'s too long. Maximum notice size is %d character.', - 'That\'s too long. Maximum notice size is %d characters.', - Notice::maxContent()), - Notice::maxContent())); + throw new ClientException(sprintf( + _m('That\'s too long. Maximum notice size is %d character.', + 'That\'s too long. Maximum notice size is %d characters.', + Notice::maxContent()), + Notice::maxContent() + )); } $act->context = new ActivityContext(); @@ -165,17 +163,21 @@ class NewnoticeAction extends FormAction if ($this->scoped->shareLocation()) { // use browser data if checked; otherwise profile data if ($this->arg('notice_data-geo')) { - $locOptions = Notice::locationOptions($this->trimmed('lat'), - $this->trimmed('lon'), - $this->trimmed('location_id'), - $this->trimmed('location_ns'), - $this->scoped); + $locOptions = Notice::locationOptions( + $this->trimmed('lat'), + $this->trimmed('lon'), + $this->trimmed('location_id'), + $this->trimmed('location_ns'), + $this->scoped + ); } else { - $locOptions = Notice::locationOptions(null, - null, - null, - null, - $this->scoped); + $locOptions = Notice::locationOptions( + null, + null, + null, + null, + $this->scoped + ); } $act->context->location = Location::fromOptions($locOptions); @@ -202,9 +204,7 @@ class NewnoticeAction extends FormAction $this->stored = Notice::saveActivity($act, $this->scoped, $options); - if ($upload instanceof MediaFile) { - $upload->attachToNotice($this->stored); - } + $upload->attachToNotice($this->stored); Event::handle('EndNoticeSaveWeb', array($this, $this->stored)); } @@ -216,7 +216,7 @@ class NewnoticeAction extends FormAction common_redirect($url, 303); } - return _('Saved the notice!'); + return _m('Saved the notice!'); } protected function showContent() @@ -240,7 +240,7 @@ class NewnoticeAction extends FormAction * * @return void */ - function showNotice(Notice $notice) + public function showNotice(Notice $notice) { $nli = new NoticeListItem($notice, $this); $nli->show(); diff --git a/actions/redirecturl.php b/actions/redirecturl.php index 826ab66a67..eb9e82dc31 100644 --- a/actions/redirecturl.php +++ b/actions/redirecturl.php @@ -56,7 +56,7 @@ class RedirecturlAction extends ManagedAction public function showPage() { - common_redirect($this->file->getUrl(false), 301); + common_redirect($this->file->getUrl(true), 301); } function isReadOnly($args) diff --git a/classes/File.php b/classes/File.php index 4e37f5f187..50b51b1ed1 100644 --- a/classes/File.php +++ b/classes/File.php @@ -53,7 +53,7 @@ class File extends Managed_DataObject return array( 'fields' => array( 'id' => array('type' => 'serial', 'not null' => true), - 'urlhash' => array('type' => 'varchar', 'length' => 64, 'not null' => true, 'description' => 'sha256 of destination URL (url field)'), + 'urlhash' => array('type' => 'varchar', 'length' => 64, 'description' => 'sha256 of destination URL (url field)'), 'url' => array('type' => 'text', 'description' => 'destination URL after following possible redirections'), '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'), @@ -112,21 +112,26 @@ class File extends Managed_DataObject // We don't have the file's URL since before, so let's continue. } - // 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')) { + // If the given url is a local attachment url, don't save a new file record. + $uh = parse_url($given_url, PHP_URL_HOST); + $up = parse_url($given_url, PHP_URL_PATH); + if ($uh == common_config('site', 'server') || $uh == common_config('attachments', 'server')) { + unset($uh); $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') { + $args = $r->map(mb_substr($up, 1)); + if ($args['action'] === 'attachment' || + $args['action'] === 'attachment_view' || + $args['action'] === 'attachment_download' || + $args['action'] === 'attachment_thumbnail' ) { try { - if (!empty($args['attachment'])) { - return File::getByID($args['attachment']); - } elseif ($args['filehash']) { - return File::getByHash($args['filehash']); + if (array_key_exists('attachment', $args)) { + return File::getByID((int)$args['attachment']); + } elseif (array_key_exists('filehash', $args)) { + $file = File::getByHash($args['filehash']); + $file->fetch(); + return $file; } } catch (NoResultException $e) { // apparently this link goes to us, but is _not_ an existing attachment (File) ID? @@ -158,10 +163,10 @@ class File extends Managed_DataObject $file->mimetype = $redir_data['type']; } if (!empty($redir_data['size'])) { - $file->size = intval($redir_data['size']); + $file->size = (int)$redir_data['size']; } if (isset($redir_data['time']) && $redir_data['time'] > 0) { - $file->date = intval($redir_data['time']); + $file->date = (int)$redir_data['time']; } $file->saveFile(); return $file; @@ -169,7 +174,7 @@ class File extends Managed_DataObject public function saveFile() { - $this->urlhash = self::hashurl($this->url); + $this->urlhash = is_null($this->url) ? null : self::hashurl($this->url); if (!Event::handle('StartFileSaveNew', array(&$this))) { throw new ServerException('File not saved due to an aborted StartFileSaveNew event.'); @@ -193,14 +198,14 @@ class File extends Managed_DataObject * - return the File object with the full reference * * @param string $given_url the URL we're looking at - * @param Notice $notice (optional) + * @param Notice|null $notice (optional) * @param bool $followRedirects defaults to true * * @return mixed File on success, -1 on some errors * * @throws ServerException on failure */ - public static function processNew($given_url, Notice $notice=null, $followRedirects=true) + public static function processNew($given_url, ?Notice $notice=null, bool $followRedirects=true) { if (empty($given_url)) { throw new ServerException('No given URL to process'); @@ -265,7 +270,8 @@ class File extends Managed_DataObject INNER JOIN notice ON file_to_post.post_id = notice.id WHERE profile_id = {$scoped->id} AND - file.url LIKE '%/notice/%/file'"; + filename IS NULL AND + file.url IS NOT NULL"; $file->query($query); $file->fetch(); $total = $file->total + $fileSize; @@ -460,6 +466,14 @@ class File extends Managed_DataObject return $dir . $filename; } + /** + * Don't use for attachments, only for assets. + * + * @param $filename + * @return mixed|string + * @throws InvalidFilenameException + * @throws ServerException + */ public static function url($filename) { self::tryFilename($filename); @@ -534,7 +548,7 @@ class File extends Managed_DataObject $needMoreMetadataMimetypes = array(null, 'application/xhtml+xml', 'text/html'); - if (!isset($this->filename) && in_array(common_bare_mime($enclosure->mimetype), $needMoreMetadataMimetypes)) { + if (isset($enclosure->url) && 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)); @@ -561,45 +575,28 @@ class File extends Managed_DataObject } /** - * Get the attachment's thumbnail record, if any. - * Make sure you supply proper 'int' typed variables (or null). + * Get the attachment's thumbnail record, if any or generate one. * - * @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) + * @param int|null $width Max width of thumbnail in pixels. (if null, use common_config values) + * @param int|null $height Max height of thumbnail in pixels. (if null, square-crop to $width) + * @param bool $crop Crop to the max-values' aspect ratio + * @param bool $force_still Don't allow fallback to showing original (such as animated GIF) + * @param bool|null $upscale Whether or not to scale smaller images up to larger thumbnail sizes. (null = site default) * * @return File_thumbnail * - * @throws UseFileAsThumbnailException if the file is considered an image itself and should be itself as thumbnail - * @throws UnsupportedMediaException if, despite trying, we can't understand how to make a thumbnail for this format - * @throws ServerException on various other errors + * @throws ClientException + * @throws FileNotFoundException + * @throws FileNotStoredLocallyException + * @throws InvalidFilenameException + * @throws NoResultException + * @throws ServerException on various other errors + * @throws UnsupportedMediaException if, despite trying, we can't understand how to make a thumbnail for this format + * @throws UseFileAsThumbnailException if the file is considered an image itself and should be itself as thumbnail */ - public function getThumbnail($width = null, $height = null, $crop = false, $force_still = true, $upscale = null): File_thumbnail + public function getThumbnail (?int $width = null, ?int $height = null, bool $crop = false, bool $force_still = true, ?bool $upscale = null): File_thumbnail { - // Get some more information about this file through our ImageFile class - $image = ImageFile::fromFileObject($this); - if ($image->animated && !common_config('thumbnail', 'animated')) { - // 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) { - 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, - !is_null($upscale) ? $upscale : common_config('thumbnail', 'upscale') - ); + return File_thumbnail::fromFileObject($this, $width, $height, $crop, $force_still, $upscale); } public function getPath() @@ -698,34 +695,33 @@ class File extends Managed_DataObject public function getAttachmentDownloadUrl() { - return common_local_url('attachment_download', array('attachment'=>$this->getID())); + return common_local_url('attachment_download', ['filehash' => $this->filehash]); } public function getAttachmentViewUrl() { - return common_local_url('attachment_view', array('attachment'=>$this->getID())); + return common_local_url('attachment_view', ['filehash' => $this->filehash]); } /** - * @param mixed $use_local true means require local, null means prefer local, false means use whatever is stored + * @param bool|null $use_local true means require local, null means prefer original, false means use whatever is stored * @return string * @throws FileNotStoredLocallyException */ - public function getUrl($use_local=null) + public function getUrl(?bool $use_local=null): ?string { if ($use_local !== false) { - if (is_string($this->filename) || !empty($this->filename)) { + if (empty($this->url)) { // A locally stored file, so let's generate a URL for our instance. return $this->getAttachmentViewUrl(); } if ($use_local) { - // if the file wasn't stored locally (has filename) and we require a local URL + // if the file isn't ours but and we require a local URL anyway throw new FileNotStoredLocallyException($this); } } - - // No local filename available, return the URL we have stored + // The original file's URL return $this->url; } @@ -748,7 +744,7 @@ class File extends Managed_DataObject { $file = new File(); $file->filehash = strtolower($hashstr); - if (!$file->find(true)) { + if (!$file->find()) { throw new NoResultException($file); } return $file; @@ -836,11 +832,10 @@ class File extends Managed_DataObject public function isLocal() { - return !empty($this->filename); + return empty($this->url) && !empty($this->filename); } - public function delete($useWhere=false) - { + public function unlink() { // Delete the file, if it exists locally if (!empty($this->filename) && file_exists(self::path($this->filename))) { $deleted = @unlink(self::path($this->filename)); @@ -848,12 +843,18 @@ class File extends Managed_DataObject common_log(LOG_ERR, sprintf('Could not unlink existing file: "%s"', self::path($this->filename))); } } + } + + public function delete($useWhere=false) + { + // Delete the file, if it exists locally + $this->unlink(); // Clear out related things in the database and filesystem, such as thumbnails $related = [ 'File_redirection', 'File_thumbnail', - 'File_to_post', + 'File_to_post' ]; Event::handle('FileDeleteRelated', [$this, &$related]); diff --git a/classes/File_thumbnail.php b/classes/File_thumbnail.php index 191282c938..6eb36f1393 100644 --- a/classes/File_thumbnail.php +++ b/classes/File_thumbnail.php @@ -59,6 +59,69 @@ class File_thumbnail extends Managed_DataObject ); } + /** + * Get the attachment's thumbnail record, if any or generate one. + * + * @param File $file + * @param int|null $width Max width of thumbnail in pixels. (if null, use common_config values) + * @param int|null $height Max height of thumbnail in pixels. (if null, square-crop to $width) + * @param bool $crop Crop to the max-values' aspect ratio + * @param bool $force_still Don't allow fallback to showing original (such as animated GIF) + * @param bool|null $upscale Whether or not to scale smaller images up to larger thumbnail sizes. (null = site default) + * + * @return File_thumbnail + * + * @throws ClientException + * @throws FileNotFoundException + * @throws FileNotStoredLocallyException + * @throws InvalidFilenameException + * @throws NoResultException + * @throws ServerException on various other errors + * @throws UnsupportedMediaException if, despite trying, we can't understand how to make a thumbnail for this format + * @throws UseFileAsThumbnailException if the file is considered an image itself and should be itself as thumbnail + */ + public static function fromFileObject (File $file, ?int $width = null, ?int $height = null, bool $crop = false, bool $force_still = true, ?bool $upscale = null): File_thumbnail + { + if (is_null($file->filename)) { + throw new FileNotFoundException("This remote file has no local thumbnail."); + } + $image = ImageFile::fromFileObject($file); + $imgPath = $image->getPath(); + $media = common_get_mime_media($file->mimetype); + if (Event::handle('CreateFileImageThumbnailSource', [$file, &$imgPath, $media])) { + if (!file_exists($imgPath)) { + throw new FileNotFoundException($imgPath); + } + + // First some mimetype specific exceptions + switch ($file->mimetype) { + case 'image/svg+xml': + throw new UseFileAsThumbnailException($file); + } + } + + if ($image->animated && !common_config('thumbnail', 'animated')) { + // 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) { + try { + // remote files with animated GIFs as thumbnails will match this + return File_thumbnail::byFile($file); + } catch (NoResultException $e) { + // and if it's not a remote file, it'll be safe to use the locally stored File + throw new UseFileAsThumbnailException($file); + } + } + } + + return $image->getFileThumbnail( + $width, + $height, + $crop, + !is_null($upscale) ? $upscale : common_config('thumbnail', 'upscale') + ); + } + /** * Save oEmbed-provided thumbnail data * @@ -128,8 +191,8 @@ class File_thumbnail extends Managed_DataObject $tn->file_id = $file_id; $tn->url = $url; $tn->filename = $filename; - $tn->width = intval($width); - $tn->height = intval($height); + $tn->width = (int)$width; + $tn->height = (int)$height; $tn->insert(); return $tn; } @@ -148,30 +211,6 @@ class File_thumbnail extends Managed_DataObject return $dir . $filename; } - public static function url($filename) - { - File::tryFilename($filename); - - // FIXME: private site thumbnails? - - $path = common_config('thumbnail', 'path'); - if (empty($path)) { - return File::url('thumb')."/{$filename}"; - } - - $protocol = (GNUsocial::useHTTPS() ? 'https' : 'http'); - $server = common_config('thumbnail', 'server') ?: common_config('site', 'server'); - - if ($path[mb_strlen($path)-1] != '/') { - $path .= '/'; - } - if ($path[0] != '/') { - $path = '/'.$path; - } - - return $protocol.'://'.$server.$path.$filename; - } - public function getFilename() { return File::tryFilename($this->filename); @@ -222,17 +261,11 @@ class File_thumbnail extends Managed_DataObject public function getUrl() { - if (!empty($this->filename) || $this->getFile()->isLocal()) { - // A locally stored File, so we can dynamically generate a URL. - $url = common_local_url('attachment_thumbnail', array('attachment'=>$this->file_id)); - if (strpos($url, '?') === false) { - $url .= '?'; - } - return $url . http_build_query(array('w'=>$this->width, 'h'=>$this->height)); + $url = common_local_url('attachment_thumbnail', ['filehash' => $this->getFile()->filehash]); + if (strpos($url, '?') === false) { + $url .= '?'; } - - // No local filename available, return the remote URL we have stored - return $this->url; + return $url . http_build_query(['w'=>$this->width, 'h'=>$this->height]); } public function getHeight() @@ -272,7 +305,7 @@ class File_thumbnail extends Managed_DataObject return parent::delete($useWhere); } - public function getFile() + public function getFile(): File { return File::getByID($this->file_id); } diff --git a/lib/media/attachment.php b/lib/media/attachment.php index 265a3874c0..08b489a8d5 100644 --- a/lib/media/attachment.php +++ b/lib/media/attachment.php @@ -61,6 +61,6 @@ class Attachment extends AttachmentListItem public function linkAttr() { - return array('rel' => 'external', 'href' => $this->attachment->getAttachmentDownloadUrl()); + return ['rel' => 'external', 'href' => $this->attachment->getUrl(null)]; } } diff --git a/lib/media/attachmentlist.php b/lib/media/attachmentlist.php index 696e000c73..14697023cb 100644 --- a/lib/media/attachmentlist.php +++ b/lib/media/attachmentlist.php @@ -75,12 +75,6 @@ class AttachmentList extends Widget function show() { $attachments = $this->notice->attachments(); - foreach ($attachments as $key=>$att) { - // Remove attachments which are not representable with neither a title nor thumbnail - if ($att->getTitle() === _('Untitled attachment') && !$att->hasThumbnail()) { - unset($attachments[$key]); - } - } if (!count($attachments)) { return 0; } diff --git a/lib/media/attachmentlistitem.php b/lib/media/attachmentlistitem.php index ebb7e0bdfc..0e57b39d62 100644 --- a/lib/media/attachmentlistitem.php +++ b/lib/media/attachmentlistitem.php @@ -91,10 +91,11 @@ class AttachmentListItem extends Widget } function linkAttr() { - return array( - 'class' => 'u-url', - 'href' => $this->attachment->getAttachmentUrl(), - 'title' => $this->linkTitle()); + return [ + 'class' => 'u-url', + 'href' => $this->attachment->getAttachmentDownloadUrl(), + 'title' => $this->linkTitle() + ]; } function showNoticeAttachment() @@ -105,101 +106,110 @@ class AttachmentListItem extends Widget function showRepresentation() { $enclosure = $this->attachment->getEnclosure(); - if (Event::handle('StartShowAttachmentRepresentation', array($this->out, $this->attachment))) { + if (Event::handle('StartShowAttachmentRepresentation', [$this->out, $this->attachment])) { $this->out->elementStart('label'); - $this->out->element('a', $this->linkAttr(), $this->title()); + $this->out->element('a', ['rel' => 'external', 'href' => $this->attachment->getAttachmentUrl()], $this->title()); $this->out->elementEnd('label'); $this->out->element('br'); - if (!empty($enclosure->mimetype)) { - // First, prepare a thumbnail if it exists. - $thumb = null; - try { - // Tell getThumbnail that we can show an animated image if it has one (4th arg, "force_still") - $thumb = $this->attachment->getThumbnail(null, null, false, false); - } catch (UseFileAsThumbnailException $e) { + try { + if (!empty($enclosure->mimetype)) { + // First, prepare a thumbnail if it exists. $thumb = null; - } catch (UnsupportedMediaException $e) { - // FIXME: Show a good representation of unsupported/unshowable images - $thumb = null; - } - - // Then get the kind of mediatype we're dealing with - $mediatype = common_get_mime_media($enclosure->mimetype); - - // FIXME: Get proper mime recognition of Ogg files! If system has 'mediainfo', this should do it: - // $ mediainfo --inform='General;%InternetMediaType%' - if ($this->attachment->mimetype === 'application/ogg') { - $mediatype = 'video'; // because this element can handle Ogg/Vorbis etc. on its own - } - - // Ugly hack to show text/html links which have a thumbnail (such as from oEmbed/OpenGraph image URLs) - if (!in_array($mediatype, ['image','audio','video']) && $thumb instanceof File_thumbnail) { - $mediatype = 'image'; - } - - switch ($mediatype) { - // Anything we understand as an image, if we need special treatment, do it in StartShowAttachmentRepresentation - case 'image': - if ($thumb instanceof File_thumbnail) { - $this->out->element('img', $thumb->getHtmlAttrs(['class'=>'u-photo', 'alt' => ''])); - } else { - try { - // getUrl(true) because we don't want to hotlink, could be made configurable - $this->out->element('img', ['class'=>'u-photo', - 'src'=>$this->attachment->getUrl(true), - 'alt' => $this->attachment->getTitle()]); - } catch (FileNotStoredLocallyException $e) { - $url = $e->file->getUrl(false); - $this->out->element('a', ['href'=>$url, 'rel'=>'external'], $url); - } - } - unset($thumb); // there's no need carrying this along after this - break; - - // HTML5 media elements - case 'audio': - case 'video': - if ($thumb instanceof File_thumbnail) { - $poster = $thumb->getUrl(); - unset($thumb); // there's no need carrying this along after this - } else { - $poster = null; + try { + // Tell getThumbnail that we can show an animated image if it has one (4th arg, "force_still") + $thumb = File_thumbnail::fromFileObject($this->attachment, null, null, false, false); + } catch (UseFileAsThumbnailException $e) { + $thumb = null; + } catch (UnsupportedMediaException $e) { + // FIXME: Show a good representation of unsupported/unshowable images + $thumb = null; + } catch (FileNotFoundException $e) { + // Remote file + $thumb = null; } - $this->out->elementStart($mediatype, - array('class'=>"attachment_player u-{$mediatype}", - 'poster'=>$poster, - 'controls'=>'controls')); - $this->out->element('source', - array('src'=>$this->attachment->getUrl(), - 'type'=>$this->attachment->mimetype)); - $this->out->elementEnd($mediatype); - break; + // Then get the kind of mediatype we're dealing with + $mediatype = common_get_mime_media($enclosure->mimetype); - default: - unset($thumb); // there's no need carrying this along - switch (common_bare_mime($this->attachment->mimetype)) { - case 'text/plain': - $this->element('div', ['class'=>'e-content plaintext'], - file_get_contents($this->attachment->getPath())); - break; - case 'text/html': - if (!empty($this->attachment->filename) - && (GNUsocial::isAjax() || common_config('attachments', 'show_html'))) { - // Locally-uploaded HTML. Scrub and display inline. - $this->showHtmlFile($this->attachment); + // FIXME: Get proper mime recognition of Ogg files! If system has 'mediainfo', this should do it: + // $ mediainfo --inform='General;%InternetMediaType%' + if ($this->attachment->mimetype === 'application/ogg') { + $mediatype = 'video'; // because this element can handle Ogg/Vorbis etc. on its own + } + + // Ugly hack to show text/html links which have a thumbnail (such as from oEmbed/OpenGraph image URLs) + if (!in_array($mediatype, ['image', 'audio', 'video']) && $thumb instanceof File_thumbnail) { + $mediatype = 'image'; + } + + switch ($mediatype) { + // Anything we understand as an image, if we need special treatment, do it in StartShowAttachmentRepresentation + case 'image': + if ($thumb instanceof File_thumbnail) { + $this->out->element('img', $thumb->getHtmlAttrs(['class' => 'u-photo', 'alt' => ''])); + } else { + try { + // getUrl(true) because we don't want to hotlink, could be made configurable + $this->out->element('img', ['class' => 'u-photo', + 'src' => $this->attachment->getUrl(true), + 'alt' => $this->attachment->getTitle()]); + } catch (FileNotStoredLocallyException $e) { + $url = $e->file->getUrl(false); + $this->out->element('a', ['href' => $url, 'rel' => 'external'], $url); + } + } + unset($thumb); // there's no need carrying this along after this break; - } - // Fall through to default if it wasn't a _local_ text/html File object - default: - Event::handle('ShowUnsupportedAttachmentRepresentation', array($this->out, $this->attachment)); + + // HTML5 media elements + case 'audio': + case 'video': + if ($thumb instanceof File_thumbnail) { + $poster = $thumb->getUrl(); + unset($thumb); // there's no need carrying this along after this + } else { + $poster = null; + } + + $this->out->elementStart($mediatype, + array('class' => "attachment_player u-{$mediatype}", + 'poster' => $poster, + 'controls' => 'controls')); + $this->out->element('source', + array('src' => $this->attachment->getUrl(), + 'type' => $this->attachment->mimetype)); + $this->out->elementEnd($mediatype); + break; + + default: + unset($thumb); // there's no need carrying this along + switch (common_bare_mime($this->attachment->mimetype)) { + case 'text/plain': + $this->element('div', ['class' => 'e-content plaintext'], + file_get_contents($this->attachment->getPath())); + break; + case 'text/html': + if (!empty($this->attachment->filename) + && (GNUsocial::isAjax() || common_config('attachments', 'show_html'))) { + // Locally-uploaded HTML. Scrub and display inline. + $this->showHtmlFile($this->attachment); + break; + } + // Fall through to default if it wasn't a _local_ text/html File object + default: + Event::handle('ShowUnsupportedAttachmentRepresentation', array($this->out, $this->attachment)); + } } + } else { + Event::handle('ShowUnsupportedAttachmentRepresentation', array($this->out, $this->attachment)); + } + } catch (FileNotFoundException $e) { + if (!$this->attachment->isLocal()) { + throw $e; } - } else { - Event::handle('ShowUnsupportedAttachmentRepresentation', array($this->out, $this->attachment)); } } Event::handle('EndShowAttachmentRepresentation', array($this->out, $this->attachment)); diff --git a/lib/media/imagefile.php b/lib/media/imagefile.php index b4bf006fd5..855c88093c 100644 --- a/lib/media/imagefile.php +++ b/lib/media/imagefile.php @@ -63,13 +63,13 @@ class ImageFile extends MediaFile * interactions (useful for temporary objects) * @param string $filepath The path of the file this media refers to. Required * @param string|null $filehash The hash of the file, if known. Optional - * + * @param string|null $fileurl * @throws ClientException * @throws NoResultException * @throws ServerException * @throws UnsupportedMediaException */ - public function __construct(?int $id = null, string $filepath, ?string $filehash = null) + public function __construct(?int $id = null, string $filepath, ?string $filehash = null, ?string $fileurl = null) { $old_limit = ini_set('memory_limit', common_config('attachments', 'memory_limit')); @@ -109,7 +109,8 @@ class ImageFile extends MediaFile $filepath, $this->mimetype, $filehash, - $id + $id, + $fileurl ); if ($this->type === IMAGETYPE_JPEG) { @@ -143,69 +144,28 @@ class ImageFile extends MediaFile } /** - * Create a thumbnail from a file object + * Shortcut method to get an ImageFile from a File * * @param File $file * @return ImageFile + * @throws ClientException * @throws FileNotFoundException + * @throws NoResultException + * @throws ServerException * @throws UnsupportedMediaException - * @throws UseFileAsThumbnailException */ public static function fromFileObject(File $file) { - $imgPath = null; $media = common_get_mime_media($file->mimetype); - if (Event::handle('CreateFileImageThumbnailSource', [$file, &$imgPath, $media])) { - if (empty($file->filename) && !file_exists($imgPath)) { - throw new FileNotFoundException($imgPath); - } - // First some mimetype specific exceptions - switch ($file->mimetype) { - case 'image/svg+xml': - throw new UseFileAsThumbnailException($file); - } - - // And we'll only consider it an image if it has such a media type - if ($media !== 'image') { - throw new UnsupportedMediaException(_m('Unsupported media format.'), $file->getPath()); - } - - if (!empty($file->filename)) { - $imgPath = $file->getPath(); - } + // And we'll only consider it an image if it has such a media type + if ($media !== 'image') { + throw new UnsupportedMediaException(_m('Unsupported media format.'), $file->getPath()); } - if (!file_exists($imgPath)) { - throw new FileNotFoundException($imgPath); - } + $filepath = $file->getPath(); - try { - $image = new self($file->getID(), $imgPath); - } catch (Exception $e) { - // 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)); - @unlink($imgPath); - } - } catch (FileNotFoundException $e) { - // File reported (via getPath) that the original file - // 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()) - )); - throw $e; - } - return $image; + return new self($file->getID(), $filepath, $file->filehash); } public function getPath() @@ -251,9 +211,9 @@ class ImageFile extends MediaFile } /** - * Process a file upload + * Create a new ImageFile object from an url * - * Uses MediaFile's `fromURL` to do the majority of the work + * Uses MediaFile's `fromUrl` to do the majority of the work * and ensures the uploaded file is in fact an image. * * @param string $url Remote image URL @@ -453,11 +413,6 @@ class ImageFile extends MediaFile return $outpath; } - public function unlink() - { - @unlink($this->filepath); - } - public function scaleToFit($maxWidth = null, $maxHeight = null, $crop = null) { return self::getScalingValues( @@ -587,7 +542,21 @@ class ImageFile extends MediaFile return $count >= 1; // number of animated frames apart from the original image } - public function getFileThumbnail($width, $height, $crop, $upscale = false) + /** + * @param $width + * @param $height + * @param $crop + * @param false $upscale + * @return File_thumbnail + * @throws ClientException + * @throws FileNotFoundException + * @throws FileNotStoredLocallyException + * @throws InvalidFilenameException + * @throws ServerException + * @throws UnsupportedMediaException + * @throws UseFileAsThumbnailException + */ + public function getFileThumbnail($width = null, $height = null, $crop = null, $upscale = false) { if (!$this->fileRecord instanceof File) { throw new ServerException('No File object attached to this ImageFile object.'); @@ -679,9 +648,7 @@ class ImageFile extends MediaFile return File_thumbnail::saveThumbnail( $this->fileRecord->getID(), - // no url since we generated it ourselves and can dynamically - // generate the url - null, + $this->fileRecord->getUrl(false), $width, $height, $outname diff --git a/lib/media/inlineattachmentlistitem.php b/lib/media/inlineattachmentlistitem.php index 5c918bb86e..e50530e6f9 100644 --- a/lib/media/inlineattachmentlistitem.php +++ b/lib/media/inlineattachmentlistitem.php @@ -41,9 +41,11 @@ class InlineAttachmentListItem extends AttachmentListItem // XXX: RDFa // TODO: add notice_type class e.g., notice_video, notice_image $this->out->elementStart('li', - array('class' => 'inline-attachment', - 'id' => 'attachment-' . $this->attachment->getID(), - )); + [ + 'class' => 'inline-attachment', + 'id' => 'attachment-' . $this->attachment->getID(), + ] + ); } /** diff --git a/lib/media/mediafile.php b/lib/media/mediafile.php index 4dbb924355..aa3a5770eb 100644 --- a/lib/media/mediafile.php +++ b/lib/media/mediafile.php @@ -48,24 +48,25 @@ class MediaFile /** * MediaFile constructor. * - * @param string $filepath The path of the file this media refers to. Required + * @param string|null $filepath The path of the file this media refers to. Required * @param string $mimetype The mimetype of the file. Required * @param string|null $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) - * + * @param string|null $fileurl Provide if remote * @throws ClientException * @throws NoResultException * @throws ServerException */ - public function __construct(string $filepath, string $mimetype, ?string $filehash = null, ?int $id = null) + public function __construct(?string $filepath = null, string $mimetype, ?string $filehash = null, ?int $id = null, ?string $fileurl = null) { $this->filepath = $filepath; $this->filename = basename($this->filepath); $this->mimetype = $mimetype; - $this->filehash = self::getHashOfFile($this->filepath, $filehash); - $this->id = $id; + $this->filehash = is_null($filepath) ? null : self::getHashOfFile($this->filepath, $filehash); + $this->id = $id; + $this->fileurl = $fileurl; // 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 @@ -82,16 +83,28 @@ class MediaFile // Otherwise, store it $this->fileRecord = $this->storeFile(); } - - $this->fileurl = common_local_url( - 'attachment', - ['attachment' => $this->fileRecord->id] - ); - - $this->short_fileurl = common_shorten_url($this->fileurl); } } + /** + * Shortcut method to get a MediaFile from a File + * + * @param File $file + * @return MediaFile|ImageFile + * @throws ClientException + * @throws FileNotFoundException + * @throws NoResultException + * @throws ServerException + */ + public static function fromFileObject(File $file) + { + $filepath = null; + try { + $filepath = $file->getPath(); + } catch (Exception $e) {} + return new self($filepath, common_get_mime_media($file->mimetype), $file->filehash, $file->getID()); + } + public function attachToNotice(Notice $notice) { File_to_post::processNew($this->fileRecord, $notice); @@ -102,9 +115,26 @@ class MediaFile return File::path($this->filename); } + /** + * @param bool|null $use_local true means require local, null means prefer original, false means use whatever is stored + * @return string + */ + public function getUrl(?bool $use_local=null): ?string + { + if ($use_local !== false) { + if (empty($this->fileurl)) { + // A locally stored file, so let's generate a URL for our instance. + return common_local_url('attachment_view', ['filehash' => $this->filehash]); + } + } + + // The original file's URL + return $this->fileurl; + } + public function shortUrl() { - return $this->short_fileurl; + return common_shorten_url($this->getUrl()); } public function getEnclosure() @@ -112,11 +142,27 @@ class MediaFile return $this->getFile()->getEnclosure(); } - public function delete() + public function delete($useWhere=false) { + if (!is_null($this->fileRecord)) { + $this->fileRecord->delete($useWhere); + } @unlink($this->filepath); } + public function unlink() + { + $this->filename = null; + // Delete the file, if it exists locally + if (!empty($this->filepath) && file_exists($this->filepath)) { + $deleted = @unlink($this->filepath); + if (!$deleted) { + common_log(LOG_ERR, sprintf('Could not unlink existing file: "%s"', $this->filepath)); + } + } + $this->fileRecord->unlink(); + } + public function getFile() { if (!$this->fileRecord instanceof File) { @@ -164,19 +210,19 @@ class MediaFile { try { $file = File::getByHash($this->filehash); - // We're done here. Yes. Already. We assume sha256 won't collide on us anytime soon. - return $file; + if (is_null($this->fileurl) && is_null($file->getUrl(false))) { + // An already existing local file is being re-added, return it + return $file; + } } catch (NoResultException $e) { // Well, let's just continue below. } - $fileurl = common_local_url('attachment_view', ['filehash' => $this->filehash]); - $file = new File; $file->filename = $this->filename; - $file->urlhash = File::hashurl($fileurl); - $file->url = $fileurl; + $file->url = $this->fileurl; + $file->urlhash = is_null($file->url) ? null : File::hashurl($file->url); $file->filehash = $this->filehash; $file->size = filesize($this->filepath); if ($file->size === false) { @@ -196,7 +242,7 @@ class MediaFile // Set file geometrical properties if available try { $image = ImageFile::fromFileObject($file); - $orig = clone $file; + $orig = clone($file); $file->width = $image->width; $file->height = $image->height; $file->update($orig); @@ -205,6 +251,8 @@ class MediaFile // may have generated a temporary file from a // video support plugin or something. // FIXME: Do this more automagically. + // Honestly, I think this is unlikely these days, + // but better be safe than sure, I guess if ($image->getPath() != $file->getPath()) { $image->unlink(); } @@ -390,6 +438,16 @@ class MediaFile try { $file = File::getByHash($filehash); + while ($file->fetch()) { + if ($file->getUrl(false)) { + continue; + } + try { + return ImageFile::fromFileObject($file); + } catch (UnsupportedMediaException $e) { + return MediaFile::fromFileObject($file); + } + } // 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 $filepath = $file->getPath(); @@ -432,10 +490,10 @@ class MediaFile } if ($media == 'image') { - return new ImageFile(null, $filepath); + return new ImageFile(null, $filepath, $filehash); } } - return new self($filepath, $mimetype, $filehash); + return new self($filepath, $mimetype, $filehash, null); } /** @@ -464,6 +522,48 @@ class MediaFile throw new ServerException(sprintf('Invalid remote media URL %s.', $url)); } + $http = new HTTPClient(); + common_debug(sprintf('Performing HEAD request for incoming activity to avoid ' . + 'unnecessarily downloading too large files. URL: %s', + $url)); + $head = $http->head($url); + $url = $head->getEffectiveUrl(); // to avoid going through redirects again + if (empty($url)) { + throw new ServerException(sprintf('URL after redirects is somehow empty, for URL %s.', $url)); + } + $headers = $head->getHeader(); + $headers = array_change_key_case($headers, CASE_LOWER); + if (array_key_exists('content-length', $headers)) { + $fileQuota = common_config('attachments', 'file_quota'); + $fileSize = $headers['content-length']; + if ($fileSize > $fileQuota) { + // 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); + + // TRANS: Message given if an upload is larger than the configured maximum. + // 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 + ) + ); + } + } else { + throw new ServerException(sprintf('Invalid remote media URL headers %s.', $url)); + } + unset($head); + unset($headers); + $tempfile = new TemporaryFile('gs-mediafile'); fwrite($tempfile->getResource(), HTTPClient::quickGet($url)); fflush($tempfile->getResource()); @@ -471,7 +571,7 @@ class MediaFile $filehash = strtolower(self::getHashOfFile($tempfile->getRealPath())); try { - $file = File::getByHash($filehash); + $file = File::getByUrl($url); /* * If no exception is thrown the file exists locally, so we'll use * that and just add redirections. @@ -531,10 +631,10 @@ class MediaFile } if ($media === 'image') { - return new ImageFile(null, $filepath); + return new ImageFile(null, $filepath, $filehash, $url); } } - return new self($filepath, $mimetype, $filehash); + return new self($filepath, $mimetype, $filehash, null, $url); } public static function fromFileInfo(SplFileInfo $finfo, Profile $scoped = null) @@ -543,6 +643,7 @@ class MediaFile try { $file = File::getByHash($filehash); + $file->fetch(); // Already have it, so let's reuse the locally stored File // by using getPath we also check whether the file exists // and throw a FileNotFoundException with the path if it doesn't. diff --git a/lib/util/router.php b/lib/util/router.php index 80097a70c3..76387ee625 100644 --- a/lib/util/router.php +++ b/lib/util/router.php @@ -223,16 +223,18 @@ class Router ['q' => '.+']); $m->connect('search/notice/rss', ['action' => 'noticesearchrss']); - foreach (['' => 'attachment', - '/view' => 'attachment_view', - '/download' => 'attachment_download', + // Attachment page for file + $m->connect("attachment/:attachment", + ['action' => 'attachment'], + ['attachment' => '[0-9]+']); + + // Retrieve local file + foreach (['/view' => 'attachment_view', + '/download' => 'attachment_download', '/thumbnail' => 'attachment_thumbnail'] as $postfix => $action) { - foreach (['filehash' => '[A-Za-z0-9._-]{64}', - 'attachment' => '[0-9]+'] as $type => $match) { - $m->connect("attachment/:{$type}{$postfix}", + $m->connect("attachment/:filehash{$postfix}", ['action' => $action], - [$type => $match]); - } + ['filehash' => '[A-Za-z0-9._-]{64}']); } $m->connect('notice/new?replyto=:replyto&inreplyto=:inreplyto', diff --git a/plugins/Embed/EmbedPlugin.php b/plugins/Embed/EmbedPlugin.php index 5475b8ef25..4cf5b72188 100644 --- a/plugins/Embed/EmbedPlugin.php +++ b/plugins/Embed/EmbedPlugin.php @@ -189,14 +189,12 @@ class EmbedPlugin extends Plugin foreach (['xml', 'json'] as $format) { $action->element( 'link', - ['rel' =>'alternate', - 'type' => "application/{$format}+oembed", - 'href' => common_local_url( - 'oembed', - [], - ['format' => $format, 'url' => $url] - ), - 'title' => 'oEmbed'] + [ + 'rel' =>'alternate', + 'type' => "application/{$format}+oembed", + 'href' => common_local_url('oembed', [], ['format' => $format, 'url' => $url]), + 'title' => 'oEmbed' + ] ); } } diff --git a/plugins/Embed/actions/oembed.php b/plugins/Embed/actions/oembed.php index 8c736aba90..9f44801885 100644 --- a/plugins/Embed/actions/oembed.php +++ b/plugins/Embed/actions/oembed.php @@ -93,7 +93,7 @@ class OEmbedAction extends Action } try { $thumb = $attachment->getThumbnail(); - $thumb_url = File_thumbnail::url($thumb->filename); + $thumb_url = $thumb->getUrl(); $oembed['thumbnail_url'] = $thumb_url; break; // only first one } catch (UseFileAsThumbnailException $e) { diff --git a/plugins/OStatus/classes/Ostatus_profile.php b/plugins/OStatus/classes/Ostatus_profile.php index a9d9e793d9..9c9e4a568e 100644 --- a/plugins/OStatus/classes/Ostatus_profile.php +++ b/plugins/OStatus/classes/Ostatus_profile.php @@ -917,7 +917,7 @@ class Ostatus_profile extends Managed_DataObject } // ImageFile throws exception if something goes wrong, which we'll let go on its merry way - $imagefile = ImageFile::fromURL($url); + $imagefile = ImageFile::fromUrl($url); $self = $this->localProfile(); diff --git a/plugins/StoreRemoteMedia/StoreRemoteMediaPlugin.php b/plugins/StoreRemoteMedia/StoreRemoteMediaPlugin.php index d3693b8c34..03b021b132 100644 --- a/plugins/StoreRemoteMedia/StoreRemoteMediaPlugin.php +++ b/plugins/StoreRemoteMedia/StoreRemoteMediaPlugin.php @@ -138,6 +138,7 @@ class StoreRemoteMediaPlugin extends Plugin try { // Exception will be thrown before $file is set to anything, so old $file value will be kept $file = File::getByHash($filehash); + $file->fetch(); //FIXME: Add some code so we don't have to store duplicate File rows for same hash files. } catch (NoResultException $e) {