From 01e3edb5729b81b75ad17f3e31b30480b52756d6 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 2 Dec 2024 09:36:35 +0100 Subject: [PATCH 1/2] feat(files): Implement resumable upload draft RFC Signed-off-by: provokateurin --- apps/files/appinfo/info.xml | 2 +- .../composer/composer/autoload_classmap.php | 7 + .../composer/composer/autoload_static.php | 7 + apps/files/lib/Capabilities.php | 33 +- .../Controller/ResumableUploadController.php | 388 ++++++ apps/files/lib/Db/ResumableUpload.php | 48 + apps/files/lib/Db/ResumableUploadMapper.php | 43 + .../Version2003Date20241126094807.php | 60 + apps/files/lib/Response/AProblemResponse.php | 46 + .../lib/Response/CompleteUploadResponse.php | 32 + .../Response/MismatchingOffsetResponse.php | 41 + apps/files/openapi.json | 23 +- .../ResumableUploadControllerTest.php | 1134 +++++++++++++++++ build/rector-strict.php | 9 +- openapi.json | 20 + psalm-strict.xml | 9 + 16 files changed, 1897 insertions(+), 5 deletions(-) create mode 100644 apps/files/lib/Controller/ResumableUploadController.php create mode 100644 apps/files/lib/Db/ResumableUpload.php create mode 100644 apps/files/lib/Db/ResumableUploadMapper.php create mode 100644 apps/files/lib/Migration/Version2003Date20241126094807.php create mode 100644 apps/files/lib/Response/AProblemResponse.php create mode 100644 apps/files/lib/Response/CompleteUploadResponse.php create mode 100644 apps/files/lib/Response/MismatchingOffsetResponse.php create mode 100644 apps/files/tests/Controller/ResumableUploadControllerTest.php diff --git a/apps/files/appinfo/info.xml b/apps/files/appinfo/info.xml index 3168eab9b38eb..60f39de8ed2e0 100644 --- a/apps/files/appinfo/info.xml +++ b/apps/files/appinfo/info.xml @@ -10,7 +10,7 @@ Files File Management File Management - 2.6.0 + 2.6.1 agpl John Molakvoæ Robin Appelman diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php index 90b5f1d4a2ce6..940129af000fc 100644 --- a/apps/files/composer/composer/autoload_classmap.php +++ b/apps/files/composer/composer/autoload_classmap.php @@ -59,12 +59,15 @@ 'OCA\\Files\\Controller\\DirectEditingViewController' => $baseDir . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => $baseDir . '/../lib/Controller/FilenamesController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => $baseDir . '/../lib/Controller/OpenLocalEditorController.php', + 'OCA\\Files\\Controller\\ResumableUploadController' => $baseDir . '/../lib/Controller/ResumableUploadController.php', 'OCA\\Files\\Controller\\TemplateController' => $baseDir . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => $baseDir . '/../lib/Controller/TransferOwnershipController.php', 'OCA\\Files\\Controller\\ViewController' => $baseDir . '/../lib/Controller/ViewController.php', 'OCA\\Files\\Dashboard\\FavoriteWidget' => $baseDir . '/../lib/Dashboard/FavoriteWidget.php', 'OCA\\Files\\Db\\OpenLocalEditor' => $baseDir . '/../lib/Db/OpenLocalEditor.php', 'OCA\\Files\\Db\\OpenLocalEditorMapper' => $baseDir . '/../lib/Db/OpenLocalEditorMapper.php', + 'OCA\\Files\\Db\\ResumableUpload' => $baseDir . '/../lib/Db/ResumableUpload.php', + 'OCA\\Files\\Db\\ResumableUploadMapper' => $baseDir . '/../lib/Db/ResumableUploadMapper.php', 'OCA\\Files\\Db\\TransferOwnership' => $baseDir . '/../lib/Db/TransferOwnership.php', 'OCA\\Files\\Db\\TransferOwnershipMapper' => $baseDir . '/../lib/Db/TransferOwnershipMapper.php', 'OCA\\Files\\DirectEditingCapabilities' => $baseDir . '/../lib/DirectEditingCapabilities.php', @@ -82,8 +85,12 @@ 'OCA\\Files\\Migration\\Version11301Date20191205150729' => $baseDir . '/../lib/Migration/Version11301Date20191205150729.php', 'OCA\\Files\\Migration\\Version12101Date20221011153334' => $baseDir . '/../lib/Migration/Version12101Date20221011153334.php', 'OCA\\Files\\Migration\\Version2003Date20241021095629' => $baseDir . '/../lib/Migration/Version2003Date20241021095629.php', + 'OCA\\Files\\Migration\\Version2003Date20241126094807' => $baseDir . '/../lib/Migration/Version2003Date20241126094807.php', 'OCA\\Files\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', 'OCA\\Files\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', + 'OCA\\Files\\Response\\AProblemResponse' => $baseDir . '/../lib/Response/AProblemResponse.php', + 'OCA\\Files\\Response\\CompleteUploadResponse' => $baseDir . '/../lib/Response/CompleteUploadResponse.php', + 'OCA\\Files\\Response\\MismatchingOffsetResponse' => $baseDir . '/../lib/Response/MismatchingOffsetResponse.php', 'OCA\\Files\\Search\\FilesSearchProvider' => $baseDir . '/../lib/Search/FilesSearchProvider.php', 'OCA\\Files\\Service\\ChunkedUploadConfig' => $baseDir . '/../lib/Service/ChunkedUploadConfig.php', 'OCA\\Files\\Service\\DirectEditingService' => $baseDir . '/../lib/Service/DirectEditingService.php', diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php index 34c41adb82a25..e5360d8ccbb0d 100644 --- a/apps/files/composer/composer/autoload_static.php +++ b/apps/files/composer/composer/autoload_static.php @@ -74,12 +74,15 @@ class ComposerStaticInitFiles 'OCA\\Files\\Controller\\DirectEditingViewController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingViewController.php', 'OCA\\Files\\Controller\\FilenamesController' => __DIR__ . '/..' . '/../lib/Controller/FilenamesController.php', 'OCA\\Files\\Controller\\OpenLocalEditorController' => __DIR__ . '/..' . '/../lib/Controller/OpenLocalEditorController.php', + 'OCA\\Files\\Controller\\ResumableUploadController' => __DIR__ . '/..' . '/../lib/Controller/ResumableUploadController.php', 'OCA\\Files\\Controller\\TemplateController' => __DIR__ . '/..' . '/../lib/Controller/TemplateController.php', 'OCA\\Files\\Controller\\TransferOwnershipController' => __DIR__ . '/..' . '/../lib/Controller/TransferOwnershipController.php', 'OCA\\Files\\Controller\\ViewController' => __DIR__ . '/..' . '/../lib/Controller/ViewController.php', 'OCA\\Files\\Dashboard\\FavoriteWidget' => __DIR__ . '/..' . '/../lib/Dashboard/FavoriteWidget.php', 'OCA\\Files\\Db\\OpenLocalEditor' => __DIR__ . '/..' . '/../lib/Db/OpenLocalEditor.php', 'OCA\\Files\\Db\\OpenLocalEditorMapper' => __DIR__ . '/..' . '/../lib/Db/OpenLocalEditorMapper.php', + 'OCA\\Files\\Db\\ResumableUpload' => __DIR__ . '/..' . '/../lib/Db/ResumableUpload.php', + 'OCA\\Files\\Db\\ResumableUploadMapper' => __DIR__ . '/..' . '/../lib/Db/ResumableUploadMapper.php', 'OCA\\Files\\Db\\TransferOwnership' => __DIR__ . '/..' . '/../lib/Db/TransferOwnership.php', 'OCA\\Files\\Db\\TransferOwnershipMapper' => __DIR__ . '/..' . '/../lib/Db/TransferOwnershipMapper.php', 'OCA\\Files\\DirectEditingCapabilities' => __DIR__ . '/..' . '/../lib/DirectEditingCapabilities.php', @@ -97,8 +100,12 @@ class ComposerStaticInitFiles 'OCA\\Files\\Migration\\Version11301Date20191205150729' => __DIR__ . '/..' . '/../lib/Migration/Version11301Date20191205150729.php', 'OCA\\Files\\Migration\\Version12101Date20221011153334' => __DIR__ . '/..' . '/../lib/Migration/Version12101Date20221011153334.php', 'OCA\\Files\\Migration\\Version2003Date20241021095629' => __DIR__ . '/..' . '/../lib/Migration/Version2003Date20241021095629.php', + 'OCA\\Files\\Migration\\Version2003Date20241126094807' => __DIR__ . '/..' . '/../lib/Migration/Version2003Date20241126094807.php', 'OCA\\Files\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', 'OCA\\Files\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', + 'OCA\\Files\\Response\\AProblemResponse' => __DIR__ . '/..' . '/../lib/Response/AProblemResponse.php', + 'OCA\\Files\\Response\\CompleteUploadResponse' => __DIR__ . '/..' . '/../lib/Response/CompleteUploadResponse.php', + 'OCA\\Files\\Response\\MismatchingOffsetResponse' => __DIR__ . '/..' . '/../lib/Response/MismatchingOffsetResponse.php', 'OCA\\Files\\Search\\FilesSearchProvider' => __DIR__ . '/..' . '/../lib/Search/FilesSearchProvider.php', 'OCA\\Files\\Service\\ChunkedUploadConfig' => __DIR__ . '/..' . '/../lib/Service/ChunkedUploadConfig.php', 'OCA\\Files\\Service\\DirectEditingService' => __DIR__ . '/..' . '/../lib/Service/DirectEditingService.php', diff --git a/apps/files/lib/Capabilities.php b/apps/files/lib/Capabilities.php index 6b50e5807a5f6..2afd2d3dcf199 100644 --- a/apps/files/lib/Capabilities.php +++ b/apps/files/lib/Capabilities.php @@ -8,6 +8,7 @@ namespace OCA\Files; use OC\Files\FilenameValidator; +use OCA\Files\Controller\ResumableUploadController; use OCA\Files\Service\ChunkedUploadConfig; use OCP\Capabilities\ICapability; use OCP\Files\Conversion\ConversionMimeProvider; @@ -24,7 +25,31 @@ public function __construct( /** * Return this classes capabilities * - * @return array{files: array{'$comment': ?string, bigfilechunking: bool, blacklisted_files: list, forbidden_filenames: list, forbidden_filename_basenames: list, forbidden_filename_characters: list, forbidden_filename_extensions: list, chunked_upload: array{max_size: int, max_parallel_count: int}, file_conversions: list}} + * @return array{ + * files: array{ + * '$comment': ?string, + * bigfilechunking: bool, + * blacklisted_files: list, + * forbidden_filenames: list, + * forbidden_filename_basenames: list, + * forbidden_filename_characters: list, + * forbidden_filename_extensions: list, + * chunked_upload: array{ + * max_size: int, + * max_parallel_count: int, + * }, + * file_conversions: list, + * resumable_upload: array{ + * supported: bool, + * interop_version: string, + * } + * } + * } */ public function getCapabilities(): array { return [ @@ -41,10 +66,14 @@ public function getCapabilities(): array { 'max_size' => ChunkedUploadConfig::getMaxChunkSize(), 'max_parallel_count' => ChunkedUploadConfig::getMaxParallelCount(), ], - 'file_conversions' => array_map(function (ConversionMimeProvider $mimeProvider) { return $mimeProvider->jsonSerialize(); }, $this->fileConversionManager->getProviders()), + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4.1-3 + 'resumable_upload' => [ + 'supported' => true, + 'interop_version' => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], ], ]; } diff --git a/apps/files/lib/Controller/ResumableUploadController.php b/apps/files/lib/Controller/ResumableUploadController.php new file mode 100644 index 0000000000000..a0fdea435ad7c --- /dev/null +++ b/apps/files/lib/Controller/ResumableUploadController.php @@ -0,0 +1,388 @@ + self::UPLOAD_DRAFT_INTEROP_VERSION, + ]; + + // Some constraints are only for append, not create + private bool $isCreation = false; + + public function __construct( + string $appName, + IRequest $request, + private readonly ?string $userId, + private readonly IURLGenerator $urlGenerator, + private readonly ResumableUploadMapper $mapper, + /** + * Only meant for testing, there is no way to mock it otherwise + * @var ?resource $inputHandle + */ + private readonly mixed $inputHandle = null, + ) { + parent::__construct($appName, $request); + } + + private function isSupported(): bool { + return $this->request->getHeader(self::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION) === self::UPLOAD_DRAFT_INTEROP_VERSION; + } + + private function getUploadComplete(): ?bool { + return match ($this->request->getHeader(self::HTTP_HEADER_UPLOAD_COMPLETE)) { + '1' => true, + '0' => false, + default => null, + }; + } + + private function getUploadOffset(): ?int { + $value = $this->request->getHeader(self::HTTP_HEADER_UPLOAD_OFFSET); + if ($value !== '') { + return (int)$value; + } + + return null; + } + + private function getUploadLength(): ?int { + $value = $this->request->getHeader(self::HTTP_HEADER_UPLOAD_LENGTH); + if ($value !== '') { + return (int)$value; + } + + return null; + } + + private function getContentLength(): ?int { + $value = $this->request->getHeader(self::HTTP_HEADER_CONTENT_LENGTH); + if ($value !== '') { + return (int)$value; + } + + return null; + } + + private function getContentType(): ?string { + $value = $this->request->getHeader(self::HTTP_HEADER_CONTENT_TYPE); + if ($value !== '') { + return $value; + } + + return null; + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'POST', url: '/upload', postfix: 'post')] + #[FrontpageRoute(verb: 'PUT', url: '/upload', postfix: 'put')] + #[FrontpageRoute(verb: 'PATCH', url: '/upload', postfix: 'patch')] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + public function createResource(): Response { + if ($this->userId === null) { + return new Response(Http::STATUS_UNAUTHORIZED, self::BASE_HEADERS); // @codeCoverageIgnore + } + + if (!$this->isSupported()) { + return new Response(Http::STATUS_NOT_IMPLEMENTED, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4-3 + $isUploadComplete = $this->getUploadComplete(); + if ($isUploadComplete === null) { + return new Response(Http::STATUS_BAD_REQUEST, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4-9 + $contentLength = $this->getContentLength(); + $uploadLength = $this->getUploadLength(); + if ($isUploadComplete && $contentLength !== null && $uploadLength !== null && $contentLength !== $uploadLength) { + return new Response(Http::STATUS_BAD_REQUEST, self::BASE_HEADERS); + } + + $token = uniqid('', true); + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4-10.1.1 + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4-10.2.1 + $size = $uploadLength ?? ($isUploadComplete ? $contentLength : null); + + $upload = new ResumableUpload(); + $upload->setUserId($this->userId); + $upload->setToken($token); + // TODO: Generate a proper path + $upload->setPath('/tmp/upload-' . $token); + $upload->setSize($size); + + $this->mapper->insert($upload); + + $this->isCreation = true; + return $this->appendResource($token); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'PATCH', url: '/upload/{token}')] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + public function appendResource(string $token): Response { + if ($this->userId === null) { + return new Response(Http::STATUS_UNAUTHORIZED, self::BASE_HEADERS); // @codeCoverageIgnore + } + + if (!$this->isSupported()) { + return new Response(Http::STATUS_NOT_IMPLEMENTED, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-2 + if (!$this->isCreation && $this->getContentType() !== self::MEDIA_TYPE_PARTIAL_UPLOAD) { + return new Response(Http::STATUS_BAD_REQUEST, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-5 + $upload = $this->mapper->findByToken($this->userId, $token); + if (!$upload instanceof ResumableUpload) { + return new Response(Http::STATUS_NOT_FOUND, self::BASE_HEADERS); + } + + $tmpFileHandle = fopen($upload->getPath(), 'ab'); + if ($tmpFileHandle === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, self::BASE_HEADERS); // @codeCoverageIgnore + } + + $tmpFileStat = fstat($tmpFileHandle); + if ($tmpFileStat === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, self::BASE_HEADERS); // @codeCoverageIgnore + } + + /** @var non-negative-int $tmpFileSize */ + $tmpFileSize = $tmpFileStat['size']; + + $headers = self::BASE_HEADERS; + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-10 + $headers[self::HTTP_HEADER_UPLOAD_OFFSET] = $tmpFileSize; + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-11 + if ($upload->getComplete() === true) { + return new CompleteUploadResponse($headers); + } + + if (!$this->isCreation) { + $uploadOffset = $this->getUploadOffset(); + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-2 + if ($uploadOffset === null || $uploadOffset < 0) { + return new Response(Http::STATUS_BAD_REQUEST, $headers); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-7 + if ($uploadOffset !== $tmpFileSize) { + return new MismatchingOffsetResponse($tmpFileSize, $uploadOffset, $headers); + } + } + + $bodyHandle = $this->inputHandle ?? fopen('php://input', 'rb'); + if ($bodyHandle === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, $headers); // @codeCoverageIgnore + } + + if ($upload->getSize() !== null) { + $offset = 0; + while (true) { + $copied = stream_copy_to_stream($bodyHandle, $tmpFileHandle, 1024 * 1024 * 16, $offset); + if ($copied === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, $headers); // @codeCoverageIgnore + } + + if ($copied === 0) { + // No more data, we can also skip checks since the size hasn't changed since the last checks + break; + } + + $offset += $copied; + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-15 + if ($upload->getSize() < $tmpFileSize + $copied) { + return new Response(Http::STATUS_BAD_REQUEST, $headers); + } + } + } else { + $copied = stream_copy_to_stream($bodyHandle, $tmpFileHandle); + if ($copied === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, $headers); // @codeCoverageIgnore + } + } + + fclose($bodyHandle); + + $tmpFileStat = fstat($tmpFileHandle); + if ($tmpFileStat === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, $headers); // @codeCoverageIgnore + } + + /** @var non-negative-int $tmpFileSize */ + $tmpFileSize = $tmpFileStat['size']; + + fclose($tmpFileHandle); + + $headers[self::HTTP_HEADER_UPLOAD_OFFSET] = $tmpFileSize; + + $isUploadComplete = $this->getUploadComplete(); + if ($isUploadComplete) { + $upload->setComplete(true); + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-14 + if ($upload->getSize() === null) { + $upload->setSize($tmpFileSize); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-14 + if ($tmpFileSize !== $upload->getSize()) { + return new Response(Http::STATUS_BAD_REQUEST, $headers); + } + + $this->mapper->update($upload); + } + + if ($this->isCreation) { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4-4 + $headers[self::HTTP_HEADER_LOCATION] = $this->urlGenerator->linkToRouteAbsolute('files.ResumableUpload.appendResource', ['token' => $token]); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-12 + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-6-13 + $headers[self::HTTP_HEADER_UPLOAD_COMPLETE] = $upload->getComplete() ? '1' : '0'; + return new Response(Http::STATUS_CREATED, $headers); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + // The webserver will convert the HEAD request into a GET request, so we have to handle it this way + #[FrontpageRoute(verb: 'GET', url: '/upload/{token}')] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + public function checkResource(string $token): Response { + if ($this->userId === null) { + return new Response(Http::STATUS_UNAUTHORIZED, self::BASE_HEADERS); // @codeCoverageIgnore + } + + if (!$this->isSupported()) { + return new Response(Http::STATUS_NOT_IMPLEMENTED, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-5-2 + if ($this->getUploadOffset() !== null || $this->getUploadComplete() !== null || $this->getUploadLength() !== null) { + return new Response(Http::STATUS_BAD_REQUEST, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-5-9 + $upload = $this->mapper->findByToken($this->userId, $token); + if (!$upload instanceof ResumableUpload) { + return new Response(Http::STATUS_NOT_FOUND, self::BASE_HEADERS); + } + + $tmpFileHandle = fopen($upload->getPath(), 'rb'); + if ($tmpFileHandle === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, self::BASE_HEADERS); // @codeCoverageIgnore + } + + $tmpFileStat = fstat($tmpFileHandle); + if ($tmpFileStat === false) { + return new Response(Http::STATUS_INTERNAL_SERVER_ERROR, self::BASE_HEADERS); // @codeCoverageIgnore + } + + /** @var non-negative-int $tmpFileSize */ + $tmpFileSize = $tmpFileStat['size']; + + fclose($tmpFileHandle); + + $headers = self::BASE_HEADERS; + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-5-8 + $headers[self::HTTP_HEADER_CACHE_CONTROL] = 'no-store'; + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-5-3 + $headers[self::HTTP_HEADER_UPLOAD_COMPLETE] = $upload->getComplete() ? '1' : '0'; + $headers[self::HTTP_HEADER_UPLOAD_OFFSET] = $tmpFileSize; + $headers[self::HTTP_HEADER_UPLOAD_LENGTH] = $upload->getSize(); + return new Response(Http::STATUS_NO_CONTENT, $headers); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'DELETE', url: '/upload/{token}')] + #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] + public function deleteResource(string $token): Response { + if ($this->userId === null) { + return new Response(Http::STATUS_UNAUTHORIZED, self::BASE_HEADERS); // @codeCoverageIgnore + } + + if (!$this->isSupported()) { + return new Response(Http::STATUS_NOT_IMPLEMENTED, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-7-3 + if ($this->getUploadOffset() !== null || $this->getUploadComplete() !== null) { + return new Response(Http::STATUS_BAD_REQUEST, self::BASE_HEADERS); + } + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-7-6 + $upload = $this->mapper->findByToken($this->userId, $token); + if (!$upload instanceof ResumableUpload) { + return new Response(Http::STATUS_NOT_FOUND, self::BASE_HEADERS); + } + + $path = $upload->getPath(); + if (file_exists($path)) { + unlink($path); + } + + $this->mapper->delete($upload); + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-7-4 + return new Response(Http::STATUS_NO_CONTENT, self::BASE_HEADERS); + } +} diff --git a/apps/files/lib/Db/ResumableUpload.php b/apps/files/lib/Db/ResumableUpload.php new file mode 100644 index 0000000000000..fc05b5c7606cc --- /dev/null +++ b/apps/files/lib/Db/ResumableUpload.php @@ -0,0 +1,48 @@ +addType('userId', 'string'); + $this->addType('token', 'string'); + $this->addType('path', 'string'); + $this->addType('size', 'integer'); + $this->addType('complete', 'boolean'); + } +} diff --git a/apps/files/lib/Db/ResumableUploadMapper.php b/apps/files/lib/Db/ResumableUploadMapper.php new file mode 100644 index 0000000000000..9a19980208b62 --- /dev/null +++ b/apps/files/lib/Db/ResumableUploadMapper.php @@ -0,0 +1,43 @@ + + */ +class ResumableUploadMapper extends QBMapper { + public const TABLE_NAME = 'resumable_upload'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, self::TABLE_NAME, ResumableUpload::class); + } + + public function findByToken(string $userId, string $token): ?ResumableUpload { + $qb = $this->db->getQueryBuilder(); + $qb + ->select('id', 'user_id', 'token', 'path', 'size', 'complete') + ->from(self::TABLE_NAME) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('token', $qb->createNamedParameter($token))); + + $result = $qb->executeQuery(); + /** @var array|false $row */ + $row = $result->fetch(); + $result->closeCursor(); + if ($row === false) { + return null; + } + + return ResumableUpload::fromRow($row); + } +} diff --git a/apps/files/lib/Migration/Version2003Date20241126094807.php b/apps/files/lib/Migration/Version2003Date20241126094807.php new file mode 100644 index 0000000000000..e6d3d5c3c2d9f --- /dev/null +++ b/apps/files/lib/Migration/Version2003Date20241126094807.php @@ -0,0 +1,60 @@ +hasTable(ResumableUploadMapper::TABLE_NAME)) { + $table = $schema->createTable(ResumableUploadMapper::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 11, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('token', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('path', Types::TEXT, [ + 'notnull' => true, + ]); + $table->addColumn('size', Types::BIGINT, [ + 'notnull' => false, + ]); + $table->addColumn('complete', Types::BOOLEAN, [ + 'notnull' => false, + ]); + $table->addUniqueIndex(['token'], ResumableUploadMapper::TABLE_NAME . '_token_idx'); + $table->addIndex(['user_id', 'token'], ResumableUploadMapper::TABLE_NAME . '_uid_token_idx'); + $table->setPrimaryKey(['id']); + + return $schema; + } + + return null; + } +} diff --git a/apps/files/lib/Response/AProblemResponse.php b/apps/files/lib/Response/AProblemResponse.php new file mode 100644 index 0000000000000..dc9d39b8038a0 --- /dev/null +++ b/apps/files/lib/Response/AProblemResponse.php @@ -0,0 +1,46 @@ + + * @template-extends Response> + */ +abstract class AProblemResponse extends Response { + public const MEDIA_TYPE_PROBLEM_JSON = 'application/problem+json'; + + /** + * @param array $data + * @psalm-param S $status + * @psalm-param H $headers + */ + public function __construct( + private readonly string $type, + private readonly string $title, + private readonly array $data, + int $status, + array $headers = [], + ) { + $headers['Content-Type'] = self::MEDIA_TYPE_PROBLEM_JSON; + parent::__construct($status, $headers); + } + + public function render(): string { + return json_encode([ + 'type' => $this->type, + 'title' => $this->title, + ...$this->data, + ], JSON_THROW_ON_ERROR); + } +} diff --git a/apps/files/lib/Response/CompleteUploadResponse.php b/apps/files/lib/Response/CompleteUploadResponse.php new file mode 100644 index 0000000000000..8ef6467efc58f --- /dev/null +++ b/apps/files/lib/Response/CompleteUploadResponse.php @@ -0,0 +1,32 @@ + + * @template-extends AProblemResponse> + */ +// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#name-completed-upload +class CompleteUploadResponse extends AProblemResponse { + /** + * @psalm-param H $headers + */ + public function __construct(array $headers = []) { + parent::__construct( + 'https://iana.org/assignments/http-problem-types#completed-upload', + 'upload is already completed', + [], + Http::STATUS_BAD_REQUEST, + $headers, + ); + } +} diff --git a/apps/files/lib/Response/MismatchingOffsetResponse.php b/apps/files/lib/Response/MismatchingOffsetResponse.php new file mode 100644 index 0000000000000..59f3224236978 --- /dev/null +++ b/apps/files/lib/Response/MismatchingOffsetResponse.php @@ -0,0 +1,41 @@ + + * @template-extends AProblemResponse> + */ +// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#name-mismatching-offset +class MismatchingOffsetResponse extends AProblemResponse { + /** + * @psalm-param non-negative-int $expectedOffset + * @psalm-param non-negative-int $providedOffset + * @psalm-param H $headers + */ + public function __construct( + int $expectedOffset, + int $providedOffset, + array $headers = [], + ) { + parent::__construct( + 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset', + 'offset from request does not match offset of resource', + [ + 'expected-offset' => $expectedOffset, + 'provided-offset' => $providedOffset, + ], + Http::STATUS_CONFLICT, + $headers, + ); + } +} diff --git a/apps/files/openapi.json b/apps/files/openapi.json index c848532366f28..5c78b30ffa640 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -39,6 +39,7 @@ "forbidden_filename_extensions", "chunked_upload", "file_conversions", + "resumable_upload", "directEditing" ], "properties": { @@ -125,6 +126,21 @@ } } }, + "resumable_upload": { + "type": "object", + "required": [ + "supported", + "interop_version" + ], + "properties": { + "supported": { + "type": "boolean" + }, + "interop_version": { + "type": "string" + } + } + }, "directEditing": { "type": "object", "required": [ @@ -2888,5 +2904,10 @@ } } }, - "tags": [] + "tags": [ + { + "name": "resumable_upload", + "description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI." + } + ] } diff --git a/apps/files/tests/Controller/ResumableUploadControllerTest.php b/apps/files/tests/Controller/ResumableUploadControllerTest.php new file mode 100644 index 0000000000000..9b1a783e78dd1 --- /dev/null +++ b/apps/files/tests/Controller/ResumableUploadControllerTest.php @@ -0,0 +1,1134 @@ + $requestHeaders + * @param array|array|array $responseHeaders + */ + private function performRequest( + array $requestHeaders, + string $requestBody, + int $responseStatusCode, + array $responseHeaders, + string $responseBody, + callable $method, + ): Response { + $request = $this->createMock(IRequest::class); + $request + ->method('getHeader') + ->willReturnCallback(fn (string $name): string => $requestHeaders[$name] ?? ''); + + $inputHandle = tmpfile(); + $this->assertNotFalse($inputHandle); + if ($requestBody !== '') { + $this->assertEquals(strlen($requestBody), fwrite($inputHandle, $requestBody)); + $this->assertEquals(0, fseek($inputHandle, 0)); + } + + $controller = new ResumableUploadController( + 'files', + $request, + 'user', + Server::get(IURLGenerator::class), + Server::get(ResumableUploadMapper::class), + $inputHandle, + ); + + /** @var Response $response */ + $response = $method($controller); + $headers = $response->getHeaders(); + unset( + $headers['X-Request-Id'], + $headers['Content-Security-Policy'], + $headers['Feature-Policy'], + $headers['X-Robots-Tag'], + ); + if ($headers['Cache-Control'] === 'no-cache, no-store, must-revalidate') { + // Only remove if default so we don't have to check it every time + unset($headers['Cache-Control']); + } + + if (($responseHeaders[ResumableUploadController::HTTP_HEADER_LOCATION] ?? null) === true) { + $this->assertMatchesRegularExpression('#http://localhost/index\.php/apps/files/upload/[a-z0-9.]+$#', $headers[ResumableUploadController::HTTP_HEADER_LOCATION]); + } else { + $this->assertEquals(null, $headers[ResumableUploadController::HTTP_HEADER_LOCATION] ?? null); + } + + unset( + $responseHeaders[ResumableUploadController::HTTP_HEADER_LOCATION], + $headers[ResumableUploadController::HTTP_HEADER_LOCATION], + ); + + $this->assertEquals($responseStatusCode, $response->getStatus()); + $this->assertEquals($responseHeaders, $headers); + $this->assertEquals($responseBody, $response->render()); + + return $response; + } + + private function getTokenFromResponse(Response $response): string { + $parts = explode('/', (string)$response->getHeaders()[ResumableUploadController::HTTP_HEADER_LOCATION]); + return end($parts); + } + + public function testCreateComplete(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '3', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 3, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testCreateEmptyComplete(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '0', + ], + '', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 0, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 0, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 0, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testCreateEmptyIncomplete(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '0', + ], + '', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 0, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 0, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 0, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testCreateCompleteUnknownUploadLength(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 3, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testCreateWrongUploadLengthTooBig(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '4', + ], + 'abc', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '3', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + } + + public function testCreateWrongUploadLengthTooSmall(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '2', + ], + 'abc', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 0, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + } + + public function testCreateMismatchingUploadLengthAndContentLength(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '3', + ResumableUploadController::HTTP_HEADER_CONTENT_LENGTH => '4', + ], + 'abc', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + } + + public function testMissingUploadComplete(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + 'abc', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + } + + public function testCreateUnsupported(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => '-1', + ], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $this->performRequest( + [], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + } + + public function testAppend(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '9', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '3', + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'def', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 6, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 6, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '6', + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'ghi', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 9, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 9, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testAppendWrongUploadLength(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '10', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 10, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '3', + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'def', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 6, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 6, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 10, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '6', + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'ghi', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 9, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 9, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 10, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testAppendMissingUploadOffset(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '9', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'def', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testAppendWrongUploadOffset(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '9', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '4', + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'def', + Http::STATUS_CONFLICT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => AProblemResponse::MEDIA_TYPE_PROBLEM_JSON, + ], + '{"type":"https:\/\/iana.org\/assignments\/http-problem-types#mismatching-upload-offset","title":"offset from request does not match offset of resource","expected-offset":3,"provided-offset":4}', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + + public function testAppendWrongContentType(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '6', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => 'text/plain', + ], + 'def', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + } + + public function testAppendMissingContentType(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '6', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + 'def', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + } + + public function testAppendAlreadyCompleted(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '3', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'def', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => AProblemResponse::MEDIA_TYPE_PROBLEM_JSON, + ], + '{"type":"https:\/\/iana.org\/assignments\/http-problem-types#completed-upload","title":"upload is already completed"}', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + } + + public function testAppendUnsupported(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => '-1', + ], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource(''), + ); + + $this->performRequest( + [], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource(''), + ); + } + + public function testAppendNonExistent(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'abc', + Http::STATUS_NOT_FOUND, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource('404'), + ); + } + + public function testCheckRejectUploadOffset(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '1', + ], + '', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource(''), + ); + } + + public function testCheckRejectUploadComplete(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ], + '', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource(''), + ); + } + + public function testCheckRejectUploadLength(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '1', + ], + '', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource(''), + ); + } + + public function testCheckUnsupported(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => '-1', + ], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource(''), + ); + + $this->performRequest( + [], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource(''), + ); + } + + public function testCheckNonExistent(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NOT_FOUND, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource('404'), + ); + } + + public function testDeleteComplete(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '3', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 3, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->deleteResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NOT_FOUND, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testDeleteIncomplete(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '6', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + + $token = $this->getTokenFromResponse($response); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 6, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->deleteResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NOT_FOUND, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + } + + public function testDeleteRejectUploadOffset(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '1', + ], + '', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->deleteResource(''), + ); + } + + public function testDeleteRejectUploadComplete(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ], + '', + Http::STATUS_BAD_REQUEST, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->deleteResource(''), + ); + } + + public function testDeleteUnsupported(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => '-1', + ], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->deleteResource(''), + ); + + $this->performRequest( + [], + '', + Http::STATUS_NOT_IMPLEMENTED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->deleteResource(''), + ); + } + + public function testDeleteNonExistent(): void { + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NOT_FOUND, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->deleteResource('404'), + ); + } +} diff --git a/build/rector-strict.php b/build/rector-strict.php index ba6f33acabfb3..5e868de73aa58 100644 --- a/build/rector-strict.php +++ b/build/rector-strict.php @@ -10,7 +10,14 @@ return (require __DIR__ . '/rector-shared.php') ->withPaths([ $nextcloudDir . '/build/rector-strict.php', - // TODO: Add more files. The entry above is just there to stop rector from complaining about the fact that it ran without checking any files. + $nextcloudDir . '/apps/files/lib/Controller/ResumableUploadController.php', + $nextcloudDir . '/apps/files/lib/Db/ResumableUpload.php', + $nextcloudDir . '/apps/files/lib/Db/ResumableUploadMapper.php', + $nextcloudDir . '/apps/files/lib/Migration/Version2003Date20241126094807.php', + $nextcloudDir . '/apps/files/lib/Response/AProblemResponse.php', + $nextcloudDir . '/apps/files/lib/Response/CompleteUploadResponse.php', + $nextcloudDir . '/apps/files/lib/Response/MismatchingOffsetResponse.php', + $nextcloudDir . '/apps/files/tests/Controller/ResumableUploadControllerTest.php', ]) ->withPreparedSets( deadCode: true, diff --git a/openapi.json b/openapi.json index 93b4fbdf9bee6..e7e77387080d2 100644 --- a/openapi.json +++ b/openapi.json @@ -49,6 +49,10 @@ "name": "federation/ocs_authapi", "description": "Class OCSAuthAPI OCS API end-points to exchange shared secret between two connected Nextclouds" }, + { + "name": "files/resumable_upload", + "description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI." + }, { "name": "theming/theming", "description": "Class ThemingController handle ajax requests to update the theme" @@ -1664,6 +1668,7 @@ "forbidden_filename_extensions", "chunked_upload", "file_conversions", + "resumable_upload", "directEditing" ], "properties": { @@ -1750,6 +1755,21 @@ } } }, + "resumable_upload": { + "type": "object", + "required": [ + "supported", + "interop_version" + ], + "properties": { + "supported": { + "type": "boolean" + }, + "interop_version": { + "type": "string" + } + } + }, "directEditing": { "type": "object", "required": [ diff --git a/psalm-strict.xml b/psalm-strict.xml index a11451d3b9522..c11db1d632b62 100644 --- a/psalm-strict.xml +++ b/psalm-strict.xml @@ -16,11 +16,20 @@ phpVersion="8.2" > + + + + + + + + + From de502ca3d0d763a24e894b2f23cd744684a87a1b Mon Sep 17 00:00:00 2001 From: provokateurin Date: Tue, 10 Dec 2024 12:54:43 +0100 Subject: [PATCH 2/2] feat(files): Add custom endpoint for finishing resumable upload Signed-off-by: provokateurin --- .../Controller/ResumableUploadController.php | 74 +++- apps/files/openapi.json | 101 +++++- .../ResumableUploadControllerTest.php | 319 +++++++++++++++++- openapi.json | 101 +++++- 4 files changed, 591 insertions(+), 4 deletions(-) diff --git a/apps/files/lib/Controller/ResumableUploadController.php b/apps/files/lib/Controller/ResumableUploadController.php index a0fdea435ad7c..c4b6ed6e6b5d2 100644 --- a/apps/files/lib/Controller/ResumableUploadController.php +++ b/apps/files/lib/Controller/ResumableUploadController.php @@ -20,12 +20,15 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Response; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; use OCP\IRequest; use OCP\IURLGenerator; +use OCP\Server; /** * Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 - * All functionality described by the draft RFC is excluded from OpenAPI. + * All functionality described by the draft RFC is excluded from OpenAPI, only the custom endpoint to finish the upload is included. */ class ResumableUploadController extends Controller { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4.2-2 @@ -385,4 +388,73 @@ public function deleteResource(string $token): Response { // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-7-4 return new Response(Http::STATUS_NO_CONTENT, self::BASE_HEADERS); } + + /** + * Finish the upload. + * + * @param string $token The token of the upload + * @param string $path The final path where the file will be moved to + * @param int $createdTimestamp The unix timestamp of when the file was created + * @param int $lastModifiedTimestamp The unix timestamp of when the file was last modified + * @param bool $overwrite Whether an existing file should be overwritten + * @return Response + * + * 204: Upload finished successfully + * 400: Upload not complete + * 401: User is unauthorized + * 404: Upload not found + * 409: File already exists + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'POST', url: '/upload/{token}/finish')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function finishUpload( + string $token, + string $path, + int $createdTimestamp, + int $lastModifiedTimestamp, + bool $overwrite = false, + ): Response { + if ($this->userId === null) { + return new Response(Http::STATUS_UNAUTHORIZED); // @codeCoverageIgnore + } + + $upload = $this->mapper->findByToken($this->userId, $token); + if (!$upload instanceof ResumableUpload) { + return new Response(Http::STATUS_NOT_FOUND); + } + + if (!$upload->getComplete()) { + return new Response(Http::STATUS_BAD_REQUEST); + } + + $userFolder = Server::get(IRootFolder::class)->getUserFolder($this->userId); + + if ($userFolder->nodeExists($path)) { + if (!$overwrite) { + return new Response(Http::STATUS_CONFLICT); + } + + $userFolder->get($path)->delete(); + } + + $tmpFileHandle = fopen($upload->getPath(), 'rb'); + $outFile = $userFolder->newFile($path); + + $outFile->putContent($tmpFileHandle); + + $userFolder->getStorage()->getCache()->put($outFile->getInternalPath(), [ + 'creation_time' => $createdTimestamp, + 'upload_time' => time(), + 'mtime' => $lastModifiedTimestamp, + // TODO: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#name-upload-metadata + 'mimetype' => Server::get(IMimeTypeDetector::class)->detectPath($path), + ]); + + unlink($upload->getPath()); + $this->mapper->delete($upload); + + return new Response(Http::STATUS_NO_CONTENT); + } } diff --git a/apps/files/openapi.json b/apps/files/openapi.json index 5c78b30ffa640..851b7b04b8025 100644 --- a/apps/files/openapi.json +++ b/apps/files/openapi.json @@ -569,6 +569,105 @@ } } }, + "/index.php/apps/files/upload/{token}/finish": { + "post": { + "operationId": "resumable_upload-finish-upload", + "summary": "Finish the upload.", + "tags": [ + "resumable_upload" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "path", + "createdTimestamp", + "lastModifiedTimestamp" + ], + "properties": { + "path": { + "type": "string", + "description": "The final path where the file will be moved to" + }, + "createdTimestamp": { + "type": "integer", + "format": "int64", + "description": "The unix timestamp of when the file was created" + }, + "lastModifiedTimestamp": { + "type": "integer", + "format": "int64", + "description": "The unix timestamp of when the file was last modified" + }, + "overwrite": { + "type": "boolean", + "default": false, + "description": "Whether an existing file should be overwritten" + } + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The token of the upload", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Upload finished successfully" + }, + "400": { + "description": "Upload not complete" + }, + "401": { + "description": "User is unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Upload not found" + }, + "409": { + "description": "File already exists" + }, + "500": { + "description": "" + } + } + } + }, "/ocs/v2.php/apps/files/api/v1/directEditing": { "get": { "operationId": "direct_editing-info", @@ -2907,7 +3006,7 @@ "tags": [ { "name": "resumable_upload", - "description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI." + "description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI, only the custom endpoint to finish the upload is included." } ] } diff --git a/apps/files/tests/Controller/ResumableUploadControllerTest.php b/apps/files/tests/Controller/ResumableUploadControllerTest.php index 9b1a783e78dd1..a7df0f439f3bc 100644 --- a/apps/files/tests/Controller/ResumableUploadControllerTest.php +++ b/apps/files/tests/Controller/ResumableUploadControllerTest.php @@ -13,15 +13,29 @@ use OCA\Files\Response\AProblemResponse; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Response; +use OCP\Files\File; +use OCP\Files\IRootFolder; use OCP\IRequest; use OCP\IURLGenerator; use OCP\Server; use Test\TestCase; +use Test\Traits\UserTrait; /** * @group DB */ class ResumableUploadControllerTest extends TestCase { + use UserTrait; + + private const username = 'user'; + + public function setUp(): void { + parent::setUp(); + + $this->createUser(self::username, ''); + self::loginAsUser(self::username); + } + /** * @psalm-param Callable(ResumableUploadController):Response $method * @param array $requestHeaders @@ -50,7 +64,7 @@ private function performRequest( $controller = new ResumableUploadController( 'files', $request, - 'user', + self::username, Server::get(IURLGenerator::class), Server::get(ResumableUploadMapper::class), $inputHandle, @@ -79,6 +93,7 @@ private function performRequest( unset( $responseHeaders[ResumableUploadController::HTTP_HEADER_LOCATION], $headers[ResumableUploadController::HTTP_HEADER_LOCATION], + $headers['X-User-Id'], ); $this->assertEquals($responseStatusCode, $response->getStatus()); @@ -1131,4 +1146,306 @@ public function testDeleteNonExistent(): void { fn (ResumableUploadController $controller): Response => $controller->deleteResource('404'), ); } + + public function testFinish(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '9', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '3', + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'def', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 6, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 6, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => '6', + ResumableUploadController::HTTP_HEADER_CONTENT_TYPE => ResumableUploadController::MEDIA_TYPE_PARTIAL_UPLOAD, + ], + 'ghi', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 9, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->appendResource($token), + ); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 9, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $start = time(); + $this->performRequest( + [], + '', + Http::STATUS_NO_CONTENT, + [], + '', + fn (ResumableUploadController $controller): Response => $controller->finishUpload($token, '/test.txt', 123, 456), + ); + $end = time(); + + $userFolder = Server::get(IRootFolder::class)->getUserFolder('user'); + + /** @var File $file */ + $file = $userFolder->get('test.txt'); + $this->assertEquals('abcdefghi', $file->getContent()); + $cacheEntry = $userFolder->getStorage()->getCache()->get($file->getInternalPath()); + $this->assertNotFalse($cacheEntry); + $this->assertEquals('files/test.txt', $cacheEntry->getPath()); + $this->assertEquals(9, $cacheEntry->getSize()); + $this->assertEquals(123, $cacheEntry->getCreationTime()); + $this->assertEquals(456, $cacheEntry->getMTime()); + $this->assertGreaterThanOrEqual($start, $cacheEntry->getUploadTime()); + $this->assertLessThanOrEqual($end, $cacheEntry->getUploadTime()); + $this->assertEquals('text/plain', $cacheEntry->getMimetype()); + } + + public function testFinishIncomplete(): void { + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '9', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '0', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 9, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [], + '', + Http::STATUS_BAD_REQUEST, + [], + '', + fn (ResumableUploadController $controller): Response => $controller->finishUpload($token, '/test.txt', 123, 456), + ); + } + + public function testFinishExistingFile(): void { + $userFolder = Server::get(IRootFolder::class)->getUserFolder('user'); + + $this->assertEquals(1, $userFolder->newFile('test.txt', 'z')->getSize()); + + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '3', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 3, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $this->performRequest( + [], + '', + Http::STATUS_CONFLICT, + [], + '', + fn (ResumableUploadController $controller): Response => $controller->finishUpload($token, '/test.txt', 123, 456), + ); + } + + public function testFinishExistingFileOverwrite(): void { + $userFolder = Server::get(IRootFolder::class)->getUserFolder('user'); + + $this->assertEquals(1, $userFolder->newFile('test.txt', 'z')->getSize()); + + $response = $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => '3', + ], + 'abc', + Http::STATUS_CREATED, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_LOCATION => true, + ], + '', + fn (ResumableUploadController $controller): Response => $controller->createResource(), + ); + $token = $this->getTokenFromResponse($response); + $this->performRequest( + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ], + '', + Http::STATUS_NO_CONTENT, + [ + ResumableUploadController::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => ResumableUploadController::UPLOAD_DRAFT_INTEROP_VERSION, + ResumableUploadController::HTTP_HEADER_UPLOAD_COMPLETE => '1', + ResumableUploadController::HTTP_HEADER_UPLOAD_OFFSET => 3, + ResumableUploadController::HTTP_HEADER_UPLOAD_LENGTH => 3, + ResumableUploadController::HTTP_HEADER_CACHE_CONTROL => 'no-store', + ], + '', + fn (ResumableUploadController $controller): Response => $controller->checkResource($token), + ); + + $start = time(); + $this->performRequest( + [], + '', + Http::STATUS_NO_CONTENT, + [], + '', + fn (ResumableUploadController $controller): Response => $controller->finishUpload($token, '/test.txt', 123, 456, true), + ); + $end = time(); + + /** @var File $file */ + $file = $userFolder->get('test.txt'); + $this->assertEquals('abc', $file->getContent()); + $cacheEntry = $userFolder->getStorage()->getCache()->get($file->getInternalPath()); + $this->assertNotFalse($cacheEntry); + $this->assertEquals('files/test.txt', $cacheEntry->getPath()); + $this->assertEquals(3, $cacheEntry->getSize()); + $this->assertEquals(123, $cacheEntry->getCreationTime()); + $this->assertEquals(456, $cacheEntry->getMTime()); + $this->assertGreaterThanOrEqual($start, $cacheEntry->getUploadTime()); + $this->assertLessThanOrEqual($end, $cacheEntry->getUploadTime()); + $this->assertEquals('text/plain', $cacheEntry->getMimetype()); + } + + public function testFinishNonExistent(): void { + $this->performRequest( + [], + '', + Http::STATUS_NOT_FOUND, + [], + '', + fn (ResumableUploadController $controller): Response => $controller->finishUpload('404', '/test.txt', 123, 456), + ); + } } diff --git a/openapi.json b/openapi.json index e7e77387080d2..8cac2eab208c4 100644 --- a/openapi.json +++ b/openapi.json @@ -51,7 +51,7 @@ }, { "name": "files/resumable_upload", - "description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI." + "description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI, only the custom endpoint to finish the upload is included." }, { "name": "theming/theming", @@ -20980,6 +20980,105 @@ } } }, + "/index.php/apps/files/upload/{token}/finish": { + "post": { + "operationId": "files-resumable_upload-finish-upload", + "summary": "Finish the upload.", + "tags": [ + "files/resumable_upload" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "path", + "createdTimestamp", + "lastModifiedTimestamp" + ], + "properties": { + "path": { + "type": "string", + "description": "The final path where the file will be moved to" + }, + "createdTimestamp": { + "type": "integer", + "format": "int64", + "description": "The unix timestamp of when the file was created" + }, + "lastModifiedTimestamp": { + "type": "integer", + "format": "int64", + "description": "The unix timestamp of when the file was last modified" + }, + "overwrite": { + "type": "boolean", + "default": false, + "description": "Whether an existing file should be overwritten" + } + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The token of the upload", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Upload finished successfully" + }, + "400": { + "description": "Upload not complete" + }, + "401": { + "description": "User is unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Upload not found" + }, + "409": { + "description": "File already exists" + }, + "500": { + "description": "" + } + } + } + }, "/ocs/v2.php/apps/files/api/v1/directEditing": { "get": { "operationId": "files-direct_editing-info",