Storage   F
last analyzed

Complexity

Total Complexity 113

Size/Duplication

Total Lines 905
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 442
dl 0
loc 905
rs 2
c 0
b 0
f 0
wmc 113

22 Methods

Rating   Name   Duplication   Size   Complexity  
B store() 0 61 10
A markDeletedFile() 0 5 1
A deleteRevision() 0 6 1
A setSourcePathAndUser() 0 3 1
A delete() 0 18 4
A deleteVersion() 0 9 1
A getVersionsSize() 0 4 2
A getSourcePathAndUser() 0 9 2
B renameOrCopy() 0 46 7
A getUidAndFilename() 0 23 4
A rollback() 0 55 5
B getVersions() 0 66 10
A copyFileContents() 0 30 4
B getAutoExpireList() 0 52 9
A getExpireList() 0 24 6
D expire() 0 121 17
A scheduleExpire() 0 8 2
A getAllVersions() 0 47 5
A createMissingDirectories() 0 8 3
B getHumanReadableTimestamp() 0 17 7
A getExpiration() 0 5 2
B expireOlderThanMaxForUser() 0 74 10

How to fix   Complexity   

Complex Class

Complex classes like Storage often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Storage, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 *
5
 * @author Arthur Schiwon <[email protected]>
6
 * @author Bart Visscher <[email protected]>
7
 * @author Bjoern Schiessle <[email protected]>
8
 * @author Björn Schießle <[email protected]>
9
 * @author Christoph Wurst <[email protected]>
10
 * @author Felix Moeller <[email protected]>
11
 * @author Felix Nieuwenhuizen <[email protected]>
12
 * @author Joas Schilling <[email protected]>
13
 * @author Jörn Friedrich Dreyer <[email protected]>
14
 * @author Julius Härtl <[email protected]>
15
 * @author Liam JACK <[email protected]>
16
 * @author Lukas Reschke <[email protected]>
17
 * @author Morris Jobke <[email protected]>
18
 * @author Robin Appelman <[email protected]>
19
 * @author Robin McCorkell <[email protected]>
20
 * @author Roeland Jago Douma <[email protected]>
21
 * @author Thomas Müller <[email protected]>
22
 * @author Victor Dubiniuk <[email protected]>
23
 * @author Vincent Petry <[email protected]>
24
 *
25
 * @license AGPL-3.0
26
 *
27
 * This code is free software: you can redistribute it and/or modify
28
 * it under the terms of the GNU Affero General Public License, version 3,
29
 * as published by the Free Software Foundation.
30
 *
31
 * This program is distributed in the hope that it will be useful,
32
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
33
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
34
 * GNU Affero General Public License for more details.
35
 *
36
 * You should have received a copy of the GNU Affero General Public License, version 3,
37
 * along with this program. If not, see <http://www.gnu.org/licenses/>
38
 *
39
 */
