summaryrefslogtreecommitdiffstats
path: root/apps/dav
diff options
context:
space:
mode:
authorLouis Chemineau <louis@chmn.me>2021-09-16 19:09:38 +0200
committerLouis Chemineau <louis@chmn.me>2021-10-15 11:54:01 +0200
commitfc6e07705a0915614c76a9ac4f0e9834c1bc4644 (patch)
tree6807a9ac447ecc5a9dfa420f9d3c2c052d50c7a8 /apps/dav
parent5645b2a18ad6d719e2de42760be238dc56460fbb (diff)
downloadnextcloud-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.php3
-rw-r--r--apps/dav/composer/composer/autoload_static.php3
-rw-r--r--apps/dav/lib/BundleUpload/BundledFile.php214
-rw-r--r--apps/dav/lib/BundleUpload/BundlingPlugin.php456
-rw-r--r--apps/dav/lib/BundleUpload/MultipartContentsParser.php497
-rw-r--r--apps/dav/lib/Capabilities.php2
-rw-r--r--apps/dav/lib/Connector/Sabre/File.php8
-rw-r--r--apps/dav/lib/Server.php6
-rwxr-xr-xapps/dav/tests/temporary/bundling_profile.sh149
-rwxr-xr-xapps/dav/tests/temporary/bundling_tests.sh70
-rwxr-xr-xapps/dav/tests/temporary/put_test.sh12
-rw-r--r--apps/dav/tests/temporary/screenshot.pngbin0 -> 183411 bytes
-rw-r--r--apps/dav/tests/unit/Files/BundlePluginTest.php711
-rw-r--r--apps/dav/tests/unit/Files/BundledFileTest.php220
-rw-r--r--apps/dav/tests/unit/Files/MultipartContentsParserTest.php416
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
new file mode 100644
index 00000000000..c4e77653128
--- /dev/null
+++ b/apps/dav/tests/temporary/screenshot.png
Binary files differ
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;
+ }
+}