Clean BulkUpload plugin

Signed-off-by: Louis Chemineau <louis@chmn.me>
pull/28991/head
Louis Chemineau 3 years ago committed by Julius Härtl
parent dd938dadef
commit def983dc7e

@ -22,9 +22,8 @@ return array(
'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => $baseDir . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php',
'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => $baseDir . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\UploadCleanup' => $baseDir . '/../lib/BackgroundJob/UploadCleanup.php',
'OCA\\DAV\\BundleUpload\\BundledFile' => $baseDir . '/../lib/BundleUpload/BundledFile.php',
'OCA\\DAV\\BundleUpload\\BundlingPlugin' => $baseDir . '/../lib/BundleUpload/BundlingPlugin.php',
'OCA\\DAV\\BundleUpload\\MultipartContentsParser' => $baseDir . '/../lib/BundleUpload/MultipartContentsParser.php',
'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => $baseDir . '/../lib/BulkUpload/BulkUploadPlugin.php',
'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => $baseDir . '/../lib/BulkUpload/MultipartRequestParser.php',
'OCA\\DAV\\CalDAV\\Activity\\Backend' => $baseDir . '/../lib/CalDAV/Activity/Backend.php',
'OCA\\DAV\\CalDAV\\Activity\\Filter\\Calendar' => $baseDir . '/../lib/CalDAV/Activity/Filter/Calendar.php',
'OCA\\DAV\\CalDAV\\Activity\\Filter\\Todo' => $baseDir . '/../lib/CalDAV/Activity/Filter/Todo.php',

@ -37,9 +37,8 @@ class ComposerStaticInitDAV
'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => __DIR__ . '/..' . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php',
'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\UploadCleanup' => __DIR__ . '/..' . '/../lib/BackgroundJob/UploadCleanup.php',
'OCA\\DAV\\BundleUpload\\BundledFile' => __DIR__ . '/..' . '/../lib/BundleUpload/BundledFile.php',
'OCA\\DAV\\BundleUpload\\BundlingPlugin' => __DIR__ . '/..' . '/../lib/BundleUpload/BundlingPlugin.php',
'OCA\\DAV\\BundleUpload\\MultipartContentsParser' => __DIR__ . '/..' . '/../lib/BundleUpload/MultipartContentsParser.php',
'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => __DIR__ . '/..' . '/../lib/BulkUpload/BulkUploadPlugin.php',
'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => __DIR__ . '/..' . '/../lib/BulkUpload/MultipartRequestParser.php',
'OCA\\DAV\\CalDAV\\Activity\\Backend' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Backend.php',
'OCA\\DAV\\CalDAV\\Activity\\Filter\\Calendar' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Filter/Calendar.php',
'OCA\\DAV\\CalDAV\\Activity\\Filter\\Todo' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Filter/Todo.php',

@ -0,0 +1,100 @@
<?php
/**
* @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\BulkUpload;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use OCP\Files\Folder;
use OCP\AppFramework\Http;
class BulkUploadPlugin extends ServerPlugin {
/** @var Folder */
private $userFolder;
/** @var LoggerInterface */
private $logger;
public function __construct(Folder $userFolder, LoggerInterface $logger) {
$this->userFolder = $userFolder;
$this->logger = $logger;
}
/**
* Register listener on POST requests with the httpPost method.
*/
public function initialize(Server $server): void {
$server->on('method:POST', [$this, 'httpPost'], 10);
}
/**
* Handle POST requests on /dav/bulk
* - parsing is done with a MultipartContentsParser object
* - writing is done with the userFolder service
*
* Will respond with an object containing an ETag for every written files.
*/
public function httpPost(RequestInterface $request, ResponseInterface $response): bool {
// Limit bulk upload to the /dav/bulk endpoint
if ($request->getPath() !== "bulk") {
return true;
}
$multiPartParser = new MultipartRequestParser($request);
$writtenFiles = [];
while (!$multiPartParser->isAtLastBoundary()) {
try {
[$headers, $content] = $multiPartParser->parseNextPart();
} catch (\Exception $e) {
// Return early if an error occurs during parsing.
$this->logger->error($e->getMessage());
$response->setStatus(Http::STATUS_BAD_REQUEST);
$response->setBody(json_encode($writtenFiles));
return false;
}
try {
$node = $this->userFolder->newFile($headers['x-file-path'], $content);
$writtenFiles[$headers['x-file-path']] = [
"error" => false,
"etag" => $node->getETag(),
];
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['path' => $headers['x-file-path']]);
$writtenFiles[$headers['x-file-path']] = [
"error" => true,
"message" => $e->getMessage(),
];
}
}
$response->setStatus(Http::STATUS_OK);
$response->setBody(json_encode($writtenFiles));
return false;
}
}

@ -0,0 +1,239 @@
<?php
/**
* @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\BulkUpload;
use Sabre\HTTP\RequestInterface;
use Sabre\DAV\Exception;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\LengthRequired;
use OCP\AppFramework\Http;
class MultipartRequestParser {
/** @var resource */
private $stream;
/** @var string */
private $boundary = "";
/** @var string */
private $lastBoundary = "";
/**
* @throws BadRequest
*/
public function __construct(RequestInterface $request) {
$stream = $request->getBody();
$contentType = $request->getHeader('Content-Type');
if (!is_resource($stream)) {
throw new BadRequest('Body should be of type resource');
}
if ($contentType === null) {
throw new BadRequest("Content-Type can not be null");
}
$this->stream = $stream;
$boundary = $this->parseBoundaryFromHeaders($contentType);
$this->boundary = '--'.$boundary."\r\n";
$this->lastBoundary = '--'.$boundary."--\r\n";
}
/**
* Parse the boundary from the Content-Type header.
* Example: Content-Type: "multipart/related; boundary=boundary_bf38b9b4b10a303a28ed075624db3978"
*
* @throws BadRequest
*/
private function parseBoundaryFromHeaders(string $contentType): string {
try {
[$mimeType, $boundary] = explode(';', $contentType);
[$boundaryKey, $boundaryValue] = explode('=', $boundary);
} catch (\Exception $e) {
throw new BadRequest("Error while parsing boundary in Content-Type header.", Http::STATUS_BAD_REQUEST, $e);
}
$boundaryValue = trim($boundaryValue);
// Remove potential quotes around boundary value.
if (substr($boundaryValue, 0, 1) == '"' && substr($boundaryValue, -1) == '"') {
$boundaryValue = substr($boundaryValue, 1, -1);
}
if (trim($mimeType) !== 'multipart/related') {
throw new BadRequest('Content-Type must be multipart/related');
}
if (trim($boundaryKey) !== 'boundary') {
throw new BadRequest('Boundary is invalid');
}
return $boundaryValue;
}
/**
* Check whether the stream's cursor is sitting right before the provided string.
*
* @throws Exception
*/
private function isAt(string $expectedContent): bool {
$expectedContentLength = strlen($expectedContent);
$content = fread($this->stream, $expectedContentLength);
if ($content === false) {
throw new Exception('An error occurred while checking content');
}
$seekBackResult = fseek($this->stream, -$expectedContentLength, SEEK_CUR);
if ($seekBackResult === -1) {
throw new Exception("Unknown error while seeking content", Http::STATUS_INTERNAL_SERVER_ERROR);
}
return $expectedContent === $content;
}
/**
* Check whether the stream's cursor is sitting right before the boundary.
*/
private function isAtBoundary(): bool {
return $this->isAt($this->boundary);
}
/**
* Check whether the stream's cursor is sitting right before the last boundary.
*/
public function isAtLastBoundary(): bool {
return $this->isAt($this->lastBoundary);
}
/**
* Parse and return the next part of the multipart headers.
*
* Example:
* --boundary_azertyuiop
* Header1: value
* Header2: value
*
* Content of
* the part
*
*/
public function parseNextPart(): array {
$this->readBoundary();
$headers = $this->readPartHeaders();
$content = $this->readPartContent($headers["content-length"], $headers["x-file-md5"]);
return [$headers, $content];
}
/**
* Read the boundary and check its content.
*
* @throws BadRequest
*/
private function readBoundary(): string {
if (!$this->isAtBoundary()) {
throw new BadRequest("Boundary not found where it should be.");
}
return fread($this->stream, strlen($this->boundary));
}
/**
* Return the headers of a part of the multipart body.
*
* @throws Exception
* @throws BadRequest
* @throws LengthRequired
*/
private function readPartHeaders(): array {
$headers = [];
while (($line = fgets($this->stream)) !== "\r\n") {
if ($line === false) {
throw new Exception('An error occurred while reading headers of a part');
}
try {
[$key, $value] = explode(':', $line, 2);
$headers[strtolower(trim($key))] = trim($value);
} catch (\Exception $e) {
throw new BadRequest('An error occurred while parsing headers of a part', Http::STATUS_BAD_REQUEST, $e);
}
}
if (!isset($headers["content-length"])) {
throw new LengthRequired("The Content-Length header must not be null.");
}
if (!isset($headers["x-file-md5"])) {
throw new BadRequest("The X-File-MD5 header must not be null.");
}
return $headers;
}
/**
* Return the content of a part of the multipart body.
*
* @throws Exception
* @throws BadRequest
*/
private function readPartContent(int $length, string $md5): string {
$computedMd5 = $this->computeMd5Hash($length);
if ($md5 !== $computedMd5) {
throw new BadRequest("Computed md5 hash is incorrect.");
}
$content = stream_get_line($this->stream, $length);
if ($content === false) {
throw new Exception("Fail to read part's content.");
}
if (feof($this->stream)) {
throw new Exception("Unexpected EOF while reading stream.");
}
// Read '\r\n'.
stream_get_contents($this->stream, 2);
return $content;
}
/**
* Compute the MD5 hash of the next x bytes.
*/
private function computeMd5Hash(int $length): string {
$context = hash_init('md5');
hash_update_stream($context, $this->stream, $length);
fseek($this->stream, -$length, SEEK_CUR);
return hash_final($context);
}
}

