Add support for image thumbnails
authorAlexander Ebert <ebert@woltlab.com>
Fri, 16 Feb 2024 17:35:34 +0000 (18:35 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Wed, 27 Mar 2024 22:55:27 +0000 (23:55 +0100)
16 files changed:
ts/WoltLabSuite/Core/Component/File/Upload.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js
wcfsetup/install/files/lib/action/FileGenerateThumbnailsAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/action/FileUploadAction.class.php
wcfsetup/install/files/lib/data/attachment/Attachment.class.php
wcfsetup/install/files/lib/data/file/File.class.php
wcfsetup/install/files/lib/data/file/temporary/FileTemporaryAction.class.php
wcfsetup/install/files/lib/data/file/temporary/FileTemporaryList.class.php
wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnail.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailEditor.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/data/file/thumbnail/FileThumbnailList.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php
wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php
wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php
wcfsetup/setup/db/install.sql

index 73c3a438aa15713210dc0a12d644d635f0f86ed1..0d839cf3f0b42a474973411fc72e8c557ba8d01b 100644 (file)
@@ -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) {
index cfe8f6b43bbefb390e4f1a33536e7a69fb5ddd66..6c6784f46d0ae484c1cb9c476f93f0896d92428d 100644 (file)
@@ -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 (file)
index 0000000..a98d287
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+namespace wcf\action;
+
+use Laminas\Diactoros\Response\EmptyResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use wcf\data\file\File;
+use wcf\http\Helper;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\file\processor\FileProcessor;
+
+final class FileGenerateThumbnailsAction implements RequestHandlerInterface
+{
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        $parameters = Helper::mapQueryParameters(
+            $request->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();
+    }
+}
index aa6431a045c682568720da9f4d1c6e94bd06dbc1..d08bbec8f99aa49902d92ecdee7b59c2efc693f2 100644 (file)
@@ -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],
+                    );
                 }
             }
 
index a00eabab289bf6997e9a54c3dc9e46302240c0bf..25643d4688d70468ac3eba206db2f50b210aeec0 100644 (file)
@@ -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
 {
index 4a7fc6e9bb715ad7931f07a6b581d4e0d26f672c..6650af413138e847518faa5f4ecb3957c0d4c246 100644 (file)
@@ -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,
         );
index 404a80ca9320476a3f5cd03c2fec56e433294ac7..8fbb72adb6ad778a6b0a2a2008f810644dcd1cc5 100644 (file)
@@ -8,6 +8,7 @@ use wcf\data\AbstractDatabaseObjectAction;
  * @author Alexander Ebert
  * @copyright 2001-2023 WoltLab GmbH
  * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
  *
  * @method FileTemporary create()
  * @method FileTemporaryEditor[] getObjects()
index 2bd4f42eafce48e6d95891d93012ec80d6a5fab5..fe6c3a835c717742c3ef522e8f7042f81d57b9d6 100644 (file)
@@ -8,6 +8,7 @@ use wcf\data\DatabaseObjectList;
  * @author Alexander Ebert
  * @copyright 2001-2023 WoltLab GmbH
  * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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 (file)
index 0000000..84864a7
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+namespace wcf\data\file\thumbnail;
+
+use wcf\data\DatabaseObject;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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 (file)
index 0000000..f502b2e
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+namespace wcf\data\file\thumbnail;
+
+use wcf\data\AbstractDatabaseObjectAction;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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 (file)
index 0000000..cf9d343
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace wcf\data\file\thumbnail;
+
+use wcf\data\DatabaseObjectEditor;
+use wcf\data\file\File;
+use wcf\system\file\processor\ThumbnailFormat;
+use wcf\util\FileUtil;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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 (file)
index 0000000..05813ea
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace wcf\data\file\thumbnail;
+
+use wcf\data\DatabaseObjectList;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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;
+}
index 88c8c92d36815fb42a16cacad89034da034f10d0..3c24055005aa7dfe9b3a89413162a21c505ea272 100644 (file)
@@ -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,
+        ]);
+    }
 }
index 311092be5bfa6bd25368c1cd8b1a50a6cba65b4b..a3a00e010356b3b04d981bb2758459ca2d456af5 100644 (file)
@@ -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);
+        }
+    }
 }
index 309624991bcc6bc99a86b3e97355a64c583c3ae8..3f624c1ad3f16744f58b9933b5b49d9fa1cf07ef 100644 (file)
@@ -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;
index ccd029f64a8adf4f4ed6865f16e66f98f5b006e2..5fe2e8fbac4b1bd4f0bd3cc164dca05809b6113d 100644 (file)
@@ -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;