40
41
namespace OCA\Files_Versions;
42
43
use OC\Files\Search\SearchBinaryOperator;
44
use OC\Files\Search\SearchComparison;
45
use OC\Files\Search\SearchQuery;
46
use OC_User;
47
use OC\Files\Filesystem;
48
use OC\Files\View;
49
use OCA\Files_Sharing\SharedMount;
50
use OCA\Files_Versions\AppInfo\Application;
51
use OCA\Files_Versions\Command\Expire;
52
use OCA\Files_Versions\Db\VersionsMapper;
53
use OCA\Files_Versions\Events\CreateVersionEvent;
54
use OCA\Files_Versions\Versions\IVersionManager;
55
use OCP\AppFramework\Db\DoesNotExistException;
56
use OCP\Files\FileInfo;
57
use OCP\Files\Folder;
58
use OCP\Files\IRootFolder;
59
use OCP\Files\Node;
60
use OCP\Command\IBus;
61
use OCP\EventDispatcher\IEventDispatcher;
62
use OCP\Files\IMimeTypeDetector;
63
use OCP\Files\NotFoundException;
64
use OCP\Files\Search\ISearchBinaryOperator;
65
use OCP\Files\Search\ISearchComparison;
66
use OCP\Files\StorageNotAvailableException;
67
use OCP\IURLGenerator;
68
use OCP\IUser;
69
use OCP\IUserManager;
70
use OCP\Lock\ILockingProvider;
71
use Psr\Log\LoggerInterface;
72
73
class Storage {
74
	public const DEFAULTENABLED = true;
75
	public const DEFAULTMAXSIZE = 50; // unit: percentage; 50% of available disk space/quota
76
	public const VERSIONS_ROOT = 'files_versions/';
77
78
	public const DELETE_TRIGGER_MASTER_REMOVED = 0;
79
	public const DELETE_TRIGGER_RETENTION_CONSTRAINT = 1;
80
	public const DELETE_TRIGGER_QUOTA_EXCEEDED = 2;
81
82
	// files for which we can remove the versions after the delete operation was successful
83
	private static $deletedFiles = [];
84
85
	private static $sourcePathAndUser = [];
86
87
	private static $max_versions_per_interval = [
88
		//first 10sec, one version every 2sec
89
		1 => ['intervalEndsAfter' => 10,      'step' => 2],
90
		//next minute, one version every 10sec
91
		2 => ['intervalEndsAfter' => 60,      'step' => 10],
92
		//next hour, one version every minute
93
		3 => ['intervalEndsAfter' => 3600,    'step' => 60],
94
		//next 24h, one version every hour
95
		4 => ['intervalEndsAfter' => 86400,   'step' => 3600],
96
		//next 30days, one version per day
97
		5 => ['intervalEndsAfter' => 2592000, 'step' => 86400],
98
		//until the end one version per week
99
		6 => ['intervalEndsAfter' => -1,      'step' => 604800],
100
	];
101
102
	/** @var \OCA\Files_Versions\AppInfo\Application */
103
	private static $application;
104
105
	/**
106
	 * get the UID of the owner of the file and the path to the file relative to
107
	 * owners files folder
108
	 *
109
	 * @param string $filename
110
	 * @return array
111
	 * @throws \OC\User\NoUserException
112
	 */
113
	public static function getUidAndFilename($filename) {
114
		$uid = Filesystem::getOwner($filename);
115
		$userManager = \OC::$server->get(IUserManager::class);
116
		// if the user with the UID doesn't exists, e.g. because the UID points
117
		// to a remote user with a federated cloud ID we use the current logged-in
118
		// user. We need a valid local user to create the versions
119
		if (!$userManager->userExists($uid)) {
120
			$uid = OC_User::getUser();
121
		}
122
		Filesystem::initMountPoints($uid);
123
		if ($uid !== OC_User::getUser()) {
124
			$info = Filesystem::getFileInfo($filename);
125
			$ownerView = new View('/'.$uid.'/files');
0 ignored issues
show
Bug introduced by
Are you sure $uid of type OCP\IUser|null|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

125
			$ownerView = new View('/'./** @scrutinizer ignore-type */ $uid.'/files');
Loading history...
126
			try {
127
				$filename = $ownerView->getPath($info['fileid']);
128
				// make sure that the file name doesn't end with a trailing slash
129
				// can for example happen single files shared across servers
130
				$filename = rtrim($filename, '/');
131
			} catch (NotFoundException $e) {
132
				$filename = null;
133
			}
134
		}
135
		return [$uid, $filename];
136
	}
137
138
	/**
139
	 * Remember the owner and the owner path of the source file
140
	 *
141
	 * @param string $source source path
142
	 */
143
	public static function setSourcePathAndUser($source) {
144
		[$uid, $path] = self::getUidAndFilename($source);
145
		self::$sourcePathAndUser[$source] = ['uid' => $uid, 'path' => $path];
146
	}
147
148
	/**
149
	 * Gets the owner and the owner path from the source path
150
	 *
151
	 * @param string $source source path
152
	 * @return array with user id and path
153
	 */
154
	public static function getSourcePathAndUser($source) {
155
		if (isset(self::$sourcePathAndUser[$source])) {
156
			$uid = self::$sourcePathAndUser[$source]['uid'];
157
			$path = self::$sourcePathAndUser[$source]['path'];
158
			unset(self::$sourcePathAndUser[$source]);
159
		} else {
160
			$uid = $path = false;
161
		}
162
		return [$uid, $path];
163
	}
164
165
	/**
166
	 * get current size of all versions from a given user
167
	 *
168
	 * @param string $user user who owns the versions
169
	 * @return int versions size
170
	 */
171
	private static function getVersionsSize($user) {
172
		$view = new View('/' . $user);
173
		$fileInfo = $view->getFileInfo('/files_versions');
174
		return isset($fileInfo['size']) ? $fileInfo['size'] : 0;
175
	}
176
177
	/**
178
	 * store a new version of a file.
179
	 */
180
	public static function store($filename) {
181
		// if the file gets streamed we need to remove the .part extension
182
		// to get the right target
183
		$ext = pathinfo($filename, PATHINFO_EXTENSION);
184
		if ($ext === 'part') {
185
			$filename = substr($filename, 0, -5);
186
		}
187
188
		// we only handle existing files
189
		if (! Filesystem::file_exists($filename) || Filesystem::is_dir($filename)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression OC\Files\Filesystem::file_exists($filename) of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
190
			return false;
191
		}
192
193
		// since hook paths are always relative to the "default filesystem view"
194
		// we always use the owner from there to get the full node
195
		$uid = Filesystem::getView()->getOwner('');
196
197
		/** @var IRootFolder $rootFolder */
198
		$rootFolder = \OC::$server->get(IRootFolder::class);
199
		$userFolder = $rootFolder->getUserFolder($uid);
200
201
		$eventDispatcher = \OC::$server->get(IEventDispatcher::class);
202
		try {
203
			$file = $userFolder->get($filename);
204
		} catch (NotFoundException $e) {
205
			return false;
206
		}
207
208
		$mount = $file->getMountPoint();
209
		if ($mount instanceof SharedMount) {
210
			$ownerFolder = $rootFolder->getUserFolder($mount->getShare()->getShareOwner());
211
			$ownerNodes = $ownerFolder->getById($file->getId());
212
			if (count($ownerNodes)) {
213
				$file = current($ownerNodes);
214
				$uid = $mount->getShare()->getShareOwner();
215
			}
216
		}
217
218
		/** @var IUserManager $userManager */
219
		$userManager = \OC::$server->get(IUserManager::class);
220
		$user = $userManager->get($uid);
221
222
		if (!$user) {
223
			return false;
224
		}
225
226
		// no use making versions for empty files
227
		if ($file->getSize() === 0) {
228
			return false;
229
		}
230
231
		$event = new CreateVersionEvent($file);
232
		$eventDispatcher->dispatch('OCA\Files_Versions::createVersion', $event);
233
		if ($event->shouldCreateVersion() === false) {
234
			return false;
235
		}
236
237
		/** @var IVersionManager $versionManager */
238
		$versionManager = \OC::$server->get(IVersionManager::class);
239
240
		$versionManager->createVersion($user, $file);
241
	}
242
243
244
	/**
245
	 * mark file as deleted so that we can remove the versions if the file is gone
246
	 * @param string $path
247
	 */
248
	public static function markDeletedFile($path) {
249
		[$uid, $filename] = self::getUidAndFilename($path);
250
		self::$deletedFiles[$path] = [
251
			'uid' => $uid,
252
			'filename' => $filename];
253
	}
254
255
	/**
256
	 * delete the version from the storage and cache
257
	 *
258
	 * @param View $view
259
	 * @param string $path
260
	 */
261
	protected static function deleteVersion($view, $path) {
262
		$view->unlink($path);
263
		/**
264
		 * @var \OC\Files\Storage\Storage $storage
265
		 * @var string $internalPath
266
		 */
267
		[$storage, $internalPath] = $view->resolvePath($path);
268
		$cache = $storage->getCache($internalPath);
269
		$cache->remove($internalPath);
270
	}
271
272
	/**
273
	 * Delete versions of a file
274
	 */
275
	public static function delete($path) {
276
		$deletedFile = self::$deletedFiles[$path];
277
		$uid = $deletedFile['uid'];
278
		$filename = $deletedFile['filename'];
279
280
		if (!Filesystem::file_exists($path)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression OC\Files\Filesystem::file_exists($path) of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
281
			$view = new View('/' . $uid . '/files_versions');
282
283
			$versions = self::getVersions($uid, $filename);
284
			if (!empty($versions)) {
285
				foreach ($versions as $v) {
286
					\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
287
					self::deleteVersion($view, $filename . '.v' . $v['version']);
288
					\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
289
				}
290
			}
291
		}
292
		unset(self::$deletedFiles[$path]);
293
	}
294
295
	/**
296
	 * Delete a version of a file
297
	 */
298
	public static function deleteRevision(string $path, int $revision): void {
299
		[$uid, $filename] = self::getUidAndFilename($path);
300
		$view = new View('/' . $uid . '/files_versions');
0 ignored issues
show
Bug introduced by
Are you sure $uid of type OCP\IUser|null|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

300
		$view = new View('/' . /** @scrutinizer ignore-type */ $uid . '/files_versions');
Loading history...
301
		\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
302
		self::deleteVersion($view, $filename . '.v' . $revision);
303
		\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
304
	}
305
306
	/**
307
	 * Rename or copy versions of a file of the given paths
308
	 *
309
	 * @param string $sourcePath source path of the file to move, relative to
310
	 * the currently logged in user's "files" folder
311
	 * @param string $targetPath target path of the file to move, relative to
312
	 * the currently logged in user's "files" folder
313
	 * @param string $operation can be 'copy' or 'rename'
314
	 */
315
	public static function renameOrCopy($sourcePath, $targetPath, $operation) {
316
		[$sourceOwner, $sourcePath] = self::getSourcePathAndUser($sourcePath);
317
318
		// it was a upload of a existing file if no old path exists
319
		// in this case the pre-hook already called the store method and we can
320
		// stop here
321
		if ($sourcePath === false) {
322
			return true;
323
		}
324
325
		[$targetOwner, $targetPath] = self::getUidAndFilename($targetPath);
326
327
		$sourcePath = ltrim($sourcePath, '/');
328
		$targetPath = ltrim($targetPath, '/');
329
330
		$rootView = new View('');
331
332
		// did we move a directory ?
333
		if ($rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
0 ignored issues
show
Bug introduced by
Are you sure $targetOwner of type OCP\IUser|null|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

333
		if ($rootView->is_dir('/' . /** @scrutinizer ignore-type */ $targetOwner . '/files/' . $targetPath)) {
Loading history...
334
			// does the directory exists for versions too ?
335
			if ($rootView->is_dir('/' . $sourceOwner . '/files_versions/' . $sourcePath)) {
336
				// create missing dirs if necessary
337
				self::createMissingDirectories($targetPath, new View('/'. $targetOwner));
338
339
				// move the directory containing the versions
340
				$rootView->$operation(
341
					'/' . $sourceOwner . '/files_versions/' . $sourcePath,
342
					'/' . $targetOwner . '/files_versions/' . $targetPath
343
				);
344
			}
345
		} elseif ($versions = Storage::getVersions($sourceOwner, '/' . $sourcePath)) {
346
			// create missing dirs if necessary
347
			self::createMissingDirectories($targetPath, new View('/'. $targetOwner));
348
349
			foreach ($versions as $v) {
350
				// move each version one by one to the target directory
351
				$rootView->$operation(
352
					'/' . $sourceOwner . '/files_versions/' . $sourcePath.'.v' . $v['version'],
353
					'/' . $targetOwner . '/files_versions/' . $targetPath.'.v'.$v['version']
354
				);
355
			}
356
		}
357
358
		// if we moved versions directly for a file, schedule expiration check for that file
359
		if (!$rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $rootView->is_dir('/' . .../files/' . $targetPath) of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
360
			self::scheduleExpire($targetOwner, $targetPath);
361
		}
362
	}
363
364
	/**
365
	 * Rollback to an old version of a file.
366
	 *
367
	 * @param string $file file name
368
	 * @param int $revision revision timestamp
369
	 * @return bool
370
	 */
371
	public static function rollback(string $file, int $revision, IUser $user) {
372
		// add expected leading slash
373
		$filename = '/' . ltrim($file, '/');
374
375
		// Fetch the userfolder to trigger view hooks
376
		$root = \OC::$server->get(IRootFolder::class);
377
		$userFolder = $root->getUserFolder($user->getUID());
378
379
		$users_view = new View('/'.$user->getUID());
380
		$files_view = new View('/'. $user->getUID().'/files');
381
382
		$versionCreated = false;
383
384
		$fileInfo = $files_view->getFileInfo($file);
385
386
		// check if user has the permissions to revert a version
387
		if (!$fileInfo->isUpdateable()) {
388
			return false;
389
		}
390
391
		//first create a new version
392
		$version = 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename);
393
		if (!$users_view->file_exists($version)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $users_view->file_exists($version) of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
394
			$users_view->copy('files'.$filename, 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename));
395
			$versionCreated = true;
396
		}
397
398
		$fileToRestore = 'files_versions' . $filename . '.v' . $revision;
399
400
		// Restore encrypted version of the old file for the newly restored file
401
		// This has to happen manually here since the file is manually copied below
402
		$oldVersion = $users_view->getFileInfo($fileToRestore)->getEncryptedVersion();
403
		$oldFileInfo = $users_view->getFileInfo($fileToRestore);
404
		$cache = $fileInfo->getStorage()->getCache();
405
		$cache->update(
406
			$fileInfo->getId(), [
407
				'encrypted' => $oldVersion,
408
				'encryptedVersion' => $oldVersion,
409
				'size' => $oldFileInfo->getSize()
410
			]
411
		);
412
413
		// rollback
414
		if (self::copyFileContents($users_view, $fileToRestore, 'files' . $filename)) {
415
			$files_view->touch($file, $revision);
416
			Storage::scheduleExpire($user->getUID(), $file);
417
418
			$node = $userFolder->get($file);
0 ignored issues
show
Unused Code introduced by
The assignment to $node is dead and can be removed.
Loading history...
419
420
			return true;
421
		} elseif ($versionCreated) {
422
			self::deleteVersion($users_view, $version);
423
		}
424
425
		return false;
426
	}
427
428
	/**
429
	 * Stream copy file contents from $path1 to $path2
430
	 *
431
	 * @param View $view view to use for copying
432
	 * @param string $path1 source file to copy
433
	 * @param string $path2 target file
434
	 *
435
	 * @return bool true for success, false otherwise
436
	 */
437
	private static function copyFileContents($view, $path1, $path2) {
438
		/** @var \OC\Files\Storage\Storage $storage1 */
439
		[$storage1, $internalPath1] = $view->resolvePath($path1);
440
		/** @var \OC\Files\Storage\Storage $storage2 */
441
		[$storage2, $internalPath2] = $view->resolvePath($path2);
442
443
		$view->lockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
444
		$view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
445
446
		try {
447
			// TODO add a proper way of overwriting a file while maintaining file ids
448
			if ($storage1->instanceOfStorage('\OC\Files\ObjectStore\ObjectStoreStorage') || $storage2->instanceOfStorage('\OC\Files\ObjectStore\ObjectStoreStorage')) {
449
				$source = $storage1->fopen($internalPath1, 'r');
450
				$target = $storage2->fopen($internalPath2, 'w');
451
				[, $result] = \OC_Helper::streamCopy($source, $target);
452
				fclose($source);
453
				fclose($target);
454
455
				if ($result !== false) {
456
					$storage1->unlink($internalPath1);
457
				}
458
			} else {
459
				$result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2);
460
			}
461
		} finally {
462
			$view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
463
			$view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
464
		}
465
466
		return ($result !== false);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $result does not seem to be defined for all execution paths leading up to this point.
Loading history...
467
	}