@ -1,214 +0,0 @@
<?php
/**
* @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com>
* @author Louis Chemineau <louis@chmn.me>
*
* @copyright Copyright (c) 2016, ownCloud GmbH.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\BundleUpload;
use OCA\DAV\Connector\Sabre\Exception\FileLocked;
use OCP\Files\StorageNotAvailableException;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use Sabre\DAV\Exception;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\ServiceUnavailable;
use OCA\DAV\Connector\Sabre\File;
use OCA\DAV\Connector\Sabre\Exception\EntityTooLarge;
use OCA\DAV\Connector\Sabre\Exception\Forbidden as DAVForbiddenException;
use OCA\DAV\Connector\Sabre\Exception\UnsupportedMediaType;
use OCP\Files\ForbiddenException;
use Sabre\DAV\Exception\BadRequest;
class BundledFile extends File {
/**
* This class is a wrapper around the bundled request body and provides access to its contents
*
* @var \OCA\DAV\BundleUpload\MultipartContentsParser
*
*/
private $contentHandler;
public function __construct($view, $info, $contentHandler){
$this->contentHandler = $contentHandler;
parent::__construct($view, $info);
}
/**
* Updates the data
*
* The $data['data] argument is a readable stream resource.
* The other $data key-values should be header fields in form of string
*
* After a successful put operation, you may choose to return an ETag. The
* ETag must always be surrounded by double-quotes. These quotes must
* appear in the actual string you're returning.
*
* Clients may use the ETag from a PUT request to later on make sure that
* when they update the file, the contents haven't changed in the mean
* time.
*
* If you don't plan to store the file byte-by-byte, and you return a
* different object on a subsequent GET you are strongly recommended to not
* return an ETag, and just return null.
*
* @param array $data
*
* @throws Forbidden
* @throws UnsupportedMediaType
* @throws BadRequest
* @throws Exception
* @throws EntityTooLarge
* @throws ServiceUnavailable
* @throws FileLocked
* @return array $properties
*/
public function putFile($data) {
$properties = array();
if (!isset($data['oc-total-length'])) {
//this should not happen, since upper layer takes care of that
//Thus, return Forbidden as sign of code inconsistency
throw new Forbidden('File requires oc-total-length header to be read');
}
try {
$exists = $this->fileView->file_exists($this->path);
if ($this->info && $exists) {
$this->contentHandler->multipartContentSeekToContentLength($data['oc-total-length']);
throw new Forbidden('Bundling not supported for already existing files');
}
} catch (StorageNotAvailableException $e) {
$this->contentHandler->multipartContentSeekToContentLength($data['oc-total-length']);
throw new ServiceUnavailable("StorageNotAvailableException raised");
}
// verify path of the target
$this->verifyPath();
$partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . rand();
// the part file and target file might be on a different storage in case of a single file storage (e.g. single file share)
/** @var \OC\Files\Storage\Storage $partStorage */
list($partStorage, $internalPartPath) = $this->fileView->resolvePath($partFilePath);
/** @var \OC\Files\Storage\Storage $storage */
list($storage, $internalPath) = $this->fileView->resolvePath($this->path);
try {
$target = $partStorage->fopen($internalPartPath, 'wb');
if ($target === false || $target === null) {
\OCP\Util::writeLog('webdav', '\OC\Files\Filesystem::fopen() failed', \OCP\Util::ERROR);
// because we have no clue about the cause we can only throw back a 500/Internal Server Error
$this->contentHandler->multipartContentSeekToContentLength($data['oc-total-length']);
throw new Exception('Could not write file contents');
}
$result = $this->contentHandler->streamReadToStream($target, $data['oc-total-length']);
if ($result === false) {
throw new Exception('Error while copying file to target location (expected filesize: ' . $data['oc-total-length'] . ' )');
}
} catch (\Exception $e) {
$partStorage->unlink($internalPartPath);
$this->convertToSabreException($e);
}
try {
$view = \OC\Files\Filesystem::getView();
if ($view) {
$run = $this->emitPreHooks($exists);
} else {
$run = true;
}
try {
$this->changeLock(ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException $e) {
$partStorage->unlink($internalPartPath);
throw new FileLocked($e->getMessage(), $e->getCode(), $e);
}
try {
if ($run) {
$renameOkay = $storage->moveFromStorage($partStorage, $internalPartPath, $internalPath);
$fileExists = $storage->file_exists($internalPath);
}
if (!$run || $renameOkay === false || $fileExists === false) {
\OCP\Util::writeLog('webdav', 'renaming part file to final file failed', \OCP\Util::ERROR);
throw new Exception('Could not rename part file to final file');
}
} catch (ForbiddenException $ex) {
throw new DAVForbiddenException($ex->getMessage(), $ex->getRetry());
} catch (\Exception $e) {
$partStorage->unlink($internalPartPath);
$this->convertToSabreException($e);
}
// since we skipped the view we need to scan and emit the hooks ourselves
$storage->getUpdater()->update($internalPath);
try {
$this->changeLock(ILockingProvider::LOCK_SHARED);
} catch (LockedException $e) {
throw new FileLocked($e->getMessage(), $e->getCode(), $e);
}
if ($view) {
$this->emitPostHooks($exists);
}
// allow sync clients to send the mtime along in a header
if (isset($data['oc-mtime'])) {
if ($this->fileView->touch($this->path, $data['oc-mtime'])) {
$properties['{DAV:}oc-mtime'] = 'accepted';
}
}
$this->refreshInfo();
if (isset($data['oc-checksum'])) {
$checksum = trim($data['oc-checksum']);
$this->fileView->putFileInfo($this->path, ['checksum' => $checksum]);
$this->refreshInfo();
} else if ($this->getChecksum() !== null && $this->getChecksum() !== '') {
$this->fileView->putFileInfo($this->path, ['checksum' => '']);
$this->refreshInfo();
}
} catch (StorageNotAvailableException $e) {
throw new ServiceUnavailable("Failed to check file size: " . $e->getMessage());
}
$etag = $this->getEtag();
$properties['{DAV:}etag'] = $etag;
$properties['{DAV:}oc-etag'] = $etag;
$properties['{DAV:}oc-fileid'] = $this->getFileId();
return $properties;
}
/*
* @param resource $data
*
* @throws Forbidden
*/
public function put($data) {
throw new Forbidden('PUT method not supported for bundling');
}
}

@ -1,456 +0,0 @@
<?php
/**
* @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com>
* @author Louis Chemineau <louis@chmn.me>
*
* @copyright Copyright (c) 2016, ownCloud GmbH.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\BundleUpload;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use OC\Files\View;
use Sabre\HTTP\URLUtil;
use OCP\Lock\ILockingProvider;
use OC\Files\FileInfo;
use Sabre\DAV\Exception\BadRequest;
use OCA\DAV\Connector\Sabre\Exception\Forbidden;
use OCP\Files\Folder;
use OCP\AppFramework\Http\JSONResponse;
use Psr\Log\LoggerInterface;
/**
* This plugin is responsible for interconnecting three components of the OC server:
* - RequestInterface object handler for request incoming from the client
* - MultipartContentsParser responsible for reading the contents of the request body
* - BundledFile responsible for storage of the file associated with request in the OC server
*
* Bundling plugin is responsible for receiving, validation and processing of the multipart/related request containing files.
*
*/
class BundlingPlugin extends ServerPlugin {
/**
* Reference to main server object
*
* @var \Sabre\DAV\Server
*/
private $server;
/**
* @var \Sabre\HTTP\RequestInterface
*/
private $request;
/**
* @var \Sabre\HTTP\ResponseInterface
*/
private $response;
/**
* @var \OCA\DAV\FilesBundle
*/
private $contentHandler = null;
/**
* @var String
*/
private $userFilesHome = null;
/**
* @var View
*/
private $fileView;
/**
* @var Array
*/
// private $cacheValidParents = null;
/** @var IFolder */
private $userFolder;
/** @var LoggerInterface */
private $logger;
/**
* Plugin constructor
*/
public function __construct(View $view, Folder $userFolder) {
$this->fileView = $view;
$this->userFolder = $userFolder;
}
/**
* This initializes the plugin.
*
* This function is called by \Sabre\DAV\Server, after
* addPlugin is called.
*
* This method should set up the requires event subscriptions.
*
* @param \Sabre\DAV\Server $server
* @return void
*/
public function initialize(\Sabre\DAV\Server $server) {
$this->server = $server;
$this->logger = $this->server->getLogger();
$server->on('method:POST', array($this, 'handleBundle'));
}
/**
* We intercept this to handle method:POST on a dav resource and process the bundled files multipart HTTP request.
*
* @throws /Sabre\DAV\Exception\BadRequest
* @throws /Sabre\DAV\Exception\Forbidden
*/
public function handleBundle(RequestInterface $request, ResponseInterface $response) {
// Limit bundle upload to the /bundle endpoint
if ($request->getPath() !== "files/bundle") {
return true;
}
$multiPartParser = new MultipartContentsParser($request);
$writtenFiles = [];
// $multiPartParser->eof()
while (!$multiPartParser->lastBoundary()) {
try {
[$headers, $content] = $multiPartParser->readNextPart();
if ((int)$headers['content-length'] !== strlen($content)) {
throw new BadRequest("Content read with different size than declared. Got " . $headers['content-length'] . ", expected" . strlen($content));
}
$node = $this->userFolder->newFile($headers['x-file-path'], $content);
$writtenFiles[$headers['x-file-path']] = $node->getSize();
if ((int)$headers['content-length'] !== $node->getSize()) {
throw new BadRequest("Written file length is different than declared length. Got " . $headers['content-length'] . ", expected" . $node->getSize());
}
// TODO - check md5 hash
// $context = hash_init('md5');
// hash_update_stream($context, $stream);
// echo hash_final($context);
// if ($header['x-file-md5'] !== hash_final($context)) {
// }
} catch (\Exception $e) {
throw $e;
$this->logger->error($e->getMessage(), ['path' => $header['x-file-path']]);
}
}
$response->setStatus(200);
$response->setBody(new JSONResponse([
$writtenFiles
]));
return false;
// $this->contentHandler = $this->getContentHandler($this->request);
// $multipleRequestsData = $this->parseBundleMetadata();
//Process bundle and send a multi-status response
// $result = $this->processBundle($multipleRequestsData);
// return $result;
}
public function handleBundleWithMetadata(RequestInterface $request, ResponseInterface $response) {
// Limit bundle upload to the /bundle endpoint
if ($request->getPath() !== "files/bundle") {
return true;
}
$multiPartParser = new MultipartContentsParser($request);
[$metadataHeaders, $rawMetadata] = $multiPartParser->getMetadata();
if ($metadataHeaders['content-type'] !== "text/xml; charset=utf-8") {
throw new BadRequest("Incorrect Content-Type for metadata.");
}
if ((int)$metadataHeaders['content-length'] !== strlen($rawMetadata)) {
throw new BadRequest("Content read with different size than declared.");
}
$metadata = $this->parseMetadata($rawMetadata);
$writtenFiles = [];
foreach ($metadata as $fileMetadata) {
try {
[$headers, $content] = $multiPartParser->readNextPart((int)$fileMetadata['oc-total-length']);
if ($fileMetadata['oc-id'] !== $headers['content-id']) {
throw new BadRequest("Content-ID do not match oc-id. Check the order of your metadata.");
}
if (isset($file[$fileMetadata['oc-id']])) {
throw new BadRequest("Content-ID appear twice. Check the order of your metadata.");
}
if ((int)$fileMetadata['oc-total-length'] !== strlen($content)) {
throw new BadRequest("Content read with different size than declared.");
}
$node = $this->userFolder->newFile($fileMetadata['oc-path'], $content);
$writtenFiles[$fileMetadata['oc-id']] = $node->getSize();
// TODO - check md5 hash
// $context = hash_init('md5');
// hash_update_stream($context, $stream);
// echo hash_final($context);
if ($fileMetadata['oc-md5'] !== hash_final($context)) {
}
} catch (\Exception $e) {
throw $e;
$this->logger->error($e->getMessage(), ['path' => $fileMetadata['oc-path']]);
}
}
$response->setStatus(200);
$response->setBody(new JSONResponse([
$writtenFiles
]));
return false;
// $this->contentHandler = $this->getContentHandler($this->request);
// $multipleRequestsData = $this->parseBundleMetadata();
//Process bundle and send a multi-status response
// $result = $this->processBundle($multipleRequestsData);
// return $result;
}
private function parseMetadata(string $rawMetadata) {
$xml = simplexml_load_string($rawMetadata);
if ($xml === false) {
$error = libxml_get_errors();
throw new \Exception('Bundle metadata contains incorrect xml structure. Unable to parse whole bundle request', $error);
}
libxml_clear_errors();
$xml->registerXPathNamespace('d','urn:DAV');
$metadataXml = $xml->xpath('/d:multipart/d:part/d:prop');
if($metadataXml === false){
throw new \Exception('Fail to access d:multipart/d:part/d:prop elements');
}
return array_map(function($xmlObject) { return get_object_vars($xmlObject->children('d', TRUE));}, $metadataXml);
}
/**
* Parses multipart contents and send appropriate response
*
* @throws \Sabre\DAV\Exception\Forbidden
*
* @return array $multipleRequestsData
*/
private function parseBundleMetadata() {
$multipleRequestsData = array();
try {
// Verify metadata part headers
$bundleMetadata = null;
try{
$bundleMetadata = $this->contentHandler->getPartHeaders($this->boundary);
}
catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
$contentParts = explode(';', $bundleMetadata['content-type']);
if (count($contentParts) != 2) {
throw new \Exception('Incorrect Content-type format. Charset might be missing');
}
$contentType = trim($contentParts[0]);
$expectedContentType = 'text/xml';
if ($contentType != $expectedContentType) {
throw new BadRequest(sprintf(
'Content-Type must be %s',
$expectedContentType
));
}
if (!isset($bundleMetadata['content-length'])) {
throw new \Exception('Bundle metadata header does not contain Content-Length. Unable to parse whole bundle request');
}
// Read metadata part headers
$bundleMetadataBody = $this->contentHandler->streamReadToString($bundleMetadata['content-length']);
$bundleMetadataBody = preg_replace("/xmlns(:[A-Za-z0-9_])?=(\"|\')DAV:(\"|\')/","xmlns\\1=\"urn:DAV\"",$bundleMetadataBody);
//Try to load xml
$xml = simplexml_load_string($bundleMetadataBody);
if (false === $xml) {
$mlerror = libxml_get_errors();
throw new \Exception('Bundle metadata contains incorrect xml structure. Unable to parse whole bundle request');
}
$xml->registerXPathNamespace('d','urn:DAV');
unset($bundleMetadataBody);
if(1 != count($xml->xpath('/d:multipart'))){
throw new \Exception('Bundle metadata does not contain d:multipart children elements');
}
$fileMetadataObjectXML = $xml->xpath('/d:multipart/d:part/d:prop');
if(0 == count($fileMetadataObjectXML)){
throw new \Exception('Bundle metadata does not contain d:multipart/d:part/d:prop children elements');
}
foreach ($fileMetadataObjectXML as $prop) {
$fileMetadata = get_object_vars($prop->children('d', TRUE));
// if any of the field is not contained,
// bthe try-catch clausule will raise Undefined index exception
$contentID = intval($fileMetadata['oc-id']);
if(array_key_exists($contentID, $multipleRequestsData)){
throw new \Exception('One or more files have the same Content-ID '.$contentID.'. Unable to parse whole bundle request');
}
$multipleRequestsData[$contentID]['oc-path'] = $fileMetadata['oc-path'];
$multipleRequestsData[$contentID]['oc-mtime'] = $fileMetadata['oc-mtime'];
$multipleRequestsData[$contentID]['oc-total-length'] = intval($fileMetadata['oc-total-length']);
$multipleRequestsData[$contentID]['response'] = null;
}
} catch (\Exception $e) {
libxml_clear_errors();
throw new Forbidden($e->getMessage());
}
return $multipleRequestsData;
}
/**
* Process multipart contents and send appropriate response
*
* @param RequestInterface $request
*
* @return boolean
*/
private function processBundle($multipleRequestsData) {
$bundleResponseProperties = array();
while(!$this->contentHandler->getEndDelimiterReached()) {
// Verify metadata part headers
$fileContentHeader = null;
//If something fails at this point, just continue, $multipleRequestsData[$contentID]['response'] will be null for this content
try{
$fileContentHeader = $this->contentHandler->getPartHeaders($this->boundary);
if(is_null($fileContentHeader) || !isset($fileContentHeader['content-id']) || !array_key_exists(intval($fileContentHeader['content-id']), $multipleRequestsData)){
continue;
}
}
catch (\Exception $e) {
continue;
}
$fileID = intval($fileContentHeader['content-id']);
$fileMetadata = $multipleRequestsData[$fileID];
$filePath = $fileMetadata['oc-path'];
list($folderPath, $fileName) = \OC\URLUtil::splitPath($filePath);
try {
//get absolute path of the file
$absoluteFilePath = $this->fileView->getAbsolutePath($folderPath) . '/' . $fileName;
$info = new FileInfo($absoluteFilePath, null, null, array(), null);
$node = new BundledFile($this->fileView, $info, $this->contentHandler);
$node->acquireLock(ILockingProvider::LOCK_SHARED);
$properties = $node->putFile($fileMetadata);
$multipleRequestsData[$fileID]['response'] = $this->handleFileMultiStatus($filePath, $properties);
} catch (\Exception $exc) {
//TODO: This should not be BadRequest! This should be any exception - how to do it carefully?
$exc = new BadRequest($exc->getMessage());
$multipleRequestsData[$fileID]['response'] = $this->handleFileMultiStatusError($filePath, $exc);
continue;
}
//TODO: do we need to unlock file if putFile failed? In this version we dont (does continue)
//release lock as in dav/lib/Connector/Sabre/LockPlugin.php
$node->releaseLock(ILockingProvider::LOCK_SHARED);
$this->server->tree->markDirty($filePath);
}
foreach($multipleRequestsData as $requestData) {
$response = $requestData['response'];
if (is_null($response)){
$exc = new BadRequest('File parsing error');
$response = $this->handleFileMultiStatusError($requestData['oc-path'], $exc);
}
$bundleResponseProperties[] = $response;
}
//multi-status response announced
$this->response->setHeader('Content-Type', 'application/xml; charset=utf-8');
$this->response->setStatus(207);
$body = $this->server->generateMultiStatus($bundleResponseProperties);
$this->response->setBody($body);
return false;
}
/**
* Adds to multi-status response exception class string and exception message for specific file
*
* @return array $entry
*/
private function handleFileMultiStatusError($ocPath, $exc){
$status = $exc->getHTTPCode();
$entry['href'] = $this->userFilesHome;
$entry[$status]['{DAV:}error']['{http://sabredav.org/ns}exception'] = get_class($exc);
$entry[$status]['{DAV:}error']['{http://sabredav.org/ns}message'] = $exc->getMessage();
$entry[$status]['{DAV:}oc-path'] = $ocPath;
return $entry;
}
/**
* Adds to multi-status response properties for specific file
*
* @return array $entry
*/
private function handleFileMultiStatus($ocPath, $properties){
$entry['href'] = $this->userFilesHome;
$entry[200] = $properties;
$entry[200]['{DAV:}oc-path'] = $ocPath;
return $entry;
}
/**
* Get content handler
*
* @param RequestInterface $request
* @return \OCA\DAV\BundleUpload\MultipartContentsParser
*/
// private function getContentHandler(RequestInterface $request) {
// if ($this->contentHandler === null) {
// return new MultipartContentsParser($request);
// }
// return $this->contentHandler;
// }
}

