[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...
This commit is contained in:
Diogo Peralta Cordeiro 2020-09-21 21:54:23 +01:00
parent e9cd437668
commit 6028175bfc
19 changed files with 578 additions and 415 deletions

View File

@ -19,11 +19,11 @@ defined('GNUSOCIAL') || die();
/** /**
* Show notice attachments * Show notice attachments
* *
* @category Personal * @category Personal
* @package StatusNet * @package GNUsocial
* @author Evan Prodromou <evan@status.net> * @author Evan Prodromou <evan@status.net>
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @copyright 2008-2009 StatusNet, Inc.
* @link http://status.net/ * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/ */
class AttachmentAction extends ManagedAction class AttachmentAction extends ManagedAction
{ {
@ -58,9 +58,11 @@ class AttachmentAction extends ManagedAction
try { try {
if (!empty($id = $this->trimmed('attachment'))) { if (!empty($id = $this->trimmed('attachment'))) {
$this->attachment = File::getByID($id); $this->attachment = File::getByID((int) $id);
} elseif (!empty($this->filehash = $this->trimmed('filehash'))) { } 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) { } catch (Exception $e) {
// Not found // Not found
@ -70,13 +72,22 @@ class AttachmentAction extends ManagedAction
$this->clientError(_m('No such attachment.'), 404); $this->clientError(_m('No such attachment.'), 404);
} }
$this->filepath = $this->attachment->getFileOrThumbnailPath(); $this->filesize = $this->attachment->size;
if (empty($this->filepath)) { $this->mimetype = $this->attachment->mimetype;
$this->clientError(_m('Requested local URL for a file that is not stored locally.'), 404); $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; return true;
} }
@ -104,8 +115,12 @@ class AttachmentAction extends ManagedAction
public function showPage(): void public function showPage(): void
{ {
if (empty($this->filepath)) { if (
// if it's not a local file, gtfo !$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); common_redirect($this->attachment->getUrl(), 303);
} }

View File

@ -1,15 +1,29 @@
<?php <?php
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social 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.
//
// GNU social 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 GNU social. If not, see <http://www.gnu.org/licenses/>.
if (!defined('GNUSOCIAL')) { exit(1); } defined('GNUSOCIAL') || die();
/** /**
* Download notice attachment * Download notice attachment
* *
* @category Personal * @category Personal
* @package GNUsocial * @package GNUsocial
* @author Mikael Nordfeldth <mmn@hethane.se> * @author Mikael Nordfeldth <mmn@hethane.se>
* @license https://www.gnu.org/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @copyright 2016 Free Software Foundation, Inc http://www.fsf.org
* @link https:/gnu.io/social * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or late
*/ */
class Attachment_downloadAction extends AttachmentAction 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 // script execution, and we don't want to have any more errors until then, so don't reset it
@ini_set('display_errors', 0); @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);
}
} }
} }

View File

