feat(files): Add custom endpoint for finishing resumable upload

Signed-off-by: provokateurin <kate@provokateurin.de>
This commit is contained in:
provokateurin 2024-12-10 12:54:43 +01:00
parent 01e3edb572
commit de502ca3d0
No known key found for this signature in database
4 changed files with 591 additions and 4 deletions

View file

@ -20,12 +20,15 @@ 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.
* All functionality described by the draft RFC is excluded from OpenAPI, only the custom endpoint to finish the upload is included.
*/
class ResumableUploadController extends Controller {
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05#section-4.2-2
@ -385,4 +388,73 @@ class ResumableUploadController extends Controller {
// 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);
}
}

View file

@ -569,6 +569,105 @@
}
}
},
"/index.php/apps/files/upload/{token}/finish": {
"post": {
"operationId": "resumable_upload-finish-upload",
"summary": "Finish the upload.",
"tags": [
"resumable_upload"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"path",
"createdTimestamp",
"lastModifiedTimestamp"
],
"properties": {
"path": {
"type": "string",
"description": "The final path where the file will be moved to"
},
"createdTimestamp": {
"type": "integer",
"format": "int64",
"description": "The unix timestamp of when the file was created"
},
"lastModifiedTimestamp": {
"type": "integer",
"format": "int64",
"description": "The unix timestamp of when the file was last modified"
},
"overwrite": {
"type": "boolean",
"default": false,
"description": "Whether an existing file should be overwritten"
}
}
}
}
}
},
"parameters": [
{
"name": "token",
"in": "path",
"description": "The token of the upload",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Upload finished successfully"
},
"400": {
"description": "Upload not complete"
},
"401": {
"description": "User is unauthorized",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"404": {
"description": "Upload not found"
},
"409": {
"description": "File already exists"
},
"500": {
"description": ""
}
}
}
},
"/ocs/v2.php/apps/files/api/v1/directEditing": {
"get": {
"operationId": "direct_editing-info",
@ -2907,7 +3006,7 @@
"tags": [
{
"name": "resumable_upload",
"description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI."
"description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI, only the custom endpoint to finish the upload is included."
}
]
}

View file

@ -13,15 +13,29 @@ use OCA\Files\Db\ResumableUploadMapper;
use OCA\Files\Response\AProblemResponse;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\Server;
use Test\TestCase;
use Test\Traits\UserTrait;
/**
* @group DB
*/
class ResumableUploadControllerTest extends TestCase {
use UserTrait;
private const username = 'user';
public function setUp(): void {
parent::setUp();
$this->createUser(self::username, '');
self::loginAsUser(self::username);
}
/**
* @psalm-param Callable(ResumableUploadController):Response $method
* @param array<string, string> $requestHeaders
@ -50,7 +64,7 @@ class ResumableUploadControllerTest extends TestCase {
$controller = new ResumableUploadController(
'files',
$request,
'user',
self::username,
Server::get(IURLGenerator::class),
Server::get(ResumableUploadMapper::class),
$inputHandle,
@ -79,6 +93,7 @@ class ResumableUploadControllerTest extends TestCase {
unset(
$responseHeaders[ResumableUploadController::HTTP_HEADER_LOCATION],
$headers[ResumableUploadController::HTTP_HEADER_LOCATION],
$headers['X-User-Id'],
);
$this->assertEquals($responseStatusCode, $response->getStatus());
@ -1131,4 +1146,306 @@ class ResumableUploadControllerTest extends TestCase {
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),
);
}
}

View file

@ -51,7 +51,7 @@
},
{
"name": "files/resumable_upload",
"description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI."
"description": "Implementation of https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-resumable-upload-05 All functionality described by the draft RFC is excluded from OpenAPI, only the custom endpoint to finish the upload is included."
},
{
"name": "theming/theming",
@ -20980,6 +20980,105 @@
}
}
},
"/index.php/apps/files/upload/{token}/finish": {
"post": {
"operationId": "files-resumable_upload-finish-upload",
"summary": "Finish the upload.",
"tags": [
"files/resumable_upload"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"path",
"createdTimestamp",
"lastModifiedTimestamp"
],
"properties": {
"path": {
"type": "string",
"description": "The final path where the file will be moved to"
},
"createdTimestamp": {
"type": "integer",
"format": "int64",
"description": "The unix timestamp of when the file was created"
},
"lastModifiedTimestamp": {
"type": "integer",
"format": "int64",
"description": "The unix timestamp of when the file was last modified"
},
"overwrite": {
"type": "boolean",
"default": false,
"description": "Whether an existing file should be overwritten"
}
}
}
}
}
},
"parameters": [
{
"name": "token",
"in": "path",
"description": "The token of the upload",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Upload finished successfully"
},
"400": {
"description": "Upload not complete"
},
"401": {
"description": "User is unauthorized",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
}
}
}
},
"404": {
"description": "Upload not found"
},
"409": {
"description": "File already exists"
},
"500": {
"description": ""
}
}
}
},
"/ocs/v2.php/apps/files/api/v1/directEditing": {
"get": {
"operationId": "files-direct_editing-info",