diff --git a/apps/dav/lib/Meta/MetaFile.php b/apps/dav/lib/Meta/MetaFile.php index ba65afa4c9ee..6ddf64840c9d 100644 --- a/apps/dav/lib/Meta/MetaFile.php +++ b/apps/dav/lib/Meta/MetaFile.php @@ -25,6 +25,7 @@ use OCA\DAV\Files\ICopySource; use OCA\DAV\Files\IProvidesAdditionalHeaders; use OCA\DAV\Files\IFileNode; +use OCP\Files\IProvidesVersionAuthor; use OCP\Files\Node; use Sabre\DAV\File; @@ -126,4 +127,27 @@ public function getContentDispositionFileName() { public function getNode() { return $this->file; } + + /** + * @return string + */ + public function getVersionAuthor() : string { + if ($this->file instanceof IProvidesVersionAuthor) { + return $this->file->getEditedBy(); + } + return ''; + } + + /** + * @return string + */ + public function getVersionAuthorName() : string { + if ($this->file instanceof IProvidesVersionAuthor) { + $uid = $this->file->getEditedBy(); + $manager = \OC::$server->getUserManager(); + $user = $manager->get($uid); + return $user !== null ? $user->getDisplayName() : ''; + } + return ''; + } } diff --git a/apps/dav/lib/Meta/MetaPlugin.php b/apps/dav/lib/Meta/MetaPlugin.php index 8965f0fb9ce4..759af31fe94b 100644 --- a/apps/dav/lib/Meta/MetaPlugin.php +++ b/apps/dav/lib/Meta/MetaPlugin.php @@ -33,6 +33,8 @@ class MetaPlugin extends ServerPlugin { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; public const PATH_FOR_FILEID_PROPERTYNAME = '{http://owncloud.org/ns}meta-path-for-user'; + public const VERSION_EDITED_BY_PROPERTYNAME = '{http://owncloud.org/ns}meta-version-edited-by'; + public const VERSION_EDITED_BY_PROPERTYNAME_NAME = '{http://owncloud.org/ns}meta-version-edited-by-name'; /** * Reference to main server object * @@ -97,6 +99,13 @@ public function handleGetProperties(PropFind $propFind, INode $node) { $file = \current($files); return $baseFolder->getRelativePath($file->getPath()); }); + } elseif ($node instanceof MetaFile) { + $propFind->handle(self::VERSION_EDITED_BY_PROPERTYNAME, function () use ($node) { + return $node->getVersionAuthor(); + }); + $propFind->handle(self::VERSION_EDITED_BY_PROPERTYNAME_NAME, function () use ($node) { + return $node->getVersionAuthorName(); + }); } } } diff --git a/apps/files_trashbin/lib/Trashbin.php b/apps/files_trashbin/lib/Trashbin.php index 2a0f1ebb4d1c..acb8a8580cd2 100644 --- a/apps/files_trashbin/lib/Trashbin.php +++ b/apps/files_trashbin/lib/Trashbin.php @@ -400,19 +400,35 @@ private static function retainVersions($filename, $owner, $ownerPath, $timestamp $rootView = new View('/'); if ($rootView->is_dir($owner . '/files_versions/' . $ownerPath)) { + $metadataFileExists = $rootView->file_exists($owner . '/files_versions/' . $ownerPath . '.json'); + if ($owner !== $user || $forceCopy) { self::copy_recursive($owner . '/files_versions/' . $ownerPath, $owner . '/files_trashbin/versions/' . \basename($ownerPath) . '.d' . $timestamp, $rootView); + if ($metadataFileExists) { + self::copy_recursive($owner . '/files_versions/' . $ownerPath . '.json', $owner . '/files_trashbin/versions/' . \basename($ownerPath) . '.json' . '.d' . $timestamp, $rootView); + } } if (!$forceCopy) { self::move($rootView, $owner . '/files_versions/' . $ownerPath, $user . '/files_trashbin/versions/' . $filename . '.d' . $timestamp); + if ($metadataFileExists) { + self::move($rootView, $owner . '/files_versions/' . $ownerPath . '.json', $user . '/files_trashbin/versions/' . $filename . '.json' . '.d' . $timestamp); + } } } elseif ($versions = \OCA\Files_Versions\Storage::getVersions($owner, $ownerPath)) { foreach ($versions as $v) { + $metaVersionExists = $rootView->file_exists($owner . '/files_versions' . $v['path'] . '.v' . $v['version'] . '.json'); + if ($owner !== $user || $forceCopy) { self::copy($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'], $owner . '/files_trashbin/versions/' . $v['name'] . '.v' . $v['version'] . '.d' . $timestamp); + if ($metaVersionExists) { + self::move($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'] . '.json', $owner . '/files_trashbin/versions/' . $filename . '.v' . $v['version'] . '.json' . '.d' . $timestamp); + } } if (!$forceCopy) { self::move($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'], $user . '/files_trashbin/versions/' . $filename . '.v' . $v['version'] . '.d' . $timestamp); + if ($metaVersionExists) { + self::move($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'] . '.json', $user . '/files_trashbin/versions/' . $filename . '.v' . $v['version'] . '.json' . '.d' . $timestamp); + } } } } diff --git a/apps/files_versions/js/versioncollection.js b/apps/files_versions/js/versioncollection.js index 7ff13a849dc4..906750afac21 100644 --- a/apps/files_versions/js/versioncollection.js +++ b/apps/files_versions/js/versioncollection.js @@ -11,12 +11,29 @@ /* global moment */ (function() { + + _.extend(OC.Files.Client, { + PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id', + PROPERTY_VERSION_EDITED_BY: '{' + OC.Files.Client.NS_OWNCLOUD + '}meta-version-edited-by', + PROPERTY_VERSION_EDITED_BY_NAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}meta-version-edited-by-name', + }); + /** * @memberof OCA.Versions */ var VersionCollection = OC.Backbone.Collection.extend({ sync: OC.Backbone.davSync, + davProperties: { + 'meta-version-edited-by': OC.Files.Client.PROPERTY_VERSION_EDITED_BY, + 'meta-version-edited-by-name': OC.Files.Client.PROPERTY_VERSION_EDITED_BY_NAME, + 'id': OC.Files.Client.PROPERTY_FILEID, + 'getlastmodified': OC.Files.Client.PROPERTY_GETLASTMODIFIED, + 'getcontentlength': OC.Files.Client.PROPERTY_GETCONTENTLENGTH, + 'resourcetype': OC.Files.Client.PROPERTY_RESOURCETYPE, + 'getcontenttype': OC.Files.Client.PROPERTY_GETCONTENTTYPE, + }, + model: OCA.Versions.VersionModel, /** @@ -46,9 +63,12 @@ id: revision, name: revision, fullPath: fullPath, - timestamp: moment(new Date(version['{DAV:}getlastmodified'])).format('X'), - size: version['{DAV:}getcontentlength'], - mimetype: version['{DAV:}getcontenttype'], + timestamp: moment(new Date(version.getlastmodified)).format('X'), + relativeTimestamp: moment(new Date(version.getlastmodified)).fromNow(), + size: version.getcontentlength, + mimetype: version.getcontenttype, + editedBy: version['meta-version-edited-by'], + editedByName: version['meta-version-edited-by-name'], fileId: fileId }; }); diff --git a/apps/files_versions/js/versionstabview.js b/apps/files_versions/js/versionstabview.js index 6c721d6dd073..55788f88d81a 100644 --- a/apps/files_versions/js/versionstabview.js +++ b/apps/files_versions/js/versionstabview.js @@ -39,6 +39,7 @@ '{{#hasDetails}}' + '
' + '{{humanReadableSize}}' + + '{{editedByName}}' + '
' + '{{/hasDetails}}' + '' + @@ -213,7 +214,9 @@ revertIconUrl: OC.imagePath('core', 'actions/history'), previewUrl: getPreviewUrl(version), revertLabel: t('files_versions', 'Restore'), - canRevert: (this.collection.getFileInfo().get('permissions') & OC.PERMISSION_UPDATE) !== 0 + canRevert: (this.collection.getFileInfo().get('permissions') & OC.PERMISSION_UPDATE) !== 0, + editedBy: version.has('editedBy'), + editedByName: version.has('editedByName') }, version.attributes); }, diff --git a/apps/files_versions/lib/Hooks.php b/apps/files_versions/lib/Hooks.php index 081a901ca0f4..6843e73e43fe 100644 --- a/apps/files_versions/lib/Hooks.php +++ b/apps/files_versions/lib/Hooks.php @@ -36,6 +36,7 @@ class Hooks { public static function connectHooks() { // Listen to write signals \OCP\Util::connectHook('OC_Filesystem', 'write', 'OCA\Files_Versions\Hooks', 'write_hook'); + // Listen to delete and rename signals \OCP\Util::connectHook('OC_Filesystem', 'post_delete', 'OCA\Files_Versions\Hooks', 'remove_hook'); \OCP\Util::connectHook('OC_Filesystem', 'delete', 'OCA\Files_Versions\Hooks', 'pre_remove_hook'); diff --git a/apps/files_versions/lib/Storage.php b/apps/files_versions/lib/Storage.php index 06166b748c2d..7e4c73169a8d 100644 --- a/apps/files_versions/lib/Storage.php +++ b/apps/files_versions/lib/Storage.php @@ -43,6 +43,8 @@ use OC\Files\Filesystem; use OC\Files\View; +use OC\Share\Constants; +use OCA\DAV\Meta\MetaPlugin; use OCA\Files_Versions\AppInfo\Application; use OCA\Files_Versions\Command\Expire; use OCP\Files\NotFoundException; @@ -192,13 +194,26 @@ public static function store($filename) { // store a new version of a file $mtime = $users_view->filemtime('files/' . $filename); $sourceFileInfo = $users_view->getFileInfo("files/$filename"); - if ($users_view->copy("files/$filename", "files_versions/$filename.v$mtime")) { + + $versionFileName = "files_versions/$filename.v$mtime"; + if ($users_view->copy("files/$filename", $versionFileName)) { // call getFileInfo to enforce a file cache entry for the new version - $users_view->getFileInfo("files_versions/$filename.v$mtime"); + $users_view->getFileInfo($versionFileName); // update checksum of the version - $users_view->putFileInfo("files_versions/$filename.v$mtime", [ + $users_view->putFileInfo($versionFileName, [ 'checksum' => $sourceFileInfo->getChecksum(), ]); + + $config = \OC::$server->getConfig(); + if ($config->getSystemValue('file_storage.save_version_author', false) === true) { + $user = \OC::$server->getUserSession()->getUser(); + if ($user !== null && !$users_view->file_exists($versionFileName . '.json')) { + $metaDataKey = MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME; + $metadata = [$metaDataKey => $user->getUID()]; + $metadataJsonObject = \json_encode($metadata); + $users_view->file_put_contents($versionFileName . '.json', $metadataJsonObject); + } + } } } } @@ -254,6 +269,9 @@ public static function delete($path) { ]; \OC_Hook::emit('\OCP\Versions', 'preDelete', $hookData); self::deleteVersion($view, $filename . '.v' . $v['version']); + if ($view->file_exists($path . ".json")) { + $view->unlink($path . ".json"); + } \OC_Hook::emit('\OCP\Versions', 'delete', $hookData); } } @@ -310,6 +328,14 @@ public static function renameOrCopy($sourcePath, $targetPath, $operation) { '/' . $sourceOwner . '/files_versions/' . $sourcePath.'.v' . $v['version'], '/' . $targetOwner . '/files_versions/' . $targetPath.'.v'.$v['version'] ); + // move each version json file that holds the name of the user that've made an edit + $sourceMetaDataFile = '/' . $sourceOwner . '/files_versions/' . $sourcePath . '.v' . $v['version'] . '.json'; + if ($rootView->file_exists($sourceMetaDataFile)) { + $rootView->$operation( + $sourceMetaDataFile, + '/' . $targetOwner . '/files_versions/' . $targetPath . '.v' . $v['version'] . '.json' + ); + } } } @@ -328,13 +354,21 @@ public static function restoreVersion($uid, $filename, $fileToRestore, $revision return false; } + $metaDataEnabled = \OC::$server->getConfig()->getSystemValue('file_storage.save_version_author', false); $versionCreated = false; - //first create a new version + //first create a new version and metadata if enabled $version = 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename); if (!$users_view->file_exists($version)) { - $users_view->copy('files'.$filename, 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename)); + $users_view->copy('files'.$filename, $version); $versionCreated = true; + + if ($metaDataEnabled === true) { + $metaTargetPath = $version . '.json'; + $metaDataKey = MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME; + $metadataJsonObject = \json_encode([$metaDataKey => $uid]); + $users_view->file_put_contents($metaTargetPath, $metadataJsonObject); + } } // Restore encrypted version of the old file for the newly restored file @@ -356,11 +390,20 @@ public static function restoreVersion($uid, $filename, $fileToRestore, $revision if (self::copyFileContents($users_view, $fileToRestore, 'files' . $filename)) { $users_view->touch("/files$filename", $revision); Storage::scheduleExpire($uid, $filename); + + if ($metaDataEnabled && $users_view->file_exists($fileToRestore . '.json')) { + $users_view->unlink($fileToRestore . '.json'); + list($storage, $internalPath) = $users_view->resolvePath($fileToRestore . '.json'); + $cache = $storage->getCache($internalPath); + $cache->remove($internalPath); + } + \OC_Hook::emit('\OCP\Versions', 'rollback', [ 'path' => $filename, 'user' => $uid, 'revision' => $revision, ]); + return true; } elseif ($versionCreated) { self::deleteVersion($users_view, $version); @@ -456,6 +499,16 @@ public static function getVersions($uid, $filename) { $versions[$key]['etag'] = $view->getETag($dir . '/' . $entryName); $versions[$key]['storage_location'] = "$dir/$entryName"; $versions[$key]['owner'] = $uid; + + $jsonMetadataFile = $dir . '/' . $entryName . '.json'; + if ($view->file_exists($jsonMetadataFile)) { + $metaDataFileContents = $view->file_get_contents($jsonMetadataFile); + if ($decoded = \json_decode($metaDataFileContents, true)) { + if (isset($decoded[MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME])) { + $versions[$key]['edited_by'] = $decoded[MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME]; + } + } + } } } } @@ -561,6 +614,7 @@ protected static function getExpireList($time, $versions, $quotaExceeded = false /** * get list of files we want to expire + * * @param array $versions list of versions * @param integer $time * @return array containing the list of to deleted versions and the size of them diff --git a/apps/files_versions/tests/VersioningTest.php b/apps/files_versions/tests/VersioningTest.php index 80a298c45f5d..c59f97349e52 100644 --- a/apps/files_versions/tests/VersioningTest.php +++ b/apps/files_versions/tests/VersioningTest.php @@ -37,7 +37,11 @@ use OC\Files\ObjectStore\ObjectStoreStorage; use OC\Files\Storage\Temporary; +use OC\Share\Constants; +use OCA\DAV\Meta\MetaPlugin; use OCP\Files\Storage; +use OCP\IConfig; +use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; /** @@ -55,6 +59,8 @@ class VersioningTest extends TestCase { private $user2; /** @var string */ private $versionsRootOfUser1; + /** @var IConfig|MockObject */ + private $mockConfig; /** * @var \OC\Files\View @@ -71,23 +77,7 @@ public static function setUpBeforeClass(): void { protected function setUp(): void { parent::setUp(); - $config = \OC::$server->getConfig(); - $mockConfig = $this->createMock('\OCP\IConfig'); - $mockConfig->expects($this->any()) - ->method('getSystemValue') - ->will($this->returnCallback(function ($key, $default) use ($config) { - if ($key === 'filesystem_check_changes') { - return \OC\Files\Cache\Watcher::CHECK_ONCE; - } else { - return $config->getSystemValue($key, $default); - } - })); - $this->overwriteService('AllConfig', $mockConfig); - - // clear hooks - \OC_Hook::clear(); - \OC::registerShareHooks(); - \OCA\Files_Versions\Hooks::connectHooks(); + \OC::$server->getEncryptionManager()->setupStorage(); // Generate random usernames for better isolation $testId = \uniqid(); @@ -120,10 +110,12 @@ protected function tearDown(): void { $user = \OC::$server->getUserManager()->get($this->user1); if ($user !== null) { + $this->logout(); $user->delete(); } $user = \OC::$server->getUserManager()->get($this->user2); if ($user !== null) { + $this->logout(); $user->delete(); } @@ -132,7 +124,46 @@ protected function tearDown(): void { parent::tearDown(); } - public function testMoveFileIntoSharedFolderAsRecipient() { + /** + * Enables versioning metadata for unit-testing. Each test in this suite + * is executed once with and without versioning metadata enabled. + */ + private function overwriteConfig($saveVersionAuthor) { + $config = \OC::$server->getConfig(); + $this->mockConfig = $this->createMock('\OCP\IConfig'); + $this->mockConfig->expects($this->any()) + ->method('getSystemValue') + ->will($this->returnCallback(function ($key, $default) use ($config, $saveVersionAuthor) { + if ($key === 'filesystem_check_changes') { + return \OC\Files\Cache\Watcher::CHECK_ONCE; + } elseif ($key === 'file_storage.save_version_author') { + return $saveVersionAuthor; + } else { + return $config->getSystemValue($key, $default); + } + })); + + $this->overwriteService('AllConfig', $this->mockConfig); + + // clear hooks + \OC_Hook::clear(); + \OC::registerShareHooks(); + \OCA\Files_Versions\Hooks::connectHooks(); + } + + public function metaDataEnabledProvider(): array { + return [ + 'metaDisabled' => [false], + 'metaEnabled' => [true], + ]; + } + + /** + * @dataProvider metaDataEnabledProvider + */ + public function testMoveFileIntoSharedFolderAsRecipient(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + \OC\Files\Filesystem::mkdir('folder1'); $fileInfo = \OC\Files\Filesystem::getFileInfo('folder1'); @@ -158,16 +189,29 @@ public function testMoveFileIntoSharedFolderAsRecipient() { // create some versions $v1 = $versionsFolder2 . '/test.txt.v' . $t1; $v2 = $versionsFolder2 . '/test.txt.v' . $t2; - $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($metaDataEnabled) { + // one metadata file for each version + $m1 = $versionsFolder2 . '/test.txt.v' . $t1 . '.json'; + $m2 = $versionsFolder2 . '/test.txt.v' . $t2 . '.json'; + + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user2])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user2])); + } + // move file into the shared folder as recipient \OC\Files\Filesystem::rename('/test.txt', '/folder1/test.txt'); $this->assertFalse($this->rootView->file_exists($v1)); $this->assertFalse($this->rootView->file_exists($v2)); + if ($metaDataEnabled) { + $this->assertFalse($this->rootView->file_exists($m1)); + $this->assertFalse($this->rootView->file_exists($m2)); + } + self::loginHelper($this->user1); $versionsFolder1 = '/' . $this->user1 . '/files_versions'; @@ -178,6 +222,14 @@ public function testMoveFileIntoSharedFolderAsRecipient() { $this->assertTrue($this->rootView->file_exists($v1Renamed)); $this->assertTrue($this->rootView->file_exists($v2Renamed)); + if ($metaDataEnabled) { + $m1Renamed = $versionsFolder1 . '/folder1/test.txt.v' . $t1 . '.json'; + $m2Renamed = $versionsFolder1 . '/folder1/test.txt.v' . $t2 . '.json'; + + $this->assertTrue($this->rootView->file_exists($m1Renamed)); + $this->assertTrue($this->rootView->file_exists($m2Renamed)); + } + \OC::$server->getShareManager()->deleteShare($share); } @@ -187,7 +239,6 @@ public function testMoveFileIntoSharedFolderAsRecipient() { * @dataProvider versionsProvider */ public function testGetExpireList($versions, $sizeOfAllDeletedFiles) { - // last interval end at 2592000 $startTime = 5000000; @@ -327,8 +378,12 @@ public function versionsProvider() { ]; } - public function testRename() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testRename(bool $metaDataEnabled) { \OC\Files\Filesystem::file_put_contents("test.txt", "test file"); + $this->overwriteConfig($metaDataEnabled); $t1 = \time(); // second version is two weeks older, this way we make sure that no @@ -338,12 +393,22 @@ public function testRename() { // create some versions $v1 = $this->versionsRootOfUser1 . '/test.txt.v' . $t1; $v2 = $this->versionsRootOfUser1 . '/test.txt.v' . $t2; + $m1 = $this->versionsRootOfUser1 . '/test.txt.v' . $t1 . '.json'; + $m2 = $this->versionsRootOfUser1 . '/test.txt.v' . $t1 . '.json'; + $v1Renamed = $this->versionsRootOfUser1 . '/test2.txt.v' . $t1; $v2Renamed = $this->versionsRootOfUser1 . '/test2.txt.v' . $t2; + $m1Renamed = $this->versionsRootOfUser1 . '/test2.txt.v' . $t1 . '.json'; + $m2Renamed = $this->versionsRootOfUser1 . '/test2.txt.v' . $t1 . '.json'; $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($metaDataEnabled) { + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user2])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user2])); + } + // execute rename hook of versions app \OC\Files\Filesystem::rename("test.txt", "test2.txt"); @@ -354,9 +419,22 @@ public function testRename() { $this->assertTrue($this->rootView->file_exists($v1Renamed)); $this->assertTrue($this->rootView->file_exists($v2Renamed)); + + if ($metaDataEnabled) { + $this->assertFalse($this->rootView->file_exists($m1)); + $this->assertFalse($this->rootView->file_exists($m2)); + + $this->assertTrue($this->rootView->file_exists($m1Renamed)); + $this->assertTrue($this->rootView->file_exists($m2Renamed)); + } } - public function testRenameInSharedFolder() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testRenameInSharedFolder(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + \OC\Files\Filesystem::mkdir('folder1'); \OC\Files\Filesystem::mkdir('folder1/folder2'); \OC\Files\Filesystem::file_put_contents("folder1/test.txt", "test file"); @@ -370,12 +448,22 @@ public function testRenameInSharedFolder() { // create some versions $v1 = $this->versionsRootOfUser1 . '/folder1/test.txt.v' . $t1; $v2 = $this->versionsRootOfUser1 . '/folder1/test.txt.v' . $t2; + $m1 = $v1 . '.json'; + $m2 = $v2 . '.json'; + $v1Renamed = $this->versionsRootOfUser1 . '/folder1/folder2/test.txt.v' . $t1; $v2Renamed = $this->versionsRootOfUser1 . '/folder1/folder2/test.txt.v' . $t2; + $m1Renamed = $v1Renamed . '.json'; + $m2Renamed = $v2Renamed . '.json'; $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($metaDataEnabled) { + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + } + $node = \OC::$server->getUserFolder($this->user1)->get('folder1'); $share = \OC::$server->getShareManager()->newShare(); $share->setNode($node) @@ -402,10 +490,23 @@ public function testRenameInSharedFolder() { $this->assertTrue($this->rootView->file_exists($v1Renamed)); $this->assertTrue($this->rootView->file_exists($v2Renamed)); + if ($metaDataEnabled) { + $this->assertFalse($this->rootView->file_exists($m1)); + $this->assertFalse($this->rootView->file_exists($m2)); + + $this->assertTrue($this->rootView->file_exists($m1Renamed)); + $this->assertTrue($this->rootView->file_exists($m2Renamed)); + } + \OC::$server->getShareManager()->deleteShare($share); } - public function testMoveFolder() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testMoveFolder(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + \OC\Files\Filesystem::mkdir('folder1'); \OC\Files\Filesystem::mkdir('folder2'); \OC\Files\Filesystem::file_put_contents('folder1/test.txt', 'test file'); @@ -422,9 +523,19 @@ public function testMoveFolder() { $v1Renamed = $this->versionsRootOfUser1 . '/folder2/folder1/test.txt.v' . $t1; $v2Renamed = $this->versionsRootOfUser1 . '/folder2/folder1/test.txt.v' . $t2; + $m1 = $v1 . '.json'; + $m2 = $v2 . '.json'; + $m1Renamed = $v1Renamed . '.json'; + $m2Renamed = $v2Renamed . '.json'; + $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($metaDataEnabled) { + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + } + // execute rename hook of versions app \OC\Files\Filesystem::rename('folder1', 'folder2/folder1'); @@ -435,9 +546,22 @@ public function testMoveFolder() { $this->assertTrue($this->rootView->file_exists($v1Renamed)); $this->assertTrue($this->rootView->file_exists($v2Renamed)); + + if ($metaDataEnabled) { + $this->assertFalse($this->rootView->file_exists($m1)); + $this->assertFalse($this->rootView->file_exists($m2)); + + $this->assertTrue($this->rootView->file_exists($m1Renamed)); + $this->assertTrue($this->rootView->file_exists($m2Renamed)); + } } - public function testMoveFolderIntoSharedFolderAsRecipient() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testMoveFolderIntoSharedFolderAsRecipient(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + \OC\Files\Filesystem::mkdir('folder1'); $node = \OC::$server->getUserFolder($this->user1)->get('folder1'); @@ -462,9 +586,17 @@ public function testMoveFolderIntoSharedFolderAsRecipient() { $this->rootView->mkdir($versionsFolder2); $this->rootView->mkdir($versionsFolder2 . '/folder2'); // create some versions + $v1 = $versionsFolder2 . '/folder2/test.txt.v' . $t1; $v2 = $versionsFolder2 . '/folder2/test.txt.v' . $t2; + if ($metaDataEnabled) { + $m1 = $v1 . '.json'; + $m2 = $v2 . '.json'; + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user2])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user2])); + } + $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); @@ -480,14 +612,26 @@ public function testMoveFolderIntoSharedFolderAsRecipient() { $v1Renamed = $versionsFolder1 . '/folder1/folder2/test.txt.v' . $t1; $v2Renamed = $versionsFolder1 . '/folder1/folder2/test.txt.v' . $t2; + $m1Renamed = $v1Renamed . '.json'; + $m2Renamed = $v2Renamed . '.json'; $this->assertTrue($this->rootView->file_exists($v1Renamed)); $this->assertTrue($this->rootView->file_exists($v2Renamed)); + if ($metaDataEnabled) { + $this->assertTrue($this->rootView->file_exists($m1Renamed)); + $this->assertTrue($this->rootView->file_exists($m2Renamed)); + } + \OC::$server->getShareManager()->deleteShare($share); } - public function testRenameSharedFile() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testRenameSharedFile(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + \OC\Files\Filesystem::file_put_contents("test.txt", "test file"); $t1 = \time(); @@ -495,17 +639,26 @@ public function testRenameSharedFile() { // version will be expired $t2 = $t1 - 60 * 60 * 24 * 14; - $this->rootView->mkdir(self::USERS_VERSIONS_ROOT); + $this->rootView->mkdir($this->versionsRootOfUser1); // create some versions - $v1 = self::USERS_VERSIONS_ROOT . '/test.txt.v' . $t1; - $v2 = self::USERS_VERSIONS_ROOT . '/test.txt.v' . $t2; + $v1 = $this->versionsRootOfUser1 . '/test.txt.v' . $t1; + $v2 = $this->versionsRootOfUser1 . '/test.txt.v' . $t2; + $m1 = $v1 . '.json'; + $m2 = $v2 . '.json'; // the renamed versions should not exist! Because we only moved the mount point! - $v1Renamed = self::USERS_VERSIONS_ROOT . '/test2.txt.v' . $t1; - $v2Renamed = self::USERS_VERSIONS_ROOT . '/test2.txt.v' . $t2; + $v1Renamed = $this->versionsRootOfUser1 . '/test2.txt.v' . $t1; + $v2Renamed = $this->versionsRootOfUser1 . '/test2.txt.v' . $t2; + $m1Renamed = $v1Renamed . '.json'; + $m2Renamed = $v2Renamed . '.json'; $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($metaDataEnabled) { + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + } + $node = \OC::$server->getUserFolder($this->user1)->get('test.txt'); $share = \OC::$server->getShareManager()->newShare(); $share->setNode($node) @@ -532,10 +685,23 @@ public function testRenameSharedFile() { $this->assertFalse($this->rootView->file_exists($v1Renamed)); $this->assertFalse($this->rootView->file_exists($v2Renamed)); + if ($metaDataEnabled) { + $this->assertTrue($this->rootView->file_exists($m1)); + $this->assertTrue($this->rootView->file_exists($m2)); + + $this->assertFalse($this->rootView->file_exists($m1Renamed)); + $this->assertFalse($this->rootView->file_exists($m2Renamed)); + } + \OC::$server->getShareManager()->deleteShare($share); } - public function testCopy() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testCopy(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + \OC\Files\Filesystem::file_put_contents("test.txt", "test file"); $t1 = \time(); @@ -549,9 +715,19 @@ public function testCopy() { $v1Copied = $this->versionsRootOfUser1 . '/test2.txt.v' . $t1; $v2Copied = $this->versionsRootOfUser1 . '/test2.txt.v' . $t2; + $m1 = $v1 . '.json'; + $m2 = $v2 . '.json'; + $m1Copied = $v1Copied . '.json'; + $m2Copied = $v2Copied . '.json'; + $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($metaDataEnabled) { + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + } + // execute copy hook of versions app \OC\Files\Filesystem::copy("test.txt", "test2.txt"); @@ -562,14 +738,27 @@ public function testCopy() { $this->assertTrue($this->rootView->file_exists($v1Copied)); $this->assertTrue($this->rootView->file_exists($v2Copied)); + + if ($metaDataEnabled) { + $this->assertTrue($this->rootView->file_exists($m1)); + $this->assertTrue($this->rootView->file_exists($m2)); + + $this->assertTrue($this->rootView->file_exists($m1Copied)); + $this->assertTrue($this->rootView->file_exists($m2Copied)); + } } public function getVersionsProvider() { return [ - ['/test.txt'], - ['/subfolder/test.txt'], - ['/subfolder/0'], - ['/0'], + ['/test.txt', false], + ['/subfolder/test.txt', false], + ['/subfolder/0', false], + ['/0', false], + + ['/test.txt', true], + ['/subfolder/test.txt',true], + ['/subfolder/0', true], + ['/0', true], ]; } @@ -580,7 +769,9 @@ public function getVersionsProvider() { * @dataProvider getVersionsProvider * @param string $filepath */ - public function testGetVersions($filepath) { + public function testGetVersions(string $filepath, bool $enableMetadata) { + $this->overwriteConfig($enableMetadata); + $t1 = \time(); // second version is two weeks older, this way we make sure that no // version will be expired @@ -592,12 +783,19 @@ public function testGetVersions($filepath) { // create some versions $v1 = $this->versionsRootOfUser1 . $filepath . '.v' . $t1; $v2 = $this->versionsRootOfUser1 . $filepath . '.v' . $t2; + $m1 = $v1 . '.json'; + $m2 = $v2 . '.json'; $this->rootView->mkdir($this->versionsRootOfUser1 . $parent); $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($enableMetadata) { + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + } + // execute copy hook of versions app $versions = \OCA\Files_Versions\Storage::getVersions($this->user1, $filepath); @@ -608,6 +806,11 @@ public function testGetVersions($filepath) { $this->assertSame($fileName, $version['name']); } + if ($enableMetadata) { + $this->assertArrayHasKey('edited_by', array_shift($versions)); + $this->assertArrayHasKey('edited_by', array_shift($versions)); + } + //cleanup $this->rootView->deleteAll($this->versionsRootOfUser1 . $parent); } @@ -615,8 +818,12 @@ public function testGetVersions($filepath) { /** * test if we find all versions and if the versions array contain * the correct 'path' and 'name' + * + * @dataProvider metaDataEnabledProvider */ - public function testGetVersionsEmptyFile() { + public function testGetVersionsEmptyFile(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + // execute copy hook of versions app $versions = \OCA\Files_Versions\Storage::getVersions($this->user1, ''); $this->assertCount(0, $versions); @@ -625,7 +832,12 @@ public function testGetVersionsEmptyFile() { $this->assertCount(0, $versions); } - public function testExpireNonexistingFile() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testExpireNonexistingFile(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + $this->logout(); // needed to have a FS setup (the background job does this) \OC_Util::setupFS($this->user1); @@ -634,9 +846,11 @@ public function testExpireNonexistingFile() { } /** + * @dataProvider metaDataEnabledProvider */ - public function testExpireNonexistingUser() { + public function testExpireNonexistingUser(bool $metaDataEnabled) { $this->expectException(\OC\User\NoUserException::class); + $this->overwriteConfig($metaDataEnabled); $this->logout(); // needed to have a FS setup (the background job does this) @@ -646,16 +860,26 @@ public function testExpireNonexistingUser() { $this->assertFalse(\OCA\Files_Versions\Storage::expire('test.txt', 'unexist')); } - public function testRestoreSameStorage() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testRestoreSameStorage(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + \OC\Files\Filesystem::mkdir('sub'); - $this->doTestRestore(); + $this->doTestRestore($metaDataEnabled); } - public function testRestoreCrossStorage() { + /** + * @dataProvider metaDataEnabledProvider + */ + public function testRestoreCrossStorage(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + $storage2 = new Temporary([]); \OC\Files\Filesystem::mount($storage2, [], $this->user1 . '/files/sub'); - $this->doTestRestore(); + $this->doTestRestore($metaDataEnabled); } /** @@ -687,7 +911,7 @@ function ($p) use (&$params) { ); } - private function doTestRestore() { + private function doTestRestore(bool $metaDataEnabled) { $filePath = $this->user1 . '/files/sub/test.txt'; $this->rootView->file_put_contents($filePath, 'test file'); @@ -702,10 +926,18 @@ private function doTestRestore() { $v1 = $this->versionsRootOfUser1 . '/sub/test.txt.v' . $t1; $v2 = $this->versionsRootOfUser1 . '/sub/test.txt.v' . $t2; + $m1 = $v1 . '.json'; + $m2 = $v2 . '.json'; + $this->rootView->mkdir($this->versionsRootOfUser1 . '/sub'); $this->rootView->file_put_contents($v1, 'version1'); $this->rootView->file_put_contents($v2, 'version2'); + if ($metaDataEnabled) { + $this->rootView->file_put_contents($m1, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + $this->rootView->file_put_contents($m2, \json_encode([MetaPlugin::VERSION_EDITED_BY_PROPERTYNAME => $this->user1])); + } + $oldVersions = \OCA\Files_Versions\Storage::getVersions( $this->user1, '/sub/test.txt' @@ -745,7 +977,7 @@ private function doTestRestore() { $this->assertEquals( $info2['mtime'], $t2, - 'Restored file has mtime from version' + 'Restored file must have mtime from version' ); $newVersions = \OCA\Files_Versions\Storage::getVersions( @@ -755,8 +987,9 @@ private function doTestRestore() { $this->assertTrue( $this->rootView->file_exists($this->versionsRootOfUser1 . '/sub/test.txt.v' . $t0), - 'A version file was created for the file before restoration' + 'A version file must be created for the file before restoration' ); + $this->assertTrue( $this->rootView->file_exists($v1), 'Untouched version file is still there' @@ -766,6 +999,23 @@ private function doTestRestore() { 'Restored version file gone from files_version folder' ); + if ($metaDataEnabled) { + $this->assertTrue( + $this->rootView->file_exists($this->versionsRootOfUser1 . '/sub/test.txt.v' . $t0 . '.json'), + 'A version metadata-file must be created for the file before restoration' + ); + + $this->assertTrue( + $this->rootView->file_exists($m1), + 'Untouched metadata-file is still there' + ); + + $this->assertFalse( + $this->rootView->file_exists($m2), + 'Restored metadata file must be gone from files_version folder' + ); + } + $this->assertCount(2, $newVersions, 'Additional version created'); $this->assertArrayHasKey( @@ -787,8 +1037,11 @@ private function doTestRestore() { /** * Test whether versions are created when overwriting as owner + * + * @dataProvider metaDataEnabledProvider */ - public function testStoreVersionAsOwner() { + public function testStoreVersionAsOwner(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); $this->loginAsUser($this->user1); $this->createAndCheckVersions( @@ -799,8 +1052,11 @@ public function testStoreVersionAsOwner() { /** * Test whether versions are created when overwriting as share recipient + * @dataProvider metaDataEnabledProvider */ - public function testStoreVersionAsRecipient() { + public function testStoreVersionAsRecipient(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + $this->loginAsUser($this->user1); \OC\Files\Filesystem::mkdir('folder'); @@ -831,8 +1087,12 @@ public function testStoreVersionAsRecipient() { * When uploading through a public link or publicwebdav, no user * is logged in. File modification must still be able to find * the owner and create versions. + * + * @dataProvider metaDataEnabledProvider */ - public function testStoreVersionAsAnonymous() { + public function testStoreVersionAsAnonymous(bool $metaDataEnabled) { + $this->overwriteConfig($metaDataEnabled); + $this->logout(); // note: public link upload does this, diff --git a/changelog/unreleased/39126 b/changelog/unreleased/39126 new file mode 100644 index 000000000000..7d6ae872674f --- /dev/null +++ b/changelog/unreleased/39126 @@ -0,0 +1,8 @@ +Enhancement: Save and display the author of a file version + +The author attribute will be saved and shown in the version list grid for each new file version. +This will allow the users to see who performed the changes on a specific file and when. +Also, the author attribute will retain on renaming, copying, and deletion/restoration of the file. + +https://github.com/owncloud/enterprise/issues/4518 +https://github.com/owncloud/core/pull/39126 diff --git a/config/config.sample.php b/config/config.sample.php index 347ca4e6c132..235c117f8665 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -1265,6 +1265,11 @@ */ 'sharing.showPublicLinkQuickAction' => false, +/** + * Save and display version of uploaded and edited files. + */ +'file_storage.save_version_author' => false, + /** * All other configuration options */ diff --git a/lib/private/Files/Meta/MetaFileVersionNode.php b/lib/private/Files/Meta/MetaFileVersionNode.php index d26df26431de..00ee300adbcc 100644 --- a/lib/private/Files/Meta/MetaFileVersionNode.php +++ b/lib/private/Files/Meta/MetaFileVersionNode.php @@ -28,6 +28,7 @@ use OCP\Files\IProvidesAdditionalHeaders; use OC\Preview; use OCA\Files_Sharing\SharedStorage; +use OCP\Files\IProvidesVersionAuthor; use OCP\Files\IRootFolder; use OCP\Files\IPreviewNode; use OCP\Files\Storage\IVersionedStorage; @@ -40,7 +41,7 @@ * * @package OC\Files\Meta */ -class MetaFileVersionNode extends AbstractFile implements IPreviewNode, IProvidesAdditionalHeaders { +class MetaFileVersionNode extends AbstractFile implements IPreviewNode, IProvidesAdditionalHeaders, IProvidesVersionAuthor { /** @var string */ private $versionId; @@ -79,6 +80,13 @@ public function __construct( $this->root = $root; } + /** + * @return string + */ + public function getEditedBy() : string { + return $this->versionInfo['edited_by'] ?? ''; + } + /** * @inheritdoc */ diff --git a/lib/public/Files/IProvidesVersionAuthor.php b/lib/public/Files/IProvidesVersionAuthor.php new file mode 100644 index 000000000000..d1b4706e628a --- /dev/null +++ b/lib/public/Files/IProvidesVersionAuthor.php @@ -0,0 +1,40 @@ + + * + * @copyright Copyright (c) 2021, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCP\Files; + +/** + * Interface IProvidesVersionAuthor + * This interface provides version author retrieval for file version + * + * @package OCP\Files + * @since 10.9.0 + */ +interface IProvidesVersionAuthor { + /** + * Returns the username of author which made this edit. Returns + * empty string if this is the initial version @see IProvidesVersionAuthor::getCreatedBy() + * + * @return string + * @since 10.9.0 + */ + public function getEditedBy() : string; +}