468
469
	/**
470
	 * get a list of all available versions of a file in descending chronological order
471
	 * @param string $uid user id from the owner of the file
472
	 * @param string $filename file to find versions of, relative to the user files dir
473
	 * @param string $userFullPath
474
	 * @return array versions newest version first
475
	 */
476
	public static function getVersions($uid, $filename, $userFullPath = '') {
477
		$versions = [];
478
		if (empty($filename)) {
479
			return $versions;
480
		}
481
		// fetch for old versions
482
		$view = new View('/' . $uid . '/');
483
484
		$pathinfo = pathinfo($filename);
485
		$versionedFile = $pathinfo['basename'];
486
487
		$dir = Filesystem::normalizePath(self::VERSIONS_ROOT . '/' . $pathinfo['dirname']);
488
489
		$dirContent = false;
490
		if ($view->is_dir($dir)) {
491
			$dirContent = $view->opendir($dir);
492
		}
493
494
		if ($dirContent === false) {
495
			return $versions;
496
		}
497
498
		if (is_resource($dirContent)) {
499
			while (($entryName = readdir($dirContent)) !== false) {
500
				if (!Filesystem::isIgnoredDir($entryName)) {
501
					$pathparts = pathinfo($entryName);
502
					$filename = $pathparts['filename'];
503
					if ($filename === $versionedFile) {
504
						$pathparts = pathinfo($entryName);
505
						$timestamp = substr($pathparts['extension'] ?? '', 1);
506
						if (!is_numeric($timestamp)) {
507
							\OC::$server->get(LoggerInterface::class)->error(
508
								'Version file {path} has incorrect name format',
509
								[
510
									'path' => $entryName,
511
									'app' => 'files_versions',
512
								]
513
							);
514
							continue;
515
						}
516
						$filename = $pathparts['filename'];
517
						$key = $timestamp . '#' . $filename;
518
						$versions[$key]['version'] = $timestamp;
519
						$versions[$key]['humanReadableTimestamp'] = self::getHumanReadableTimestamp((int)$timestamp);
520
						if (empty($userFullPath)) {
521
							$versions[$key]['preview'] = '';
522
						} else {
523
							/** @var IURLGenerator $urlGenerator */
524
							$urlGenerator = \OC::$server->get(IURLGenerator::class);
525
							$versions[$key]['preview'] = $urlGenerator->linkToRoute('files_version.Preview.getPreview',
526
								['file' => $userFullPath, 'version' => $timestamp]);
527
						}
528
						$versions[$key]['path'] = Filesystem::normalizePath($pathinfo['dirname'] . '/' . $filename);
529
						$versions[$key]['name'] = $versionedFile;
530
						$versions[$key]['size'] = $view->filesize($dir . '/' . $entryName);
531
						$versions[$key]['mimetype'] = \OC::$server->get(IMimeTypeDetector::class)->detectPath($versionedFile);
532
					}
533
				}
534
			}
535
			closedir($dirContent);
536
		}
537
538
		// sort with newest version first
539
		krsort($versions);
540
541
		return $versions;
542
	}
