Compare commits
2 Commits
jtr/docs-c
...
feat/files
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de502ca3d0 | ||
|
|
01e3edb572 |
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
460
apps/files/lib/Controller/ResumableUploadController.php
Normal file
460
apps/files/lib/Controller/ResumableUploadController.php
Normal file
@@ -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
apps/files/lib/Db/ResumableUpload.php
Normal file
48
apps/files/lib/Db/ResumableUpload.php
Normal 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');
|
||||
}
|
||||
}
|
||||
43
apps/files/lib/Db/ResumableUploadMapper.php
Normal file
43
apps/files/lib/Db/ResumableUploadMapper.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
60
apps/files/lib/Migration/Version2003Date20241126094807.php
Normal file
60
apps/files/lib/Migration/Version2003Date20241126094807.php
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
46
apps/files/lib/Response/AProblemResponse.php
Normal file
46
apps/files/lib/Response/AProblemResponse.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
32
apps/files/lib/Response/CompleteUploadResponse.php
Normal file
32
apps/files/lib/Response/CompleteUploadResponse.php
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
41
apps/files/lib/Response/MismatchingOffsetResponse.php
Normal file
41
apps/files/lib/Response/MismatchingOffsetResponse.php
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1451
apps/files/tests/Controller/ResumableUploadControllerTest.php
Normal file
1451
apps/files/tests/Controller/ResumableUploadControllerTest.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
openapi.json
119
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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user