LegacyVersionsBackend.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\Files_Versions\Versions;
  8. use Exception;
  9. use OC\Files\View;
  10. use OCA\DAV\Connector\Sabre\Exception\Forbidden;
  11. use OCA\Files_Versions\Db\VersionEntity;
  12. use OCA\Files_Versions\Db\VersionsMapper;
  13. use OCA\Files_Versions\Storage;
  14. use OCP\Constants;
  15. use OCP\Files\File;
  16. use OCP\Files\FileInfo;
  17. use OCP\Files\Folder;
  18. use OCP\Files\IMimeTypeLoader;
  19. use OCP\Files\IRootFolder;
  20. use OCP\Files\Node;
  21. use OCP\Files\NotFoundException;
  22. use OCP\Files\Storage\ISharedStorage;
  23. use OCP\Files\Storage\IStorage;
  24. use OCP\IUser;
  25. use OCP\IUserManager;
  26. use OCP\IUserSession;
  27. use Psr\Log\LoggerInterface;
  28. class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend, IVersionsImporterBackend {
  29. public function __construct(
  30. private IRootFolder $rootFolder,
  31. private IUserManager $userManager,
  32. private VersionsMapper $versionsMapper,
  33. private IMimeTypeLoader $mimeTypeLoader,
  34. private IUserSession $userSession,
  35. private LoggerInterface $logger,
  36. ) {
  37. }
  38. public function useBackendForStorage(IStorage $storage): bool {
  39. return true;
  40. }
  41. public function getVersionsForFile(IUser $user, FileInfo $file): array {
  42. $storage = $file->getStorage();
  43. if ($storage->instanceOfStorage(ISharedStorage::class)) {
  44. $owner = $storage->getOwner('');
  45. if ($owner === false) {
  46. throw new NotFoundException('No owner for ' . $file->getPath());
  47. }
  48. $user = $this->userManager->get($owner);
  49. $fileId = $file->getId();
  50. if ($fileId === null) {
  51. throw new NotFoundException("File not found ($fileId)");
  52. }
  53. if ($user === null) {
  54. throw new NotFoundException("User $owner not found for $fileId");
  55. }
  56. $userFolder = $this->rootFolder->getUserFolder($user->getUID());
  57. $file = $userFolder->getFirstNodeById($fileId);
  58. if (!$file) {
  59. throw new NotFoundException('version file not found for share owner');
  60. }
  61. } else {
  62. $userFolder = $this->rootFolder->getUserFolder($user->getUID());
  63. }
  64. $fileId = $file->getId();
  65. if ($fileId === null) {
  66. throw new NotFoundException("File not found ($fileId)");
  67. }
  68. // Insert entries in the DB for existing versions.
  69. $relativePath = $userFolder->getRelativePath($file->getPath());
  70. if ($relativePath === null) {
  71. throw new NotFoundException("Relative path not found for file $fileId (" . $file->getPath() . ')');
  72. }
  73. $currentVersion = [
  74. 'version' => (string)$file->getMtime(),
  75. 'size' => $file->getSize(),
  76. 'mimetype' => $file->getMimetype(),
  77. ];
  78. $versionsInDB = $this->versionsMapper->findAllVersionsForFileId($file->getId());
  79. /** @var array<int, array> */
  80. $versionsInFS = array_values(Storage::getVersions($user->getUID(), $relativePath));
  81. /** @var array<int, array{db: ?VersionEntity, fs: ?mixed}> */
  82. $groupedVersions = [];
  83. $davVersions = [];
  84. foreach ($versionsInDB as $version) {
  85. $revisionId = $version->getTimestamp();
  86. $groupedVersions[$revisionId] = $groupedVersions[$revisionId] ?? [];
  87. $groupedVersions[$revisionId]['db'] = $version;
  88. }
  89. foreach ([$currentVersion, ...$versionsInFS] as $version) {
  90. $revisionId = $version['version'];
  91. $groupedVersions[$revisionId] = $groupedVersions[$revisionId] ?? [];
  92. $groupedVersions[$revisionId]['fs'] = $version;
  93. }
  94. /** @var array<string, array{db: ?VersionEntity, fs: ?mixed}> $groupedVersions */
  95. foreach ($groupedVersions as $versions) {
  96. if (empty($versions['db']) && !empty($versions['fs'])) {
  97. $versions['db'] = new VersionEntity();
  98. $versions['db']->setFileId($fileId);
  99. $versions['db']->setTimestamp((int)$versions['fs']['version']);
  100. $versions['db']->setSize((int)$versions['fs']['size']);
  101. $versions['db']->setMimetype($this->mimeTypeLoader->getId($versions['fs']['mimetype']));
  102. $versions['db']->setMetadata([]);
  103. $this->versionsMapper->insert($versions['db']);
  104. } elseif (!empty($versions['db']) && empty($versions['fs'])) {
  105. $this->versionsMapper->delete($versions['db']);
  106. continue;
  107. }
  108. $version = new Version(
  109. $versions['db']->getTimestamp(),
  110. $versions['db']->getTimestamp(),
  111. $file->getName(),
  112. $versions['db']->getSize(),
  113. $this->mimeTypeLoader->getMimetypeById($versions['db']->getMimetype()),
  114. $userFolder->getRelativePath($file->getPath()),
  115. $file,
  116. $this,
  117. $user,
  118. $versions['db']->getMetadata() ?? [],
  119. );
  120. array_push($davVersions, $version);
  121. }
  122. return $davVersions;
  123. }
  124. public function createVersion(IUser $user, FileInfo $file) {
  125. $userFolder = $this->rootFolder->getUserFolder($user->getUID());
  126. $relativePath = $userFolder->getRelativePath($file->getPath());
  127. $userView = new View('/' . $user->getUID());
  128. // create all parent folders
  129. Storage::createMissingDirectories($relativePath, $userView);
  130. Storage::scheduleExpire($user->getUID(), $relativePath);
  131. // store a new version of a file
  132. $userView->copy('files/' . $relativePath, 'files_versions/' . $relativePath . '.v' . $file->getMtime());
  133. // ensure the file is scanned
  134. $userView->getFileInfo('files_versions/' . $relativePath . '.v' . $file->getMtime());
  135. }
  136. public function rollback(IVersion $version) {
  137. if (!$this->currentUserHasPermissions($version->getSourceFile(), Constants::PERMISSION_UPDATE)) {
  138. throw new Forbidden('You cannot restore this version because you do not have update permissions on the source file.');
  139. }
  140. return Storage::rollback($version->getVersionPath(), $version->getRevisionId(), $version->getUser());
  141. }
  142. private function getVersionFolder(IUser $user): Folder {
  143. $userRoot = $this->rootFolder->getUserFolder($user->getUID())
  144. ->getParent();
  145. try {
  146. /** @var Folder $folder */
  147. $folder = $userRoot->get('files_versions');
  148. return $folder;
  149. } catch (NotFoundException $e) {
  150. return $userRoot->newFolder('files_versions');
  151. }
  152. }
  153. public function read(IVersion $version) {
  154. $versions = $this->getVersionFolder($version->getUser());
  155. /** @var File $file */
  156. $file = $versions->get($version->getVersionPath() . '.v' . $version->getRevisionId());
  157. return $file->fopen('r');
  158. }
  159. public function getVersionFile(IUser $user, FileInfo $sourceFile, $revision): File {
  160. $userFolder = $this->rootFolder->getUserFolder($user->getUID());
  161. $owner = $sourceFile->getOwner();
  162. $storage = $sourceFile->getStorage();
  163. // Shared files have their versions in the owners root folder so we need to obtain them from there
  164. if ($storage->instanceOfStorage(ISharedStorage::class) && $owner) {
  165. /** @var ISharedStorage $storage */
  166. $userFolder = $this->rootFolder->getUserFolder($owner->getUID());
  167. $user = $owner;
  168. $ownerPathInStorage = $sourceFile->getInternalPath();
  169. $sourceFile = $storage->getShare()->getNode();
  170. if ($sourceFile instanceof Folder) {
  171. $sourceFile = $sourceFile->get($ownerPathInStorage);
  172. }
  173. }
  174. $versionFolder = $this->getVersionFolder($user);
  175. /** @var File $file */
  176. $file = $versionFolder->get($userFolder->getRelativePath($sourceFile->getPath()) . '.v' . $revision);
  177. return $file;
  178. }
  179. public function deleteVersion(IVersion $version): void {
  180. if (!$this->currentUserHasPermissions($version->getSourceFile(), Constants::PERMISSION_DELETE)) {
  181. throw new Forbidden('You cannot delete this version because you do not have delete permissions on the source file.');
  182. }
  183. Storage::deleteRevision($version->getVersionPath(), $version->getRevisionId());
  184. $versionEntity = $this->versionsMapper->findVersionForFileId(
  185. $version->getSourceFile()->getId(),
  186. $version->getTimestamp(),
  187. );
  188. $this->versionsMapper->delete($versionEntity);
  189. }
  190. public function createVersionEntity(File $file): void {
  191. $versionEntity = new VersionEntity();
  192. $versionEntity->setFileId($file->getId());
  193. $versionEntity->setTimestamp($file->getMTime());
  194. $versionEntity->setSize($file->getSize());
  195. $versionEntity->setMimetype($this->mimeTypeLoader->getId($file->getMimetype()));
  196. $versionEntity->setMetadata([]);
  197. $tries = 1;
  198. while ($tries < 5) {
  199. try {
  200. $this->versionsMapper->insert($versionEntity);
  201. /* No errors, get out of the method */
  202. return;
  203. } catch (\OCP\DB\Exception $e) {
  204. if (!in_array($e->getReason(), [
  205. \OCP\DB\Exception::REASON_CONSTRAINT_VIOLATION,
  206. \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION,
  207. ])
  208. ) {
  209. throw $e;
  210. }
  211. /* Conflict with another version, increase mtime and try again */
  212. $versionEntity->setTimestamp($versionEntity->getTimestamp() + 1);
  213. $tries++;
  214. $this->logger->warning('Constraint violation while inserting version, retrying with increased timestamp', ['exception' => $e]);
  215. }
  216. }
  217. }
  218. public function updateVersionEntity(File $sourceFile, int $revision, array $properties): void {
  219. $versionEntity = $this->versionsMapper->findVersionForFileId($sourceFile->getId(), $revision);
  220. if (isset($properties['timestamp'])) {
  221. $versionEntity->setTimestamp($properties['timestamp']);
  222. }
  223. if (isset($properties['size'])) {
  224. $versionEntity->setSize($properties['size']);
  225. }
  226. if (isset($properties['mimetype'])) {
  227. $versionEntity->setMimetype($properties['mimetype']);
  228. }
  229. $this->versionsMapper->update($versionEntity);
  230. }
  231. public function deleteVersionsEntity(File $file): void {
  232. $this->versionsMapper->deleteAllVersionsForFileId($file->getId());
  233. }
  234. private function currentUserHasPermissions(FileInfo $sourceFile, int $permissions): bool {
  235. $currentUserId = $this->userSession->getUser()?->getUID();
  236. if ($currentUserId === null) {
  237. throw new NotFoundException('No user logged in');
  238. }
  239. if ($sourceFile->getOwner()?->getUID() === $currentUserId) {
  240. return ($sourceFile->getPermissions() & $permissions) === $permissions;
  241. }
  242. $nodes = $this->rootFolder->getUserFolder($currentUserId)->getById($sourceFile->getId());
  243. if (count($nodes) === 0) {
  244. throw new NotFoundException('Version file not accessible by current user');
  245. }
  246. foreach ($nodes as $node) {
  247. if (($node->getPermissions() & $permissions) === $permissions) {
  248. return true;
  249. }
  250. }
  251. return false;
  252. }
  253. public function setMetadataValue(Node $node, int $revision, string $key, string $value): void {
  254. if (!$this->currentUserHasPermissions($node, Constants::PERMISSION_UPDATE)) {
  255. throw new Forbidden('You cannot update the version\'s metadata because you do not have update permissions on the source file.');
  256. }
  257. $versionEntity = $this->versionsMapper->findVersionForFileId($node->getId(), $revision);
  258. $versionEntity->setMetadataValue($key, $value);
  259. $this->versionsMapper->update($versionEntity);
  260. }
  261. /**
  262. * @inheritdoc
  263. */
  264. public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void {
  265. $userFolder = $this->rootFolder->getUserFolder($user->getUID());
  266. $relativePath = $userFolder->getRelativePath($target->getPath());
  267. if ($relativePath === null) {
  268. throw new \Exception('Target does not have a relative path' . $target->getPath());
  269. }
  270. $userView = new View('/' . $user->getUID());
  271. // create all parent folders
  272. Storage::createMissingDirectories($relativePath, $userView);
  273. Storage::scheduleExpire($user->getUID(), $relativePath);
  274. foreach ($versions as $version) {
  275. // 1. Import the file in its new location.
  276. // Nothing to do for the current version.
  277. if ($version->getTimestamp() !== $source->getMTime()) {
  278. $backend = $version->getBackend();
  279. $versionFile = $backend->getVersionFile($user, $source, $version->getRevisionId());
  280. $newVersionPath = 'files_versions/' . $relativePath . '.v' . $version->getTimestamp();
  281. $versionContent = $versionFile->fopen('r');
  282. if ($versionContent === false) {
  283. $this->logger->warning('Fail to open version file.', ['source' => $source, 'version' => $version, 'versionFile' => $versionFile]);
  284. continue;
  285. }
  286. $userView->file_put_contents($newVersionPath, $versionContent);
  287. // ensure the file is scanned
  288. $userView->getFileInfo($newVersionPath);
  289. }
  290. // 2. Create the entity in the database
  291. $versionEntity = new VersionEntity();
  292. $versionEntity->setFileId($target->getId());
  293. $versionEntity->setTimestamp($version->getTimestamp());
  294. $versionEntity->setSize($version->getSize());
  295. $versionEntity->setMimetype($this->mimeTypeLoader->getId($version->getMimetype()));
  296. if ($version instanceof IMetadataVersion) {
  297. $versionEntity->setMetadata($version->getMetadata());
  298. }
  299. $this->versionsMapper->insert($versionEntity);
  300. }
  301. }
  302. /**
  303. * @inheritdoc
  304. */
  305. public function clearVersionsForFile(IUser $user, Node $source, Node $target): void {
  306. $userFolder = $this->rootFolder->getUserFolder($user->getUID());
  307. $relativePath = $userFolder->getRelativePath($source->getPath());
  308. if ($relativePath === null) {
  309. throw new Exception('Relative path not found for node with path: ' . $source->getPath());
  310. }
  311. $versions = Storage::getVersions($user->getUID(), $relativePath);
  312. /** @var Folder versionFolder */
  313. $versionFolder = $this->rootFolder->get('admin/files_versions');
  314. foreach ($versions as $version) {
  315. $versionFolder->get($version['path'] . '.v' . (int)$version['version'])->delete();
  316. }
  317. $this->versionsMapper->deleteAllVersionsForFileId($target->getId());
  318. }
  319. }