Skip to content

Commit

Permalink
Use Webdav PUT for uploads in the web browser
Browse files Browse the repository at this point in the history
- uses PUT method with jquery.fileupload for regular and public file
  lists
- for IE and browsers that don't support it, use POST with iframe
  transport
- implemented Sabre plugin to handle iframe transport and redirect the
  embedded PUT request to the proper handler
- added RFC5995 POST to file collection with "add-member" property to
  make it possible to auto-rename conflicting file names
- remove obsolete ajax/upload.php and obsolete ajax routes
  • Loading branch information
Vincent Petry committed Dec 21, 2015
1 parent 0c16909 commit 5e88bef
Show file tree
Hide file tree
Showing 16 changed files with 1,302 additions and 715 deletions.
59 changes: 55 additions & 4 deletions apps/dav/lib/connector/sabre/filesplugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
namespace OCA\DAV\Connector\Sabre;

use Sabre\DAV\IFile;
use \Sabre\DAV\PropFind;
use \Sabre\DAV\PropPatch;
use \Sabre\HTTP\RequestInterface;
use \Sabre\HTTP\ResponseInterface;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
use Sabre\DAV\Exception\BadRequest;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use OCP\Files\StorageNotAvailableException;
use OCA\DAV\Connector\Sabre\Directory;

