[Media] Support any kind of thumbnails in the Core

Sanitize Attachments instead of Validate (part 1)
Ensure the intended filetypes and mimetypes during Vips conversions (part 1)
Various bug fixes
This commit is contained in:
Diogo Peralta Cordeiro 2021-07-22 20:56:29 +01:00 committed by Hugo Sales
parent 481e953cde
commit 861732176e
Signed by: someonewithpc
GPG Key ID: 7D0C7EAFC9D835A0
5 changed files with 151 additions and 64 deletions

View File

@ -114,7 +114,7 @@ END;
]);
$processed_attachments = [];
foreach ($attachments as $f) {
foreach ($attachments as $f) { // where $f is a Symfony\Component\HttpFoundation\File\UploadedFile
$processed_attachments[] = GSFile::validateAndStoreFileAsAttachment(
$f, Common::config('attachments', 'dir'),
Security::sanitize($f->getClientOriginalName()),

View File

@ -25,6 +25,7 @@ use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Entity\AttachmentThumbnail;
use App\Util\Common;
@ -34,7 +35,7 @@ use App\Util\Exception\ServerException;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use function App\Core\I18n\_m;
use Symfony\Component\Mime\MimeTypes;
class Attachment extends Controller
{
@ -77,12 +78,12 @@ class Attachment extends Controller
*/
public function attachment_view(Request $request, int $id)
{
return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], $res['title'], HeaderUtils::DISPOSITION_INLINE));
return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], GSFile::titleToFilename($res['title'], $res['mimetype']), HeaderUtils::DISPOSITION_INLINE));
}
public function attachment_download(Request $request, int $id)
{
return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], $res['title'], HeaderUtils::DISPOSITION_ATTACHMENT));
return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], GSFile::titleToFilename($res['title'], $res['mimetype']), HeaderUtils::DISPOSITION_ATTACHMENT));
}
/**
@ -91,18 +92,17 @@ class Attachment extends Controller
* @param Request $request
* @param int $id Attachment ID
*
* @return Response
* @throws ClientException
* @throws NotFoundException
* @throws ServerException
* @throws \App\Util\Exception\DuplicateFoundException
*
* @return Response
*/
public function attachment_thumbnail(Request $request, int $id): Response
{
$attachment = DB::findOneBy('attachment', ['id' => $id]);
if (preg_match('/^(image|video)/', $attachment->getMimeType()) !== 1) {
throw new ClientException(_m('Can not generate thumbnail for attachment with id={id}', ['id' => $id]));
}
if (!is_null($attachment->getScope())) {
// && ($attachment->scope | VisibilityScope::PUBLIC) != 0
// $user = Common::ensureLoggedIn();
@ -125,7 +125,8 @@ class Attachment extends Controller
$filename = $thumbnail->getFilename();
$path = $thumbnail->getPath();
$mimetype = $attachment->getMimetype();
return GSFile::sendFile(filepath: $path, mimetype: $attachment->getMimetype(), output_filename: $filename, disposition: 'inline');
return GSFile::sendFile(filepath: $path, mimetype: $mimetype, output_filename: $filename . '.' . MimeTypes::getDefault()->getExtensions($mimetype)[0], disposition: HeaderUtils::DISPOSITION_INLINE);
}
}

View File