543
544
	/**
545
	 * Expire versions that older than max version retention time
546
	 *
547
	 * @param string $uid
548
	 */
549
	public static function expireOlderThanMaxForUser($uid) {
550
		/** @var IRootFolder $root */
551
		$root = \OC::$server->get(IRootFolder::class);
552
		try {
553
			/** @var Folder $versionsRoot */
554
			$versionsRoot = $root->get('/' . $uid . '/files_versions');
555
		} catch (NotFoundException $e) {
556
			return;
557
		}
558
559
		$expiration = self::getExpiration();
560
		$threshold = $expiration->getMaxAgeAsTimestamp();
561
		if (!$threshold) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $threshold of type false|integer is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
562
			return;
563
		}
564
565
		$allVersions = $versionsRoot->search(new SearchQuery(
566
			new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_NOT, [
567
				new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', FileInfo::MIMETYPE_FOLDER),
568
			]),
569
			0,
570
			0,
571
			[]
572
		));
573
574
		/** @var VersionsMapper $versionsMapper */
575
		$versionsMapper = \OC::$server->get(VersionsMapper::class);
576
		$userFolder = $root->getUserFolder($uid);
577
		$versionEntities = [];
578
579
		/** @var Node[] $versions */
580
		$versions = array_filter($allVersions, function (Node $info) use ($threshold, $userFolder, $versionsMapper, $versionsRoot, &$versionEntities) {
581
			// Check that the file match '*.v*'
582
			$versionsBegin = strrpos($info->getName(), '.v');
583
			if ($versionsBegin === false) {
584
				return false;
585
			}
586
587
			$version = (int)substr($info->getName(), $versionsBegin + 2);
588
589
			// Check that the version does not have a label.
590
			$path = $versionsRoot->getRelativePath($info->getPath());
591
			if ($path === null) {
592
				throw new DoesNotExistException('Could not find relative path of (' . $info->getPath() . ')');
593
			}
594
595
			$node = $userFolder->get(substr($path, 0, -strlen('.v'.$version)));
596
			try {
597
				$versionEntity = $versionsMapper->findVersionForFileId($node->getId(), $version);
598
				$versionEntities[$info->getId()] = $versionEntity;
599
600
				if ($versionEntity->getLabel() !== '') {
601
					return false;
602
				}
603
			} catch (DoesNotExistException $ex) {
604
				// Version on FS can have no equivalent in the DB if they were created before the version naming feature.
605
				// So we ignore DoesNotExistException.
606
			}
607
608
			// Check that the version's timestamp is lower than $threshold
609
			return $version < $threshold;
610
		});
