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