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