@ -22,6 +22,7 @@
namespace App\Core;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Entity\Attachment;
use App\Util\Common;
use App\Util\Exception\ClientException;
@ -34,7 +35,7 @@ use SplFileInfo;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use function App\Core\I18n\_m;
use Symfony\Component\Mime\MimeTypes;
/**
* GNU social's File Abstraction
@ -58,8 +59,9 @@ class GSFile
* @param bool $is_local
* @param null|int $actor_id
*
* @return Attachment
* @throws DuplicateFoundException
*
* @return Attachment
*/
public static function validateAndStoreFileAsAttachment(SplFileInfo $file,
string $dest_dir,
@ -75,13 +77,17 @@ class GSFile
// The following properly gets the mimetype with `file` or other
// available methods, so should be safe
$mimetype = $file->getMimeType();
$title = $width = $height = null;
Event::handle('AttachmentValidation', [&$file, &$mimetype, &$title, &$width, &$height]);
$width = $height = null;
Event::handle('AttachmentSanitization', [&$file, &$mimetype, &$title, &$width, &$height]);
if ($is_local) {
$filesize = $file->getSize();
Event::handle('EnforceQuota', [$actor_id, $filesize]);
}
$attachment = Attachment::create([
'file_hash' => $hash,
'gsactor_id' => $actor_id,
'mimetype' => $mimetype,
'title' => $title ?: _m('Untitled attachment'),
'title' => $title,
'filename' => $hash,
'is_local' => $is_local,
'size' => $file->getSize(),
@ -141,13 +147,13 @@ class GSFile
[
'Content-Description' => 'File Transfer',
'Content-Type' => $mimetype,
'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $output_filename ?: _m('Untitled attachment'), _m('Untitled attachment')),
'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $output_filename ?? _m('Untitled attachment') . '.' . MimeTypes::getDefault()->getExtensions($mimetype)[0]),
'Cache-Control' => 'public',
],
$public = true,
$disposition = null,
$add_etag = true,
$add_last_modified = true
public: true,
// contentDisposition: $disposition,
autoEtag: true,
autoLastModified: true
);
if (Common::config('site', 'x_static_delivery')) {
$response->trustXSendfileTypeHeader();
@ -239,4 +245,42 @@ class GSFile
}
return trim($mimetype);
}
/**
* Given an attachment title and mimetype allows to generate the most appropriate filename.
*
* @param string $title
* @param string $mimetype
* @param null|string $ext
* @param bool $force
*
* @return null|string
*/
public static function titleToFilename(string $title, string $mimetype, ?string &$ext = null, bool $force = false): string | null
{
$valid_extensions = MimeTypes::getDefault()->getExtensions($mimetype);
// If title seems to be a filename with an extension
if (preg_match('/\.[a-z0-9]/i', $title) === 1) {
$title_without_extension = substr($title, 0, strrpos($title, '.'));
$original_extension = substr($title, strrpos($title, '.') + 1);
if (empty(MimeTypes::getDefault()->getMimeTypes($original_extension)) || !in_array($original_extension, $valid_extensions)) {
unset($title_without_extension, $original_extension);
}
}
if ($force) {
return ($title_without_extension ?? $title) . ".{$ext}";
} else {
if (isset($original_extension)) {
return $title;
} else {
if (!empty($valid_extensions)) {
return "{$title}.{$valid_extensions[0]}";
} else {
return null;
}
}
}
}
}

View File