611
612
		foreach ($versions as $version) {
613
			$internalPath = $version->getInternalPath();
614
			\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
615
616
			$versionEntity = isset($versionEntities[$version->getId()]) ? $versionEntities[$version->getId()] : null;
617
			if (!is_null($versionEntity)) {
618
				$versionsMapper->delete($versionEntity);
619
			}
620
621
			$version->delete();
622
			\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
623
		}
624
	}
625
626
	/**
627
	 * translate a timestamp into a string like "5 days ago"
628
	 *
629
	 * @param int $timestamp
630
	 * @return string for example "5 days ago"
631
	 */
632
	private static function getHumanReadableTimestamp(int $timestamp): string {
633
		$diff = time() - $timestamp;
634
635
		if ($diff < 60) { // first minute
636
			return  $diff . " seconds ago";
637
		} elseif ($diff < 3600) { //first hour
638
			return round($diff / 60) . " minutes ago";
639
		} elseif ($diff < 86400) { // first day
640
			return round($diff / 3600) . " hours ago";
641
		} elseif ($diff < 604800) { //first week
642
			return round($diff / 86400) . " days ago";
643
		} elseif ($diff < 2419200) { //first month
644
			return round($diff / 604800) . " weeks ago";
645
		} elseif ($diff < 29030400) { // first year
646
			return round($diff / 2419200) . " months ago";
647
		} else {
648
			return round($diff / 29030400) . " years ago";
649
		}
650
	}
