diff options
author | Louis Chemineau <louis@chmn.me> | 2021-09-16 19:09:38 +0200 |
---|---|---|
committer | Louis Chemineau <louis@chmn.me> | 2021-10-15 11:54:01 +0200 |
commit | fc6e07705a0915614c76a9ac4f0e9834c1bc4644 (patch) | |
tree | 6807a9ac447ecc5a9dfa420f9d3c2c052d50c7a8 /apps/dav | |
parent | 5645b2a18ad6d719e2de42760be238dc56460fbb (diff) | |
download | nextcloud-server-fc6e07705a0915614c76a9ac4f0e9834c1bc4644.tar.gz nextcloud-server-fc6e07705a0915614c76a9ac4f0e9834c1bc4644.zip |
Add BulkUpload DAV plugin
Signed-off-by: Louis Chemineau <louis@chmn.me>
Diffstat (limited to 'apps/dav')
-rw-r--r-- | apps/dav/composer/composer/autoload_classmap.php | 3 | ||||
-rw-r--r-- | apps/dav/composer/composer/autoload_static.php | 3 | ||||
-rw-r--r-- | apps/dav/lib/BundleUpload/BundledFile.php | 214 | ||||
-rw-r--r-- | apps/dav/lib/BundleUpload/BundlingPlugin.php | 456 | ||||
-rw-r--r-- | apps/dav/lib/BundleUpload/MultipartContentsParser.php | 497 | ||||
-rw-r--r-- | apps/dav/lib/Capabilities.php | 2 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/File.php | 8 | ||||
-rw-r--r-- | apps/dav/lib/Server.php | 6 | ||||
-rwxr-xr-x | apps/dav/tests/temporary/bundling_profile.sh | 149 | ||||
-rwxr-xr-x | apps/dav/tests/temporary/bundling_tests.sh | 70 | ||||
-rwxr-xr-x | apps/dav/tests/temporary/put_test.sh | 12 | ||||
-rw-r--r-- | apps/dav/tests/temporary/screenshot.png | bin | 0 -> 183411 bytes | |||
-rw-r--r-- | apps/dav/tests/unit/Files/BundlePluginTest.php | 711 | ||||
-rw-r--r-- | apps/dav/tests/unit/Files/BundledFileTest.php | 220 | ||||
-rw-r--r-- | apps/dav/tests/unit/Files/MultipartContentsParserTest.php | 416 |
15 files changed, 2763 insertions, 4 deletions
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 62c41a0828d..e7e29d85d89 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -22,6 +22,9 @@ 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\\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 5d5f57eb51b..ee23085eee5 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -37,6 +37,9 @@ 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\\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/BundleUpload/BundledFile.php b/apps/dav/lib/BundleUpload/BundledFile.php new file mode 100644 index 00000000000..db9b5bbd3fe --- /dev/null +++ b/apps/dav/lib/BundleUpload/BundledFile.php @@ -0,0 +1,214 @@ +<?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 new file mode 100644 index 00000000000..b3c7a007ac2 --- /dev/null +++ b/apps/dav/lib/BundleUpload/BundlingPlugin.php @@ -0,0 +1,456 @@ +<?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 new file mode 100644 index 00000000000..93b24539c49 --- /dev/null +++ b/apps/dav/lib/BundleUpload/MultipartContentsParser.php @@ -0,0 +1,497 @@ +<?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 5d4e3c05077..17db2346c68 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -3,6 +3,7 @@ * @copyright Copyright (c) 2016, ownCloud GmbH * * @author Thomas Müller <thomas.mueller@tmit.eu> + * @author Louis Chemineau <louis@chmn.me> * * @license AGPL-3.0 * @@ -28,6 +29,7 @@ class Capabilities implements ICapability { return [ 'dav' => [ 'chunking' => '1.0', + 'bundleupload' => '1.0', ] ]; } diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php index 5ff5f831eb5..ec33b44fe4b 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() . '"'; } - private function getPartFileBasePath($path) { + protected 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 */ - private function emitPreHooks($exists, $path = null) { + protected 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 */ - private function emitPostHooks($exists, $path = null) { + protected 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 */ - private function convertToSabreException(\Exception $e) { + protected 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 e9634f670d3..74ae94a6d51 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -62,10 +62,12 @@ 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\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; @@ -294,6 +296,10 @@ class Server { \OC::$server->getShareManager(), $view )); + $rootFolder = \OC::$server->query(IRootFolder::class); + $this->server->addPlugin( + new BundlingPlugin($view, $userFolder) + ); } $this->server->addPlugin(new \OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin( \OC::$server->getConfig(), diff --git a/apps/dav/tests/temporary/bundling_profile.sh b/apps/dav/tests/temporary/bundling_profile.sh new file mode 100755 index 00000000000..a86e5ba5be4 --- /dev/null +++ b/apps/dav/tests/temporary/bundling_profile.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +script_path="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +user='admin' +pass='admin' +server='localhost/owncloud' +upload="/tmp/upload.txt" + + +testfile2="$script_path/zombie.jpg" +size2=$(du -sb $testfile2 | awk '{ print $1 }') +md52=$(md5sum $testfile2 | awk '{ print $1 }') + +header="<?xml version='1.0' encoding='UTF-8'?>\n +<d:multipart xmlns:d=\"DAV:\">\n + <d:part>\n + <d:prop>\n + <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>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <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>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie3.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>2</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie4.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>3</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie5.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>4</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie6.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>5</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie7.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>6</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie8.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>7</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie9.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>8</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/test/zombie10.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>9</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n +</d:multipart>" +headersize=$(echo -en $header | wc -c) + +mdupload=$(md5sum $upload | awk '{ print $1 }') +boundrary="boundary_$mdupload" + +#CONTENTS +echo -en "--$boundrary\r\nContent-Type: text/xml; charset=utf-8\r\nContent-Length: $headersize\r\n\r\n" > $upload +echo -en $header >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 0\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 1\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 2\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 3\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 4\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 5\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 6\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 7\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 8\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +echo -en "\r\n--$boundrary\r\nContent-ID: 9\r\n\r\n" >> $upload +cat $testfile2 >> $upload + +#END BOUNDRARY +echo -en "\r\n--$boundrary--\r\n" >> $upload + +#POST +#curl -X DELETE -u $user:$pass --cookie "XDEBUG_SESSION=MROW4A;path=/;" "http://$server/remote.php/webdav/config.cfg" + +blackfire --samples 1 curl -X POST -H "Content-Type: multipart/related; boundary=$boundrary" --cookie "XDEBUG_SESSION=MROW4A;path=/;" \ + --data-binary "@$upload" \ + "http://$user:$pass@$server/remote.php/dav/files/$user" + + + + diff --git a/apps/dav/tests/temporary/bundling_tests.sh b/apps/dav/tests/temporary/bundling_tests.sh new file mode 100755 index 00000000000..3aa1eac3469 --- /dev/null +++ b/apps/dav/tests/temporary/bundling_tests.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -eu + +scriptPath="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +user='admin' +pass='password' +server='nextcloud.test' +upload="/tmp/upload.txt" + + +testFile1="$scriptPath/put_test.sh" +size1=$(du -sb "$testFile1" | awk '{ print $1 }') +# md51=$(md5sum "$testFile1" | awk '{ print $1 }') +id1="0" + +testFile2="$scriptPath/screenshot.png" +size2=$(du -sb "$testFile2" | awk '{ print $1 }') +# md52=$(md5sum "$testFile2" | awk '{ print $1 }') +id2="1" + +header="<?xml version='1.0' encoding='UTF-8'?>\n +<d:multipart xmlns:d=\"DAV:\">\n + <d:part>\n + <d:prop>\n + <d:oc-path>/put_test.sh</d:oc-path>\n + <d:oc-mtime>1476393777</d:oc-mtime>\n + <d:oc-id>$id1</d:oc-id>\n + <d:oc-total-length>$size1</d:oc-total-length>\n + </d:prop>\n + </d:part>\n + <d:part>\n + <d:prop>\n + <d:oc-path>/zombie.jpg</d:oc-path>\n + <d:oc-mtime>1476393386</d:oc-mtime>\n + <d:oc-id>$id2</d:oc-id>\n + <d:oc-total-length>$size2</d:oc-total-length>\n + </d:prop>\n + </d:part>\n +</d:multipart>" +headerSize=$(echo -en "$header" | wc -c) + +mdUpload=$(md5sum $upload | awk '{ print $1 }') +boundary="boundary_$mdUpload" + +#CONTENTS +echo -en "--$boundary\r\nContent-Type: text/xml; charset=utf-8\r\nContent-Length: $headerSize\r\n\r\n" > $upload +echo -en "$header" >> $upload + +cat "$upload" +echo -en "\r\n--$boundary\r\nContent-ID: $id1\r\n\r\n" >> $upload +cat "$testFile1" >> $upload + +echo -en "\r\n--$boundary\r\nContent-ID: $id2\r\n\r\n" >> $upload +cat "$testFile2" >> $upload + +#END boundary +echo -en "\r\n--$boundary--\r\n" >> $upload + +#POST +#curl -X DELETE -u $user:$pass --cookie "XDEBUG_SESSION=MROW4A;path=/;" "http://$server/remote.php/webdav/config.cfg" + +curl -X POST -k -H "Content-Type: multipart/related; boundary=$boundary" --cookie "XDEBUG_SESSION=MROW4A;path=/;" \ + --data-binary "@$upload" \ + "https://$user:$pass@$server/remote.php/dav/files/bundle" + + + + diff --git a/apps/dav/tests/temporary/put_test.sh b/apps/dav/tests/temporary/put_test.sh new file mode 100755 index 00000000000..c3b64dee448 --- /dev/null +++ b/apps/dav/tests/temporary/put_test.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +script_path="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +user='admin' +pass='admin' +server='localhost/owncloud' + +testfile2="$script_path/zombie.jpg" + +blackfire --samples 1 curl -X PUT -u $user:$pass --cookie "XDEBUG_SESSION=MROW4A;path=/;" --data-binary @"$testfile2" "http://$server/remote.php/webdav/test/zombie.jpg" +#curl -X PUT -u $user:$pass --cookie "XDEBUG_SESSION=MROW4A;path=/;" --data-binary @"$testfile2" "http://$server/remote.php/webdav/test/zombie.jpg" diff --git a/apps/dav/tests/temporary/screenshot.png b/apps/dav/tests/temporary/screenshot.png Binary files differnew file mode 100644 index 00000000000..c4e77653128 --- /dev/null +++ b/apps/dav/tests/temporary/screenshot.png diff --git a/apps/dav/tests/unit/Files/BundlePluginTest.php b/apps/dav/tests/unit/Files/BundlePluginTest.php new file mode 100644 index 00000000000..92309fe5021 --- /dev/null +++ b/apps/dav/tests/unit/Files/BundlePluginTest.php @@ -0,0 +1,711 @@ +<?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 new file mode 100644 index 00000000000..e1a65459b60 --- /dev/null +++ b/apps/dav/tests/unit/Files/BundledFileTest.php @@ -0,0 +1,220 @@ +<?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 new file mode 100644 index 00000000000..6d23fe296d2 --- /dev/null +++ b/apps/dav/tests/unit/Files/MultipartContentsParserTest.php @@ -0,0 +1,416 @@ +<?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; + } +} |