@ -26,12 +26,14 @@ use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use App\Util\TemporaryFile;
use DateTimeInterface;
use Symfony\Component\Mime\MimeTypes;
/**
* Entity for Attachment thumbnails
@ -53,6 +55,7 @@ class AttachmentThumbnail extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $attachment_id;
private ?string $mimetype;
private int $width;
private int $height;
private string $filename;
@ -69,6 +72,17 @@ class AttachmentThumbnail extends Entity
return $this->attachment_id;
}
public function setMimetype(?string $mimetype): self
{
$this->mimetype = $mimetype;
return $this;
}
public function getMimetype(): ?string
{
return $this->mimetype;
}
public function setWidth(int $width): self
{
$this->width = $width;
@ -155,24 +169,34 @@ class AttachmentThumbnail extends Entity
return DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'width' => $predicted_width, 'height' => $predicted_height]);
});
} catch (NotFoundException $e) {
$ext = image_type_to_extension(IMAGETYPE_WEBP, include_dot: true);
$temp = new TemporaryFile(['prefix' => 'gs-thumbnail', 'suffix' => $ext]);
$thumbnail = self::create(['attachment_id' => $attachment->getId()]);
$event_map = ['image' => 'ResizeImagePath', 'video' => 'ResizeVideoPath'];
$major_mime = GSFile::mimetypeMajor($attachment->getMimetype());
if (in_array($major_mime, array_keys($event_map)) && !Event::handle($event_map[$major_mime], [$attachment->getPath(), $temp->getRealPath(), &$width, &$height, $crop, &$mimetype])) {
Event::handle('ResizerAvailable', [&$event_map]);
$mimetype = $attachment->getMimetype();
$major_mime = GSFile::mimetypeMajor($mimetype);
if (in_array($major_mime, array_keys($event_map))) {
$temp = null; // Let the EncoderPlugin create a temporary file for us
if (!Event::handle(
$event_map[$major_mime],
[$attachment->getPath(), &$temp, &$width, &$height, $crop, &$mimetype]
)
) {
$thumbnail->setWidth($predicted_width);
$thumbnail->setHeight($predicted_height);
$ext = '.' . MimeTypes::getDefault()->getExtensions($temp->getMimeType())[0];
$filename = "{$predicted_width}x{$predicted_height}{$ext}-" . $attachment->getFileHash();
$temp->move(Common::config('thumbnail', 'dir'), $filename);
$thumbnail->setFilename($filename);
DB::persist($thumbnail);
DB::flush();
$temp->move(Common::config('thumbnail', 'dir'), $filename);
return $thumbnail;
} else {
Log::debug($m = ('Cannot resize attachment with mimetype ' . $attachment->getMimetype()));
Log::debug($m = ('Somehow the EncoderPlugin didn\'t handle ' . $attachment->getMimetype()));
throw new ServerException($m);
}
} else {
throw new ClientException(_m('Can not generate thumbnail for attachment with id={id}', ['id' => $attachment->getId()]));
}
}
}
@ -278,6 +302,7 @@ class AttachmentThumbnail extends Entity
'name' => 'attachment_thumbnail',
'fields' => [
'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'thumbnail for what attachment'],
// 'mimetype' => ['type' => 'varchar', 'length' => 50, 'description' => 'mime type of resource'],
'width' => ['type' => 'int', 'not null' => true, 'description' => 'width of thumbnail'],
'height' => ['type' => 'int', 'not null' => true, 'description' => 'height of thumbnail'],
'filename' => ['type' => 'varchar', 'length' => 191, 'not null' => true, 'description' => 'thumbnail filename'],

View File

@ -41,9 +41,9 @@ class TemporaryFile extends \SplFileInfo
* @param array $options - ['prefix' => ?string, 'suffix' => ?string, 'mode' => ?string, 'directory' => ?string]
* Description of options:
* > prefix: The file name will begin with that prefix, default is 'gs-php'
* > suffix: The file name will begin with that prefix, default is ''
* > mode: The file name will begin with that prefix, default is 'w+b'
* > directory: The file name will begin with that prefix, default is the system's temporary
* > suffix: The file name will end with that suffix, default is ''
* > mode: Operation mode, default is 'w+b'
* > directory: Directory where the file will be used, default is the system's temporary
*
* @throws TemporaryFileException
*/
@ -157,6 +157,23 @@ class TemporaryFile extends \SplFileInfo
$destpath = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $this->getName($filename);
$this->commit($destpath, $dirmode, $filemode);
}
/**
* Release the hold on the temporary file and move it to the desired
* location, setting file permissions in the process.
*
* @param string $destpath Full path of destination file
* @param int $dirmode New directory permissions (in octal mode)
* @param int $filemode New file permissions (in octal mode)
*
* @throws TemporaryFileException
*
* @return void
*/
public function commit(string $destpath, int $dirmode = 0755, int $filemode = 0644): void
{
$temppath = $this->getRealPath();
// Might be attempted, and won't end well