@ -19,13 +19,13 @@ defined('GNUSOCIAL') || die();
/** /**
* Show notice attachments * Show notice attachments
* *
* @category Personal * @category Personal
* @package StatusNet * @package GNUsocial
* @author Evan Prodromou <evan@status.net> * @author Evan Prodromou <evan@status.net>
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @copyright 2008-2009 StatusNet, Inc.
* @link http://status.net/ * @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_w = null; // max width
protected $thumb_h = null; // max height protected $thumb_h = null; // max height
@ -52,14 +52,21 @@ class Attachment_thumbnailAction extends AttachmentAction
public function showPage(): void public function showPage(): void
{ {
// Returns a File_thumbnail object or throws exception if not available // Returns a File_thumbnail object or throws exception if not available
$filename = $this->filename;
$filepath = $this->filepath;
try { try {
$thumbnail = $this->attachment->getThumbnail($this->thumb_w, $this->thumb_h, $this->thumb_c); $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) { } catch (UseFileAsThumbnailException $e) {
// With this exception, the file exists locally // With this exception, the file exists locally $e->file;
$file = $e->file;
} catch (FileNotFoundException $e) { } catch (FileNotFoundException $e) {
$this->clientError(_m('No such attachment'), 404); $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 // 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 // script execution, and we don't want to have any more errors until then, so don't reset it
@ini_set('display_errors', 0); @ini_set('display_errors', 0);
common_send_file($this->filepath, $this->mimetype, $this->filename, 'inline'); common_send_file($filepath, $this->mimetype, $filename, 'inline');
} }
} }

View File

@ -19,9 +19,10 @@ defined('GNUSOCIAL') || die();
/** /**
* View notice attachment * View notice attachment
* *
* @package GNUsocial * @package GNUsocial
* @author Miguel Dantas <biodantasgs@gmail.com> * @author Miguel Dantas <biodantasgs@gmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @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 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 // script execution, and we don't want to have any more errors until then, so don't reset it
@ini_set('display_errors', 0); @ini_set('display_errors', 0);
$disposition = in_array(common_get_mime_media($this->mimetype), ['image', 'video']) ? 'inline' : 'attachment'; if ($this->attachment->isLocal()) {
common_send_file($this->filepath, $this->mimetype, $this->filename, $disposition); $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);
}
} }
} }

View File

@ -1,47 +1,43 @@
<?php <?php
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social 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.
//
// GNU social 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 GNU social. If not, see <http://www.gnu.org/licenses/>.
/** /**
* StatusNet, the distributed open-source microblogging tool
*
* Handler for posting new notices * 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 <http://www.gnu.org/licenses/>.
*
* @category Personal * @category Personal
* @package StatusNet * @package GNUsocial
* @author Evan Prodromou <evan@status.net> * @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net> * @author Zach Copley <zach@status.net>
* @author Sarven Capadisli <csarven@status.net> * @author Sarven Capadisli <csarven@status.net>
* @copyright 2008-2009 StatusNet, Inc. * @copyright 2008-2009 StatusNet, Inc.
* @copyright 2013 Free Software Foundation, Inc. * @copyright 2013 Free Software Foundation, Inc http://www.fsf.org
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
* @link http://status.net/
*/ */
if (!defined('GNUSOCIAL')) { exit(1); } defined('GNUSOCIAL') || die();
/** /**
* Action for posting new notices * Action for posting new notices
* *
* @category Personal * @category Personal
* @package StatusNet * @package GNUsocial
* @author Evan Prodromou <evan@status.net> * @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net> * @author Zach Copley <zach@status.net>
* @author Sarven Capadisli <csarven@status.net> * @author Sarven Capadisli <csarven@status.net>
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
* @link http://status.net/
*/ */
class NewnoticeAction extends FormAction class NewnoticeAction extends FormAction
{ {
@ -56,7 +52,7 @@ class NewnoticeAction extends FormAction
* *
* @return string page title * @return string page title
*/ */
function title() public function title()
{ {
if ($this->getInfo() && $this->stored instanceof Notice) { if ($this->getInfo() && $this->stored instanceof Notice) {
// TRANS: Page title after sending a notice. // TRANS: Page title after sending a notice.
@ -66,12 +62,12 @@ class NewnoticeAction extends FormAction
return _m('TITLE', 'New reply'); return _m('TITLE', 'New reply');
} }
// TRANS: Page title for sending a new notice. // TRANS: Page title for sending a new notice.
return _m('TITLE','New notice'); return _m('TITLE', 'New notice');
} }
protected function doPreparation() protected function doPreparation()
{ {
foreach(array('inreplyto') as $opt) { foreach (['inreplyto'] as $opt) {
if ($this->trimmed($opt)) { if ($this->trimmed($opt)) {
$this->formOpts[$opt] = $this->trimmed($opt); $this->formOpts[$opt] = $this->trimmed($opt);
} }
@ -106,26 +102,6 @@ class NewnoticeAction extends FormAction
$options = array('source' => 'web'); $options = array('source' => 'web');
Event::handle('StartSaveNewNoticeWeb', array($this, $user, &$content, &$options)); 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(); $inter = new CommandInterpreter();
$cmd = $inter->handle_command($user, $content); $cmd = $inter->handle_command($user, $content);
@ -144,15 +120,37 @@ class NewnoticeAction extends FormAction
$act->time = time(); $act->time = time();
$act->actor = $this->scoped->asActivityObject(); $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) // 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 // This is done after MediaFile::fromUpload etc. just to act the same as the ApiStatusesUpdateAction
if (Notice::contentTooLong($content)) { if (Notice::contentTooLong($content)) {
// TRANS: Client error displayed when the parameter "status" is missing. // TRANS: Client error displayed when the parameter "status" is missing.
// TRANS: %d is the maximum number of character for a notice. // 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.', throw new ClientException(sprintf(
'That\'s too long. Maximum notice size is %d characters.', _m('That\'s too long. Maximum notice size is %d character.',
Notice::maxContent()), 'That\'s too long. Maximum notice size is %d characters.',
Notice::maxContent())); Notice::maxContent()),
Notice::maxContent()
));
} }
$act->context = new ActivityContext(); $act->context = new ActivityContext();
@ -165,17 +163,21 @@ class NewnoticeAction extends FormAction
if ($this->scoped->shareLocation()) { if ($this->scoped->shareLocation()) {
// use browser data if checked; otherwise profile data // use browser data if checked; otherwise profile data
if ($this->arg('notice_data-geo')) { if ($this->arg('notice_data-geo')) {
$locOptions = Notice::locationOptions($this->trimmed('lat'), $locOptions = Notice::locationOptions(
$this->trimmed('lon'), $this->trimmed('lat'),
$this->trimmed('location_id'), $this->trimmed('lon'),
$this->trimmed('location_ns'), $this->trimmed('location_id'),
$this->scoped); $this->trimmed('location_ns'),
$this->scoped
);
} else { } else {
$locOptions = Notice::locationOptions(null, $locOptions = Notice::locationOptions(
null, null,
null, null,
null, null,
$this->scoped); null,
$this->scoped
);
} }
$act->context->location = Location::fromOptions($locOptions); $act->context->location = Location::fromOptions($locOptions);
@ -202,9 +204,7 @@ class NewnoticeAction extends FormAction
$this->stored = Notice::saveActivity($act, $this->scoped, $options); $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)); Event::handle('EndNoticeSaveWeb', array($this, $this->stored));
} }
@ -216,7 +216,7 @@ class NewnoticeAction extends FormAction
common_redirect($url, 303); common_redirect($url, 303);
} }
return _('Saved the notice!'); return _m('Saved the notice!');
} }
protected function showContent() protected function showContent()
@ -240,7 +240,7 @@ class NewnoticeAction extends FormAction
* *
* @return void * @return void
*/ */
function showNotice(Notice $notice) public function showNotice(Notice $notice)
{ {
$nli = new NoticeListItem($notice, $this); $nli = new NoticeListItem($notice, $this);
$nli->show(); $nli->show();

View File

@ -56,7 +56,7 @@ class RedirecturlAction extends ManagedAction
public function showPage() public function showPage()
{ {
common_redirect($this->file->getUrl(false), 301); common_redirect($this->file->getUrl(true), 301);
} }
function isReadOnly($args) function isReadOnly($args)

View File

@ -53,7 +53,7 @@ class File extends Managed_DataObject
return array( return array(
'fields' => array( 'fields' => array(
'id' => array('type' => 'serial', 'not null' => true), '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'), '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'), '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'), '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. // 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 // If the given url is a local attachment url, don't save a new file record.
// save a new file record. This should never happen, but let's make it foolproof $uh = parse_url($given_url, PHP_URL_HOST);
// FIXME: how about attachments servers? $up = parse_url($given_url, PHP_URL_PATH);
$u = parse_url($given_url); if ($uh == common_config('site', 'server') || $uh == common_config('attachments', 'server')) {
if (isset($u['host']) && $u['host'] === common_config('site', 'server')) { unset($uh);
$r = Router::get(); $r = Router::get();
// Skip the / in the beginning or $r->map won't match // Skip the / in the beginning or $r->map won't match
try { try {
$args = $r->map(mb_substr($u['path'], 1)); $args = $r->map(mb_substr($up, 1));
if ($args['action'] === 'attachment') { if ($args['action'] === 'attachment' ||
$args['action'] === 'attachment_view' ||
$args['action'] === 'attachment_download' ||
$args['action'] === 'attachment_thumbnail' ) {
try { try {
if (!empty($args['attachment'])) { if (array_key_exists('attachment', $args)) {
return File::getByID($args['attachment']); return File::getByID((int)$args['attachment']);
} elseif ($args['filehash']) { } elseif (array_key_exists('filehash', $args)) {
return File::getByHash($args['filehash']); $file = File::getByHash($args['filehash']);
$file->fetch();
return $file;
} }
} catch (NoResultException $e) { } catch (NoResultException $e) {
// apparently this link goes to us, but is _not_ an existing attachment (File) ID? // 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']; $file->mimetype = $redir_data['type'];
} }
if (!empty($redir_data['size'])) { 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) { if (isset($redir_data['time']) && $redir_data['time'] > 0) {
$file->date = intval($redir_data['time']); $file->date = (int)$redir_data['time'];
} }
$file->saveFile(); $file->saveFile();
return $file; return $file;
@ -169,7 +174,7 @@ class File extends Managed_DataObject
public function saveFile() 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))) { if (!Event::handle('StartFileSaveNew', array(&$this))) {
throw new ServerException('File not saved due to an aborted StartFileSaveNew event.'); 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 * - return the File object with the full reference
* *
* @param string $given_url the URL we're looking at * @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 * @param bool $followRedirects defaults to true
* *
* @return mixed File on success, -1 on some errors * @return mixed File on success, -1 on some errors
* *
* @throws ServerException on failure * @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)) { if (empty($given_url)) {
throw new ServerException('No given URL to process'); throw new ServerException('No given URL to process');
@ -265,7 +270,8 @@ class File extends Managed_DataObject
INNER JOIN notice INNER JOIN notice
ON file_to_post.post_id = notice.id ON file_to_post.post_id = notice.id
WHERE profile_id = {$scoped->id} AND WHERE profile_id = {$scoped->id} AND
file.url LIKE '%/notice/%/file'"; filename IS NULL AND
file.url IS NOT NULL";
$file->query($query); $file->query($query);
$file->fetch(); $file->fetch();
$total = $file->total + $fileSize; $total = $file->total + $fileSize;
@ -460,6 +466,14 @@ class File extends Managed_DataObject
return $dir . $filename; 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) public static function url($filename)
{ {
self::tryFilename($filename); self::tryFilename($filename);
@ -534,7 +548,7 @@ class File extends Managed_DataObject
$needMoreMetadataMimetypes = array(null, 'application/xhtml+xml', 'text/html'); $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, // This fetches enclosure metadata for non-local links with unset/HTML mimetypes,
// which may be enriched through oEmbed or similar (implemented as plugins) // which may be enriched through oEmbed or similar (implemented as plugins)
Event::handle('FileEnclosureMetadata', array($this, &$enclosure)); Event::handle('FileEnclosureMetadata', array($this, &$enclosure));
@ -561,45 +575,28 @@ class File extends Managed_DataObject
} }
/** /**
* Get the attachment's thumbnail record, if any. * Get the attachment's thumbnail record, if any or generate one.
* 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 int|null $width 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 int|null $height Max height of thumbnail in pixels. (if null, square-crop to $width)
* @param $crop bool Crop to the max-values' aspect ratio * @param bool $crop Crop to the max-values' aspect ratio
* @param $force_still bool Don't allow fallback to showing original (such as animated GIF) * @param bool $force_still 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 bool|null $upscale Whether or not to scale smaller images up to larger thumbnail sizes. (null = site default)
* *
* @return File_thumbnail * @return File_thumbnail
* *
* @throws UseFileAsThumbnailException if the file is considered an image itself and should be itself as thumbnail * @throws ClientException
* @throws UnsupportedMediaException if, despite trying, we can't understand how to make a thumbnail for this format * @throws FileNotFoundException
* @throws ServerException on various other errors * @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 return File_thumbnail::fromFileObject($this, $width, $height, $crop, $force_still, $upscale);
$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')
);
} }
public function getPath() public function getPath()
@ -698,34 +695,33 @@ class File extends Managed_DataObject
public function getAttachmentDownloadUrl() 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() 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 * @return string
* @throws FileNotStoredLocallyException * @throws FileNotStoredLocallyException
*/ */
public function getUrl($use_local=null) public function getUrl(?bool $use_local=null): ?string
{ {
if ($use_local !== false) { 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. // A locally stored file, so let's generate a URL for our instance.
return $this->getAttachmentViewUrl(); return $this->getAttachmentViewUrl();
} }
if ($use_local) { 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); throw new FileNotStoredLocallyException($this);
} }
} }
// The original file's URL
// No local filename available, return the URL we have stored
return $this->url; return $this->url;
} }
@ -748,7 +744,7 @@ class File extends Managed_DataObject
{ {
$file = new File(); $file = new File();
$file->filehash = strtolower($hashstr); $file->filehash = strtolower($hashstr);
if (!$file->find(true)) { if (!$file->find()) {
throw new NoResultException($file); throw new NoResultException($file);
} }
return $file; return $file;
@ -836,11 +832,10 @@ class File extends Managed_DataObject
public function isLocal() 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 // Delete the file, if it exists locally
if (!empty($this->filename) && file_exists(self::path($this->filename))) { if (!empty($this->filename) && file_exists(self::path($this->filename))) {
$deleted = @unlink(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))); 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 // Clear out related things in the database and filesystem, such as thumbnails
$related = [ $related = [
'File_redirection', 'File_redirection',
'File_thumbnail', 'File_thumbnail',
'File_to_post', 'File_to_post'
]; ];
Event::handle('FileDeleteRelated', [$this, &$related]); Event::handle('FileDeleteRelated', [$this, &$related]);

View File

@ -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 * Save oEmbed-provided thumbnail data
* *
@ -128,8 +191,8 @@ class File_thumbnail extends Managed_DataObject
$tn->file_id = $file_id; $tn->file_id = $file_id;
$tn->url = $url; $tn->url = $url;
$tn->filename = $filename; $tn->filename = $filename;
$tn->width = intval($width); $tn->width = (int)$width;
$tn->height = intval($height); $tn->height = (int)$height;
$tn->insert(); $tn->insert();
return $tn; return $tn;
} }
@ -148,30 +211,6 @@ class File_thumbnail extends Managed_DataObject
return $dir . $filename; 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() public function getFilename()
{ {
return File::tryFilename($this->filename); return File::tryFilename($this->filename);
@ -222,17 +261,11 @@ class File_thumbnail extends Managed_DataObject
public function getUrl() public function getUrl()
{ {
if (!empty($this->filename) || $this->getFile()->isLocal()) { $url = common_local_url('attachment_thumbnail', ['filehash' => $this->getFile()->filehash]);
// A locally stored File, so we can dynamically generate a URL. if (strpos($url, '?') === false) {
$url = common_local_url('attachment_thumbnail', array('attachment'=>$this->file_id)); $url .= '?';
if (strpos($url, '?') === false) {
$url .= '?';
}
return $url . http_build_query(array('w'=>$this->width, 'h'=>$this->height));
} }
return $url . http_build_query(['w'=>$this->width, 'h'=>$this->height]);
// No local filename available, return the remote URL we have stored
return $this->url;
} }
public function getHeight() public function getHeight()
@ -272,7 +305,7 @@ class File_thumbnail extends Managed_DataObject
return parent::delete($useWhere); return parent::delete($useWhere);
} }
public function getFile() public function getFile(): File
{ {
return File::getByID($this->file_id); return File::getByID($this->file_id);
} }

View File

@ -61,6 +61,6 @@ class Attachment extends AttachmentListItem
public function linkAttr() public function linkAttr()
{ {
return array('rel' => 'external', 'href' => $this->attachment->getAttachmentDownloadUrl()); return ['rel' => 'external', 'href' => $this->attachment->getUrl(null)];
} }
} }

