[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

@ -20,10 +20,10 @@ defined('GNUSOCIAL') || die();
* Show notice attachments
*
* @category Personal
* @package StatusNet
* @package GNUsocial
* @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
* @link http://status.net/
* @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->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->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);
}
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);
}

View File

@ -1,6 +1,20 @@
<?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
@ -8,8 +22,8 @@ if (!defined('GNUSOCIAL')) { exit(1); }
* @category Personal
* @package GNUsocial
* @author Mikael Nordfeldth <mmn@hethane.se>
* @license https://www.gnu.org/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
* @link https:/gnu.io/social
* @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);
}
}
}

View File

@ -20,12 +20,12 @@ defined('GNUSOCIAL') || die();
* Show notice attachments
*
* @category Personal
* @package StatusNet
* @package GNUsocial
* @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
* @link http://status.net/
* @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');
}
}

View File

@ -21,7 +21,8 @@ defined('GNUSOCIAL') || die();
*
* @package GNUsocial
* @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
{
@ -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);
}
}
}

View File

@ -1,47 +1,43 @@
<?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
*
* 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
* @package StatusNet
* @package GNUsocial
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @author Sarven Capadisli <csarven@status.net>
* @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
* @package GNUsocial
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@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
* @link http://status.net/
* @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.
@ -71,7 +67,7 @@ class NewnoticeAction extends FormAction
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.',
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()));
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'),
$locOptions = Notice::locationOptions(
$this->trimmed('lat'),
$this->trimmed('lon'),
$this->trimmed('location_id'),
$this->trimmed('location_ns'),
$this->scoped);
$this->scoped
);
} else {
$locOptions = Notice::locationOptions(null,
$locOptions = Notice::locationOptions(
null,
null,
null,
$this->scoped);
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);
}
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();

View File

@ -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)

View File

@ -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 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]);

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
*
@ -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));
$url = common_local_url('attachment_thumbnail', ['filehash' => $this->getFile()->filehash]);
if (strpos($url, '?') === false) {
$url .= '?';
}
return $url . http_build_query(array('w'=>$this->width, 'h'=>$this->height));
}
// 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);
}

View File

@ -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)];
}
}

View File

@ -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;
}

View File

@ -91,10 +91,11 @@ class AttachmentListItem extends Widget
}
function linkAttr() {
return array(
return [
'class' => 'u-url',
'href' => $this->attachment->getAttachmentUrl(),
'title' => $this->linkTitle());
'href' => $this->attachment->getAttachmentDownloadUrl(),
'title' => $this->linkTitle()
];
}
function showNoticeAttachment()
@ -105,25 +106,29 @@ 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');
try {
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);
$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;
}
// Then get the kind of mediatype we're dealing with
@ -201,6 +206,11 @@ class AttachmentListItem extends Widget
} else {
Event::handle('ShowUnsupportedAttachmentRepresentation', array($this->out, $this->attachment));
}
} catch (FileNotFoundException $e) {
if (!$this->attachment->isLocal()) {
throw $e;
}
}
}
Event::handle('EndShowAttachmentRepresentation', array($this->out, $this->attachment));
}

View File

@ -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();
}
}
$filepath = $file->getPath();
if (!file_exists($imgPath)) {
throw new FileNotFoundException($imgPath);
}
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

View File

@ -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',
[
'class' => 'inline-attachment',
'id' => 'attachment-' . $this->attachment->getID(),
));
]
);
}
/**

View File

@ -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->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.
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.

View File

@ -223,16 +223,18 @@ class Router
['q' => '.+']);
$m->connect('search/notice/rss', ['action' => 'noticesearchrss']);
foreach (['' => 'attachment',
'/view' => 'attachment_view',
// 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',

View File

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

View File

@ -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) {

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 = ImageFile::fromURL($url);
$imagefile = ImageFile::fromUrl($url);
$self = $this->localProfile();

View File

@ -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) {