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