@ -1,497 +0,0 @@
<?php
/**
* @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com>
* @author Louis Chemineau <louis@chmn.me>
*
* @copyright Copyright (c) 2016, ownCloud GmbH.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\BundleUpload;
use Exception;
use Sabre\HTTP\RequestInterface;
use Sabre\DAV\Exception\BadRequest;
/**
* This class is used to parse multipart/related HTTP message according to RFC http://www.rfc-archive.org/getrfc.php?rfc=2387
* This class requires a message to contain Content-length parameters, which is used in high performance reading of file contents.
*/
class MultipartContentsParser {
/**
* @var \Sabre\HTTP\RequestInterface
*/
// private $request;
/** @var resource */
private $stream = null;
/** @var string */
private $boundary = "";
private $lastBoundary = "";
/**
* @var Bool
*/
// private $endDelimiterReached = false;
/**
* Constructor.
*/
public function __construct(RequestInterface $request) {
$this->stream = $request->getBody();
if (gettype($this->stream) !== 'resource') {
throw new BadRequest('Wrong body type');
}
$this->boundary = '--'.$this->getBoundary($request->getHeader('Content-Type'))."\r\n";
$this->lastBoundary = '--'.$this->getBoundary($request->getHeader('Content-Type'))."--\r\n";
}
/**
* Parse the boundary from a Content-Type header
*
* @throws \Sabre\DAV\Exception\BadRequest
*/
private function getBoundary(string $contentType) {
// Making sure the end node exists
//TODO: add support for user creation if that is first sync. Currently user has to be created.
// $this->userFilesHome = $this->request->getPath();
// $userFilesHomeNode = $this->server->tree->getNodeForPath($this->userFilesHome);
// if (!($userFilesHomeNode instanceof FilesHome)){
// throw new Forbidden('URL endpoint has to be instance of \OCA\DAV\Files\FilesHome');
// }
// $headers = array('Content-Type');
// foreach ($headers as $header) {
// $value = $this->request->getHeader($header);
// if ($value === null) {
// throw new Forbidden(sprintf('%s header is needed', $header));
// } elseif (!is_int($value) && empty($value)) {
// throw new Forbidden(sprintf('%s header must not be empty', $header));
// }
// }
// Validate content-type
// Ex: Content-Type: "multipart/related; boundary=boundary_bf38b9b4b10a303a28ed075624db3978"
[$mimeType, $boundary] = explode(';', $contentType);
if (trim($mimeType) !== 'multipart/related') {
throw new BadRequest('Content-Type must be multipart/related');
}
// Validate boundary
[$key, $value] = explode('=', $boundary);
if (trim($key) !== 'boundary') {
throw new BadRequest('Boundary is invalid');
}
$value=trim($value);
// Remove potential quotes around boundary value
if (substr($value, 0, 1) == '"' && substr($value, -1) == '"') {
$value = substr($value, 1, -1);
}
return $value;
}
/**
* Get a line.
*
* If false is return, it's the end of file.
*
* @throws \Sabre\DAV\Exception\BadRequest
*/
// public function gets() {
// $content = $this->getContent();
// if (!is_resource($content)) {
// throw new BadRequest('Unable to get request content');
// }
// return fgets($content);
// }
/**
*/
// public function getCursor() {
// return ftell($this->getContent());
// }
/**
*/
// public function getEndDelimiterReached() {
// return $this->endDelimiterReached;
// }
/**
* Return if end of file.
*/
public function eof() {
return feof($this->stream);
}
/**
* Seeks to offset of some file contentLength from the current cursor position in the
* multipartContent.
*
* Return true on success and false on failure
*/
// public function multipartContentSeekToContentLength(int $contentLength) {
// return (fseek($this->getContent(), $contentLength, SEEK_CUR) === 0 ? true : false);
// }
/**
* Get request content.
*
* @throws \Sabre\DAV\Exception\BadRequest
*
* @return resource
*/
// public function getContent() {
// if ($this->stream === null) {
// // Pass body by reference, so other objects can have global access
// $content = $this->request->getBody();
// if (!$this->stream) {
// throw new BadRequest('Unable to get request content');
// }
// if (gettype($this->stream) !== 'resource') {
// throw new BadRequest('Wrong body type');
// }
// $this->stream = $content;
// }
// return $this->stream;
// }
// public function getBoundary(string $boundary) {
// return "\r\n--$boundary\r\n";
// }
public function checkBoundary(string $boundary, string $line) {
if ($line !== $boundary) {
throw new Exception("Invalid boundary, is '$line', should be '$this->boundary'.");
}
return true;
}
public function lastBoundary() {
$content = fread($this->stream, strlen($this->lastBoundary));
$result = fseek($this->stream, -strlen($this->lastBoundary), SEEK_CUR);
if ($result === -1) {
throw new Exception("Unknown error while seeking content");
}
return $content === $this->lastBoundary;
}
/**
* Return the next part of the request.
*
* @throws Exception
*/
public function readNextPart(int $length = 0) {
$this->checkBoundary($this->boundary, fread($this->stream, strlen($this->boundary)));
$headers = $this->readPartHeaders();
if ($length === 0 && isset($headers["content-length"])) {
$length = $headers["content-length"];
}
if ($length === 0) {
throw new Exception("Part cannot be of length 0.");
}
$content = $this->readPartContent2($length);
return [$headers, $content];
}
/**
* Return the next part of the request.
*
* @throws Exception
*/
public function readNextStream() {
$this->checkBoundary($this->boundary, fread($this->stream, strlen($this->boundary)));
$headers = $this->readPartHeaders();
return [$headers, $this->stream];
}
/**
* Return the headers of a part of the request.
*
* @throws \Sabre\DAV\Exception\BadRequest
* @throws Exception
*/
public function readPartHeaders() {
$headers = [];
$blankLineCount = 0;
while($blankLineCount < 1) {
$line = fgets($this->stream);
if ($line === false) {
throw new Exception('An error appears while reading headers of a part');
}
if ($line === "\r\n") {
break;
}
try {
[$key, $value] = explode(':', $line, 2);
$headers[strtolower(trim($key))] = trim($value);
} catch (Exception $e) {
throw new BadRequest('An error appears while parsing headers of a part', $e);
}
}
return $headers;
}
/**
* Return the content of the current part of the stream.
*
* @throws \Sabre\DAV\Exception\BadRequest
* @throws Exception
*/
public function readPartContent() {
$line = '';
$content = '';
do {
$content .= $line;
if (feof($this->stream)) {
throw new BadRequest("Unexpected EOF while reading stream.");
}
$line = fgets($this->stream);
if ($line === false) {
throw new Exception("Fail to read part's content.");
}
} while ($line !== $this->boundary);
// We need to be before $boundary for the next parsing.
$result = fseek($this->stream, -strlen($this->boundary), SEEK_CUR);
if ($result === -1) {
throw new Exception("Fail to seek upstream.");
}
// Remove the extra new line "\r\n" that is not part of the content
return substr($content, 0, -2);
}
public function readPartContent2(int $length) {
// Read stream until file's $length, EOF or $boundary is reached
$content = stream_get_line($this->stream, $length);
if ($content === false) {
throw new Exception("Fail to read part's content.");
}
if (feof($this->stream)) {
throw new Exception("Unexpected EOF while reading stream.");
}
stream_get_contents($this->stream, 2);
return $content;
}
public function getContentPosition() {
return ftell($this->stream);
}
public function getMetadata() {
fseek($this->stream, 0);
return $this->readNextPart();
}
public function getContent(int $pos, int $length) {
$previousPos = ftell($this->stream);
$content = stream_get_contents($this->stream, $length, $pos);
fseek($this->stream, $previousPos);
return $content;
}
/**
* Get a part of request separated by boundary $boundary.
*
* If this method returns an exception, it means whole request has to be abandoned,
* Request part without correct headers might corrupt the message and parsing is impossible
*
* @throws \Exception
*/
// public function getPartHeaders(string $boundary) {
// $delimiter = '--'.$boundary."\r\n";
// $endDelimiter = '--'.$boundary.'--';
// $boundaryCount = 0;
// $content = '';
// $headers = null;
// while (!$this->eof()) {
// $line = $this->gets();
// if ($line === false) {
// if ($boundaryCount == 0) {
// // Empty part, ignore
// break;
// }
// else{
// throw new \Exception('An error appears while reading and parsing header of content part using fgets');
// }
// }
// if ($boundaryCount == 0) {
// if ($line != $delimiter) {
// if ($this->getCursor() == strlen($line)) {
// throw new \Exception('Expected boundary delimiter in content part - this is not a multipart request');
// }
// elseif ($line == $endDelimiter || $line == $endDelimiter."\r\n") {
// $this->endDelimiterReached = true;
// break;
// }
// elseif ($line == "\r\n") {
// continue;
// }
// } else {
// continue;
// }
// // At this point we know, that first line was boundary
// $boundaryCount++;
// }
// elseif ($boundaryCount == 1 && $line == "\r\n"){
// //header-end according to RFC
// $content .= $line;
// $headers = $this->readHeaders($content);
// break;
// }
// elseif ($line == $endDelimiter || $line == $endDelimiter."\r\n") {
// $this->endDelimiterReached = true;
// break;
// }
// $content .= $line;
// }
// if ($this->eof()){
// $this->endDelimiterReached = true;
// }
// return $headers;
// }
/**
* Read the contents from the current file pointer to the specified length
*
* @throws \Sabre\DAV\Exception\BadRequest
*/
// public function streamReadToString(int $length) {
// if ($length<0) {
// throw new BadRequest('Method streamRead cannot read contents with negative length');
// }
// $source = $this->getContent();
// $bufChunkSize = 8192;
// $count = $length;
// $buf = '';
// while ($count!=0) {
// $bufSize = (($count - $bufChunkSize)<0) ? $count : $bufChunkSize;
// $buf .= fread($source, $bufSize);
// $count -= $bufSize;
// }
// $bytesWritten = strlen($buf);
// if ($length != $bytesWritten){
// throw new BadRequest('Method streamRead read '.$bytesWritten.' expected '.$length);
// }
// return $buf;
// }
/**
* Read the contents from the current file pointer to the specified length and pass
*
* @param resource $target
*
* @throws \Sabre\DAV\Exception\BadRequest
*/
// public function streamReadToStream($target, int $length) {
// if ($length<0) {
// throw new BadRequest('Method streamRead cannot read contents with negative length');
// }
// $source = $this->getContent();
// $bufChunkSize = 8192;
// $count = $length;
// $returnStatus = true;
// while ($count!=0) {
// $bufSize = (($count - $bufChunkSize)<0) ? $count : $bufChunkSize;
// $buf = fread($source, $bufSize);
// $bytesWritten = fwrite($target, $buf);
// // note: strlen is expensive so only use it when necessary,
// // on the last block
// if ($bytesWritten === false
// || ($bytesWritten < $bufSize)
// ) {
// // write error, could be disk full ?
// $returnStatus = false;
// break;
// }
// $count -= $bufSize;
// }
// return $returnStatus;
// }
/**
* Get headers from content
*/
// public function readHeaders($content) {
// $headers = null;
// $headerLimitation = strpos($content, "\r\n\r\n");
// if ($headerLimitation === false) {
// return null;
// }
// $headersContent = substr($content, 0, $headerLimitation);
// $headersContent = trim($headersContent);
// foreach (explode("\r\n", $headersContent) as $header) {
// $parts = explode(':', $header, 2);
// if (count($parts) != 2) {
// //has incorrect header, abort
// return null;
// }
// $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
// }
// return $headers;
// }
}