651
652
	/**
653
	 * returns all stored file versions from a given user
654
	 * @param string $uid id of the user
655
	 * @return array with contains two arrays 'all' which contains all versions sorted by age and 'by_file' which contains all versions sorted by filename
656
	 */
657
	private static function getAllVersions($uid) {
658
		$view = new View('/' . $uid . '/');
659
		$dirs = [self::VERSIONS_ROOT];
660
		$versions = [];
661
662
		while (!empty($dirs)) {
663
			$dir = array_pop($dirs);
664
			$files = $view->getDirectoryContent($dir);
665
666
			foreach ($files as $file) {
667
				$fileData = $file->getData();
668
				$filePath = $dir . '/' . $fileData['name'];
669
				if ($file['type'] === 'dir') {
670
					$dirs[] = $filePath;
671
				} else {
672
					$versionsBegin = strrpos($filePath, '.v');
673
					$relPathStart = strlen(self::VERSIONS_ROOT);
674
					$version = substr($filePath, $versionsBegin + 2);
675
					$relpath = substr($filePath, $relPathStart, $versionsBegin - $relPathStart);
676
					$key = $version . '#' . $relpath;
677
					$versions[$key] = ['path' => $relpath, 'timestamp' => $version];
678
				}
679
			}
680
		}
681
682
		// newest version first
683
		krsort($versions);
684
685
		$result = [
686
			'all' => [],
687
			'by_file' => [],
688
		];
689
690
		foreach ($versions as $key => $value) {
691
			$size = $view->filesize(self::VERSIONS_ROOT.'/'.$value['path'].'.v'.$value['timestamp']);
692
			$filename = $value['path'];
693
694
			$result['all'][$key]['version'] = $value['timestamp'];
695
			$result['all'][$key]['path'] = $filename;
696
			$result['all'][$key]['size'] = $size;
697
698
			$result['by_file'][$filename][$key]['version'] = $value['timestamp'];
699
			$result['by_file'][$filename][$key]['path'] = $filename;
700
			$result['by_file'][$filename][$key]['size'] = $size;
701
		}
702
703
		return $result;
704
	}
705
706
	/**
707
	 * get list of files we want to expire
708
	 * @param array $versions list of versions
709
	 * @param integer $time
710
	 * @param bool $quotaExceeded is versions storage limit reached
711
	 * @return array containing the list of to deleted versions and the size of them
712
	 */
