Add basic support for file processors
authorAlexander Ebert <ebert@woltlab.com>
Fri, 26 Jan 2024 17:00:25 +0000 (18:00 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Sat, 20 Apr 2024 14:30:13 +0000 (16:30 +0200)
14 files changed:
com.woltlab.wcf/templates/shared_messageFormAttachments.tpl
ts/WoltLabSuite/Core/Component/File/Upload.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js
wcfsetup/install/files/lib/action/FileUploadPreflightAction.class.php
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/data/file/File.class.php
wcfsetup/install/files/lib/data/file/FileEditor.class.php
wcfsetup/install/files/lib/data/file/temporary/FileTemporary.class.php
wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/file/processor/event/FileProcessorCollecting.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/file/processor/exception/DuplicateFileProcessor.class.php [new file with mode: 0644]
wcfsetup/setup/db/install.sql

index 8b190553d770168c5cd08c30f246a520b8e74d2d..6383581ffec6045a51388ea31b95fdd2ccda7598 100644 (file)
@@ -2,6 +2,9 @@
        <button type="button">
                <woltlab-core-file-upload
                        data-endpoint="{link controller='FileUploadPreflight'}{/link}"
+                       data-type-name="com.woltlab.wcf.attachment"
+                       data-context-object-type=""
+                       data-context-object-id=""
                ></woltlab-core-file-upload>
        </button>
        <dl class="wide">
index b5554413191417a1e46116dbf2a2a41929e31b87..d0d0875f1ff6961df59cf42805519bce24c4dec9 100644 (file)
@@ -1,11 +1,15 @@
 import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
 import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
+import { ucfirst } from "WoltLabSuite/Core/StringUtil";
 
 type PreflightResponse = {
   endpoints: string[];
 };
 
 async function upload(element: WoltlabCoreFileUploadElement, file: File): Promise<void> {
+  const typeName = element.dataset.typeName!;
+  const context = getContextFromDataAttributes(element);
+
   const fileHash = await getSha256Hash(await file.arrayBuffer());
 
   const response = (await prepareRequest(element.dataset.endpoint!)
@@ -13,6 +17,8 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis
       filename: file.name,
       fileSize: file.size,
       fileHash,
+      typeName,
+      context,
     })
     .fetchAsJson()) as PreflightResponse;
   const { endpoints } = response;
@@ -36,6 +42,33 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis
   }
 }
 
+function getContextFromDataAttributes(element: WoltlabCoreFileUploadElement): Record<string, string> {
+  const context = {};
+  const prefixContext = "data-context-";
+
+  for (const attribute of element.attributes) {
+    if (!attribute.name.startsWith(prefixContext)) {
+      continue;
+    }
+
+    const key = attribute.name
+      .substring(prefixContext.length)
+      .split("-")
+      .map((part, index) => {
+        if (index === 0) {
+          return part;
+        }
+
+        return ucfirst(part);
+      })
+      .join("");
+
+    context[key] = attribute.value;
+  }
+
+  return context;
+}
+
 async function getSha256Hash(data: BufferSource): Promise<string> {
   const buffer = await window.crypto.subtle.digest("SHA-256", data);
 
index 09fc21c9f421a6c50cd8c3b3b95a7ecb89e90246..289fe0ad5d9dda053cc541f8f9a841dedb531ab0 100644 (file)
@@ -1,14 +1,18 @@
-define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Backend_1, Selector_1) {
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/StringUtil"], function (require, exports, Backend_1, Selector_1, StringUtil_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.setup = void 0;
     async function upload(element, file) {
+        const typeName = element.dataset.typeName;
+        const context = getContextFromDataAttributes(element);
         const fileHash = await getSha256Hash(await file.arrayBuffer());
         const response = (await (0, Backend_1.prepareRequest)(element.dataset.endpoint)
             .post({
             filename: file.name,
             fileSize: file.size,
             fileHash,
+            typeName,
+            context,
         })
             .fetchAsJson());
         const { endpoints } = response;
@@ -26,6 +30,27 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co
             }
         }
     }
