window.addEventListener("beforeunload", () => (ignoreConnectionErrors = true));
class BackendRequest {
+ readonly #headers = new Map<string, string>();
readonly #url: string;
readonly #type: RequestType;
readonly #payload?: Payload;
return this;
}
+ withHeader(key: string, value: string): this {
+ this.#headers.set(key, value);
+
+ return this;
+ }
+
protected allowCaching(): this {
this.#allowCaching = true;
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",
--- /dev/null
+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);
+}
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;
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");
}
}
let ignoreConnectionErrors = false;
window.addEventListener("beforeunload", () => (ignoreConnectionErrors = true));
class BackendRequest {
+ #headers = new Map();
#url;
#type;
#payload;
this.#showLoadingIndicator = false;
return this;
}
+ withHeader(key, value) {
+ this.#headers.set(key, value);
+ return this;
+ }
allowCaching() {
this.#allowCaching = true;
return this;
}
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",
--- /dev/null
+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;
+});
-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;
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) {
+++ /dev/null
-<?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,
- ]);
- }
-}
\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);
}
--- /dev/null
+<?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,
+ ]);
+ }
+}