From 844816716cc7954deb0539d367b58f02ad4bd385 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 16 Feb 2024 18:35:34 +0100 Subject: [PATCH] Add support for image thumbnails --- ts/WoltLabSuite/Core/Component/File/Upload.ts | 3 +- .../Core/Component/File/Upload.js | 14 ++++- .../FileGenerateThumbnailsAction.class.php | 36 +++++++++++ .../lib/action/FileUploadAction.class.php | 6 +- .../lib/data/attachment/Attachment.class.php | 2 + .../files/lib/data/file/File.class.php | 2 +- .../temporary/FileTemporaryAction.class.php | 1 + .../temporary/FileTemporaryList.class.php | 1 + .../file/thumbnail/FileThumbnail.class.php | 42 +++++++++++++ .../thumbnail/FileThumbnailAction.class.php | 20 ++++++ .../thumbnail/FileThumbnailEditor.class.php | 63 +++++++++++++++++++ .../thumbnail/FileThumbnailList.class.php | 22 +++++++ .../AttachmentFileProcessor.class.php | 36 ++++++++--- .../file/processor/FileProcessor.class.php | 51 +++++++++++++++ .../file/processor/IFileProcessor.class.php | 3 + wcfsetup/setup/db/install.sql | 15 +++++ 16 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php create mode 100644 wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index 73c3a438aa..0d839cf3f0 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -77,7 +77,8 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis element.dispatchEvent(event); if (response.endpointThumbnails !== "") { - // TODO: Dispatch the request to generate thumbnails. + void (await prepareRequest(response.endpointThumbnails).get().fetchAsResponse()); + // TODO: Handle errors and notify about the new thumbnails. } } } catch (e) { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js index cfe8f6b43b..6c6784f46d 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -44,7 +44,19 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co try { const response = (await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsJson()); if (response.completed) { - console.log(response); + const event = new CustomEvent("uploadCompleted", { + detail: { + data: response.data, + endpointThumbnails: response.endpointThumbnails, + fileID: response.fileID, + typeName: response.typeName, + }, + }); + element.dispatchEvent(event); + if (response.endpointThumbnails !== "") { + void (await (0, Backend_1.prepareRequest)(response.endpointThumbnails).get().fetchAsResponse()); + // TODO: Handle errors and notify about the new thumbnails. + } } } catch (e) { diff --git a/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php b/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php new file mode 100644 index 0000000000..a98d2875a9 --- /dev/null +++ b/wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php @@ -0,0 +1,36 @@ +getQueryParams(), + <<<'EOT' + array { + id: positive-int, + } + EOT, + ); + + $file = new File($parameters['id']); + if (!$file->fileID) { + throw new IllegalLinkException(); + } + + FileProcessor::getInstance()->generateThumbnails($file); + + return new EmptyResponse(); + } +} diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php index aa6431a045..d08bbec8f9 100644 --- a/wcfsetup/install/files/lib/action/FileUploadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileUploadAction.class.php @@ -13,6 +13,7 @@ use wcf\data\file\temporary\FileTemporaryEditor; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; use wcf\system\io\File as IoFile; +use wcf\system\request\LinkHandler; final class FileUploadAction implements RequestHandlerInterface { @@ -134,7 +135,10 @@ final class FileUploadAction implements RequestHandlerInterface $thumbnailFormats = $processor->getThumbnailFormats(); if ($thumbnailFormats !== []) { // TODO: Endpoint to generate thumbnails. - $endpointThumbnails = ''; + $endpointThumbnails = LinkHandler::getInstance()->getControllerLink( + FileGenerateThumbnailsAction::class, + ['id' => $file->fileID], + ); } } diff --git a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php index a00eabab28..25643d4688 100644 --- a/wcfsetup/install/files/lib/data/attachment/Attachment.class.php +++ b/wcfsetup/install/files/lib/data/attachment/Attachment.class.php @@ -44,6 +44,8 @@ use wcf\util\FileUtil; * @property-read int $uploadTime timestamp at which the attachment has been uploaded * @property-read int $showOrder position of the attachment in relation to the other attachment to the same message * @property-read int|null $fileID + * @property-read int|null $thumbnailID + * @property-read int|null $tinyThumbnailID */ class Attachment extends DatabaseObject implements ILinkableObject, IRouteController, IThumbnailFile { diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 4a7fc6e9bb..6650af4131 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -29,7 +29,7 @@ class File extends DatabaseObject $folderB = \substr($this->fileHash, 2, 2); return \sprintf( - \WCF_DIR . '_data/public/fileUpload/%s/%s/', + \WCF_DIR . '_data/private/fileUpload/%s/%s/', $folderA, $folderB, ); diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php index 404a80ca93..8fbb72adb6 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php @@ -8,6 +8,7 @@ use wcf\data\AbstractDatabaseObjectAction; * @author Alexander Ebert * @copyright 2001-2023 WoltLab GmbH * @license GNU Lesser General Public License + * @since 6.1 * * @method FileTemporary create() * @method FileTemporaryEditor[] getObjects() diff --git a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php index 2bd4f42eaf..fe6c3a835c 100644 --- a/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php +++ b/wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php @@ -8,6 +8,7 @@ use wcf\data\DatabaseObjectList; * @author Alexander Ebert * @copyright 2001-2023 WoltLab GmbH * @license GNU Lesser General Public License + * @since 6.1 * * @method FileTemporary current() * @method FileTemporary[] getObjects() diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php new file mode 100644 index 0000000000..84864a7984 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php @@ -0,0 +1,42 @@ + + * @since 6.1 + * + * @property-read int $thumbnailID + * @property-read int $fileID + * @property-read string $identifier + * @property-read string $fileHash + * @property-read string $fileExtension + */ +class FileThumbnail extends DatabaseObject +{ + public function getPath(): string + { + $folderA = \substr($this->fileHash, 0, 2); + $folderB = \substr($this->fileHash, 2, 2); + + return \sprintf( + \WCF_DIR . '_data/public/thumbnail/%s/%s/', + $folderA, + $folderB, + ); + } + + public function getSourceFilename(): string + { + return \sprintf( + '%d-%s.%s', + $this->thumbnailID, + $this->fileHash, + $this->fileExtension, + ); + } +} diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php new file mode 100644 index 0000000000..f502b2e746 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php @@ -0,0 +1,20 @@ + + * @since 6.1 + * + * @method FileThumbnail create() + * @method FileThumbnailEditor[] getObjects() + * @method FileThumbnailEditor getSingleObject() + */ +class FileThumbnailAction extends AbstractDatabaseObjectAction +{ + protected $className = FileThumbnailEditor::class; +} diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php new file mode 100644 index 0000000000..cf9d3431a7 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php @@ -0,0 +1,63 @@ + + * @since 6.1 + * + * @method static FileThumbnail create(array $parameters = []) + * @method FileThumbnail getDecoratedObject() + * @mixin FileThumbnail + */ +class FileThumbnailEditor extends DatabaseObjectEditor +{ + /** + * @inheritDoc + */ + protected static $baseClass = FileThumbnail::class; + + public static function createFromTemporaryFile( + File $file, + ThumbnailFormat $format, + string $filename + ): FileThumbnail { + $mimeType = FileUtil::getMimeType($filename); + $fileExtension = match ($mimeType) { + 'image/gif' => 'gif', + 'image/jpg', 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + }; + + $action = new FileThumbnailAction([], 'create', [ + 'data' => [ + 'fileID' => $file->fileID, + 'identifier' => $format->identifier, + 'fileHash' => hash_file('sha256', $filename), + 'fileExtension' => $fileExtension, + ], + ]); + $fileThumbnail = $action->executeAction()['returnValues']; + \assert($fileThumbnail instanceof FileThumbnail); + + $filePath = $fileThumbnail->getPath(); + if (!\is_dir($filePath)) { + \mkdir($filePath, recursive: true); + } + + \rename( + $filename, + $filePath . $fileThumbnail->getSourceFilename() + ); + + return $fileThumbnail; + } +} diff --git a/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php new file mode 100644 index 0000000000..05813ea353 --- /dev/null +++ b/wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php @@ -0,0 +1,22 @@ + + * @since 6.1 + * + * @method FileThumbnail current() + * @method FileThumbnail[] getObjects() + * @method FileThumbnail|null getSingleObject() + * @method FileThumbnail|null search($objectID) + * @property FileThumbnail[] $objects + */ +class FileThumbnailList extends DatabaseObjectList +{ + public $className = FileThumbnail::class; +} diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 88c8c92d36..3c24055005 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -5,7 +5,9 @@ namespace wcf\system\file\processor; use wcf\data\attachment\Attachment; use wcf\data\attachment\AttachmentEditor; use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; use wcf\system\attachment\AttachmentHandler; +use wcf\system\exception\NotImplementedException; /** * @author Alexander Ebert @@ -119,7 +121,6 @@ final class AttachmentFileProcessor implements IFileProcessor ]; } - #[\Override] public function toHtmlElement(string $objectType, int $objectID, string $tmpHash, int $parentObjectID): string { return FileProcessor::getInstance()->getHtmlElement( @@ -137,18 +138,39 @@ final class AttachmentFileProcessor implements IFileProcessor public function getThumbnailFormats(): array { return [ + new ThumbnailFormat( + '', + \ATTACHMENT_THUMBNAIL_HEIGHT, + \ATTACHMENT_THUMBNAIL_WIDTH, + !!\ATTACHMENT_RETAIN_DIMENSIONS, + ), new ThumbnailFormat( 'tiny', 144, 144, false, ), - new ThumbnailFormat( - 'default', - \ATTACHMENT_THUMBNAIL_HEIGHT, - \ATTACHMENT_THUMBNAIL_WIDTH, - !!\ATTACHMENT_RETAIN_DIMENSIONS, - ), ]; } + + #[\Override] + public function adoptThumbnail(FileThumbnail $thumbnail): void + { + $attachment = Attachment::findByFileID($thumbnail->fileID); + if ($attachment === null) { + // TODO: How to handle this case? + return; + } + + $columnName = match ($thumbnail->identifier) { + '' => 'thumbnailID', + 'tiny'=>'tinyThumbnailID', + 'default'=>throw new \RuntimeException('TODO'), // TODO + }; + + $attachmentEditor = new AttachmentEditor($attachment); + $attachmentEditor->update([ + $columnName => $thumbnail->thumbnailID, + ]); + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php index 311092be5b..a3a00e0103 100644 --- a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php @@ -3,10 +3,17 @@ namespace wcf\system\file\processor; use wcf\action\FileUploadPreflightAction; +use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; +use wcf\data\file\thumbnail\FileThumbnailEditor; +use wcf\data\file\thumbnail\FileThumbnailList; use wcf\system\event\EventHandler; use wcf\system\file\processor\event\FileProcessorCollecting; +use wcf\system\image\adapter\ImageAdapter; +use wcf\system\image\ImageHandler; use wcf\system\request\LinkHandler; use wcf\system\SingletonFactory; +use wcf\util\FileUtil; use wcf\util\JSON; use wcf\util\StringUtil; @@ -68,4 +75,48 @@ final class FileProcessor extends SingletonFactory StringUtil::encodeHTML($allowedFileExtensions), ); } + + public function generateThumbnails(File $file): void + { + $processor = $file->getProcessor(); + if ($processor === null) { + return; + } + + $formats = $processor->getThumbnailFormats(); + if ($formats === []) { + return; + } + + $thumbnailList = new FileThumbnailList(); + $thumbnailList->getConditionBuilder()->add("fileID = ?", [$file->fileID]); + $thumbnailList->readObjects(); + + $existingThumbnails = []; + foreach ($thumbnailList as $thumbnail) { + \assert($thumbnail instanceof FileThumbnail); + $existingThumbnails[$thumbnail->identifier] = $thumbnail; + } + + $imageAdapter = null; + foreach ($formats as $format) { + if (isset($existingThumbnails[$format->identifier])) { + continue; + } + + if ($imageAdapter === null) { + $imageAdapter = ImageHandler::getInstance()->getAdapter(); + $imageAdapter->loadFile($file->getPath() . $file->getSourceFilename()); + } + + assert($imageAdapter instanceof ImageAdapter); + $image = $imageAdapter->createThumbnail($format->width, $format->height, $format->retainDimensions); + + $filename = FileUtil::getTemporaryFilename(); + $imageAdapter->writeImage($image, $filename); + + $fileThumbnail = FileThumbnailEditor::createFromTemporaryFile($file, $format, $filename); + $processor->adoptThumbnail($fileThumbnail); + } + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 309624991b..3f624c1ad3 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -3,6 +3,7 @@ namespace wcf\system\file\processor; use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; /** * @author Alexander Ebert @@ -16,6 +17,8 @@ interface IFileProcessor public function adopt(File $file, array $context): void; + public function adoptThumbnail(FileThumbnail $thumbnail): void; + public function canDownload(File $file): bool; public function getAllowedFileExtensions(array $context): array; diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index ccd029f64a..5fe2e8fbac 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -225,6 +225,8 @@ CREATE TABLE wcf1_attachment ( showOrder SMALLINT(5) NOT NULL DEFAULT 0, fileID INT, + thumbnailID INT, + tinyThumbnailID INT, KEY (objectTypeID, objectID), KEY (objectTypeID, tmpHash), @@ -617,6 +619,15 @@ CREATE TABLE wcf1_file_temporary ( chunks VARBINARY(255) NOT NULL ); +DROP TABLE IF EXISTS wcf1_file_thumbnail; +CREATE TABLE wcf1_file_thumbnail ( + thumbnailID INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + fileID INT NOT NULL, + identifier VARCHAR(50) NOT NULL, + fileHash CHAR(64) NOT NULL, + fileExtension VARCHAR(10) NOT NULL +); + /* As the flood control table can be a high traffic table and as it is periodically emptied, there is no foreign key on the `objectTypeID` to speed up insertions. */ DROP TABLE IF EXISTS wcf1_flood_control; @@ -2005,6 +2016,8 @@ ALTER TABLE wcf1_article_content ADD FOREIGN KEY (teaserImageID) REFERENCES wcf1 ALTER TABLE wcf1_attachment ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE; ALTER TABLE wcf1_attachment ADD FOREIGN KEY (userID) REFERENCES wcf1_user (userID) ON DELETE SET NULL; ALTER TABLE wcf1_attachment ADD FOREIGN KEY (fileID) REFERENCES wcf1_file (fileID) ON DELETE SET NULL; +ALTER TABLE wcf1_attachment ADD FOREIGN KEY (thumbnailID) REFERENCES wcf1_file_thumbnail (thumbnailID) ON DELETE SET NULL; +ALTER TABLE wcf1_attachment ADD FOREIGN KEY (tinyThumbnailID) REFERENCES wcf1_file_thumbnail (thumbnailID) ON DELETE SET NULL; ALTER TABLE wcf1_bbcode ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE; @@ -2055,6 +2068,8 @@ ALTER TABLE wcf1_email_log_entry ADD FOREIGN KEY (recipientID) REFERENCES wcf1_u ALTER TABLE wcf1_event_listener ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE; +ALTER TABLE wcf1_file_thumbnail ADD FOREIGN KEY (fileID) REFERENCES wcf1_file (fileID) ON DELETE CASCADE; + ALTER TABLE wcf1_language_item ADD FOREIGN KEY (languageID) REFERENCES wcf1_language (languageID) ON DELETE CASCADE; ALTER TABLE wcf1_language_item ADD FOREIGN KEY (languageCategoryID) REFERENCES wcf1_language_category (languageCategoryID) ON DELETE CASCADE; ALTER TABLE wcf1_language_item ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE; -- 2.20.1