From 16268bd2c3e20b92076bb42e0d73d6559ff30911 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Mon, 21 Dec 2015 13:10:03 +0100 Subject: [PATCH 1/6] JS utility function to compare paths --- core/js/js.js | 22 +++++++++++ core/js/tests/specs/coreSpec.js | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/core/js/js.js b/core/js/js.js index 07ed396bec9b..4b451fc5c55a 100644 --- a/core/js/js.js +++ b/core/js/js.js @@ -425,6 +425,28 @@ var OC={ return path.replace(/\\/g,'/').replace(/\/[^\/]*$/, ''); }, + /** + * Returns whether the given paths are the same, without + * leading, trailing or doubled slashes and also removing + * the dot sections. + * + * @param {String} path1 first path + * @param {String} path2 second path + * @return {bool} true if the paths are the same + * + * @since 9.0 + */ + isSamePath: function(path1, path2) { + var filterDot = function(p) { + return p !== '.'; + }; + var pathSections1 = _.filter((path1 || '').split('/'), filterDot); + var pathSections2 = _.filter((path2 || '').split('/'), filterDot); + path1 = OC.joinPaths.apply(OC, pathSections1); + path2 = OC.joinPaths.apply(OC, pathSections2); + return path1 === path2; + }, + /** * Join path sections * diff --git a/core/js/tests/specs/coreSpec.js b/core/js/tests/specs/coreSpec.js index 1ffe7b38a40b..e996a6e6c258 100644 --- a/core/js/tests/specs/coreSpec.js +++ b/core/js/tests/specs/coreSpec.js @@ -188,6 +188,71 @@ describe('Core base tests', function() { expect(OC.joinPaths('/', '//', '/')).toEqual('/'); }); }); + describe('isSamePath', function() { + it('recognizes empty paths are equal', function() { + expect(OC.isSamePath('', '')).toEqual(true); + expect(OC.isSamePath('/', '')).toEqual(true); + expect(OC.isSamePath('//', '')).toEqual(true); + expect(OC.isSamePath('/', '/')).toEqual(true); + expect(OC.isSamePath('/', '//')).toEqual(true); + }); + it('recognizes path with single sections as equal regardless of extra slashes', function() { + expect(OC.isSamePath('abc', 'abc')).toEqual(true); + expect(OC.isSamePath('/abc', 'abc')).toEqual(true); + expect(OC.isSamePath('//abc', 'abc')).toEqual(true); + expect(OC.isSamePath('abc', '/abc')).toEqual(true); + expect(OC.isSamePath('abc/', 'abc')).toEqual(true); + expect(OC.isSamePath('abc/', 'abc/')).toEqual(true); + expect(OC.isSamePath('/abc/', 'abc/')).toEqual(true); + expect(OC.isSamePath('/abc/', '/abc/')).toEqual(true); + expect(OC.isSamePath('//abc/', '/abc/')).toEqual(true); + expect(OC.isSamePath('//abc//', '/abc/')).toEqual(true); + + expect(OC.isSamePath('abc', 'def')).toEqual(false); + expect(OC.isSamePath('/abc', 'def')).toEqual(false); + expect(OC.isSamePath('//abc', 'def')).toEqual(false); + expect(OC.isSamePath('abc', '/def')).toEqual(false); + expect(OC.isSamePath('abc/', 'def')).toEqual(false); + expect(OC.isSamePath('abc/', 'def/')).toEqual(false); + expect(OC.isSamePath('/abc/', 'def/')).toEqual(false); + expect(OC.isSamePath('/abc/', '/def/')).toEqual(false); + expect(OC.isSamePath('//abc/', '/def/')).toEqual(false); + expect(OC.isSamePath('//abc//', '/def/')).toEqual(false); + }); + it('recognizes path with multiple sections as equal regardless of extra slashes', function() { + expect(OC.isSamePath('abc/def', 'abc/def')).toEqual(true); + expect(OC.isSamePath('/abc/def', 'abc/def')).toEqual(true); + expect(OC.isSamePath('abc/def', '/abc/def')).toEqual(true); + expect(OC.isSamePath('abc/def/', '/abc/def/')).toEqual(true); + expect(OC.isSamePath('/abc/def/', '/abc/def/')).toEqual(true); + expect(OC.isSamePath('/abc/def/', 'abc/def/')).toEqual(true); + expect(OC.isSamePath('//abc/def/', 'abc/def/')).toEqual(true); + expect(OC.isSamePath('//abc/def//', 'abc/def/')).toEqual(true); + + expect(OC.isSamePath('abc/def', 'abc/ghi')).toEqual(false); + expect(OC.isSamePath('/abc/def', 'abc/ghi')).toEqual(false); + expect(OC.isSamePath('abc/def', '/abc/ghi')).toEqual(false); + expect(OC.isSamePath('abc/def/', '/abc/ghi/')).toEqual(false); + expect(OC.isSamePath('/abc/def/', '/abc/ghi/')).toEqual(false); + expect(OC.isSamePath('/abc/def/', 'abc/ghi/')).toEqual(false); + expect(OC.isSamePath('//abc/def/', 'abc/ghi/')).toEqual(false); + expect(OC.isSamePath('//abc/def//', 'abc/ghi/')).toEqual(false); + }); + it('recognizes path entries with dot', function() { + expect(OC.isSamePath('.', '')).toEqual(true); + expect(OC.isSamePath('.', '.')).toEqual(true); + expect(OC.isSamePath('.', '/')).toEqual(true); + expect(OC.isSamePath('/.', '/')).toEqual(true); + expect(OC.isSamePath('/./', '/')).toEqual(true); + expect(OC.isSamePath('/./', '/.')).toEqual(true); + expect(OC.isSamePath('/./', '/./')).toEqual(true); + expect(OC.isSamePath('/./', '/./')).toEqual(true); + + expect(OC.isSamePath('a/./b', 'a/b')).toEqual(true); + expect(OC.isSamePath('a/b/.', 'a/b')).toEqual(true); + expect(OC.isSamePath('./a/b', 'a/b')).toEqual(true); + }); + }); describe('filePath', function() { beforeEach(function() { OC.webroot = 'http://localhost'; From 540987e3209d3a1354117ecc766628db85a46d12 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Wed, 16 Dec 2015 17:35:53 +0100 Subject: [PATCH 2/6] 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 --- apps/dav/lib/Connector/Sabre/FilesPlugin.php | 51 ++ .../Connector/Sabre/IFrameTransportPlugin.php | 188 +++++ .../dav/lib/Connector/Sabre/ServerFactory.php | 1 + .../unit/Connector/Sabre/FilesPluginTest.php | 85 +- .../Sabre/IFrameTransportPluginTest.php | 164 ++++ apps/files/ajax/upload.php | 253 ------ apps/files/appinfo/routes.php | 12 - apps/files/js/app.js | 1 + apps/files/js/file-upload.js | 786 +++++++++++++----- apps/files/js/filelist.js | 310 ++++--- apps/files/js/files.js | 11 - apps/files/templates/list.php | 3 +- apps/files/tests/js/fileUploadSpec.js | 141 ++-- apps/files/tests/js/filelistSpec.js | 208 ++++- apps/files_sharing/js/public.js | 43 +- apps/files_sharing/tests/js/publicAppSpec.js | 23 + 16 files changed, 1512 insertions(+), 768 deletions(-) create mode 100644 apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php create mode 100644 apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php delete mode 100644 apps/files/ajax/upload.php diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index c14e4d2d9a61..a7a01722ea01 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -43,6 +43,8 @@ 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 { @@ -155,6 +157,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']); @@ -414,4 +418,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->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 000000000000..af6e5a62a5e5 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php @@ -0,0 +1,188 @@ + + * + * @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 + * + */ + +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 40a8ae0c8dd1..404d09a74a5d 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -106,6 +106,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()); // 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 22d67d6eaa55..a98112a8383d 100644 --- a/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php @@ -108,7 +108,7 @@ public function setUp() { * @param string $class * @return \PHPUnit_Framework_MockObject_MockObject */ - private function createTestNode($class) { + private function createTestNode($class, $path = '/dummypath') { $node = $this->getMockBuilder($class) ->disableOriginalConstructor() ->getMock(); @@ -119,7 +119,7 @@ private function createTestNode($class) { $this->tree->expects($this->any()) ->method('getNodeForPath') - ->with('/dummypath') + ->with($path) ->will($this->returnValue($node)); $node->expects($this->any()) @@ -550,4 +550,85 @@ public function testDownloadHeaders($isClumsyAgent, $contentDispositionHeader) { $this->plugin->httpGet($request, $response); } + + 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 000000000000..485dd1b779e1 --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php @@ -0,0 +1,164 @@ + + * 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)); + } +} diff --git a/apps/files/ajax/upload.php b/apps/files/ajax/upload.php deleted file mode 100644 index 2287d8b47979..000000000000 --- a/apps/files/ajax/upload.php +++ /dev/null @@ -1,253 +0,0 @@ - - * @author Bart Visscher - * @author Björn Schießle - * @author Clark Tomlinson - * @author Florian Pritz - * @author Frank Karlitschek - * @author Individual IT Services - * @author Joas Schilling - * @author Jörn Friedrich Dreyer - * @author Lukas Reschke - * @author Luke Policinski - * @author Robin Appelman - * @author Roman Geber - * @author TheSFReader - * @author Thomas Müller - * @author Vincent Petry - * - * @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 - * - */ -\OC::$server->getSession()->close(); - -// Firefox and Konqueror tries to download application/json for me. --Arthur -OCP\JSON::setContentTypeHeader('text/plain'); - -// If a directory token is sent along check if public upload is permitted. -// If not, check the login. -// If no token is sent along, rely on login only - -$errorCode = null; - -$l = \OC::$server->getL10N('files'); -if (empty($_POST['dirToken'])) { - // The standard case, files are uploaded through logged in users :) - OCP\JSON::checkLoggedIn(); - $dir = isset($_POST['dir']) ? (string)$_POST['dir'] : ''; - if (!$dir || empty($dir) || $dir === false) { - OCP\JSON::error(array('data' => array_merge(array('message' => $l->t('Unable to set upload directory.'))))); - die(); - } -} else { - // TODO: ideally this code should be in files_sharing/ajax/upload.php - // and the upload/file transfer code needs to be refactored into a utility method - // that could be used there - - \OC_User::setIncognitoMode(true); - - $publicDirectory = !empty($_POST['subdir']) ? (string)$_POST['subdir'] : '/'; - - $linkItem = OCP\Share::getShareByToken((string)$_POST['dirToken']); - if ($linkItem === false) { - OCP\JSON::error(array('data' => array_merge(array('message' => $l->t('Invalid Token'))))); - die(); - } - - if (!($linkItem['permissions'] & \OCP\Constants::PERMISSION_CREATE)) { - OCP\JSON::checkLoggedIn(); - } else { - // resolve reshares - $rootLinkItem = OCP\Share::resolveReShare($linkItem); - - OCP\JSON::checkUserExists($rootLinkItem['uid_owner']); - // Setup FS with owner - OC_Util::tearDownFS(); - OC_Util::setupFS($rootLinkItem['uid_owner']); - - // The token defines the target directory (security reasons) - $path = \OC\Files\Filesystem::getPath($linkItem['file_source']); - if($path === null) { - OCP\JSON::error(array('data' => array_merge(array('message' => $l->t('Unable to set upload directory.'))))); - die(); - } - $dir = sprintf( - "/%s/%s", - $path, - $publicDirectory - ); - - if (!$dir || empty($dir) || $dir === false) { - OCP\JSON::error(array('data' => array_merge(array('message' => $l->t('Unable to set upload directory.'))))); - die(); - } - - $dir = rtrim($dir, '/'); - } -} - -OCP\JSON::callCheck(); - -// get array with current storage stats (e.g. max file size) -$storageStats = \OCA\Files\Helper::buildFileStorageStatistics($dir); - -if (!isset($_FILES['files'])) { - OCP\JSON::error(array('data' => array_merge(array('message' => $l->t('No file was uploaded. Unknown error')), $storageStats))); - exit(); -} - -foreach ($_FILES['files']['error'] as $error) { - if ($error != 0) { - $errors = array( - UPLOAD_ERR_OK => $l->t('There is no error, the file uploaded with success'), - UPLOAD_ERR_INI_SIZE => $l->t('The uploaded file exceeds the upload_max_filesize directive in php.ini: ') - . OC::$server->getIniWrapper()->getNumeric('upload_max_filesize'), - UPLOAD_ERR_FORM_SIZE => $l->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), - UPLOAD_ERR_PARTIAL => $l->t('The uploaded file was only partially uploaded'), - UPLOAD_ERR_NO_FILE => $l->t('No file was uploaded'), - UPLOAD_ERR_NO_TMP_DIR => $l->t('Missing a temporary folder'), - UPLOAD_ERR_CANT_WRITE => $l->t('Failed to write to disk'), - ); - $errorMessage = $errors[$error]; - \OC::$server->getLogger()->alert("Upload error: $error - $errorMessage", array('app' => 'files')); - OCP\JSON::error(array('data' => array_merge(array('message' => $errorMessage), $storageStats))); - exit(); - } -} -$files = $_FILES['files']; - -$error = false; - -$maxUploadFileSize = $storageStats['uploadMaxFilesize']; -$maxHumanFileSize = OCP\Util::humanFileSize($maxUploadFileSize); - -$totalSize = 0; -$isReceivedShare = \OC::$server->getRequest()->getParam('isReceivedShare', false) === 'true'; -// defer quota check for received shares -if (!$isReceivedShare && $storageStats['freeSpace'] >= 0) { - foreach ($files['size'] as $size) { - $totalSize += $size; - } -} -if ($maxUploadFileSize >= 0 and $totalSize > $maxUploadFileSize) { - OCP\JSON::error(array('data' => array('message' => $l->t('Not enough storage available'), - 'uploadMaxFilesize' => $maxUploadFileSize, - 'maxHumanFilesize' => $maxHumanFileSize))); - exit(); -} - -$result = array(); -if (\OC\Files\Filesystem::isValidPath($dir) === true) { - $fileCount = count($files['name']); - for ($i = 0; $i < $fileCount; $i++) { - - if (isset($_POST['resolution'])) { - $resolution = $_POST['resolution']; - } else { - $resolution = null; - } - - // target directory for when uploading folders - $relativePath = ''; - if(!empty($_POST['file_directory'])) { - $relativePath = '/'.$_POST['file_directory']; - } - - // $path needs to be normalized - this failed within drag'n'drop upload to a sub-folder - if ($resolution === 'autorename') { - // append a number in brackets like 'filename (2).ext' - $target = OCP\Files::buildNotExistingFileName($dir . $relativePath, $files['name'][$i]); - } else { - $target = \OC\Files\Filesystem::normalizePath($dir . $relativePath.'/'.$files['name'][$i]); - } - - // relative dir to return to the client - if (isset($publicDirectory)) { - // path relative to the public root - $returnedDir = $publicDirectory . $relativePath; - } else { - // full path - $returnedDir = $dir . $relativePath; - } - $returnedDir = \OC\Files\Filesystem::normalizePath($returnedDir); - - - $exists = \OC\Files\Filesystem::file_exists($target); - if ($exists) { - $updatable = \OC\Files\Filesystem::isUpdatable($target); - } - if ( ! $exists || ($updatable && $resolution === 'replace' ) ) { - // upload and overwrite file - try - { - if (is_uploaded_file($files['tmp_name'][$i]) and \OC\Files\Filesystem::fromTmpFile($files['tmp_name'][$i], $target)) { - - // updated max file size after upload - $storageStats = \OCA\Files\Helper::buildFileStorageStatistics($dir); - - $meta = \OC\Files\Filesystem::getFileInfo($target); - if ($meta === false) { - $error = $l->t('The target folder has been moved or deleted.'); - $errorCode = 'targetnotfound'; - } else { - $data = \OCA\Files\Helper::formatFileInfo($meta); - $data['status'] = 'success'; - $data['originalname'] = $files['name'][$i]; - $data['uploadMaxFilesize'] = $maxUploadFileSize; - $data['maxHumanFilesize'] = $maxHumanFileSize; - $data['permissions'] = $meta['permissions']; - $data['directory'] = $returnedDir; - $result[] = $data; - } - - } else { - $error = $l->t('Upload failed. Could not find uploaded file'); - } - } catch(Exception $ex) { - $error = $ex->getMessage(); - } - - } else { - // file already exists - $meta = \OC\Files\Filesystem::getFileInfo($target); - if ($meta === false) { - $error = $l->t('Upload failed. Could not get file info.'); - } else { - $data = \OCA\Files\Helper::formatFileInfo($meta); - if ($updatable) { - $data['status'] = 'existserror'; - } else { - $data['status'] = 'readonly'; - } - $data['originalname'] = $files['name'][$i]; - $data['uploadMaxFilesize'] = $maxUploadFileSize; - $data['maxHumanFilesize'] = $maxHumanFileSize; - $data['permissions'] = $meta['permissions']; - $data['directory'] = $returnedDir; - $result[] = $data; - } - } - } -} else { - $error = $l->t('Invalid directory.'); -} - -if ($error === false) { - OCP\JSON::encodedPrint($result); -} else { - OCP\JSON::error(array(array('data' => array_merge(array('message' => $error, 'code' => $errorCode), $storageStats)))); -} diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index a21c152ffe51..36d6b6911dd4 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -70,24 +70,12 @@ /** @var $this \OC\Route\Router */ -$this->create('files_ajax_delete', 'ajax/delete.php') - ->actionInclude('files/ajax/delete.php'); $this->create('files_ajax_download', 'ajax/download.php') ->actionInclude('files/ajax/download.php'); $this->create('files_ajax_getstoragestats', 'ajax/getstoragestats.php') ->actionInclude('files/ajax/getstoragestats.php'); $this->create('files_ajax_list', 'ajax/list.php') ->actionInclude('files/ajax/list.php'); -$this->create('files_ajax_move', 'ajax/move.php') - ->actionInclude('files/ajax/move.php'); -$this->create('files_ajax_newfile', 'ajax/newfile.php') - ->actionInclude('files/ajax/newfile.php'); -$this->create('files_ajax_newfolder', 'ajax/newfolder.php') - ->actionInclude('files/ajax/newfolder.php'); -$this->create('files_ajax_rename', 'ajax/rename.php') - ->actionInclude('files/ajax/rename.php'); -$this->create('files_ajax_upload', 'ajax/upload.php') - ->actionInclude('files/ajax/upload.php'); $this->create('download', 'download{file}') ->requirements(array('file' => '.*')) diff --git a/apps/files/js/app.js b/apps/files/js/app.js index fbfa510e07e9..17e92de90ddc 100644 --- a/apps/files/js/app.js +++ b/apps/files/js/app.js @@ -93,6 +93,7 @@ direction: $('#defaultFileSortingDirection').val() }, config: this._filesConfig, + enableUpload: true } ); this.files.initialize(); diff --git a/apps/files/js/file-upload.js b/apps/files/js/file-upload.js index 3257ded7b89d..453553330c64 100644 --- a/apps/files/js/file-upload.js +++ b/apps/files/js/file-upload.js @@ -18,83 +18,448 @@ * - TODO music upload button */ -/* global jQuery, oc_requesttoken, humanFileSize, FileList */ +/* global jQuery, humanFileSize */ /** - * Function that will allow us to know if Ajax uploads are supported - * @link https://github.com/New-Bamboo/example-ajax-upload/blob/master/public/index.html - * also see article @link http://blog.new-bamboo.co.uk/2012/01/10/ridiculously-simple-ajax-uploads-with-formdata + * File upload object + * + * @class OC.FileUpload + * @classdesc + * + * Represents a file upload + * + * @param {OC.Uploader} uploader uploader + * @param {Object} data blueimp data */ -function supportAjaxUploadWithProgress() { - return supportFileAPI() && supportAjaxUploadProgressEvents() && supportFormData(); - - // Is the File API supported? - function supportFileAPI() { - var fi = document.createElement('INPUT'); - fi.type = 'file'; - return 'files' in fi; - } +OC.FileUpload = function(uploader, data) { + this.uploader = uploader; + this.data = data; +}; +OC.FileUpload.CONFLICT_MODE_DETECT = 0; +OC.FileUpload.CONFLICT_MODE_OVERWRITE = 1; +OC.FileUpload.CONFLICT_MODE_AUTORENAME = 2; +OC.FileUpload.prototype = { - // Are progress events supported? - function supportAjaxUploadProgressEvents() { - var xhr = new XMLHttpRequest(); - return !! (xhr && ('upload' in xhr) && ('onprogress' in xhr.upload)); - } + /** + * Upload element + * + * @type Object + */ + $uploadEl: null, - // Is FormData supported? - function supportFormData() { - return !! window.FormData; - } -} + /** + * Target folder + * + * @type string + */ + _targetFolder: '', -/** - * Add form data into the given form data - * - * @param {Array|Object} formData form data which can either be an array or an object - * @param {Object} newData key-values to add to the form data - * - * @return updated form data - */ -function addFormData(formData, newData) { - // in IE8, formData is an array instead of object - if (_.isArray(formData)) { - _.each(newData, function(value, key) { - formData.push({name: key, value: value}); + /** + * @type int + */ + _conflictMode: OC.FileUpload.CONFLICT_MODE_DETECT, + + /** + * New name from server after autorename + * + * @type String + */ + _newName: null, + + /** + * Returns the file to be uploaded + * + * @return {File} file + */ + getFile: function() { + return this.data.files[0]; + }, + + /** + * Return the final filename. + * Either this is the original file name or the file name + * after an autorename. + * + * @return {String} file name + */ + getFileName: function() { + // in case of autorename + if (this._newName) { + return this._newName; + } + + if (this._conflictMode === OC.FileUpload.CONFLICT_MODE_AUTORENAME) { + + var locationUrl = this.getResponseHeader('Content-Location'); + if (locationUrl) { + this._newName = decodeURIComponent(OC.basename(locationUrl)); + return this._newName; + } + } + + return this.getFile().name; + }, + + setTargetFolder: function(targetFolder) { + this._targetFolder = targetFolder; + }, + + getTargetFolder: function() { + return this._targetFolder; + }, + + /** + * Get full path for the target file, including relative path, + * without the file name. + * + * @return {String} full path + */ + getFullPath: function() { + return OC.joinPaths(this._targetFolder, this.getFile().relativePath || ''); + }, + + /** + * Set conflict resolution mode. + * See CONFLICT_MODE_* constants. + */ + setConflictMode: function(mode) { + this._conflictMode = mode; + }, + + /** + * Returns whether the upload is in progress + * + * @return {bool} + */ + isPending: function() { + return this.data.state() === 'pending'; + }, + + deleteUpload: function() { + delete this.data.jqXHR; + }, + + /** + * Submit the upload + */ + submit: function() { + var data = this.data; + var file = this.getFile(); + + // it was a folder upload, so make sure the parent directory exists alrady + var folderPromise; + if (file.relativePath) { + folderPromise = this.uploader.ensureFolderExists(this.getFullPath()); + } else { + folderPromise = $.Deferred().resolve().promise(); + } + + if (this.uploader.fileList) { + this.data.url = this.uploader.fileList.getUploadUrl(file.name, this.getFullPath()); + } + + if (!this.data.headers) { + this.data.headers = {}; + } + + // webdav without multipart + this.data.multipart = false; + this.data.type = 'PUT'; + + delete this.data.headers['If-None-Match']; + if (this._conflictMode === OC.FileUpload.CONFLICT_MODE_DETECT) { + this.data.headers['If-None-Match'] = '*'; + } else if (this._conflictMode === OC.FileUpload.CONFLICT_MODE_AUTORENAME) { + // POST to parent folder, with slug + this.data.type = 'POST'; + this.data.url = this.uploader.fileList.getUploadUrl('&' + file.name, this.getFullPath()); + } + + if (file.lastModified) { + // preserve timestamp + this.data.headers['X-OC-Mtime'] = file.lastModified / 1000; + } + + if (!this.uploader.isXHRUpload()) { + data.formData = []; + + // pass headers as parameters + data.formData.push({name: 'headers', value: JSON.stringify(this.data.headers)}); + data.formData.push({name: 'requesttoken', value: OC.requestToken}); + if (data.type === 'POST') { + // still add the method to the URL + data.url += '?_method=POST'; + } + } + + // wait for creation of the required directory before uploading + folderPromise.then(function() { + data.submit(); + }, function() { + data.abort(); + }); + + }, + + /** + * Abort the upload + */ + abort: function() { + this.data.abort(); + }, + + /** + * Returns the server response + * + * @return {Object} response + */ + getResponse: function() { + var response = this.data.response(); + if (typeof response.result !== 'string') { + //fetch response from iframe + response = $.parseJSON(response.result[0].body.innerText); + if (!response) { + // likely due to internal server error + response = {status: 500}; + } + } else { + response = response.result; + } + return response; + }, + + /** + * Returns the status code from the response + * + * @return {int} status code + */ + getResponseStatus: function() { + if (this.uploader.isXHRUpload()) { + var xhr = this.data.response().jqXHR; + if (xhr) { + return xhr.status; + } + return null; + } + return this.getResponse().status; + }, + + /** + * Returns the response header by name + * + * @param {String} headerName header name + * @return {Array|String} response header value(s) + */ + getResponseHeader: function(headerName) { + headerName = headerName.toLowerCase(); + if (this.uploader.isXHRUpload()) { + return this.data.response().jqXHR.getResponseHeader(headerName); + } + + var headers = this.getResponse().headers; + if (!headers) { + return null; + } + + var value = _.find(headers, function(value, key) { + return key.toLowerCase() === headerName; }); - } else { - formData = _.extend(formData, newData); + if (_.isArray(value) && value.length === 1) { + return value[0]; + } + return value; } - return formData; -} +}; /** * keeps track of uploads in progress and implements callbacks for the conflicts dialog * @namespace */ -OC.Upload = { + +OC.Uploader = function() { + this.init.apply(this, arguments); +}; + +OC.Uploader.prototype = { + /** + * @type Array + */ _uploads: [], + + /** + * List of directories known to exist. + * + * Key is the fullpath and value is boolean, true meaning that the directory + * was already created so no need to create it again. + */ + _knownDirs: {}, + + /** + * @type OCA.Files.FileList + */ + fileList: null, + + /** + * @type OC.Files.Client + */ + filesClient: null, + + /** + * Function that will allow us to know if Ajax uploads are supported + * @link https://github.com/New-Bamboo/example-ajax-upload/blob/master/public/index.html + * also see article @link http://blog.new-bamboo.co.uk/2012/01/10/ridiculously-simple-ajax-uploads-with-formdata + */ + _supportAjaxUploadWithProgress: function() { + return supportFileAPI() && supportAjaxUploadProgressEvents() && supportFormData(); + + // Is the File API supported? + function supportFileAPI() { + var fi = document.createElement('INPUT'); + fi.type = 'file'; + return 'files' in fi; + } + + // Are progress events supported? + function supportAjaxUploadProgressEvents() { + var xhr = new XMLHttpRequest(); + return !! (xhr && ('upload' in xhr) && ('onprogress' in xhr.upload)); + } + + // Is FormData supported? + function supportFormData() { + return !! window.FormData; + } + }, + + /** + * Returns whether an XHR upload will be used + * + * @return {bool} true if XHR upload will be used, + * false for iframe upload + */ + isXHRUpload: function () { + return !this.fileUploadParam.forceIframeTransport && + ((!this.fileUploadParam.multipart && $.support.xhrFileUpload) || + $.support.xhrFormDataFileUpload); + }, + + /** + * Makes sure that the upload folder and its parents exists + * + * @param {String} fullPath full path + * @return {Promise} promise that resolves when all parent folders + * were created + */ + ensureFolderExists: function(fullPath) { + if (!fullPath || fullPath === '/') { + return $.Deferred().resolve().promise(); + } + + // remove trailing slash + if (fullPath.charAt(fullPath.length - 1) === '/') { + fullPath = fullPath.substr(0, fullPath.length - 1); + } + + var self = this; + var promise = this._knownDirs[fullPath]; + + if (this.fileList) { + // assume the current folder exists + this._knownDirs[this.fileList.getCurrentDirectory()] = $.Deferred().resolve().promise(); + } + + if (!promise) { + var deferred = new $.Deferred(); + promise = deferred.promise(); + this._knownDirs[fullPath] = promise; + + // make sure all parents already exist + var parentPath = OC.dirname(fullPath); + var parentPromise = this._knownDirs[parentPath]; + if (!parentPromise) { + parentPromise = this.ensureFolderExists(parentPath); + } + + parentPromise.then(function() { + self.filesClient.createDirectory(fullPath).always(function(status) { + // 405 is expected if the folder already exists + if ((status >= 200 && status < 300) || status === 405) { + self.$uploadEl.trigger($.Event('fileuploadcreatedfolder'), fullPath); + deferred.resolve(); + return; + } + OC.Notification.showTemporary(t('files', 'Could not create folder "{dir}"', {dir: fullPath})); + deferred.reject(); + }); + }, function() { + deferred.reject(); + }); + } + + return promise; + }, + + /** + * Submit the given uploads + * + * @param {Array} array of uploads to start + */ + submitUploads: function(uploads) { + var self = this; + _.each(uploads, function(upload) { + upload.submit(); + self._uploads[upload.data.uploadId] = upload; + }); + }, + /** - * deletes the jqHXR object from a data selection - * @param {object} data + * Show conflict for the given file object + * + * @param {OC.FileUpload} file upload object */ - deleteUpload:function(data) { - delete data.jqXHR; + showConflict: function(fileUpload) { + //show "file already exists" dialog + var self = this; + var file = fileUpload.getFile(); + // retrieve more info about this file + this.filesClient.getFileInfo(fileUpload.getFullPath()).then(function(status, fileInfo) { + var original = fileInfo; + var replacement = file; + OC.dialogs.fileexists(fileUpload, original, replacement, self); + }); }, /** * cancels all uploads */ cancelUploads:function() { this.log('canceling uploads'); - jQuery.each(this._uploads, function(i, jqXHR) { - jqXHR.abort(); + jQuery.each(this._uploads, function(i, upload) { + upload.abort(); }); - this._uploads = []; + this.clear(); + }, + /** + * Clear uploads + */ + clear: function() { + this._uploads = {}; + this._knownDirs = {}; }, - rememberUpload:function(jqXHR) { - if (jqXHR) { - this._uploads.push(jqXHR); + /** + * Returns an upload by id + * + * @param {int} data uploadId + * @return {OC.FileUpload} file upload + */ + getUpload: function(data) { + if (_.isString(data)) { + return this._uploads[data]; + } else if (data.uploadId) { + return this._uploads[data.uploadId]; } + return null; }, + showUploadCancelMessage: _.debounce(function() { OC.Notification.showTemporary(t('files', 'Upload cancelled.'), {timeout: 10}); }, 500), @@ -106,8 +471,8 @@ OC.Upload = { isProcessing:function() { var count = 0; - jQuery.each(this._uploads, function(i, data) { - if (data.state() === 'pending') { + jQuery.each(this._uploads, function(i, upload) { + if (upload.isPending()) { count++; } }); @@ -115,9 +480,8 @@ OC.Upload = { }, /** * callback for the conflicts dialog - * @param {object} data */ - onCancel:function(data) { + onCancel:function() { this.cancelUploads(); }, /** @@ -147,43 +511,29 @@ OC.Upload = { }, /** * handle skipping an upload - * @param {object} data + * @param {OC.FileUpload} upload */ - onSkip:function(data) { - this.log('skip', null, data); - this.deleteUpload(data); + onSkip:function(upload) { + this.log('skip', null, upload); + upload.deleteUpload(); }, /** * handle replacing a file on the server with an uploaded file - * @param {object} data + * @param {FileUpload} data */ - onReplace:function(data) { - this.log('replace', null, data); - if (data.data) { - data.data.append('resolution', 'replace'); - } else { - if (!data.formData) { - data.formData = {}; - } - addFormData(data.formData, {resolution: 'replace'}); - } - data.submit(); + onReplace:function(upload) { + this.log('replace', null, upload); + upload.setConflictMode(OC.FileUpload.CONFLICT_MODE_OVERWRITE); + upload.submit(); }, /** * handle uploading a file and letting the server decide a new name - * @param {object} data + * @param {object} upload */ - onAutorename:function(data) { - this.log('autorename', null, data); - if (data.data) { - data.data.append('resolution', 'autorename'); - } else { - if (!data.formData) { - data.formData = {}; - } - addFormData(data.formData, {resolution: 'autorename'}); - } - data.submit(); + onAutorename:function(upload) { + this.log('autorename', null, upload); + upload.setConflictMode(OC.FileUpload.CONFLICT_MODE_AUTORENAME); + upload.submit(); }, _trace:false, //TODO implement log handler for JS per class? log:function(caption, e, data) { @@ -205,11 +555,20 @@ OC.Upload = { * @param {function} callbacks.onCancel */ checkExistingFiles: function (selection, callbacks) { - var fileList = FileList; + var fileList = this.fileList; var conflicts = []; // only keep non-conflicting uploads selection.uploads = _.filter(selection.uploads, function(upload) { - var fileInfo = fileList.findFile(upload.files[0].name); + var file = upload.getFile(); + if (file.relativePath) { + // can't check in subfolder contents + return true; + } + if (!fileList) { + // no list to check against + return true; + } + var fileInfo = fileList.findFile(file.name); if (fileInfo) { conflicts.push([ // original @@ -225,9 +584,9 @@ OC.Upload = { }); if (conflicts.length) { // wait for template loading - OC.dialogs.fileexists(null, null, null, OC.Upload).done(function() { + OC.dialogs.fileexists(null, null, null, this).done(function() { _.each(conflicts, function(conflictData) { - OC.dialogs.fileexists(conflictData[1], conflictData[0], conflictData[1].files[0], OC.Upload); + OC.dialogs.fileexists(conflictData[1], conflictData[0], conflictData[1].getFile(), this); }); }); } @@ -240,15 +599,26 @@ OC.Upload = { }, _hideProgressBar: function() { + var self = this; $('#uploadprogresswrapper .stop').fadeOut(); $('#uploadprogressbar').fadeOut(function() { - $('#file_upload_start').trigger(new $.Event('resized')); + self.$uploadEl.trigger(new $.Event('resized')); }); }, _showProgressBar: function() { $('#uploadprogressbar').fadeIn(); - $('#file_upload_start').trigger(new $.Event('resized')); + this.$uploadEl.trigger(new $.Event('resized')); + }, + + on: function() { + // forward events to upload element + this.$uploadEl.on.apply(this.$uploadEl, arguments); + }, + + off: function() { + // forward events to upload element + this.$uploadEl.off.apply(this.$uploadEl, arguments); }, /** @@ -269,12 +639,33 @@ OC.Upload = { return ($tr.attr('data-mounttype') === 'shared-root' && $tr.attr('data-mime') !== 'httpd/unix-directory'); }, - init: function() { + /** + * Initialize the upload object + * + * @param {Object} $uploadEl upload element + * @param {Object} options + * @param {OCA.Files.FileList} [options.fileList] file list object + * @param {OC.Files.Client} [options.filesClient] files client object + * @param {Object} [options.dropZone] drop zone for drag and drop upload + */ + init: function($uploadEl, options) { var self = this; - if ( $('#file_upload_start').exists() ) { - var file_upload_param = { - dropZone: $('#content'), // restrict dropZone to content div - pasteZone: null, + options = options || {}; + + this.fileList = options.fileList; + this.filesClient = options.filesClient || OC.Files.getClient(); + + $uploadEl = $($uploadEl); + this.$uploadEl = $uploadEl; + + if ($uploadEl.exists()) { + $('#uploadprogresswrapper .stop').on('click', function() { + this.cancelUploads(); + }); + + this.fileUploadParam = { + type: 'PUT', + dropZone: options.dropZone, // restrict dropZone to content div autoUpload: false, sequentialUploads: true, //singleFileUploads is on by default, so the data.files array will always have length 1 @@ -295,9 +686,13 @@ OC.Upload = { * @returns {boolean} */ add: function(e, data) { - OC.Upload.log('add', e, data); + self.log('add', e, data); var that = $(this), freeSpace; + var upload = new OC.FileUpload(self, data); + // can't link directly due to jQuery not liking cyclic deps on its ajax object + data.uploadId = _.uniqueId('file-upload'); + // we need to collect all data upload objects before // starting the upload so we can check their existence // and set individual conflict actions. Unfortunately, @@ -317,16 +712,17 @@ OC.Upload = { biggestFileBytes: 0 }; } + // TODO: move originalFiles to a separate container, maybe inside OC.Upload var selection = data.originalFiles.selection; // add uploads if ( selection.uploads.length < selection.filesToUpload ) { // remember upload - selection.uploads.push(data); + selection.uploads.push(upload); } //examine file - var file = data.files[0]; + var file = upload.getFile(); try { // FIXME: not so elegant... need to refactor that method to return a value Files.isFileNameValid(file.name); @@ -336,9 +732,14 @@ OC.Upload = { data.errorThrown = errorMessage; } + if (data.targetDir) { + upload.setTargetFolder(data.targetDir); + delete data.targetDir; + } + // in case folder drag and drop is not supported file will point to a directory // http://stackoverflow.com/a/20448357 - if ( ! file.type && file.size%4096 === 0 && file.size <= 102400) { + if ( ! file.type && file.size % 4096 === 0 && file.size <= 102400) { var dirUploadFailure = false; try { var reader = new FileReader(); @@ -394,7 +795,7 @@ OC.Upload = { // end upload for whole selection on error if (data.errorThrown) { - // trigger fileupload fail + // trigger fileupload fail handler var fu = that.data('blueimp-fileupload') || that.data('fileupload'); fu._trigger('fail', e, data); return false; //don't upload anything @@ -409,9 +810,7 @@ OC.Upload = { var callbacks = { onNoConflicts: function (selection) { - $.each(selection.uploads, function(i, upload) { - upload.submit(); - }); + self.submitUploads(selection.uploads); }, onSkipConflicts: function (selection) { //TODO mark conflicting files as toskip @@ -429,7 +828,7 @@ OC.Upload = { } }; - OC.Upload.checkExistingFiles(selection, callbacks); + self.checkExistingFiles(selection, callbacks); } @@ -440,54 +839,38 @@ OC.Upload = { * @param {object} e */ start: function(e) { - OC.Upload.log('start', e, null); + self.log('start', e, null); //hide the tooltip otherwise it covers the progress bar $('#upload').tipsy('hide'); }, - submit: function(e, data) { - OC.Upload.rememberUpload(data); - if (!data.formData) { - data.formData = {}; - } - - var fileDirectory = ''; - if(typeof data.files[0].relativePath !== 'undefined') { - fileDirectory = data.files[0].relativePath; - } + fail: function(e, data) { + var upload = self.getUpload(data); + var status = upload.getResponseStatus(); + self.log('fail', e, upload); - var params = { - requesttoken: oc_requesttoken, - dir: data.targetDir || FileList.getCurrentDirectory(), - file_directory: fileDirectory, - }; - if (data.files[0].isReceivedShare) { - params.isReceivedShare = true; + if (data.textStatus === 'abort') { + self.showUploadCancelMessage(); + } else if (status === 412) { + // file already exists + self.showConflict(upload); + } else if (status === 404) { + // target folder does not exist any more + OC.Notification.showTemporary( + t('files', 'Target folder "{dir}" does not exist any more', {dir: upload.getFullPath()}) + ); + self.cancelUploads(); + } else if (status === 507) { + // not enough space + OC.Notification.showTemporary( + t('files', 'Not enough free space') + ); + self.cancelUploads(); + } else { + // HTTP connection problem or other error + OC.Notification.showTemporary(data.errorThrown, {timeout: 10}); } - addFormData(data.formData, params); - }, - fail: function(e, data) { - OC.Upload.log('fail', e, data); - if (typeof data.textStatus !== 'undefined' && data.textStatus !== 'success' ) { - if (data.textStatus === 'abort') { - OC.Upload.showUploadCancelMessage(); - } else { - // HTTP connection problem - var message = t('files', 'Error uploading file "{fileName}": {message}', { - fileName: data.files[0].name, - message: data.errorThrown - }); - OC.Notification.show(message, {timeout: 0, type: 'error'}); - if (data.result) { - var result = JSON.parse(data.result); - if (result && result[0] && result[0].data && result[0].data.code === 'targetnotfound') { - // abort upload of next files if any - OC.Upload.cancelUploads(); - } - } - } - } - OC.Upload.deleteUpload(data); + upload.deleteUpload(); }, /** * called for every successful upload @@ -495,51 +878,16 @@ OC.Upload = { * @param {object} data */ done:function(e, data) { - OC.Upload.log('done', e, data); - // handle different responses (json or body from iframe for ie) - var response; - if (typeof data.result === 'string') { - response = data.result; - } else { - //fetch response from iframe - response = data.result[0].body.innerText; - } - var result = JSON.parse(response); + var upload = self.getUpload(data); + var that = $(this); + self.log('done', e, upload); - delete data.jqXHR; - - var fu = $(this).data('blueimp-fileupload') || $(this).data('fileupload'); - - if (result.status === 'error' && result.data && result.data.message){ - data.textStatus = 'servererror'; - data.errorThrown = result.data.message; - fu._trigger('fail', e, data); - } else if (typeof result[0] === 'undefined') { - data.textStatus = 'servererror'; - data.errorThrown = t('files', 'Could not get result from server.'); - fu._trigger('fail', e, data); - } else if (result[0].status === 'readonly') { - var original = result[0]; - var replacement = data.files[0]; - OC.dialogs.fileexists(data, original, replacement, OC.Upload); - } else if (result[0].status === 'existserror') { - //show "file already exists" dialog - var original = result[0]; - var replacement = data.files[0]; - OC.dialogs.fileexists(data, original, replacement, OC.Upload); - } else if (result[0].status !== 'success') { - //delete data.jqXHR; - data.textStatus = 'servererror'; - data.errorThrown = result[0].data.message; // error message has been translated on server + var status = upload.getResponseStatus(); + if (status < 200 || status >= 300) { + // trigger fail handler + var fu = that.data('blueimp-fileupload') || that.data('fileupload'); fu._trigger('fail', e, data); - } else { // Successful upload - // Checking that the uploaded file is the last one and contained in the current directory - if (data.files[0] === data.originalFiles[data.originalFiles.length - 1] && - result[0].directory === FileList.getCurrentDirectory()) { - // Scroll to the last uploaded file and highlight all of them - var fileList = _.pluck(data.originalFiles, 'name'); - FileList.highlightFiles(fileList); - } + return; } }, /** @@ -548,15 +896,14 @@ OC.Upload = { * @param {object} data */ stop: function(e, data) { - OC.Upload.log('stop', e, data); + self.log('stop', e, data); } }; // initialize jquery fileupload (blueimp) - var fileupload = $('#file_upload_start').fileupload(file_upload_param); - window.file_upload_param = fileupload; + var fileupload = this.$uploadEl.fileupload(this.fileUploadParam); - if (supportAjaxUploadWithProgress()) { + if (this._supportAjaxUploadWithProgress()) { //remaining time var lastUpdate = new Date().getMilliseconds(); var lastSize = 0; @@ -565,11 +912,12 @@ OC.Upload = { var bufferIndex = 0; var bufferTotal = 0; for(var i = 0; i < bufferSize;i++){ - buffer[i] = 0; + buffer[i] = 0; } + // add progress handlers fileupload.on('fileuploadadd', function(e, data) { - OC.Upload.log('progress handle fileuploadadd', e, data); + self.log('progress handle fileuploadadd', e, data); //show cancel button //if (data.dataType !== 'iframe') { //FIXME when is iframe used? only for ie? // $('#uploadprogresswrapper .stop').show(); @@ -577,7 +925,7 @@ OC.Upload = { }); // add progress handlers fileupload.on('fileuploadstart', function(e, data) { - OC.Upload.log('progress handle fileuploadstart', e, data); + self.log('progress handle fileuploadstart', e, data); $('#uploadprogresswrapper .stop').show(); $('#uploadprogresswrapper .label').show(); $('#uploadprogressbar').progressbar({value: 0}); @@ -588,14 +936,14 @@ OC.Upload = { + t('files', '...') + ''); $('#uploadprogressbar').tipsy({gravity:'n', fade:true, live:true}); - OC.Upload._showProgressBar(); + self._showProgressBar(); }); fileupload.on('fileuploadprogress', function(e, data) { - OC.Upload.log('progress handle fileuploadprogress', e, data); + self.log('progress handle fileuploadprogress', e, data); //TODO progressbar in row }); fileupload.on('fileuploadprogressall', function(e, data) { - OC.Upload.log('progress handle fileuploadprogressall', e, data); + self.log('progress handle fileuploadprogressall', e, data); var progress = (data.loaded / data.total) * 100; var thisUpdate = new Date().getMilliseconds(); var diffUpdate = (thisUpdate - lastUpdate)/1000; // eg. 2s @@ -612,14 +960,14 @@ OC.Upload = { var smoothRemainingSeconds = (bufferTotal / bufferSize); //seconds var date = new Date(smoothRemainingSeconds * 1000); var timeStringDesktop = ""; - var timeStringMobile = ""; + var timeStringMobile = ""; if(date.getUTCHours() > 0){ - timeStringDesktop = t('files', '{hours}:{minutes}:{seconds} hour{plural_s} left' , { + timeStringDesktop = t('files', '{hours}:{minutes}:{seconds} hour{plural_s} left' , { hours:date.getUTCHours(), minutes: ('0' + date.getUTCMinutes()).slice(-2), seconds: ('0' + date.getUTCSeconds()).slice(-2), plural_s: ( smoothRemainingSeconds === 3600 ? "": "s") // 1 hour = 1*60m*60s = 3600s - }); + }); timeStringMobile = t('files', '{hours}:{minutes}h' , { hours:date.getUTCHours(), minutes: ('0' + date.getUTCMinutes()).slice(-2), @@ -630,12 +978,12 @@ OC.Upload = { minutes: date.getUTCMinutes(), seconds: ('0' + date.getUTCSeconds()).slice(-2), plural_s: (smoothRemainingSeconds === 60 ? "": "s") // 1 minute = 1*60s = 60s - }); + }); timeStringMobile = t('files', '{minutes}:{seconds}m' , { minutes: date.getUTCMinutes(), seconds: ('0' + date.getUTCSeconds()).slice(-2) }); - } else if(date.getUTCSeconds() > 0){ + } else if(date.getUTCSeconds() > 0){ timeStringDesktop = t('files', '{seconds} second{plural_s} left' , { seconds: date.getUTCSeconds(), plural_s: (smoothRemainingSeconds === 1 ? "": "s") // 1 second = 1s = 1s @@ -657,14 +1005,16 @@ OC.Upload = { $('#uploadprogressbar').progressbar('value', progress); }); fileupload.on('fileuploadstop', function(e, data) { - OC.Upload.log('progress handle fileuploadstop', e, data); - OC.Upload._hideProgressBar(); + self.log('progress handle fileuploadstop', e, data); + + self.clear(); + self._hideProgressBar(); }); fileupload.on('fileuploadfail', function(e, data) { - OC.Upload.log('progress handle fileuploadfail', e, data); + self.log('progress handle fileuploadfail', e, data); //if user pressed cancel hide upload progress bar and cancel button if (data.errorThrown === 'abort') { - OC.Upload._hideProgressBar(); + self._hideProgressBar(); } }); @@ -686,36 +1036,20 @@ OC.Upload = { } } - $.assocArraySize = function(obj) { - // http://stackoverflow.com/a/6700/11236 - var size = 0, key; - for (key in obj) { - if (obj.hasOwnProperty(key)) { - size++; - } - } - return size; - }; - // warn user not to leave the page while upload is in progress $(window).on('beforeunload', function(e) { - if (OC.Upload.isProcessing()) { + if (self.isProcessing()) { return t('files', 'File upload is in progress. Leaving the page now will cancel the upload.'); } }); //add multiply file upload attribute to all browsers except konqueror (which crashes when it's used) if (navigator.userAgent.search(/konqueror/i) === -1) { - $('#file_upload_start').attr('multiple', 'multiple'); + this.$uploadEl.attr('multiple', 'multiple'); } - window.file_upload_param = file_upload_param; - return file_upload_param; + return this.fileUploadParam; } }; -$(document).ready(function() { - OC.Upload.init(); -}); - diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index f7d16e960c99..21bda3649713 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -30,6 +30,7 @@ * @param {Object} [options.dragOptions] drag options, disabled by default * @param {Object} [options.folderDropOptions] folder drop options, disabled by default * @param {boolean} [options.detailsViewEnabled=true] whether to enable details view + * @param {boolean} [options.enableUpload=false] whether to enable uploader * @param {OC.Files.Client} [options.filesClient] files client to use */ var FileList = function($el, options) { @@ -183,6 +184,11 @@ _dragOptions: null, _folderDropOptions: null, + /** + * @type OC.Uploader + */ + _uploader: null, + /** * Initialize the file list and its components * @@ -323,8 +329,6 @@ this.$el.find('.selectedActions a').tooltip({placement:'top'}); - this.setupUploadEvents(); - this.$container.on('scroll', _.bind(this._onScroll, this)); if (options.scrollTo) { @@ -333,6 +337,20 @@ }); } + if (options.enableUpload) { + // TODO: auto-create this element + var $uploadEl = this.$el.find('#file_upload_start'); + if ($uploadEl.exists()) { + this._uploader = new OC.Uploader($uploadEl, { + fileList: this, + filesClient: this.filesClient, + dropZone: $('#content') + }); + + this.setupUploadEvents(this._uploader); + } + } + OC.Plugins.attach('OCA.Files.FileList', this); }, @@ -1390,6 +1408,8 @@ return; } this._setCurrentDir(targetDir, changeUrl, fileId); + // discard finished uploads list, we'll get it through a regular reload + this._uploads = {}; this.reload().then(function(success){ if (!success) { self.changeDirectory(currentDir, true); @@ -1624,6 +1644,24 @@ return OCA.Files.Files.getDownloadUrl(files, dir || this.getCurrentDirectory(), isDir); }, + getUploadUrl: function(fileName, dir) { + if (_.isUndefined(dir)) { + dir = this.getCurrentDirectory(); + } + + var pathSections = dir.split('/'); + if (!_.isUndefined(fileName)) { + pathSections.push(fileName); + } + var encodedPath = ''; + _.each(pathSections, function(section) { + if (section !== '') { + encodedPath += '/' + encodeURIComponent(section); + } + }); + return OC.linkToRemoteBase('webdav') + encodedPath; + }, + /** * Generates a preview URL based on the URL space. * @param urlSpec attributes for the URL @@ -2083,19 +2121,11 @@ ) .done(function() { // TODO: error handling / conflicts - self.filesClient.getFileInfo( - targetPath, { - properties: self._getWebdavProperties() - } - ) - .then(function(status, data) { - self.add(data, {animate: true, scrollTo: true}); - deferred.resolve(status, data); - }) - .fail(function(status) { - OC.Notification.showTemporary(t('files', 'Could not create file "{file}"', {file: name})); - deferred.reject(status); - }); + self.addAndFetchFileInfo(targetPath, '', {scrollTo: true}).then(function(status, data) { + deferred.resolve(status, data); + }, function() { + OC.Notification.showTemporary(t('files', 'Could not create file "{file}"', {file: name})); + }); }) .fail(function(status) { if (status === 412) { @@ -2136,32 +2166,19 @@ var targetPath = this.getCurrentDirectory() + '/' + name; this.filesClient.createDirectory(targetPath) - .done(function(createStatus) { - self.filesClient.getFileInfo( - targetPath, { - properties: self._getWebdavProperties() - } - ) - .done(function(status, data) { - self.add(data, {animate: true, scrollTo: true}); - deferred.resolve(status, data); - }) - .fail(function() { - OC.Notification.showTemporary(t('files', 'Could not create folder "{dir}"', {dir: name})); - deferred.reject(createStatus); - }); + .done(function() { + self.addAndFetchFileInfo(targetPath, '', {scrollTo:true}).then(function(status, data) { + deferred.resolve(status, data); + }, function() { + OC.Notification.showTemporary(t('files', 'Could not create folder "{dir}"', {dir: name})); + }); }) .fail(function(createStatus) { // method not allowed, folder might exist already if (createStatus === 405) { - self.filesClient.getFileInfo( - targetPath, { - properties: self._getWebdavProperties() - } - ) + // add it to the list, for completeness + self.addAndFetchFileInfo(targetPath, '', {scrollTo:true}) .done(function(status, data) { - // add it to the list, for completeness - self.add(data, {animate: true, scrollTo: true}); OC.Notification.showTemporary( t('files', 'Could not create folder "{dir}" because it already exists', {dir: name}) ); @@ -2183,6 +2200,60 @@ return promise; }, + /** + * Add file into the list by fetching its information from the server first. + * + * If the given directory does not match the current directory, nothing will + * be fetched. + * + * @param {String} fileName file name + * @param {String} [dir] optional directory, defaults to the current one + * @param {Object} options same options as #add + * @return {Promise} promise that resolves with the file info, or an + * already resolved Promise if no info was fetched. The promise rejects + * if the file was not found or an error occurred. + * + * @since 9.0 + */ + addAndFetchFileInfo: function(fileName, dir, options) { + var self = this; + var deferred = $.Deferred(); + if (_.isUndefined(dir)) { + dir = this.getCurrentDirectory(); + } else { + dir = dir || '/'; + } + + var targetPath = OC.joinPaths(dir, fileName); + + if ((OC.dirname(targetPath) || '/') !== this.getCurrentDirectory()) { + // no need to fetch information + deferred.resolve(); + return deferred.promise(); + } + + var addOptions = _.extend({ + animate: true, + scrollTo: false + }, options || {}); + + this.filesClient.getFileInfo(targetPath, { + properties: this._getWebdavProperties() + }) + .then(function(status, data) { + // remove first to avoid duplicates + self.remove(data.name); + self.add(data, addOptions); + deferred.resolve(status, data); + }) + .fail(function(status) { + OC.Notification.showTemporary(t('files', 'Could not create file "{file}"', {file: name})); + deferred.reject(status); + }); + + return deferred.promise(); + }, + /** * Returns whether the given file name exists in the list * @@ -2553,17 +2624,16 @@ /** * Setup file upload events related to the file-upload plugin */ - setupUploadEvents: function() { + setupUploadEvents: function($uploadEl) { var self = this; - // handle upload events - var fileUploadStart = this.$el.find('#file_upload_start'); + self._uploads = {}; // detect the progress bar resize - fileUploadStart.on('resized', this._onResize); + $uploadEl.on('resized', this._onResize); - fileUploadStart.on('fileuploaddrop', function(e, data) { - OC.Upload.log('filelist handle fileuploaddrop', e, data); + $uploadEl.on('fileuploaddrop', function(e, data) { + self._uploader.log('filelist handle fileuploaddrop', e, data); if (self.$el.hasClass('hidden')) { // do not upload to invisible lists @@ -2625,13 +2695,8 @@ } } }); - fileUploadStart.on('fileuploadadd', function(e, data) { - OC.Upload.log('filelist handle fileuploadadd', e, data); - - //finish delete if we are uploading a deleted file - if (self.deleteFiles && self.deleteFiles.indexOf(data.files[0].name)!==-1) { - self.finishDelete(null, true); //delete file before continuing - } + $uploadEl.on('fileuploadadd', function(e, data) { + self._uploader.log('filelist handle fileuploadadd', e, data); // add ui visualization to existing folder if (data.context && data.context.data('type') === 'dir') { @@ -2653,126 +2718,57 @@ } } + if (!data.targetDir) { + data.targetDir = self.getCurrentDirectory(); + } + }); /* * when file upload done successfully add row to filelist * update counter when uploading to sub folder */ - fileUploadStart.on('fileuploaddone', function(e, data) { - OC.Upload.log('filelist handle fileuploaddone', e, data); + $uploadEl.on('fileuploaddone', function(e, data) { + self._uploader.log('filelist handle fileuploaddone', e, data); - var response; - if (typeof data.result === 'string') { - response = data.result; - } else { - // fetch response from iframe - response = data.result[0].body.innerText; + var status = data.jqXHR.status; + if (status < 200 || status >= 300) { + // error was handled in OC.Uploads already + return; } - var result = JSON.parse(response); - - if (typeof result[0] !== 'undefined' && result[0].status === 'success') { - var file = result[0]; - var size = 0; - - if (data.context && data.context.data('type') === 'dir') { - - // update upload counter ui - var uploadText = data.context.find('.uploadtext'); - var currentUploads = parseInt(uploadText.attr('currentUploads'), 10); - currentUploads -= 1; - uploadText.attr('currentUploads', currentUploads); - var translatedText = n('files', 'Uploading %n file', 'Uploading %n files', currentUploads); - if (currentUploads === 0) { - self.showFileBusyState(uploadText.closest('tr'), false); - uploadText.text(translatedText); - uploadText.hide(); - } else { - uploadText.text(translatedText); - } - // update folder size - size = parseInt(data.context.data('size'), 10); - size += parseInt(file.size, 10); - data.context.attr('data-size', size); - data.context.find('td.filesize').text(humanFileSize(size)); - } else { - // only append new file if uploaded into the current folder - if (file.directory !== self.getCurrentDirectory()) { - // Uploading folders actually uploads a list of files - // for which the target directory (file.directory) might lie deeper - // than the current directory - - var fileDirectory = file.directory.replace('/','').replace(/\/$/, ""); - var currentDirectory = self.getCurrentDirectory().replace('/','').replace(/\/$/, "") + '/'; - - if (currentDirectory !== '/') { - // abort if fileDirectory does not start with current one - if (fileDirectory.indexOf(currentDirectory) !== 0) { - return; - } - - // remove the current directory part - fileDirectory = fileDirectory.substr(currentDirectory.length); - } - - // only take the first section of the path - fileDirectory = fileDirectory.split('/'); - - var fd; - // if the first section exists / is a subdir - if (fileDirectory.length) { - fileDirectory = fileDirectory[0]; - - // See whether it is already in the list - fd = self.findFileEl(fileDirectory); - if (fd.length === 0) { - var dir = { - name: fileDirectory, - type: 'dir', - mimetype: 'httpd/unix-directory', - permissions: file.permissions, - size: 0, - id: file.parentId - }; - fd = self.add(dir, {insert: true}); - } - - // update folder size - size = parseInt(fd.attr('data-size'), 10); - size += parseInt(file.size, 10); - fd.attr('data-size', size); - fd.find('td.filesize').text(OC.Util.humanFileSize(size)); - } - - return; - } - - // add as stand-alone row to filelist - size = t('files', 'Pending'); - if (data.files[0].size>=0) { - size=data.files[0].size; - } - //should the file exist in the list remove it - self.remove(file.name); - - // create new file context - data.context = self.add(file, {animate: true}); - } + var upload = self._uploader.getUpload(data); + var fileName = upload.getFileName(); + var fetchInfoPromise = self.addAndFetchFileInfo(fileName, upload.getFullPath()); + if (!self._uploads) { + self._uploads = {}; + } + if (OC.isSamePath(OC.dirname(upload.getFullPath() + '/'), self.getCurrentDirectory())) { + self._uploads[fileName] = fetchInfoPromise; } }); - fileUploadStart.on('fileuploadstop', function() { - OC.Upload.log('filelist handle fileuploadstop'); - - //cleanup uploading to a dir - var uploadText = self.$fileList.find('tr .uploadtext'); - self.showFileBusyState(uploadText.closest('tr'), false); - uploadText.fadeOut(); - uploadText.attr('currentUploads', 0); - + $uploadEl.on('fileuploadcreatedfolder', function(e, fullPath) { + self.addAndFetchFileInfo(OC.basename(fullPath), OC.dirname(fullPath)); + }); + $uploadEl.on('fileuploadstop', function() { + self._uploader.log('filelist handle fileuploadstop'); + + // prepare list of uploaded file names in the current directory + // and discard the other ones + var promises = _.values(self._uploads); + var fileNames = _.keys(self._uploads); + self._uploads = []; + + // as soon as all info is fetched + $.when.apply($, promises).then(function() { + // highlight uploaded files + self.highlightFiles(fileNames); + }); self.updateStorageStatistics(); }); - fileUploadStart.on('fileuploadfail', function(e, data) { - OC.Upload.log('filelist handle fileuploadfail', e, data); + $uploadEl.on('fileuploadfail', function(e, data) { + self._uploader.log('filelist handle fileuploadfail', e, data); + + self._uploads = []; //if user pressed cancel hide upload chrome if (data.errorThrown === 'abort') { diff --git a/apps/files/js/files.js b/apps/files/js/files.js index 53e07ddb0907..b525ecdaed01 100644 --- a/apps/files/js/files.js +++ b/apps/files/js/files.js @@ -226,17 +226,6 @@ // TODO: move file list related code (upload) to OCA.Files.FileList $('#file_action_panel').attr('activeAction', false); - // Triggers invisible file input - $('#upload a').on('click', function() { - $(this).parent().children('#file_upload_start').trigger('click'); - return false; - }); - - // Trigger cancelling of file upload - $('#uploadprogresswrapper .stop').on('click', function() { - OC.Upload.cancelUploads(); - }); - // drag&drop support using jquery.fileupload // TODO use OC.dialogs $(document).bind('drop dragover', function (e) { diff --git a/apps/files/templates/list.php b/apps/files/templates/list.php index dda7a1814a07..e741849f38bb 100644 --- a/apps/files/templates/list.php +++ b/apps/files/templates/list.php @@ -75,8 +75,7 @@
- +
diff --git a/apps/files/tests/js/fileUploadSpec.js b/apps/files/tests/js/fileUploadSpec.js index 0483d4649d43..bfaf0a9fe57b 100644 --- a/apps/files/tests/js/fileUploadSpec.js +++ b/apps/files/tests/js/fileUploadSpec.js @@ -19,11 +19,11 @@ * */ -/* global FileList */ - describe('OC.Upload tests', function() { var $dummyUploader; var testFile; + var uploader; + var failStub; beforeEach(function() { testFile = { @@ -46,59 +46,64 @@ describe('OC.Upload tests', function() { '
' ); $dummyUploader = $('#file_upload_start'); + uploader = new OC.Uploader($dummyUploader); + failStub = sinon.stub(); + $dummyUploader.on('fileuploadfail', failStub); }); afterEach(function() { - delete window.file_upload_param; $dummyUploader = undefined; + failStub = undefined; }); - describe('Adding files for upload', function() { - var params; - var failStub; - beforeEach(function() { - params = OC.Upload.init(); - failStub = sinon.stub(); - $dummyUploader.on('fileuploadfail', failStub); - }); - afterEach(function() { - params = undefined; - failStub = undefined; - }); - - /** - * Add file for upload - * @param file file data - */ - function addFile(file) { - return params.add.call( + /** + * Add file for upload + * @param {Array.} files array of file data to simulate upload + * @return {Array.} array of uploadinfo or null if add() returned false + */ + function addFiles(uploader, files) { + return _.map(files, function(file) { + var jqXHR = {status: 200}; + var uploadInfo = { + originalFiles: files, + files: [file], + jqXHR: jqXHR, + response: sinon.stub.returns(jqXHR), + submit: sinon.stub() + }; + if (uploader.fileUploadParam.add.call( $dummyUploader[0], {}, - { - originalFiles: {}, - files: [file] - }); - } + uploadInfo + )) { + return uploadInfo; + } + return null; + }); + } + describe('Adding files for upload', function() { it('adds file when size is below limits', function() { - var result = addFile(testFile); - expect(result).toEqual(true); + var result = addFiles(uploader, [testFile]); + expect(result[0]).not.toEqual(null); + expect(result[0].submit.calledOnce).toEqual(true); }); it('adds file when free space is unknown', function() { var result; $('#free_space').val(-2); - result = addFile(testFile); + result = addFiles(uploader, [testFile]); - expect(result).toEqual(true); + expect(result[0]).not.toEqual(null); + expect(result[0].submit.calledOnce).toEqual(true); expect(failStub.notCalled).toEqual(true); }); it('does not add file if it exceeds upload limit', function() { var result; $('#upload_limit').val(1000); - result = addFile(testFile); + result = addFiles(uploader, [testFile]); - expect(result).toEqual(false); + expect(result[0]).toEqual(null); expect(failStub.calledOnce).toEqual(true); expect(failStub.getCall(0).args[1].textStatus).toEqual('sizeexceedlimit'); expect(failStub.getCall(0).args[1].errorThrown).toEqual( @@ -109,9 +114,9 @@ describe('OC.Upload tests', function() { var result; $('#free_space').val(1000); - result = addFile(testFile); + result = addFiles(uploader, [testFile]); - expect(result).toEqual(false); + expect(result[0]).toEqual(null); expect(failStub.calledOnce).toEqual(true); expect(failStub.getCall(0).args[1].textStatus).toEqual('notenoughspace'); expect(failStub.getCall(0).args[1].errorThrown).toEqual( @@ -120,12 +125,10 @@ describe('OC.Upload tests', function() { }); }); describe('Upload conflicts', function() { - var oldFileList; var conflictDialogStub; - var callbacks; + var fileList; beforeEach(function() { - oldFileList = FileList; $('#testArea').append( '
' + '' + @@ -145,74 +148,56 @@ describe('OC.Upload tests', function() { '
' + '
' ); - FileList = new OCA.Files.FileList($('#tableContainer')); + fileList = new OCA.Files.FileList($('#tableContainer')); - FileList.add({name: 'conflict.txt', mimetype: 'text/plain'}); - FileList.add({name: 'conflict2.txt', mimetype: 'text/plain'}); + fileList.add({name: 'conflict.txt', mimetype: 'text/plain'}); + fileList.add({name: 'conflict2.txt', mimetype: 'text/plain'}); conflictDialogStub = sinon.stub(OC.dialogs, 'fileexists'); - callbacks = { - onNoConflicts: sinon.stub() - }; + + uploader = new OC.Uploader($dummyUploader, { + fileList: fileList + }); }); afterEach(function() { conflictDialogStub.restore(); - FileList.destroy(); - FileList = oldFileList; + fileList.destroy(); }); it('does not show conflict dialog when no client side conflict', function() { - var selection = { - // yes, the format of uploads is weird... - uploads: [ - {files: [{name: 'noconflict.txt'}]}, - {files: [{name: 'noconflict2.txt'}]} - ] - }; - - OC.Upload.checkExistingFiles(selection, callbacks); + var result = addFiles(uploader, [{name: 'noconflict.txt'}, {name: 'noconflict2.txt'}]); expect(conflictDialogStub.notCalled).toEqual(true); - expect(callbacks.onNoConflicts.calledOnce).toEqual(true); - expect(callbacks.onNoConflicts.calledWith(selection)).toEqual(true); + expect(result[0].submit.calledOnce).toEqual(true); + expect(result[1].submit.calledOnce).toEqual(true); }); it('shows conflict dialog when no client side conflict', function() { - var selection = { - // yes, the format of uploads is weird... - uploads: [ - {files: [{name: 'conflict.txt'}]}, - {files: [{name: 'conflict2.txt'}]}, - {files: [{name: 'noconflict.txt'}]} - ] - }; - var deferred = $.Deferred(); conflictDialogStub.returns(deferred.promise()); deferred.resolve(); - OC.Upload.checkExistingFiles(selection, callbacks); + var result = addFiles(uploader, [ + {name: 'conflict.txt'}, + {name: 'conflict2.txt'}, + {name: 'noconflict.txt'} + ]); expect(conflictDialogStub.callCount).toEqual(3); - expect(conflictDialogStub.getCall(1).args[0]) - .toEqual({files: [ { name: 'conflict.txt' } ]}); + expect(conflictDialogStub.getCall(1).args[0].getFileName()) + .toEqual('conflict.txt'); expect(conflictDialogStub.getCall(1).args[1]) .toEqual({ name: 'conflict.txt', mimetype: 'text/plain', directory: '/' }); expect(conflictDialogStub.getCall(1).args[2]).toEqual({ name: 'conflict.txt' }); // yes, the dialog must be called several times... - expect(conflictDialogStub.getCall(2).args[0]).toEqual({ - files: [ { name: 'conflict2.txt' } ] - }); + expect(conflictDialogStub.getCall(2).args[0].getFileName()).toEqual('conflict2.txt'); expect(conflictDialogStub.getCall(2).args[1]) .toEqual({ name: 'conflict2.txt', mimetype: 'text/plain', directory: '/' }); expect(conflictDialogStub.getCall(2).args[2]).toEqual({ name: 'conflict2.txt' }); - expect(callbacks.onNoConflicts.calledOnce).toEqual(true); - expect(callbacks.onNoConflicts.calledWith({ - uploads: [ - {files: [{name: 'noconflict.txt'}]} - ] - })).toEqual(true); + expect(result[0].submit.calledOnce).toEqual(false); + expect(result[1].submit.calledOnce).toEqual(false); + expect(result[2].submit.calledOnce).toEqual(true); }); }); }); diff --git a/apps/files/tests/js/filelistSpec.js b/apps/files/tests/js/filelistSpec.js index 35af2f89fc24..209962637e31 100644 --- a/apps/files/tests/js/filelistSpec.js +++ b/apps/files/tests/js/filelistSpec.js @@ -159,7 +159,8 @@ describe('OCA.Files.FileList tests', function() { pageSizeStub = sinon.stub(OCA.Files.FileList.prototype, 'pageSize').returns(20); fileList = new OCA.Files.FileList($('#app-content-files'), { filesClient: filesClient, - config: filesConfig + config: filesConfig, + enableUpload: true }); }); afterEach(function() { @@ -2423,7 +2424,7 @@ describe('OCA.Files.FileList tests', function() { deferredInfo.resolve( 200, - new FileInfo({ + new FileInfo({ path: '/subdir', name: 'test.txt', mimetype: 'text/plain' @@ -2483,12 +2484,70 @@ describe('OCA.Files.FileList tests', function() { // TODO: error cases // TODO: unique name cases }); + describe('addAndFetchFileInfo', function() { + var getFileInfoStub; + var getFileInfoDeferred; + + beforeEach(function() { + getFileInfoDeferred = $.Deferred(); + getFileInfoStub = sinon.stub(OC.Files.Client.prototype, 'getFileInfo'); + getFileInfoStub.returns(getFileInfoDeferred.promise()); + }); + afterEach(function() { + getFileInfoStub.restore(); + }); + it('does not fetch if the given folder is not the current one', function() { + var promise = fileList.addAndFetchFileInfo('testfile.txt', '/another'); + expect(getFileInfoStub.notCalled).toEqual(true); + + expect(promise.state()).toEqual('resolved'); + }); + it('fetches info when folder is the current one', function() { + fileList.addAndFetchFileInfo('testfile.txt', '/subdir'); + expect(getFileInfoStub.calledOnce).toEqual(true); + expect(getFileInfoStub.getCall(0).args[0]).toEqual('/subdir/testfile.txt'); + }); + it('adds file data to list when fetching is done', function() { + fileList.addAndFetchFileInfo('testfile.txt', '/subdir'); + getFileInfoDeferred.resolve(200, { + name: 'testfile.txt', + size: 100 + }); + expect(fileList.findFileEl('testfile.txt').attr('data-size')).toEqual('100'); + }); + it('replaces file data to list when fetching is done', function() { + fileList.addAndFetchFileInfo('testfile.txt', '/subdir', {replace: true}); + fileList.add({ + name: 'testfile.txt', + size: 95 + }); + getFileInfoDeferred.resolve(200, { + name: 'testfile.txt', + size: 100 + }); + expect(fileList.findFileEl('testfile.txt').attr('data-size')).toEqual('100'); + }); + it('resolves promise with file data when fetching is done', function() { + var promise = fileList.addAndFetchFileInfo('testfile.txt', '/subdir', {replace: true}); + getFileInfoDeferred.resolve(200, { + name: 'testfile.txt', + size: 100 + }); + expect(promise.state()).toEqual('resolved'); + promise.then(function(status, data) { + expect(status).toEqual(200); + expect(data.name).toEqual('testfile.txt'); + expect(data.size).toEqual(100); + }); + }); + }); /** * Test upload mostly by testing the code inside the event handlers * that were registered on the magic upload object */ describe('file upload', function() { var $uploader; + var uploadData; beforeEach(function() { // note: this isn't the real blueimp file uploader from jquery.fileupload @@ -2496,14 +2555,52 @@ describe('OCA.Files.FileList tests', function() { // test the response of the handlers $uploader = $('#file_upload_start'); fileList.setFiles(testFiles); + // simulate data structure from jquery.upload + uploadData = { + files: [{ + name: 'upload.txt' + }] + }; }); afterEach(function() { $uploader = null; + uploadData = null; + }); + + describe('enableupload', function() { + it('sets up uploader when enableUpload is true', function() { + expect(fileList._uploader).toBeDefined(); + }); + it('does not sets up uploader when enableUpload is false', function() { + fileList.destroy(); + fileList = new OCA.Files.FileList($('#app-content-files'), { + filesClient: filesClient + }); + expect(fileList._uploader).toBeFalsy(); + }); }); + describe('adding files for upload', function() { + /** + * Simulate add event on the given target + * + * @return event object including the result + */ + function addFile(data) { + var ev = new $.Event('fileuploadadd', {}); + // using triggerHandler instead of trigger so we can pass + // extra data + $uploader.triggerHandler(ev, data || {}); + return ev; + } + + it('sets target dir to the current directory', function() { + addFile(uploadData); + expect(uploadData.targetDir).toEqual('/subdir'); + }); + }); describe('dropping external files', function() { - var uploadData; /** * Simulate drop event on the given target @@ -2524,17 +2621,6 @@ describe('OCA.Files.FileList tests', function() { return ev; } - beforeEach(function() { - // simulate data structure from jquery.upload - uploadData = { - files: [{ - relativePath: 'fileToUpload.txt' - }] - }; - }); - afterEach(function() { - uploadData = null; - }); it('drop on a tr or crumb outside file list does not trigger upload', function() { var $anotherTable = $('
outside
crumb
'); var ev; @@ -2558,12 +2644,14 @@ describe('OCA.Files.FileList tests', function() { ev = dropOn(fileList.$fileList.find('th:first'), uploadData); expect(ev.result).not.toEqual(false); + expect(uploadData.targetDir).toEqual('/subdir'); }); it('drop on an element on the table container triggers upload', function() { var ev; ev = dropOn($('#app-content-files'), uploadData); expect(ev.result).not.toEqual(false); + expect(uploadData.targetDir).toEqual('/subdir'); }); it('drop on an element inside the table does not trigger upload if no upload permission', function() { $('#permissions').val(0); @@ -2587,6 +2675,7 @@ describe('OCA.Files.FileList tests', function() { ev = dropOn(fileList.findFileEl('One.txt').find('td:first'), uploadData); expect(ev.result).not.toEqual(false); + expect(uploadData.targetDir).toEqual('/subdir'); }); it('drop on a folder row inside the table triggers upload to target folder', function() { var ev; @@ -2619,6 +2708,97 @@ describe('OCA.Files.FileList tests', function() { expect(fileList.findFileEl('afile.txt').find('.uploadtext').length).toEqual(0); }); }); + + describe('after folder creation due to folder upload', function() { + it('fetches folder info', function() { + var fetchInfoStub = sinon.stub(fileList, 'addAndFetchFileInfo'); + + var ev = new $.Event('fileuploadcreatedfolder', {}); + $uploader.triggerHandler(ev, '/subdir/newfolder'); + + expect(fetchInfoStub.calledOnce).toEqual(true); + expect(fetchInfoStub.getCall(0).args[0]).toEqual('newfolder'); + expect(fetchInfoStub.getCall(0).args[1]).toEqual('/subdir'); + + fetchInfoStub.restore(); + }); + }); + + describe('after upload', function() { + var fetchInfoStub; + + beforeEach(function() { + fetchInfoStub = sinon.stub(fileList, 'addAndFetchFileInfo'); + + }); + afterEach(function() { + fetchInfoStub.restore(); + }); + + + function createUpload(name, dir) { + var data = { + files: [{ + name: name + }], + upload: { + getFileName: sinon.stub().returns(name), + getFullPath: sinon.stub().returns(dir) + }, + jqXHR: { + status: 200 + } + } + return data; + } + + /** + * Simulate add event on the given target + * + * @return event object including the result + */ + function addFile(data) { + var ev = new $.Event('fileuploaddone', {}); + // using triggerHandler instead of trigger so we can pass + // extra data + var deferred = $.Deferred(); + fetchInfoStub.returns(deferred.promise()); + $uploader.triggerHandler(ev, data || {}); + return deferred; + } + + it('fetches file info', function() { + addFile(createUpload('upload.txt', '/subdir')); + expect(fetchInfoStub.calledOnce).toEqual(true); + expect(fetchInfoStub.getCall(0).args[0]).toEqual('upload.txt'); + expect(fetchInfoStub.getCall(0).args[1]).toEqual('/subdir'); + }); + it('highlights all uploaded files after all fetches are done', function() { + var highlightStub = sinon.stub(fileList, 'highlightFiles'); + var def1 = addFile(createUpload('upload.txt', '/subdir')); + var def2 = addFile(createUpload('upload2.txt', '/subdir')); + var def3 = addFile(createUpload('upload3.txt', '/another')); + $uploader.triggerHandler(new $.Event('fileuploadstop')); + + expect(highlightStub.notCalled).toEqual(true); + def1.resolve(); + expect(highlightStub.notCalled).toEqual(true); + def2.resolve(); + def3.resolve(); + expect(highlightStub.calledOnce).toEqual(true); + expect(highlightStub.getCall(0).args[0]).toEqual(['upload.txt', 'upload2.txt']); + + highlightStub.restore(); + }); + it('queries storage stats', function() { + var statStub = sinon.stub(fileList, 'updateStorageStatistics'); + addFile(createUpload('upload.txt', '/subdir')); + expect(statStub.notCalled).toEqual(true); + $uploader.triggerHandler(new $.Event('fileuploadstop')); + expect(statStub.calledOnce).toEqual(true); + statStub.restore(); + }); + }); }); describe('Handling errors', function () { var deferredList; diff --git a/apps/files_sharing/js/public.js b/apps/files_sharing/js/public.js index 572ccc47b719..9c45dc6259ae 100644 --- a/apps/files_sharing/js/public.js +++ b/apps/files_sharing/js/public.js @@ -71,7 +71,8 @@ OCA.Sharing.PublicApp = { folderDropOptions: folderDropOptions, fileActions: fileActions, detailsViewEnabled: false, - filesClient: filesClient + filesClient: filesClient, + enableUpload: true } ); this.files = OCA.Files.Files; @@ -169,6 +170,30 @@ OCA.Sharing.PublicApp = { return OC.generateUrl('/s/' + token + '/download') + '?' + OC.buildQueryString(params); }; + this.fileList.getUploadUrl = function(fileName, dir) { + if (_.isUndefined(dir)) { + dir = this.getCurrentDirectory(); + } + + var pathSections = dir.split('/'); + if (!_.isUndefined(fileName)) { + pathSections.push(fileName); + } + var encodedPath = ''; + _.each(pathSections, function(section) { + if (section !== '') { + encodedPath += '/' + encodeURIComponent(section); + } + }); + var base = ''; + + if (!this._uploader.isXHRUpload()) { + // also add auth in URL due to POST workaround + base = OC.getProtocol() + '://' + token + '@' + OC.getHost() + (OC.getPort() ? ':' + OC.getPort() : ''); + } + return base + OC.getRootPath() + '/public.php/webdav' + encodedPath; + }; + this.fileList.getAjaxUrl = function (action, params) { params = params || {}; params.t = token; @@ -202,20 +227,12 @@ OCA.Sharing.PublicApp = { OCA.Files.FileList.prototype.updateEmptyContent.apply(this, arguments); }; - var file_upload_start = $('#file_upload_start'); - file_upload_start.on('fileuploadadd', function (e, data) { - var fileDirectory = ''; - if (typeof data.files[0].relativePath !== 'undefined') { - fileDirectory = data.files[0].relativePath; + this.fileList._uploader.on('fileuploadadd', function(e, data) { + if (!data.headers) { + data.headers = {}; } - // Add custom data to the upload handler - data.formData = { - requesttoken: $('#publicUploadRequestToken').val(), - dirToken: $('#dirToken').val(), - subdir: data.targetDir || self.fileList.getCurrentDirectory(), - file_directory: fileDirectory - }; + data.headers.Authorization = 'Basic ' + btoa(token + ':'); }); // do not allow sharing from the public page diff --git a/apps/files_sharing/tests/js/publicAppSpec.js b/apps/files_sharing/tests/js/publicAppSpec.js index 585657448822..e8ec9899ecc5 100644 --- a/apps/files_sharing/tests/js/publicAppSpec.js +++ b/apps/files_sharing/tests/js/publicAppSpec.js @@ -87,10 +87,18 @@ describe('OCA.Sharing.PublicApp tests', function() { }); it('Uses public webdav endpoint', function() { + App._initialized = false; + fakeServer.restore(); + window.fakeServer = sinon.fakeServer.create(); + + // uploader function messes up with fakeServer + var uploaderDetectStub = sinon.stub(OC.Uploader.prototype, '_supportAjaxUploadWithProgress'); + App.initialize($('#preview')); expect(fakeServer.requests.length).toEqual(1); expect(fakeServer.requests[0].method).toEqual('PROPFIND'); expect(fakeServer.requests[0].url).toEqual('https://example.com:9876/owncloud/public.php/webdav/subdir'); expect(fakeServer.requests[0].requestHeaders.Authorization).toEqual('Basic c2g0dG9rOm51bGw='); + uploaderDetectStub.restore(); }); describe('Download Url', function() { @@ -118,5 +126,20 @@ describe('OCA.Sharing.PublicApp tests', function() { .toEqual(OC.webroot + '/index.php/apps/files_sharing/ajax/test.php?a=1&b=x%20y&t=sh4tok'); }); }); + describe('Upload Url', function() { + var fileList; + + beforeEach(function() { + fileList = App.fileList; + }); + it('returns correct upload URL', function() { + expect(fileList.getUploadUrl('some file.txt')) + .toEqual('/owncloud/public.php/webdav/subdir/some%20file.txt'); + }); + it('returns correct upload URL with specified dir', function() { + expect(fileList.getUploadUrl('some file.txt', 'sub')) + .toEqual('/owncloud/public.php/webdav/sub/some%20file.txt'); + }); + }); }); }); From 55e933bb9aa5062b8ec59c92651ecfeaa1af0226 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Fri, 15 Jul 2016 13:19:16 +0200 Subject: [PATCH 3/6] Goodbye Iframe transport ! Not needed any more in IE >= 11 --- .../Connector/Sabre/IFrameTransportPlugin.php | 188 ------------------ .../dav/lib/Connector/Sabre/ServerFactory.php | 1 - .../Sabre/IFrameTransportPluginTest.php | 164 --------------- 3 files changed, 353 deletions(-) delete mode 100644 apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php delete mode 100644 apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php diff --git a/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php b/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php deleted file mode 100644 index af6e5a62a5e5..000000000000 --- a/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php +++ /dev/null @@ -1,188 +0,0 @@ - - * - * @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 - * - */ - -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 404d09a74a5d..40a8ae0c8dd1 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -106,7 +106,6 @@ 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()); // 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/IFrameTransportPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php deleted file mode 100644 index 485dd1b779e1..000000000000 --- a/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php +++ /dev/null @@ -1,164 +0,0 @@ - - * 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)); - } -} From 36f49ca0910a9e418f10728ac457aac78cc0cab3 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Fri, 15 Jul 2016 16:03:02 +0200 Subject: [PATCH 4/6] Add support for chunked upload Hacked around Blueimp's jquery.fileupload to make it work with our new chunking API. --- apps/files/js/file-upload.js | 135 +++++++++++++++++++++++++---------- apps/files/js/filelist.js | 20 +++--- 2 files changed, 110 insertions(+), 45 deletions(-) diff --git a/apps/files/js/file-upload.js b/apps/files/js/file-upload.js index 453553330c64..5f5f6303cf8d 100644 --- a/apps/files/js/file-upload.js +++ b/apps/files/js/file-upload.js @@ -18,7 +18,7 @@ * - TODO music upload button */ -/* global jQuery, humanFileSize */ +/* global jQuery, humanFileSize, md5 */ /** * File upload object @@ -34,12 +34,21 @@ OC.FileUpload = function(uploader, data) { this.uploader = uploader; this.data = data; + var path = OC.joinPaths(this.uploader.fileList.getCurrentDirectory(), this.getFile().name); + this.id = 'web-file-upload-' + md5(path) + '-' + (new Date()).getTime(); }; OC.FileUpload.CONFLICT_MODE_DETECT = 0; OC.FileUpload.CONFLICT_MODE_OVERWRITE = 1; OC.FileUpload.CONFLICT_MODE_AUTORENAME = 2; OC.FileUpload.prototype = { + /** + * Unique upload id + * + * @type string + */ + id: null, + /** * Upload element * @@ -66,6 +75,15 @@ OC.FileUpload.prototype = { */ _newName: null, + /** + * Returns the unique upload id + * + * @return string + */ + getId: function() { + return this.id; + }, + /** * Returns the file to be uploaded * @@ -143,6 +161,7 @@ OC.FileUpload.prototype = { * Submit the upload */ submit: function() { + var self = this; var data = this.data; var file = this.getFile(); @@ -192,19 +211,54 @@ OC.FileUpload.prototype = { } } + var chunkFolderPromise; + if ($.support.blobSlice + && this.uploader.fileUploadParam.maxChunkSize + && this.getFile().size > this.uploader.fileUploadParam.maxChunkSize + ) { + data.isChunked = true; + chunkFolderPromise = this.uploader.davClient.createDirectory( + 'uploads/' + encodeURIComponent(OC.getCurrentUser().uid) + '/' + encodeURIComponent(this.getId()) + ); + // TODO: if fails, it means same id already existed, need to retry + } else { + chunkFolderPromise = $.Deferred().resolve().promise(); + } + // wait for creation of the required directory before uploading - folderPromise.then(function() { + $.when(folderPromise, chunkFolderPromise).then(function() { data.submit(); }, function() { - data.abort(); + self.abort(); }); }, + /** + * Process end of transfer + */ + done: function() { + if (!this.data.isChunked) { + return $.Deferred().resolve().promise(); + } + + var uid = OC.getCurrentUser().uid; + return this.uploader.davClient.move( + 'uploads/' + encodeURIComponent(uid) + '/' + encodeURIComponent(this.getId()) + '/.file', + 'files/' + encodeURIComponent(uid) + '/' + OC.joinPaths(this.getFullPath(), this.getFileName()) + ); + }, + /** * Abort the upload */ abort: function() { + if (this.data.isChunked) { + // delete transfer directory for this upload + this.uploader.davClient.remove( + 'uploads/' + encodeURIComponent(OC.getCurrentUser().uid) + '/' + encodeURIComponent(this.getId()) + ); + } this.data.abort(); }, @@ -280,7 +334,7 @@ OC.Uploader = function() { this.init.apply(this, arguments); }; -OC.Uploader.prototype = { +OC.Uploader.prototype = _.extend({ /** * @type Array */ @@ -384,7 +438,7 @@ OC.Uploader.prototype = { self.filesClient.createDirectory(fullPath).always(function(status) { // 405 is expected if the folder already exists if ((status >= 200 && status < 300) || status === 405) { - self.$uploadEl.trigger($.Event('fileuploadcreatedfolder'), fullPath); + self.trigger('createdfolder', fullPath); deferred.resolve(); return; } @@ -407,8 +461,8 @@ OC.Uploader.prototype = { submitUploads: function(uploads) { var self = this; _.each(uploads, function(upload) { - upload.submit(); self._uploads[upload.data.uploadId] = upload; + upload.submit(); }); }, @@ -611,16 +665,6 @@ OC.Uploader.prototype = { this.$uploadEl.trigger(new $.Event('resized')); }, - on: function() { - // forward events to upload element - this.$uploadEl.on.apply(this.$uploadEl, arguments); - }, - - off: function() { - // forward events to upload element - this.$uploadEl.off.apply(this.$uploadEl, arguments); - }, - /** * Returns whether the given file is known to be a received shared file * @@ -654,6 +698,12 @@ OC.Uploader.prototype = { this.fileList = options.fileList; this.filesClient = options.filesClient || OC.Files.getClient(); + this.davClient = new OC.Files.Client({ + host: OC.getHost(), + port: OC.getPort(), + root: OC.getRootPath() + '/remote.php/dav/', + useHTTPS: OC.getProtocol() === 'https' + }); $uploadEl = $($uploadEl); this.$uploadEl = $uploadEl; @@ -668,6 +718,7 @@ OC.Uploader.prototype = { dropZone: options.dropZone, // restrict dropZone to content div autoUpload: false, sequentialUploads: true, + maxChunkSize: 10000000, //singleFileUploads is on by default, so the data.files array will always have length 1 /** * on first add of every selection @@ -691,7 +742,7 @@ OC.Uploader.prototype = { var upload = new OC.FileUpload(self, data); // can't link directly due to jQuery not liking cyclic deps on its ajax object - data.uploadId = _.uniqueId('file-upload'); + data.uploadId = upload.getId(); // we need to collect all data upload objects before // starting the upload so we can check their existence @@ -845,7 +896,10 @@ OC.Uploader.prototype = { }, fail: function(e, data) { var upload = self.getUpload(data); - var status = upload.getResponseStatus(); + var status = null; + if (upload) { + status = upload.getResponseStatus(); + } self.log('fail', e, upload); if (data.textStatus === 'abort') { @@ -918,10 +972,7 @@ OC.Uploader.prototype = { // add progress handlers fileupload.on('fileuploadadd', function(e, data) { self.log('progress handle fileuploadadd', e, data); - //show cancel button - //if (data.dataType !== 'iframe') { //FIXME when is iframe used? only for ie? - // $('#uploadprogresswrapper .stop').show(); - //} + self.trigger('add', e, data); }); // add progress handlers fileupload.on('fileuploadstart', function(e, data) { @@ -937,10 +988,12 @@ OC.Uploader.prototype = { + ''); $('#uploadprogressbar').tipsy({gravity:'n', fade:true, live:true}); self._showProgressBar(); + self.trigger('start', e, data); }); fileupload.on('fileuploadprogress', function(e, data) { self.log('progress handle fileuploadprogress', e, data); //TODO progressbar in row + self.trigger('progress', e, data); }); fileupload.on('fileuploadprogressall', function(e, data) { self.log('progress handle fileuploadprogressall', e, data); @@ -1003,6 +1056,7 @@ OC.Uploader.prototype = { }) ); $('#uploadprogressbar').progressbar('value', progress); + self.trigger('progressall', e, data); }); fileupload.on('fileuploadstop', function(e, data) { self.log('progress handle fileuploadstop', e, data); @@ -1016,23 +1070,32 @@ OC.Uploader.prototype = { if (data.errorThrown === 'abort') { self._hideProgressBar(); } + self.trigger('fail', e, data); }); - } else { - // for all browsers that don't support the progress bar - // IE 8 & 9 - - // show a spinner - fileupload.on('fileuploadstart', function() { - $('#upload').addClass('icon-loading'); - $('#upload .icon-upload').hide(); + fileupload.on('fileuploadchunksend', function(e, data) { + // modify the request to adjust it to our own chunking + var upload = self.getUpload(data); + var range = data.contentRange.split(' ')[1]; + var chunkId = range.split('/')[0]; + data.url = OC.getRootPath() + + '/remote.php/dav/uploads' + + '/' + encodeURIComponent(OC.getCurrentUser().uid) + + '/' + encodeURIComponent(upload.getId()) + + '/' + encodeURIComponent(chunkId); + delete data.contentRange; + delete data.headers['Content-Range']; }); - - // hide a spinner - fileupload.on('fileuploadstop fileuploadfail', function() { - $('#upload').removeClass('icon-loading'); - $('#upload .icon-upload').show(); + fileupload.on('fileuploaddone', function(e, data) { + var upload = self.getUpload(data); + upload.done().then(function() { + self.trigger('done', e, upload); + }); + }); + fileupload.on('fileuploaddrop', function(e, data) { + self.trigger('drop', e, data); }); + } } @@ -1050,6 +1113,6 @@ OC.Uploader.prototype = { return this.fileUploadParam; } -}; +}, OC.Backbone.Events); diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index 21bda3649713..f5e01dac9e40 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -2623,16 +2623,18 @@ /** * Setup file upload events related to the file-upload plugin + * + * @param {OC.Uploader} uploader */ - setupUploadEvents: function($uploadEl) { + setupUploadEvents: function(uploader) { var self = this; self._uploads = {}; // detect the progress bar resize - $uploadEl.on('resized', this._onResize); + uploader.on('resized', this._onResize); - $uploadEl.on('fileuploaddrop', function(e, data) { + uploader.on('drop', function(e, data) { self._uploader.log('filelist handle fileuploaddrop', e, data); if (self.$el.hasClass('hidden')) { @@ -2695,7 +2697,7 @@ } } }); - $uploadEl.on('fileuploadadd', function(e, data) { + uploader.on('add', function(e, data) { self._uploader.log('filelist handle fileuploadadd', e, data); // add ui visualization to existing folder @@ -2727,16 +2729,16 @@ * when file upload done successfully add row to filelist * update counter when uploading to sub folder */ - $uploadEl.on('fileuploaddone', function(e, data) { + uploader.on('done', function(e, upload) { self._uploader.log('filelist handle fileuploaddone', e, data); + var data = upload.data; var status = data.jqXHR.status; if (status < 200 || status >= 300) { // error was handled in OC.Uploads already return; } - var upload = self._uploader.getUpload(data); var fileName = upload.getFileName(); var fetchInfoPromise = self.addAndFetchFileInfo(fileName, upload.getFullPath()); if (!self._uploads) { @@ -2746,10 +2748,10 @@ self._uploads[fileName] = fetchInfoPromise; } }); - $uploadEl.on('fileuploadcreatedfolder', function(e, fullPath) { + uploader.on('createdfolder', function(e, fullPath) { self.addAndFetchFileInfo(OC.basename(fullPath), OC.dirname(fullPath)); }); - $uploadEl.on('fileuploadstop', function() { + uploader.on('stop', function() { self._uploader.log('filelist handle fileuploadstop'); // prepare list of uploaded file names in the current directory @@ -2765,7 +2767,7 @@ }); self.updateStorageStatistics(); }); - $uploadEl.on('fileuploadfail', function(e, data) { + uploader.on('fail', function(e, data) { self._uploader.log('filelist handle fileuploadfail', e, data); self._uploads = []; From 1871f7b04ea315004db8617860bb44922824970e Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Wed, 31 Aug 2016 16:32:14 +0200 Subject: [PATCH 5/6] Fix js unit tests for webdav put upload changes --- apps/files/js/file-upload.js | 15 ++++- apps/files/js/filelist.js | 8 +-- apps/files/tests/js/fileUploadSpec.js | 2 +- apps/files/tests/js/filelistSpec.js | 86 +++++++++++---------------- 4 files changed, 53 insertions(+), 58 deletions(-) diff --git a/apps/files/js/file-upload.js b/apps/files/js/file-upload.js index 5f5f6303cf8d..40595fbe44e4 100644 --- a/apps/files/js/file-upload.js +++ b/apps/files/js/file-upload.js @@ -34,7 +34,12 @@ OC.FileUpload = function(uploader, data) { this.uploader = uploader; this.data = data; - var path = OC.joinPaths(this.uploader.fileList.getCurrentDirectory(), this.getFile().name); + var path = ''; + if (this.uploader.fileList) { + path = OC.joinPaths(this.uploader.fileList.getCurrentDirectory(), this.getFile().name); + } else { + path = this.getFile().name; + } this.id = 'web-file-upload-' + md5(path) + '-' + (new Date()).getTime(); }; OC.FileUpload.CONFLICT_MODE_DETECT = 0; @@ -364,6 +369,9 @@ OC.Uploader.prototype = _.extend({ * also see article @link http://blog.new-bamboo.co.uk/2012/01/10/ridiculously-simple-ajax-uploads-with-formdata */ _supportAjaxUploadWithProgress: function() { + if (window.TESTING) { + return true; + } return supportFileAPI() && supportAjaxUploadProgressEvents() && supportFormData(); // Is the File API supported? @@ -718,7 +726,6 @@ OC.Uploader.prototype = _.extend({ dropZone: options.dropZone, // restrict dropZone to content div autoUpload: false, sequentialUploads: true, - maxChunkSize: 10000000, //singleFileUploads is on by default, so the data.files array will always have length 1 /** * on first add of every selection @@ -924,7 +931,9 @@ OC.Uploader.prototype = _.extend({ OC.Notification.showTemporary(data.errorThrown, {timeout: 10}); } - upload.deleteUpload(); + if (upload) { + upload.deleteUpload(); + } }, /** * called for every successful upload diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index f5e01dac9e40..53272373ccda 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -2685,16 +2685,16 @@ // add target dir data.targetDir = dir; } else { - // we are dropping somewhere inside the file list, which will - // upload the file to the current directory - data.targetDir = self.getCurrentDirectory(); - // cancel uploads to current dir if no permission var isCreatable = (self.getDirectoryPermissions() & OC.PERMISSION_CREATE) !== 0; if (!isCreatable) { self._showPermissionDeniedNotification(); return false; } + + // we are dropping somewhere inside the file list, which will + // upload the file to the current directory + data.targetDir = self.getCurrentDirectory(); } }); uploader.on('add', function(e, data) { diff --git a/apps/files/tests/js/fileUploadSpec.js b/apps/files/tests/js/fileUploadSpec.js index bfaf0a9fe57b..19f8cde7e442 100644 --- a/apps/files/tests/js/fileUploadSpec.js +++ b/apps/files/tests/js/fileUploadSpec.js @@ -48,7 +48,7 @@ describe('OC.Upload tests', function() { $dummyUploader = $('#file_upload_start'); uploader = new OC.Uploader($dummyUploader); failStub = sinon.stub(); - $dummyUploader.on('fileuploadfail', failStub); + uploader.on('fail', failStub); }); afterEach(function() { $dummyUploader = undefined; diff --git a/apps/files/tests/js/filelistSpec.js b/apps/files/tests/js/filelistSpec.js index 209962637e31..030063c3859c 100644 --- a/apps/files/tests/js/filelistSpec.js +++ b/apps/files/tests/js/filelistSpec.js @@ -2546,15 +2546,12 @@ describe('OCA.Files.FileList tests', function() { * that were registered on the magic upload object */ describe('file upload', function() { - var $uploader; var uploadData; + var uploader; beforeEach(function() { - // note: this isn't the real blueimp file uploader from jquery.fileupload - // but it makes it possible to simulate the event triggering to - // test the response of the handlers - $uploader = $('#file_upload_start'); fileList.setFiles(testFiles); + uploader = fileList._uploader; // simulate data structure from jquery.upload uploadData = { files: [{ @@ -2564,7 +2561,7 @@ describe('OCA.Files.FileList tests', function() { }); afterEach(function() { - $uploader = null; + uploader = null; uploadData = null; }); @@ -2588,11 +2585,7 @@ describe('OCA.Files.FileList tests', function() { * @return event object including the result */ function addFile(data) { - var ev = new $.Event('fileuploadadd', {}); - // using triggerHandler instead of trigger so we can pass - // extra data - $uploader.triggerHandler(ev, data || {}); - return ev; + uploader.trigger('add', {}, data || {}); } it('sets target dir to the current directory', function() { @@ -2614,11 +2607,8 @@ describe('OCA.Files.FileList tests', function() { target: $target } }; - var ev = new $.Event('fileuploaddrop', eventData); - // using triggerHandler instead of trigger so we can pass - // extra data - $uploader.triggerHandler(ev, data || {}); - return ev; + uploader.trigger('drop', eventData, data || {}); + return !!data.targetDir; } it('drop on a tr or crumb outside file list does not trigger upload', function() { @@ -2626,62 +2616,62 @@ describe('OCA.Files.FileList tests', function() { var ev; $('#testArea').append($anotherTable); ev = dropOn($anotherTable.find('tr'), uploadData); - expect(ev.result).toEqual(false); + expect(ev).toEqual(false); - ev = dropOn($anotherTable.find('.crumb')); - expect(ev.result).toEqual(false); + ev = dropOn($anotherTable.find('.crumb'), uploadData); + expect(ev).toEqual(false); }); it('drop on an element outside file list container does not trigger upload', function() { var $anotherEl = $('
outside
'); var ev; $('#testArea').append($anotherEl); - ev = dropOn($anotherEl); + ev = dropOn($anotherEl, uploadData); - expect(ev.result).toEqual(false); + expect(ev).toEqual(false); }); it('drop on an element inside the table triggers upload', function() { var ev; ev = dropOn(fileList.$fileList.find('th:first'), uploadData); - expect(ev.result).not.toEqual(false); + expect(ev).not.toEqual(false); expect(uploadData.targetDir).toEqual('/subdir'); }); it('drop on an element on the table container triggers upload', function() { var ev; ev = dropOn($('#app-content-files'), uploadData); - expect(ev.result).not.toEqual(false); + expect(ev).not.toEqual(false); expect(uploadData.targetDir).toEqual('/subdir'); }); it('drop on an element inside the table does not trigger upload if no upload permission', function() { $('#permissions').val(0); var ev; - ev = dropOn(fileList.$fileList.find('th:first')); + ev = dropOn(fileList.$fileList.find('th:first'), uploadData); - expect(ev.result).toEqual(false); + expect(ev).toEqual(false); expect(notificationStub.calledOnce).toEqual(true); }); it('drop on an folder does not trigger upload if no upload permission on that folder', function() { var $tr = fileList.findFileEl('somedir'); var ev; $tr.data('permissions', OC.PERMISSION_READ); - ev = dropOn($tr); + ev = dropOn($tr, uploadData); - expect(ev.result).toEqual(false); + expect(ev).toEqual(false); expect(notificationStub.calledOnce).toEqual(true); }); it('drop on a file row inside the table triggers upload to current folder', function() { var ev; ev = dropOn(fileList.findFileEl('One.txt').find('td:first'), uploadData); - expect(ev.result).not.toEqual(false); + expect(ev).not.toEqual(false); expect(uploadData.targetDir).toEqual('/subdir'); }); it('drop on a folder row inside the table triggers upload to target folder', function() { var ev; ev = dropOn(fileList.findFileEl('somedir').find('td:eq(2)'), uploadData); - expect(ev.result).not.toEqual(false); + expect(ev).not.toEqual(false); expect(uploadData.targetDir).toEqual('/subdir/somedir'); }); it('drop on a breadcrumb inside the table triggers upload to target folder', function() { @@ -2689,7 +2679,7 @@ describe('OCA.Files.FileList tests', function() { fileList.changeDirectory('a/b/c/d'); ev = dropOn(fileList.$el.find('.crumb:eq(2)'), uploadData); - expect(ev.result).not.toEqual(false); + expect(ev).not.toEqual(false); expect(uploadData.targetDir).toEqual('/a/b'); }); it('renders upload indicator element for folders only', function() { @@ -2713,8 +2703,7 @@ describe('OCA.Files.FileList tests', function() { it('fetches folder info', function() { var fetchInfoStub = sinon.stub(fileList, 'addAndFetchFileInfo'); - var ev = new $.Event('fileuploadcreatedfolder', {}); - $uploader.triggerHandler(ev, '/subdir/newfolder'); + uploader.trigger('createdfolder', {}, '/subdir/newfolder'); expect(fetchInfoStub.calledOnce).toEqual(true); expect(fetchInfoStub.getCall(0).args[0]).toEqual('newfolder'); @@ -2737,19 +2726,16 @@ describe('OCA.Files.FileList tests', function() { function createUpload(name, dir) { - var data = { - files: [{ - name: name - }], - upload: { - getFileName: sinon.stub().returns(name), - getFullPath: sinon.stub().returns(dir) - }, - jqXHR: { - status: 200 + var jqXHR = { + status: 200 + }; + return { + getFileName: sinon.stub().returns(name), + getFullPath: sinon.stub().returns(dir), + data: { + jqXHR: jqXHR } - } - return data; + }; } /** @@ -2758,12 +2744,12 @@ describe('OCA.Files.FileList tests', function() { * @return event object including the result */ function addFile(data) { - var ev = new $.Event('fileuploaddone', {}); - // using triggerHandler instead of trigger so we can pass - // extra data + var ev = new $.Event('done', { + jqXHR: {status: 200} + }); var deferred = $.Deferred(); fetchInfoStub.returns(deferred.promise()); - $uploader.triggerHandler(ev, data || {}); + uploader.trigger('done', ev, data || {}); return deferred; } @@ -2778,7 +2764,7 @@ describe('OCA.Files.FileList tests', function() { var def1 = addFile(createUpload('upload.txt', '/subdir')); var def2 = addFile(createUpload('upload2.txt', '/subdir')); var def3 = addFile(createUpload('upload3.txt', '/another')); - $uploader.triggerHandler(new $.Event('fileuploadstop')); + uploader.trigger('stop', {}); expect(highlightStub.notCalled).toEqual(true); def1.resolve(); @@ -2794,7 +2780,7 @@ describe('OCA.Files.FileList tests', function() { var statStub = sinon.stub(fileList, 'updateStorageStatistics'); addFile(createUpload('upload.txt', '/subdir')); expect(statStub.notCalled).toEqual(true); - $uploader.triggerHandler(new $.Event('fileuploadstop')); + uploader.trigger('stop', {}); expect(statStub.calledOnce).toEqual(true); statStub.restore(); }); From a89f4f027f705c26d429071314ee985a73250dd2 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Sat, 3 Sep 2016 18:06:35 +0200 Subject: [PATCH 6/6] More fixes to file upload --- apps/files/js/file-upload.js | 28 +++++++++++--------- apps/files/js/filelist.js | 24 +++++++++++------ apps/files/tests/js/filelistSpec.js | 2 +- core/js/files/client.js | 41 ++++++++++++++++++++++++++++- 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/apps/files/js/file-upload.js b/apps/files/js/file-upload.js index 40595fbe44e4..b82f78d23bca 100644 --- a/apps/files/js/file-upload.js +++ b/apps/files/js/file-upload.js @@ -204,6 +204,14 @@ OC.FileUpload.prototype = { this.data.headers['X-OC-Mtime'] = file.lastModified / 1000; } + var userName = this.uploader.filesClient.getUserName(); + var password = this.uploader.filesClient.getPassword(); + if (userName) { + // copy username/password from DAV client + this.data.headers['Authorization'] = + 'Basic ' + btoa(userName + ':' + (password || '')); + } + if (!this.uploader.isXHRUpload()) { data.formData = []; @@ -222,7 +230,7 @@ OC.FileUpload.prototype = { && this.getFile().size > this.uploader.fileUploadParam.maxChunkSize ) { data.isChunked = true; - chunkFolderPromise = this.uploader.davClient.createDirectory( + chunkFolderPromise = this.uploader.filesClient.createDirectory( 'uploads/' + encodeURIComponent(OC.getCurrentUser().uid) + '/' + encodeURIComponent(this.getId()) ); // TODO: if fails, it means same id already existed, need to retry @@ -248,7 +256,7 @@ OC.FileUpload.prototype = { } var uid = OC.getCurrentUser().uid; - return this.uploader.davClient.move( + return this.uploader.filesClient.move( 'uploads/' + encodeURIComponent(uid) + '/' + encodeURIComponent(this.getId()) + '/.file', 'files/' + encodeURIComponent(uid) + '/' + OC.joinPaths(this.getFullPath(), this.getFileName()) ); @@ -260,7 +268,7 @@ OC.FileUpload.prototype = { abort: function() { if (this.data.isChunked) { // delete transfer directory for this upload - this.uploader.davClient.remove( + this.uploader.filesClient.remove( 'uploads/' + encodeURIComponent(OC.getCurrentUser().uid) + '/' + encodeURIComponent(this.getId()) ); } @@ -343,7 +351,7 @@ OC.Uploader.prototype = _.extend({ /** * @type Array */ - _uploads: [], + _uploads: {}, /** * List of directories known to exist. @@ -586,7 +594,7 @@ OC.Uploader.prototype = _.extend({ onReplace:function(upload) { this.log('replace', null, upload); upload.setConflictMode(OC.FileUpload.CONFLICT_MODE_OVERWRITE); - upload.submit(); + this.submitUploads([upload]); }, /** * handle uploading a file and letting the server decide a new name @@ -595,7 +603,7 @@ OC.Uploader.prototype = _.extend({ onAutorename:function(upload) { this.log('autorename', null, upload); upload.setConflictMode(OC.FileUpload.CONFLICT_MODE_AUTORENAME); - upload.submit(); + this.submitUploads([upload]); }, _trace:false, //TODO implement log handler for JS per class? log:function(caption, e, data) { @@ -706,19 +714,13 @@ OC.Uploader.prototype = _.extend({ this.fileList = options.fileList; this.filesClient = options.filesClient || OC.Files.getClient(); - this.davClient = new OC.Files.Client({ - host: OC.getHost(), - port: OC.getPort(), - root: OC.getRootPath() + '/remote.php/dav/', - useHTTPS: OC.getProtocol() === 'https' - }); $uploadEl = $($uploadEl); this.$uploadEl = $uploadEl; if ($uploadEl.exists()) { $('#uploadprogresswrapper .stop').on('click', function() { - this.cancelUploads(); + self.cancelUploads(); }); this.fileUploadParam = { diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index 53272373ccda..cdb6f86e6499 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -2747,8 +2747,13 @@ if (OC.isSamePath(OC.dirname(upload.getFullPath() + '/'), self.getCurrentDirectory())) { self._uploads[fileName] = fetchInfoPromise; } + + var uploadText = self.$fileList.find('tr .uploadtext'); + self.showFileBusyState(uploadText.closest('tr'), false); + uploadText.fadeOut(); + uploadText.attr('currentUploads', 0); }); - uploader.on('createdfolder', function(e, fullPath) { + uploader.on('createdfolder', function(fullPath) { self.addAndFetchFileInfo(OC.basename(fullPath), OC.dirname(fullPath)); }); uploader.on('stop', function() { @@ -2766,6 +2771,11 @@ self.highlightFiles(fileNames); }); self.updateStorageStatistics(); + + var uploadText = self.$fileList.find('tr .uploadtext'); + self.showFileBusyState(uploadText.closest('tr'), false); + uploadText.fadeOut(); + uploadText.attr('currentUploads', 0); }); uploader.on('fail', function(e, data) { self._uploader.log('filelist handle fileuploadfail', e, data); @@ -2773,13 +2783,11 @@ self._uploads = []; //if user pressed cancel hide upload chrome - if (data.errorThrown === 'abort') { - //cleanup uploading to a dir - var uploadText = self.$fileList.find('tr .uploadtext'); - self.showFileBusyState(uploadText.closest('tr'), false); - uploadText.fadeOut(); - uploadText.attr('currentUploads', 0); - } + //cleanup uploading to a dir + var uploadText = self.$fileList.find('tr .uploadtext'); + self.showFileBusyState(uploadText.closest('tr'), false); + uploadText.fadeOut(); + uploadText.attr('currentUploads', 0); self.updateStorageStatistics(); }); diff --git a/apps/files/tests/js/filelistSpec.js b/apps/files/tests/js/filelistSpec.js index 030063c3859c..088b7a9ef472 100644 --- a/apps/files/tests/js/filelistSpec.js +++ b/apps/files/tests/js/filelistSpec.js @@ -2703,7 +2703,7 @@ describe('OCA.Files.FileList tests', function() { it('fetches folder info', function() { var fetchInfoStub = sinon.stub(fileList, 'addAndFetchFileInfo'); - uploader.trigger('createdfolder', {}, '/subdir/newfolder'); + uploader.trigger('createdfolder', '/subdir/newfolder'); expect(fetchInfoStub.calledOnce).toEqual(true); expect(fetchInfoStub.getCall(0).args[0]).toEqual('newfolder'); diff --git a/core/js/files/client.js b/core/js/files/client.js index 0bf5a69e19c9..94cc98c5047e 100644 --- a/core/js/files/client.js +++ b/core/js/files/client.js @@ -705,8 +705,47 @@ */ addFileInfoParser: function(parserFunction) { this._fileInfoParsers.push(parserFunction); - } + }, + /** + * Returns the dav.Client instance used internally + * + * @since 9.2 + * @return {dav.Client} + */ + getClient: function() { + return this._client; + }, + + /** + * Returns the user name + * + * @since 9.2 + * @return {String} userName + */ + getUserName: function() { + return this._client.userName; + }, + + /** + * Returns the password + * + * @since 9.2 + * @return {String} password + */ + getPassword: function() { + return this._client.password; + }, + + /** + * Returns the base URL + * + * @since 9.2 + * @return {String} base URL + */ + getBaseUrl: function() { + return this._client.baseUrl; + } }; /**