+    function getContextFromDataAttributes(element) {
+        const context = {};
+        const prefixContext = "data-context-";
+        for (const attribute of element.attributes) {
+            if (!attribute.name.startsWith(prefixContext)) {
+                continue;
+            }
+            const key = attribute.name
+                .substring(prefixContext.length)
+                .split("-")
+                .map((part, index) => {
+                if (index === 0) {
+                    return part;
+                }
+                return (0, StringUtil_1.ucfirst)(part);
+            })
+                .join("");
+            context[key] = attribute.value;
+        }
+        return context;
+    }
     async function getSha256Hash(data) {
         const buffer = await window.crypto.subtle.digest("SHA-256", data);
         return Array.from(new Uint8Array(buffer))
index b9b172530bb086cff5cc64b273f1e87146bb98b2..c55675960a6077c9138af13ac42a56ad6d25b1ee 100644 (file)
@@ -4,13 +4,18 @@ namespace wcf\action;
 
 use Laminas\Diactoros\Response\EmptyResponse;
 use Laminas\Diactoros\Response\JsonResponse;
+use Masterminds\HTML5\Parser\EventHandler;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\RequestHandlerInterface;
 use wcf\data\file\temporary\FileTemporary;
 use wcf\data\file\temporary\FileTemporaryAction;
 use wcf\http\Helper;
+use wcf\system\event\EventHandler as EventEventHandler;
+use wcf\system\file\processor\event\FileProcessorCollecting;
+use wcf\system\file\processor\FileProcessor;
 use wcf\system\request\LinkHandler;
+use wcf\util\JSON;
 
 final class FileUploadPreflightAction implements RequestHandlerInterface
 {
@@ -24,10 +29,25 @@ final class FileUploadPreflightAction implements RequestHandlerInterface
                         filename: non-empty-string,
                         fileSize: positive-int,
                         fileHash: non-empty-string,
+                        typeName: non-empty-string,
+                        context: array<non-empty-string, string>,
                     }
                     EOT,
         );
 
+        $fileProcessor = FileProcessor::getInstance()->forTypeName($parameters['typeName']);
+        if ($fileProcessor === null) {
+            // 400 Bad Request
+            return new JsonResponse([
+                'typeName' => 'unknown',
+            ], 400);
+        }
+
+        if (!$fileProcessor->acceptUpload($parameters['filename'], $parameters['fileSize'], $parameters['context'])) {
+            // 403 Permission Denied
+            return new EmptyResponse(403);
+        }
+
         $numberOfChunks = FileTemporary::getNumberOfChunks($parameters['fileSize']);
         if ($numberOfChunks > FileTemporary::MAX_CHUNK_COUNT) {
             // 413 Content Too Large
@@ -63,6 +83,8 @@ final class FileUploadPreflightAction implements RequestHandlerInterface
                 'filename' => $parameters['filename'],
                 'fileSize' => $parameters['fileSize'],
                 'fileHash' => $parameters['fileHash'],
+                'typeName' => $parameters['typeName'],
+                'context' => JSON::encode($parameters['context']),
                 'chunks' => \str_repeat('0', $numberOfChunks),
             ],
         ]);
index 1711b94975568bc5a64045e85b97574e4bd9f7c5..64c2c69e3df13464c87383a6ec54c809810c518b 100644 (file)
@@ -10,6 +10,7 @@ use wcf\system\event\listener\PipSyncedPhrasePreloadListener;
 use wcf\system\event\listener\PreloadPhrasesCollectingListener;
 use wcf\system\event\listener\UserLoginCancelLostPasswordListener;
 use wcf\system\event\listener\UsernameValidatingCheckCharactersListener;
+use wcf\system\file\processor\event\FileProcessorCollecting;
 use wcf\system\language\event\LanguageImported;
 use wcf\system\language\event\PhraseChanged;
 use wcf\system\language\LanguageFactory;
@@ -89,6 +90,10 @@ return static function (): void {
         $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession);
     });
 
+    $eventHandler->register(FileProcessorCollecting::class, static function (FileProcessorCollecting $event) {
+        $event->register(new \wcf\system\file\processor\AttachmentFileProcessor());
+    });
+
     try {
         $licenseApi = new LicenseApi();
         $licenseData = $licenseApi->readFromFile();
index 993d6b0b596f01d4979a6ade47d646986549f5f9..c2b99ffe1bd802300ed20e8f8c47df0d7af870f0 100644 (file)
@@ -14,6 +14,7 @@ use wcf\data\DatabaseObject;
  * @property-read string $filename
  * @property-read int $fileSize
  * @property-read string $fileHash
+ * @property-read string $typeName
  */
 class File extends DatabaseObject
 {
index 780a3040777cb84dde36ca79b96ea87bf619f7d4..a70d78b8c6f26cecb075ebb9e19b2808939c69a5 100644 (file)
@@ -28,6 +28,7 @@ class FileEditor extends DatabaseObjectEditor
             'filename' => $fileTemporary->filename,
             'fileSize' => $fileTemporary->fileSize,
             'fileHash' => $fileTemporary->fileHash,
+            'typeName' => $fileTemporary->typeName,
         ]]);
         $file = $fileAction->executeAction()['returnValues'];
         \assert($file instanceof File);
index 09000f5afe823dc3e39320ce2edbb25f52dcc798..061dfe5bf75ab052e0a94e8b0f3466cdf57ecf43 100644 (file)
@@ -3,6 +3,7 @@
 namespace wcf\data\file\temporary;
 
 use wcf\data\DatabaseObject;
