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