Migrate the chunk upload to the new API
authorAlexander Ebert <ebert@woltlab.com>
Fri, 22 Mar 2024 16:57:23 +0000 (17:57 +0100)
committerAlexander Ebert <ebert@woltlab.com>
Sun, 12 May 2024 10:23:31 +0000 (12:23 +0200)
ts/WoltLabSuite/Core/Ajax/Backend.ts
ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/File/Upload.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Backend.js
wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Chunk/Chunk.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js
wcfsetup/install/files/lib/action/FileUploadAction.class.php [deleted file]
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/PostChunk.class.php [new file with mode: 0644]

index ad3fa8cdd979ec96eb0f1d46350882d6847a5b31..46d9e8779b30d3fb3f2f59764e0b27c4d64d1a83 100644 (file)
@@ -50,6 +50,7 @@ let ignoreConnectionErrors = false;
 window.addEventListener("beforeunload", () => (ignoreConnectionErrors = true));
 
 class BackendRequest {
+  readonly #headers = new Map<string, string>();
   readonly #url: string;
   readonly #type: RequestType;
   readonly #payload?: Payload;
@@ -77,6 +78,12 @@ class BackendRequest {
     return this;
   }
 
+  withHeader(key: string, value: string): this {
+    this.#headers.set(key, value);
+
+    return this;
+  }
+
   protected allowCaching(): this {
     this.#allowCaching = true;
 
@@ -117,12 +124,13 @@ class BackendRequest {
   async #fetch(requestOptions: RequestInit = {}): Promise<Response | undefined> {
     registerGlobalRejectionHandler();
 
+    this.#headers.set("X-Requested-With", "XMLHttpRequest");
+    this.#headers.set("X-XSRF-TOKEN", getXsrfToken());
+    const headers = Object.fromEntries(this.#headers);
+
     const init: RequestInit = extend(
       {
-        headers: {
-          "X-Requested-With": "XMLHttpRequest",
-          "X-XSRF-TOKEN": getXsrfToken(),
-        },
+        headers,
         mode: "same-origin",
         credentials: "same-origin",
         cache: this.#allowCaching ? "default" : "no-store",
diff --git a/ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts b/ts/WoltLabSuite/Core/Api/Files/Chunk/Chunk.ts
new file mode 100644 (file)
index 0000000..84dd41e
--- /dev/null
@@ -0,0 +1,37 @@
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "../../Result";
+
+export type Response =
+  | {
+      completed: false;
+    }
+  | {
+      completed: true;
+      generateThumbnails: boolean;
+      fileID: number;
+      typeName: string;
+      mimeType: string;
+      link: string;
+      data: Record<string, unknown>;
+    };
+
+export async function uploadChunk(
+  identifier: string,
+  sequenceNo: number,
+  checksum: string,
+  payload: Blob,
+): Promise<ApiResult<Response>> {
+  const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/upload/${identifier}/chunk/${sequenceNo}`);
+
+  let response: Response;
+  try {
+    response = (await prepareRequest(url)
+      .post(payload)
+      .withHeader("chunk-checksum-sha256", checksum)
+      .fetchAsJson()) as Response;
+  } catch (e) {
+    return apiResultFromError(e);
+  }
+
+  return apiResultFromValue(response);
+}
index 1636aba3c29a60bbcfbd16f4b84c042376b6a691..c1b62d2d72a2f6415fb323abdf2d8294be035173 100644 (file)
@@ -1,24 +1,8 @@
 import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
-import { StatusNotOk } from "WoltLabSuite/Core/Ajax/Error";
-import { isPlainObject } from "WoltLabSuite/Core/Core";
 import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
 import { upload as filesUpload } from "WoltLabSuite/Core/Api/Files/Upload";
 import WoltlabCoreFileElement from "./woltlab-core-file";
-
-type UploadResponse =
-  | { completed: false }
-  | ({
-      completed: true;
-    } & UploadCompleted);
-
-export type UploadCompleted = {
-  endpointThumbnails: string;
-  fileID: number;
-  typeName: string;
-  mimeType: string;
-  link: string;
-  data: Record<string, unknown>;
-};
+import { Response as UploadChunkResponse, uploadChunk } from "WoltLabSuite/Core/Api/Files/Chunk/Chunk";
 
 export type ThumbnailsGenerated = {
   data: GenerateThumbnailsResponse;
@@ -67,38 +51,29 @@ async function upload(element: WoltlabCoreFileUploadElement, file: File): Promis
     const end = start + chunkSize;
     const chunk = file.slice(start, end);
 
-    // TODO fix the URL
-    throw new Error("TODO: fix the url");
-    const endpoint = new URL(String(i));
-
     const checksum = await getSha256Hash(await chunk.arrayBuffer());
-    endpoint.searchParams.append("checksum", checksum);
-
-    let response: UploadResponse;
-    try {
-      response = (await prepareRequest(endpoint.toString()).post(chunk).fetchAsJson()) as UploadResponse;
-    } catch (e) {
-      // TODO: Handle errors
-      console.error(e);
 
+    const response = await uploadChunk(identifier, i, checksum, chunk);
+    if (!response.ok) {
       fileElement.uploadFailed();
-      throw e;
+
+      throw response.error;
     }
 
-    await chunkUploadCompleted(fileElement, response);
+    await chunkUploadCompleted(fileElement, response.value);
   }
 }
 
-async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, response: UploadResponse): Promise<void> {
+async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, response: UploadChunkResponse): Promise<void> {
   if (!response.completed) {
     return;
   }
 
-  const hasThumbnails = response.endpointThumbnails !== "";
-  fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, hasThumbnails);
+  fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, response.generateThumbnails);
 
-  if (hasThumbnails) {
-    await generateThumbnails(fileElement, response.endpointThumbnails);
+  if (response.generateThumbnails) {
+    throw new Error("TODO: endpoint to generate thumbnails");
+    await generateThumbnails(fileElement, "todo");
   }
 }
 
index 46f8132f269cf74603f285669c341d8e76940f87..e423cf4fa2331f201a4c5eba360d3491457ee9a7 100644 (file)
@@ -29,6 +29,7 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi
     let ignoreConnectionErrors = false;
     window.addEventListener("beforeunload", () => (ignoreConnectionErrors = true));
     class BackendRequest {
+        #headers = new Map();
         #url;
         #type;
         #payload;
@@ -50,6 +51,10 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi
             this.#showLoadingIndicator = false;
             return this;
         }
+        withHeader(key, value) {
+            this.#headers.set(key, value);
+            return this;
+        }
         allowCaching() {
             this.#allowCaching = true;
             return this;
@@ -82,11 +87,11 @@ define(["require", "exports", "tslib", "./Status", "./Error", "../Core"], functi
         }
         async #fetch(requestOptions = {}) {
             (0, Error_1.registerGlobalRejectionHandler)();
+            this.#headers.set("X-Requested-With", "XMLHttpRequest");
+            this.#headers.set("X-XSRF-TOKEN", (0, Core_1.getXsrfToken)());
+            const headers = Object.fromEntries(this.#headers);
             const init = (0, Core_1.extend)({
-                headers: {
-                    "X-Requested-With": "XMLHttpRequest",
-                    "X-XSRF-TOKEN": (0, Core_1.getXsrfToken)(),
-                },
+                headers,
                 mode: "same-origin",
                 credentials: "same-origin",
                 cache: this.#allowCaching ? "default" : "no-store",
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Chunk/Chunk.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/Chunk/Chunk.js
new file mode 100644 (file)
index 0000000..26d7826
--- /dev/null
@@ -0,0 +1,20 @@
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../../Result"], function (require, exports, Backend_1, Result_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.uploadChunk = void 0;
+    async function uploadChunk(identifier, sequenceNo, checksum, payload) {
+        const url = new URL(`${window.WSC_API_URL}index.php?api/rpc/core/files/upload/${identifier}/chunk/${sequenceNo}`);
+        let response;
+        try {
+            response = (await (0, Backend_1.prepareRequest)(url)
+                .post(payload)
+                .withHeader("chunk-checksum-sha256", checksum)
+                .fetchAsJson());
+        }
+        catch (e) {
+            return (0, Result_1.apiResultFromError)(e);
+        }
+        return (0, Result_1.apiResultFromValue)(response);
+    }
+    exports.uploadChunk = uploadChunk;
+});
index bf96359f6cb3507cc524afa84f373dfe5ba155ce..3f73cf85df362ea1d43e1669b5eef2b8c99e2a5c 100644 (file)
@@ -1,4 +1,4 @@
-define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload"], function (require, exports, Backend_1, Selector_1, Upload_1) {
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk"], function (require, exports, Backend_1, Selector_1, Upload_1, Chunk_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.setup = void 0;
@@ -26,32 +26,23 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Co
             const start = i * chunkSize;
             const end = start + chunkSize;
             const chunk = file.slice(start, end);
-            // TODO fix the URL
-            throw new Error("TODO: fix the url");
-            const endpoint = new URL(String(i));
             const checksum = await getSha256Hash(await chunk.arrayBuffer());
-            endpoint.searchParams.append("checksum", checksum);
-            let response;
-            try {
-                response = (await (0, Backend_1.prepareRequest)(endpoint.toString()).post(chunk).fetchAsJson());
-            }
-            catch (e) {
-                // TODO: Handle errors
-                console.error(e);
+            const response = await (0, Chunk_1.uploadChunk)(identifier, i, checksum, chunk);
+            if (!response.ok) {
                 fileElement.uploadFailed();
-                throw e;
+                throw response.error;
             }
-            await chunkUploadCompleted(fileElement, response);
+            await chunkUploadCompleted(fileElement, response.value);
         }
     }
     async function chunkUploadCompleted(fileElement, response) {
         if (!response.completed) {
             return;
         }
-        const hasThumbnails = response.endpointThumbnails !== "";
-        fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, hasThumbnails);
-        if (hasThumbnails) {
-            await generateThumbnails(fileElement, response.endpointThumbnails);
+        fileElement.uploadCompleted(response.fileID, response.mimeType, response.link, response.data, response.generateThumbnails);
+        if (response.generateThumbnails) {
+            throw new Error("TODO: endpoint to generate thumbnails");
+            await generateThumbnails(fileElement, "todo");
         }
     }
     async function generateThumbnails(fileElement, endpoint) {
diff --git a/wcfsetup/install/files/lib/action/FileUploadAction.class.php b/wcfsetup/install/files/lib/action/FileUploadAction.class.php
deleted file mode 100644 (file)
index 210c140..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-<?php
-
-namespace wcf\action;
-
-use Laminas\Diactoros\Response\EmptyResponse;
-use Laminas\Diactoros\Response\JsonResponse;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Http\Message\ServerRequestInterface;
-use Psr\Http\Server\RequestHandlerInterface;
-use wcf\data\file\FileEditor;
-use wcf\data\file\temporary\FileTemporary;
-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
-{
-    /**
-     * Read data in chunks to avoid hitting the memory limit.
-     * See https://stackoverflow.com/a/61997147
-     */
-    private const FREAD_BUFFER_SIZE = 10 * 1_024 * 1_024;
-
-    public function handle(ServerRequestInterface $request): ResponseInterface
-    {
-        // TODO: `sequenceNo` should be of type `non-negative-int`, but requires Valinor 1.7+
-        $parameters = Helper::mapQueryParameters(
-            $request->getQueryParams(),
-            <<<'EOT'
-                    array {
-                        checksum: non-empty-string,
-                        identifier: non-empty-string,
-                        sequenceNo: int,
-                    }
-                    EOT,
-        );
-
-        $fileTemporary = new FileTemporary($parameters['identifier']);
-        if (!$fileTemporary->identifier) {
-            // TODO: Proper error message
-            throw new IllegalLinkException();
-        }
-
-        // Check if this is a valid sequence no.
-        if ($parameters['sequenceNo'] >= $fileTemporary->getChunkCount()) {
-            // TODO: Proper error message
-            throw new IllegalLinkException();
-        }
-
-        // Check if this chunk has already been written.
-        if ($fileTemporary->hasChunk($parameters['sequenceNo'])) {
-            // 409 Conflict
-            return new EmptyResponse(409);
-        }
-
-        // Validate the chunk size.
-        $chunkSize = $fileTemporary->getChunkSize();
-        $stream = $request->getBody();
-        $receivedSize = $stream->getSize();
-        if ($receivedSize !== null && $receivedSize > $chunkSize) {
-            // 413 Content Too Large
-            return new EmptyResponse(413);
-        }
-
-        $tmpPath = $fileTemporary->getPath();
-        if (!\is_dir($tmpPath)) {
-            \mkdir($tmpPath, recursive: true);
-        }
-
-        $file = new IoFile($tmpPath . $fileTemporary->getFilename(), 'cb+');
-        $file->lock(\LOCK_EX);
-        $file->seek($parameters['sequenceNo'] * $chunkSize);
-
-        // Check if the checksum matches the received data.
-        $ctx = \hash_init('sha256');
-        $total = 0;
-        while (!$stream->eof()) {
-            // Write the chunk using a buffer to avoid blowing up the memory limit.
-            // See https://stackoverflow.com/a/61997147
-            $chunk = $stream->read(self::FREAD_BUFFER_SIZE);
-            $total += \strlen($chunk);
-
-            if ($total > $chunkSize) {
-                // 413 Content Too Large
-                return new EmptyResponse(413);
-            }
-
-            \hash_update($ctx, $chunk);
-            $file->write($chunk);
-        }
-        $file->sync();
-        $file->close();
-
-        $result = \hash_final($ctx);
-
-        if ($result !== $parameters['checksum']) {
-            // TODO: Proper error message
-            throw new IllegalLinkException();
-        }
-
-        // Mark the chunk as written.
-        $chunks = $fileTemporary->chunks;
-        $chunks[$parameters['sequenceNo']] = '1';
-        (new FileTemporaryEditor($fileTemporary))->update([
-            'chunks' => $chunks,
-        ]);
-
-        // Check if we have all chunks.
-        if ($chunks === \str_repeat('1', $fileTemporary->getChunkCount())) {
-            // Check if the final result matches the expected checksum.
-            $checksum = \hash_file('sha256', $tmpPath . $fileTemporary->getFilename());
-            if ($checksum !== $fileTemporary->fileHash) {
-                // TODO: Proper error message
-                throw new IllegalLinkException();
-            }
-
-            $file = FileEditor::createFromTemporary($fileTemporary);
-
-            $context = $fileTemporary->getContext();
-            (new FileTemporaryEditor($fileTemporary))->delete();
-            unset($fileTemporary);
-
-            $processor = $file->getProcessor();
-            if ($processor === null) {
-                // TODO: Mark the file as orphaned.
-                \assert($processor !== null);
-            }
-
-            $processor->adopt($file, $context);
-
-            $endpointThumbnails = '';
-            if ($file->isImage()) {
-                $thumbnailFormats = $processor->getThumbnailFormats();
-                if ($thumbnailFormats !== []) {
-                    // TODO: Endpoint to generate thumbnails.
-                    $endpointThumbnails = LinkHandler::getInstance()->getControllerLink(
-                        FileGenerateThumbnailsAction::class,
-                        ['id' => $file->fileID],
-                    );
-                }
-            }
-
-            // TODO: This is just debug code.
-            return new JsonResponse([
-                'completed' => true,
-                'endpointThumbnails' => $endpointThumbnails,
-                'fileID' => $file->fileID,
-                'typeName' => $file->typeName,
-                'mimeType' => $file->mimeType,
-                'link' => $file->getLink(),
-                'data' => $processor->getUploadResponse($file),
-            ]);
-        }
-
-        return new JsonResponse([
-            'completed' => false,
-        ]);
-    }
-}
index 9c351ff8b608ed5c443c8a6779075b3f6f201866..7283b02e1ac3c863ccb576ffed0e39fe299dcd2f 100644 (file)
@@ -104,6 +104,7 @@ return static function (): void {
         \wcf\event\endpoint\ControllerCollecting::class,
         static function (\wcf\event\endpoint\ControllerCollecting $event) {
             $event->register(new \wcf\system\endpoint\controller\core\files\PostUpload);
+            $event->register(new \wcf\system\endpoint\controller\core\files\upload\PostChunk);
             $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions);
             $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession);
         }
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/PostChunk.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/PostChunk.class.php
new file mode 100644 (file)
index 0000000..41ea4ce
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+
+namespace wcf\system\endpoint\controller\core\files\upload;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use wcf\data\file\FileEditor;
+use wcf\data\file\temporary\FileTemporary;
+use wcf\data\file\temporary\FileTemporaryEditor;
+use wcf\system\endpoint\IController;
+use wcf\system\endpoint\PostRequest;
+use wcf\system\exception\UserInputException;
+use wcf\system\io\File;
+
+#[PostRequest('/core/files/upload/{identifier}/chunk/{sequenceNo:\d+}')]
+final class PostChunk implements IController
+{
+    /**
+     * Read data in chunks to avoid hitting the memory limit.
+     * See https://stackoverflow.com/a/61997147
+     */
+    private const FREAD_BUFFER_SIZE = 10 * 1_024 * 1_024;
+
+    public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+    {
+        $checksum = \current($request->getHeader('chunk-checksum-sha256'));
+        if ($checksum === false) {
+            throw new UserInputException('chunk-checksum-sha256');
+        }
+
+        $identifier = $variables['identifier'];
+        $sequenceNo = $variables['sequenceNo'];
+
+        $fileTemporary = new FileTemporary($identifier);
+        if (!$fileTemporary->identifier) {
+            throw new UserInputException('identifier');
+        }
+
+        // Check if this is a valid sequence no.
+        if ($sequenceNo >= $fileTemporary->getChunkCount()) {
+            throw new UserInputException('sequenceNo', 'outOfRange');
+        }
+
+        // Check if this chunk has already been written.
+        if ($fileTemporary->hasChunk($sequenceNo)) {
+            throw new UserInputException('sequenceNo', 'alreadyExists');
+        }
+
+        // Validate the chunk size.
+        $chunkSize = $fileTemporary->getChunkSize();
+        $stream = $request->getBody();
+        $receivedSize = $stream->getSize();
+        if ($receivedSize !== null && $receivedSize > $chunkSize) {
+            throw new UserInputException('payload', 'tooLarge');
+        }
+
+        $tmpPath = $fileTemporary->getPath();
+        if (!\is_dir($tmpPath)) {
+            \mkdir($tmpPath, recursive: true);
+        }
+
+        $file = new File($tmpPath . $fileTemporary->getFilename(), 'cb+');
+        $file->lock(\LOCK_EX);
+        $file->seek($sequenceNo * $chunkSize);
+
+        // Check if the checksum matches the received data.
+        $ctx = \hash_init('sha256');
+        $total = 0;
+        while (!$stream->eof()) {
+            // Write the chunk using a buffer to avoid blowing up the memory limit.
+            // See https://stackoverflow.com/a/61997147
+            $chunk = $stream->read(self::FREAD_BUFFER_SIZE);
+            $total += \strlen($chunk);
+
+            if ($total > $chunkSize) {
+                throw new UserInputException('file', 'exceedsFileSize');
+            }
+
+            \hash_update($ctx, $chunk);
+            $file->write($chunk);
+        }
+        $file->sync();
+        $file->close();
+
+        $result = \hash_final($ctx);
+
+        if ($result !== $checksum) {
+            throw new UserInputException('payload', 'checksum');
+        }
+
+        // Mark the chunk as written.
+        $chunks = $fileTemporary->chunks;
+        $chunks[$sequenceNo] = '1';
+        (new FileTemporaryEditor($fileTemporary))->update([
+            'chunks' => $chunks,
+        ]);
+
+        // Check if we have all chunks.
+        if ($chunks === \str_repeat('1', $fileTemporary->getChunkCount())) {
+            // Check if the final result matches the expected checksum.
+            $checksum = \hash_file('sha256', $tmpPath . $fileTemporary->getFilename());
+            if ($checksum !== $fileTemporary->fileHash) {
+                throw new UserInputException('file', 'checksum');
+            }
+
+            $file = FileEditor::createFromTemporary($fileTemporary);
+
+            $context = $fileTemporary->getContext();
+            (new FileTemporaryEditor($fileTemporary))->delete();
+            unset($fileTemporary);
+
+            $processor = $file->getProcessor();
+            if ($processor === null) {
+                // TODO: Mark the file as orphaned.
+                \assert($processor !== null);
+            }
+
+            $processor->adopt($file, $context);
+
+            $generateThumbnails = false;
+            if ($file->isImage()) {
+                $thumbnailFormats = $processor->getThumbnailFormats();
+                if ($thumbnailFormats !== []) {
+                    $generateThumbnails = true;
+                }
+            }
+
+            // TODO: This is just debug code.
+            return new JsonResponse([
+                'completed' => true,
+                'generateThumbnails' => $generateThumbnails,
+                'fileID' => $file->fileID,
+                'typeName' => $file->typeName,
+                'mimeType' => $file->mimeType,
+                'link' => $file->getLink(),
+                'data' => $processor->getUploadResponse($file),
+            ]);
+        }
+
+        return new JsonResponse([
+            'completed' => false,
+        ]);
+    }
+}