From 861732176e193772273c59f8beed35d57c8375b7 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Thu, 22 Jul 2021 20:56:29 +0100 Subject: [PATCH] [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 --- components/Posting/Posting.php | 2 +- src/Controller/Attachment.php | 19 +++--- src/Core/GSFile.php | 102 +++++++++++++++++++++-------- src/Entity/AttachmentThumbnail.php | 69 ++++++++++++------- src/Util/TemporaryFile.php | 23 ++++++- 5 files changed, 151 insertions(+), 64 deletions(-) diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index 3d8489f25c..dbb7198fe2 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -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()), diff --git a/src/Controller/Attachment.php b/src/Controller/Attachment.php index 6f328c0f08..9d4c07830d 100644 --- a/src/Controller/Attachment.php +++ b/src/Controller/Attachment.php @@ -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,32 +78,31 @@ 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)); } /** * Controller to produce a thumbnail for a given attachment id * * @param Request $request - * @param int $id Attachment ID + * @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); } } diff --git a/src/Core/GSFile.php b/src/Core/GSFile.php index 411b961500..c1733bf949 100644 --- a/src/Core/GSFile.php +++ b/src/Core/GSFile.php @@ -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 @@ -53,13 +54,14 @@ class GSFile * Perform file validation (checks and normalization) and store the given file * * @param SplFileInfo $file - * @param string $dest_dir + * @param string $dest_dir * @param null|string $title - * @param bool $is_local - * @param null|int $actor_id + * @param bool $is_local + * @param null|int $actor_id + * + * @throws DuplicateFoundException * * @return Attachment - * @throws DuplicateFoundException */ public static function validateAndStoreFileAsAttachment(SplFileInfo $file, string $dest_dir, @@ -75,18 +77,22 @@ 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, + 'file_hash' => $hash, 'gsactor_id' => $actor_id, - 'mimetype' => $mimetype, - 'title' => $title ?: _m('Untitled attachment'), - 'filename' => $hash, - 'is_local' => $is_local, - 'size' => $file->getSize(), - 'width' => $width, - 'height' => $height, + 'mimetype' => $mimetype, + 'title' => $title, + 'filename' => $hash, + 'is_local' => $is_local, + 'size' => $file->getSize(), + 'width' => $width, + 'height' => $height, ]); $file->move($dest_dir, $hash); DB::persist($attachment); @@ -105,18 +111,18 @@ class GSFile if (Common::isValidHttpUrl($url)) { $head = HTTPClient::head($url); // This must come before getInfo given that Symfony HTTPClient is lazy (thus forcing curl exec) - $headers = $head->getHeaders(); - $url = $head->getInfo('url'); // The last effective url (after getHeaders so it follows redirects) + $headers = $head->getHeaders(); + $url = $head->getInfo('url'); // The last effective url (after getHeaders so it follows redirects) $url_hash = hash(Attachment::URLHASH_ALGO, $url); try { return DB::findOneBy('attachment', ['remote_url_hash' => $url_hash]); } catch (NotFoundException) { - $headers = array_change_key_case($headers, CASE_LOWER); + $headers = array_change_key_case($headers, CASE_LOWER); $attachment = Attachment::create([ - 'remote_url' => $url, + 'remote_url' => $url, 'remote_url_hash' => $url_hash, - 'mimetype' => $headers['content-type'][0], - 'is_local' => false, + 'mimetype' => $headers['content-type'][0], + 'is_local' => false, ]); DB::persist($attachment); Event::handle('AttachmentStoreNew', [&$attachment]); @@ -140,14 +146,14 @@ class GSFile Response::HTTP_OK, [ 'Content-Description' => 'File Transfer', - 'Content-Type' => $mimetype, - 'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $output_filename ?: _m('Untitled attachment'), _m('Untitled attachment')), - 'Cache-Control' => 'public', + 'Content-Type' => $mimetype, + '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(); @@ -205,7 +211,7 @@ class GSFile */ public static function getAttachmentFileInfo(int $id): array { - $res = self::getFileInfo($id); + $res = self::getFileInfo($id); $res['filepath'] = Common::config('attachments', 'dir') . $res['file_hash']; return $res; } @@ -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; + } + } + } + } } diff --git a/src/Entity/AttachmentThumbnail.php b/src/Entity/AttachmentThumbnail.php index bcf2c83090..d593f4e92e 100644 --- a/src/Entity/AttachmentThumbnail.php +++ b/src/Entity/AttachmentThumbnail.php @@ -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; @@ -151,27 +165,37 @@ class AttachmentThumbnail extends Entity try { return Cache::get('thumb-' . $attachment->getId() . "-{$width}x{$height}", function () use ($crop, $attachment, $width, $height, &$predicted_width, &$predicted_height) { - [$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(),$attachment->getHeight(), $width, $height, $crop); + [$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $width, $height, $crop); 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])) { - $thumbnail->setWidth($predicted_width); - $thumbnail->setHeight($predicted_height); - $filename = "{$predicted_width}x{$predicted_height}{$ext}-" . $attachment->getFileHash(); - $temp->move(Common::config('thumbnail', 'dir'), $filename); - $thumbnail->setFilename($filename); - DB::persist($thumbnail); - DB::flush(); - return $thumbnail; + $thumbnail = self::create(['attachment_id' => $attachment->getId()]); + 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(); + $thumbnail->setFilename($filename); + DB::persist($thumbnail); + DB::flush(); + $temp->move(Common::config('thumbnail', 'dir'), $filename); + return $thumbnail; + } else { + Log::debug($m = ('Somehow the EncoderPlugin didn\'t handle ' . $attachment->getMimetype())); + throw new ServerException($m); + } } else { - Log::debug($m = ('Cannot resize attachment with mimetype ' . $attachment->getMimetype())); - throw new ServerException($m); + throw new ClientException(_m('Can not generate thumbnail for attachment with id={id}', ['id' => $attachment->getId()])); } } } @@ -278,10 +302,11 @@ 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'], - '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'], - 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + // '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'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], 'primary key' => ['attachment_id', 'width', 'height'], 'indexes' => [ diff --git a/src/Util/TemporaryFile.php b/src/Util/TemporaryFile.php index 24260641ad..c9bf64e31b 100644 --- a/src/Util/TemporaryFile.php +++ b/src/Util/TemporaryFile.php @@ -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