diff --git a/appinfo/routes.php b/appinfo/routes.php index 0f9d016f..f1957572 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -31,5 +31,10 @@ 'name' => 'FileHandling#save', 'url' => '/ajax/savefile', 'verb' => 'PUT' + ], + [ + 'name' => 'FileHandling#close', + 'url' => '/ajax/closefile', + 'verb' => 'PUT' ] ]]; diff --git a/composer.json b/composer.json index 6b884aa1..65efa676 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ } }, "require": { + "firebase/php-jwt": "^5.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.4" diff --git a/composer.lock b/composer.lock index c01f267d..bf12c5db 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,66 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0745b7c318b334ff0a6437c3c1f19082", - "packages": [], + "content-hash": "b2969793a9877834a9fca7c9068236fd", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v5.5.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "83b609028194aa042ea33b5af2d41a7427de80e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/83b609028194aa042ea33b5af2d41a7427de80e6", + "reference": "83b609028194aa042ea33b5af2d41a7427de80e6", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": ">=4.8 <=9" + }, + "suggest": { + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v5.5.1" + }, + "time": "2021-11-08T20:18:51+00:00" + } + ], "packages-dev": [ { "name": "bamarni/composer-bin-plugin", diff --git a/controller/filehandlingcontroller.php b/controller/filehandlingcontroller.php index 23530702..176fd67c 100644 --- a/controller/filehandlingcontroller.php +++ b/controller/filehandlingcontroller.php @@ -30,6 +30,8 @@ use OCP\Files\File; use OCP\Files\ForbiddenException; use OCP\Files\IRootFolder; +use OCP\Files\Storage\IPersistentLockingStorage; +use OCP\Lock\Persistent\ILock; use OCP\IL10N; use OCP\ILogger; use OCP\IRequest; @@ -37,8 +39,11 @@ use OCP\Lock\LockedException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; + use Sabre\DAV\Exception\NotFound; +use Firebase\JWT\JWT; + class FileHandlingController extends Controller { /** @var IL10N */ @@ -125,7 +130,23 @@ public function load($dir, $filename) { if ($fileContents !== false) { $permissions = $this->getPermissions($node); - $writable = ($permissions & Constants::PERMISSION_UPDATE) === Constants::PERMISSION_UPDATE; + + // handle locks + $activePersistentLock = $this->getPersistentLock($node); + if ($activePersistentLock && !$this->verifyPersistentLock($node, $activePersistentLock)) { + // there is lock existing on this file + // and thus this user cannot write to this file + $writable = false; + } else { + // check if permissions allow writing + $writable = ($permissions & Constants::PERMISSION_UPDATE) === Constants::PERMISSION_UPDATE; + } + + if ($writable) { + // get new/refresh write lock for the user + $activePersistentLock = $this->acquirePersistentLock($node); + } + $mime = $node->getMimeType(); $mTime = $node->getMTime(); $encoding = \mb_detect_encoding($fileContents . "a", "UTF-8, GB2312, GBK ,BIG5, WINDOWS-1252, SJIS-win, EUC-JP, ISO-8859-15, ISO-8859-1, ASCII", true); @@ -138,6 +159,7 @@ public function load($dir, $filename) { [ 'filecontents' => $fileContents, 'writeable' => $writable, + 'locked' => $activePersistentLock ? $activePersistentLock->getOwner() : null, 'mime' => $mime, 'mtime' => $mTime ], @@ -198,6 +220,16 @@ public function save($path, $filecontents, $mtime) { // Get file mtime $filemtime = $node->getMTime(); + // Check lock (if there is any) + $activePersistentLock = $this->getPersistentLock($node); + if ($activePersistentLock && !$this->verifyPersistentLock($node, $activePersistentLock)) { + // Then the file has persistent lock acquired + return new DataResponse( + ['message' => $this->l->t('Cannot save file as it is locked by %s.', [$activePersistentLock->getOwner()])], + Http::STATUS_BAD_REQUEST + ); + } + if ($mtime !== $filemtime) { // Then the file has changed since opening $this->logger->error( @@ -211,6 +243,10 @@ public function save($path, $filecontents, $mtime) { } else { // File same as when opened, save file if (($permissions & Constants::PERMISSION_UPDATE) === Constants::PERMISSION_UPDATE) { + // Refresh (or aquire for expired) lock for the user for further writing + $activePersistentLock = $this->acquirePersistentLock($node); + + // Write file $filecontents = \iconv(\mb_detect_encoding($filecontents), "UTF-8", $filecontents); try { $node->putContent($filecontents); @@ -220,8 +256,10 @@ public function save($path, $filecontents, $mtime) { } catch (ForbiddenException $e) { return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } + // Clear statcache \clearstatcache(); + // Get new mtime $newmtime = $node->getMTime(); $newsize = $node->getSize(); @@ -254,6 +292,56 @@ public function save($path, $filecontents, $mtime) { } } + /** + * close text file + * + * @NoAdminRequired + * @NoSubadminRequired + * @PublicPage + * @NoCSRFRequired + * + * @param string $path + * @return DataResponse + */ + public function close($path) { + try { + if ($path !== '') { + try { + $node = $this->getNode($path); + } catch (ShareNotFound $e) { + return new DataResponse( + ['message' => $this->l->t('Invalid share token')], + Http::STATUS_BAD_REQUEST + ); + } catch (NoUserException $e) { + return new DataResponse( + ['message' => $this->l->t('No user found')], + Http::STATUS_BAD_REQUEST + ); + } + + // Check lock (if there is any) + $activePersistentLock = $this->getPersistentLock($node); + if ($activePersistentLock && $this->verifyPersistentLock($node, $activePersistentLock)) { + // Clear lock on close + $this->releasePersistentLock($node, $activePersistentLock); + } + + // Done + return new DataResponse([], Http::STATUS_OK); + } else { + $this->logger->error('No file path supplied'); + return new DataResponse(['message' => $this->l->t('File path not supplied')], Http::STATUS_BAD_REQUEST); + } + } catch (HintException $e) { + $message = (string)$e->getHint(); + return new DataResponse(['message' => $message], Http::STATUS_BAD_REQUEST); + } catch (\Exception $e) { + $message = (string)$this->l->t('An internal server error occurred.'); + return new DataResponse(['message' => $message], Http::STATUS_BAD_REQUEST); + } + } + private function getNode(string $path): File { $sharingToken = $this->request->getParam('sharingToken'); @@ -279,7 +367,7 @@ private function getNode(string $path): File { return $node; } - private function getPermissions($node): int { + private function getPermissions(File $node): int { $sharingToken = $this->request->getParam('sharingToken'); if ($sharingToken) { @@ -289,4 +377,126 @@ private function getPermissions($node): int { return $node->getPermissions(); } + private function acquirePersistentLock(File $file): ?ILock { + $storage = $file->getStorage(); + if ($storage->instanceOfStorage(IPersistentLockingStorage::class)) { + $sharingToken = $this->request->getParam('sharingToken'); + + if ($sharingToken) { + $accessToken = $this->getTokenForPublicLinkAccess( + $file->getId(), + $file->getParent()->getPath(), + $sharingToken + ); + $owner = $this->l->t('Public Link User via Text Editor'); + } else { + $user = $this->userSession->getUser(); + if (!$user) { + return null; + } + $accessToken = $this->getTokenForUserAccess( + $file->getId(), + $file->getParent()->getPath(), + $user->getUID() + ); + $owner = $this->l->t('%s via Text Editor', [$user->getDisplayName()]); + } + + /** + * @var IPersistentLockingStorage $storage + * @phpstan-ignore-next-line + */ + '@phan-var IPersistentLockingStorage $storage'; + return $storage->lockNodePersistent($file->getInternalPath(), [ + 'token' => $accessToken, + 'owner' => $owner + ]); + } + + return null; + } + + private function getPersistentLock(File $file): ?ILock { + $storage = $file->getStorage(); + if ($storage->instanceOfStorage(IPersistentLockingStorage::class)) { + /** + * @var IPersistentLockingStorage $storage + * @phpstan-ignore-next-line + */ + '@phan-var IPersistentLockingStorage $storage'; + $locks = $storage->getLocks($file->getInternalPath(), false); + if (\count($locks) > 0) { + // use active lock (first returned) + return $locks[0]; + } + } + + return null; + } + + private function verifyPersistentLock(File $file, ILock $lock): bool { + $storage = $file->getStorage(); + if ($storage->instanceOfStorage(IPersistentLockingStorage::class)) { + $sharingToken = $this->request->getParam('sharingToken'); + + if ($sharingToken) { + $accessToken = $this->getTokenForPublicLinkAccess( + $file->getId(), + $file->getParent()->getPath(), + $sharingToken + ); + } else { + $user = $this->userSession->getUser(); + if (!$user) { + return false; + } + $accessToken = $this->getTokenForUserAccess( + $file->getId(), + $file->getParent()->getPath(), + $user->getUID() + ); + } + + // token in the lock should match access token for this user/share + return $lock->getToken() === $accessToken; + } + + return false; + } + + private function releasePersistentLock(File $file, ILock $lock): bool { + $storage = $file->getStorage(); + if ($storage->instanceOfStorage(IPersistentLockingStorage::class)) { + /** + * @var IPersistentLockingStorage $storage + * @phpstan-ignore-next-line + */ + '@phan-var IPersistentLockingStorage $storage'; + return $storage->unlockNodePersistent($file->getInternalPath(), [ + 'token' => $lock->getToken() + ]); + } + + return false; + } + + private function getTokenForUserAccess(int $fileId, string $fileParentPath, string $userId): string { + // as this app is not collaborative, the token is static + return JWT::encode([ + 'uid' => $userId, + 'st' => '', + 'fid' => $fileId, + 'fpp' => $fileParentPath, + ], 'files_texteditor', 'HS256'); + } + + private function getTokenForPublicLinkAccess(int $fileId, string $fileParentPath, string $sharingToken): string { + // as this app is not collaborative, the token is static + return JWT::encode([ + 'uid' => '', + 'st' => $sharingToken, + 'fid' => $fileId, + 'fpp' => $fileParentPath, + ], 'files_texteditor', 'HS256'); + } } diff --git a/js/editor.js b/js/editor.js index d259d29e..8f201a1d 100644 --- a/js/editor.js +++ b/js/editor.js @@ -24,6 +24,7 @@ var Files_Texteditor = { * Stores info on the file being edited */ file: { + opened: false, edited: false, mtime: null, dir: null, @@ -134,12 +135,18 @@ var Files_Texteditor = { }, /** - * Handles on close button click + * Handles editor closing action */ _onCloseTrigger: function() { // Hide or close? if(!OCA.Files_Texteditor.file.edited) { + OCA.Files_Texteditor.closeFile( + OCA.Files_Texteditor.file, + function(data){}, + function(message){} + ); OCA.Files_Texteditor.closeEditor(); + OCA.Files_Texteditor.file.opened = false; } else { // Trick the autosave attempt into thinking we have no changes OCA.Files_Texteditor.file.edited = false; @@ -155,8 +162,16 @@ var Files_Texteditor = { 'Saved' ) ) + // Try to close + OCA.Files_Texteditor.closeFile( + OCA.Files_Texteditor.file, + function(data){}, + function(message){} + ); // Remove the editor OCA.Files_Texteditor.closeEditor(); + // Mark not opened + OCA.Files_Texteditor.file.opened = false; }, function(message){ OC.Notification.showTemporary(t( @@ -217,6 +232,25 @@ var Files_Texteditor = { } }, + /** + * Handler for window close detected + */ + _onWindowClose: function (e) { + if(!OCA.Files_Texteditor.file.opened) { + // just close + return; + } + + // inform user this could lead to changes lost or lock remaining + var message = t('files_texteditor','Editor has not been closed. Changes might be lost and file will remain locked for editing'); + var e = e || window.event; + if (e) { + e.preventDefault(); // required in some browsers + e.returnValue = message; // required in some browsers + } + return message; + }, + /** * Handler when unsaved work is detected */ @@ -235,6 +269,11 @@ var Files_Texteditor = { $.each(this.previewPlugins, function(mime, plugin) { plugin.init(); }); + + $(window).bind('beforeunload', this._onWindowClose); + $(window).on('unload', function () { + $(window).trigger('beforeunload'); + }); }, /** @@ -291,6 +330,8 @@ var Files_Texteditor = { file.name, function(file, data){ // Success! + OCA.Files_Texteditor.file.opened = true; + // Sort the title document.title = file.name + ' - ' + OCA.Files_Texteditor.oldTitle; // Load ace @@ -344,6 +385,16 @@ var Files_Texteditor = { $('#editor_wrap').before(controlBar); this.setFilenameMaxLength(); this.bindControlBar(); + + if (!file.writeable && file.locked) { + $('#editor_controls small.saving-message') + .text(t('files_texteditor', 'file is read-only, locked by {locked}', {locked: file.locked})) + .show(); + } else if (!file.writeable) { + $('#editor_controls small.saving-message') + .text(t('files_texteditor', 'file is read-only')) + .show(); + } }, @@ -389,7 +440,9 @@ var Files_Texteditor = { window.aceEditor = ace.edit(this.editor); aceEditor.setShowPrintMargin(false); aceEditor.getSession().setUseWrapMode(true); - if (!file.writeable) { aceEditor.setReadOnly(true); } + if (!file.writeable) { + aceEditor.setReadOnly(true); + } if (file.mime && file.mime === 'text/html') { this.setEditorSyntaxMode('html'); } else { @@ -504,6 +557,7 @@ var Files_Texteditor = { ).done(function(data) { // Call success callback OCA.Files_Texteditor.file.writeable = data.writeable; + OCA.Files_Texteditor.file.locked = data.locked; OCA.Files_Texteditor.file.mime = data.mime; OCA.Files_Texteditor.file.mtime = data.mtime; success(OCA.Files_Texteditor.file, data.filecontents); @@ -512,6 +566,37 @@ var Files_Texteditor = { }); }, + /** + * Close the file + */ + closeFile: function(file, success, failure) { + // Send the post request + if(file.dir == '/') { + var path = file.dir + file.name; + } else { + var path = file.dir + '/' + file.name; + } + $.ajax({ + type: 'PUT', + url: OC.generateUrl('/apps/files_texteditor/ajax/closefile'), + data: { + path: path, + sharingToken: $('#sharingToken').val() + } + }) + .done(success) + .fail(function(jqXHR) { + var message; + + try{ + message = JSON.parse(jqXHR.responseText).message; + }catch(e){ + } + + failure(message); + }); + }, + /** * Send the new file data back to the server */ diff --git a/tests/acceptance/features/bootstrap/TextEditorContext.php b/tests/acceptance/features/bootstrap/TextEditorContext.php index 5002327d..547416ec 100644 --- a/tests/acceptance/features/bootstrap/TextEditorContext.php +++ b/tests/acceptance/features/bootstrap/TextEditorContext.php @@ -100,6 +100,21 @@ public function theUserInputsTextInTheTextArea(string $text):void { ); } + /** + * @When the user inputs :text in the text area and waits for autosave + * + * @param string $text + * + * @return void + */ + public function theUserInputsTextInTheTextAreaAndWaits(string $text):void { + $this->theUserInputsTextInTheTextArea($text); + // The text editor autosaves every few seconds. + // So wait 10 seconds. We expect that the content should have been saved + // by then, and later test steps can check that the save worked. + \sleep(10); + } + /** * @When the user inputs the following text in the text area: * diff --git a/tests/acceptance/features/webUITextEditor/createTextFiles.feature b/tests/acceptance/features/webUITextEditor/createTextFiles.feature index 93d64c51..b28b5014 100644 --- a/tests/acceptance/features/webUITextEditor/createTextFiles.feature +++ b/tests/acceptance/features/webUITextEditor/createTextFiles.feature @@ -36,12 +36,11 @@ Feature: textFiles Then file "New text file.txt" should be listed on the webUI - Scenario: Create a text file with the default file extension and do not close the editor + Scenario: Create a text file with the default file extension and check the editor autosave When the user creates a text file with the name "abc" using the webUI without changing the default file extension - And the user inputs "something" in the text area - Then file "abc.txt" should be listed on the webUI - And the user reloads the current page of the webUI + And the user inputs "something" in the text area and waits for autosave Then file "abc.txt" should be listed on the webUI + And the content of file "abc.txt" for user "Alice" should be "something" Scenario: Create a text file with the default file extension and unicode file name diff --git a/tests/unit/controller/filehandlingcontrollerTest.php b/tests/unit/controller/filehandlingcontrollerTest.php index 72e3a8a9..11679dad 100644 --- a/tests/unit/controller/filehandlingcontrollerTest.php +++ b/tests/unit/controller/filehandlingcontrollerTest.php @@ -25,8 +25,18 @@ use OCA\Files_Texteditor\Controller\FileHandlingController; use OCP\Constants; use OCP\Files\ForbiddenException; +use OCP\Files\Storage\IStorage; +use OCP\Files\Storage\IPersistentLockingStorage; +use OCP\Lock\Persistent\ILock; use OCP\Lock\LockedException; +use OCP\Files\File; +use OCP\Files\Folder; use Test\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Firebase\JWT\JWT; + +interface IPersistentLockingStorageTest extends IPersistentLockingStorage, IStorage { +} class FileHandlingControllerTest extends TestCase { @@ -57,6 +67,9 @@ class FileHandlingControllerTest extends TestCase { /** @var \OCP\Files\File|\PHPUnit\Framework\MockObject\MockObject */ private $fileMock; + /** @var IStorage|\PHPUnit\Framework\MockObject\MockObject */ + private $fileStorageMock; + /** @var \OCP\IUser|\PHPUnit\Framework\MockObject\MockObject */ private $userMock; @@ -84,8 +97,10 @@ public function setUp(): void { $this->rootMock = $this->getMockBuilder('OCP\Files\IRootFolder') ->disableOriginalConstructor() ->getMock(); - - $this->fileMock = $this->getMockBuilder('OCP\Files\File') + $this->fileStorageMock = $this->getMockBuilder(IStorage::class) + ->disableOriginalConstructor() + ->getMock(); + $this->fileMock = $this->getMockBuilder(File::class) ->disableOriginalConstructor() ->getMock(); $this->userMock = $this->getMockBuilder('OCP\IUser') @@ -95,11 +110,9 @@ public function setUp(): void { ->disableOriginalConstructor() ->getMock(); - $this->l10nMock->expects($this->any())->method('t')->willReturnCallback( - function ($message) { - return $message; - } - ); + $this->l10nMock->expects($this->any())->method('t')->willReturnCallback(function ($text, $parameters = []) { + return \vsprintf($text, $parameters); + }); $this->controller = new FileHandlingController( $this->appName, @@ -129,6 +142,10 @@ public function testLoad($filename, $fileContent, $expectedStatus, $expectedMess ->method('getPermissions') ->willReturn(Constants::PERMISSION_ALL); + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); + $this->rootMock->expects($this->any()) ->method('get') ->willReturn($this->fileMock); @@ -148,9 +165,12 @@ public function testLoad($filename, $fileContent, $expectedStatus, $expectedMess if ($status === 200) { $this->assertArrayHasKey('filecontents', $data); $this->assertArrayHasKey('writeable', $data); + $this->assertArrayHasKey('locked', $data); $this->assertArrayHasKey('mime', $data); $this->assertArrayHasKey('mtime', $data); $this->assertSame($data['filecontents'], $fileContent); + $this->assertSame($data['writeable'], true); + $this->assertSame($data['locked'], null); } else { $this->assertArrayHasKey('message', $data); $this->assertSame($expectedMessage, $data['message']); @@ -192,6 +212,10 @@ public function testLoadExceptionWithException(\Exception $exception, $expectedM ->method('getPermissions') ->willReturn(Constants::PERMISSION_ALL); + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); + $this->rootMock->expects($this->any()) ->method('get') ->willReturn($this->fileMock); @@ -228,6 +252,10 @@ public function testSaveExceptionWithException(\Exception $exception, $expectedM ->method('getPermissions') ->willReturn(Constants::PERMISSION_ALL); + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); + $this->rootMock->expects($this->any()) ->method('get') ->willReturn($this->fileMock); @@ -273,6 +301,10 @@ public function testSave($path, $fileContents, $mTime, $fileMTime, $isUpdatable, ->method('getPermissions') ->willReturn($permissions); + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); + $this->rootMock->expects($this->any()) ->method('get') ->willReturn($this->fileMock); @@ -313,6 +345,10 @@ public function testFileTooBig() { $this->fileMock->expects($this->any()) ->method('getPermissions') ->willReturn(Constants::PERMISSION_ALL); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); $this->rootMock->expects($this->any()) ->method('get') @@ -368,6 +404,10 @@ public function testLoadWithShare() { $this->fileMock->expects($this->any()) ->method('getContent') ->willReturn($fileContent); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); $this->shareMock->expects($this->any()) ->method('getPermissions') @@ -384,6 +424,7 @@ public function testLoadWithShare() { $this->assertArrayHasKey('filecontents', $data); $this->assertArrayHasKey('writeable', $data); + $this->assertArrayHasKey('locked', $data); $this->assertArrayHasKey('mime', $data); $this->assertArrayHasKey('mtime', $data); $this->assertSame($data['filecontents'], $fileContent); @@ -415,6 +456,10 @@ public function testSaveWithShare() { ->method('getMTime') ->willReturn($fileMTime); + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); + $this->rootMock->expects($this->any()) ->method('get') ->willReturn($this->fileMock); @@ -429,4 +474,662 @@ public function testSaveWithShare() { $this->assertArrayHasKey('mtime', $data); $this->assertArrayHasKey('size', $data); } + + public function testLoadReadOnly() { + $filename = 'test.txt'; + $fileContent = 'test'; + $fileMTime = 65638643; + + $this->userMock->expects($this->any()) + ->method('getUID') + ->willReturn('admin'); + + $this->userSessionMock->expects($this->any()) + ->method('getUser') + ->willReturn($this->userMock); + + $this->fileMock->expects($this->any()) + ->method('getPermissions') + ->willReturn(Constants::PERMISSION_READ); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($this->fileStorageMock); + + $this->fileMock->expects($this->any()) + ->method('getContent') + ->willReturn($fileContent); + + $this->fileMock->expects($this->any()) + ->method('getMTime') + ->willReturn($fileMTime); + + $this->rootMock->expects($this->any()) + ->method('get') + ->willReturn($this->fileMock); + + $result = $this->controller->load('/', $filename); + $data = $result->getData(); + $status = $result->getStatus(); + $this->assertSame($status, 200); + + $this->assertArrayHasKey('filecontents', $data); + $this->assertArrayHasKey('writeable', $data); + $this->assertArrayHasKey('locked', $data); + $this->assertArrayHasKey('mime', $data); + $this->assertArrayHasKey('mtime', $data); + $this->assertSame($data['filecontents'], $fileContent); + $this->assertSame($data['writeable'], false); + $this->assertSame($data['locked'], null); + } + + public function dataLoadAcquirePersistentLock() { + return [ + [Constants::PERMISSION_ALL, null, true, 'test@test.com'], + [Constants::PERMISSION_ALL, 'public-share', true, 'test@test.com'], + [Constants::PERMISSION_READ, null, false, null], + [Constants::PERMISSION_READ, 'public-share', false, null], + ]; + } + /** + * @dataProvider dataLoadAcquirePersistentLock + * + * @param int $permissions + * @param string $shareToken + * @param bool $expectWritable + * @param string $expectLocked + */ + public function testLoadAcquirePersistentLock($permissions, $shareToken, $expectWritable, $expectLocked) { + $filename = 'test.txt'; + $fileContent = 'test'; + $parentPath = '/test'; + $fileId = 1; + $fileMTime = 65638643; + $userId = 'test@test.com'; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturn($shareToken); + + $this->shareMock->expects($this->any()) + ->method('getPermissions') + ->willReturn($permissions); + + $this->shareMock->expects($this->any()) + ->method('getNode') + ->willReturn($this->fileMock); + + $this->shareManagerMock->expects($this->any()) + ->method('getShareByToken') + ->willReturn($this->shareMock); + + $parentFolderMock = $this->getMockBuilder(Folder::class) + ->disableOriginalConstructor() + ->getMock(); + + $parentFolderMock->expects($this->any()) + ->method('getPath') + ->willReturn($parentPath); + + $persistentLockMock = $this->getMockBuilder(ILock::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockMock->expects($this->any()) + ->method('getOwner') + ->willReturn($userId); + + $persistentLockMock->expects($this->any()) + ->method('getToken') + ->willReturn('token'); + + $persistentLockingStorageMock = $this->getMockBuilder(IPersistentLockingStorageTest::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockingStorageMock->expects($this->any()) + ->method('instanceOfStorage') + ->willReturn(true); + + $persistentLockingStorageMock->expects($this->any()) + ->method('getLocks') + ->willReturn([]); + + if ($expectWritable) { + $persistentLockingStorageMock->expects($this->once()) + ->method('lockNodePersistent') + ->willReturn($persistentLockMock); + } else { + $persistentLockingStorageMock->expects($this->never()) + ->method('lockNodePersistent'); + } + + $this->userMock->expects($this->any()) + ->method('getUID') + ->willReturn($userId); + + $this->userSessionMock->expects($this->any()) + ->method('getUser') + ->willReturn($this->userMock); + + $this->fileMock->expects($this->any()) + ->method('getId') + ->willReturn($fileId); + + $this->fileMock->expects($this->any()) + ->method('getInternalPath') + ->willReturn($parentPath . $filename); + + $this->fileMock->expects($this->any()) + ->method('getPermissions') + ->willReturn($permissions); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($persistentLockingStorageMock); + + $this->fileMock->expects($this->any()) + ->method('getContent') + ->willReturn($fileContent); + + $this->fileMock->expects($this->any()) + ->method('getMTime') + ->willReturn($fileMTime); + + $this->fileMock->expects($this->any()) + ->method('getParent') + ->willReturn($parentFolderMock); + + $this->rootMock->expects($this->any()) + ->method('get') + ->willReturn($this->fileMock); + + $result = $this->controller->load($parentPath, $filename); + $data = $result->getData(); + $status = $result->getStatus(); + $this->assertSame($status, 200); + + $this->assertArrayHasKey('filecontents', $data); + $this->assertArrayHasKey('writeable', $data); + $this->assertArrayHasKey('locked', $data); + $this->assertArrayHasKey('mime', $data); + $this->assertArrayHasKey('mtime', $data); + $this->assertSame($data['filecontents'], $fileContent); + $this->assertSame($data['writeable'], $expectWritable); + $this->assertSame($data['locked'], $expectLocked); + } + + /** + * Test that when there is lock from other app, load enforces read-only on a file + */ + public function testLoadWithPersistentLockFromOtherApp() { + $filename = 'test.txt'; + $fileContent = 'test'; + $parentPath = '/test'; + $fileId = 1; + $fileMTime = 65638643; + + $parentFolderMock = $this->getMockBuilder(Folder::class) + ->disableOriginalConstructor() + ->getMock(); + + $parentFolderMock->expects($this->any()) + ->method('getPath') + ->willReturn($parentPath); + + $persistentLockMock = $this->getMockBuilder(ILock::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockMock->expects($this->any()) + ->method('getOwner') + ->willReturn('test@test.com'); + + $persistentLockMock->expects($this->any()) + ->method('getToken') + ->willReturn('other-app-token'); + + $persistentLockingStorageMock = $this->getMockBuilder(IPersistentLockingStorageTest::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockingStorageMock->expects($this->any()) + ->method('instanceOfStorage') + ->willReturn(true); + + $persistentLockingStorageMock->expects($this->any()) + ->method('getLocks') + ->willReturn([$persistentLockMock]); + + $persistentLockingStorageMock->expects($this->never()) + ->method('lockNodePersistent'); + + $this->userMock->expects($this->any()) + ->method('getUID') + ->willReturn('admin'); + + $this->userSessionMock->expects($this->any()) + ->method('getUser') + ->willReturn($this->userMock); + + $this->fileMock->expects($this->any()) + ->method('getId') + ->willReturn($fileId); + + $this->fileMock->expects($this->any()) + ->method('getPermissions') + ->willReturn(Constants::PERMISSION_ALL); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($persistentLockingStorageMock); + + $this->fileMock->expects($this->any()) + ->method('getContent') + ->willReturn($fileContent); + + $this->fileMock->expects($this->any()) + ->method('getMTime') + ->willReturn($fileMTime); + + $this->fileMock->expects($this->any()) + ->method('getParent') + ->willReturn($parentFolderMock); + + $this->rootMock->expects($this->any()) + ->method('get') + ->willReturn($this->fileMock); + + $result = $this->controller->load($parentPath, $filename); + $data = $result->getData(); + $status = $result->getStatus(); + $this->assertSame($status, 200); + + $this->assertArrayHasKey('filecontents', $data); + $this->assertArrayHasKey('writeable', $data); + $this->assertArrayHasKey('locked', $data); + $this->assertArrayHasKey('mime', $data); + $this->assertArrayHasKey('mtime', $data); + $this->assertSame($data['filecontents'], $fileContent); + $this->assertSame($data['writeable'], false); + $this->assertSame($data['locked'], 'test@test.com'); + } + + public function dataSaveVerifyPersistentLock() { + return [ + [null], + ['public-share'], + ]; + } + /** + * @dataProvider dataSaveVerifyPersistentLock + * + * @param string $shareToken + */ + public function testSaveVerifyPersistentLock($shareToken) { + $filename = 'test.txt'; + $fileContent = 'test'; + $parentPath = '/test'; + $fileId = 1; + $mTime = 65638643; + $fileMTime = 65638643; + $userId = 'test@test.com'; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturn($shareToken); + + $this->shareMock->expects($this->any()) + ->method('getPermissions') + ->willReturn(Constants::PERMISSION_ALL); + + $this->shareMock->expects($this->any()) + ->method('getNode') + ->willReturn($this->fileMock); + + $this->shareManagerMock->expects($this->any()) + ->method('getShareByToken') + ->willReturn($this->shareMock); + + $parentFolderMock = $this->getMockBuilder(Folder::class) + ->disableOriginalConstructor() + ->getMock(); + + $parentFolderMock->expects($this->any()) + ->method('getPath') + ->willReturn($parentPath); + + $persistentLockMock = $this->getMockBuilder(ILock::class) + ->disableOriginalConstructor() + ->getMock(); + + if ($shareToken) { + $owner = 'Public Link User via Text Editor'; + $token = JWT::encode([ + 'uid' => '', + 'st' => $shareToken, + 'fid' => $fileId, + 'fpp' => $parentPath, + ], 'files_texteditor', 'HS256'); + } else { + $owner = $userId . ' via Text Editor'; + $token = JWT::encode([ + 'uid' => $userId, + 'st' => '', + 'fid' => $fileId, + 'fpp' => $parentPath, + ], 'files_texteditor', 'HS256'); + } + + $persistentLockMock->expects($this->any()) + ->method('getOwner') + ->willReturn($owner); + + $persistentLockMock->expects($this->any()) + ->method('getToken') + ->willReturn($token); + + $persistentLockingStorageMock = $this->getMockBuilder(IPersistentLockingStorageTest::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockingStorageMock->expects($this->any()) + ->method('instanceOfStorage') + ->willReturn(true); + + $persistentLockingStorageMock->expects($this->any()) + ->method('getLocks') + ->willReturn([$persistentLockMock]); + + $persistentLockingStorageMock->expects($this->once()) + ->method('lockNodePersistent') + ->with($this->stringContains($parentPath . $filename), $this->equalTo([ + 'token' => $token, + 'owner' => $owner + ])) + ->willReturn($persistentLockMock); + + $this->userMock->expects($this->any()) + ->method('getUID') + ->willReturn($userId); + + $this->userMock->expects($this->any()) + ->method('getDisplayName') + ->willReturn($userId); + + $this->userSessionMock->expects($this->any()) + ->method('getUser') + ->willReturn($this->userMock); + + $this->fileMock->expects($this->any()) + ->method('getPermissions') + ->willReturn(Constants::PERMISSION_ALL); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($persistentLockingStorageMock); + + $this->fileMock->expects($this->any()) + ->method('getId') + ->willReturn($fileId); + + $this->fileMock->expects($this->any()) + ->method('getInternalPath') + ->willReturn($parentPath . $filename); + + $this->fileMock->expects($this->any()) + ->method('getMTime') + ->willReturn($fileMTime); + + $this->fileMock->expects($this->any()) + ->method('getParent') + ->willReturn($parentFolderMock); + + $this->rootMock->expects($this->any()) + ->method('get') + ->willReturn($this->fileMock); + + $result = $this->controller->save($parentPath . $filename, $fileContent, $mTime); + $status = $result->getStatus(); + $data = $result->getData(); + + $this->assertSame(200, $status); + $this->assertArrayHasKey('mtime', $data); + $this->assertArrayHasKey('size', $data); + } + + /** + * Test that when there is lock from other app, save is not allowed + */ + public function testSaveWithPersistentLockFromOtherApp() { + $filename = 'test.txt'; + $fileContent = 'test'; + $parentPath = '/test'; + $fileId = 1; + $mTime = 65638643; + $fileMTime = 65638643; + + $parentFolderMock = $this->getMockBuilder(Folder::class) + ->disableOriginalConstructor() + ->getMock(); + + $parentFolderMock->expects($this->any()) + ->method('getPath') + ->willReturn($parentPath); + + $persistentLockMock = $this->getMockBuilder(ILock::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockMock->expects($this->any()) + ->method('getOwner') + ->willReturn('test@test.com'); + + $persistentLockMock->expects($this->any()) + ->method('getToken') + ->willReturn('other-app-token'); + + $persistentLockingStorageMock = $this->getMockBuilder(IPersistentLockingStorageTest::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockingStorageMock->expects($this->any()) + ->method('instanceOfStorage') + ->willReturn(true); + + $persistentLockingStorageMock->expects($this->any()) + ->method('getLocks') + ->willReturn([$persistentLockMock]); + + $persistentLockingStorageMock->expects($this->never()) + ->method('lockNodePersistent'); + + $this->userMock->expects($this->any()) + ->method('getUID') + ->willReturn('admin'); + + $this->userSessionMock->expects($this->any()) + ->method('getUser') + ->willReturn($this->userMock); + + $this->fileMock->expects($this->any()) + ->method('getPermissions') + ->willReturn(Constants::PERMISSION_ALL); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($persistentLockingStorageMock); + + $this->fileMock->expects($this->any()) + ->method('getId') + ->willReturn($fileId); + + $this->fileMock->expects($this->any()) + ->method('getMTime') + ->willReturn($fileMTime); + + $this->fileMock->expects($this->any()) + ->method('getParent') + ->willReturn($parentFolderMock); + + $this->rootMock->expects($this->any()) + ->method('get') + ->willReturn($this->fileMock); + + $result = $this->controller->save($parentPath . $filename, $fileContent, $mTime); + $status = $result->getStatus(); + $data = $result->getData(); + + $this->assertSame(400, $status); + $this->assertArrayHasKey('message', $data); + $this->assertSame('Cannot save file as it is locked by test@test.com.', $data['message']); + } + + public function dataClose() { + return [ + [true, null], + [true, 'public-share'], + [false, null], + [false, 'public-share'], + ]; + } + /** + * @dataProvider dataClose + * + * @param bool $isLocked + * @param string $shareToken + */ + public function testClose($isLocked, $shareToken) { + $filename = 'test.txt'; + $parentPath = '/test'; + $fileId = 1; + $fileMTime = 65638643; + $userId = 'test@test.com'; + + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturn($shareToken); + + $this->shareMock->expects($this->any()) + ->method('getPermissions') + ->willReturn(Constants::PERMISSION_ALL); + + $this->shareMock->expects($this->any()) + ->method('getNode') + ->willReturn($this->fileMock); + + $this->shareManagerMock->expects($this->any()) + ->method('getShareByToken') + ->willReturn($this->shareMock); + + $parentFolderMock = $this->getMockBuilder(Folder::class) + ->disableOriginalConstructor() + ->getMock(); + + $parentFolderMock->expects($this->any()) + ->method('getPath') + ->willReturn($parentPath); + + $persistentLockMock = $this->getMockBuilder(ILock::class) + ->disableOriginalConstructor() + ->getMock(); + + if ($shareToken) { + $token = JWT::encode([ + 'uid' => '', + 'st' => $shareToken, + 'fid' => $fileId, + 'fpp' => $parentPath, + ], 'files_texteditor', 'HS256'); + } else { + $token = JWT::encode([ + 'uid' => $userId, + 'st' => '', + 'fid' => $fileId, + 'fpp' => $parentPath, + ], 'files_texteditor', 'HS256'); + } + + $persistentLockMock->expects($this->never()) + ->method('getOwner'); + + $persistentLockMock->expects($this->any()) + ->method('getToken') + ->willReturn($token); + + $persistentLockingStorageMock = $this->getMockBuilder(IPersistentLockingStorageTest::class) + ->disableOriginalConstructor() + ->getMock(); + + $persistentLockingStorageMock->expects($this->any()) + ->method('instanceOfStorage') + ->willReturn(true); + + if ($isLocked) { + $persistentLockingStorageMock->expects($this->any()) + ->method('getLocks') + ->willReturn([$persistentLockMock]); + + $persistentLockingStorageMock->expects($this->once()) + ->method('unlockNodePersistent') + ->with($this->stringContains($parentPath . $filename), $this->equalTo([ + 'token' => $token + ])) + ->willReturn(true); + } else { + $persistentLockingStorageMock->expects($this->any()) + ->method('getLocks') + ->willReturn([]); + + $persistentLockingStorageMock->expects($this->never()) + ->method('unlockNodePersistent'); + } + + $this->userMock->expects($this->any()) + ->method('getUID') + ->willReturn($userId); + + $this->userMock->expects($this->any()) + ->method('getDisplayName') + ->willReturn($userId); + + $this->userSessionMock->expects($this->any()) + ->method('getUser') + ->willReturn($this->userMock); + + $this->fileMock->expects($this->any()) + ->method('getPermissions') + ->willReturn(Constants::PERMISSION_ALL); + + $this->fileMock->expects($this->any()) + ->method('getStorage') + ->willReturn($persistentLockingStorageMock); + + $this->fileMock->expects($this->any()) + ->method('getId') + ->willReturn($fileId); + + $this->fileMock->expects($this->any()) + ->method('getInternalPath') + ->willReturn($parentPath . $filename); + + $this->fileMock->expects($this->any()) + ->method('getMTime') + ->willReturn($fileMTime); + + $this->fileMock->expects($this->any()) + ->method('getParent') + ->willReturn($parentFolderMock); + + $this->rootMock->expects($this->any()) + ->method('get') + ->willReturn($this->fileMock); + + $result = $this->controller->close($parentPath . $filename); + $status = $result->getStatus(); + $data = $result->getData(); + + $this->assertSame(200, $status); + $this->assertEmpty($data); + } }