Completed
Push — master ( 90bbef...7a7d0b )
by
unknown
32:50
created
apps/files_versions/lib/Storage.php 1 patch
Indentation   +957 added lines, -957 removed lines patch added patch discarded remove patch
@@ -47,961 +47,961 @@
 block discarded – undo
47 47
 use Psr\Log\LoggerInterface;
48 48
 
49 49
 class Storage {
50
-	public const DEFAULTENABLED = true;
51
-	public const DEFAULTMAXSIZE = 50; // unit: percentage; 50% of available disk space/quota
52
-	public const VERSIONS_ROOT = 'files_versions/';
53
-
54
-	public const DELETE_TRIGGER_MASTER_REMOVED = 0;
55
-	public const DELETE_TRIGGER_RETENTION_CONSTRAINT = 1;
56
-	public const DELETE_TRIGGER_QUOTA_EXCEEDED = 2;
57
-
58
-	// files for which we can remove the versions after the delete operation was successful
59
-	private static $deletedFiles = [];
60
-
61
-	private static $sourcePathAndUser = [];
62
-
63
-	private static $max_versions_per_interval = [
64
-		//first 10sec, one version every 2sec
65
-		1 => ['intervalEndsAfter' => 10,      'step' => 2],
66
-		//next minute, one version every 10sec
67
-		2 => ['intervalEndsAfter' => 60,      'step' => 10],
68
-		//next hour, one version every minute
69
-		3 => ['intervalEndsAfter' => 3600,    'step' => 60],
70
-		//next 24h, one version every hour
71
-		4 => ['intervalEndsAfter' => 86400,   'step' => 3600],
72
-		//next 30days, one version per day
73
-		5 => ['intervalEndsAfter' => 2592000, 'step' => 86400],
74
-		//until the end one version per week
75
-		6 => ['intervalEndsAfter' => -1,      'step' => 604800],
76
-	];
77
-
78
-	/** @var Application */
79
-	private static $application;
80
-
81
-	/**
82
-	 * get the UID of the owner of the file and the path to the file relative to
83
-	 * owners files folder
84
-	 *
85
-	 * @param string $filename
86
-	 * @return array
87
-	 * @throws NoUserException
88
-	 */
89
-	public static function getUidAndFilename($filename) {
90
-		$uid = Filesystem::getOwner($filename);
91
-		$userManager = Server::get(IUserManager::class);
92
-		// if the user with the UID doesn't exists, e.g. because the UID points
93
-		// to a remote user with a federated cloud ID we use the current logged-in
94
-		// user. We need a valid local user to create the versions
95
-		if (!$userManager->userExists($uid)) {
96
-			$uid = OC_User::getUser();
97
-		}
98
-		Filesystem::initMountPoints($uid);
99
-		if ($uid !== OC_User::getUser()) {
100
-			$info = Filesystem::getFileInfo($filename);
101
-			$ownerView = new View('/' . $uid . '/files');
102
-			try {
103
-				$filename = $ownerView->getPath($info['fileid']);
104
-				// make sure that the file name doesn't end with a trailing slash
105
-				// can for example happen single files shared across servers
106
-				$filename = rtrim($filename, '/');
107
-			} catch (NotFoundException $e) {
108
-				$filename = null;
109
-			}
110
-		}
111
-		return [$uid, $filename];
112
-	}
113
-
114
-	/**
115
-	 * Remember the owner and the owner path of the source file
116
-	 *
117
-	 * @param string $source source path
118
-	 */
119
-	public static function setSourcePathAndUser($source) {
120
-		[$uid, $path] = self::getUidAndFilename($source);
121
-		self::$sourcePathAndUser[$source] = ['uid' => $uid, 'path' => $path];
122
-	}
123
-
124
-	/**
125
-	 * Gets the owner and the owner path from the source path
126
-	 *
127
-	 * @param string $source source path
128
-	 * @return array with user id and path
129
-	 */
130
-	public static function getSourcePathAndUser($source) {
131
-		if (isset(self::$sourcePathAndUser[$source])) {
132
-			$uid = self::$sourcePathAndUser[$source]['uid'];
133
-			$path = self::$sourcePathAndUser[$source]['path'];
134
-			unset(self::$sourcePathAndUser[$source]);
135
-		} else {
136
-			$uid = $path = false;
137
-		}
138
-		return [$uid, $path];
139
-	}
140
-
141
-	/**
142
-	 * get current size of all versions from a given user
143
-	 *
144
-	 * @param string $user user who owns the versions
145
-	 * @return int versions size
146
-	 */
147
-	private static function getVersionsSize($user) {
148
-		$view = new View('/' . $user);
149
-		$fileInfo = $view->getFileInfo('/files_versions');
150
-		return isset($fileInfo['size']) ? $fileInfo['size'] : 0;
151
-	}
152
-
153
-	/**
154
-	 * store a new version of a file.
155
-	 */
156
-	public static function store($filename) {
157
-		// if the file gets streamed we need to remove the .part extension
158
-		// to get the right target
159
-		$ext = pathinfo($filename, PATHINFO_EXTENSION);
160
-		if ($ext === 'part') {
161
-			$filename = substr($filename, 0, -5);
162
-		}
163
-
164
-		// we only handle existing files
165
-		if (! Filesystem::file_exists($filename) || Filesystem::is_dir($filename)) {
166
-			return false;
167
-		}
168
-
169
-		// since hook paths are always relative to the "default filesystem view"
170
-		// we always use the owner from there to get the full node
171
-		$uid = Filesystem::getView()->getOwner('');
172
-
173
-		/** @var IRootFolder $rootFolder */
174
-		$rootFolder = Server::get(IRootFolder::class);
175
-		$userFolder = $rootFolder->getUserFolder($uid);
176
-
177
-		$eventDispatcher = Server::get(IEventDispatcher::class);
178
-		try {
179
-			$file = $userFolder->get($filename);
180
-		} catch (NotFoundException $e) {
181
-			return false;
182
-		}
183
-
184
-		$mount = $file->getMountPoint();
185
-		if ($mount instanceof SharedMount) {
186
-			$ownerFolder = $rootFolder->getUserFolder($mount->getShare()->getShareOwner());
187
-			$ownerNode = $ownerFolder->getFirstNodeById($file->getId());
188
-			if ($ownerNode) {
189
-				$file = $ownerNode;
190
-				$uid = $mount->getShare()->getShareOwner();
191
-			}
192
-		}
193
-
194
-		/** @var IUserManager $userManager */
195
-		$userManager = Server::get(IUserManager::class);
196
-		$user = $userManager->get($uid);
197
-
198
-		if (!$user) {
199
-			return false;
200
-		}
201
-
202
-		// no use making versions for empty files
203
-		if ($file->getSize() === 0) {
204
-			return false;
205
-		}
206
-
207
-		$event = new CreateVersionEvent($file);
208
-		$eventDispatcher->dispatch('OCA\Files_Versions::createVersion', $event);
209
-		if ($event->shouldCreateVersion() === false) {
210
-			return false;
211
-		}
212
-
213
-		/** @var IVersionManager $versionManager */
214
-		$versionManager = Server::get(IVersionManager::class);
215
-
216
-		$versionManager->createVersion($user, $file);
217
-	}
218
-
219
-
220
-	/**
221
-	 * mark file as deleted so that we can remove the versions if the file is gone
222
-	 * @param string $path
223
-	 */
224
-	public static function markDeletedFile($path) {
225
-		[$uid, $filename] = self::getUidAndFilename($path);
226
-		self::$deletedFiles[$path] = [
227
-			'uid' => $uid,
228
-			'filename' => $filename];
229
-	}
230
-
231
-	/**
232
-	 * delete the version from the storage and cache
233
-	 *
234
-	 * @param View $view
235
-	 * @param string $path
236
-	 */
237
-	protected static function deleteVersion($view, $path) {
238
-		$view->unlink($path);
239
-		/**
240
-		 * @var \OC\Files\Storage\Storage $storage
241
-		 * @var string $internalPath
242
-		 */
243
-		[$storage, $internalPath] = $view->resolvePath($path);
244
-		$cache = $storage->getCache($internalPath);
245
-		$cache->remove($internalPath);
246
-	}
247
-
248
-	/**
249
-	 * Delete versions of a file
250
-	 */
251
-	public static function delete($path) {
252
-		$deletedFile = self::$deletedFiles[$path];
253
-		$uid = $deletedFile['uid'];
254
-		$filename = $deletedFile['filename'];
255
-
256
-		if (!Filesystem::file_exists($path)) {
257
-			$view = new View('/' . $uid . '/files_versions');
258
-
259
-			$versions = self::getVersions($uid, $filename);
260
-			if (!empty($versions)) {
261
-				foreach ($versions as $v) {
262
-					\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
263
-					self::deleteVersion($view, $filename . '.v' . $v['version']);
264
-					\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
265
-				}
266
-			}
267
-		}
268
-		unset(self::$deletedFiles[$path]);
269
-	}
270
-
271
-	/**
272
-	 * Delete a version of a file
273
-	 */
274
-	public static function deleteRevision(string $path, int $revision): void {
275
-		[$uid, $filename] = self::getUidAndFilename($path);
276
-		$view = new View('/' . $uid . '/files_versions');
277
-		\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
278
-		self::deleteVersion($view, $filename . '.v' . $revision);
279
-		\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
280
-	}
281
-
282
-	/**
283
-	 * Rename or copy versions of a file of the given paths
284
-	 *
285
-	 * @param string $sourcePath source path of the file to move, relative to
286
-	 *                           the currently logged in user's "files" folder
287
-	 * @param string $targetPath target path of the file to move, relative to
288
-	 *                           the currently logged in user's "files" folder
289
-	 * @param string $operation can be 'copy' or 'rename'
290
-	 */
291
-	public static function renameOrCopy($sourcePath, $targetPath, $operation) {
292
-		[$sourceOwner, $sourcePath] = self::getSourcePathAndUser($sourcePath);
293
-
294
-		// it was a upload of a existing file if no old path exists
295
-		// in this case the pre-hook already called the store method and we can
296
-		// stop here
297
-		if ($sourcePath === false) {
298
-			return true;
299
-		}
300
-
301
-		[$targetOwner, $targetPath] = self::getUidAndFilename($targetPath);
302
-
303
-		$sourcePath = ltrim($sourcePath, '/');
304
-		$targetPath = ltrim($targetPath, '/');
305
-
306
-		$rootView = new View('');
307
-
308
-		// did we move a directory ?
309
-		if ($rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
310
-			// does the directory exists for versions too ?
311
-			if ($rootView->is_dir('/' . $sourceOwner . '/files_versions/' . $sourcePath)) {
312
-				// create missing dirs if necessary
313
-				self::createMissingDirectories($targetPath, new View('/' . $targetOwner));
314
-
315
-				// move the directory containing the versions
316
-				$rootView->$operation(
317
-					'/' . $sourceOwner . '/files_versions/' . $sourcePath,
318
-					'/' . $targetOwner . '/files_versions/' . $targetPath
319
-				);
320
-			}
321
-		} elseif ($versions = Storage::getVersions($sourceOwner, '/' . $sourcePath)) {
322
-			// create missing dirs if necessary
323
-			self::createMissingDirectories($targetPath, new View('/' . $targetOwner));
324
-
325
-			foreach ($versions as $v) {
326
-				// move each version one by one to the target directory
327
-				$rootView->$operation(
328
-					'/' . $sourceOwner . '/files_versions/' . $sourcePath . '.v' . $v['version'],
329
-					'/' . $targetOwner . '/files_versions/' . $targetPath . '.v' . $v['version']
330
-				);
331
-			}
332
-		}
333
-
334
-		// if we moved versions directly for a file, schedule expiration check for that file
335
-		if (!$rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
336
-			self::scheduleExpire($targetOwner, $targetPath);
337
-		}
338
-	}
339
-
340
-	/**
341
-	 * Rollback to an old version of a file.
342
-	 *
343
-	 * @param string $file file name
344
-	 * @param int $revision revision timestamp
345
-	 * @return bool
346
-	 */
347
-	public static function rollback(string $file, int $revision, IUser $user) {
348
-		// add expected leading slash
349
-		$filename = '/' . ltrim($file, '/');
350
-
351
-		// Fetch the userfolder to trigger view hooks
352
-		$root = Server::get(IRootFolder::class);
353
-		$userFolder = $root->getUserFolder($user->getUID());
354
-
355
-		$users_view = new View('/' . $user->getUID());
356
-		$files_view = new View('/' . $user->getUID() . '/files');
357
-
358
-		$versionCreated = false;
359
-
360
-		$fileInfo = $files_view->getFileInfo($file);
361
-
362
-		// check if user has the permissions to revert a version
363
-		if (!$fileInfo->isUpdateable()) {
364
-			return false;
365
-		}
366
-
367
-		//first create a new version
368
-		$version = 'files_versions' . $filename . '.v' . $users_view->filemtime('files' . $filename);
369
-		if (!$users_view->file_exists($version)) {
370
-			$users_view->copy('files' . $filename, 'files_versions' . $filename . '.v' . $users_view->filemtime('files' . $filename));
371
-			$versionCreated = true;
372
-		}
373
-
374
-		$fileToRestore = 'files_versions' . $filename . '.v' . $revision;
375
-
376
-		// Restore encrypted version of the old file for the newly restored file
377
-		// This has to happen manually here since the file is manually copied below
378
-		$oldVersion = $users_view->getFileInfo($fileToRestore)->getEncryptedVersion();
379
-		$oldFileInfo = $users_view->getFileInfo($fileToRestore);
380
-		$cache = $fileInfo->getStorage()->getCache();
381
-		$cache->update(
382
-			$fileInfo->getId(), [
383
-				'encrypted' => $oldVersion,
384
-				'encryptedVersion' => $oldVersion,
385
-				'size' => $oldFileInfo->getData()['size'],
386
-				'unencrypted_size' => $oldFileInfo->getData()['unencrypted_size'],
387
-			]
388
-		);
389
-
390
-		// rollback
391
-		if (self::copyFileContents($users_view, $fileToRestore, 'files' . $filename)) {
392
-			$files_view->touch($file, $revision);
393
-			Storage::scheduleExpire($user->getUID(), $file);
394
-
395
-			return true;
396
-		} elseif ($versionCreated) {
397
-			self::deleteVersion($users_view, $version);
398
-		}
399
-
400
-		return false;
401
-	}
402
-
403
-	/**
404
-	 * Stream copy file contents from $path1 to $path2
405
-	 *
406
-	 * @param View $view view to use for copying
407
-	 * @param string $path1 source file to copy
408
-	 * @param string $path2 target file
409
-	 *
410
-	 * @return bool true for success, false otherwise
411
-	 */
412
-	private static function copyFileContents($view, $path1, $path2) {
413
-		/** @var \OC\Files\Storage\Storage $storage1 */
414
-		[$storage1, $internalPath1] = $view->resolvePath($path1);
415
-		/** @var \OC\Files\Storage\Storage $storage2 */
416
-		[$storage2, $internalPath2] = $view->resolvePath($path2);
417
-
418
-		$view->lockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
419
-		$view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
420
-
421
-		try {
422
-			// TODO add a proper way of overwriting a file while maintaining file ids
423
-			if ($storage1->instanceOfStorage(ObjectStoreStorage::class)
424
-				|| $storage2->instanceOfStorage(ObjectStoreStorage::class)
425
-			) {
426
-				$source = $storage1->fopen($internalPath1, 'r');
427
-				$result = $source !== false;
428
-				if ($result) {
429
-					if ($storage2->instanceOfStorage(IWriteStreamStorage::class)) {
430
-						/** @var IWriteStreamStorage $storage2 */
431
-						$storage2->writeStream($internalPath2, $source);
432
-					} else {
433
-						$target = $storage2->fopen($internalPath2, 'w');
434
-						$result = $target !== false;
435
-						if ($result) {
436
-							[, $result] = Files::streamCopy($source, $target, true);
437
-						}
438
-						// explicit check as S3 library closes streams already
439
-						if (is_resource($target)) {
440
-							fclose($target);
441
-						}
442
-					}
443
-				}
444
-				// explicit check as S3 library closes streams already
445
-				if (is_resource($source)) {
446
-					fclose($source);
447
-				}
448
-
449
-				if ($result !== false) {
450
-					$storage1->unlink($internalPath1);
451
-				}
452
-			} else {
453
-				$result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2);
454
-			}
455
-		} finally {
456
-			$view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
457
-			$view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
458
-		}
459
-
460
-		return ($result !== false);
461
-	}
462
-
463
-	/**
464
-	 * get a list of all available versions of a file in descending chronological order
465
-	 * @param string $uid user id from the owner of the file
466
-	 * @param string $filename file to find versions of, relative to the user files dir
467
-	 * @param string $userFullPath
468
-	 * @return array versions newest version first
469
-	 */
470
-	public static function getVersions($uid, $filename, $userFullPath = '') {
471
-		$versions = [];
472
-		if (empty($filename)) {
473
-			return $versions;
474
-		}
475
-		// fetch for old versions
476
-		$view = new View('/' . $uid . '/');
477
-
478
-		$pathinfo = pathinfo($filename);
479
-		$versionedFile = $pathinfo['basename'];
480
-
481
-		$dir = Filesystem::normalizePath(self::VERSIONS_ROOT . '/' . $pathinfo['dirname']);
482
-
483
-		$dirContent = false;
484
-		if ($view->is_dir($dir)) {
485
-			$dirContent = $view->opendir($dir);
486
-		}
487
-
488
-		if ($dirContent === false) {
489
-			return $versions;
490
-		}
491
-
492
-		if (is_resource($dirContent)) {
493
-			while (($entryName = readdir($dirContent)) !== false) {
494
-				if (!Filesystem::isIgnoredDir($entryName)) {
495
-					$pathparts = pathinfo($entryName);
496
-					$filename = $pathparts['filename'];
497
-					if ($filename === $versionedFile) {
498
-						$pathparts = pathinfo($entryName);
499
-						$timestamp = substr($pathparts['extension'] ?? '', 1);
500
-						if (!is_numeric($timestamp)) {
501
-							Server::get(LoggerInterface::class)->error(
502
-								'Version file {path} has incorrect name format',
503
-								[
504
-									'path' => $entryName,
505
-									'app' => 'files_versions',
506
-								]
507
-							);
508
-							continue;
509
-						}
510
-						$filename = $pathparts['filename'];
511
-						$key = $timestamp . '#' . $filename;
512
-						$versions[$key]['version'] = $timestamp;
513
-						$versions[$key]['humanReadableTimestamp'] = self::getHumanReadableTimestamp((int)$timestamp);
514
-						if (empty($userFullPath)) {
515
-							$versions[$key]['preview'] = '';
516
-						} else {
517
-							/** @var IURLGenerator $urlGenerator */
518
-							$urlGenerator = Server::get(IURLGenerator::class);
519
-							$versions[$key]['preview'] = $urlGenerator->linkToRoute('files_version.Preview.getPreview',
520
-								['file' => $userFullPath, 'version' => $timestamp]);
521
-						}
522
-						$versions[$key]['path'] = Filesystem::normalizePath($pathinfo['dirname'] . '/' . $filename);
523
-						$versions[$key]['name'] = $versionedFile;
524
-						$versions[$key]['size'] = $view->filesize($dir . '/' . $entryName);
525
-						$versions[$key]['mimetype'] = Server::get(IMimeTypeDetector::class)->detectPath($versionedFile);
526
-					}
527
-				}
528
-			}
529
-			closedir($dirContent);
530
-		}
531
-
532
-		// sort with newest version first
533
-		krsort($versions);
534
-
535
-		return $versions;
536
-	}
537
-
538
-	/**
539
-	 * Expire versions that older than max version retention time
540
-	 *
541
-	 * @param string $uid
542
-	 */
543
-	public static function expireOlderThanMaxForUser($uid) {
544
-		/** @var IRootFolder $root */
545
-		$root = Server::get(IRootFolder::class);
546
-		try {
547
-			/** @var Folder $versionsRoot */
548
-			$versionsRoot = $root->get('/' . $uid . '/files_versions');
549
-		} catch (NotFoundException $e) {
550
-			return;
551
-		}
552
-
553
-		$expiration = self::getExpiration();
554
-		$threshold = $expiration->getMaxAgeAsTimestamp();
555
-		if (!$threshold) {
556
-			return;
557
-		}
558
-
559
-		$allVersions = $versionsRoot->search(new SearchQuery(
560
-			new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_NOT, [
561
-				new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', FileInfo::MIMETYPE_FOLDER),
562
-			]),
563
-			0,
564
-			0,
565
-			[]
566
-		));
567
-
568
-		/** @var VersionsMapper $versionsMapper */
569
-		$versionsMapper = Server::get(VersionsMapper::class);
570
-		$userFolder = $root->getUserFolder($uid);
571
-		$versionEntities = [];
572
-
573
-		/** @var Node[] $versions */
574
-		$versions = array_filter($allVersions, function (Node $info) use ($threshold, $userFolder, $versionsMapper, $versionsRoot, &$versionEntities) {
575
-			// Check that the file match '*.v*'
576
-			$versionsBegin = strrpos($info->getName(), '.v');
577
-			if ($versionsBegin === false) {
578
-				return false;
579
-			}
580
-
581
-			$version = (int)substr($info->getName(), $versionsBegin + 2);
582
-
583
-			// Check that the version does not have a label.
584
-			$path = $versionsRoot->getRelativePath($info->getPath());
585
-			if ($path === null) {
586
-				throw new DoesNotExistException('Could not find relative path of (' . $info->getPath() . ')');
587
-			}
588
-
589
-			try {
590
-				$node = $userFolder->get(substr($path, 0, -strlen('.v' . $version)));
591
-				$versionEntity = $versionsMapper->findVersionForFileId($node->getId(), $version);
592
-				$versionEntities[$info->getId()] = $versionEntity;
593
-
594
-				if ($versionEntity->getMetadataValue('label') !== null && $versionEntity->getMetadataValue('label') !== '') {
595
-					return false;
596
-				}
597
-			} catch (NotFoundException $e) {
598
-				// Original node not found, delete the version
599
-				return true;
600
-			} catch (StorageNotAvailableException|StorageInvalidException $e) {
601
-				// Storage can't be used, but it might only be temporary so we can't always delete the version
602
-				// since we can't determine if the version is named we take the safe route and don't expire
603
-				return false;
604
-			} catch (DoesNotExistException $ex) {
605
-				// Version on FS can have no equivalent in the DB if they were created before the version naming feature.
606
-				// So we ignore DoesNotExistException.
607
-			}
608
-
609
-			// Check that the version's timestamp is lower than $threshold
610
-			return $version < $threshold;
611
-		});
612
-
613
-		foreach ($versions as $version) {
614
-			$internalPath = $version->getInternalPath();
615
-			\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
616
-
617
-			$versionEntity = isset($versionEntities[$version->getId()]) ? $versionEntities[$version->getId()] : null;
618
-			if (!is_null($versionEntity)) {
619
-				$versionsMapper->delete($versionEntity);
620
-			}
621
-
622
-			try {
623
-				$version->delete();
624
-				\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
625
-			} catch (NotPermittedException $e) {
626
-				Server::get(LoggerInterface::class)->error("Missing permissions to delete version for user {$uid}: {$internalPath}", ['app' => 'files_versions', 'exception' => $e]);
627
-			}
628
-		}
629
-	}
630
-
631
-	/**
632
-	 * translate a timestamp into a string like "5 days ago"
633
-	 *
634
-	 * @param int $timestamp
635
-	 * @return string for example "5 days ago"
636
-	 */
637
-	private static function getHumanReadableTimestamp(int $timestamp): string {
638
-		$diff = time() - $timestamp;
639
-
640
-		if ($diff < 60) { // first minute
641
-			return  $diff . ' seconds ago';
642
-		} elseif ($diff < 3600) { //first hour
643
-			return round($diff / 60) . ' minutes ago';
644
-		} elseif ($diff < 86400) { // first day
645
-			return round($diff / 3600) . ' hours ago';
646
-		} elseif ($diff < 604800) { //first week
647
-			return round($diff / 86400) . ' days ago';
648
-		} elseif ($diff < 2419200) { //first month
649
-			return round($diff / 604800) . ' weeks ago';
650
-		} elseif ($diff < 29030400) { // first year
651
-			return round($diff / 2419200) . ' months ago';
652
-		} else {
653
-			return round($diff / 29030400) . ' years ago';
654
-		}
655
-	}
656
-
657
-	/**
658
-	 * returns all stored file versions from a given user
659
-	 * @param string $uid id of the user
660
-	 * @return array with contains two arrays 'all' which contains all versions sorted by age and 'by_file' which contains all versions sorted by filename
661
-	 */
662
-	private static function getAllVersions($uid) {
663
-		$view = new View('/' . $uid . '/');
664
-		$dirs = [self::VERSIONS_ROOT];
665
-		$versions = [];
666
-
667
-		while (!empty($dirs)) {
668
-			$dir = array_pop($dirs);
669
-			$files = $view->getDirectoryContent($dir);
670
-
671
-			foreach ($files as $file) {
672
-				$fileData = $file->getData();
673
-				$filePath = $dir . '/' . $fileData['name'];
674
-				if ($file['type'] === 'dir') {
675
-					$dirs[] = $filePath;
676
-				} else {
677
-					$versionsBegin = strrpos($filePath, '.v');
678
-					$relPathStart = strlen(self::VERSIONS_ROOT);
679
-					$version = substr($filePath, $versionsBegin + 2);
680
-					$relpath = substr($filePath, $relPathStart, $versionsBegin - $relPathStart);
681
-					$key = $version . '#' . $relpath;
682
-					$versions[$key] = ['path' => $relpath, 'timestamp' => $version];
683
-				}
684
-			}
685
-		}
686
-
687
-		// newest version first
688
-		krsort($versions);
689
-
690
-		$result = [
691
-			'all' => [],
692
-			'by_file' => [],
693
-		];
694
-
695
-		foreach ($versions as $key => $value) {
696
-			$size = $view->filesize(self::VERSIONS_ROOT . '/' . $value['path'] . '.v' . $value['timestamp']);
697
-			$filename = $value['path'];
698
-
699
-			$result['all'][$key]['version'] = $value['timestamp'];
700
-			$result['all'][$key]['path'] = $filename;
701
-			$result['all'][$key]['size'] = $size;
702
-
703
-			$result['by_file'][$filename][$key]['version'] = $value['timestamp'];
704
-			$result['by_file'][$filename][$key]['path'] = $filename;
705
-			$result['by_file'][$filename][$key]['size'] = $size;
706
-		}
707
-
708
-		return $result;
709
-	}
710
-
711
-	/**
712
-	 * get list of files we want to expire
713
-	 * @param array $versions list of versions
714
-	 * @param integer $time
715
-	 * @param bool $quotaExceeded is versions storage limit reached
716
-	 * @return array containing the list of to deleted versions and the size of them
717
-	 */
718
-	protected static function getExpireList($time, $versions, $quotaExceeded = false) {
719
-		$expiration = self::getExpiration();
720
-
721
-		if ($expiration->shouldAutoExpire()) {
722
-			// Exclude versions that are newer than the minimum age from the auto expiration logic.
723
-			$minAge = $expiration->getMinAgeAsTimestamp();
724
-			if ($minAge !== false) {
725
-				$versionsToAutoExpire = array_filter($versions, fn ($version) => $version['version'] < $minAge);
726
-			} else {
727
-				$versionsToAutoExpire = $versions;
728
-			}
729
-
730
-			[$toDelete, $size] = self::getAutoExpireList($time, $versionsToAutoExpire);
731
-		} else {
732
-			$size = 0;
733
-			$toDelete = [];  // versions we want to delete
734
-		}
735
-
736
-		foreach ($versions as $key => $version) {
737
-			if (!is_numeric($version['version'])) {
738
-				Server::get(LoggerInterface::class)->error(
739
-					'Found a non-numeric timestamp version: ' . json_encode($version),
740
-					['app' => 'files_versions']);
741
-				continue;
742
-			}
743
-			if ($expiration->isExpired((int)($version['version']), $quotaExceeded) && !isset($toDelete[$key])) {
744
-				$size += $version['size'];
745
-				$toDelete[$key] = $version['path'] . '.v' . $version['version'];
746
-			}
747
-		}
748
-
749
-		return [$toDelete, $size];
750
-	}
751
-
752
-	/**
753
-	 * get list of files we want to expire
754
-	 * @param array $versions list of versions
755
-	 * @param integer $time
756
-	 * @return array containing the list of to deleted versions and the size of them
757
-	 */
758
-	protected static function getAutoExpireList($time, $versions) {
759
-		$size = 0;
760
-		$toDelete = [];  // versions we want to delete
761
-
762
-		$interval = 1;
763
-		$step = Storage::$max_versions_per_interval[$interval]['step'];
764
-		if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) {
765
-			$nextInterval = -1;
766
-		} else {
767
-			$nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
768
-		}
769
-
770
-		$firstVersion = reset($versions);
771
-
772
-		if ($firstVersion === false) {
773
-			return [$toDelete, $size];
774
-		}
775
-
776
-		$firstKey = key($versions);
777
-		$prevTimestamp = $firstVersion['version'];
778
-		$nextVersion = $firstVersion['version'] - $step;
779
-		unset($versions[$firstKey]);
780
-
781
-		foreach ($versions as $key => $version) {
782
-			$newInterval = true;
783
-			while ($newInterval) {
784
-				if ($nextInterval === -1 || $prevTimestamp > $nextInterval) {
785
-					if ($version['version'] > $nextVersion) {
786
-						//distance between two version too small, mark to delete
787
-						$toDelete[$key] = $version['path'] . '.v' . $version['version'];
788
-						$size += $version['size'];
789
-						Server::get(LoggerInterface::class)->info('Mark to expire ' . $version['path'] . ' next version should be ' . $nextVersion . ' or smaller. (prevTimestamp: ' . $prevTimestamp . '; step: ' . $step, ['app' => 'files_versions']);
790
-					} else {
791
-						$nextVersion = $version['version'] - $step;
792
-						$prevTimestamp = $version['version'];
793
-					}
794
-					$newInterval = false; // version checked so we can move to the next one
795
-				} else { // time to move on to the next interval
796
-					$interval++;
797
-					$step = Storage::$max_versions_per_interval[$interval]['step'];
798
-					$nextVersion = $prevTimestamp - $step;
799
-					if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) {
800
-						$nextInterval = -1;
801
-					} else {
802
-						$nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
803
-					}
804
-					$newInterval = true; // we changed the interval -> check same version with new interval
805
-				}
806
-			}
807
-		}
808
-
809
-		return [$toDelete, $size];
810
-	}
811
-
812
-	/**
813
-	 * Schedule versions expiration for the given file
814
-	 *
815
-	 * @param string $uid owner of the file
816
-	 * @param string $fileName file/folder for which to schedule expiration
817
-	 */
818
-	public static function scheduleExpire($uid, $fileName) {
819
-		// let the admin disable auto expire
820
-		$expiration = self::getExpiration();
821
-		if ($expiration->isEnabled()) {
822
-			$command = new Expire($uid, $fileName);
823
-			/** @var IBus $bus */
824
-			$bus = Server::get(IBus::class);
825
-			$bus->push($command);
826
-		}
827
-	}
828
-
829
-	/**
830
-	 * Expire versions which exceed the quota.
831
-	 *
832
-	 * This will setup the filesystem for the given user but will not
833
-	 * tear it down afterwards.
834
-	 *
835
-	 * @param string $filename path to file to expire
836
-	 * @param string $uid user for which to expire the version
837
-	 * @return bool|int|null
838
-	 */
839
-	public static function expire($filename, $uid) {
840
-		$expiration = self::getExpiration();
841
-
842
-		/** @var LoggerInterface $logger */
843
-		$logger = Server::get(LoggerInterface::class);
844
-
845
-		if ($expiration->isEnabled()) {
846
-			// get available disk space for user
847
-			$user = Server::get(IUserManager::class)->get($uid);
848
-			if (is_null($user)) {
849
-				$logger->error('Backends provided no user object for ' . $uid, ['app' => 'files_versions']);
850
-				throw new NoUserException('Backends provided no user object for ' . $uid);
851
-			}
852
-
853
-			\OC_Util::setupFS($uid);
854
-
855
-			try {
856
-				if (!Filesystem::file_exists($filename)) {
857
-					return false;
858
-				}
859
-			} catch (StorageNotAvailableException $e) {
860
-				// if we can't check that the file hasn't been deleted we can only assume that it hasn't
861
-				// note that this `StorageNotAvailableException` is about the file the versions originate from,
862
-				// not the storage that the versions are stored on
863
-			}
864
-
865
-			if (empty($filename)) {
866
-				// file maybe renamed or deleted
867
-				return false;
868
-			}
869
-			$versionsFileview = new View('/' . $uid . '/files_versions');
870
-
871
-			$softQuota = true;
872
-			$quota = $user->getQuota();
873
-			if ($quota === null || $quota === 'none') {
874
-				$quota = Filesystem::free_space('/');
875
-				$softQuota = false;
876
-			} else {
877
-				$quota = Util::computerFileSize($quota);
878
-			}
879
-
880
-			// make sure that we have the current size of the version history
881
-			$versionsSize = self::getVersionsSize($uid);
882
-
883
-			// calculate available space for version history
884
-			// subtract size of files and current versions size from quota
885
-			if ($quota >= 0) {
886
-				if ($softQuota) {
887
-					$root = Server::get(IRootFolder::class);
888
-					$userFolder = $root->getUserFolder($uid);
889
-					if (is_null($userFolder)) {
890
-						$availableSpace = 0;
891
-					} else {
892
-						$free = $quota - $userFolder->getSize(false); // remaining free space for user
893
-						if ($free > 0) {
894
-							$availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $versionsSize; // how much space can be used for versions
895
-						} else {
896
-							$availableSpace = $free - $versionsSize;
897
-						}
898
-					}
899
-				} else {
900
-					$availableSpace = $quota;
901
-				}
902
-			} else {
903
-				$availableSpace = PHP_INT_MAX;
904
-			}
905
-
906
-			$allVersions = Storage::getVersions($uid, $filename);
907
-
908
-			$time = time();
909
-			[$toDelete, $sizeOfDeletedVersions] = self::getExpireList($time, $allVersions, $availableSpace <= 0);
910
-
911
-			$availableSpace = $availableSpace + $sizeOfDeletedVersions;
912
-			$versionsSize = $versionsSize - $sizeOfDeletedVersions;
913
-
914
-			// if still not enough free space we rearrange the versions from all files
915
-			if ($availableSpace <= 0) {
916
-				$result = self::getAllVersions($uid);
917
-				$allVersions = $result['all'];
918
-
919
-				foreach ($result['by_file'] as $versions) {
920
-					[$toDeleteNew, $size] = self::getExpireList($time, $versions, $availableSpace <= 0);
921
-					$toDelete = array_merge($toDelete, $toDeleteNew);
922
-					$sizeOfDeletedVersions += $size;
923
-				}
924
-				$availableSpace = $availableSpace + $sizeOfDeletedVersions;
925
-				$versionsSize = $versionsSize - $sizeOfDeletedVersions;
926
-			}
927
-
928
-			foreach ($toDelete as $key => $path) {
929
-				// Make sure to cleanup version table relations as expire does not pass deleteVersion
930
-				try {
931
-					/** @var VersionsMapper $versionsMapper */
932
-					$versionsMapper = Server::get(VersionsMapper::class);
933
-					$file = Server::get(IRootFolder::class)->getUserFolder($uid)->get($filename);
934
-					$pathparts = pathinfo($path);
935
-					$timestamp = (int)substr($pathparts['extension'] ?? '', 1);
936
-					$versionEntity = $versionsMapper->findVersionForFileId($file->getId(), $timestamp);
937
-					if ($versionEntity->getMetadataValue('label') !== null && $versionEntity->getMetadataValue('label') !== '') {
938
-						continue;
939
-					}
940
-					$versionsMapper->delete($versionEntity);
941
-				} catch (DoesNotExistException $e) {
942
-				}
943
-
944
-				\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
945
-				self::deleteVersion($versionsFileview, $path);
946
-				\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
947
-				unset($allVersions[$key]); // update array with the versions we keep
948
-				$logger->info('Expire: ' . $path, ['app' => 'files_versions']);
949
-			}
950
-
951
-			// Check if enough space is available after versions are rearranged.
952
-			// If not we delete the oldest versions until we meet the size limit for versions,
953
-			// but always keep the two latest versions
954
-			$numOfVersions = count($allVersions) - 2 ;
955
-			$i = 0;
956
-			// sort oldest first and make sure that we start at the first element
957
-			ksort($allVersions);
958
-			reset($allVersions);
959
-			while ($availableSpace < 0 && $i < $numOfVersions) {
960
-				$version = current($allVersions);
961
-				\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $version['path'] . '.v' . $version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
962
-				self::deleteVersion($versionsFileview, $version['path'] . '.v' . $version['version']);
963
-				\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $version['path'] . '.v' . $version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
964
-				$logger->info('running out of space! Delete oldest version: ' . $version['path'] . '.v' . $version['version'], ['app' => 'files_versions']);
965
-				$versionsSize -= $version['size'];
966
-				$availableSpace += $version['size'];
967
-				next($allVersions);
968
-				$i++;
969
-			}
970
-
971
-			return $versionsSize; // finally return the new size of the version history
972
-		}
973
-
974
-		return false;
975
-	}
976
-
977
-	/**
978
-	 * Create recursively missing directories inside of files_versions
979
-	 * that match the given path to a file.
980
-	 *
981
-	 * @param string $filename $path to a file, relative to the user's
982
-	 *                         "files" folder
983
-	 * @param View $view view on data/user/
984
-	 */
985
-	public static function createMissingDirectories($filename, $view) {
986
-		$dirname = Filesystem::normalizePath(dirname($filename));
987
-		$dirParts = explode('/', $dirname);
988
-		$dir = '/files_versions';
989
-		foreach ($dirParts as $part) {
990
-			$dir = $dir . '/' . $part;
991
-			if (!$view->file_exists($dir)) {
992
-				$view->mkdir($dir);
993
-			}
994
-		}
995
-	}
996
-
997
-	/**
998
-	 * Static workaround
999
-	 * @return Expiration
1000
-	 */
1001
-	protected static function getExpiration() {
1002
-		if (self::$application === null) {
1003
-			self::$application = Server::get(Application::class);
1004
-		}
1005
-		return self::$application->getContainer()->get(Expiration::class);
1006
-	}
50
+    public const DEFAULTENABLED = true;
51
+    public const DEFAULTMAXSIZE = 50; // unit: percentage; 50% of available disk space/quota
52
+    public const VERSIONS_ROOT = 'files_versions/';
53
+
54
+    public const DELETE_TRIGGER_MASTER_REMOVED = 0;
55
+    public const DELETE_TRIGGER_RETENTION_CONSTRAINT = 1;
56
+    public const DELETE_TRIGGER_QUOTA_EXCEEDED = 2;
57
+
58
+    // files for which we can remove the versions after the delete operation was successful
59
+    private static $deletedFiles = [];
60
+
61
+    private static $sourcePathAndUser = [];
62
+
63
+    private static $max_versions_per_interval = [
64
+        //first 10sec, one version every 2sec
65
+        1 => ['intervalEndsAfter' => 10,      'step' => 2],
66
+        //next minute, one version every 10sec
67
+        2 => ['intervalEndsAfter' => 60,      'step' => 10],
68
+        //next hour, one version every minute
69
+        3 => ['intervalEndsAfter' => 3600,    'step' => 60],
70
+        //next 24h, one version every hour
71
+        4 => ['intervalEndsAfter' => 86400,   'step' => 3600],
72
+        //next 30days, one version per day
73
+        5 => ['intervalEndsAfter' => 2592000, 'step' => 86400],
74
+        //until the end one version per week
75
+        6 => ['intervalEndsAfter' => -1,      'step' => 604800],
76
+    ];
77
+
78
+    /** @var Application */
79
+    private static $application;
80
+
81
+    /**
82
+     * get the UID of the owner of the file and the path to the file relative to
83
+     * owners files folder
84
+     *
85
+     * @param string $filename
86
+     * @return array
87
+     * @throws NoUserException
88
+     */
89
+    public static function getUidAndFilename($filename) {
90
+        $uid = Filesystem::getOwner($filename);
91
+        $userManager = Server::get(IUserManager::class);
92
+        // if the user with the UID doesn't exists, e.g. because the UID points
93
+        // to a remote user with a federated cloud ID we use the current logged-in
94
+        // user. We need a valid local user to create the versions
95
+        if (!$userManager->userExists($uid)) {
96
+            $uid = OC_User::getUser();
97
+        }
98
+        Filesystem::initMountPoints($uid);
99
+        if ($uid !== OC_User::getUser()) {
100
+            $info = Filesystem::getFileInfo($filename);
101
+            $ownerView = new View('/' . $uid . '/files');
102
+            try {
103
+                $filename = $ownerView->getPath($info['fileid']);
104
+                // make sure that the file name doesn't end with a trailing slash
105
+                // can for example happen single files shared across servers
106
+                $filename = rtrim($filename, '/');
107
+            } catch (NotFoundException $e) {
108
+                $filename = null;
109
+            }
110
+        }
111
+        return [$uid, $filename];
112
+    }
113
+
114
+    /**
115
+     * Remember the owner and the owner path of the source file
116
+     *
117
+     * @param string $source source path
118
+     */
119
+    public static function setSourcePathAndUser($source) {
120
+        [$uid, $path] = self::getUidAndFilename($source);
121
+        self::$sourcePathAndUser[$source] = ['uid' => $uid, 'path' => $path];
122
+    }
123
+
124
+    /**
125
+     * Gets the owner and the owner path from the source path
126
+     *
127
+     * @param string $source source path
128
+     * @return array with user id and path
129
+     */
130
+    public static function getSourcePathAndUser($source) {
131
+        if (isset(self::$sourcePathAndUser[$source])) {
132
+            $uid = self::$sourcePathAndUser[$source]['uid'];
133
+            $path = self::$sourcePathAndUser[$source]['path'];
134
+            unset(self::$sourcePathAndUser[$source]);
135
+        } else {
136
+            $uid = $path = false;
137
+        }
138
+        return [$uid, $path];
139
+    }
140
+
141
+    /**
142
+     * get current size of all versions from a given user
143
+     *
144
+     * @param string $user user who owns the versions
145
+     * @return int versions size
146
+     */
147
+    private static function getVersionsSize($user) {
148
+        $view = new View('/' . $user);
149
+        $fileInfo = $view->getFileInfo('/files_versions');
150
+        return isset($fileInfo['size']) ? $fileInfo['size'] : 0;
151
+    }
152
+
153
+    /**
154
+     * store a new version of a file.
155
+     */
156
+    public static function store($filename) {
157
+        // if the file gets streamed we need to remove the .part extension
158
+        // to get the right target
159
+        $ext = pathinfo($filename, PATHINFO_EXTENSION);
160
+        if ($ext === 'part') {
161
+            $filename = substr($filename, 0, -5);
162
+        }
163
+
164
+        // we only handle existing files
165
+        if (! Filesystem::file_exists($filename) || Filesystem::is_dir($filename)) {
166
+            return false;
167
+        }
168
+
169
+        // since hook paths are always relative to the "default filesystem view"
170
+        // we always use the owner from there to get the full node
171
+        $uid = Filesystem::getView()->getOwner('');
172
+
173
+        /** @var IRootFolder $rootFolder */
174
+        $rootFolder = Server::get(IRootFolder::class);
175
+        $userFolder = $rootFolder->getUserFolder($uid);
176
+
177
+        $eventDispatcher = Server::get(IEventDispatcher::class);
178
+        try {
179
+            $file = $userFolder->get($filename);
180
+        } catch (NotFoundException $e) {
181
+            return false;
182
+        }
183
+
184
+        $mount = $file->getMountPoint();
185
+        if ($mount instanceof SharedMount) {
186
+            $ownerFolder = $rootFolder->getUserFolder($mount->getShare()->getShareOwner());
187
+            $ownerNode = $ownerFolder->getFirstNodeById($file->getId());
188
+            if ($ownerNode) {
189
+                $file = $ownerNode;
190
+                $uid = $mount->getShare()->getShareOwner();
191
+            }
192
+        }
193
+
194
+        /** @var IUserManager $userManager */
195
+        $userManager = Server::get(IUserManager::class);
196
+        $user = $userManager->get($uid);
197
+
198
+        if (!$user) {
199
+            return false;
200
+        }
201
+
202
+        // no use making versions for empty files
203
+        if ($file->getSize() === 0) {
204
+            return false;
205
+        }
206
+
207
+        $event = new CreateVersionEvent($file);
208
+        $eventDispatcher->dispatch('OCA\Files_Versions::createVersion', $event);
209
+        if ($event->shouldCreateVersion() === false) {
210
+            return false;
211
+        }
212
+
213
+        /** @var IVersionManager $versionManager */
214
+        $versionManager = Server::get(IVersionManager::class);
215
+
216
+        $versionManager->createVersion($user, $file);
217
+    }
218
+
219
+
220
+    /**
221
+     * mark file as deleted so that we can remove the versions if the file is gone
222
+     * @param string $path
223
+     */
224
+    public static function markDeletedFile($path) {
225
+        [$uid, $filename] = self::getUidAndFilename($path);
226
+        self::$deletedFiles[$path] = [
227
+            'uid' => $uid,
228
+            'filename' => $filename];
229
+    }
230
+
231
+    /**
232
+     * delete the version from the storage and cache
233
+     *
234
+     * @param View $view
235
+     * @param string $path
236
+     */
237
+    protected static function deleteVersion($view, $path) {
238
+        $view->unlink($path);
239
+        /**
240
+         * @var \OC\Files\Storage\Storage $storage
241
+         * @var string $internalPath
242
+         */
243
+        [$storage, $internalPath] = $view->resolvePath($path);
244
+        $cache = $storage->getCache($internalPath);
245
+        $cache->remove($internalPath);
246
+    }
247
+
248
+    /**
249
+     * Delete versions of a file
250
+     */
251
+    public static function delete($path) {
252
+        $deletedFile = self::$deletedFiles[$path];
253
+        $uid = $deletedFile['uid'];
254
+        $filename = $deletedFile['filename'];
255
+
256
+        if (!Filesystem::file_exists($path)) {
257
+            $view = new View('/' . $uid . '/files_versions');
258
+
259
+            $versions = self::getVersions($uid, $filename);
260
+            if (!empty($versions)) {
261
+                foreach ($versions as $v) {
262
+                    \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
263
+                    self::deleteVersion($view, $filename . '.v' . $v['version']);
264
+                    \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
265
+                }
266
+            }
267
+        }
268
+        unset(self::$deletedFiles[$path]);
269
+    }
270
+
271
+    /**
272
+     * Delete a version of a file
273
+     */
274
+    public static function deleteRevision(string $path, int $revision): void {
275
+        [$uid, $filename] = self::getUidAndFilename($path);
276
+        $view = new View('/' . $uid . '/files_versions');
277
+        \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
278
+        self::deleteVersion($view, $filename . '.v' . $revision);
279
+        \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
280
+    }
281
+
282
+    /**
283
+     * Rename or copy versions of a file of the given paths
284
+     *
285
+     * @param string $sourcePath source path of the file to move, relative to
286
+     *                           the currently logged in user's "files" folder
287
+     * @param string $targetPath target path of the file to move, relative to
288
+     *                           the currently logged in user's "files" folder
289
+     * @param string $operation can be 'copy' or 'rename'
290
+     */
291
+    public static function renameOrCopy($sourcePath, $targetPath, $operation) {
292
+        [$sourceOwner, $sourcePath] = self::getSourcePathAndUser($sourcePath);
293
+
294
+        // it was a upload of a existing file if no old path exists
295
+        // in this case the pre-hook already called the store method and we can
296
+        // stop here
297
+        if ($sourcePath === false) {
298
+            return true;
299
+        }
300
+
301
+        [$targetOwner, $targetPath] = self::getUidAndFilename($targetPath);
302
+
303
+        $sourcePath = ltrim($sourcePath, '/');
304
+        $targetPath = ltrim($targetPath, '/');
305
+
306
+        $rootView = new View('');
307
+
308
+        // did we move a directory ?
309
+        if ($rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
310
+            // does the directory exists for versions too ?
311
+            if ($rootView->is_dir('/' . $sourceOwner . '/files_versions/' . $sourcePath)) {
312
+                // create missing dirs if necessary
313
+                self::createMissingDirectories($targetPath, new View('/' . $targetOwner));
314
+
315
+                // move the directory containing the versions
316
+                $rootView->$operation(
317
+                    '/' . $sourceOwner . '/files_versions/' . $sourcePath,
318
+                    '/' . $targetOwner . '/files_versions/' . $targetPath
319
+                );
320
+            }
321
+        } elseif ($versions = Storage::getVersions($sourceOwner, '/' . $sourcePath)) {
322
+            // create missing dirs if necessary
323
+            self::createMissingDirectories($targetPath, new View('/' . $targetOwner));
324
+
325
+            foreach ($versions as $v) {
326
+                // move each version one by one to the target directory
327
+                $rootView->$operation(
328
+                    '/' . $sourceOwner . '/files_versions/' . $sourcePath . '.v' . $v['version'],
329
+                    '/' . $targetOwner . '/files_versions/' . $targetPath . '.v' . $v['version']
330
+                );
331
+            }
332
+        }
333
+
334
+        // if we moved versions directly for a file, schedule expiration check for that file
335
+        if (!$rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
336
+            self::scheduleExpire($targetOwner, $targetPath);
337
+        }
338
+    }
339
+
340
+    /**
341
+     * Rollback to an old version of a file.
342
+     *
343
+     * @param string $file file name
344
+     * @param int $revision revision timestamp
345
+     * @return bool
346
+     */
347
+    public static function rollback(string $file, int $revision, IUser $user) {
348
+        // add expected leading slash
349
+        $filename = '/' . ltrim($file, '/');
350
+
351
+        // Fetch the userfolder to trigger view hooks
352
+        $root = Server::get(IRootFolder::class);
353
+        $userFolder = $root->getUserFolder($user->getUID());
354
+
355
+        $users_view = new View('/' . $user->getUID());
356
+        $files_view = new View('/' . $user->getUID() . '/files');
357
+
358
+        $versionCreated = false;
359
+
360
+        $fileInfo = $files_view->getFileInfo($file);
361
+
362
+        // check if user has the permissions to revert a version
363
+        if (!$fileInfo->isUpdateable()) {
364
+            return false;
365
+        }
366
+
367
+        //first create a new version
368
+        $version = 'files_versions' . $filename . '.v' . $users_view->filemtime('files' . $filename);
369
+        if (!$users_view->file_exists($version)) {
370
+            $users_view->copy('files' . $filename, 'files_versions' . $filename . '.v' . $users_view->filemtime('files' . $filename));
371
+            $versionCreated = true;
372
+        }
373
+
374
+        $fileToRestore = 'files_versions' . $filename . '.v' . $revision;
375
+
376
+        // Restore encrypted version of the old file for the newly restored file
377
+        // This has to happen manually here since the file is manually copied below
378
+        $oldVersion = $users_view->getFileInfo($fileToRestore)->getEncryptedVersion();
379
+        $oldFileInfo = $users_view->getFileInfo($fileToRestore);
380
+        $cache = $fileInfo->getStorage()->getCache();
381
+        $cache->update(
382
+            $fileInfo->getId(), [
383
+                'encrypted' => $oldVersion,
384
+                'encryptedVersion' => $oldVersion,
385
+                'size' => $oldFileInfo->getData()['size'],
386
+                'unencrypted_size' => $oldFileInfo->getData()['unencrypted_size'],
387
+            ]
388
+        );
389
+
390
+        // rollback
391
+        if (self::copyFileContents($users_view, $fileToRestore, 'files' . $filename)) {
392
+            $files_view->touch($file, $revision);
393
+            Storage::scheduleExpire($user->getUID(), $file);
394
+
395
+            return true;
396
+        } elseif ($versionCreated) {
397
+            self::deleteVersion($users_view, $version);
398
+        }
399
+
400
+        return false;
401
+    }
402
+
403
+    /**
404
+     * Stream copy file contents from $path1 to $path2
405
+     *
406
+     * @param View $view view to use for copying
407
+     * @param string $path1 source file to copy
408
+     * @param string $path2 target file
409
+     *
410
+     * @return bool true for success, false otherwise
411
+     */
412
+    private static function copyFileContents($view, $path1, $path2) {
413
+        /** @var \OC\Files\Storage\Storage $storage1 */
414
+        [$storage1, $internalPath1] = $view->resolvePath($path1);
415
+        /** @var \OC\Files\Storage\Storage $storage2 */
416
+        [$storage2, $internalPath2] = $view->resolvePath($path2);
417
+
418
+        $view->lockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
419
+        $view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
420
+
421
+        try {
422
+            // TODO add a proper way of overwriting a file while maintaining file ids
423
+            if ($storage1->instanceOfStorage(ObjectStoreStorage::class)
424
+                || $storage2->instanceOfStorage(ObjectStoreStorage::class)
425
+            ) {
426
+                $source = $storage1->fopen($internalPath1, 'r');
427
+                $result = $source !== false;
428
+                if ($result) {
429
+                    if ($storage2->instanceOfStorage(IWriteStreamStorage::class)) {
430
+                        /** @var IWriteStreamStorage $storage2 */
431
+                        $storage2->writeStream($internalPath2, $source);
432
+                    } else {
433
+                        $target = $storage2->fopen($internalPath2, 'w');
434
+                        $result = $target !== false;
435
+                        if ($result) {
436
+                            [, $result] = Files::streamCopy($source, $target, true);
437
+                        }
438
+                        // explicit check as S3 library closes streams already
439
+                        if (is_resource($target)) {
440
+                            fclose($target);
441
+                        }
442
+                    }
443
+                }
444
+                // explicit check as S3 library closes streams already
445
+                if (is_resource($source)) {
446
+                    fclose($source);
447
+                }
448
+
449
+                if ($result !== false) {
450
+                    $storage1->unlink($internalPath1);
451
+                }
452
+            } else {
453
+                $result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2);
454
+            }
455
+        } finally {
456
+            $view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
457
+            $view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
458
+        }
459
+
460
+        return ($result !== false);
461
+    }
462
+
463
+    /**
464
+     * get a list of all available versions of a file in descending chronological order
465
+     * @param string $uid user id from the owner of the file
466
+     * @param string $filename file to find versions of, relative to the user files dir
467
+     * @param string $userFullPath
468
+     * @return array versions newest version first
469
+     */
470
+    public static function getVersions($uid, $filename, $userFullPath = '') {
471
+        $versions = [];
472
+        if (empty($filename)) {
473
+            return $versions;
474
+        }
475
+        // fetch for old versions
476
+        $view = new View('/' . $uid . '/');
477
+
478
+        $pathinfo = pathinfo($filename);
479
+        $versionedFile = $pathinfo['basename'];
480
+
481
+        $dir = Filesystem::normalizePath(self::VERSIONS_ROOT . '/' . $pathinfo['dirname']);
482
+
483
+        $dirContent = false;
484
+        if ($view->is_dir($dir)) {
485
+            $dirContent = $view->opendir($dir);
486
+        }
487
+
488
+        if ($dirContent === false) {
489
+            return $versions;
490
+        }
491
+
492
+        if (is_resource($dirContent)) {
493
+            while (($entryName = readdir($dirContent)) !== false) {
494
+                if (!Filesystem::isIgnoredDir($entryName)) {
495
+                    $pathparts = pathinfo($entryName);
496
+                    $filename = $pathparts['filename'];
497
+                    if ($filename === $versionedFile) {
498
+                        $pathparts = pathinfo($entryName);
499
+                        $timestamp = substr($pathparts['extension'] ?? '', 1);
500
+                        if (!is_numeric($timestamp)) {
501
+                            Server::get(LoggerInterface::class)->error(
502
+                                'Version file {path} has incorrect name format',
503
+                                [
504
+                                    'path' => $entryName,
505
+                                    'app' => 'files_versions',
506
+                                ]
507
+                            );
508
+                            continue;
509
+                        }
510
+                        $filename = $pathparts['filename'];
511
+                        $key = $timestamp . '#' . $filename;
512
+                        $versions[$key]['version'] = $timestamp;
513
+                        $versions[$key]['humanReadableTimestamp'] = self::getHumanReadableTimestamp((int)$timestamp);
514
+                        if (empty($userFullPath)) {
515
+                            $versions[$key]['preview'] = '';
516
+                        } else {
517
+                            /** @var IURLGenerator $urlGenerator */
518
+                            $urlGenerator = Server::get(IURLGenerator::class);
519
+                            $versions[$key]['preview'] = $urlGenerator->linkToRoute('files_version.Preview.getPreview',
520
+                                ['file' => $userFullPath, 'version' => $timestamp]);
521
+                        }
522
+                        $versions[$key]['path'] = Filesystem::normalizePath($pathinfo['dirname'] . '/' . $filename);
523
+                        $versions[$key]['name'] = $versionedFile;
524
+                        $versions[$key]['size'] = $view->filesize($dir . '/' . $entryName);
525
+                        $versions[$key]['mimetype'] = Server::get(IMimeTypeDetector::class)->detectPath($versionedFile);
526
+                    }
527
+                }
528
+            }
529
+            closedir($dirContent);
530
+        }
531
+
532
+        // sort with newest version first
533
+        krsort($versions);
534
+
535
+        return $versions;
536
+    }
537
+
538
+    /**
539
+     * Expire versions that older than max version retention time
540
+     *
541
+     * @param string $uid
542
+     */
543
+    public static function expireOlderThanMaxForUser($uid) {
544
+        /** @var IRootFolder $root */
545
+        $root = Server::get(IRootFolder::class);
546
+        try {
547
+            /** @var Folder $versionsRoot */
548
+            $versionsRoot = $root->get('/' . $uid . '/files_versions');
549
+        } catch (NotFoundException $e) {
550
+            return;
551
+        }
552
+
553
+        $expiration = self::getExpiration();
554
+        $threshold = $expiration->getMaxAgeAsTimestamp();
555
+        if (!$threshold) {
556
+            return;
557
+        }
558
+
559
+        $allVersions = $versionsRoot->search(new SearchQuery(
560
+            new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_NOT, [
561
+                new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', FileInfo::MIMETYPE_FOLDER),
562
+            ]),
563
+            0,
564
+            0,
565
+            []
566
+        ));
567
+
568
+        /** @var VersionsMapper $versionsMapper */
569
+        $versionsMapper = Server::get(VersionsMapper::class);
570
+        $userFolder = $root->getUserFolder($uid);
571
+        $versionEntities = [];
572
+
573
+        /** @var Node[] $versions */
574
+        $versions = array_filter($allVersions, function (Node $info) use ($threshold, $userFolder, $versionsMapper, $versionsRoot, &$versionEntities) {
575
+            // Check that the file match '*.v*'
576
+            $versionsBegin = strrpos($info->getName(), '.v');
577
+            if ($versionsBegin === false) {
578
+                return false;
579
+            }
580
+
581
+            $version = (int)substr($info->getName(), $versionsBegin + 2);
582
+
583
+            // Check that the version does not have a label.
584
+            $path = $versionsRoot->getRelativePath($info->getPath());
585
+            if ($path === null) {
586
+                throw new DoesNotExistException('Could not find relative path of (' . $info->getPath() . ')');
587
+            }
588
+
589
+            try {
590
+                $node = $userFolder->get(substr($path, 0, -strlen('.v' . $version)));
591
+                $versionEntity = $versionsMapper->findVersionForFileId($node->getId(), $version);
592
+                $versionEntities[$info->getId()] = $versionEntity;
593
+
594
+                if ($versionEntity->getMetadataValue('label') !== null && $versionEntity->getMetadataValue('label') !== '') {
595
+                    return false;
596
+                }
597
+            } catch (NotFoundException $e) {
598
+                // Original node not found, delete the version
599
+                return true;
600
+            } catch (StorageNotAvailableException|StorageInvalidException $e) {
601
+                // Storage can't be used, but it might only be temporary so we can't always delete the version
602
+                // since we can't determine if the version is named we take the safe route and don't expire
603
+                return false;
604
+            } catch (DoesNotExistException $ex) {
605
+                // Version on FS can have no equivalent in the DB if they were created before the version naming feature.
606
+                // So we ignore DoesNotExistException.
607
+            }
608
+
609
+            // Check that the version's timestamp is lower than $threshold
610
+            return $version < $threshold;
611
+        });
612
+
613
+        foreach ($versions as $version) {
614
+            $internalPath = $version->getInternalPath();
615
+            \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
616
+
617
+            $versionEntity = isset($versionEntities[$version->getId()]) ? $versionEntities[$version->getId()] : null;
618
+            if (!is_null($versionEntity)) {
619
+                $versionsMapper->delete($versionEntity);
620
+            }
621
+
622
+            try {
623
+                $version->delete();
624
+                \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
625
+            } catch (NotPermittedException $e) {
626
+                Server::get(LoggerInterface::class)->error("Missing permissions to delete version for user {$uid}: {$internalPath}", ['app' => 'files_versions', 'exception' => $e]);
627
+            }
628
+        }
629
+    }
630
+
631
+    /**
632
+     * translate a timestamp into a string like "5 days ago"
633
+     *
634
+     * @param int $timestamp
635
+     * @return string for example "5 days ago"
636
+     */
637
+    private static function getHumanReadableTimestamp(int $timestamp): string {
638
+        $diff = time() - $timestamp;
639
+
640
+        if ($diff < 60) { // first minute
641
+            return  $diff . ' seconds ago';
642
+        } elseif ($diff < 3600) { //first hour
643
+            return round($diff / 60) . ' minutes ago';
644
+        } elseif ($diff < 86400) { // first day
645
+            return round($diff / 3600) . ' hours ago';
646
+        } elseif ($diff < 604800) { //first week
647
+            return round($diff / 86400) . ' days ago';
648
+        } elseif ($diff < 2419200) { //first month
649
+            return round($diff / 604800) . ' weeks ago';
650
+        } elseif ($diff < 29030400) { // first year
651
+            return round($diff / 2419200) . ' months ago';
652
+        } else {
653
+            return round($diff / 29030400) . ' years ago';
654
+        }
655
+    }
656
+
657
+    /**
658
+     * returns all stored file versions from a given user
659
+     * @param string $uid id of the user
660
+     * @return array with contains two arrays 'all' which contains all versions sorted by age and 'by_file' which contains all versions sorted by filename
661
+     */
662
+    private static function getAllVersions($uid) {
663
+        $view = new View('/' . $uid . '/');
664
+        $dirs = [self::VERSIONS_ROOT];
665
+        $versions = [];
666
+
667
+        while (!empty($dirs)) {
668
+            $dir = array_pop($dirs);
669
+            $files = $view->getDirectoryContent($dir);
670
+
671
+            foreach ($files as $file) {
672
+                $fileData = $file->getData();
673
+                $filePath = $dir . '/' . $fileData['name'];
674
+                if ($file['type'] === 'dir') {
675
+                    $dirs[] = $filePath;
676
+                } else {
677
+                    $versionsBegin = strrpos($filePath, '.v');
678
+                    $relPathStart = strlen(self::VERSIONS_ROOT);
679
+                    $version = substr($filePath, $versionsBegin + 2);
680
+                    $relpath = substr($filePath, $relPathStart, $versionsBegin - $relPathStart);
681
+                    $key = $version . '#' . $relpath;
682
+                    $versions[$key] = ['path' => $relpath, 'timestamp' => $version];
683
+                }
684
+            }
685
+        }
686
+
687
+        // newest version first
688
+        krsort($versions);
689
+
690
+        $result = [
691
+            'all' => [],
692
+            'by_file' => [],
693
+        ];
694
+
695
+        foreach ($versions as $key => $value) {
696
+            $size = $view->filesize(self::VERSIONS_ROOT . '/' . $value['path'] . '.v' . $value['timestamp']);
697
+            $filename = $value['path'];
698
+
699
+            $result['all'][$key]['version'] = $value['timestamp'];
700
+            $result['all'][$key]['path'] = $filename;
701
+            $result['all'][$key]['size'] = $size;
702
+
703
+            $result['by_file'][$filename][$key]['version'] = $value['timestamp'];
704
+            $result['by_file'][$filename][$key]['path'] = $filename;
705
+            $result['by_file'][$filename][$key]['size'] = $size;
706
+        }
707
+
708
+        return $result;
709
+    }
710
+
711
+    /**
712
+     * get list of files we want to expire
713
+     * @param array $versions list of versions
714
+     * @param integer $time
715
+     * @param bool $quotaExceeded is versions storage limit reached
716
+     * @return array containing the list of to deleted versions and the size of them
717
+     */
718
+    protected static function getExpireList($time, $versions, $quotaExceeded = false) {
719
+        $expiration = self::getExpiration();
720
+
721
+        if ($expiration->shouldAutoExpire()) {
722
+            // Exclude versions that are newer than the minimum age from the auto expiration logic.
723
+            $minAge = $expiration->getMinAgeAsTimestamp();
724
+            if ($minAge !== false) {
725
+                $versionsToAutoExpire = array_filter($versions, fn ($version) => $version['version'] < $minAge);
726
+            } else {
727
+                $versionsToAutoExpire = $versions;
728
+            }
729
+
730
+            [$toDelete, $size] = self::getAutoExpireList($time, $versionsToAutoExpire);
731
+        } else {
732
+            $size = 0;
733
+            $toDelete = [];  // versions we want to delete
734
+        }
735
+
736
+        foreach ($versions as $key => $version) {
737
+            if (!is_numeric($version['version'])) {
738
+                Server::get(LoggerInterface::class)->error(
739
+                    'Found a non-numeric timestamp version: ' . json_encode($version),
740
+                    ['app' => 'files_versions']);
741
+                continue;
742
+            }
743
+            if ($expiration->isExpired((int)($version['version']), $quotaExceeded) && !isset($toDelete[$key])) {
744
+                $size += $version['size'];
745
+                $toDelete[$key] = $version['path'] . '.v' . $version['version'];
746
+            }
747
+        }
748
+
749
+        return [$toDelete, $size];
750
+    }
751
+
752
+    /**
753
+     * get list of files we want to expire
754
+     * @param array $versions list of versions
755
+     * @param integer $time
756
+     * @return array containing the list of to deleted versions and the size of them
757
+     */
758
+    protected static function getAutoExpireList($time, $versions) {
759
+        $size = 0;
760
+        $toDelete = [];  // versions we want to delete
761
+
762
+        $interval = 1;
763
+        $step = Storage::$max_versions_per_interval[$interval]['step'];
764
+        if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) {
765
+            $nextInterval = -1;
766
+        } else {
767
+            $nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
768
+        }
769
+
770
+        $firstVersion = reset($versions);
771
+
772
+        if ($firstVersion === false) {
773
+            return [$toDelete, $size];
774
+        }
775
+
776
+        $firstKey = key($versions);
777
+        $prevTimestamp = $firstVersion['version'];
778
+        $nextVersion = $firstVersion['version'] - $step;
779
+        unset($versions[$firstKey]);
780
+
781
+        foreach ($versions as $key => $version) {
782
+            $newInterval = true;
783
+            while ($newInterval) {
784
+                if ($nextInterval === -1 || $prevTimestamp > $nextInterval) {
785
+                    if ($version['version'] > $nextVersion) {
786
+                        //distance between two version too small, mark to delete
787
+                        $toDelete[$key] = $version['path'] . '.v' . $version['version'];
788
+                        $size += $version['size'];
789
+                        Server::get(LoggerInterface::class)->info('Mark to expire ' . $version['path'] . ' next version should be ' . $nextVersion . ' or smaller. (prevTimestamp: ' . $prevTimestamp . '; step: ' . $step, ['app' => 'files_versions']);
790
+                    } else {
791
+                        $nextVersion = $version['version'] - $step;
792
+                        $prevTimestamp = $version['version'];
793
+                    }
794
+                    $newInterval = false; // version checked so we can move to the next one
795
+                } else { // time to move on to the next interval
796
+                    $interval++;
797
+                    $step = Storage::$max_versions_per_interval[$interval]['step'];
798
+                    $nextVersion = $prevTimestamp - $step;
799
+                    if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) {
800
+                        $nextInterval = -1;
801
+                    } else {
802
+                        $nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
803
+                    }
804
+                    $newInterval = true; // we changed the interval -> check same version with new interval
805
+                }
806
+            }
807
+        }
808
+
809
+        return [$toDelete, $size];
810
+    }
811
+
812
+    /**
813
+     * Schedule versions expiration for the given file
814
+     *
815
+     * @param string $uid owner of the file
816
+     * @param string $fileName file/folder for which to schedule expiration
817
+     */
818
+    public static function scheduleExpire($uid, $fileName) {
819
+        // let the admin disable auto expire
820
+        $expiration = self::getExpiration();
821
+        if ($expiration->isEnabled()) {
822
+            $command = new Expire($uid, $fileName);
823
+            /** @var IBus $bus */
824
+            $bus = Server::get(IBus::class);
825
+            $bus->push($command);
826
+        }
827
+    }
828
+
829
+    /**
830
+     * Expire versions which exceed the quota.
831
+     *
832
+     * This will setup the filesystem for the given user but will not
833
+     * tear it down afterwards.
834
+     *
835
+     * @param string $filename path to file to expire
836
+     * @param string $uid user for which to expire the version
837
+     * @return bool|int|null
838
+     */
839
+    public static function expire($filename, $uid) {
840
+        $expiration = self::getExpiration();
841
+
842
+        /** @var LoggerInterface $logger */
843
+        $logger = Server::get(LoggerInterface::class);
844
+
845
+        if ($expiration->isEnabled()) {
846
+            // get available disk space for user
847
+            $user = Server::get(IUserManager::class)->get($uid);
848
+            if (is_null($user)) {
849
+                $logger->error('Backends provided no user object for ' . $uid, ['app' => 'files_versions']);
850
+                throw new NoUserException('Backends provided no user object for ' . $uid);
851
+            }
852
+
853
+            \OC_Util::setupFS($uid);
854
+
855
+            try {
856
+                if (!Filesystem::file_exists($filename)) {
857
+                    return false;
858
+                }
859
+            } catch (StorageNotAvailableException $e) {
860
+                // if we can't check that the file hasn't been deleted we can only assume that it hasn't
861
+                // note that this `StorageNotAvailableException` is about the file the versions originate from,
862
+                // not the storage that the versions are stored on
863
+            }
864
+
865
+            if (empty($filename)) {
866
+                // file maybe renamed or deleted
867
+                return false;
868
+            }
869
+            $versionsFileview = new View('/' . $uid . '/files_versions');
870
+
871
+            $softQuota = true;
872
+            $quota = $user->getQuota();
873
+            if ($quota === null || $quota === 'none') {
874
+                $quota = Filesystem::free_space('/');
875
+                $softQuota = false;
876
+            } else {
877
+                $quota = Util::computerFileSize($quota);
878
+            }
879
+
880
+            // make sure that we have the current size of the version history
881
+            $versionsSize = self::getVersionsSize($uid);
882
+
883
+            // calculate available space for version history
884
+            // subtract size of files and current versions size from quota
885
+            if ($quota >= 0) {
886
+                if ($softQuota) {
887
+                    $root = Server::get(IRootFolder::class);
888
+                    $userFolder = $root->getUserFolder($uid);
889
+                    if (is_null($userFolder)) {
890
+                        $availableSpace = 0;
891
+                    } else {
892
+                        $free = $quota - $userFolder->getSize(false); // remaining free space for user
893
+                        if ($free > 0) {
894
+                            $availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $versionsSize; // how much space can be used for versions
895
+                        } else {
896
+                            $availableSpace = $free - $versionsSize;
897
+                        }
898
+                    }
899
+                } else {
900
+                    $availableSpace = $quota;
901
+                }
902
+            } else {
903
+                $availableSpace = PHP_INT_MAX;
904
+            }
905
+
906
+            $allVersions = Storage::getVersions($uid, $filename);
907
+
908
+            $time = time();
909
+            [$toDelete, $sizeOfDeletedVersions] = self::getExpireList($time, $allVersions, $availableSpace <= 0);
910
+
911
+            $availableSpace = $availableSpace + $sizeOfDeletedVersions;
912
+            $versionsSize = $versionsSize - $sizeOfDeletedVersions;
913
+
914
+            // if still not enough free space we rearrange the versions from all files
915
+            if ($availableSpace <= 0) {
916
+                $result = self::getAllVersions($uid);
917
+                $allVersions = $result['all'];
918
+
919
+                foreach ($result['by_file'] as $versions) {
920
+                    [$toDeleteNew, $size] = self::getExpireList($time, $versions, $availableSpace <= 0);
921
+                    $toDelete = array_merge($toDelete, $toDeleteNew);
922
+                    $sizeOfDeletedVersions += $size;
923
+                }
924
+                $availableSpace = $availableSpace + $sizeOfDeletedVersions;
925
+                $versionsSize = $versionsSize - $sizeOfDeletedVersions;
926
+            }
927
+
928
+            foreach ($toDelete as $key => $path) {
929
+                // Make sure to cleanup version table relations as expire does not pass deleteVersion
930
+                try {
931
+                    /** @var VersionsMapper $versionsMapper */
932
+                    $versionsMapper = Server::get(VersionsMapper::class);
933
+                    $file = Server::get(IRootFolder::class)->getUserFolder($uid)->get($filename);
934
+                    $pathparts = pathinfo($path);
935
+                    $timestamp = (int)substr($pathparts['extension'] ?? '', 1);
936
+                    $versionEntity = $versionsMapper->findVersionForFileId($file->getId(), $timestamp);
937
+                    if ($versionEntity->getMetadataValue('label') !== null && $versionEntity->getMetadataValue('label') !== '') {
938
+                        continue;
939
+                    }
940
+                    $versionsMapper->delete($versionEntity);
941
+                } catch (DoesNotExistException $e) {
942
+                }
943
+
944
+                \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
945
+                self::deleteVersion($versionsFileview, $path);
946
+                \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
947
+                unset($allVersions[$key]); // update array with the versions we keep
948
+                $logger->info('Expire: ' . $path, ['app' => 'files_versions']);
949
+            }
950
+
951
+            // Check if enough space is available after versions are rearranged.
952
+            // If not we delete the oldest versions until we meet the size limit for versions,
953
+            // but always keep the two latest versions
954
+            $numOfVersions = count($allVersions) - 2 ;
955
+            $i = 0;
956
+            // sort oldest first and make sure that we start at the first element
957
+            ksort($allVersions);
958
+            reset($allVersions);
959
+            while ($availableSpace < 0 && $i < $numOfVersions) {
960
+                $version = current($allVersions);
961
+                \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $version['path'] . '.v' . $version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
962
+                self::deleteVersion($versionsFileview, $version['path'] . '.v' . $version['version']);
963
+                \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $version['path'] . '.v' . $version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
964
+                $logger->info('running out of space! Delete oldest version: ' . $version['path'] . '.v' . $version['version'], ['app' => 'files_versions']);
965
+                $versionsSize -= $version['size'];
966
+                $availableSpace += $version['size'];
967
+                next($allVersions);
968
+                $i++;
969
+            }
970
+
971
+            return $versionsSize; // finally return the new size of the version history
972
+        }
973
+
974
+        return false;
975
+    }
976
+
977
+    /**
978
+     * Create recursively missing directories inside of files_versions
979
+     * that match the given path to a file.
980
+     *
981
+     * @param string $filename $path to a file, relative to the user's
982
+     *                         "files" folder
983
+     * @param View $view view on data/user/
984
+     */
985
+    public static function createMissingDirectories($filename, $view) {
986
+        $dirname = Filesystem::normalizePath(dirname($filename));
987
+        $dirParts = explode('/', $dirname);
988
+        $dir = '/files_versions';
989
+        foreach ($dirParts as $part) {
990
+            $dir = $dir . '/' . $part;
991
+            if (!$view->file_exists($dir)) {
992
+                $view->mkdir($dir);
993
+            }
994
+        }
995
+    }
996
+
997
+    /**
998
+     * Static workaround
999
+     * @return Expiration
1000
+     */
1001
+    protected static function getExpiration() {
1002
+        if (self::$application === null) {
1003
+            self::$application = Server::get(Application::class);
1004
+        }
1005
+        return self::$application->getContainer()->get(Expiration::class);
1006
+    }
1007 1007
 }
Please login to merge, or discard this patch.