713
	protected static function getExpireList($time, $versions, $quotaExceeded = false) {
714
		$expiration = self::getExpiration();
715
716
		if ($expiration->shouldAutoExpire()) {
717
			[$toDelete, $size] = self::getAutoExpireList($time, $versions);
718
		} else {
719
			$size = 0;
720
			$toDelete = [];  // versions we want to delete
721
		}
722
723
		foreach ($versions as $key => $version) {
724
			if (!is_numeric($version['version'])) {
725
				\OC::$server->get(LoggerInterface::class)->error(
726
					'Found a non-numeric timestamp version: '. json_encode($version),
727
					['app' => 'files_versions']);
728
				continue;
729
			}
730
			if ($expiration->isExpired((int)($version['version']), $quotaExceeded) && !isset($toDelete[$key])) {
731
				$size += $version['size'];
732
				$toDelete[$key] = $version['path'] . '.v' . $version['version'];
733
			}
734
		}
735
736
		return [$toDelete, $size];
737
	}
738
739
	/**
740
	 * get list of files we want to expire
741
	 * @param array $versions list of versions
742
	 * @param integer $time
743
	 * @return array containing the list of to deleted versions and the size of them
744
	 */
745
	protected static function getAutoExpireList($time, $versions) {
746
		$size = 0;
747
		$toDelete = [];  // versions we want to delete
748
749
		$interval = 1;
750
		$step = Storage::$max_versions_per_interval[$interval]['step'];
751
		if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) {
752
			$nextInterval = -1;
753
		} else {
754
			$nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
755
		}
756
757
		$firstVersion = reset($versions);
758
759
		if ($firstVersion === false) {
760
			return [$toDelete, $size];
761
		}
762
763
		$firstKey = key($versions);
764
		$prevTimestamp = $firstVersion['version'];
765
		$nextVersion = $firstVersion['version'] - $step;
766
		unset($versions[$firstKey]);
767
768
		foreach ($versions as $key => $version) {
769
			$newInterval = true;
770
			while ($newInterval) {
771
				if ($nextInterval === -1 || $prevTimestamp > $nextInterval) {
772
					if ($version['version'] > $nextVersion) {
773
						//distance between two version too small, mark to delete
774
						$toDelete[$key] = $version['path'] . '.v' . $version['version'];
775
						$size += $version['size'];
776
						\OC::$server->get(LoggerInterface::class)->info('Mark to expire '. $version['path'] .' next version should be ' . $nextVersion . " or smaller. (prevTimestamp: " . $prevTimestamp . "; step: " . $step, ['app' => 'files_versions']);
777
					} else {
778
						$nextVersion = $version['version'] - $step;
779
						$prevTimestamp = $version['version'];
780
					}
781
					$newInterval = false; // version checked so we can move to the next one
782
				} else { // time to move on to the next interval
783
					$interval++;
784
					$step = Storage::$max_versions_per_interval[$interval]['step'];
785
					$nextVersion = $prevTimestamp - $step;
786
					if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) {
787
						$nextInterval = -1;
788
					} else {
789
						$nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
790
					}
791
					$newInterval = true; // we changed the interval -> check same version with new interval
792
				}
793
			}
794
		}
795
796
		return [$toDelete, $size];
797
	}
798
799
	/**
800
	 * Schedule versions expiration for the given file
801
	 *
802
	 * @param string $uid owner of the file
803
	 * @param string $fileName file/folder for which to schedule expiration
804
	 */
805
	public static function scheduleExpire($uid, $fileName) {
806
		// let the admin disable auto expire
807
		$expiration = self::getExpiration();
808
		if ($expiration->isEnabled()) {
809
			$command = new Expire($uid, $fileName);
810
			/** @var IBus $bus */
811
			$bus = \OC::$server->get(IBus::class);
812
			$bus->push($command);
813
		}
814
	}
815
816
	/**
817
	 * Expire versions which exceed the quota.
818
	 *
819
	 * This will setup the filesystem for the given user but will not
820
	 * tear it down afterwards.
821
	 *
822
	 * @param string $filename path to file to expire
823
	 * @param string $uid user for which to expire the version
824
	 * @return bool|int|null
825
	 */