class FilesPlugin extends \Sabre\DAV\ServerPlugin {

Expand Down Expand Up @@ -114,6 +116,8 @@ public function initialize(\Sabre\DAV\Server $server) {
$this->server = $server;
$this->server->on('propFind', array($this, 'handleGetProperties'));
$this->server->on('propPatch', array($this, 'handleUpdateProperties'));
// RFC5995 to add file to the collection with a suggested name
$this->server->on('method:POST', [$this, 'httpPost']);
$this->server->on('afterBind', array($this, 'sendFileIdHeader'));
$this->server->on('afterWriteContent', array($this, 'sendFileIdHeader'));
$this->server->on('afterMethod:GET', [$this,'httpGet']);
Expand Down Expand Up @@ -318,4 +322,51 @@ public function sendFileIdHeader($filePath, \Sabre\DAV\INode $node = null) {
}
}

/**
* POST operation on directories to create a new file
* with suggested name
*
* @param RequestInterface $request request object
* @param ResponseInterface $response response object
* @return null|false
*/
public function httpPost(RequestInterface $request, ResponseInterface $response) {
// TODO: move this to another plugin ?
if (!\OC::$CLI && !\OC::$server->getRequest()->passesCSRFCheck()) {
throw new BadRequest('Invalid CSRF token');
}

list($parentPath, $name) = \Sabre\HTTP\URLUtil::splitPath($request->getPath());

// Making sure the parent node exists and is a directory
$node = $this->server->tree->getNodeForPath($parentPath);

if ($node instanceof Directory) {
// no Add-Member found
if (empty($name) || $name[0] !== '&') {
// suggested name required
throw new BadRequest('Missing suggested file name');
}

$name = substr($name, 1);

if (empty($name)) {
// suggested name required
throw new BadRequest('Missing suggested file name');
}

// make sure the name is unique
$name = basename(\OC_Helper::buildNotExistingFileNameForView($parentPath, $name, $this->fileView));

$node->createFile($name, $request->getBodyAsStream());

list($parentUrl, ) = \Sabre\HTTP\URLUtil::splitPath($request->getUrl());

$response->setHeader('Location', $parentUrl . '/' . rawurlencode($name));

// created
$response->setStatus(201);
return false;
}
}
}
188 changes: 188 additions & 0 deletions apps/dav/lib/connector/sabre/iframetransportplugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php
/**
* @author Vincent Petry <pvince81@owncloud.com>
*
* @copyright Copyright (c) 2015, ownCloud, Inc.
* @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\Connector\Sabre;

use Sabre\DAV\IFile;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\DAV\Exception\BadRequest;

/**
* Plugin to receive Webdav PUT through POST,
* mostly used as a workaround for browsers that
* do not support PUT upload.
*/
class IFrameTransportPlugin extends \Sabre\DAV\ServerPlugin {

/**
* @var \Sabre\DAV\Server $server
*/
private $server;

/**
* This initializes the plugin.
*
* @param \Sabre\DAV\Server $server
* @return void
*/
public function initialize(\Sabre\DAV\Server $server) {
$this->server = $server;
$this->server->on('method:POST', [$this, 'handlePost']);
}

/**
* POST operation
*
* @param RequestInterface $request request object
* @param ResponseInterface $response response object
* @return null|false
*/
public function handlePost(RequestInterface $request, ResponseInterface $response) {
try {
return $this->processUpload($request, $response);
} catch (\Sabre\DAV\Exception $e) {
$response->setStatus($e->getHTTPCode());
$response->setBody(['message' => $e->getMessage()]);
$this->convertResponse($response);
return false;
}
}

/**
* Wrap and send response in JSON format
*
* @param ResponseInterface $response response object
*/
private function convertResponse(ResponseInterface $response) {
if (is_resource($response->getBody())) {
throw new BadRequest('Cannot request binary data with iframe transport');
}

$responseData = json_encode([
'status' => $response->getStatus(),
'headers' => $response->getHeaders(),
'data' => $response->getBody(),
]);

// IE needs this content type
$response->setHeader('Content-Type', 'text/plain');
$response->setHeader('Content-Length', strlen($responseData));
$response->setStatus(200);
$response->setBody($responseData);
}

/**
* Process upload
*
* @param RequestInterface $request request object
* @param ResponseInterface $response response object
* @return null|false
*/
private function processUpload(RequestInterface $request, ResponseInterface $response) {
$queryParams = $request->getQueryParameters();

if (!isset($queryParams['_method'])) {
return null;
}

$method = $queryParams['_method'];
if ($method !== 'PUT') {
return null;
}

$contentType = $request->getHeader('Content-Type');
list($contentType) = explode(';', $contentType);
if ($contentType !== 'application/x-www-form-urlencoded'
&& $contentType !== 'multipart/form-data'
) {
return null;
}

if (!isset($_FILES['files'])) {
return null;
}

// TODO: move this to another plugin ?
if (!\OC::$CLI && !\OC::$server->getRequest()->passesCSRFCheck()) {
throw new BadRequest('Invalid CSRF token');
}

if ($_FILES) {
$file = current($_FILES);
} else {
return null;
}

if ($file['error'][0] !== 0) {
throw new BadRequest('Error during upload, code ' . $file['error'][0]);
}

if (!\OC::$CLI && !is_uploaded_file($file['tmp_name'][0])) {
return null;
}

if (count($file['tmp_name']) > 1) {
throw new BadRequest('Only a single file can be uploaded');
}

$postData = $request->getPostData();
if (isset($postData['headers'])) {
$headers = json_decode($postData['headers'], true);

// copy safe headers into the request
$allowedHeaders = [
'If',
'If-Match',
'If-None-Match',
'If-Modified-Since',
'If-Unmodified-Since',
'Authorization',
];

foreach ($allowedHeaders as $allowedHeader) {
if (isset($headers[$allowedHeader])) {
$request->setHeader($allowedHeader, $headers[$allowedHeader]);
}
}
}

// MEGAHACK, because the Sabre File impl reads this property directly
$_SERVER['CONTENT_LENGTH'] = $file['size'][0];
$request->setHeader('Content-Length', $file['size'][0]);

$tmpFile = $file['tmp_name'][0];
$resource = fopen($tmpFile, 'r');

$request->setBody($resource);
$request->setMethod($method);

$this->server->invokeMethod($request, $response, false);

fclose($resource);
unlink($tmpFile);

$this->convertResponse($response);

return false;
}

}
1 change: 1 addition & 0 deletions apps/dav/lib/connector/sabre/serverfactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public function createServer($baseUri,
// FIXME: The following line is a workaround for legacy components relying on being able to send a GET to /
$server->addPlugin(new \OCA\DAV\Connector\Sabre\DummyGetResponsePlugin());
$server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $this->logger));
$server->addPlugin(new \OCA\DAV\Connector\Sabre\IframeTransportPlugin());
$server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin());
$server->addPlugin(new \OCA\DAV\Connector\Sabre\ListenerPlugin($this->dispatcher));
// Finder on OS X requires Class 2 WebDAV support (locking), since we do
Expand Down
86 changes: 84 additions & 2 deletions apps/dav/tests/unit/connector/sabre/filesplugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public function setUp() {
$this->tree = $this->getMockBuilder('\Sabre\DAV\Tree')
->disableOriginalConstructor()
->getMock();
$this->server->tree = $this->tree;
$this->view = $this->getMockBuilder('\OC\Files\View')
->disableOriginalConstructor()
->getMock();
Expand All @@ -60,7 +61,7 @@ public function setUp() {
/**
* @param string $class
*/
private function createTestNode($class) {
private function createTestNode($class, $path = '/dummypath') {
$node = $this->getMockBuilder($class)
->disableOriginalConstructor()
->getMock();
Expand All @@ -70,7 +71,7 @@ private function createTestNode($class) {

$this->tree->expects($this->any())
->method('getNodeForPath')
->with('/dummypath')
->with($path)
->will($this->returnValue($node));

$node->expects($this->any())
Expand Down Expand Up @@ -348,4 +349,85 @@ public function testMoveSrcNotExist() {

$this->plugin->checkMove('FolderA/test.txt', 'test.txt');
}

public function postCreateFileProvider() {
$baseUrl = 'http://example.com/owncloud/remote.php/webdav/subdir/';
return [
['test.txt', 'some file.txt', 'some file.txt', $baseUrl . 'some%20file.txt'],
['some file.txt', 'some file.txt', 'some file (2).txt', $baseUrl . 'some%20file%20%282%29.txt'],
];
}

/**
* @dataProvider postCreateFileProvider
*/
public function testPostWithAddMember($existingFile, $wantedName, $deduplicatedName, $expectedLocation) {
$request = $this->getMock('Sabre\HTTP\RequestInterface');
$response = $this->getMock('Sabre\HTTP\ResponseInterface');

$request->expects($this->any())
->method('getUrl')
->will($this->returnValue('http://example.com/owncloud/remote.php/webdav/subdir/&' . $wantedName));

$request->expects($this->any())
->method('getPath')
->will($this->returnValue('/subdir/&' . $wantedName));

$request->expects($this->once())
->method('getBodyAsStream')
->will($this->returnValue(fopen('data://text/plain,hello', 'r')));

$this->view->expects($this->any())
->method('file_exists')
->will($this->returnCallback(function($path) use ($existingFile) {
return ($path === '/subdir/' . $existingFile);
}));

$node = $this->createTestNode('\OCA\DAV\Connector\Sabre\Directory', '/subdir');

$node->expects($this->once())
->method('createFile')
->with($deduplicatedName, $this->isType('resource'));

$response->expects($this->once())
->method('setStatus')
->with(201);
$response->expects($this->once())
->method('setHeader')
->with('Location', $expectedLocation);

$this->assertFalse($this->plugin->httpPost($request, $response));
}

public function testPostOnNonDirectory() {
$request = $this->getMock('Sabre\HTTP\RequestInterface');
$response = $this->getMock('Sabre\HTTP\ResponseInterface');

$request->expects($this->any())
->method('getPath')
->will($this->returnValue('/subdir/test.txt/&abc'));

$this->createTestNode('\OCA\DAV\Connector\Sabre\File', '/subdir/test.txt');

$this->assertNull($this->plugin->httpPost($request, $response));
}

/**
* @expectedException \Sabre\DAV\Exception\BadRequest
*/
public function testPostWithoutAddMember() {
$request = $this->getMock('Sabre\HTTP\RequestInterface');
$response = $this->getMock('Sabre\HTTP\ResponseInterface');

$request->expects($this->any())
->method('getPath')
->will($this->returnValue('/subdir/&'));

$node = $this->createTestNode('\OCA\DAV\Connector\Sabre\Directory', '/subdir');

$node->expects($this->never())
->method('createFile');

$this->plugin->httpPost($request, $response);
}
}
Loading

0 comments on commit 5e88bef

Please sign in to comment.