From 59c5be1cc572793a8d50e87ab589e1cc4cf2ed12 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Wed, 16 Dec 2015 17:35:53 +0100 Subject: [PATCH] Use Webdav PUT for uploads in the web browser - uses PUT method with jquery.fileupload for regular and public file lists - for IE and browsers that don't support it, use POST with iframe transport - implemented Sabre plugin to handle iframe transport and redirect the embedded PUT request to the proper handler - added RFC5995 POST to file collection with "add-member" property to make it possible to auto-rename conflicting file names - remove obsolete ajax/upload.php and obsolete ajax routes Signed-off-by: Roeland Jago Douma --- 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 | 283 ------- apps/files/appinfo/routes.php | 12 - apps/files/js/app.js | 1 + apps/files/js/file-upload.js | 790 +++++++++++++----- apps/files/js/filelist.js | 314 ++++--- 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, 1516 insertions(+), 802 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 aa5bacea5bb..39d15e0c6e9 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -46,6 +46,8 @@ use \Sabre\HTTP\ResponseInterface; use OCP\Files\StorageNotAvailableException; use OCP\IConfig; use OCP\IRequest; +use Sabre\DAV\Exception\BadRequest; +use OCA\DAV\Connector\Sabre\Directory; class FilesPlugin extends ServerPlugin { @@ -170,6 +172,8 @@ class FilesPlugin extends ServerPlugin { $this->server = $server; $this->server->on('propFind', array($this, 'handleGetProperties')); $this->server->on('propPatch', array($this, 'handleUpdateProperties')); + // RFC5995 to add file to the collection with a suggested name + $this->server->on('method:POST', [$this, 'httpPost']); $this->server->on('afterBind', array($this, 'sendFileIdHeader')); $this->server->on('afterWriteContent', array($this, 'sendFileIdHeader')); $this->server->on('afterMethod:GET', [$this,'httpGet']); @@ -432,4 +436,51 @@ class FilesPlugin extends ServerPlugin { } } + /** + * POST operation on directories to create a new file + * with suggested name + * + * @param RequestInterface $request request object + * @param ResponseInterface $response response object + * @return null|false + */ + public function httpPost(RequestInterface $request, ResponseInterface $response) { + // TODO: move this to another plugin ? + if (!\OC::$CLI && !\OC::$server->getRequest()->passesCSRFCheck()) { + throw new BadRequest('Invalid CSRF token'); + } + + list($parentPath, $name) = \Sabre\HTTP\URLUtil::splitPath($request->getPath()); + + // Making sure the parent node exists and is a directory + $node = $this->tree->getNodeForPath($parentPath); + + if ($node instanceof Directory) { + // no Add-Member found + if (empty($name) || $name[0] !== '&') { + // suggested name required + throw new BadRequest('Missing suggested file name'); + } + + $name = substr($name, 1); + + if (empty($name)) { + // suggested name required + throw new BadRequest('Missing suggested file name'); + } + + // make sure the name is unique + $name = basename(\OC_Helper::buildNotExistingFileNameForView($parentPath, $name, $this->fileView)); + + $node->createFile($name, $request->getBodyAsStream()); + + list($parentUrl, ) = \Sabre\HTTP\URLUtil::splitPath($request->getUrl()); + + $response->setHeader('Content-Location', $parentUrl . '/' . rawurlencode($name)); + + // created + $response->setStatus(201); + return false; + } + } } diff --git a/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php b/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php new file mode 100644 index 00000000000..af6e5a62a5e --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/IFrameTransportPlugin.php @@ -0,0 +1,188 @@ + + * + * @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 6d9f9b1bc8b..da541abc199 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -114,6 +114,7 @@ class ServerFactory { // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to / $server->addPlugin(new \OCA\DAV\Connector\Sabre\DummyGetResponsePlugin()); $server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $this->logger)); + $server->addPlugin(new \OCA\DAV\Connector\Sabre\IFrameTransportPlugin()); $server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); // Some WebDAV clients do require Class 2 WebDAV support (locking), since // we do not provide locking we emulate it using a fake locking plugin. diff --git a/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php index 282a5b2f626..43ca119abff 100644 --- a/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/FilesPluginTest.php @@ -123,7 +123,7 @@ class FilesPluginTest extends TestCase { * @param string $class * @return \PHPUnit_Framework_MockObject_MockObject */ - private function createTestNode($class) { + private function createTestNode($class, $path = '/dummypath') { $node = $this->getMockBuilder($class) ->disableOriginalConstructor() ->getMock(); @@ -134,7 +134,7 @@ class FilesPluginTest extends TestCase { $this->tree->expects($this->any()) ->method('getNodeForPath') - ->with('/dummypath') + ->with($path) ->will($this->returnValue($node)); $node->expects($this->any()) @@ -547,4 +547,85 @@ class FilesPluginTest extends TestCase { $this->assertEquals("false", $propFind->get(self::HAS_PREVIEW_PROPERTYNAME)); } + + public function postCreateFileProvider() { + $baseUrl = 'http://example.com/owncloud/remote.php/webdav/subdir/'; + return [ + ['test.txt', 'some file.txt', 'some file.txt', $baseUrl . 'some%20file.txt'], + ['some file.txt', 'some file.txt', 'some file (2).txt', $baseUrl . 'some%20file%20%282%29.txt'], + ]; + } + + /** + * @dataProvider postCreateFileProvider + */ + public function testPostWithAddMember($existingFile, $wantedName, $deduplicatedName, $expectedLocation) { + $request = $this->getMock('Sabre\HTTP\RequestInterface'); + $response = $this->getMock('Sabre\HTTP\ResponseInterface'); + + $request->expects($this->any()) + ->method('getUrl') + ->will($this->returnValue('http://example.com/owncloud/remote.php/webdav/subdir/&' . $wantedName)); + + $request->expects($this->any()) + ->method('getPath') + ->will($this->returnValue('/subdir/&' . $wantedName)); + + $request->expects($this->once()) + ->method('getBodyAsStream') + ->will($this->returnValue(fopen('data://text/plain,hello', 'r'))); + + $this->view->expects($this->any()) + ->method('file_exists') + ->will($this->returnCallback(function($path) use ($existingFile) { + return ($path === '/subdir/' . $existingFile); + })); + + $node = $this->createTestNode('\OCA\DAV\Connector\Sabre\Directory', '/subdir'); + + $node->expects($this->once()) + ->method('createFile') + ->with($deduplicatedName, $this->isType('resource')); + + $response->expects($this->once()) + ->method('setStatus') + ->with(201); + $response->expects($this->once()) + ->method('setHeader') + ->with('Content-Location', $expectedLocation); + + $this->assertFalse($this->plugin->httpPost($request, $response)); + } + + public function testPostOnNonDirectory() { + $request = $this->getMock('Sabre\HTTP\RequestInterface'); + $response = $this->getMock('Sabre\HTTP\ResponseInterface'); + + $request->expects($this->any()) + ->method('getPath') + ->will($this->returnValue('/subdir/test.txt/&abc')); + + $this->createTestNode('\OCA\DAV\Connector\Sabre\File', '/subdir/test.txt'); + + $this->assertNull($this->plugin->httpPost($request, $response)); + } + + /** + * @expectedException \Sabre\DAV\Exception\BadRequest + */ + public function testPostWithoutAddMember() { + $request = $this->getMock('Sabre\HTTP\RequestInterface'); + $response = $this->getMock('Sabre\HTTP\ResponseInterface'); + + $request->expects($this->any()) + ->method('getPath') + ->will($this->returnValue('/subdir/&')); + + $node = $this->createTestNode('\OCA\DAV\Connector\Sabre\Directory', '/subdir'); + + $node->expects($this->never()) + ->method('createFile'); + + $this->plugin->httpPost($request, $response); + } } diff --git a/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php new file mode 100644 index 00000000000..485dd1b779e --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/IFrameTransportPluginTest.php @@ -0,0 +1,164 @@ + + * 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 9de5c0bce39..00000000000 --- a/apps/files/ajax/upload.php +++ /dev/null @@ -1,283 +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 - * - * @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; -$errorFileName = 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; - } - - if(isset($_POST['dirToken'])) { - // If it is a read only share the resolution will always be autorename - $shareManager = \OC::$server->getShareManager(); - $share = $shareManager->getShareByToken((string)$_POST['dirToken']); - if (!($share->getPermissions() & \OCP\Constants::PERMISSION_READ)) { - $resolution = 'autorename'; - } - } - - // 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'); - $errorFileName = $files['name'][$i]; - } - } 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) { - // Do not leak file information if it is a read-only share - if(isset($_POST['dirToken'])) { - $shareManager = \OC::$server->getShareManager(); - $share = $shareManager->getShareByToken((string)$_POST['dirToken']); - if (!($share->getPermissions() & \OCP\Constants::PERMISSION_READ)) { - $newResults = []; - foreach($result as $singleResult) { - $fileName = $singleResult['originalname']; - $newResults['filename'] = $fileName; - $newResults['mimetype'] = \OC::$server->getMimeTypeDetector()->detectPath($fileName); - } - $result = $newResults; - } - } - OCP\JSON::encodedPrint($result); -} else { - OCP\JSON::error(array(array('data' => array_merge(array( - 'message' => $error, - 'code' => $errorCode, - 'filename' => $errorFileName - ), $storageStats)))); -} diff --git a/apps/files/appinfo/routes.php b/apps/files/appinfo/routes.php index 49dbe553435..06d8d39585f 100644 --- a/apps/files/appinfo/routes.php +++ b/apps/files/appinfo/routes.php @@ -75,24 +75,12 @@ $application->registerRoutes( /** @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 fbfa510e07e..17e92de90dd 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 56ea384c9e0..0d45623ce65 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: [], + /** - * deletes the jqHXR object from a data selection - * @param {object} data + * 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 */ - deleteUpload:function(data) { - delete data.jqXHR; + 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; + }); + }, + + /** + * Show conflict for the given file object + * + * @param {OC.FileUpload} file upload object + */ + 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,34 @@ 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: $('#app-content'), // restrict dropZone to app-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 +687,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 +713,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 +733,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(); @@ -390,7 +792,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 @@ -405,9 +807,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 @@ -425,7 +825,7 @@ OC.Upload = { } }; - OC.Upload.checkExistingFiles(selection, callbacks); + self.checkExistingFiles(selection, callbacks); } @@ -436,106 +836,54 @@ 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; - } - - var params = { - requesttoken: oc_requesttoken, - dir: data.targetDir || FileList.getCurrentDirectory(), - file_directory: fileDirectory, - }; - if (data.files[0].isReceivedShare) { - params.isReceivedShare = true; - } - - 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: escapeHTML(data.files[0].name), - message: data.errorThrown - }, undefined, {escape: false}); - 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(); - } - } - } + var upload = self.getUpload(data); + var status = upload.getResponseStatus(); + self.log('fail', e, upload); + + 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}); } - OC.Upload.deleteUpload(data); + upload.deleteUpload(); }, /** * called for every successful upload * @param {object} e * @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); + done:function(e, data) { + 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; } }, /** @@ -544,15 +892,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; @@ -561,11 +908,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(); @@ -573,7 +921,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}); @@ -584,14 +932,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 @@ -608,14 +956,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), @@ -626,12 +974,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 @@ -653,14 +1001,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(); } }); var disableDropState = function() { @@ -715,36 +1065,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 159d008e6e6..43cb3bac64b 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) { @@ -188,6 +189,11 @@ _dragOptions: null, _folderDropOptions: null, + /** + * @type OC.Uploader + */ + _uploader: null, + /** * Initialize the file list and its components * @@ -328,8 +334,6 @@ this.$el.find('.selectedActions a').tooltip({placement:'top'}); - this.setupUploadEvents(); - this.$container.on('scroll', _.bind(this._onScroll, this)); if (options.scrollTo) { @@ -338,6 +342,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); }, @@ -1420,7 +1438,10 @@ return; } this._setCurrentDir(targetDir, changeUrl, fileId); - return this.reload().then(function(success){ + + // 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); } @@ -1660,6 +1681,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 @@ -2121,19 +2160,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) { @@ -2174,32 +2205,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}) ); @@ -2221,6 +2239,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 * @@ -2590,18 +2662,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; - var delegatedElement = '#file_upload_start'; + self._uploads = {}; // detect the progress bar resize - fileUploadStart.on('resized', this._onResize); + $uploadEl.on('resized', this._onResize); - fileUploadStart.on('fileuploaddrop', delegatedElement, 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 @@ -2664,13 +2734,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') { @@ -2692,126 +2757,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 2873b84bc9a..0be098b2e73 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 dda7a1814a0..e741849f38b 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 0483d4649d4..bfaf0a9fe57 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 304f8438a59..55ab2c535af 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() { @@ -2441,7 +2442,7 @@ describe('OCA.Files.FileList tests', function() { deferredInfo.resolve( 200, - new FileInfo({ + new FileInfo({ path: '/subdir', name: 'test.txt', mimetype: 'text/plain' @@ -2501,12 +2502,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 @@ -2514,14 +2573,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 @@ -2540,17 +2637,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; @@ -2574,12 +2660,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); @@ -2603,6 +2691,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; @@ -2635,6 +2724,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 0dfff235998..0045c7156d2 100644 --- a/apps/files_sharing/js/public.js +++ b/apps/files_sharing/js/public.js @@ -72,7 +72,8 @@ OCA.Sharing.PublicApp = { folderDropOptions: folderDropOptions, fileActions: fileActions, detailsViewEnabled: false, - filesClient: filesClient + filesClient: filesClient, + enableUpload: true } ); this.files = OCA.Files.Files; @@ -170,6 +171,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; @@ -203,20 +228,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 58565744882..e8ec9899ecc 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'); + }); + }); }); }); -- 2.39.5