Compare commits

...

2 Commits

Author SHA1 Message Date
provokateurin de502ca3d0 feat(files): Add custom endpoint for finishing resumable upload
Signed-off-by: provokateurin <kate@provokateurin.de>
2026-02-03 15:20:42 +01:00
provokateurin 01e3edb572 feat(files): Implement resumable upload draft RFC
Signed-off-by: provokateurin <kate@provokateurin.de>
2026-02-03 14:59:55 +01:00
16 changed files with 2484 additions and 5 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<name>Files</name>
<summary>File Management</summary>
<description>File Management</description>
<version>2.6.0</version>
<version>2.6.1</version>
<licence>agpl</licence>
<author>John Molakvoæ</author>
<author>Robin Appelman</author>
@@ -59,12 +59,15 @@ return array(
'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 @@ return array(
'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',
@@ -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',
+31 -2
View File
@@ -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 @@ class Capabilities implements ICapability {
/**
* Return this classes capabilities
*
* @return array{files: array{'$comment': ?string, bigfilechunking: bool, blacklisted_files: list<mixed>, forbidden_filenames: list<string>, forbidden_filename_basenames: list<string>, forbidden_filename_characters: list<string>, forbidden_filename_extensions: list<string>, chunked_upload: array{max_size: int, max_parallel_count: int}, file_conversions: list<array{from: string, to: string, extension: string, displayName: string}>}}
* @return array{
* files: array{
* '$comment': ?string,
* bigfilechunking: bool,
* blacklisted_files: list<mixed>,
* forbidden_filenames: list<string>,
* forbidden_filename_basenames: list<string>,
* forbidden_filename_characters: list<string>,
* forbidden_filename_extensions: list<string>,
* chunked_upload: array{
* max_size: int,
* max_parallel_count: int,
* },
* file_conversions: list<array{
* from: string,
* to: string,
* extension: string,
* displayName: string,
* }>,
* resumable_upload: array{
* supported: bool,
* interop_version: string,
* }
* }
* }
*/
public function getCapabilities(): array {
return [
@@ -41,10 +66,14 @@ class Capabilities implements ICapability {
'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,
],
],
];
}
@@ -0,0 +1,460 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Controller;
use OCA\Files\Db\ResumableUpload;
use OCA\Files\Db\ResumableUploadMapper;
use OCA\Files\Response\CompleteUploadResponse;
use OCA\Files\Response\MismatchingOffsetResponse;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
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, 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
public const UPLOAD_DRAFT_INTEROP_VERSION = '6';
public const MEDIA_TYPE_PARTIAL_UPLOAD = 'application/partial-upload';
public const HTTP_HEADER_LOCATION = 'Location';
public const HTTP_HEADER_CONTENT_LENGTH = 'Content-Length';
public const HTTP_HEADER_CONTENT_TYPE = 'Content-Type';
public const HTTP_HEADER_CACHE_CONTROL = 'Cache-Control';
public const HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION = 'Upload-Draft-Interop-Version';
public const HTTP_HEADER_UPLOAD_COMPLETE = 'Upload-Complete';
public const HTTP_HEADER_UPLOAD_OFFSET = 'Upload-Offset';
public const HTTP_HEADER_UPLOAD_LENGTH = 'Upload-Length';
private const BASE_HEADERS = [
self::HTTP_HEADER_UPLOAD_DRAFT_INTEROP_VERSION => 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<Http::STATUS_NO_CONTENT|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_CONFLICT|Http::STATUS_INTERNAL_SERVER_ERROR, array{}>
*
* 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);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method void setId(int $id)
* @method int getId()
* @method void setUserId(string $userId)
* @method string getUserId()
* @method void setToken(string $token)
* @method string getToken()
* @method void setPath(string $path)
* @method string getPath()
* @method void setSize(int|null $size)
* @method int|null getSize()
* @method void setComplete(bool|null $complete)
* @method bool|null getComplete()
*/
class ResumableUpload extends Entity {
public $id;
protected string $userId = '';
protected string $token = '';
protected string $path = '';
protected ?int $size = null;
protected ?bool $complete = false;
public function __construct() {
$this->addType('userId', 'string');
$this->addType('token', 'string');
$this->addType('path', 'string');
$this->addType('size', 'integer');
$this->addType('complete', 'boolean');
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<ResumableUpload>
*/
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);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Migration;
use Closure;
use Doctrine\DBAL\Schema\SchemaException;
use OCA\Files\Db\ResumableUploadMapper;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version2003Date20241126094807 extends SimpleMigrationStep {
/**
* @param Closure(): ISchemaWrapper $schemaClosure
* @throws SchemaException
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
if (!$schema->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;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Response;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
/**
* @template S of Http::STATUS_*
* @template H of array<string, mixed>
* @template-extends Response<Http::STATUS_*, array<string, mixed>>
*/
abstract class AProblemResponse extends Response {
public const MEDIA_TYPE_PROBLEM_JSON = 'application/problem+json';
/**
* @param array<string, mixed> $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);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Response;
use OCP\AppFramework\Http;
/**
* @template H of array<string, mixed>
* @template-extends AProblemResponse<Http::STATUS_BAD_REQUEST, array<string, mixed>>
*/
// 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,
);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files\Response;
use OCP\AppFramework\Http;
/**
* @template H of array<string, mixed>
* @template-extends AProblemResponse<Http::STATUS_CONFLICT, array<string, mixed>>
*/
// 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,
);
}
}
+121 -1
View File
@@ -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."
}
]
}
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -10,7 +10,14 @@ $nextcloudDir = dirname(__DIR__);
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,
+119
View File
@@ -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",
+9
View File
@@ -16,11 +16,20 @@
phpVersion="8.2"
>
<projectFiles>
<file name="apps/files/lib/Controller/ResumableUploadController.php"/>
<file name="apps/files/lib/Db/ResumableUpload.php"/>
<file name="apps/files/lib/Db/ResumableUploadMapper.php"/>
<file name="apps/files/lib/Migration/Version2003Date20241126094807.php"/>
<file name="apps/files/lib/Response/AProblemResponse.php"/>
<file name="apps/files/lib/Response/CompleteUploadResponse.php"/>
<file name="apps/files/lib/Response/MismatchingOffsetResponse.php"/>
<file name="apps/files/tests/Controller/ResumableUploadControllerTest.php"/>
<ignoreFiles>
<directory name="apps/**/composer"/>
<directory name="apps/**/tests"/>
<directory name="lib/composer"/>
<directory name="lib/l10n"/>
<directory name="lib/public"/>
<directory name="3rdparty"/>
</ignoreFiles>
</projectFiles>