@ -29,7 +29,7 @@ class Capabilities implements ICapability {
return [
'dav' => [
'chunking' => '1.0',
'bundleupload' => '1.0',
'bulkupload' => '1.0',
]
];
}

@ -356,7 +356,7 @@ class File extends Node implements IFile {
return '"' . $this->info->getEtag() . '"';
}
protected function getPartFileBasePath($path) {
private function getPartFileBasePath($path) {
$partFileInStorage = \OC::$server->getConfig()->getSystemValue('part_file_in_storage', true);
if ($partFileInStorage) {
return $path;
@ -368,7 +368,7 @@ class File extends Node implements IFile {
/**
* @param string $path
*/
protected function emitPreHooks($exists, $path = null) {
private function emitPreHooks($exists, $path = null) {
if (is_null($path)) {
$path = $this->path;
}
@ -396,7 +396,7 @@ class File extends Node implements IFile {
/**
* @param string $path
*/
protected function emitPostHooks($exists, $path = null) {
private function emitPostHooks($exists, $path = null) {
if (is_null($path)) {
$path = $this->path;
}
@ -633,7 +633,7 @@ class File extends Node implements IFile {
*
* @throws \Sabre\DAV\Exception
*/
protected function convertToSabreException(\Exception $e) {
private function convertToSabreException(\Exception $e) {
if ($e instanceof \Sabre\DAV\Exception) {
throw $e;
}

@ -34,6 +34,7 @@
*/
namespace OCA\DAV;
use Psr\Log\LoggerInterface;
use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\CalDAV\BirthdayService;
use OCA\DAV\CardDAV\HasPhotoPlugin;
@ -62,12 +63,11 @@ use OCA\DAV\DAV\PublicAuth;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\DAV\Files\BrowserErrorPagePlugin;
use OCA\DAV\Files\LazySearchBackend;
use OCA\DAV\BundleUpload\BundlingPlugin;
use OCA\DAV\BulkUpload\BulkUploadPlugin;
use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin;
use OCA\DAV\SystemTag\SystemTagPlugin;
use OCA\DAV\Upload\ChunkingPlugin;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\IRequest;
use OCP\SabrePluginEvent;
use Sabre\CardDAV\VCFExportPlugin;
@ -296,9 +296,9 @@ class Server {
\OC::$server->getShareManager(),
$view
));
$rootFolder = \OC::$server->query(IRootFolder::class);
$logger = \OC::$server->get(LoggerInterface::class);
$this->server->addPlugin(
new BundlingPlugin($view, $userFolder)
new BulkUploadPlugin($userFolder, $logger)
);
}
$this->server->addPlugin(new \OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin(

@ -2,6 +2,8 @@
set -eu
# benchmark.sh
export KB=1000
export MB=$((KB*1000))
@ -32,10 +34,10 @@ do
echo "- Upload of $nb tiny file of ${size}B"
echo " - Bundled"
start=$(date +%s)
echo "$requests_count" | xargs -d ' ' -P $CONCURRENCY -I{} ./bundle_upload.sh "$nb" "$size"
echo "$requests_count" | xargs -d ' ' -P $CONCURRENCY -I{} ./bulk_upload.sh "$nb" "$size"
end=$(date +%s)
bundle_exec_time=$((end-start))
echo "${bundle_exec_time}s"
bulk_exec_time=$((end-start))
echo "${bulk_exec_time}s"
echo " - Single"
start=$(date +%s)
@ -44,7 +46,7 @@ do
single_exec_time=$((end-start))
echo "${single_exec_time}s"
md_output+="| $nb | $size | $bundle_exec_time | $single_exec_time |\n"
md_output+="| $nb | $size | $bulk_exec_time | $single_exec_time |\n"
done
echo -en "$md_output"

@ -2,6 +2,8 @@
set -eu
# bulk_upload.sh <nb-of-files> <size-of-files>
KB=${KB:-100}
MB=${MB:-$((KB*1000))}
@ -14,10 +16,10 @@ BANDWIDTH=${BANDWIDTH:-$((100*MB/CONCURRENCY))}
USER="admin"
PASS="password"
SERVER="nextcloud.test"
UPLOAD_PATH="/tmp/bundle_upload_request_$(openssl rand --hex 8).txt"
UPLOAD_PATH="/tmp/bulk_upload_request_$(openssl rand --hex 8).txt"
BOUNDARY="boundary_$(openssl rand --hex 8)"
LOCAL_FOLDER="/tmp/bundle_upload/${BOUNDARY}_${NB}_${SIZE}"
REMOTE_FOLDER="/bundle_upload/${BOUNDARY}_${NB}_${SIZE}"
LOCAL_FOLDER="/tmp/bulk_upload/${BOUNDARY}_${NB}_${SIZE}"
REMOTE_FOLDER="/bulk_upload/${BOUNDARY}_${NB}_${SIZE}"
mkdir --parent "$LOCAL_FOLDER"
@ -48,7 +50,13 @@ done
echo -en "--$BOUNDARY--\r\n" >> "$UPLOAD_PATH"
echo "Creating folder /${BOUNDARY}_${NB}_${SIZE}"
echo "Creating folder /bulk_upload"
curl \
-X MKCOL \
-k \
"https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/bulk_upload" > /dev/null
echo "Creating folder $REMOTE_FOLDER"
curl \
-X MKCOL \
-k \
@ -56,15 +64,15 @@ curl \
echo "Uploading $NB files with total size: $(du -sh "$UPLOAD_PATH" | cut -d ' ' -f1)"
echo "Local file is: $UPLOAD_PATH"
blackfire curl \
curl \
-X POST \
-k \
--progress-bar \
--limit-rate "${BANDWIDTH}k" \
--cookie "XDEBUG_PROFILE=MROW4A;path=/;" \
--cookie "XDEBUG_PROFILE=true;path=/;" \
-H "Content-Type: multipart/related; boundary=$BOUNDARY" \
--data-binary "@$UPLOAD_PATH" \
"https://$USER:$PASS@$SERVER/remote.php/dav/files/bundle"
"https://$USER:$PASS@$SERVER/remote.php/dav/bulk"
rm -rf "${LOCAL_FOLDER:?}"
rm "$UPLOAD_PATH"

@ -2,6 +2,8 @@
set -eu
# single_upload.sh <nb-of-files> <size-of-files>
export KB=${KB:-100}
export MB=${MB:-$((KB*1000))}
@ -15,15 +17,20 @@ export USER="admin"
export PASS="password"
export SERVER="nextcloud.test"
export UPLOAD_ID="single_$(openssl rand --hex 8)"
export LOCAL_FOLDER="/tmp/bundle_upload/${UPLOAD_ID}_${NB}_${SIZE}"
export REMOTE_FOLDER="/bundle_upload/${UPLOAD_ID}_${NB}_${SIZE}"
export LOCAL_FOLDER="/tmp/single_upload/${UPLOAD_ID}_${NB}_${SIZE}"
export REMOTE_FOLDER="/single_upload/${UPLOAD_ID}_${NB}_${SIZE}"
mkdir --parent "$LOCAL_FOLDER"
curl \
-X MKCOL \
-k \
--cookie "XDEBUG_SESSION=MROW4A;path=/;" \
"https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/bulk_upload" > /dev/null
curl \
-X MKCOL \
-k \
--cookie "XDEBUG_SESSION=true;path=/;" \
"https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/$REMOTE_FOLDER"
upload_file() {

@ -35,6 +35,7 @@ class CapabilitiesTest extends TestCase {
$expected = [
'dav' => [
'chunking' => '1.0',
'bulkupload' => '1.0',
],
];
$this->assertSame($expected, $capabilities->getCapabilities());

@ -1,711 +0,0 @@
<?php
/**
* @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com>
* @author Louis Chemineau <louis@chmn.me>
*
* @copyright Copyright (c) 2016, ownCloud GmbH.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\Files;
use OC\Files\FileInfo;
use OC\Files\Storage\Local;
use Sabre\HTTP\RequestInterface;
use Test\TestCase;
use OC\Files\View;
use OCP\Files\Storage;
use Sabre\DAV\Exception;
use OC\Files\Filesystem;
use OCP\Files\StorageNotAvailableException;
/**
* Class BundlingPlugin
*
* @group DB
*
* @package OCA\DAV\Tests\unit\Files
*/
class BundlingPluginTest extends TestCase {
/**
* @var string
*/
private $user;
/** @var \OC\Files\View | \PHPUnit_Framework_MockObject_MockObject */
private $view;
/** @var \OC\Files\FileInfo | \PHPUnit_Framework_MockObject_MockObject */
private $info;
/**
* @var \Sabre\DAV\Server | \PHPUnit_Framework_MockObject_MockObject
*/
private $server;
/**
* @var FilesPlugin
*/
private $plugin;
/**
* @var \Sabre\HTTP\RequestInterface | \PHPUnit_Framework_MockObject_MockObject
*/
private $request;
/**
* @var \Sabre\HTTP\ResponseInterface | \PHPUnit_Framework_MockObject_MockObject
*/
private $response;
/**
* @var MultipartContentsParser | \PHPUnit_Framework_MockObject_MockObject
*/
private $contentHandler;
const BOUNDRARY = 'test_boundrary';
public function setUp() {
parent::setUp();
// $this->server = new \Sabre\DAV\Server();
$this->server = $this->getMockBuilder('\Sabre\DAV\Server')
->setConstructorArgs(array())
->setMethods(array('emit'))
->getMock();
$this->server->tree = $this->getMockBuilder('\Sabre\DAV\Tree')
->disableOriginalConstructor()
->getMock();
// setup
$storage = $this->getMockBuilder(Local::class)
->setMethods(["fopen","moveFromStorage","file_exists"])
->setConstructorArgs([['datadir' => \OC::$server->getTempManager()->getTemporaryFolder()]])
->getMock();
$storage->method('fopen')
->will($this->returnCallback(
function ($path,$mode) {
$bodyStream = fopen('php://temp', 'r+');
return $bodyStream;
}
));
$storage->method('moveFromStorage')
->will($this->returnValue(true));
$storage->method('file_exists')
->will($this->returnValue(true));
\OC_Hook::clear();
$this->user = $this->getUniqueID('user_');
$userManager = \OC::$server->getUserManager();
$userManager->createUser($this->user, 'pass');
$this->loginAsUser($this->user);
Filesystem::mount($storage, [], $this->user . '/');
$this->view = $this->getMockBuilder(View::class)
->setMethods(['resolvePath', 'touch', 'file_exists', 'getFileInfo'])
->setConstructorArgs([])
->getMock();
$this->view->method('touch')
->will($this->returnValue(true));
$this->view
->method('resolvePath')
->will($this->returnCallback(
function ($path) use ($storage) {
return [$storage, $path];
}
));
$this->view
->method('getFileInfo')
->will($this->returnCallback(
function ($path) {
$props = array();
$props['checksum'] = null;
$props['etag'] = $path;
$props['fileid'] = $path;
$info = new FileInfo($path, null, null, $props, null);
return $info;
}
));
$this->info = $this->createMock('OC\Files\FileInfo', [], [], '', false);
$this->request = $this->getMockBuilder(RequestInterface::class)
->disableOriginalConstructor()
->getMock();
$this->response = new \Sabre\HTTP\Response();
$this->plugin = new BundlingPlugin(
$this->view
);
$this->plugin->initialize($this->server);
}
/*TESTS*/
/**
* This test checks that if url endpoint is wrong, plugin with return exception
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage URL endpoint has to be instance of \OCA\DAV\Files\FilesHome
*/
public function testHandleBundleNotHomeCollection() {
$this->request
->expects($this->once())
->method('getPath')
->will($this->returnValue('notFilesHome.xml'));
$node = $this->getMockBuilder('\OCA\DAV\Connector\Sabre\File')
->disableOriginalConstructor()
->getMock();
$this->server->tree->expects($this->once())
->method('getNodeForPath')
->with('notFilesHome.xml')
->will($this->returnValue($node));
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Simulate NULL request header
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Content-Type header is needed
*/
public function testHandleBundleNoHeader() {
$this->setupServerTillFilesHome();
$this->request
->expects($this->once())
->method('getHeader')
->with('Content-Type')
->will($this->returnValue(null));
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Simulate empty request header
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Content-Type header must not be empty
*/
public function testHandleBundleEmptyHeader() {
$this->setupServerTillFilesHome();
$this->request
->expects($this->once())
->method('getHeader')
->with('Content-Type')
->will($this->returnValue(""));
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Simulate content-type header without boundrary specification request header
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Improper Content-type format. Boundary may be missing
*/
public function testHandleBundleNoBoundraryHeader() {
$this->setupServerTillFilesHome();
$this->request
->expects($this->atLeastOnce())
->method('getHeader')
->with('Content-Type')
->will($this->returnValue("multipart/related"));
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Simulate content-type header with wrong boundrary specification request header
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Boundary is not set
*/
public function testHandleBundleWrongBoundraryHeader() {
$this->setupServerTillFilesHome();
$this->request
->expects($this->atLeastOnce())
->method('getHeader')
->with('Content-Type')
->will($this->returnValue("multipart/related;thisIsNotBoundrary"));
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Simulate content-type header with wrong boundrary specification request header
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Content-Type must be multipart/related
*/
public function testHandleBundleWrongContentTypeHeader() {
$this->setupServerTillFilesHome();
$this->request
->expects($this->atLeastOnce())
->method('getHeader')
->with('Content-Type')
->will($this->returnValue("multipart/mixed; boundary=".self::BOUNDRARY));
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Simulate content-type header with alternative correct boundrary specification request header
*
* Request with user out of quota
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage beforeWriteBundle preconditions failed
*/
public function testHandleAlternativeBoundraryPlusBundleOutOfQuota() {
$this->setupServerTillFilesHome();
$this->request
->expects($this->atLeastOnce())
->method('getHeader')
->with('Content-Type')
->will($this->returnValue("multipart/related; boundary=\"".self::BOUNDRARY."\""));
$this->server
->expects($this->once())
->method('emit')
->will($this->returnValue(false));
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Request without request body
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Unable to get request content
*/
public function testHandleBundleWithNullBody() {
$this->setupServerTillHeader();
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Test empty request body. This will pass getPartHeader, but exception will be raised after we ready headers
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Incorrect Content-type format. Charset might be missing
*/
public function testHandleBundleWithEmptyBody() {
$this->setupServerTillHeader();
$this->fillMultipartContentsParserStreamWithBody("");
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Test wrong request body
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Expected boundary delimiter in content part - this is not a multipart request
*/
public function testHandleBundleWithWrongBody() {
$this->setupServerTillHeader();
$this->fillMultipartContentsParserStreamWithBody("WrongBody");
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Test wrong request body, with metadata header containing no charset
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Incorrect Content-type format. Charset might be missing
*/
public function testHandleMetadataNoCharsetType(){
$bodyContent = 'I am wrong metadata not in utf-8';
$headers['content-length'] = strlen($bodyContent);
$headers['content-type'] = 'text/xml';
//this part will have some arbitrary, correct headers
$bodyFull = "--".self::BOUNDRARY
."\r\nContent-Type: ".$headers['content-type']
."\r\n\r\n"
."$bodyContent\r\n--".self::BOUNDRARY."--";
$this->setupServerTillHeader();
$this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Test wrong request body, with metadata header containing wrong content-type
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Content-Type must be text/xml
*/
public function testHandleMetadataWrongContentType(){
$bodyContent = 'I am wrong metadata content type';
$headers['content-type'] = 'text/plain; charset=utf-8';
//this part will have some arbitrary, correct headers
$bodyFull = "--".self::BOUNDRARY
."\r\nContent-Type: ".$headers['content-type']
."\r\n\r\n"
."$bodyContent\r\n--".self::BOUNDRARY."--";
$this->setupServerTillHeader();
$this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Test wrong request body, with metadata header containing wrong content-type
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Bundle metadata header does not contain Content-Length. Unable to parse whole bundle request
*/
public function testHandleMetadataNoContentLength(){
$bodyContent = 'I am wrong metadata content type';
//$headers['content-length'] = strlen($bodyContent);
$headers['content-type'] = 'text/xml; charset=utf-8';
//this part will have some arbitrary, correct headers
$bodyFull = "--".self::BOUNDRARY
."\r\nContent-Type: ".$headers['content-type']
//."\r\nContent-length: ".$headers['content-length']
."\r\n\r\n"
."$bodyContent\r\n--".self::BOUNDRARY."--";
$this->setupServerTillHeader();
$this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Try to parse body which is not xml
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Bundle metadata contains incorrect xml structure. Unable to parse whole bundle request
*/
public function testHandleWrongMetadataNoXML(){
$bodyContent = "I am not xml";
$this->setupServerTillMetadata($bodyContent);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Try to parse body which has xml d:multipart element which
* has not been declared <d:multipart xmlns:d='DAV:'>
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Bundle metadata does not contain d:multipart children elements
*/
public function testHandleWrongMetadataWrongXMLdElement(){
$bodyContent = "<?xml version='1.0' encoding='UTF-8'?><d:multipart></d:multipart>";
$this->setupServerTillMetadata($bodyContent);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* This test checks that exception is raised for
* parsed XML which contains empty(without d:part elements) d:multipart section in metadata XML
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Bundle metadata does not contain d:multipart/d:part/d:prop children elements
*/
public function testHandleEmptyMultipartMetadataSection(){
$bodyContent = "<?xml version='1.0' encoding='UTF-8'?><d:multipart xmlns:d='DAV:'></d:multipart>";
$this->setupServerTillMetadata($bodyContent);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Metadata contains part properties not containing obligatory field will raise exception
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage Undefined index: oc-id
*/
public function testHandleWrongMetadataNoPartID(){
$bodyContent = "<?xml version='1.0' encoding='UTF-8'?>
<d:multipart xmlns:d='DAV:'>
<d:part>
<d:prop>
</d:prop>
</d:part>
</d:multipart>";
$this->setupServerTillMetadata($bodyContent);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* In the request, insert two files with the same Content-ID
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage One or more files have the same Content-ID 1. Unable to parse whole bundle request
*/
public function testHandleWrongMetadataMultipleIDs(){
$bodyContent = "<?xml version='1.0' encoding='UTF-8'?>
<d:multipart xmlns:d='DAV:'>
<d:part>
<d:prop>
<d:oc-path>/test/zombie1.jpg</d:oc-path>\n
<d:oc-mtime>1476393386</d:oc-mtime>\n
<d:oc-id>1</d:oc-id>\n
<d:oc-total-length>6</d:oc-total-length>\n
</d:prop>
</d:part>
<d:part>
<d:prop>
<d:oc-path>/test/zombie2.jpg</d:oc-path>\n
<d:oc-mtime>1476393386</d:oc-mtime>\n
<d:oc-id>1</d:oc-id>\n
<d:oc-total-length>6</d:oc-total-length>\n
</d:prop>
</d:part>
</d:multipart>";
$this->setupServerTillMetadata($bodyContent);
$this->plugin->handleBundle($this->request, $this->response);
}
/**
* Specify metadata part without corresponding binary content
*
*/
public function testHandleWithoutBinaryContent(){
$bodyContent = "<?xml version='1.0' encoding='UTF-8'?>
<d:multipart xmlns:d='DAV:'>
<d:part>
<d:prop>
<d:oc-path>/test/zombie1.jpg</d:oc-path>\n
<d:oc-mtime>1476393386</d:oc-mtime>\n
<d:oc-id>1</d:oc-id>\n
<d:oc-total-length>6</d:oc-total-length>\n
</d:prop>
</d:part>
</d:multipart>";
$this->setupServerTillMetadata($bodyContent);
$this->plugin->handleBundle($this->request, $this->response);
$return = $this->response->getBody();
$this->assertTrue(false != $return);
$xml = simplexml_load_string($return);
$this->assertTrue(false != $xml);
$xml->registerXPathNamespace('d','urn:DAV');
$xml->registerXPathNamespace('s','http://sabredav.org/ns');
$this->assertEquals(1, count($xml->xpath('/d:multistatus')));
$fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:status');
$this->assertTrue(false != $fileMetadataObjectXML);
$this->assertEquals(1, count($fileMetadataObjectXML));
$this->assertEquals("HTTP/1.1 400 Bad Request", (string) $fileMetadataObjectXML[0]);
$fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:prop/d:error/s:message');
$this->assertTrue(false != $fileMetadataObjectXML);
$this->assertEquals(1, count($fileMetadataObjectXML));
$this->assertEquals("File parsing error", (string) $fileMetadataObjectXML[0]);
}
/**
* This test will simulate success and failure in putFile class.
*
*/
public function testHandlePutFile(){
$this->setupServerTillData();
$this->view
->method('file_exists')
->will($this->onConsecutiveCalls(true, false, $this->throwException(new StorageNotAvailableException())));
$this->plugin->handleBundle($this->request, $this->response);
$return = $this->response->getBody();
$this->assertTrue(false != $return);
$xml = simplexml_load_string($return);
$this->assertTrue(false != $xml);
$xml->registerXPathNamespace('d','urn:DAV');
$xml->registerXPathNamespace('s','http://sabredav.org/ns');
$this->assertEquals(1, count($xml->xpath('/d:multistatus')));
$fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:status');
$this->assertTrue(false != $fileMetadataObjectXML);
$this->assertEquals(3, count($fileMetadataObjectXML));
$this->assertEquals("HTTP/1.1 400 Bad Request", (string) $fileMetadataObjectXML[0]);
$this->assertEquals("HTTP/1.1 200 OK", (string) $fileMetadataObjectXML[1]);
$this->assertEquals("HTTP/1.1 400 Bad Request", (string) $fileMetadataObjectXML[2]);
$fileMetadataObjectXML = $xml->xpath('/d:multistatus/d:response/d:propstat/d:prop/d:error/s:message');
$this->assertTrue(false != $fileMetadataObjectXML);
$this->assertEquals(2, count($fileMetadataObjectXML));
$this->assertEquals("Bundling not supported for already existing files", (string) $fileMetadataObjectXML[0]);
$this->assertEquals("StorageNotAvailableException raised", (string) $fileMetadataObjectXML[1]);
}
/*UTILITIES*/
private function setupServerTillData(){
$bodyContent = "<?xml version='1.0' encoding='UTF-8'?>
<d:multipart xmlns:d='DAV:'>
<d:part>
<d:prop>
<d:oc-path>/test/zombie1.jpg</d:oc-path>\n
<d:oc-mtime>1476393386</d:oc-mtime>\n
<d:oc-id>0</d:oc-id>\n
<d:oc-total-length>7</d:oc-total-length>\n
</d:prop>
</d:part>
<d:part>
<d:prop>
<d:oc-path>/test/zombie2.jpg</d:oc-path>\n
<d:oc-mtime>1476393386</d:oc-mtime>\n
<d:oc-id>1</d:oc-id>\n
<d:oc-total-length>7</d:oc-total-length>\n
</d:prop>
</d:part>
<d:part>
<d:prop>
<d:oc-path>zombie3.jpg</d:oc-path>\n
<d:oc-mtime>1476393232</d:oc-mtime>\n
<d:oc-id>2</d:oc-id>\n
<d:oc-total-length>7</d:oc-total-length>\n
</d:prop>
</d:part>
</d:multipart>";
$headers['content-length'] = strlen($bodyContent);
$headers['content-type'] = 'text/xml; charset=utf-8';
//this part will have some arbitrary, correct headers
$bodyFull = "--".self::BOUNDRARY
."\r\nContent-Type: ".$headers['content-type']
."\r\nContent-length: ".$headers['content-length']
."\r\n\r\n"
."$bodyContent"
."\r\n--".self::BOUNDRARY
."\r\nContent-ID: 0"
."\r\n\r\n"
."zombie1"
."\r\n--".self::BOUNDRARY
."\r\nContent-ID: 1"
."\r\n\r\n"
."zombie2"
."\r\n--".self::BOUNDRARY
."\r\nContent-ID: 2"
."\r\n\r\n"
."zombie3"
."\r\n--".self::BOUNDRARY."--";
$this->setupServerTillHeader();
$this->fillMultipartContentsParserStreamWithBody($bodyFull);
}
private function setupServerTillMetadata($bodyContent){
$headers['content-length'] = strlen($bodyContent);
$headers['content-type'] = 'text/xml; charset=utf-8';
//this part will have some arbitrary, correct headers
$bodyFull = "--".self::BOUNDRARY
."\r\nContent-Type: ".$headers['content-type']
."\r\nContent-length: ".$headers['content-length']
."\r\n\r\n"
."$bodyContent\r\n--".self::BOUNDRARY."--";
$this->setupServerTillHeader();
$this->fillMultipartContentsParserStreamWithBody($bodyFull);
}
private function setupServerTillHeader(){
$this->setupServerTillFilesHome();
$this->request
->expects($this->atLeastOnce())
->method('getHeader')
->with('Content-Type')
->will($this->returnValue("multipart/related; boundary=".self::BOUNDRARY));
$this->server
->expects($this->once())
->method('emit')
->will($this->returnValue(true));
}
private function setupServerTillFilesHome(){
$this->request
->expects($this->once())
->method('getPath')
->will($this->returnValue('files/admin'));
$node = $this->getMockBuilder('\OCA\DAV\Files\FilesHome')
->disableOriginalConstructor()
->getMock();
$this->server->tree->expects($this->once())
->method('getNodeForPath')
->with('files/admin')
->will($this->returnValue($node));
}
private function fillMultipartContentsParserStreamWithBody($bodyString){
$bodyStream = fopen('php://temp', 'r+');
fwrite($bodyStream, $bodyString);
rewind($bodyStream);
$this->request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
}
public function tearDown() {
$userManager = \OC::$server->getUserManager();
$userManager->get($this->user)->delete();
unset($_SERVER['HTTP_OC_CHUNKED']);
parent::tearDown();
}
}

@ -1,220 +0,0 @@
<?php
/**
* @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com>
* @author Louis Chemineau <louis@chmn.me>
*
* @copyright Copyright (c) 2016, ownCloud GmbH.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\Files;
use OCP\Lock\ILockingProvider;
/**
* Class File
*
* @group DB
*
* @package OCA\DAV\Tests\unit\Connector\Sabre
*/
class BundledFileTest extends \Test\TestCase {
/**
* @var string
*/
private $user;
/* BASICS */
public function setUp() {
parent::setUp();
\OC_Hook::clear();
$this->user = $this->getUniqueID('user_');
$userManager = \OC::$server->getUserManager();
$userManager->createUser($this->user, 'pass');
$this->loginAsUser($this->user);
}
/* TESTS */
/**
* Test basic successful bundled file PutFile
*/
public function testPutFile() {
$bodyContent = 'blabla';
$headers['oc-total-length'] = 6;
$headers['oc-path'] = '/foo.txt';
$headers['oc-mtime'] = '1473336321';
$headers['response'] = null;
//this part will have some arbitrary, correct headers
$bodyFull = "$bodyContent\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->doPutFIle($headers, $multipartContentsParser);
}
/**
* Test basic successful bundled file PutFile
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage File requires oc-total-length header to be read
*/
public function testPutFileNoLength() {
$bodyContent = 'blabla';
$headers['oc-path'] = '/foo.txt';
$headers['oc-mtime'] = '1473336321';
$headers['response'] = null;
//this part will have some arbitrary, correct headers
$bodyFull = "$bodyContent\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->doPutFIle($headers, $multipartContentsParser);
}
/**
* Test putting a single file
*
* @expectedException \Sabre\DAV\Exception\Forbidden
* @expectedExceptionMessage PUT method not supported for bundling
*/
public function testThrowIfPut() {
$fileContents = $this->getStream('test data');
$this->doPut('/foo.txt', $fileContents);
}
/* UTILITIES */
private function getMockStorage() {
$storage = $this->getMockBuilder('\OCP\Files\Storage')
->getMock();
$storage->expects($this->any())
->method('getId')
->will($this->returnValue('home::someuser'));
return $storage;
}
public function tearDown() {
$userManager = \OC::$server->getUserManager();
$userManager->get($this->user)->delete();
unset($_SERVER['HTTP_OC_CHUNKED']);
parent::tearDown();
}
/**
* @param string $string
*/
private function getStream($string) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $string);
fseek($stream, 0);
return $stream;
}
/**
* Do basic put for single bundled file
*/
private function doPutFIle($fileMetadata, $contentHandler, $view = null, $viewRoot = null) {
$path = $fileMetadata['oc-path'];
if(is_null($view)){
$view = \OC\Files\Filesystem::getView();
}
if (!is_null($viewRoot)) {
$view = new \OC\Files\View($viewRoot);
} else {
$viewRoot = '/' . $this->user . '/files';
}
$info = new \OC\Files\FileInfo(
$viewRoot . '/' . ltrim($path, '/'),
$this->getMockStorage(),
null,
['permissions' => \OCP\Constants::PERMISSION_ALL],
null
);
$file = new BundledFile($view, $info, $contentHandler);
// beforeMethod locks
$view->lockFile($path, ILockingProvider::LOCK_SHARED);
$result = $file->putFile($fileMetadata);
// afterMethod unlocks
$view->unlockFile($path, ILockingProvider::LOCK_SHARED);
return $result;
}
private function fillMultipartContentsParserStreamWithBody($bodyString){
$bodyStream = fopen('php://temp', 'r+');
fwrite($bodyStream, $bodyString);
rewind($bodyStream);
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
$mcp = new \OCA\DAV\Files\MultipartContentsParser($request);
return $mcp;
}
/**
* Simulate putting a file to the given path.
*
* @param string $path path to put the file into
* @param string $viewRoot root to use for the view
*
* @return null|string of the PUT operaiton which is usually the etag
*/
private function doPut($path, $fileContents, $viewRoot = null) {
$view = \OC\Files\Filesystem::getView();
if (!is_null($viewRoot)) {
$view = new \OC\Files\View($viewRoot);
} else {
$viewRoot = '/' . $this->user . '/files';
}
$info = new \OC\Files\FileInfo(
$viewRoot . '/' . ltrim($path, '/'),
$this->getMockStorage(),
null,
['permissions' => \OCP\Constants::PERMISSION_ALL],
null
);
$file = new BundledFile($view, $info, null);
// beforeMethod locks
$view->lockFile($path, ILockingProvider::LOCK_SHARED);
$result = $file->put($fileContents);
// afterMethod unlocks
$view->unlockFile($path, ILockingProvider::LOCK_SHARED);
return $result;
}
}

@ -1,416 +0,0 @@
<?php
/**
* @author Piotr Mrowczynski <Piotr.Mrowczynski@owncloud.com>
* @author Louis Chemineau <louis@chmn.me>
*
* @copyright Copyright (c) 2016, ownCloud GmbH.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\Tests\unit\DAV;
use Test\TestCase;
class MultipartContentsParserTest extends TestCase {
private $boundrary;
protected function setUp() {
parent::setUp();
$this->boundrary = 'boundary';
}
/*TESTS*/
/**
* Test basic gets() functionality, that if passed string instead of resource, it should fail
*
* @expectedException \Sabre\DAV\Exception\BadRequest
* @expectedExceptionMessage Unable to get request content
*/
public function testGetsThrowWrongContents() {
//TODO
$bodyStream = "I am not a stream, but pretend to be";
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
$mcp = new \OCA\DAV\Files\MultipartContentsParser($request);
$mcp->gets();
}
/**
* Test function readHeaders(), so if passed empty string, it will return null
*
*/
public function testReadHeadersThrowEmptyHeader() {
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$mcp = new \OCA\DAV\Files\MultipartContentsParser($request);
$mcp->readHeaders('');
$this->assertEquals(null, $mcp->readHeaders(''));
}
/**
* streamRead function with incorrect parameter
*
* @expectedException \Sabre\DAV\Exception\BadRequest
* @expectedExceptionMessage Method streamRead cannot read contents with negative length
*/
public function testStreamReadToStringThrowNegativeLength() {
$bodyContent = 'blabla';
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyContent);
//give negative length
$multipartContentsParser->streamReadToString(-1);
}
/**
* streamRead function with incorrect parameter
*
* @expectedException \Sabre\DAV\Exception\BadRequest
* @expectedExceptionMessage Method streamRead cannot read contents with negative length
*/
public function testStreamReadToStreamThrowNegativeLength() {
$target = fopen('php://temp', 'r+');
$bodyContent = 'blabla';
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyContent);
//give negative length
$multipartContentsParser->streamReadToStream($target,-1);
}
public function testStreamReadToString() {
$length = 0;
list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length);
$this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length));
$length = 1000;
list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length);
$this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length));
$length = 8192;
list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length);
$this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length));
$length = 20000;
list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length);
$this->assertEquals($bodyString, $multipartContentsParser->streamReadToString($length));
}
public function testStreamReadToStream() {
$length = 0;
$this->streamReadToStreamBuilder($length);
$length = 1000;
$this->streamReadToStreamBuilder($length);
$length = 8192;
$this->streamReadToStreamBuilder($length);
$length = 20000;
$this->streamReadToStreamBuilder($length);
}
private function streamReadToStreamBuilder($length) {
$target = fopen('php://temp', 'r+');
list($multipartContentsParser, $bodyString) = $this->fillMultipartContentsParserStreamWithChars($length);
$this->assertEquals(true, $multipartContentsParser->streamReadToStream($target,$length));
rewind($target);
$this->assertEquals($bodyString, stream_get_contents($target));
}
/**
* @expectedException \Exception
* @expectedExceptionMessage An error appears while reading and parsing header of content part using fgets
*/
public function testGetPartThrowFailfgets() {
$bodyStream = fopen('php://temp', 'r+');
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
$mcp = $this->getMockBuilder('OCA\DAV\Files\MultipartContentsParser')
->setConstructorArgs(array($request))
->setMethods(array('gets'))
->getMock();
$mcp->expects($this->any())
->method('gets')
->will($this->onConsecutiveCalls("--boundary\r\n", "Content-ID: 0\r\n", false));
$mcp->getPartHeaders($this->boundrary);
}
/**
* If one one the content parts does not contain boundrary, means that received wrong request
*
* @expectedException \Exception
* @expectedExceptionMessage Expected boundary delimiter in content part
*/
public function testGetPartThrowNoBoundraryFound() {
// Calling multipletimes getPart on parts without contents should return null,null and signal immedietaly that endDelimiter was reached
$bodyFull = "--boundary_wrong\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
$multipartContentsParser->getPartHeaders($this->boundrary);
}
/**
* Reading from request which method getBody returns false
*
* @expectedException \Sabre\DAV\Exception\BadRequest
* @expectedExceptionMessage Unable to get request content
*/
public function testStreamReadThrowWrongBody() {
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn(false);
$mcp = new \OCA\DAV\Files\MultipartContentsParser($request);
$mcp->getPartHeaders($this->boundrary);
}
/**
* Reading from request which method getBody returns false
*
*/
public function testMultipartContentSeekToContentLength() {
$bodyStream = fopen('php://temp', 'r+');
$bodyString = '';
$length = 1000;
for ($x = 0; $x < $length; $x++) {
$bodyString .= 'k';
}
fwrite($bodyStream, $bodyString);
rewind($bodyStream);
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
$mcp = new \OCA\DAV\Files\MultipartContentsParser($request);
$this->assertEquals(true,$mcp->multipartContentSeekToContentLength($length));
}
/**
* Test cases with wrong or incomplete boundraries
*
*/
public function testGetPartHeadersWrongBoundaryCases() {
// Calling multipletimes getPart on parts without contents should return null and signal immedietaly that endDelimiter was reached
$bodyFull = "--boundary\r\n--boundary_wrong\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary));
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
// Test empty content
$bodyFull = "--boundary\r\n";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->assertEquals(null, $multipartContentsParser->getPartHeaders($this->boundrary));
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
// Test empty content
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody('');
$this->assertEquals(null, $multipartContentsParser->getPartHeaders($this->boundrary));
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
// Calling multipletimes getPart on parts without contents should return null and signal immedietaly that endDelimiter was reached
// endDelimiter should be signaled after first getPart since it will read --boundrary till it finds contents.
$bodyFull = "--boundary\r\n--boundary\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
$this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary));
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
$this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary));
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
$this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary));
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
$this->assertEquals(null,$multipartContentsParser->getPartHeaders($this->boundrary));
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
}
/**
* Test will check if we can correctly parse headers and content using streamReadToString
*
*/
public function testReadHeaderBodyCorrect() {
//multipart part will have some content bodyContent and some headers
$bodyContent = 'blabla';
$headers['content-length'] = '6';
$headers['content-type'] = 'text/xml; charset=utf-8';
//this part will have some arbitrary, correct headers
$bodyFull = '--boundary'
."\r\nContent-Type: ".$headers['content-type']
."\r\nContent-length: ".$headers['content-length']
."\r\n\r\n"
."$bodyContent\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
//parse it
$headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary);
$bodyParsed = $multipartContentsParser->streamReadToString(6);
//check if end delimiter is not reached, since we just read 6 bytes, and stopped at \r\n
$this->assertEquals(false,$multipartContentsParser->getEndDelimiterReached());
//check that we parsed correct headers
$this->assertEquals($bodyContent, $bodyParsed);
$this->assertEquals($headers, $headersParsed);
//parse further to check if there is new part. There is no, so headers are null and delimiter reached
$headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary);
$this->assertEquals(null,$headersParsed);
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
}
/**
* Test will check parsing incorrect headers and content using streamReadToString
*
*/
public function testReadHeaderBodyIncorrect() {
//multipart part will have some content bodyContent and some headers
$bodyContent = 'blabla';
$headers['content-length'] = '6';
$headers['content-type'] = 'text/xml; charset=utf-8';
//this part will one correct and one incorrect header
$bodyFull = '--boundary'
."\r\nContent-Type: ".$headers['content-type']
."\r\nContent-length"
."\r\n\r\n"
."$bodyContent\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
//parse it and expect null, since contains incorrect headers
$headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary);
$this->assertEquals(null, $headersParsed);
$this->assertEquals(false,$multipartContentsParser->getEndDelimiterReached());
//parse further to check if next call with not read headers again
//this should return null again and get to end of delimiter
$headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary);
$this->assertEquals(null,$headersParsed);
$this->assertEquals(true,$multipartContentsParser->getEndDelimiterReached());
}
/**
* Test will check reading error in StreamReadToString
*
* @expectedException \Sabre\DAV\Exception\BadRequest
* @expectedExceptionMessage Method streamRead read 20 expeceted 60
*/
public function testReadBodyIncorrect() {
//multipart part will have some content bodyContent and content-length header will specify to big value
//this
$bodyContent = 'blabla';
$headers['content-length'] = '60';
$headers['content-type'] = 'text/xml; charset=utf-8';
//this part will have some arbitrary, correct headers
$bodyFull = '--boundary'
."\r\nContent-Type: ".$headers['content-type']
."\r\nContent-length: ".$headers['content-length']
."\r\n\r\n"
."$bodyContent\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
//parse headers
$headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary);
$this->assertEquals($headers, $headersParsed);
$this->assertEquals(true, array_key_exists('content-length',$headersParsed));
$multipartContentsParser->streamReadToString($headersParsed['content-length']);
}
/**
* Test will check reading error in StreamReadToString return false
*
*/
public function testReadBodyStreamIncorrect() {
//multipart part will have some content bodyContent and content-length header will specify to big value
//this
$bodyContent = 'blabla';
$headers['content-length'] = '60';
$headers['content-type'] = 'text/xml; charset=utf-8';
//this part will have some arbitrary, correct headers
$bodyFull = '--boundary'
."\r\nContent-Type: ".$headers['content-type']
."\r\nContent-length: ".$headers['content-length']
."\r\n\r\n"
."$bodyContent\r\n--boundary--";
$multipartContentsParser = $this->fillMultipartContentsParserStreamWithBody($bodyFull);
//parse headers
$headersParsed = $multipartContentsParser->getPartHeaders($this->boundrary);
$this->assertEquals($headers, $headersParsed);
$this->assertEquals(true, array_key_exists('content-length',$headersParsed));
$target = fopen('php://temp', 'r+');
$bodyParsed = $multipartContentsParser->streamReadToStream($target, $headersParsed['content-length']);
$this->assertEquals(false, $bodyParsed);
}
/*UTILITIES*/
private function fillMultipartContentsParserStreamWithChars($length){
$bodyStream = fopen('php://temp', 'r+');
$bodyString = '';
for ($x = 0; $x < $length; $x++) {
$bodyString .= 'k';
}
fwrite($bodyStream, $bodyString);
rewind($bodyStream);
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
$mcp = new \OCA\DAV\Files\MultipartContentsParser($request);
return array($mcp, $bodyString);
}
private function fillMultipartContentsParserStreamWithBody($bodyString){
$bodyStream = fopen('php://temp', 'r+');
fwrite($bodyStream, $bodyString);
rewind($bodyStream);
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
$mcp = new \OCA\DAV\Files\MultipartContentsParser($request);
return $mcp;
}
}

@ -0,0 +1,281 @@
<?php
/**
* @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\DAV\Tests\unit\DAV;
use Test\TestCase;
use \OCA\DAV\BulkUpload\MultipartRequestParser;
class MultipartRequestParserTest extends TestCase {
private function getValidBodyObject() {
return [
[
"headers" => [
"Content-Length" => 7,
"X-File-MD5" => "4f2377b4d911f7ec46325fe603c3af03",
"X-File-Path" => "/coucou.txt"
],
"content" => "Coucou\n"
]
];
}
private function getMultipartParser(array $parts, array $headers = [], string $boundary = "boundary_azertyuiop"): MultipartRequestParser {
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$headers = array_merge(['Content-Type' => 'multipart/related; boundary='.$boundary], $headers);
$request->expects($this->any())
->method('getHeader')
->willReturnCallback(function (string $key) use (&$headers) {
return $headers[$key];
});
$body = "";
foreach ($parts as $part) {
$body .= '--'.$boundary."\r\n";
foreach ($part['headers'] as $headerKey => $headerPart) {
$body .= $headerKey.": ".$headerPart."\r\n";
}
$body .= "\r\n";
$body .= $part['content']."\r\n";
}
$body .= '--'.$boundary."--";
$stream = fopen('php://temp','r+');
fwrite($stream, $body);
rewind($stream);
$request->expects($this->any())
->method('getBody')
->willReturn($stream);
return new MultipartRequestParser($request);
}
/**
* Test validation of the request's body type
*/
public function testBodyTypeValidation() {
$bodyStream = "I am not a stream, but pretend to be";
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
->disableOriginalConstructor()
->getMock();
$request->expects($this->any())
->method('getBody')
->willReturn($bodyStream);
$this->expectExceptionMessage('Body should be of type resource');
new MultipartRequestParser($request);
}
/**
* Test with valid request.
* - valid boundary
* - valid md5 hash
* - valid content-length
* - valid file content
* - valid file path
*/
public function testValidRequest() {
$multipartParser = $this->getMultipartParser(
$this->getValidBodyObject()
);
[$headers, $content] = $multipartParser->parseNextPart();
$this->assertSame((int)$headers["content-length"], 7, "Content-Length header should be the same as provided.");
$this->assertSame($headers["x-file-md5"], "4f2377b4d911f7ec46325fe603c3af03", "X-File-MD5 header should be the same as provided.");
$this->assertSame($headers["x-file-path"], "/coucou.txt", "X-File-Path header should be the same as provided.");
$this->assertSame($content, "Coucou\n", "Content should be the same");
}
/**
* Test with invalid md5 hash.
*/
public function testInvalidMd5Hash() {
$bodyObject = $this->getValidBodyObject();
$bodyObject["0"]["headers"]["X-File-MD5"] = "f2377b4d911f7ec46325fe603c3af03";
$multipartParser = $this->getMultipartParser(
$bodyObject
);
$this->expectExceptionMessage('Computed md5 hash is incorrect.');
$multipartParser->parseNextPart();
}
/**
* Test with a null md5 hash.
*/
public function testNullMd5Hash() {
$bodyObject = $this->getValidBodyObject();
unset($bodyObject["0"]["headers"]["X-File-MD5"]);
$multipartParser = $this->getMultipartParser(
$bodyObject
);
$this->expectExceptionMessage('The X-File-MD5 header must not be null.');
$multipartParser->parseNextPart();
}
/**
* Test with a null Content-Length.
*/
public function testNullContentLength() {
$bodyObject = $this->getValidBodyObject();
unset($bodyObject["0"]["headers"]["Content-Length"]);
$multipartParser = $this->getMultipartParser(
$bodyObject
);
$this->expectExceptionMessage('The Content-Length header must not be null.');
$multipartParser->parseNextPart();
}
/**
* Test with a lower Content-Length.
*/
public function testLowerContentLength() {
$bodyObject = $this->getValidBodyObject();
$bodyObject["0"]["headers"]["Content-Length"] = 6;
$multipartParser = $this->getMultipartParser(
$bodyObject
);
$this->expectExceptionMessage('Computed md5 hash is incorrect.');
$multipartParser->parseNextPart();
}
/**
* Test with a higher Content-Length.
*/
public function testHigherContentLength() {
$bodyObject = $this->getValidBodyObject();
$bodyObject["0"]["headers"]["Content-Length"] = 8;
$multipartParser = $this->getMultipartParser(
$bodyObject
);
$this->expectExceptionMessage('Computed md5 hash is incorrect.');
$multipartParser->parseNextPart();
}
/**
* Test with wrong boundary in body.
*/
public function testWrongBoundary() {
$bodyObject = $this->getValidBodyObject();
$multipartParser = $this->getMultipartParser(
$bodyObject,
['Content-Type' => 'multipart/related; boundary=boundary_poiuytreza']
);
$this->expectExceptionMessage('Boundary not found where it should be.');
$multipartParser->parseNextPart();
}
/**
* Test with no boundary in request headers.
*/
public function testNoBoundaryInHeader() {
$bodyObject = $this->getValidBodyObject();
$this->expectExceptionMessage('Error while parsing boundary in Content-Type header.');
$this->getMultipartParser(
$bodyObject,
['Content-Type' => 'multipart/related']
);
}
/**
* Test with no boundary in the request's headers.
*/
public function testNoBoundaryInBody() {
$bodyObject = $this->getValidBodyObject();
$multipartParser = $this->getMultipartParser(
$bodyObject,
['Content-Type' => 'multipart/related; boundary=boundary_azertyuiop'],
''
);
$this->expectExceptionMessage('Boundary not found where it should be.');
$multipartParser->parseNextPart();
}
/**
* Test with a boundary with quotes in the request's headers.
*/
public function testBoundaryWithQuotes() {
$bodyObject = $this->getValidBodyObject();
$multipartParser = $this->getMultipartParser(
$bodyObject,
['Content-Type' => 'multipart/related; boundary="boundary_azertyuiop"'],
);
$multipartParser->parseNextPart();
// Dummy assertion, we just want to test that the parsing works.
$this->assertTrue(true);
}
/**
* Test with a wrong Content-Type in the request's headers.
*/
public function testWrongContentType() {
$bodyObject = $this->getValidBodyObject();
$this->expectExceptionMessage('Content-Type must be multipart/related');
$this->getMultipartParser(
$bodyObject,
['Content-Type' => 'multipart/form-data; boundary="boundary_azertyuiop"'],
);
}
/**
* Test with a wrong key after the content type in the request's headers.
*/
public function testWrongKeyInContentType() {
$bodyObject = $this->getValidBodyObject();
$this->expectExceptionMessage('Boundary is invalid');
$this->getMultipartParser(
$bodyObject,
['Content-Type' => 'multipart/related; wrongkey="boundary_azertyuiop"'],
);
}
/**
* Test with a null Content-Type in the request's headers.
*/
public function testNullContentType() {
$bodyObject = $this->getValidBodyObject();
$this->expectExceptionMessage('Content-Type can not be null');
$this->getMultipartParser(
$bodyObject,
['Content-Type' => null],
);
}
}

@ -537,6 +537,57 @@ trait WebDav {
$this->makeDavRequest($user, 'PUT', $file, ['OC-Chunked' => '1'], $data, "uploads");
}
/**
* @Given user :user uploads bulked files :name1 with :content1 and :name2 with :content2 and :name3 with :content3
* @param string $user
* @param string $name1
* @param string $content1
* @param string $name2
* @param string $content2
* @param string $name3
* @param string $content3
*/
public function userUploadsChunkedFiles($user, $name1, $content1, $name2, $content2, $name3, $content3) {
$boundary = "boundary_azertyuiop";
$body = "";
$body .= '--'.$boundary."\r\n";
$body .= "X-File-Path: ".$name1."\r\n";
$body .= "X-File-MD5: f6a6263167c92de8644ac998b3c4e4d1\r\n";
$body .= "Content-Length: ".strlen($content1)."\r\n";
$body .= "\r\n";
$body .= $content1."\r\n";
$body .= '--'.$boundary."\r\n";
$body .= "X-File-Path: ".$name2."\r\n";
$body .= "X-File-MD5: 87c7d4068be07d390a1fffd21bf1e944\r\n";
$body .= "Content-Length: ".strlen($content2)."\r\n";
$body .= "\r\n";
$body .= $content2."\r\n";
$body .= '--'.$boundary."\r\n";
$body .= "X-File-Path: ".$name3."\r\n";
$body .= "X-File-MD5: e86a1cf0678099986a901c79086f5617\r\n";
$body .= "Content-Length: ".strlen($content3)."\r\n";
$body .= "\r\n";
$body .= $content3."\r\n";
$body .= '--'.$boundary."--\r\n";
$stream = fopen('php://temp','r+');
fwrite($stream, $body);
rewind($stream);
$client = new GClient();
$options = [
'auth' => [$user, $this->regularUser],
'headers' => [
'Content-Type' => 'multipart/related; boundary='.$boundary,
'Content-Length' => (string)strlen($body),
],
'body' => $body
];
return $client->request("POST", substr($this->baseUrl, 0, -4) . "remote.php/dav/bulk", $options);
}
/**
* @Given user :user creates a new chunking upload with id :id
*/

@ -608,3 +608,14 @@ Feature: webdav-related
And user "user0" uploads new chunk file "3" with "CCCCC" to id "chunking-42"
When user "user0" moves new chunk file with id "chunking-42" to "/myChunkedFile.txt" with size 15
Then the HTTP status code should be "201"
Scenario: Upload bulked files
Given user "user0" exists
And user "user0" uploads bulked files "A.txt" with "AAAAA" and "B.txt" with "BBBBB" and "C.txt" with "CCCCC"
When As an "user0"
Then Downloading file "/A.txt"
And Downloaded content should be "AAAAA"
And Downloading file "/B.txt"
And Downloaded content should be "BBBBB"
And Downloading file "/C.txt"
And Downloaded content should be "CCCCC"

Loading…
Cancel
Save