diff options
author | Vincent Petry <pvince81@owncloud.com> | 2015-12-16 17:35:53 +0100 |
---|---|---|
committer | Roeland Jago Douma <roeland@famdouma.nl> | 2016-10-24 21:45:00 +0200 |
commit | 59c5be1cc572793a8d50e87ab589e1cc4cf2ed12 (patch) | |
tree | 97b096fee075115bac45d69f2e0da7af5ffb41f2 /apps/dav | |
parent | 4d01f23978549c2a33e9fdfdc3b9308cc9dc1078 (diff) | |
download | nextcloud-server-59c5be1cc572793a8d50e87ab589e1cc4cf2ed12.tar.gz nextcloud-server-59c5be1cc572793a8d50e87ab589e1cc4cf2ed12.zip |
Use Webdav PUT for uploads in the web browser
- 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
Signed-off-by: Roeland Jago Douma <roeland@famdouma.nl>
Diffstat (limited to 'apps/dav')
5 files changed, 487 insertions, 2 deletions
diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index aa5bacea5bb..39d15e0c6e9 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -46,6 +46,8 @@ use \Sabre\HTTP\ResponseInterface; use OCP\Files\StorageNotAvailableException; use OCP\IConfig; use OCP\IRequest; +use Sabre\DAV\Exception\BadRequest; +use OCA\DAV\Connector\Sabre\Directory; class FilesPlugin extends ServerPlugin { @@ -170,6 +172,8 @@ class FilesPlugin extends ServerPlugin { $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']); @@ -432,4 +436,51 @@ class FilesPlugin extends ServerPlugin { } } + /** + * 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->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('Content-Location', $parentUrl . '/' . rawurlencode($name)); + + // created + $response->setStatus(201); + return false; + } + } } diff --git a/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php b/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php new file mode 100644 index 00000000000..af6e5a62a5e --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php @@ -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' && $method !== 'POST') { + 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; + } + +} diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 6d9f9b1bc8b..da541abc199 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -114,6 +114,7 @@ class ServerFactory { // 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()); // Some WebDAV clients do require Class 2 WebDAV support (locking), since // we do not provide locking we emulate it using a fake locking plugin. diff --git a/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php index 282a5b2f626..43ca119abff 100644 --- a/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php @@ -123,7 +123,7 @@ class FilesPluginTest extends TestCase { * @param string $class * @return \PHPUnit_Framework_MockObject_MockObject */ - private function createTestNode($class) { + private function createTestNode($class, $path = '/dummypath') { $node = $this->getMockBuilder($class) ->disableOriginalConstructor() ->getMock(); @@ -134,7 +134,7 @@ class FilesPluginTest extends TestCase { $this->tree->expects($this->any()) ->method('getNodeForPath') - ->with('/dummypath') + ->with($path) ->will($this->returnValue($node)); $node->expects($this->any()) @@ -547,4 +547,85 @@ class FilesPluginTest extends TestCase { $this->assertEquals("false", $propFind->get(self::HAS_PREVIEW_PROPERTYNAME)); } + + 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('Content-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); + } } diff --git a/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php new file mode 100644 index 00000000000..485dd1b779e --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php @@ -0,0 +1,164 @@ +<?php + +namespace OCA\DAV\Tests\Unit\Connector\Sabre; + +/** + * Copyright (c) 2015 Vincent Petry <pvince81@owncloud.com> + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ +class IFrameTransportPluginTest extends \Test\TestCase { + + /** + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * @var \OCA\DAV\Connector\Sabre\IFrameTransportPlugin + */ + private $plugin; + + public function setUp() { + parent::setUp(); + $this->server = $this->getMockBuilder('\Sabre\DAV\Server') + ->disableOriginalConstructor() + ->getMock(); + + $this->plugin = new \OCA\DAV\Connector\Sabre\IFrameTransportPlugin(); + $this->plugin->initialize($this->server); + } + + public function tearDown() { + $_FILES = null; + unset($_SERVER['CONTENT_LENGTH']); + } + + public function testPutConversion() { + $request = $this->getMock('Sabre\HTTP\RequestInterface'); + $response = $this->getMock('Sabre\HTTP\ResponseInterface'); + + $request->expects($this->once()) + ->method('getQueryParameters') + ->will($this->returnValue(['_method' => 'PUT'])); + + $postData = [ + 'headers' => json_encode([ + 'If-None-Match' => '*', + 'Disallowed-Header' => 'test', + ]), + ]; + + $request->expects($this->once()) + ->method('getPostData') + ->will($this->returnValue($postData)); + + $request->expects($this->once()) + ->method('getHeader') + ->with('Content-Type') + ->will($this->returnValue('multipart/form-data')); + + $tmpFileName = tempnam(sys_get_temp_dir(), 'tmpfile'); + $fh = fopen($tmpFileName, 'w'); + fwrite($fh, 'hello'); + fclose($fh); + + $_FILES = ['files' => [ + 'error' => [0], + 'tmp_name' => [$tmpFileName], + 'size' => [5], + ]]; + + $request->expects($this->any()) + ->method('setHeader') + ->withConsecutive( + ['If-None-Match', '*'], + ['Content-Length', 5] + ); + + $request->expects($this->once()) + ->method('setMethod') + ->with('PUT'); + + $this->server->expects($this->once()) + ->method('invokeMethod') + ->with($request, $response); + + // response data before conversion + $response->expects($this->once()) + ->method('getHeaders') + ->will($this->returnValue(['Test-Response-Header' => [123]])); + + $response->expects($this->any()) + ->method('getBody') + ->will($this->returnValue('test')); + + $response->expects($this->once()) + ->method('getStatus') + ->will($this->returnValue(201)); + + $responseBody = json_encode([ + 'status' => 201, + 'headers' => ['Test-Response-Header' => [123]], + 'data' => 'test', + ]); + + // response data after conversion + $response->expects($this->once()) + ->method('setBody') + ->with($responseBody); + + $response->expects($this->once()) + ->method('setStatus') + ->with(200); + + $response->expects($this->any()) + ->method('setHeader') + ->withConsecutive( + ['Content-Type', 'text/plain'], + ['Content-Length', strlen($responseBody)] + ); + + $this->assertFalse($this->plugin->handlePost($request, $response)); + + $this->assertEquals(5, $_SERVER['CONTENT_LENGTH']); + + $this->assertFalse(file_exists($tmpFileName)); + } + + public function testIgnoreNonPut() { + $request = $this->getMock('Sabre\HTTP\RequestInterface'); + $response = $this->getMock('Sabre\HTTP\ResponseInterface'); + + $request->expects($this->once()) + ->method('getQueryParameters') + ->will($this->returnValue(['_method' => 'PROPFIND'])); + + $this->server->expects($this->never()) + ->method('invokeMethod') + ->with($request, $response); + + $this->assertNull($this->plugin->handlePost($request, $response)); + } + + public function testIgnoreMismatchedContentType() { + $request = $this->getMock('Sabre\HTTP\RequestInterface'); + $response = $this->getMock('Sabre\HTTP\ResponseInterface'); + + $request->expects($this->once()) + ->method('getQueryParameters') + ->will($this->returnValue(['_method' => 'PUT'])); + + $request->expects($this->once()) + ->method('getHeader') + ->with('Content-Type') + ->will($this->returnValue('text/plain')); + + $this->server->expects($this->never()) + ->method('invokeMethod') + ->with($request, $response); + + $this->assertNull($this->plugin->handlePost($request, $response)); + } +} |