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..c4b6ed6e6b5d2
--- /dev/null
+++ b/apps/files/lib/Controller/ResumableUploadController.php
@@ -0,0 +1,460 @@
+ 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);
+ }
+
+ /**
+ * 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/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..851b7b04b8025 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": [
@@ -553,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",
@@ -2888,5 +3003,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, 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
new file mode 100644
index 0000000000000..a7df0f439f3bc
--- /dev/null
+++ b/apps/files/tests/Controller/ResumableUploadControllerTest.php
@@ -0,0 +1,1451 @@
+createUser(self::username, '');
+ self::loginAsUser(self::username);
+ }
+
+ /**
+ * @psalm-param Callable(ResumableUploadController):Response $method
+ * @param array $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,
+ self::username,
+ 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],
+ $headers['X-User-Id'],
+ );
+
+ $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'),
+ );
+ }
+
+ 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/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..8cac2eab208c4 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, only the custom endpoint to finish the upload is included."
+ },
{
"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": [
@@ -20960,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",
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"
>
+
+
+
+
+
+
+
+
+