diff options
Diffstat (limited to 'apps/dav')
18 files changed, 665 insertions, 2543 deletions
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index e7e29d85d89..1a536c98272 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -22,9 +22,8 @@ return array( 'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => $baseDir . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php', 'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => $baseDir . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php', 'OCA\\DAV\\BackgroundJob\\UploadCleanup' => $baseDir . '/../lib/BackgroundJob/UploadCleanup.php', - 'OCA\\DAV\\BundleUpload\\BundledFile' => $baseDir . '/../lib/BundleUpload/BundledFile.php', - 'OCA\\DAV\\BundleUpload\\BundlingPlugin' => $baseDir . '/../lib/BundleUpload/BundlingPlugin.php', - 'OCA\\DAV\\BundleUpload\\MultipartContentsParser' => $baseDir . '/../lib/BundleUpload/MultipartContentsParser.php', + 'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => $baseDir . '/../lib/BulkUpload/BulkUploadPlugin.php', + 'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => $baseDir . '/../lib/BulkUpload/MultipartRequestParser.php', 'OCA\\DAV\\CalDAV\\Activity\\Backend' => $baseDir . '/../lib/CalDAV/Activity/Backend.php', 'OCA\\DAV\\CalDAV\\Activity\\Filter\\Calendar' => $baseDir . '/../lib/CalDAV/Activity/Filter/Calendar.php', 'OCA\\DAV\\CalDAV\\Activity\\Filter\\Todo' => $baseDir . '/../lib/CalDAV/Activity/Filter/Todo.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index ee23085eee5..b65d4477800 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -37,9 +37,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => __DIR__ . '/..' . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php', 'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php', 'OCA\\DAV\\BackgroundJob\\UploadCleanup' => __DIR__ . '/..' . '/../lib/BackgroundJob/UploadCleanup.php', - 'OCA\\DAV\\BundleUpload\\BundledFile' => __DIR__ . '/..' . '/../lib/BundleUpload/BundledFile.php', - 'OCA\\DAV\\BundleUpload\\BundlingPlugin' => __DIR__ . '/..' . '/../lib/BundleUpload/BundlingPlugin.php', - 'OCA\\DAV\\BundleUpload\\MultipartContentsParser' => __DIR__ . '/..' . '/../lib/BundleUpload/MultipartContentsParser.php', + 'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => __DIR__ . '/..' . '/../lib/BulkUpload/BulkUploadPlugin.php', + 'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => __DIR__ . '/..' . '/../lib/BulkUpload/MultipartRequestParser.php', 'OCA\\DAV\\CalDAV\\Activity\\Backend' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Backend.php', 'OCA\\DAV\\CalDAV\\Activity\\Filter\\Calendar' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Filter/Calendar.php', 'OCA\\DAV\\CalDAV\\Activity\\Filter\\Todo' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Filter/Todo.php', diff --git a/apps/dav/lib/BulkUpload/BulkUploadPlugin.php b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php new file mode 100644 index 00000000000..0766ae37a17 --- /dev/null +++ b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php @@ -0,0 +1,100 @@ +<?php +/** + * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ + +namespace OCA\DAV\BulkUpload; + +use Psr\Log\LoggerInterface; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use OCP\Files\Folder; +use OCP\AppFramework\Http; + +class BulkUploadPlugin extends ServerPlugin { + + /** @var Folder */ + private $userFolder; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(Folder $userFolder, LoggerInterface $logger) { + $this->userFolder = $userFolder; + $this->logger = $logger; + } + + /** + * Register listener on POST requests with the httpPost method. + */ + public function initialize(Server $server): void { + $server->on('method:POST', [$this, 'httpPost'], 10); + } + + /** + * Handle POST requests on /dav/bulk + * - parsing is done with a MultipartContentsParser object + * - writing is done with the userFolder service + * + * Will respond with an object containing an ETag for every written files. + */ + public function httpPost(RequestInterface $request, ResponseInterface $response): bool { + // Limit bulk upload to the /dav/bulk endpoint + if ($request->getPath() !== "bulk") { + return true; + } + + $multiPartParser = new MultipartRequestParser($request); + $writtenFiles = []; + + while (!$multiPartParser->isAtLastBoundary()) { + try { + [$headers, $content] = $multiPartParser->parseNextPart(); + } catch (\Exception $e) { + // Return early if an error occurs during parsing. + $this->logger->error($e->getMessage()); + $response->setStatus(Http::STATUS_BAD_REQUEST); + $response->setBody(json_encode($writtenFiles)); + return false; + } + + try { + $node = $this->userFolder->newFile($headers['x-file-path'], $content); + $writtenFiles[$headers['x-file-path']] = [ + "error" => false, + "etag" => $node->getETag(), + ]; + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['path' => $headers['x-file-path']]); + $writtenFiles[$headers['x-file-path']] = [ + "error" => true, + "message" => $e->getMessage(), + ]; + } + } + + $response->setStatus(Http::STATUS_OK); + $response->setBody(json_encode($writtenFiles)); + + return false; + } +} diff --git a/apps/dav/lib/BulkUpload/MultipartRequestParser.php b/apps/dav/lib/BulkUpload/MultipartRequestParser.php new file mode 100644 index 00000000000..7554447fc93 --- /dev/null +++ b/apps/dav/lib/BulkUpload/MultipartRequestParser.php @@ -0,0 +1,239 @@ +<?php +/** + * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ + +namespace OCA\DAV\BulkUpload; + +use Sabre\HTTP\RequestInterface; +use Sabre\DAV\Exception; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\LengthRequired; +use OCP\AppFramework\Http; + +class MultipartRequestParser { + + /** @var resource */ + private $stream; + + /** @var string */ + private $boundary = ""; + + /** @var string */ + private $lastBoundary = ""; + + /** + * @throws BadRequest + */ + public function __construct(RequestInterface $request) { + $stream = $request->getBody(); + $contentType = $request->getHeader('Content-Type'); + + if (!is_resource($stream)) { + throw new BadRequest('Body should be of type resource'); + } + + if ($contentType === null) { + throw new BadRequest("Content-Type can not be null"); + } + + $this->stream = $stream; + + $boundary = $this->parseBoundaryFromHeaders($contentType); + $this->boundary = '--'.$boundary."\r\n"; + $this->lastBoundary = '--'.$boundary."--\r\n"; + } + + /** + * Parse the boundary from the Content-Type header. + * Example: Content-Type: "multipart/related; boundary=boundary_bf38b9b4b10a303a28ed075624db3978" + * + * @throws BadRequest + */ + private function parseBoundaryFromHeaders(string $contentType): string { + try { + [$mimeType, $boundary] = explode(';', $contentType); + [$boundaryKey, $boundaryValue] = explode('=', $boundary); + } catch (\Exception $e) { + throw new BadRequest("Error while parsing boundary in Content-Type header.", Http::STATUS_BAD_REQUEST, $e); + } + + $boundaryValue = trim($boundaryValue); + + // Remove potential quotes around boundary value. + if (substr($boundaryValue, 0, 1) == '"' && substr($boundaryValue, -1) == '"') { + $boundaryValue = substr($boundaryValue, 1, -1); + } + + if (trim($mimeType) !== 'multipart/related') { + throw new BadRequest('Content-Type must be multipart/related'); + } + + if (trim($boundaryKey) !== 'boundary') { + throw new BadRequest('Boundary is invalid'); + } + + return $boundaryValue; + } + + /** + * Check whether the stream's cursor is sitting right before the provided string. + * + * @throws Exception + */ + private function isAt(string $expectedContent): bool { + $expectedContentLength = strlen($expectedContent); + + $content = fread($this->stream, $expectedContentLength); + if ($content === false) { + throw new Exception('An error occurred while checking content'); + } + + $seekBackResult = fseek($this->stream, -$expectedContentLength, SEEK_CUR); + if ($seekBackResult === -1) { + throw new Exception("Unknown error while seeking content", Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return $expectedContent === $content; + } + + + /** + * Check whether the stream's cursor is sitting right before the boundary. + */ + private function isAtBoundary(): bool { + return $this->isAt($this->boundary); + } + + /** + * Check whether the stream's cursor is sitting right before the last boundary. + */ + public function isAtLastBoundary(): bool { + return $this->isAt($this->lastBoundary); + } + + /** + * Parse and return the next part of the multipart headers. + * + * Example: + * --boundary_azertyuiop + * Header1: value + * Header2: value + * + * Content of + * the part + * + */ + public function parseNextPart(): array { + $this->readBoundary(); + + $headers = $this->readPartHeaders(); + + $content = $this->readPartContent($headers["content-length"], $headers["x-file-md5"]); + + return [$headers, $content]; + } + + /** + * Read the boundary and check its content. + * + * @throws BadRequest + */ + private function readBoundary(): string { + if (!$this->isAtBoundary()) { + throw new BadRequest("Boundary not found where it should be."); + } + + return fread($this->stream, strlen($this->boundary)); + } + + /** + * Return the headers of a part of the multipart body. + * + * @throws Exception + * @throws BadRequest + * @throws LengthRequired + */ + private function readPartHeaders(): array { + $headers = []; + + while (($line = fgets($this->stream)) !== "\r\n") { + if ($line === false) { + throw new Exception('An error occurred while reading headers of a part'); + } + + try { + [$key, $value] = explode(':', $line, 2); + $headers[strtolower(trim($key))] = trim($value); + } catch (\Exception $e) { + throw new BadRequest('An error occurred while parsing headers of a part', Http::STATUS_BAD_REQUEST, $e); + } + } + + if (!isset($headers["content-length"])) { + throw new LengthRequired("The Content-Length header must not be null."); + } + + if (!isset($headers["x-file-md5"])) { + throw new BadRequest("The X-File-MD5 header must not be null."); + } + + return $headers; + } + + /** + * Return the content of a part of the multipart body. + * + * @throws Exception + * @throws BadRequest + */ + private function readPartContent(int $length, string $md5): string { + $computedMd5 = $this->computeMd5Hash($length); + + if ($md5 !== $computedMd5) { + throw new BadRequest("Computed md5 hash is incorrect."); + } + + $content = stream_get_line($this->stream, $length); + + if ($content === false) { + throw new Exception("Fail to read part's content."); + } + + if (feof($this->stream)) { + throw new Exception("Unexpected EOF while reading stream."); + } + + // Read '\r\n'. + stream_get_contents($this->stream, 2); + + return $content; + } + + /** + * Compute the MD5 hash of the next x bytes. + */ + private function computeMd5Hash(int $length): string { + $context = hash_init('md5'); + hash_update_stream($context, $this->stream, $length); + fseek($this->stream, -$length, SEEK_CUR); + return hash_final($context); + } +} diff --git a/apps/dav/lib/BundleUpload/BundledFile.php b/apps/dav/lib/BundleUpload/BundledFile.php deleted file mode 100644 index db9b5bbd3fe..00000000000 --- a/apps/dav/lib/BundleUpload/BundledFile.php +++ /dev/null @@ -1,214 +0,0 @@ -<?php -/** - * @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com> - * @author Louis Chemineau <louis@chmn.me> - * - * @copyright Copyright (c) 2016, ownCloud GmbH. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\DAV\BundleUpload; - -use OCA\DAV\Connector\Sabre\Exception\FileLocked; -use OCP\Files\StorageNotAvailableException; -use OCP\Lock\ILockingProvider; -use OCP\Lock\LockedException; -use Sabre\DAV\Exception; -use Sabre\DAV\Exception\Forbidden; -use Sabre\DAV\Exception\ServiceUnavailable; -use OCA\DAV\Connector\Sabre\File; -use OCA\DAV\Connector\Sabre\Exception\EntityTooLarge; -use OCA\DAV\Connector\Sabre\Exception\Forbidden as DAVForbiddenException; -use OCA\DAV\Connector\Sabre\Exception\UnsupportedMediaType; -use OCP\Files\ForbiddenException; -use Sabre\DAV\Exception\BadRequest; - -class BundledFile extends File { - - - /** - * This class is a wrapper around the bundled request body and provides access to its contents - * - * @var \OCA\DAV\BundleUpload\MultipartContentsParser - * - */ - private $contentHandler; - - public function __construct($view, $info, $contentHandler){ - $this->contentHandler = $contentHandler; - parent::__construct($view, $info); - } - /** - * Updates the data - * - * The $data['data] argument is a readable stream resource. - * The other $data key-values should be header fields in form of string - * - * After a successful put operation, you may choose to return an ETag. The - * ETag must always be surrounded by double-quotes. These quotes must - * appear in the actual string you're returning. - * - * Clients may use the ETag from a PUT request to later on make sure that - * when they update the file, the contents haven't changed in the mean - * time. - * - * If you don't plan to store the file byte-by-byte, and you return a - * different object on a subsequent GET you are strongly recommended to not - * return an ETag, and just return null. - * - * @param array $data - * - * @throws Forbidden - * @throws UnsupportedMediaType - * @throws BadRequest - * @throws Exception - * @throws EntityTooLarge - * @throws ServiceUnavailable - * @throws FileLocked - * @return array $properties - */ - public function putFile($data) { - $properties = array(); - - if (!isset($data['oc-total-length'])) { - //this should not happen, since upper layer takes care of that - //Thus, return Forbidden as sign of code inconsistency - throw new Forbidden('File requires oc-total-length header to be read'); - } - - try { - $exists = $this->fileView->file_exists($this->path); - if ($this->info && $exists) { - $this->contentHandler->multipartContentSeekToContentLength($data['oc-total-length']); - throw new Forbidden('Bundling not supported for already existing files'); - } - } catch (StorageNotAvailableException $e) { - $this->contentHandler->multipartContentSeekToContentLength($data['oc-total-length']); - throw new ServiceUnavailable("StorageNotAvailableException raised"); - } - - // verify path of the target - $this->verifyPath(); - - $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . rand(); - - // the part file and target file might be on a different storage in case of a single file storage (e.g. single file share) - /** @var \OC\Files\Storage\Storage $partStorage */ - list($partStorage, $internalPartPath) = $this->fileView->resolvePath($partFilePath); - /** @var \OC\Files\Storage\Storage $storage */ - list($storage, $internalPath) = $this->fileView->resolvePath($this->path); - try { - $target = $partStorage->fopen($internalPartPath, 'wb'); - if ($target === false || $target === null) { - \OCP\Util::writeLog('webdav', '\OC\Files\Filesystem::fopen() failed', \OCP\Util::ERROR); - // because we have no clue about the cause we can only throw back a 500/Internal Server Error - $this->contentHandler->multipartContentSeekToContentLength($data['oc-total-length']); - throw new Exception('Could not write file contents'); - } - - $result = $this->contentHandler->streamReadToStream($target, $data['oc-total-length']); - - if ($result === false) { - throw new Exception('Error while copying file to target location (expected filesize: ' . $data['oc-total-length'] . ' )'); - } - - } catch (\Exception $e) { - $partStorage->unlink($internalPartPath); - $this->convertToSabreException($e); - } - - try { - $view = \OC\Files\Filesystem::getView(); - if ($view) { - $run = $this->emitPreHooks($exists); - } else { - $run = true; - } - - try { - $this->changeLock(ILockingProvider::LOCK_EXCLUSIVE); - } catch (LockedException $e) { - $partStorage->unlink($internalPartPath); - throw new FileLocked($e->getMessage(), $e->getCode(), $e); - } - - try { - if ($run) { - $renameOkay = $storage->moveFromStorage($partStorage, $internalPartPath, $internalPath); - $fileExists = $storage->file_exists($internalPath); - } - if (!$run || $renameOkay === false || $fileExists === false) { - \OCP\Util::writeLog('webdav', 'renaming part file to final file failed', \OCP\Util::ERROR); - throw new Exception('Could not rename part file to final file'); - } - } catch (ForbiddenException $ex) { - throw new DAVForbiddenException($ex->getMessage(), $ex->getRetry()); - } catch (\Exception $e) { - $partStorage->unlink($internalPartPath); - $this->convertToSabreException($e); - } - - // since we skipped the view we need to scan and emit the hooks ourselves - $storage->getUpdater()->update($internalPath); - - try { - $this->changeLock(ILockingProvider::LOCK_SHARED); - } catch (LockedException $e) { - throw new FileLocked($e->getMessage(), $e->getCode(), $e); - } - - if ($view) { - $this->emitPostHooks($exists); - } - - // allow sync clients to send the mtime along in a header - if (isset($data['oc-mtime'])) { - if ($this->fileView->touch($this->path, $data['oc-mtime'])) { - $properties['{DAV:}oc-mtime'] = 'accepted'; - } - } - - $this->refreshInfo(); - - if (isset($data['oc-checksum'])) { - $checksum = trim($data['oc-checksum']); - $this->fileView->putFileInfo($this->path, ['checksum' => $checksum]); - $this->refreshInfo(); - } else if ($this->getChecksum() !== null && $this->getChecksum() !== '') { - $this->fileView->putFileInfo($this->path, ['checksum' => '']); - $this->refreshInfo(); - } - - } catch (StorageNotAvailableException $e) { - throw new ServiceUnavailable("Failed to check file size: " . $e->getMessage()); - } - - $etag = $this->getEtag(); - $properties['{DAV:}etag'] = $etag; - $properties['{DAV:}oc-etag'] = $etag; - $properties['{DAV:}oc-fileid'] = $this->getFileId(); - return $properties; - } - - /* - * @param resource $data - * - * @throws Forbidden - */ - public function put($data) { - throw new Forbidden('PUT method not supported for bundling'); - } -}
\ No newline at end of file diff --git a/apps/dav/lib/BundleUpload/BundlingPlugin.php b/apps/dav/lib/BundleUpload/BundlingPlugin.php deleted file mode 100644 index b3c7a007ac2..00000000000 --- a/apps/dav/lib/BundleUpload/BundlingPlugin.php +++ /dev/null @@ -1,456 +0,0 @@ -<?php -/** - * @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com> - * @author Louis Chemineau <louis@chmn.me> - * - * @copyright Copyright (c) 2016, ownCloud GmbH. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\DAV\BundleUpload; - -use Sabre\DAV\ServerPlugin; -use Sabre\HTTP\RequestInterface; -use Sabre\HTTP\ResponseInterface; -use OC\Files\View; -use Sabre\HTTP\URLUtil; -use OCP\Lock\ILockingProvider; -use OC\Files\FileInfo; -use Sabre\DAV\Exception\BadRequest; -use OCA\DAV\Connector\Sabre\Exception\Forbidden; -use OCP\Files\Folder; -use OCP\AppFramework\Http\JSONResponse; -use Psr\Log\LoggerInterface; - -/** - * This plugin is responsible for interconnecting three components of the OC server: - * - RequestInterface object handler for request incoming from the client - * - MultipartContentsParser responsible for reading the contents of the request body - * - BundledFile responsible for storage of the file associated with request in the OC server - * - * Bundling plugin is responsible for receiving, validation and processing of the multipart/related request containing files. - * - */ -class BundlingPlugin extends ServerPlugin { - /** - * Reference to main server object - * - * @var \Sabre\DAV\Server - */ - private $server; - - /** - * @var \Sabre\HTTP\RequestInterface - */ - private $request; - - /** - * @var \Sabre\HTTP\ResponseInterface - */ - private $response; - - /** - * @var \OCA\DAV\FilesBundle - */ - private $contentHandler = null; - - /** - * @var String - */ - private $userFilesHome = null; - - /** - * @var View - */ - private $fileView; - - /** - * @var Array - */ - // private $cacheValidParents = null; - - /** @var IFolder */ - private $userFolder; - - /** @var LoggerInterface */ - private $logger; - - /** - * Plugin constructor - */ - public function __construct(View $view, Folder $userFolder) { - $this->fileView = $view; - $this->userFolder = $userFolder; - } - - /** - * This initializes the plugin. - * - * This function is called by \Sabre\DAV\Server, after - * addPlugin is called. - * - * This method should set up the requires event subscriptions. - * - * @param \Sabre\DAV\Server $server - * @return void - */ - public function initialize(\Sabre\DAV\Server $server) { - $this->server = $server; - $this->logger = $this->server->getLogger(); - - $server->on('method:POST', array($this, 'handleBundle')); - } - - /** - * We intercept this to handle method:POST on a dav resource and process the bundled files multipart HTTP request. - * - * @throws /Sabre\DAV\Exception\BadRequest - * @throws /Sabre\DAV\Exception\Forbidden - */ - public function handleBundle(RequestInterface $request, ResponseInterface $response) { - // Limit bundle upload to the /bundle endpoint - if ($request->getPath() !== "files/bundle") { - return true; - } - - $multiPartParser = new MultipartContentsParser($request); - $writtenFiles = []; - - // $multiPartParser->eof() - while (!$multiPartParser->lastBoundary()) { - try { - [$headers, $content] = $multiPartParser->readNextPart(); - - if ((int)$headers['content-length'] !== strlen($content)) { - throw new BadRequest("Content read with different size than declared. Got " . $headers['content-length'] . ", expected" . strlen($content)); - } - - $node = $this->userFolder->newFile($headers['x-file-path'], $content); - $writtenFiles[$headers['x-file-path']] = $node->getSize(); - - if ((int)$headers['content-length'] !== $node->getSize()) { - throw new BadRequest("Written file length is different than declared length. Got " . $headers['content-length'] . ", expected" . $node->getSize()); - } - - // TODO - check md5 hash - // $context = hash_init('md5'); - // hash_update_stream($context, $stream); - // echo hash_final($context); - // if ($header['x-file-md5'] !== hash_final($context)) { - // } - } catch (\Exception $e) { - throw $e; - $this->logger->error($e->getMessage(), ['path' => $header['x-file-path']]); - } - } - - $response->setStatus(200); - $response->setBody(new JSONResponse([ - $writtenFiles - ])); - - return false; - - // $this->contentHandler = $this->getContentHandler($this->request); - - // $multipleRequestsData = $this->parseBundleMetadata(); - - //Process bundle and send a multi-status response - // $result = $this->processBundle($multipleRequestsData); - - // return $result; - } - - public function handleBundleWithMetadata(RequestInterface $request, ResponseInterface $response) { - // Limit bundle upload to the /bundle endpoint - if ($request->getPath() !== "files/bundle") { - return true; - } - - $multiPartParser = new MultipartContentsParser($request); - - [$metadataHeaders, $rawMetadata] = $multiPartParser->getMetadata(); - - if ($metadataHeaders['content-type'] !== "text/xml; charset=utf-8") { - throw new BadRequest("Incorrect Content-Type for metadata."); - } - - if ((int)$metadataHeaders['content-length'] !== strlen($rawMetadata)) { - throw new BadRequest("Content read with different size than declared."); - } - - $metadata = $this->parseMetadata($rawMetadata); - - $writtenFiles = []; - - foreach ($metadata as $fileMetadata) { - try { - [$headers, $content] = $multiPartParser->readNextPart((int)$fileMetadata['oc-total-length']); - - if ($fileMetadata['oc-id'] !== $headers['content-id']) { - throw new BadRequest("Content-ID do not match oc-id. Check the order of your metadata."); - } - - if (isset($file[$fileMetadata['oc-id']])) { - throw new BadRequest("Content-ID appear twice. Check the order of your metadata."); - } - - if ((int)$fileMetadata['oc-total-length'] !== strlen($content)) { - throw new BadRequest("Content read with different size than declared."); - } - - $node = $this->userFolder->newFile($fileMetadata['oc-path'], $content); - $writtenFiles[$fileMetadata['oc-id']] = $node->getSize(); - - // TODO - check md5 hash - // $context = hash_init('md5'); - // hash_update_stream($context, $stream); - // echo hash_final($context); - if ($fileMetadata['oc-md5'] !== hash_final($context)) { - - } - } catch (\Exception $e) { - throw $e; - $this->logger->error($e->getMessage(), ['path' => $fileMetadata['oc-path']]); - } - } - - $response->setStatus(200); - $response->setBody(new JSONResponse([ - $writtenFiles - ])); - - return false; - - // $this->contentHandler = $this->getContentHandler($this->request); - - // $multipleRequestsData = $this->parseBundleMetadata(); - - //Process bundle and send a multi-status response - // $result = $this->processBundle($multipleRequestsData); - - // return $result; - } - - private function parseMetadata(string $rawMetadata) { - $xml = simplexml_load_string($rawMetadata); - if ($xml === false) { - $error = libxml_get_errors(); - throw new \Exception('Bundle metadata contains incorrect xml structure. Unable to parse whole bundle request', $error); - } - - libxml_clear_errors(); - - $xml->registerXPathNamespace('d','urn:DAV'); - - $metadataXml = $xml->xpath('/d:multipart/d:part/d:prop'); - - if($metadataXml === false){ - throw new \Exception('Fail to access d:multipart/d:part/d:prop elements'); - } - - return array_map(function($xmlObject) { return get_object_vars($xmlObject->children('d', TRUE));}, $metadataXml); - } - - /** - * Parses multipart contents and send appropriate response - * - * @throws \Sabre\DAV\Exception\Forbidden - * - * @return array $multipleRequestsData - */ - private function parseBundleMetadata() { - $multipleRequestsData = array(); - try { - // Verify metadata part headers - $bundleMetadata = null; - try{ - $bundleMetadata = $this->contentHandler->getPartHeaders($this->boundary); - } - catch (\Exception $e) { - throw new \Exception($e->getMessage()); - } - $contentParts = explode(';', $bundleMetadata['content-type']); - if (count($contentParts) != 2) { - throw new \Exception('Incorrect Content-type format. Charset might be missing'); - } - $contentType = trim($contentParts[0]); - $expectedContentType = 'text/xml'; - if ($contentType != $expectedContentType) { - throw new BadRequest(sprintf( - 'Content-Type must be %s', - $expectedContentType - )); - } - if (!isset($bundleMetadata['content-length'])) { - throw new \Exception('Bundle metadata header does not contain Content-Length. Unable to parse whole bundle request'); - } - - // Read metadata part headers - $bundleMetadataBody = $this->contentHandler->streamReadToString($bundleMetadata['content-length']); - - $bundleMetadataBody = preg_replace("/xmlns(:[A-Za-z0-9_])?=(\"|\')DAV:(\"|\')/","xmlns\\1=\"urn:DAV\"",$bundleMetadataBody); - - //Try to load xml - $xml = simplexml_load_string($bundleMetadataBody); - if (false === $xml) { - $mlerror = libxml_get_errors(); - throw new \Exception('Bundle metadata contains incorrect xml structure. Unable to parse whole bundle request'); - } - $xml->registerXPathNamespace('d','urn:DAV'); - unset($bundleMetadataBody); - - if(1 != count($xml->xpath('/d:multipart'))){ - throw new \Exception('Bundle metadata does not contain d:multipart children elements'); - } - - $fileMetadataObjectXML = $xml->xpath('/d:multipart/d:part/d:prop'); - - if(0 == count($fileMetadataObjectXML)){ - throw new \Exception('Bundle metadata does not contain d:multipart/d:part/d:prop children elements'); - } - - foreach ($fileMetadataObjectXML as $prop) { - $fileMetadata = get_object_vars($prop->children('d', TRUE)); - - // if any of the field is not contained, - // bthe try-catch clausule will raise Undefined index exception - $contentID = intval($fileMetadata['oc-id']); - if(array_key_exists($contentID, $multipleRequestsData)){ - throw new \Exception('One or more files have the same Content-ID '.$contentID.'. Unable to parse whole bundle request'); - } - $multipleRequestsData[$contentID]['oc-path'] = $fileMetadata['oc-path']; - $multipleRequestsData[$contentID]['oc-mtime'] = $fileMetadata['oc-mtime']; - $multipleRequestsData[$contentID]['oc-total-length'] = intval($fileMetadata['oc-total-length']); - $multipleRequestsData[$contentID]['response'] = null; - } - } catch (\Exception $e) { - libxml_clear_errors(); - throw new Forbidden($e->getMessage()); - } - return $multipleRequestsData; - } - - /** - * Process multipart contents and send appropriate response - * - * @param RequestInterface $request - * - * @return boolean - */ - private function processBundle($multipleRequestsData) { - $bundleResponseProperties = array(); - - while(!$this->contentHandler->getEndDelimiterReached()) { - // Verify metadata part headers - $fileContentHeader = null; - - //If something fails at this point, just continue, $multipleRequestsData[$contentID]['response'] will be null for this content - try{ - $fileContentHeader = $this->contentHandler->getPartHeaders($this->boundary); - if(is_null($fileContentHeader) || !isset($fileContentHeader['content-id']) || !array_key_exists(intval($fileContentHeader['content-id']), $multipleRequestsData)){ - continue; - } - } - catch (\Exception $e) { - continue; - } - - $fileID = intval($fileContentHeader['content-id']); - $fileMetadata = $multipleRequestsData[$fileID]; - - $filePath = $fileMetadata['oc-path']; - - list($folderPath, $fileName) = \OC\URLUtil::splitPath($filePath); - - try { - //get absolute path of the file - $absoluteFilePath = $this->fileView->getAbsolutePath($folderPath) . '/' . $fileName; - $info = new FileInfo($absoluteFilePath, null, null, array(), null); - $node = new BundledFile($this->fileView, $info, $this->contentHandler); - $node->acquireLock(ILockingProvider::LOCK_SHARED); - $properties = $node->putFile($fileMetadata); - $multipleRequestsData[$fileID]['response'] = $this->handleFileMultiStatus($filePath, $properties); - } catch (\Exception $exc) { - //TODO: This should not be BadRequest! This should be any exception - how to do it carefully? - $exc = new BadRequest($exc->getMessage()); - $multipleRequestsData[$fileID]['response'] = $this->handleFileMultiStatusError($filePath, $exc); - continue; - } - - //TODO: do we need to unlock file if putFile failed? In this version we dont (does continue) - //release lock as in dav/lib/Connector/Sabre/LockPlugin.php - $node->releaseLock(ILockingProvider::LOCK_SHARED); - $this->server->tree->markDirty($filePath); - } - - foreach($multipleRequestsData as $requestData) { - $response = $requestData['response']; - if (is_null($response)){ - $exc = new BadRequest('File parsing error'); - $response = $this->handleFileMultiStatusError($requestData['oc-path'], $exc); - } - $bundleResponseProperties[] = $response; - } - - //multi-status response announced - $this->response->setHeader('Content-Type', 'application/xml; charset=utf-8'); - $this->response->setStatus(207); - $body = $this->server->generateMultiStatus($bundleResponseProperties); - $this->response->setBody($body); - - return false; - } - - /** - * Adds to multi-status response exception class string and exception message for specific file - * - * @return array $entry - */ - private function handleFileMultiStatusError($ocPath, $exc){ - $status = $exc->getHTTPCode(); - $entry['href'] = $this->userFilesHome; - $entry[$status]['{DAV:}error']['{http://sabredav.org/ns}exception'] = get_class($exc); - $entry[$status]['{DAV:}error']['{http://sabredav.org/ns}message'] = $exc->getMessage(); - $entry[$status]['{DAV:}oc-path'] = $ocPath; - return $entry; - } - - /** - * Adds to multi-status response properties for specific file - * - * @return array $entry - */ - private function handleFileMultiStatus($ocPath, $properties){ - $entry['href'] = $this->userFilesHome; - $entry[200] = $properties; - $entry[200]['{DAV:}oc-path'] = $ocPath; - return $entry; - } - - /** - * Get content handler - * - * @param RequestInterface $request - * @return \OCA\DAV\BundleUpload\MultipartContentsParser - */ - // private function getContentHandler(RequestInterface $request) { - // if ($this->contentHandler === null) { - // return new MultipartContentsParser($request); - // } - // return $this->contentHandler; - // } -}
\ No newline at end of file diff --git a/apps/dav/lib/BundleUpload/MultipartContentsParser.php b/apps/dav/lib/BundleUpload/MultipartContentsParser.php deleted file mode 100644 index 93b24539c49..00000000000 --- a/apps/dav/lib/BundleUpload/MultipartContentsParser.php +++ /dev/null @@ -1,497 +0,0 @@ -<?php -/** - * @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com> - * @author Louis Chemineau <louis@chmn.me> - * - * @copyright Copyright (c) 2016, ownCloud GmbH. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\DAV\BundleUpload; - -use Exception; -use Sabre\HTTP\RequestInterface; -use Sabre\DAV\Exception\BadRequest; - -/** - * This class is used to parse multipart/related HTTP message according to RFC http://www.rfc-archive.org/getrfc.php?rfc=2387 - * This class requires a message to contain Content-length parameters, which is used in high performance reading of file contents. - */ - -class MultipartContentsParser { - /** - * @var \Sabre\HTTP\RequestInterface - */ - // private $request; - - /** @var resource */ - private $stream = null; - - /** @var string */ - private $boundary = ""; - private $lastBoundary = ""; - - /** - * @var Bool - */ - // private $endDelimiterReached = false; - - /** - * Constructor. - */ - public function __construct(RequestInterface $request) { - $this->stream = $request->getBody(); - if (gettype($this->stream) !== 'resource') { - throw new BadRequest('Wrong body type'); - } - - $this->boundary = '--'.$this->getBoundary($request->getHeader('Content-Type'))."\r\n"; - $this->lastBoundary = '--'.$this->getBoundary($request->getHeader('Content-Type'))."--\r\n"; - } - - /** - * Parse the boundary from a Content-Type header - * - * @throws \Sabre\DAV\Exception\BadRequest - */ - private function getBoundary(string $contentType) { - // Making sure the end node exists - //TODO: add support for user creation if that is first sync. Currently user has to be created. - // $this->userFilesHome = $this->request->getPath(); - // $userFilesHomeNode = $this->server->tree->getNodeForPath($this->userFilesHome); - // if (!($userFilesHomeNode instanceof FilesHome)){ - // throw new Forbidden('URL endpoint has to be instance of \OCA\DAV\Files\FilesHome'); - // } - - // $headers = array('Content-Type'); - // foreach ($headers as $header) { - // $value = $this->request->getHeader($header); - // if ($value === null) { - // throw new Forbidden(sprintf('%s header is needed', $header)); - // } elseif (!is_int($value) && empty($value)) { - // throw new Forbidden(sprintf('%s header must not be empty', $header)); - // } - // } - - // Validate content-type - // Ex: Content-Type: "multipart/related; boundary=boundary_bf38b9b4b10a303a28ed075624db3978" - [$mimeType, $boundary] = explode(';', $contentType); - - if (trim($mimeType) !== 'multipart/related') { - throw new BadRequest('Content-Type must be multipart/related'); - } - - // Validate boundary - [$key, $value] = explode('=', $boundary); - if (trim($key) !== 'boundary') { - throw new BadRequest('Boundary is invalid'); - } - - $value=trim($value); - - // Remove potential quotes around boundary value - if (substr($value, 0, 1) == '"' && substr($value, -1) == '"') { - $value = substr($value, 1, -1); - } - - return $value; - } - - /** - * Get a line. - * - * If false is return, it's the end of file. - * - * @throws \Sabre\DAV\Exception\BadRequest - */ - // public function gets() { - // $content = $this->getContent(); - // if (!is_resource($content)) { - // throw new BadRequest('Unable to get request content'); - // } - - // return fgets($content); - // } - - /** - */ - // public function getCursor() { - // return ftell($this->getContent()); - // } - - /** - */ - // public function getEndDelimiterReached() { - // return $this->endDelimiterReached; - // } - - /** - * Return if end of file. - */ - public function eof() { - return feof($this->stream); - } - - /** - * Seeks to offset of some file contentLength from the current cursor position in the - * multipartContent. - * - * Return true on success and false on failure - */ - // public function multipartContentSeekToContentLength(int $contentLength) { - // return (fseek($this->getContent(), $contentLength, SEEK_CUR) === 0 ? true : false); - // } - - /** - * Get request content. - * - * @throws \Sabre\DAV\Exception\BadRequest - * - * @return resource - */ - // public function getContent() { - // if ($this->stream === null) { - // // Pass body by reference, so other objects can have global access - // $content = $this->request->getBody(); - - // if (!$this->stream) { - // throw new BadRequest('Unable to get request content'); - // } - - // if (gettype($this->stream) !== 'resource') { - // throw new BadRequest('Wrong body type'); - // } - - // $this->stream = $content; - // } - - // return $this->stream; - // } - - // public function getBoundary(string $boundary) { - // return "\r\n--$boundary\r\n"; - // } - - public function checkBoundary(string $boundary, string $line) { - if ($line !== $boundary) { - throw new Exception("Invalid boundary, is '$line', should be '$this->boundary'."); - } - - return true; - } - - public function lastBoundary() { - $content = fread($this->stream, strlen($this->lastBoundary)); - $result = fseek($this->stream, -strlen($this->lastBoundary), SEEK_CUR); - - if ($result === -1) { - throw new Exception("Unknown error while seeking content"); - } - - return $content === $this->lastBoundary; - } - - /** - * Return the next part of the request. - * - * @throws Exception - */ - public function readNextPart(int $length = 0) { - $this->checkBoundary($this->boundary, fread($this->stream, strlen($this->boundary))); - - $headers = $this->readPartHeaders(); - - if ($length === 0 && isset($headers["content-length"])) { - $length = $headers["content-length"]; - } - - if ($length === 0) { - throw new Exception("Part cannot be of length 0."); - } - - $content = $this->readPartContent2($length); - - return [$headers, $content]; - } - - /** - * Return the next part of the request. - * - * @throws Exception - */ - public function readNextStream() { - $this->checkBoundary($this->boundary, fread($this->stream, strlen($this->boundary))); - - $headers = $this->readPartHeaders(); - - return [$headers, $this->stream]; - } - - /** - * Return the headers of a part of the request. - * - * @throws \Sabre\DAV\Exception\BadRequest - * @throws Exception - */ - public function readPartHeaders() { - $headers = []; - $blankLineCount = 0; - - while($blankLineCount < 1) { - $line = fgets($this->stream); - - if ($line === false) { - throw new Exception('An error appears while reading headers of a part'); - } - - if ($line === "\r\n") { - break; - } - - try { - [$key, $value] = explode(':', $line, 2); - $headers[strtolower(trim($key))] = trim($value); - } catch (Exception $e) { - throw new BadRequest('An error appears while parsing headers of a part', $e); - } - } - - return $headers; - } - - /** - * Return the content of the current part of the stream. - * - * @throws \Sabre\DAV\Exception\BadRequest - * @throws Exception - */ - public function readPartContent() { - $line = ''; - $content = ''; - - do { - $content .= $line; - - if (feof($this->stream)) { - throw new BadRequest("Unexpected EOF while reading stream."); - } - - $line = fgets($this->stream); - - if ($line === false) { - throw new Exception("Fail to read part's content."); - } - } while ($line !== $this->boundary); - - // We need to be before $boundary for the next parsing. - $result = fseek($this->stream, -strlen($this->boundary), SEEK_CUR); - - if ($result === -1) { - throw new Exception("Fail to seek upstream."); - } - - // Remove the extra new line "\r\n" that is not part of the content - return substr($content, 0, -2); - } - - public function readPartContent2(int $length) { - // Read stream until file's $length, EOF or $boundary is reached - $content = stream_get_line($this->stream, $length); - - if ($content === false) { - throw new Exception("Fail to read part's content."); - } - - if (feof($this->stream)) { - throw new Exception("Unexpected EOF while reading stream."); - } - - stream_get_contents($this->stream, 2); - - return $content; - } - - public function getContentPosition() { - return ftell($this->stream); - } - - public function getMetadata() { - fseek($this->stream, 0); - return $this->readNextPart(); - } - - public function getContent(int $pos, int $length) { - $previousPos = ftell($this->stream); - - $content = stream_get_contents($this->stream, $length, $pos); - - fseek($this->stream, $previousPos); - - return $content; - } - - /** - * Get a part of request separated by boundary $boundary. - * - * If this method returns an exception, it means whole request has to be abandoned, - * Request part without correct headers might corrupt the message and parsing is impossible - * - * @throws \Exception - */ - // public function getPartHeaders(string $boundary) { - // $delimiter = '--'.$boundary."\r\n"; - // $endDelimiter = '--'.$boundary.'--'; - // $boundaryCount = 0; - // $content = ''; - // $headers = null; - - // while (!$this->eof()) { - // $line = $this->gets(); - // if ($line === false) { - // if ($boundaryCount == 0) { - // // Empty part, ignore - // break; - // } - // else{ - // throw new \Exception('An error appears while reading and parsing header of content part using fgets'); - // } - // } - - // if ($boundaryCount == 0) { - // if ($line != $delimiter) { - // if ($this->getCursor() == strlen($line)) { - // throw new \Exception('Expected boundary delimiter in content part - this is not a multipart request'); - // } - // elseif ($line == $endDelimiter || $line == $endDelimiter."\r\n") { - // $this->endDelimiterReached = true; - // break; - // } - // elseif ($line == "\r\n") { - // continue; - // } - // } else { - // continue; - // } - // // At this point we know, that first line was boundary - // $boundaryCount++; - // } - // elseif ($boundaryCount == 1 && $line == "\r\n"){ - // //header-end according to RFC - // $content .= $line; - // $headers = $this->readHeaders($content); - // break; - // } - // elseif ($line == $endDelimiter || $line == $endDelimiter."\r\n") { - // $this->endDelimiterReached = true; - // break; - // } - - // $content .= $line; - // } - - // if ($this->eof()){ - // $this->endDelimiterReached = true; - // } - - // return $headers; - // } - - /** - * Read the contents from the current file pointer to the specified length - * - * @throws \Sabre\DAV\Exception\BadRequest - */ - // public function streamReadToString(int $length) { - // if ($length<0) { - // throw new BadRequest('Method streamRead cannot read contents with negative length'); - // } - // $source = $this->getContent(); - // $bufChunkSize = 8192; - // $count = $length; - // $buf = ''; - - // while ($count!=0) { - // $bufSize = (($count - $bufChunkSize)<0) ? $count : $bufChunkSize; - // $buf .= fread($source, $bufSize); - // $count -= $bufSize; - // } - - // $bytesWritten = strlen($buf); - // if ($length != $bytesWritten){ - // throw new BadRequest('Method streamRead read '.$bytesWritten.' expected '.$length); - // } - // return $buf; - // } - - /** - * Read the contents from the current file pointer to the specified length and pass - * - * @param resource $target - * - * @throws \Sabre\DAV\Exception\BadRequest - */ - // public function streamReadToStream($target, int $length) { - // if ($length<0) { - // throw new BadRequest('Method streamRead cannot read contents with negative length'); - // } - // $source = $this->getContent(); - // $bufChunkSize = 8192; - // $count = $length; - // $returnStatus = true; - - // while ($count!=0) { - // $bufSize = (($count - $bufChunkSize)<0) ? $count : $bufChunkSize; - // $buf = fread($source, $bufSize); - // $bytesWritten = fwrite($target, $buf); - - // // note: strlen is expensive so only use it when necessary, - // // on the last block - // if ($bytesWritten === false - // || ($bytesWritten < $bufSize) - // ) { - // // write error, could be disk full ? - // $returnStatus = false; - // break; - // } - // $count -= $bufSize; - // } - - // return $returnStatus; - // } - - - /** - * Get headers from content - */ - // public function readHeaders($content) { - // $headers = null; - // $headerLimitation = strpos($content, "\r\n\r\n"); - // if ($headerLimitation === false) { - // return null; - // } - // $headersContent = substr($content, 0, $headerLimitation); - // $headersContent = trim($headersContent); - // foreach (explode("\r\n", $headersContent) as $header) { - // $parts = explode(':', $header, 2); - // if (count($parts) != 2) { - // //has incorrect header, abort - // return null; - // } - // $headers[strtolower(trim($parts[0]))] = trim($parts[1]); - // } - - // return $headers; - // } -}
\ No newline at end of file diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php index 17db2346c68..ce60bccfd0b 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -29,7 +29,7 @@ class Capabilities implements ICapability { return [ 'dav' => [ 'chunking' => '1.0', - 'bundleupload' => '1.0', + 'bulkupload' => '1.0', ] ]; } diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php index ec33b44fe4b..5ff5f831eb5 100644 --- a/apps/dav/lib/Connector/Sabre/File.php +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -356,7 +356,7 @@ class File extends Node implements IFile { return '"' . $this->info->getEtag() . '"'; } - protected function getPartFileBasePath($path) { + private function getPartFileBasePath($path) { $partFileInStorage = \OC::$server->getConfig()->getSystemValue('part_file_in_storage', true); if ($partFileInStorage) { return $path; @@ -368,7 +368,7 @@ class File extends Node implements IFile { /** * @param string $path */ - protected function emitPreHooks($exists, $path = null) { + private function emitPreHooks($exists, $path = null) { if (is_null($path)) { $path = $this->path; } @@ -396,7 +396,7 @@ class File extends Node implements IFile { /** * @param string $path */ - protected function emitPostHooks($exists, $path = null) { + private function emitPostHooks($exists, $path = null) { if (is_null($path)) { $path = $this->path; } @@ -633,7 +633,7 @@ class File extends Node implements IFile { * * @throws \Sabre\DAV\Exception */ - protected function convertToSabreException(\Exception $e) { + private function convertToSabreException(\Exception $e) { if ($e instanceof \Sabre\DAV\Exception) { throw $e; } diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 74ae94a6d51..055c37f8472 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -34,6 +34,7 @@ */ namespace OCA\DAV; +use Psr\Log\LoggerInterface; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\BirthdayService; use OCA\DAV\CardDAV\HasPhotoPlugin; @@ -62,12 +63,11 @@ use OCA\DAV\DAV\PublicAuth; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\Files\BrowserErrorPagePlugin; use OCA\DAV\Files\LazySearchBackend; -use OCA\DAV\BundleUpload\BundlingPlugin; +use OCA\DAV\BulkUpload\BulkUploadPlugin; use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin; use OCA\DAV\SystemTag\SystemTagPlugin; use OCA\DAV\Upload\ChunkingPlugin; use OCP\EventDispatcher\IEventDispatcher; -use OCP\Files\IRootFolder; use OCP\IRequest; use OCP\SabrePluginEvent; use Sabre\CardDAV\VCFExportPlugin; @@ -296,9 +296,9 @@ class Server { \OC::$server->getShareManager(), $view )); - $rootFolder = \OC::$server->query(IRootFolder::class); + $logger = \OC::$server->get(LoggerInterface::class); $this->server->addPlugin( - new BundlingPlugin($view, $userFolder) + new BulkUploadPlugin($userFolder, $logger) ); } $this->server->addPlugin(new \OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin( diff --git a/apps/dav/tests/temporary/benchmark.sh b/apps/dav/tests/benchmarks/benchmark.sh index 4a2f283e320..27d7c4ecbc7 100755 --- a/apps/dav/tests/temporary/benchmark.sh +++ b/apps/dav/tests/benchmarks/benchmark.sh @@ -2,6 +2,8 @@ set -eu +# benchmark.sh + export KB=1000 export MB=$((KB*1000)) @@ -32,10 +34,10 @@ do echo "- Upload of $nb tiny file of ${size}B" echo " - Bundled" start=$(date +%s) - echo "$requests_count" | xargs -d ' ' -P $CONCURRENCY -I{} ./bundle_upload.sh "$nb" "$size" + echo "$requests_count" | xargs -d ' ' -P $CONCURRENCY -I{} ./bulk_upload.sh "$nb" "$size" end=$(date +%s) - bundle_exec_time=$((end-start)) - echo "${bundle_exec_time}s" + bulk_exec_time=$((end-start)) + echo "${bulk_exec_time}s" echo " - Single" start=$(date +%s) @@ -44,7 +46,7 @@ do single_exec_time=$((end-start)) echo "${single_exec_time}s" - md_output+="| $nb | $size | $bundle_exec_time | $single_exec_time |\n" + md_output+="| $nb | $size | $bulk_exec_time | $single_exec_time |\n" done echo -en "$md_output"
\ No newline at end of file diff --git a/apps/dav/tests/temporary/bundle_upload.sh b/apps/dav/tests/benchmarks/bulk_upload.sh index 9d2b9c6f200..862ddfe461f 100755 --- a/apps/dav/tests/temporary/bundle_upload.sh +++ b/apps/dav/tests/benchmarks/bulk_upload.sh @@ -2,6 +2,8 @@ set -eu +# bulk_upload.sh <nb-of-files> <size-of-files> + KB=${KB:-100} MB=${MB:-$((KB*1000))} @@ -14,10 +16,10 @@ BANDWIDTH=${BANDWIDTH:-$((100*MB/CONCURRENCY))} USER="admin" PASS="password" SERVER="nextcloud.test" -UPLOAD_PATH="/tmp/bundle_upload_request_$(openssl rand --hex 8).txt" +UPLOAD_PATH="/tmp/bulk_upload_request_$(openssl rand --hex 8).txt" BOUNDARY="boundary_$(openssl rand --hex 8)" -LOCAL_FOLDER="/tmp/bundle_upload/${BOUNDARY}_${NB}_${SIZE}" -REMOTE_FOLDER="/bundle_upload/${BOUNDARY}_${NB}_${SIZE}" +LOCAL_FOLDER="/tmp/bulk_upload/${BOUNDARY}_${NB}_${SIZE}" +REMOTE_FOLDER="/bulk_upload/${BOUNDARY}_${NB}_${SIZE}" mkdir --parent "$LOCAL_FOLDER" @@ -48,7 +50,13 @@ done echo -en "--$BOUNDARY--\r\n" >> "$UPLOAD_PATH" -echo "Creating folder /${BOUNDARY}_${NB}_${SIZE}" +echo "Creating folder /bulk_upload" +curl \ + -X MKCOL \ + -k \ + "https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/bulk_upload" > /dev/null + +echo "Creating folder $REMOTE_FOLDER" curl \ -X MKCOL \ -k \ @@ -56,15 +64,15 @@ curl \ echo "Uploading $NB files with total size: $(du -sh "$UPLOAD_PATH" | cut -d ' ' -f1)" echo "Local file is: $UPLOAD_PATH" -blackfire curl \ +curl \ -X POST \ -k \ --progress-bar \ --limit-rate "${BANDWIDTH}k" \ - --cookie "XDEBUG_PROFILE=MROW4A;path=/;" \ + --cookie "XDEBUG_PROFILE=true;path=/;" \ -H "Content-Type: multipart/related; boundary=$BOUNDARY" \ --data-binary "@$UPLOAD_PATH" \ - "https://$USER:$PASS@$SERVER/remote.php/dav/files/bundle" + "https://$USER:$PASS@$SERVER/remote.php/dav/bulk" rm -rf "${LOCAL_FOLDER:?}" rm "$UPLOAD_PATH" diff --git a/apps/dav/tests/temporary/single_upload.sh b/apps/dav/tests/benchmarks/single_upload.sh index da8e414be60..ec57e66668d 100755 --- a/apps/dav/tests/temporary/single_upload.sh +++ b/apps/dav/tests/benchmarks/single_upload.sh @@ -2,6 +2,8 @@ set -eu +# single_upload.sh <nb-of-files> <size-of-files> + export KB=${KB:-100} export MB=${MB:-$((KB*1000))} @@ -15,15 +17,20 @@ export USER="admin" export PASS="password" export SERVER="nextcloud.test" export UPLOAD_ID="single_$(openssl rand --hex 8)" -export LOCAL_FOLDER="/tmp/bundle_upload/${UPLOAD_ID}_${NB}_${SIZE}" -export REMOTE_FOLDER="/bundle_upload/${UPLOAD_ID}_${NB}_${SIZE}" +export LOCAL_FOLDER="/tmp/single_upload/${UPLOAD_ID}_${NB}_${SIZE}" +export REMOTE_FOLDER="/single_upload/${UPLOAD_ID}_${NB}_${SIZE}" mkdir --parent "$LOCAL_FOLDER" curl \ -X MKCOL \ -k \ - --cookie "XDEBUG_SESSION=MROW4A;path=/;" \ + "https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/bulk_upload" > /dev/null + +curl \ + -X MKCOL \ + -k \ + --cookie "XDEBUG_SESSION=true;path=/;" \ "https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/$REMOTE_FOLDER" upload_file() { diff --git a/apps/dav/tests/unit/CapabilitiesTest.php b/apps/dav/tests/unit/CapabilitiesTest.php index 719b62115d9..399467f6ed8 100644 --- a/apps/dav/tests/unit/CapabilitiesTest.php +++ b/apps/dav/tests/unit/CapabilitiesTest.php @@ -35,6 +35,7 @@ class CapabilitiesTest extends TestCase { $expected = [ 'dav' => [ 'chunking' => '1.0', + 'bulkupload' => '1.0', ], ]; $this->assertSame($expected, $capabilities->getCapabilities()); diff --git a/apps/dav/tests/unit/Files/BundlePluginTest.php b/apps/dav/tests/unit/Files/BundlePluginTest.php deleted file mode 100644 index 92309fe5021..00000000000 --- a/apps/dav/tests/unit/Files/BundlePluginTest.php +++ /dev/null @@ -1,711 +0,0 @@ -<?php -/** - * @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com> - * @author Louis Chemineau <louis@chmn.me> - * - * @copyright Copyright (c) 2016, ownCloud GmbH. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\DAV\Files; - -use OC\Files\FileInfo; -use OC\Files\Storage\Local; -use Sabre\HTTP\RequestInterface; -use Test\TestCase; -use OC\Files\View; -use OCP\Files\Storage; -use Sabre\DAV\Exception; -use OC\Files\Filesystem; -use OCP\Files\StorageNotAvailableException; - -/** - * Class BundlingPlugin - * - * @group DB - * - * @package OCA\DAV\Tests\unit\Files - */ -class BundlingPluginTest extends TestCase { - - /** - * @var string - */ - private $user; - - /** @var \OC\Files\View | \PHPUnit_Framework_MockObject_MockObject */ - private $view; - - /** @var \OC\Files\FileInfo | \PHPUnit_Framework_MockObject_MockObject */ - private $info; - - /** - * @var \Sabre\DAV\Server | \PHPUnit_Framework_MockObject_MockObject - */ - private $server; - - /** - * @var FilesPlugin - */ - private $plugin; - - /** - * @var \Sabre\HTTP\RequestInterface | \PHPUnit_Framework_MockObject_MockObject - */ - private $request; - /** - * @var \Sabre\HTTP\ResponseInterface | \PHPUnit_Framework_MockObject_MockObject - */ - private $response; - - /** - * @var MultipartContentsParser | \PHPUnit_Framework_MockObject_MockObject - */ - private $contentHandler; - - const BOUNDRARY = 'test_boundrary'; - - public function setUp() { - parent::setUp(); -// $this->server = new \Sabre\DAV\Server(); - - $this->server = $this->getMockBuilder('\Sabre\DAV\Server') - ->setConstructorArgs(array()) - ->setMethods(array('emit')) - ->getMock(); - - $this->server->tree = $this->getMockBuilder('\Sabre\DAV\Tree') - ->disableOriginalConstructor() - ->getMock(); - - // setup - $storage = $this->getMockBuilder(Local::class) - ->setMethods(["fopen","moveFromStorage","file_exists"]) - ->setConstructorArgs([['datadir' => \OC::$server->getTempManager()->getTemporaryFolder()]]) - ->getMock(); - $storage->method('fopen') - ->will($this->returnCallback( - function ($path,$mode) { - $bodyStream = fopen('php://temp', 'r+'); - return $bodyStream; - } - )); - $storage->method('moveFromStorage') - ->will($this->returnValue(true)); - $storage->method('file_exists') - ->will($this->returnValue(true)); - - \OC_Hook::clear(); - - $this->user = $this->getUniqueID('user_'); - $userManager = \OC::$server->getUserManager(); - $userManager->createUser($this->user, 'pass'); - - $this->loginAsUser($this->user); - - Filesystem::mount($storage, [], $this->user . '/'); - - $this->view = $this->getMockBuilder(View::class) - ->setMethods(['resolvePath', 'touch', 'file_exists', 'getFileInfo']) - ->setConstructorArgs([]) - ->getMock(); - - $this->view->method('touch') - ->will($this->returnValue(true)); - - $this->view - ->method('resolvePath') - ->will($this->returnCallback( - function ($path) use ($storage) { - return [$storage, $path]; - } - )); - - $this->view - ->method('getFileInfo') - ->will($this->returnCallback( - function ($path) { - $props = array(); - $props['checksum'] = null; - $props['etag'] = $path; - $props['fileid'] = $path; - $info = new FileInfo($path, null, null, $props, null); - return $info; - } - )); - - $this->info = $this->createMock('OC\Files\FileInfo', [], [], '', false); - - $this->request = $this->getMockBuilder(RequestInterface::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->response = new \Sabre\HTTP\Response(); - - $this->plugin = new BundlingPlugin( - $this->view - ); - - $this->plugin->initialize($this->server); - } - - /*TESTS*/ - - /** - * This test checks that if url endpoint is wrong, plugin with return exception - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage URL endpoint has to be instance of \OCA\DAV\Files\FilesHome - */ - public function testHandleBundleNotHomeCollection() { - - $this->request - ->expects($this->once()) - ->method('getPath') - ->will($this->returnValue('notFilesHome.xml')); - - $node = $this->getMockBuilder('\OCA\DAV\Connector\Sabre\File') - ->disableOriginalConstructor() - ->getMock(); - - $this->server->tree->expects($this->once()) - ->method('getNodeForPath') - ->with('notFilesHome.xml') - ->will($this->returnValue($node)); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Simulate NULL request header - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Content-Type header is needed - */ - public function testHandleBundleNoHeader() { - $this->setupServerTillFilesHome(); - - $this->request - ->expects($this->once()) - ->method('getHeader') - ->with('Content-Type') - ->will($this->returnValue(null)); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Simulate empty request header - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Content-Type header must not be empty - */ - public function testHandleBundleEmptyHeader() { - $this->setupServerTillFilesHome(); - - $this->request - ->expects($this->once()) - ->method('getHeader') - ->with('Content-Type') - ->will($this->returnValue("")); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Simulate content-type header without boundrary specification request header - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Improper Content-type format. Boundary may be missing - */ - public function testHandleBundleNoBoundraryHeader() { - $this->setupServerTillFilesHome(); - - $this->request - ->expects($this->atLeastOnce()) - ->method('getHeader') - ->with('Content-Type') - ->will($this->returnValue("multipart/related")); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Simulate content-type header with wrong boundrary specification request header - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Boundary is not set - */ - public function testHandleBundleWrongBoundraryHeader() { - $this->setupServerTillFilesHome(); - - $this->request - ->expects($this->atLeastOnce()) - ->method('getHeader') - ->with('Content-Type') - ->will($this->returnValue("multipart/related;thisIsNotBoundrary")); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Simulate content-type header with wrong boundrary specification request header - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Content-Type must be multipart/related - */ - public function testHandleBundleWrongContentTypeHeader() { - $this->setupServerTillFilesHome(); - - $this->request - ->expects($this->atLeastOnce()) - ->method('getHeader') - ->with('Content-Type') - ->will($this->returnValue("multipart/mixed; boundary=".self::BOUNDRARY)); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Simulate content-type header with alternative correct boundrary specification request header - * - * Request with user out of quota - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage beforeWriteBundle preconditions failed - */ - public function testHandleAlternativeBoundraryPlusBundleOutOfQuota() { - $this->setupServerTillFilesHome(); - - $this->request - ->expects($this->atLeastOnce()) - ->method('getHeader') - ->with('Content-Type') - ->will($this->returnValue("multipart/related; boundary=\"".self::BOUNDRARY."\"")); - - $this->server - ->expects($this->once()) - ->method('emit') - ->will($this->returnValue(false)); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Request without request body - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Unable to get request content - */ - public function testHandleBundleWithNullBody() { - $this->setupServerTillHeader(); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Test empty request body. This will pass getPartHeader, but exception will be raised after we ready headers - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Incorrect Content-type format. Charset might be missing - */ - public function testHandleBundleWithEmptyBody() { - $this->setupServerTillHeader(); - - $this->fillMultipartContentsParserStreamWithBody(""); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Test wrong request body - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Expected boundary delimiter in content part - this is not a multipart request - */ - public function testHandleBundleWithWrongBody() { - $this->setupServerTillHeader(); - - $this->fillMultipartContentsParserStreamWithBody("WrongBody"); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Test wrong request body, with metadata header containing no charset - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Incorrect Content-type format. Charset might be missing - */ - public function testHandleMetadataNoCharsetType(){ - $bodyContent = 'I am wrong metadata not in utf-8'; - $headers['content-length'] = strlen($bodyContent); - $headers['content-type'] = 'text/xml'; - - //this part will have some arbitrary, correct headers - $bodyFull = "--".self::BOUNDRARY - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\n\r\n" - ."$bodyContent\r\n--".self::BOUNDRARY."--"; - - $this->setupServerTillHeader(); - - $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Test wrong request body, with metadata header containing wrong content-type - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Content-Type must be text/xml - */ - public function testHandleMetadataWrongContentType(){ - $bodyContent = 'I am wrong metadata content type'; - $headers['content-type'] = 'text/plain; charset=utf-8'; - - //this part will have some arbitrary, correct headers - $bodyFull = "--".self::BOUNDRARY - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\n\r\n" - ."$bodyContent\r\n--".self::BOUNDRARY."--"; - - $this->setupServerTillHeader(); - - $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Test wrong request body, with metadata header containing wrong content-type - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Bundle metadata header does not contain Content-Length. Unable to parse whole bundle request - */ - public function testHandleMetadataNoContentLength(){ - $bodyContent = 'I am wrong metadata content type'; - //$headers['content-length'] = strlen($bodyContent); - $headers['content-type'] = 'text/xml; charset=utf-8'; - - //this part will have some arbitrary, correct headers - $bodyFull = "--".self::BOUNDRARY - ."\r\nContent-Type: ".$headers['content-type'] - //."\r\nContent-length: ".$headers['content-length'] - ."\r\n\r\n" - ."$bodyContent\r\n--".self::BOUNDRARY."--"; - - $this->setupServerTillHeader(); - - $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Try to parse body which is not xml - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Bundle metadata contains incorrect xml structure. Unable to parse whole bundle request - */ - public function testHandleWrongMetadataNoXML(){ - $bodyContent = "I am not xml"; - - $this->setupServerTillMetadata($bodyContent); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Try to parse body which has xml d:multipart element which - * has not been declared <d:multipart xmlns:d='DAV:'> - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Bundle metadata does not contain d:multipart children elements - */ - public function testHandleWrongMetadataWrongXMLdElement(){ - $bodyContent = "<?xml version='1.0' encoding='UTF-8'?><d:multipart></d:multipart>"; - - $this->setupServerTillMetadata($bodyContent); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * This test checks that exception is raised for - * parsed XML which contains empty(without d:part elements) d:multipart section in metadata XML - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Bundle metadata does not contain d:multipart/d:part/d:prop children elements - */ - public function testHandleEmptyMultipartMetadataSection(){ - $bodyContent = "<?xml version='1.0' encoding='UTF-8'?><d:multipart xmlns:d='DAV:'></d:multipart>"; - - $this->setupServerTillMetadata($bodyContent); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Metadata contains part properties not containing obligatory field will raise exception - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage Undefined index: oc-id - */ - public function testHandleWrongMetadataNoPartID(){ - $bodyContent = "<?xml version='1.0' encoding='UTF-8'?> - <d:multipart xmlns:d='DAV:'> - <d:part> - <d:prop> - </d:prop> - </d:part> - </d:multipart>"; - - $this->setupServerTillMetadata($bodyContent); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * In the request, insert two files with the same Content-ID - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage One or more files have the same Content-ID 1. Unable to parse whole bundle request - */ - public function testHandleWrongMetadataMultipleIDs(){ - $bodyContent = "<?xml version='1.0' encoding='UTF-8'?> - <d:multipart xmlns:d='DAV:'> - <d:part> - <d:prop> - <d:oc-path>/test/zombie1.jpg</d:oc-path>\n - <d:oc-mtime>1476393386</d:oc-mtime>\n - <d:oc-id>1</d:oc-id>\n - <d:oc-total-length>6</d:oc-total-length>\n - </d:prop> - </d:part> - <d:part> - <d:prop> - <d:oc-path>/test/zombie2.jpg</d:oc-path>\n - <d:oc-mtime>1476393386</d:oc-mtime>\n - <d:oc-id>1</d:oc-id>\n - <d:oc-total-length>6</d:oc-total-length>\n - </d:prop> - </d:part> - </d:multipart>"; - - $this->setupServerTillMetadata($bodyContent); - - $this->plugin->handleBundle($this->request, $this->response); - } - - /** - * Specify metadata part without corresponding binary content - * - */ - public function testHandleWithoutBinaryContent(){ - $bodyContent = "<?xml version='1.0' encoding='UTF-8'?> - <d:multipart xmlns:d='DAV:'> - <d:part> - <d:prop> - <d:oc-path>/test/zombie1.jpg</d:oc-path>\n - <d:oc-mtime>1476393386</d:oc-mtime>\n - <d:oc-id>1</d:oc-id>\n - <d:oc-total-length>6</d:oc-total-length>\n - </d:prop> - </d:part> - </d:multipart>"; - - $this->setupServerTillMetadata($bodyContent); - $this->plugin->handleBundle($this->request, $this->response); - $return = $this->response->getBody(); - $this->assertTrue(false != $return); - $xml = simplexml_load_string($return); - $this->assertTrue(false != $xml); - $xml->registerXPathNamespace('d','urn:DAV'); - $xml->registerXPathNamespace('s','http://sabredav.org/ns'); - - $this->assertEquals(1, count($xml->xpath('/d:multistatus'))); - - $fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:status'); - $this->assertTrue(false != $fileMetadataObjectXML); - $this->assertEquals(1, count($fileMetadataObjectXML)); - $this->assertEquals("HTTP/1.1 400 Bad Request", (string) $fileMetadataObjectXML[0]); - - $fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:prop/d:error/s:message'); - $this->assertTrue(false != $fileMetadataObjectXML); - $this->assertEquals(1, count($fileMetadataObjectXML)); - $this->assertEquals("File parsing error", (string) $fileMetadataObjectXML[0]); - } - - /** - * This test will simulate success and failure in putFile class. - * - */ - public function testHandlePutFile(){ - $this->setupServerTillData(); - - $this->view - ->method('file_exists') - ->will($this->onConsecutiveCalls(true, false, $this->throwException(new StorageNotAvailableException()))); - - $this->plugin->handleBundle($this->request, $this->response); - - $return = $this->response->getBody(); - $this->assertTrue(false != $return); - $xml = simplexml_load_string($return); - $this->assertTrue(false != $xml); - $xml->registerXPathNamespace('d','urn:DAV'); - $xml->registerXPathNamespace('s','http://sabredav.org/ns'); - - $this->assertEquals(1, count($xml->xpath('/d:multistatus'))); - - $fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:status'); - $this->assertTrue(false != $fileMetadataObjectXML); - $this->assertEquals(3, count($fileMetadataObjectXML)); - $this->assertEquals("HTTP/1.1 400 Bad Request", (string) $fileMetadataObjectXML[0]); - $this->assertEquals("HTTP/1.1 200 OK", (string) $fileMetadataObjectXML[1]); - $this->assertEquals("HTTP/1.1 400 Bad Request", (string) $fileMetadataObjectXML[2]); - - $fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:prop/d:error/s:message'); - $this->assertTrue(false != $fileMetadataObjectXML); - $this->assertEquals(2, count($fileMetadataObjectXML)); - $this->assertEquals("Bundling not supported for already existing files", (string) $fileMetadataObjectXML[0]); - $this->assertEquals("StorageNotAvailableException raised", (string) $fileMetadataObjectXML[1]); - } - - /*UTILITIES*/ - - private function setupServerTillData(){ - $bodyContent = "<?xml version='1.0' encoding='UTF-8'?> - <d:multipart xmlns:d='DAV:'> - <d:part> - <d:prop> - <d:oc-path>/test/zombie1.jpg</d:oc-path>\n - <d:oc-mtime>1476393386</d:oc-mtime>\n - <d:oc-id>0</d:oc-id>\n - <d:oc-total-length>7</d:oc-total-length>\n - </d:prop> - </d:part> - <d:part> - <d:prop> - <d:oc-path>/test/zombie2.jpg</d:oc-path>\n - <d:oc-mtime>1476393386</d:oc-mtime>\n - <d:oc-id>1</d:oc-id>\n - <d:oc-total-length>7</d:oc-total-length>\n - </d:prop> - </d:part> - <d:part> - <d:prop> - <d:oc-path>zombie3.jpg</d:oc-path>\n - <d:oc-mtime>1476393232</d:oc-mtime>\n - <d:oc-id>2</d:oc-id>\n - <d:oc-total-length>7</d:oc-total-length>\n - </d:prop> - </d:part> - </d:multipart>"; - - $headers['content-length'] = strlen($bodyContent); - $headers['content-type'] = 'text/xml; charset=utf-8'; - - //this part will have some arbitrary, correct headers - $bodyFull = "--".self::BOUNDRARY - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\nContent-length: ".$headers['content-length'] - ."\r\n\r\n" - ."$bodyContent" - ."\r\n--".self::BOUNDRARY - ."\r\nContent-ID: 0" - ."\r\n\r\n" - ."zombie1" - ."\r\n--".self::BOUNDRARY - ."\r\nContent-ID: 1" - ."\r\n\r\n" - ."zombie2" - ."\r\n--".self::BOUNDRARY - ."\r\nContent-ID: 2" - ."\r\n\r\n" - ."zombie3" - ."\r\n--".self::BOUNDRARY."--"; - - $this->setupServerTillHeader(); - - $this->fillMultipartContentsParserStreamWithBody($bodyFull); - } - - private function setupServerTillMetadata($bodyContent){ - $headers['content-length'] = strlen($bodyContent); - $headers['content-type'] = 'text/xml; charset=utf-8'; - - //this part will have some arbitrary, correct headers - $bodyFull = "--".self::BOUNDRARY - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\nContent-length: ".$headers['content-length'] - ."\r\n\r\n" - ."$bodyContent\r\n--".self::BOUNDRARY."--"; - - $this->setupServerTillHeader(); - - $this->fillMultipartContentsParserStreamWithBody($bodyFull); - } - - private function setupServerTillHeader(){ - $this->setupServerTillFilesHome(); - - $this->request - ->expects($this->atLeastOnce()) - ->method('getHeader') - ->with('Content-Type') - ->will($this->returnValue("multipart/related; boundary=".self::BOUNDRARY)); - - $this->server - ->expects($this->once()) - ->method('emit') - ->will($this->returnValue(true)); - } - - private function setupServerTillFilesHome(){ - $this->request - ->expects($this->once()) - ->method('getPath') - ->will($this->returnValue('files/admin')); - - $node = $this->getMockBuilder('\OCA\DAV\Files\FilesHome') - ->disableOriginalConstructor() - ->getMock(); - - $this->server->tree->expects($this->once()) - ->method('getNodeForPath') - ->with('files/admin') - ->will($this->returnValue($node)); - } - - private function fillMultipartContentsParserStreamWithBody($bodyString){ - $bodyStream = fopen('php://temp', 'r+'); - fwrite($bodyStream, $bodyString); - rewind($bodyStream); - - $this->request->expects($this->any()) - ->method('getBody') - ->willReturn($bodyStream); - } - - public function tearDown() { - $userManager = \OC::$server->getUserManager(); - $userManager->get($this->user)->delete(); - unset($_SERVER['HTTP_OC_CHUNKED']); - - parent::tearDown(); - } -} diff --git a/apps/dav/tests/unit/Files/BundledFileTest.php b/apps/dav/tests/unit/Files/BundledFileTest.php deleted file mode 100644 index e1a65459b60..00000000000 --- a/apps/dav/tests/unit/Files/BundledFileTest.php +++ /dev/null @@ -1,220 +0,0 @@ -<?php -/** - * @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com> - * @author Louis Chemineau <louis@chmn.me> - * - * @copyright Copyright (c) 2016, ownCloud GmbH. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\DAV\Files; - -use OCP\Lock\ILockingProvider; - -/** - * Class File - * - * @group DB - * - * @package OCA\DAV\Tests\unit\Connector\Sabre - */ -class BundledFileTest extends \Test\TestCase { - - /** - * @var string - */ - private $user; - - /* BASICS */ - - public function setUp() { - parent::setUp(); - - \OC_Hook::clear(); - - $this->user = $this->getUniqueID('user_'); - $userManager = \OC::$server->getUserManager(); - $userManager->createUser($this->user, 'pass'); - - $this->loginAsUser($this->user); - } - - /* TESTS */ - - /** - * Test basic successful bundled file PutFile - */ - public function testPutFile() { - $bodyContent = 'blabla'; - $headers['oc-total-length'] = 6; - $headers['oc-path'] = '/foo.txt'; - $headers['oc-mtime'] = '1473336321'; - $headers['response'] = null; - - //this part will have some arbitrary, correct headers - $bodyFull = "$bodyContent\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - $this->doPutFIle($headers, $multipartContentsParser); - } - - /** - * Test basic successful bundled file PutFile - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage File requires oc-total-length header to be read - */ - public function testPutFileNoLength() { - $bodyContent = 'blabla'; - $headers['oc-path'] = '/foo.txt'; - $headers['oc-mtime'] = '1473336321'; - $headers['response'] = null; - - //this part will have some arbitrary, correct headers - $bodyFull = "$bodyContent\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - $this->doPutFIle($headers, $multipartContentsParser); - } - - /** - * Test putting a single file - * - * @expectedException \Sabre\DAV\Exception\Forbidden - * @expectedExceptionMessage PUT method not supported for bundling - */ - public function testThrowIfPut() { - $fileContents = $this->getStream('test data'); - $this->doPut('/foo.txt', $fileContents); - } - - /* UTILITIES */ - - private function getMockStorage() { - $storage = $this->getMockBuilder('\OCP\Files\Storage') - ->getMock(); - $storage->expects($this->any()) - ->method('getId') - ->will($this->returnValue('home::someuser')); - return $storage; - } - - public function tearDown() { - $userManager = \OC::$server->getUserManager(); - $userManager->get($this->user)->delete(); - unset($_SERVER['HTTP_OC_CHUNKED']); - - parent::tearDown(); - } - - /** - * @param string $string - */ - private function getStream($string) { - $stream = fopen('php://temp', 'r+'); - fwrite($stream, $string); - fseek($stream, 0); - return $stream; - } - - /** - * Do basic put for single bundled file - */ - private function doPutFIle($fileMetadata, $contentHandler, $view = null, $viewRoot = null) { - $path = $fileMetadata['oc-path']; - - if(is_null($view)){ - $view = \OC\Files\Filesystem::getView(); - } - if (!is_null($viewRoot)) { - $view = new \OC\Files\View($viewRoot); - } else { - $viewRoot = '/' . $this->user . '/files'; - } - - $info = new \OC\Files\FileInfo( - $viewRoot . '/' . ltrim($path, '/'), - $this->getMockStorage(), - null, - ['permissions' => \OCP\Constants::PERMISSION_ALL], - null - ); - - $file = new BundledFile($view, $info, $contentHandler); - - // beforeMethod locks - $view->lockFile($path, ILockingProvider::LOCK_SHARED); - - $result = $file->putFile($fileMetadata); - - // afterMethod unlocks - $view->unlockFile($path, ILockingProvider::LOCK_SHARED); - - return $result; - } - - private function fillMultipartContentsParserStreamWithBody($bodyString){ - $bodyStream = fopen('php://temp', 'r+'); - fwrite($bodyStream, $bodyString); - rewind($bodyStream); - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - $request->expects($this->any()) - ->method('getBody') - ->willReturn($bodyStream); - - $mcp = new \OCA\DAV\Files\MultipartContentsParser($request); - return $mcp; - } - - /** - * Simulate putting a file to the given path. - * - * @param string $path path to put the file into - * @param string $viewRoot root to use for the view - * - * @return null|string of the PUT operaiton which is usually the etag - */ - private function doPut($path, $fileContents, $viewRoot = null) { - $view = \OC\Files\Filesystem::getView(); - if (!is_null($viewRoot)) { - $view = new \OC\Files\View($viewRoot); - } else { - $viewRoot = '/' . $this->user . '/files'; - } - - $info = new \OC\Files\FileInfo( - $viewRoot . '/' . ltrim($path, '/'), - $this->getMockStorage(), - null, - ['permissions' => \OCP\Constants::PERMISSION_ALL], - null - ); - - $file = new BundledFile($view, $info, null); - - // beforeMethod locks - $view->lockFile($path, ILockingProvider::LOCK_SHARED); - - $result = $file->put($fileContents); - - // afterMethod unlocks - $view->unlockFile($path, ILockingProvider::LOCK_SHARED); - - return $result; - } -} diff --git a/apps/dav/tests/unit/Files/MultipartContentsParserTest.php b/apps/dav/tests/unit/Files/MultipartContentsParserTest.php deleted file mode 100644 index 6d23fe296d2..00000000000 --- a/apps/dav/tests/unit/Files/MultipartContentsParserTest.php +++ /dev/null @@ -1,416 +0,0 @@ -<?php -/** - * @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com> - * @author Louis Chemineau <louis@chmn.me> - * - * @copyright Copyright (c) 2016, ownCloud GmbH. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\DAV\Tests\unit\DAV; - -use Test\TestCase; - -class MultipartContentsParserTest extends TestCase { - private $boundrary; - - protected function setUp() { - parent::setUp(); - - $this->boundrary = 'boundary'; - - } - - /*TESTS*/ - - /** - * Test basic gets() functionality, that if passed string instead of resource, it should fail - * - * @expectedException \Sabre\DAV\Exception\BadRequest - * @expectedExceptionMessage Unable to get request content - */ - public function testGetsThrowWrongContents() { - //TODO - $bodyStream = "I am not a stream, but pretend to be"; - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - $request->expects($this->any()) - ->method('getBody') - ->willReturn($bodyStream); - - $mcp = new \OCA\DAV\Files\MultipartContentsParser($request); - - $mcp->gets(); - } - - /** - * Test function readHeaders(), so if passed empty string, it will return null - * - */ - public function testReadHeadersThrowEmptyHeader() { - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - - $mcp = new \OCA\DAV\Files\MultipartContentsParser($request); - $mcp->readHeaders(''); - $this->assertEquals(null, $mcp->readHeaders('')); - } - - /** - * streamRead function with incorrect parameter - * - * @expectedException \Sabre\DAV\Exception\BadRequest - * @expectedExceptionMessage Method streamRead cannot read contents with negative length - */ - public function testStreamReadToStringThrowNegativeLength() { - $bodyContent = 'blabla'; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyContent); - //give negative length - $multipartContentsParser->streamReadToString(-1); - } - - /** - * streamRead function with incorrect parameter - * - * @expectedException \Sabre\DAV\Exception\BadRequest - * @expectedExceptionMessage Method streamRead cannot read contents with negative length - */ - public function testStreamReadToStreamThrowNegativeLength() { - $target = fopen('php://temp', 'r+'); - $bodyContent = 'blabla'; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyContent); - //give negative length - $multipartContentsParser->streamReadToStream($target,-1); - } - - public function testStreamReadToString() { - $length = 0; - list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length); - $this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length)); - - $length = 1000; - list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length); - $this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length)); - - $length = 8192; - list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length); - $this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length)); - - $length = 20000; - list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length); - $this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length)); - } - - public function testStreamReadToStream() { - $length = 0; - $this->streamReadToStreamBuilder($length); - - $length = 1000; - $this->streamReadToStreamBuilder($length); - - $length = 8192; - $this->streamReadToStreamBuilder($length); - - $length = 20000; - $this->streamReadToStreamBuilder($length); - } - - private function streamReadToStreamBuilder($length) { - $target = fopen('php://temp', 'r+'); - list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length); - $this->assertEquals(true, $multipartContentsParser->streamReadToStream($target,$length)); - rewind($target); - $this->assertEquals($bodyString, stream_get_contents($target)); - } - - /** - * @expectedException \Exception - * @expectedExceptionMessage An error appears while reading and parsing header of content part using fgets - */ - public function testGetPartThrowFailfgets() { - $bodyStream = fopen('php://temp', 'r+'); - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - $request->expects($this->any()) - ->method('getBody') - ->willReturn($bodyStream); - - $mcp = $this->getMockBuilder('OCA\DAV\Files\MultipartContentsParser') - ->setConstructorArgs(array($request)) - ->setMethods(array('gets')) - ->getMock(); - - $mcp->expects($this->any()) - ->method('gets') - ->will($this->onConsecutiveCalls("--boundary\r\n", "Content-ID: 0\r\n", false)); - - $mcp->getPartHeaders($this->boundrary); - } - - /** - * If one one the content parts does not contain boundrary, means that received wrong request - * - * @expectedException \Exception - * @expectedExceptionMessage Expected boundary delimiter in content part - */ - public function testGetPartThrowNoBoundraryFound() { - // Calling multipletimes getPart on parts without contents should return null,null and signal immedietaly that endDelimiter was reached - $bodyFull = "--boundary_wrong\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - $multipartContentsParser->getPartHeaders($this->boundrary); - } - - /** - * Reading from request which method getBody returns false - * - * @expectedException \Sabre\DAV\Exception\BadRequest - * @expectedExceptionMessage Unable to get request content - */ - public function testStreamReadThrowWrongBody() { - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - $request->expects($this->any()) - ->method('getBody') - ->willReturn(false); - - $mcp = new \OCA\DAV\Files\MultipartContentsParser($request); - $mcp->getPartHeaders($this->boundrary); - } - - /** - * Reading from request which method getBody returns false - * - */ - public function testMultipartContentSeekToContentLength() { - $bodyStream = fopen('php://temp', 'r+'); - $bodyString = ''; - $length = 1000; - for ($x = 0; $x < $length; $x++) { - $bodyString .= 'k'; - } - fwrite($bodyStream, $bodyString); - rewind($bodyStream); - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - $request->expects($this->any()) - ->method('getBody') - ->willReturn($bodyStream); - - $mcp = new \OCA\DAV\Files\MultipartContentsParser($request); - $this->assertEquals(true,$mcp->multipartContentSeekToContentLength($length)); - } - - /** - * Test cases with wrong or incomplete boundraries - * - */ - public function testGetPartHeadersWrongBoundaryCases() { - // Calling multipletimes getPart on parts without contents should return null and signal immedietaly that endDelimiter was reached - $bodyFull = "--boundary\r\n--boundary_wrong\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - $this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary)); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - - // Test empty content - $bodyFull = "--boundary\r\n"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - $this->assertEquals(null, $multipartContentsParser->getPartHeaders($this->boundrary)); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - - // Test empty content - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody(''); - $this->assertEquals(null, $multipartContentsParser->getPartHeaders($this->boundrary)); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - - // Calling multipletimes getPart on parts without contents should return null and signal immedietaly that endDelimiter was reached - // endDelimiter should be signaled after first getPart since it will read --boundrary till it finds contents. - $bodyFull = "--boundary\r\n--boundary\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - $this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary)); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - $this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary)); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - $this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary)); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - $this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary)); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - } - - /** - * Test will check if we can correctly parse headers and content using streamReadToString - * - */ - public function testReadHeaderBodyCorrect() { - //multipart part will have some content bodyContent and some headers - $bodyContent = 'blabla'; - $headers['content-length'] = '6'; - $headers['content-type'] = 'text/xml; charset=utf-8'; - - //this part will have some arbitrary, correct headers - $bodyFull = '--boundary' - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\nContent-length: ".$headers['content-length'] - ."\r\n\r\n" - ."$bodyContent\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - //parse it - $headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary); - $bodyParsed = $multipartContentsParser->streamReadToString(6); - - //check if end delimiter is not reached, since we just read 6 bytes, and stopped at \r\n - $this->assertEquals(false,$multipartContentsParser->getEndDelimiterReached()); - - //check that we parsed correct headers - $this->assertEquals($bodyContent, $bodyParsed); - $this->assertEquals($headers, $headersParsed); - - //parse further to check if there is new part. There is no, so headers are null and delimiter reached - $headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary); - $this->assertEquals(null,$headersParsed); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - } - - /** - * Test will check parsing incorrect headers and content using streamReadToString - * - */ - public function testReadHeaderBodyIncorrect() { - - //multipart part will have some content bodyContent and some headers - $bodyContent = 'blabla'; - $headers['content-length'] = '6'; - $headers['content-type'] = 'text/xml; charset=utf-8'; - - //this part will one correct and one incorrect header - $bodyFull = '--boundary' - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\nContent-length" - ."\r\n\r\n" - ."$bodyContent\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - //parse it and expect null, since contains incorrect headers - $headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary); - $this->assertEquals(null, $headersParsed); - $this->assertEquals(false,$multipartContentsParser->getEndDelimiterReached()); - - //parse further to check if next call with not read headers again - //this should return null again and get to end of delimiter - $headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary); - $this->assertEquals(null,$headersParsed); - $this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached()); - } - - /** - * Test will check reading error in StreamReadToString - * - * @expectedException \Sabre\DAV\Exception\BadRequest - * @expectedExceptionMessage Method streamRead read 20 expeceted 60 - */ - public function testReadBodyIncorrect() { - //multipart part will have some content bodyContent and content-length header will specify to big value - //this - $bodyContent = 'blabla'; - $headers['content-length'] = '60'; - $headers['content-type'] = 'text/xml; charset=utf-8'; - - //this part will have some arbitrary, correct headers - $bodyFull = '--boundary' - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\nContent-length: ".$headers['content-length'] - ."\r\n\r\n" - ."$bodyContent\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - //parse headers - $headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary); - $this->assertEquals($headers, $headersParsed); - - $this->assertEquals(true, array_key_exists('content-length',$headersParsed)); - $multipartContentsParser->streamReadToString($headersParsed['content-length']); - } - - /** - * Test will check reading error in StreamReadToString return false - * - */ - public function testReadBodyStreamIncorrect() { - //multipart part will have some content bodyContent and content-length header will specify to big value - //this - $bodyContent = 'blabla'; - $headers['content-length'] = '60'; - $headers['content-type'] = 'text/xml; charset=utf-8'; - - //this part will have some arbitrary, correct headers - $bodyFull = '--boundary' - ."\r\nContent-Type: ".$headers['content-type'] - ."\r\nContent-length: ".$headers['content-length'] - ."\r\n\r\n" - ."$bodyContent\r\n--boundary--"; - $multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull); - - //parse headers - $headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary); - $this->assertEquals($headers, $headersParsed); - - $this->assertEquals(true, array_key_exists('content-length',$headersParsed)); - $target = fopen('php://temp', 'r+'); - $bodyParsed = $multipartContentsParser->streamReadToStream($target, $headersParsed['content-length']); - $this->assertEquals(false, $bodyParsed); - } - - /*UTILITIES*/ - - private function fillMultipartContentsParserStreamWithChars($length){ - $bodyStream = fopen('php://temp', 'r+'); - $bodyString = ''; - for ($x = 0; $x < $length; $x++) { - $bodyString .= 'k'; - } - fwrite($bodyStream, $bodyString); - rewind($bodyStream); - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - $request->expects($this->any()) - ->method('getBody') - ->willReturn($bodyStream); - - $mcp = new \OCA\DAV\Files\MultipartContentsParser($request); - return array($mcp, $bodyString); - } - - private function fillMultipartContentsParserStreamWithBody($bodyString){ - $bodyStream = fopen('php://temp', 'r+'); - fwrite($bodyStream, $bodyString); - rewind($bodyStream); - $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') - ->disableOriginalConstructor() - ->getMock(); - $request->expects($this->any()) - ->method('getBody') - ->willReturn($bodyStream); - - $mcp = new \OCA\DAV\Files\MultipartContentsParser($request); - return $mcp; - } -} diff --git a/apps/dav/tests/unit/Files/MultipartRequestParserTest.php b/apps/dav/tests/unit/Files/MultipartRequestParserTest.php new file mode 100644 index 00000000000..ec9e2d0a383 --- /dev/null +++ b/apps/dav/tests/unit/Files/MultipartRequestParserTest.php @@ -0,0 +1,281 @@ +<?php +/** + * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ + +namespace OCA\DAV\Tests\unit\DAV; + +use Test\TestCase; +use \OCA\DAV\BulkUpload\MultipartRequestParser; + +class MultipartRequestParserTest extends TestCase { + private function getValidBodyObject() { + return [ + [ + "headers" => [ + "Content-Length" => 7, + "X-File-MD5" => "4f2377b4d911f7ec46325fe603c3af03", + "X-File-Path" => "/coucou.txt" + ], + "content" => "Coucou\n" + ] + ]; + } + + private function getMultipartParser(array $parts, array $headers = [], string $boundary = "boundary_azertyuiop"): MultipartRequestParser { + $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') + ->disableOriginalConstructor() + ->getMock(); + + $headers = array_merge(['Content-Type' => 'multipart/related; boundary='.$boundary], $headers); + $request->expects($this->any()) + ->method('getHeader') + ->willReturnCallback(function (string $key) use (&$headers) { + return $headers[$key]; + }); + + $body = ""; + foreach ($parts as $part) { + $body .= '--'.$boundary."\r\n"; + + foreach ($part['headers'] as $headerKey => $headerPart) { + $body .= $headerKey.": ".$headerPart."\r\n"; + } + + $body .= "\r\n"; + $body .= $part['content']."\r\n"; + } + + $body .= '--'.$boundary."--"; + + $stream = fopen('php://temp','r+'); + fwrite($stream, $body); + rewind($stream); + + $request->expects($this->any()) + ->method('getBody') + ->willReturn($stream); + + return new MultipartRequestParser($request); + } + + + /** + * Test validation of the request's body type + */ + public function testBodyTypeValidation() { + $bodyStream = "I am not a stream, but pretend to be"; + $request = $this->getMockBuilder('Sabre\HTTP\RequestInterface') + ->disableOriginalConstructor() + ->getMock(); + $request->expects($this->any()) + ->method('getBody') + ->willReturn($bodyStream); + + $this->expectExceptionMessage('Body should be of type resource'); + new MultipartRequestParser($request); + } + + /** + * Test with valid request. + * - valid boundary + * - valid md5 hash + * - valid content-length + * - valid file content + * - valid file path + */ + public function testValidRequest() { + $multipartParser = $this->getMultipartParser( + $this->getValidBodyObject() + ); + + [$headers, $content] = $multipartParser->parseNextPart(); + + $this->assertSame((int)$headers["content-length"], 7, "Content-Length header should be the same as provided."); + $this->assertSame($headers["x-file-md5"], "4f2377b4d911f7ec46325fe603c3af03", "X-File-MD5 header should be the same as provided."); + $this->assertSame($headers["x-file-path"], "/coucou.txt", "X-File-Path header should be the same as provided."); + + $this->assertSame($content, "Coucou\n", "Content should be the same"); + } + + /** + * Test with invalid md5 hash. + */ + public function testInvalidMd5Hash() { + $bodyObject = $this->getValidBodyObject(); + $bodyObject["0"]["headers"]["X-File-MD5"] = "f2377b4d911f7ec46325fe603c3af03"; + $multipartParser = $this->getMultipartParser( + $bodyObject + ); + + $this->expectExceptionMessage('Computed md5 hash is incorrect.'); + $multipartParser->parseNextPart(); + } + + /** + * Test with a null md5 hash. + */ + public function testNullMd5Hash() { + $bodyObject = $this->getValidBodyObject(); + unset($bodyObject["0"]["headers"]["X-File-MD5"]); + $multipartParser = $this->getMultipartParser( + $bodyObject + ); + + $this->expectExceptionMessage('The X-File-MD5 header must not be null.'); + $multipartParser->parseNextPart(); + } + + /** + * Test with a null Content-Length. + */ + public function testNullContentLength() { + $bodyObject = $this->getValidBodyObject(); + unset($bodyObject["0"]["headers"]["Content-Length"]); + $multipartParser = $this->getMultipartParser( + $bodyObject + ); + + $this->expectExceptionMessage('The Content-Length header must not be null.'); + $multipartParser->parseNextPart(); + } + + /** + * Test with a lower Content-Length. + */ + public function testLowerContentLength() { + $bodyObject = $this->getValidBodyObject(); + $bodyObject["0"]["headers"]["Content-Length"] = 6; + $multipartParser = $this->getMultipartParser( + $bodyObject + ); + + $this->expectExceptionMessage('Computed md5 hash is incorrect.'); + $multipartParser->parseNextPart(); + } + + /** + * Test with a higher Content-Length. + */ + public function testHigherContentLength() { + $bodyObject = $this->getValidBodyObject(); + $bodyObject["0"]["headers"]["Content-Length"] = 8; + $multipartParser = $this->getMultipartParser( + $bodyObject + ); + + $this->expectExceptionMessage('Computed md5 hash is incorrect.'); + $multipartParser->parseNextPart(); + } + + /** + * Test with wrong boundary in body. + */ + public function testWrongBoundary() { + $bodyObject = $this->getValidBodyObject(); + $multipartParser = $this->getMultipartParser( + $bodyObject, + ['Content-Type' => 'multipart/related; boundary=boundary_poiuytreza'] + ); + + $this->expectExceptionMessage('Boundary not found where it should be.'); + $multipartParser->parseNextPart(); + } + + /** + * Test with no boundary in request headers. + */ + public function testNoBoundaryInHeader() { + $bodyObject = $this->getValidBodyObject(); + $this->expectExceptionMessage('Error while parsing boundary in Content-Type header.'); + $this->getMultipartParser( + $bodyObject, + ['Content-Type' => 'multipart/related'] + ); + } + + /** + * Test with no boundary in the request's headers. + */ + public function testNoBoundaryInBody() { + $bodyObject = $this->getValidBodyObject(); + $multipartParser = $this->getMultipartParser( + $bodyObject, + ['Content-Type' => 'multipart/related; boundary=boundary_azertyuiop'], + '' + ); + + $this->expectExceptionMessage('Boundary not found where it should be.'); + $multipartParser->parseNextPart(); + } + + /** + * Test with a boundary with quotes in the request's headers. + */ + public function testBoundaryWithQuotes() { + $bodyObject = $this->getValidBodyObject(); + $multipartParser = $this->getMultipartParser( + $bodyObject, + ['Content-Type' => 'multipart/related; boundary="boundary_azertyuiop"'], + ); + + $multipartParser->parseNextPart(); + + // Dummy assertion, we just want to test that the parsing works. + $this->assertTrue(true); + } + + /** + * Test with a wrong Content-Type in the request's headers. + */ + public function testWrongContentType() { + $bodyObject = $this->getValidBodyObject(); + $this->expectExceptionMessage('Content-Type must be multipart/related'); + $this->getMultipartParser( + $bodyObject, + ['Content-Type' => 'multipart/form-data; boundary="boundary_azertyuiop"'], + ); + } + + /** + * Test with a wrong key after the content type in the request's headers. + */ + public function testWrongKeyInContentType() { + $bodyObject = $this->getValidBodyObject(); + $this->expectExceptionMessage('Boundary is invalid'); + $this->getMultipartParser( + $bodyObject, + ['Content-Type' => 'multipart/related; wrongkey="boundary_azertyuiop"'], + ); + } + + /** + * Test with a null Content-Type in the request's headers. + */ + public function testNullContentType() { + $bodyObject = $this->getValidBodyObject(); + $this->expectExceptionMessage('Content-Type can not be null'); + $this->getMultipartParser( + $bodyObject, + ['Content-Type' => null], + + ); + } +} |