View File

@ -75,12 +75,6 @@ class AttachmentList extends Widget
function show() function show()
{ {
$attachments = $this->notice->attachments(); $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)) { if (!count($attachments)) {
return 0; return 0;
} }

View File

@ -91,10 +91,11 @@ class AttachmentListItem extends Widget
} }
function linkAttr() { function linkAttr() {
return array( return [
'class' => 'u-url', 'class' => 'u-url',
'href' => $this->attachment->getAttachmentUrl(), 'href' => $this->attachment->getAttachmentDownloadUrl(),
'title' => $this->linkTitle()); 'title' => $this->linkTitle()
];
} }
function showNoticeAttachment() function showNoticeAttachment()
@ -105,101 +106,110 @@ class AttachmentListItem extends Widget
function showRepresentation() { function showRepresentation() {
$enclosure = $this->attachment->getEnclosure(); $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->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->elementEnd('label');
$this->out->element('br'); $this->out->element('br');
if (!empty($enclosure->mimetype)) { try {
// First, prepare a thumbnail if it exists. if (!empty($enclosure->mimetype)) {
$thumb = null; // First, prepare a thumbnail if it exists.
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) {
$thumb = null; $thumb = null;
} catch (UnsupportedMediaException $e) { try {
// FIXME: Show a good representation of unsupported/unshowable images // Tell getThumbnail that we can show an animated image if it has one (4th arg, "force_still")
$thumb = null; $thumb = File_thumbnail::fromFileObject($this->attachment, null, null, false, false);
} } catch (UseFileAsThumbnailException $e) {
$thumb = null;
// Then get the kind of mediatype we're dealing with } catch (UnsupportedMediaException $e) {
$mediatype = common_get_mime_media($enclosure->mimetype); // FIXME: Show a good representation of unsupported/unshowable images
$thumb = null;
// FIXME: Get proper mime recognition of Ogg files! If system has 'mediainfo', this should do it: } catch (FileNotFoundException $e) {
// $ mediainfo --inform='General;%InternetMediaType%' // Remote file
if ($this->attachment->mimetype === 'application/ogg') { $thumb = null;
$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;
} }
$this->out->elementStart($mediatype, // Then get the kind of mediatype we're dealing with
array('class'=>"attachment_player u-{$mediatype}", $mediatype = common_get_mime_media($enclosure->mimetype);
'poster'=>$poster,
'controls'=>'controls'));
$this->out->element('source',
array('src'=>$this->attachment->getUrl(),
'type'=>$this->attachment->mimetype));
$this->out->elementEnd($mediatype);
break;
default: // FIXME: Get proper mime recognition of Ogg files! If system has 'mediainfo', this should do it:
unset($thumb); // there's no need carrying this along // $ mediainfo --inform='General;%InternetMediaType%'
switch (common_bare_mime($this->attachment->mimetype)) { if ($this->attachment->mimetype === 'application/ogg') {
case 'text/plain': $mediatype = 'video'; // because this element can handle Ogg/Vorbis etc. on its own
$this->element('div', ['class'=>'e-content plaintext'], }
file_get_contents($this->attachment->getPath()));
break; // Ugly hack to show text/html links which have a thumbnail (such as from oEmbed/OpenGraph image URLs)
case 'text/html': if (!in_array($mediatype, ['image', 'audio', 'video']) && $thumb instanceof File_thumbnail) {
if (!empty($this->attachment->filename) $mediatype = 'image';
&& (GNUsocial::isAjax() || common_config('attachments', 'show_html'))) { }
// Locally-uploaded HTML. Scrub and display inline.
$this->showHtmlFile($this->attachment); 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; break;
}
// Fall through to default if it wasn't a _local_ text/html File object // HTML5 media elements
default: case 'audio':
Event::handle('ShowUnsupportedAttachmentRepresentation', array($this->out, $this->attachment)); 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)); Event::handle('EndShowAttachmentRepresentation', array($this->out, $this->attachment));

View File

@ -63,13 +63,13 @@ class ImageFile extends MediaFile
* interactions (useful for temporary objects) * interactions (useful for temporary objects)
* @param string $filepath The path of the file this media refers to. Required * @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 $filehash The hash of the file, if known. Optional
* * @param string|null $fileurl
* @throws ClientException * @throws ClientException
* @throws NoResultException * @throws NoResultException
* @throws ServerException * @throws ServerException
* @throws UnsupportedMediaException * @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')); $old_limit = ini_set('memory_limit', common_config('attachments', 'memory_limit'));
@ -109,7 +109,8 @@ class ImageFile extends MediaFile
$filepath, $filepath,
$this->mimetype, $this->mimetype,
$filehash, $filehash,
$id $id,
$fileurl
); );
if ($this->type === IMAGETYPE_JPEG) { 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 * @param File $file
* @return ImageFile * @return ImageFile
* @throws ClientException
* @throws FileNotFoundException * @throws FileNotFoundException
* @throws NoResultException
* @throws ServerException
* @throws UnsupportedMediaException * @throws UnsupportedMediaException
* @throws UseFileAsThumbnailException
*/ */
public static function fromFileObject(File $file) public static function fromFileObject(File $file)
{ {
$imgPath = null;
$media = common_get_mime_media($file->mimetype); $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 // And we'll only consider it an image if it has such a media type
switch ($file->mimetype) { if ($media !== 'image') {
case 'image/svg+xml': throw new UnsupportedMediaException(_m('Unsupported media format.'), $file->getPath());
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();
}
} }
if (!file_exists($imgPath)) { $filepath = $file->getPath();
throw new FileNotFoundException($imgPath);
}
try { return new self($file->getID(), $filepath, $file->filehash);
$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;
} }
public function getPath() 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. * and ensures the uploaded file is in fact an image.
* *
* @param string $url Remote image URL * @param string $url Remote image URL
@ -453,11 +413,6 @@ class ImageFile extends MediaFile
return $outpath; return $outpath;
} }
public function unlink()
{
@unlink($this->filepath);
}
public function scaleToFit($maxWidth = null, $maxHeight = null, $crop = null) public function scaleToFit($maxWidth = null, $maxHeight = null, $crop = null)
{ {
return self::getScalingValues( return self::getScalingValues(
@ -587,7 +542,21 @@ class ImageFile extends MediaFile
return $count >= 1; // number of animated frames apart from the original image 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) { if (!$this->fileRecord instanceof File) {
throw new ServerException('No File object attached to this ImageFile object.'); throw new ServerException('No File object attached to this ImageFile object.');
@ -679,9 +648,7 @@ class ImageFile extends MediaFile
return File_thumbnail::saveThumbnail( return File_thumbnail::saveThumbnail(
$this->fileRecord->getID(), $this->fileRecord->getID(),
// no url since we generated it ourselves and can dynamically $this->fileRecord->getUrl(false),
// generate the url
null,
$width, $width,
$height, $height,
$outname $outname

View File

@ -41,9 +41,11 @@ class InlineAttachmentListItem extends AttachmentListItem
// XXX: RDFa // XXX: RDFa
// TODO: add notice_type class e.g., notice_video, notice_image // TODO: add notice_type class e.g., notice_video, notice_image
$this->out->elementStart('li', $this->out->elementStart('li',
array('class' => 'inline-attachment', [
'id' => 'attachment-' . $this->attachment->getID(), 'class' => 'inline-attachment',
)); 'id' => 'attachment-' . $this->attachment->getID(),
]
);
} }
/** /**

View File

@ -48,24 +48,25 @@ class MediaFile
/** /**
* MediaFile constructor. * 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 $mimetype The mimetype of the file. Required
* @param string|null $filehash The hash of the file, if known. Optional * @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. * @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 * If null, it searches for it. If -1, it skips all DB
* interactions (useful for temporary objects) * interactions (useful for temporary objects)
* * @param string|null $fileurl Provide if remote
* @throws ClientException * @throws ClientException
* @throws NoResultException * @throws NoResultException
* @throws ServerException * @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->filepath = $filepath;
$this->filename = basename($this->filepath); $this->filename = basename($this->filepath);
$this->mimetype = $mimetype; $this->mimetype = $mimetype;
$this->filehash = self::getHashOfFile($this->filepath, $filehash); $this->filehash = is_null($filepath) ? null : self::getHashOfFile($this->filepath, $filehash);
$this->id = $id; $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, // 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 // or add redirects
@ -82,16 +83,28 @@ class MediaFile
// Otherwise, store it // Otherwise, store it
$this->fileRecord = $this->storeFile(); $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) public function attachToNotice(Notice $notice)
{ {
File_to_post::processNew($this->fileRecord, $notice); File_to_post::processNew($this->fileRecord, $notice);
@ -102,9 +115,26 @@ class MediaFile
return File::path($this->filename); 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() public function shortUrl()
{ {
return $this->short_fileurl; return common_shorten_url($this->getUrl());
} }
public function getEnclosure() public function getEnclosure()
@ -112,11 +142,27 @@ class MediaFile
return $this->getFile()->getEnclosure(); return $this->getFile()->getEnclosure();
} }
public function delete() public function delete($useWhere=false)
{ {
if (!is_null($this->fileRecord)) {
$this->fileRecord->delete($useWhere);
}
@unlink($this->filepath); @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() public function getFile()
{ {
if (!$this->fileRecord instanceof File) { if (!$this->fileRecord instanceof File) {
@ -164,19 +210,19 @@ class MediaFile
{ {
try { try {
$file = File::getByHash($this->filehash); $file = File::getByHash($this->filehash);
// We're done here. Yes. Already. We assume sha256 won't collide on us anytime soon. if (is_null($this->fileurl) && is_null($file->getUrl(false))) {
return $file; // An already existing local file is being re-added, return it
return $file;
}
} catch (NoResultException $e) { } catch (NoResultException $e) {
// Well, let's just continue below. // Well, let's just continue below.
} }
$fileurl = common_local_url('attachment_view', ['filehash' => $this->filehash]);
$file = new File; $file = new File;
$file->filename = $this->filename; $file->filename = $this->filename;
$file->urlhash = File::hashurl($fileurl); $file->url = $this->fileurl;
$file->url = $fileurl; $file->urlhash = is_null($file->url) ? null : File::hashurl($file->url);
$file->filehash = $this->filehash; $file->filehash = $this->filehash;
$file->size = filesize($this->filepath); $file->size = filesize($this->filepath);
if ($file->size === false) { if ($file->size === false) {
@ -196,7 +242,7 @@ class MediaFile
// Set file geometrical properties if available // Set file geometrical properties if available
try { try {
$image = ImageFile::fromFileObject($file); $image = ImageFile::fromFileObject($file);
$orig = clone $file; $orig = clone($file);
$file->width = $image->width; $file->width = $image->width;
$file->height = $image->height; $file->height = $image->height;
$file->update($orig); $file->update($orig);
@ -205,6 +251,8 @@ class MediaFile
// may have generated a temporary file from a // may have generated a temporary file from a
// video support plugin or something. // video support plugin or something.
// FIXME: Do this more automagically. // 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()) { if ($image->getPath() != $file->getPath()) {
$image->unlink(); $image->unlink();
} }
@ -390,6 +438,16 @@ class MediaFile
try { try {
$file = File::getByHash($filehash); $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. // 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 // but if the _actual_ locally stored file doesn't exist, getPath will throw FileNotFoundException
$filepath = $file->getPath(); $filepath = $file->getPath();
@ -432,10 +490,10 @@ class MediaFile
} }
if ($media == 'image') { 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)); 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'); $tempfile = new TemporaryFile('gs-mediafile');
fwrite($tempfile->getResource(), HTTPClient::quickGet($url)); fwrite($tempfile->getResource(), HTTPClient::quickGet($url));
fflush($tempfile->getResource()); fflush($tempfile->getResource());
@ -471,7 +571,7 @@ class MediaFile
$filehash = strtolower(self::getHashOfFile($tempfile->getRealPath())); $filehash = strtolower(self::getHashOfFile($tempfile->getRealPath()));
try { try {
$file = File::getByHash($filehash); $file = File::getByUrl($url);
/* /*
* If no exception is thrown the file exists locally, so we'll use * If no exception is thrown the file exists locally, so we'll use
* that and just add redirections. * that and just add redirections.
@ -531,10 +631,10 @@ class MediaFile
} }
if ($media === 'image') { 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) public static function fromFileInfo(SplFileInfo $finfo, Profile $scoped = null)
@ -543,6 +643,7 @@ class MediaFile
try { try {
$file = File::getByHash($filehash); $file = File::getByHash($filehash);
$file->fetch();
// Already have it, so let's reuse the locally stored File // Already have it, so let's reuse the locally stored File
// by using getPath we also check whether the file exists // by using getPath we also check whether the file exists
// and throw a FileNotFoundException with the path if it doesn't. // and throw a FileNotFoundException with the path if it doesn't.

View File

@ -223,16 +223,18 @@ class Router
['q' => '.+']); ['q' => '.+']);
$m->connect('search/notice/rss', ['action' => 'noticesearchrss']); $m->connect('search/notice/rss', ['action' => 'noticesearchrss']);
foreach (['' => 'attachment', // Attachment page for file
'/view' => 'attachment_view', $m->connect("attachment/:attachment",
'/download' => 'attachment_download', ['action' => 'attachment'],
['attachment' => '[0-9]+']);
// Retrieve local file
foreach (['/view' => 'attachment_view',
'/download' => 'attachment_download',
'/thumbnail' => 'attachment_thumbnail'] as $postfix => $action) { '/thumbnail' => 'attachment_thumbnail'] as $postfix => $action) {
foreach (['filehash' => '[A-Za-z0-9._-]{64}', $m->connect("attachment/:filehash{$postfix}",
'attachment' => '[0-9]+'] as $type => $match) {
$m->connect("attachment/:{$type}{$postfix}",
['action' => $action], ['action' => $action],
[$type => $match]); ['filehash' => '[A-Za-z0-9._-]{64}']);
}
} }
$m->connect('notice/new?replyto=:replyto&inreplyto=:inreplyto', $m->connect('notice/new?replyto=:replyto&inreplyto=:inreplyto',

View File

@ -189,14 +189,12 @@ class EmbedPlugin extends Plugin
foreach (['xml', 'json'] as $format) { foreach (['xml', 'json'] as $format) {
$action->element( $action->element(
'link', 'link',
['rel' =>'alternate', [
'type' => "application/{$format}+oembed", 'rel' =>'alternate',
'href' => common_local_url( 'type' => "application/{$format}+oembed",
'oembed', 'href' => common_local_url('oembed', [], ['format' => $format, 'url' => $url]),
[], 'title' => 'oEmbed'
['format' => $format, 'url' => $url] ]
),
'title' => 'oEmbed']
); );
} }
} }

View File

@ -93,7 +93,7 @@ class OEmbedAction extends Action
} }
try { try {
$thumb = $attachment->getThumbnail(); $thumb = $attachment->getThumbnail();
$thumb_url = File_thumbnail::url($thumb->filename); $thumb_url = $thumb->getUrl();
$oembed['thumbnail_url'] = $thumb_url; $oembed['thumbnail_url'] = $thumb_url;
break; // only first one break; // only first one
} catch (UseFileAsThumbnailException $e) { } catch (UseFileAsThumbnailException $e) {

View File

@ -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 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(); $self = $this->localProfile();

View File

@ -138,6 +138,7 @@ class StoreRemoteMediaPlugin extends Plugin
try { try {
// Exception will be thrown before $file is set to anything, so old $file value will be kept // Exception will be thrown before $file is set to anything, so old $file value will be kept
$file = File::getByHash($filehash); $file = File::getByHash($filehash);
$file->fetch();
//FIXME: Add some code so we don't have to store duplicate File rows for same hash files. //FIXME: Add some code so we don't have to store duplicate File rows for same hash files.
} catch (NoResultException $e) { } catch (NoResultException $e) {