+use wcf\util\JSON;
 
 /**
  * @author Alexander Ebert
@@ -15,6 +16,8 @@ use wcf\data\DatabaseObject;
  * @property-read string $filename
  * @property-read int $fileSize
  * @property-read string $fileHash
+ * @property-read string $typeName
+ * @property-read string $context
  * @property-read string $chunks
  */
 class FileTemporary extends DatabaseObject
@@ -57,6 +60,11 @@ class FileTemporary extends DatabaseObject
         );
     }
 
+    public function getContext(): array
+    {
+        return JSON::decode($this->context);
+    }
+
     public static function getNumberOfChunks(int $fileSize): int
     {
         return \ceil($fileSize / self::getOptimalChunkSize());
diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php
new file mode 100644 (file)
index 0000000..2f58b02
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace wcf\system\file\processor;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+final class AttachmentFileProcessor implements IFileProcessor
+{
+    public function getTypeName(): string
+    {
+        return 'com.woltlab.wcf.attachment';
+    }
+
+    public function acceptUpload(string $filename, int $fileSize, array $context): bool
+    {
+        return true;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/FileProcessor.class.php
new file mode 100644 (file)
index 0000000..31e7f32
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace wcf\system\file\processor;
+
+use wcf\system\event\EventHandler;
+use wcf\system\file\processor\event\FileProcessorCollecting;
+use wcf\system\SingletonFactory;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+final class FileProcessor extends SingletonFactory
+{
+    /**
+     * @var array<string, IFileProcessor>
+     */
+    private array $processors;
+
+    #[\Override]
+    public function init(): void
+    {
+        $event = new FileProcessorCollecting();
+        EventHandler::getInstance()->fire($event);
+        $this->processors = $event->getProcessors();
+    }
+
+    public function forTypeName(string $typeName): ?IFileProcessor
+    {
+        return $this->processors[$typeName] ?? null;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php
new file mode 100644 (file)
index 0000000..1b840c4
--- /dev/null
@@ -0,0 +1,16 @@
+<?php
+
+namespace wcf\system\file\processor;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+interface IFileProcessor
+{
+    public function getTypeName(): string;
+
+    public function acceptUpload(string $filename, int $fileSize, array $context): bool;
+}
diff --git a/wcfsetup/install/files/lib/system/file/processor/event/FileProcessorCollecting.class.php b/wcfsetup/install/files/lib/system/file/processor/event/FileProcessorCollecting.class.php
new file mode 100644 (file)
index 0000000..33b2c6b
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+namespace wcf\system\file\processor\event;
+
+use wcf\system\event\IEvent;
+use wcf\system\file\processor\exception\DuplicateFileProcessor;
+use wcf\system\file\processor\IFileProcessor;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+final class FileProcessorCollecting implements IEvent
+{
+    /**
+     * @var array<string, IFileProcessor>
+     */
+    private array $data = [];
+
+    public function register(IFileProcessor $fileUploadProcessor): void
+    {
+        $typeName = $fileUploadProcessor->getTypeName();
+        if (isset($this->data[$typeName])) {
+            throw new DuplicateFileProcessor($typeName);
+        }
+
+        $this->data[$typeName] = $fileUploadProcessor;
+    }
+
+    /**
+     * @return array<string, IFileProcessor>
+     */
+    public function getProcessors(): array
+    {
+        return $this->data;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/file/processor/exception/DuplicateFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/exception/DuplicateFileProcessor.class.php
new file mode 100644 (file)
index 0000000..ce45692
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\system\file\processor\exception;
+
+/**
+ * @author Alexander Ebert
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ */
+final class DuplicateFileProcessor extends \Exception
+{
+    public function __construct(string $typeName)
+    {
+        parent::__construct(
+            \sprintf("The file processor '%s' has already been registered", $typeName),
+        );
+    }
+}
index 0efb7111807b1c25fcfe85b1076e5f74bf147691..ed0b30c7170ba9b8814ea7b3a20e90950c8add33 100644 (file)
@@ -598,7 +598,8 @@ CREATE TABLE wcf1_file (
        fileID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
        filename VARCHAR(255) NOT NULL,
        fileSize BIGINT NOT NULL,
-       fileHash CHAR(64) NOT NULL
+       fileHash CHAR(64) NOT NULL,
+       typeName VARCHAR(255) NOT NULL
 );
 
 DROP TABLE IF EXISTS wcf1_file_temporary;
@@ -608,6 +609,8 @@ CREATE TABLE wcf1_file_temporary (
        filename VARCHAR(255) NOT NULL,
        fileSize BIGINT NOT NULL,
        fileHash CHAR(64) NOT NULL,
+       typeName VARCHAR(255) NOT NULL,
+       context TEXT,
        chunks VARBINARY(255) NOT NULL
 );