826
	public static function expire($filename, $uid) {
827
		$expiration = self::getExpiration();
828
829
		/** @var LoggerInterface $logger */
830
		$logger = \OC::$server->get(LoggerInterface::class);
831
832
		if ($expiration->isEnabled()) {
833
			// get available disk space for user
834
			$user = \OC::$server->get(IUserManager::class)->get($uid);
835
			if (is_null($user)) {
836
				$logger->error('Backends provided no user object for ' . $uid, ['app' => 'files_versions']);
837
				throw new \OC\User\NoUserException('Backends provided no user object for ' . $uid);
838
			}
839
840
			\OC_Util::setupFS($uid);
841
842
			try {
843
				if (!Filesystem::file_exists($filename)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression OC\Files\Filesystem::file_exists($filename) of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
844
					return false;
845
				}
846
			} catch (StorageNotAvailableException $e) {
847
				// if we can't check that the file hasn't been deleted we can only assume that it hasn't
848
				// note that this `StorageNotAvailableException` is about the file the versions originate from,
849
				// not the storage that the versions are stored on
850
			}
851
852
			if (empty($filename)) {
853
				// file maybe renamed or deleted
854
				return false;
855
			}
856
			$versionsFileview = new View('/'.$uid.'/files_versions');
857
858
			$softQuota = true;
859
			$quota = $user->getQuota();
860
			if ($quota === null || $quota === 'none') {
861
				$quota = Filesystem::free_space('/');
862
				$softQuota = false;
863
			} else {
864
				$quota = \OCP\Util::computerFileSize($quota);
865
			}
866
867
			// make sure that we have the current size of the version history
868
			$versionsSize = self::getVersionsSize($uid);
869
870
			// calculate available space for version history
871
			// subtract size of files and current versions size from quota
872
			if ($quota >= 0) {
873
				if ($softQuota) {
874
					$root = \OC::$server->get(IRootFolder::class);
875
					$userFolder = $root->getUserFolder($uid);
876
					if (is_null($userFolder)) {
877
						$availableSpace = 0;
878
					} else {
879
						$free = $quota - $userFolder->getSize(false); // remaining free space for user
880
						if ($free > 0) {
881
							$availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $versionsSize; // how much space can be used for versions
882
						} else {
883
							$availableSpace = $free - $versionsSize;
884
						}
885
					}
886
				} else {
887
					$availableSpace = $quota;
888
				}
889
			} else {
890
				$availableSpace = PHP_INT_MAX;
891
			}
892
893
			$allVersions = Storage::getVersions($uid, $filename);
894
895
			$time = time();
896
			[$toDelete, $sizeOfDeletedVersions] = self::getExpireList($time, $allVersions, $availableSpace <= 0);
897
898
			$availableSpace = $availableSpace + $sizeOfDeletedVersions;
899
			$versionsSize = $versionsSize - $sizeOfDeletedVersions;
900
901
			// if still not enough free space we rearrange the versions from all files
902
			if ($availableSpace <= 0) {
903
				$result = self::getAllVersions($uid);
904
				$allVersions = $result['all'];
905
906
				foreach ($result['by_file'] as $versions) {
907
					[$toDeleteNew, $size] = self::getExpireList($time, $versions, $availableSpace <= 0);
908
					$toDelete = array_merge($toDelete, $toDeleteNew);
909
					$sizeOfDeletedVersions += $size;
910
				}
911
				$availableSpace = $availableSpace + $sizeOfDeletedVersions;
912
				$versionsSize = $versionsSize - $sizeOfDeletedVersions;
913
			}
914
915
			foreach ($toDelete as $key => $path) {
916
				\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
917
				self::deleteVersion($versionsFileview, $path);
918
				\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
919
				unset($allVersions[$key]); // update array with the versions we keep
920
				$logger->info('Expire: ' . $path, ['app' => 'files_versions']);
921
			}
922
923
			// Check if enough space is available after versions are rearranged.
924
			// If not we delete the oldest versions until we meet the size limit for versions,
925
			// but always keep the two latest versions
926
			$numOfVersions = count($allVersions) - 2 ;
927
			$i = 0;
928
			// sort oldest first and make sure that we start at the first element
929
			ksort($allVersions);
930
			reset($allVersions);
931
			while ($availableSpace < 0 && $i < $numOfVersions) {
932
				$version = current($allVersions);
933
				\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
934
				self::deleteVersion($versionsFileview, $version['path'] . '.v' . $version['version']);
935
				\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]);
936
				$logger->info('running out of space! Delete oldest version: ' . $version['path'].'.v'.$version['version'], ['app' => 'files_versions']);
937
				$versionsSize -= $version['size'];
938
				$availableSpace += $version['size'];
939
				next($allVersions);
940
				$i++;
941
			}
942
943
			return $versionsSize; // finally return the new size of the version history
944
		}
945
946
		return false;
947
	}
948
949
	/**
950
	 * Create recursively missing directories inside of files_versions
951
	 * that match the given path to a file.
952
	 *
953
	 * @param string $filename $path to a file, relative to the user's
954
	 * "files" folder
955
	 * @param View $view view on data/user/
956
	 */
957
	public static function createMissingDirectories($filename, $view) {
958
		$dirname = Filesystem::normalizePath(dirname($filename));
959
		$dirParts = explode('/', $dirname);
960
		$dir = "/files_versions";
961
		foreach ($dirParts as $part) {
962
			$dir = $dir . '/' . $part;
963
			if (!$view->file_exists($dir)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $view->file_exists($dir) of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
964
				$view->mkdir($dir);
965
			}
966
		}
967
	}
968
969
	/**
970
	 * Static workaround
971
	 * @return Expiration
972
	 */
973
	protected static function getExpiration() {
974
		if (self::$application === null) {
975
			self::$application = \OC::$server->get(Application::class);
976
		}
977
		return self::$application->getContainer()->get(Expiration::class);
978
	}
979
}
980