Completed
Push — stable9 ( 485cb1...e094cf )
by Lukas
26:41 queued 26:23
created

Storage::getVersionsSize()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 1
dl 0
loc 5
rs 9.4285
c 0
b 0
f 0
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 Carlos Damken <[email protected]>
10
 * @author Felix Moeller <[email protected]>
11
 * @author Georg Ehrke <[email protected]>
12
 * @author Joas Schilling <[email protected]>
13
 * @author Jörn Friedrich Dreyer <[email protected]>
14
 * @author Lukas Reschke <[email protected]>
15
 * @author Morris Jobke <[email protected]>
16
 * @author Robin Appelman <[email protected]>
17
 * @author Robin McCorkell <[email protected]>
18
 * @author Thomas Müller <[email protected]>
19
 * @author Victor Dubiniuk <[email protected]>
20
 * @author Vincent Petry <[email protected]>
21
 *
22
 * @license AGPL-3.0
23
 *
24
 * This code is free software: you can redistribute it and/or modify
25
 * it under the terms of the GNU Affero General Public License, version 3,
26
 * as published by the Free Software Foundation.
27
 *
28
 * This program is distributed in the hope that it will be useful,
29
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
30
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
31
 * GNU Affero General Public License for more details.
32
 *
33
 * You should have received a copy of the GNU Affero General Public License, version 3,
34
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
35
 *
36
 */
37
38
/**
39
 * Versions
40
 *
41
 * A class to handle the versioning of files.
42
 */
43
44
namespace OCA\Files_Versions;
45
46
use OC\Files\Filesystem;
47
use OC\Files\View;
48
use OCA\Files_Versions\AppInfo\Application;
49
use OCA\Files_Versions\Command\Expire;
50
use OCP\Files\NotFoundException;
51
use OCP\Lock\ILockingProvider;
52
use OCP\User;
53
54
class Storage {
55
56
	const DEFAULTENABLED=true;
57
	const DEFAULTMAXSIZE=50; // unit: percentage; 50% of available disk space/quota
58
	const VERSIONS_ROOT = 'files_versions/';
59
60
	const DELETE_TRIGGER_MASTER_REMOVED = 0;
61
	const DELETE_TRIGGER_RETENTION_CONSTRAINT = 1;
62
	const DELETE_TRIGGER_QUOTA_EXCEEDED = 2;
63
64
	// files for which we can remove the versions after the delete operation was successful
65
	private static $deletedFiles = array();
66
67
	private static $sourcePathAndUser = array();
68
69
	private static $max_versions_per_interval = array(
0 ignored issues
show
Unused Code introduced by
The property $max_versions_per_interval is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
70
		//first 10sec, one version every 2sec
71
		1 => array('intervalEndsAfter' => 10,      'step' => 2),
72
		//next minute, one version every 10sec
73
		2 => array('intervalEndsAfter' => 60,      'step' => 10),
74
		//next hour, one version every minute
75
		3 => array('intervalEndsAfter' => 3600,    'step' => 60),
76
		//next 24h, one version every hour
77
		4 => array('intervalEndsAfter' => 86400,   'step' => 3600),
78
		//next 30days, one version per day
79
		5 => array('intervalEndsAfter' => 2592000, 'step' => 86400),
80
		//until the end one version per week
81
		6 => array('intervalEndsAfter' => -1,      'step' => 604800),
82
	);
83
84
	/** @var \OCA\Files_Versions\AppInfo\Application */
85
	private static $application;
86
87
	/**
88
	 * get the UID of the owner of the file and the path to the file relative to
89
	 * owners files folder
90
	 *
91
	 * @param string $filename
92
	 * @return array
93
	 * @throws \OC\User\NoUserException
94
	 */
95 View Code Duplication
	public static function getUidAndFilename($filename) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
96
		$uid = Filesystem::getOwner($filename);
97
		$userManager = \OC::$server->getUserManager();
98
		// if the user with the UID doesn't exists, e.g. because the UID points
99
		// to a remote user with a federated cloud ID we use the current logged-in
100
		// user. We need a valid local user to create the versions
101
		if (!$userManager->userExists($uid)) {
102
			$uid = User::getUser();
0 ignored issues
show
Deprecated Code introduced by
The method OCP\User::getUser() has been deprecated with message: 8.0.0 Use \OC::$server->getUserSession()->getUser()->getUID()

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
103
		}
104
		Filesystem::initMountPoints($uid);
105
		if ( $uid != User::getUser() ) {
0 ignored issues
show
Deprecated Code introduced by
The method OCP\User::getUser() has been deprecated with message: 8.0.0 Use \OC::$server->getUserSession()->getUser()->getUID()

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
106
			$info = Filesystem::getFileInfo($filename);
107
			$ownerView = new View('/'.$uid.'/files');
108
			try {
109
				$filename = $ownerView->getPath($info['fileid']);
110
				// make sure that the file name doesn't end with a trailing slash
111
				// can for example happen single files shared across servers
112
				$filename = rtrim($filename, '/');
113
			} catch (NotFoundException $e) {
114
				$filename = null;
115
			}
116
		}
117
		return [$uid, $filename];
118
	}
119
120
	/**
121
	 * Remember the owner and the owner path of the source file
122
	 *
123
	 * @param string $source source path
124
	 */
125
	public static function setSourcePathAndUser($source) {
126
		list($uid, $path) = self::getUidAndFilename($source);
127
		self::$sourcePathAndUser[$source] = array('uid' => $uid, 'path' => $path);
128
	}
129
130
	/**
131
	 * Gets the owner and the owner path from the source path
132
	 *
133
	 * @param string $source source path
134
	 * @return array with user id and path
135
	 */
136
	public static function getSourcePathAndUser($source) {
137
138
		if (isset(self::$sourcePathAndUser[$source])) {
139
			$uid = self::$sourcePathAndUser[$source]['uid'];
140
			$path = self::$sourcePathAndUser[$source]['path'];
141
			unset(self::$sourcePathAndUser[$source]);
142
		} else {
143
			$uid = $path = false;
144
		}
145
		return array($uid, $path);
146
	}
147
148
	/**
149
	 * get current size of all versions from a given user
150
	 *
151
	 * @param string $user user who owns the versions
152
	 * @return int versions size
153
	 */
154
	private static function getVersionsSize($user) {
155
		$view = new View('/' . $user);
156
		$fileInfo = $view->getFileInfo('/files_versions');
157
		return isset($fileInfo['size']) ? $fileInfo['size'] : 0;
158
	}
159
160
	/**
161
	 * store a new version of a file.
162
	 */
163
	public static function store($filename) {
164
		if(\OCP\Config::getSystemValue('files_versions', Storage::DEFAULTENABLED)=='true') {
0 ignored issues
show
Deprecated Code introduced by
The method OCP\Config::getSystemValue() has been deprecated with message: 8.0.0 use method getSystemValue of \OCP\IConfig This function gets the value from config.php. If it does not exist,
$default will be returned.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
165
166
			// if the file gets streamed we need to remove the .part extension
167
			// to get the right target
168
			$ext = pathinfo($filename, PATHINFO_EXTENSION);
169
			if ($ext === 'part') {
170
				$filename = substr($filename, 0, strlen($filename) - 5);
171
			}
172
173
			// we only handle existing files
174
			if (! Filesystem::file_exists($filename) || Filesystem::is_dir($filename)) {
175
				return false;
176
			}
177
178
			list($uid, $filename) = self::getUidAndFilename($filename);
179
180
			$files_view = new View('/'.$uid .'/files');
181
			$users_view = new View('/'.$uid);
182
183
			// no use making versions for empty files
184
			if ($files_view->filesize($filename) === 0) {
185
				return false;
186
			}
187
188
			// create all parent folders
189
			self::createMissingDirectories($filename, $users_view);
190
191
			self::scheduleExpire($uid, $filename);
192
193
			// store a new version of a file
194
			$mtime = $users_view->filemtime('files/' . $filename);
195
			$users_view->copy('files/' . $filename, 'files_versions/' . $filename . '.v' . $mtime);
196
			// call getFileInfo to enforce a file cache entry for the new version
197
			$users_view->getFileInfo('files_versions/' . $filename . '.v' . $mtime);
198
		}
199
	}
200
201
202
	/**
203
	 * mark file as deleted so that we can remove the versions if the file is gone
204
	 * @param string $path
205
	 */
206
	public static function markDeletedFile($path) {
207
		list($uid, $filename) = self::getUidAndFilename($path);
208
		self::$deletedFiles[$path] = array(
209
			'uid' => $uid,
210
			'filename' => $filename);
211
	}
212
213
	/**
214
	 * delete the version from the storage and cache
215
	 *
216
	 * @param View $view
217
	 * @param string $path
218
	 */
219
	protected static function deleteVersion($view, $path) {
220
		$view->unlink($path);
221
		/**
222
		 * @var \OC\Files\Storage\Storage $storage
223
		 * @var string $internalPath
224
		 */
225
		list($storage, $internalPath) = $view->resolvePath($path);
226
		$cache = $storage->getCache($internalPath);
227
		$cache->remove($internalPath);
228
	}
229
230
	/**
231
	 * Delete versions of a file
232
	 */
233
	public static function delete($path) {
234
235
		$deletedFile = self::$deletedFiles[$path];
236
		$uid = $deletedFile['uid'];
237
		$filename = $deletedFile['filename'];
238
239
		if (!Filesystem::file_exists($path)) {
240
241
			$view = new View('/' . $uid . '/files_versions');
242
243
			$versions = self::getVersions($uid, $filename);
244
			if (!empty($versions)) {
245
				foreach ($versions as $v) {
246
					\OC_Hook::emit('\OCP\Versions', 'preDelete', array('path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED));
247
					self::deleteVersion($view, $filename . '.v' . $v['version']);
248
					\OC_Hook::emit('\OCP\Versions', 'delete', array('path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED));
249
				}
250
			}
251
		}
252
		unset(self::$deletedFiles[$path]);
253
	}
254
255
	/**
256
	 * Rename or copy versions of a file of the given paths
257
	 *
258
	 * @param string $sourcePath source path of the file to move, relative to
259
	 * the currently logged in user's "files" folder
260
	 * @param string $targetPath target path of the file to move, relative to
261
	 * the currently logged in user's "files" folder
262
	 * @param string $operation can be 'copy' or 'rename'
263
	 */
264
	public static function renameOrCopy($sourcePath, $targetPath, $operation) {
265
		list($sourceOwner, $sourcePath) = self::getSourcePathAndUser($sourcePath);
266
267
		// it was a upload of a existing file if no old path exists
268
		// in this case the pre-hook already called the store method and we can
269
		// stop here
270
		if ($sourcePath === false) {
271
			return true;
272
		}
273
274
		list($targetOwner, $targetPath) = self::getUidAndFilename($targetPath);
275
276
		$sourcePath = ltrim($sourcePath, '/');
277
		$targetPath = ltrim($targetPath, '/');
278
279
		$rootView = new View('');
280
281
		// did we move a directory ?
282
		if ($rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
283
			// does the directory exists for versions too ?
284
			if ($rootView->is_dir('/' . $sourceOwner . '/files_versions/' . $sourcePath)) {
285
				// create missing dirs if necessary
286
				self::createMissingDirectories($targetPath, new View('/'. $targetOwner));
287
288
				// move the directory containing the versions
289
				$rootView->$operation(
290
					'/' . $sourceOwner . '/files_versions/' . $sourcePath,
291
					'/' . $targetOwner . '/files_versions/' . $targetPath
292
				);
293
			}
294
		} else if ($versions = Storage::getVersions($sourceOwner, '/' . $sourcePath)) {
295
			// create missing dirs if necessary
296
			self::createMissingDirectories($targetPath, new View('/'. $targetOwner));
297
298
			foreach ($versions as $v) {
299
				// move each version one by one to the target directory
300
				$rootView->$operation(
301
					'/' . $sourceOwner . '/files_versions/' . $sourcePath.'.v' . $v['version'],
302
					'/' . $targetOwner . '/files_versions/' . $targetPath.'.v'.$v['version']
303
				);
304
			}
305
		}
306
307
		// if we moved versions directly for a file, schedule expiration check for that file
308
		if (!$rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) {
309
			self::scheduleExpire($targetOwner, $targetPath);
310
		}
311
312
	}
313
314
	/**
315
	 * Rollback to an old version of a file.
316
	 *
317
	 * @param string $file file name
318
	 * @param int $revision revision timestamp
319
	 */
320
	public static function rollback($file, $revision) {
321
322
		if(\OCP\Config::getSystemValue('files_versions', Storage::DEFAULTENABLED)=='true') {
0 ignored issues
show
Deprecated Code introduced by
The method OCP\Config::getSystemValue() has been deprecated with message: 8.0.0 use method getSystemValue of \OCP\IConfig This function gets the value from config.php. If it does not exist,
$default will be returned.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
323
			// add expected leading slash
324
			$file = '/' . ltrim($file, '/');
325
			list($uid, $filename) = self::getUidAndFilename($file);
326
			if ($uid === null || trim($filename, '/') === '') {
327
				return false;
328
			}
329
330
			$users_view = new View('/'.$uid);
331
			$files_view = new View('/'. User::getUser().'/files');
0 ignored issues
show
Deprecated Code introduced by
The method OCP\User::getUser() has been deprecated with message: 8.0.0 Use \OC::$server->getUserSession()->getUser()->getUID()

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
332
			$versionCreated = false;
333
334
			$fileInfo = $files_view->getFileInfo($file);
335
336
			// check if user has the permissions to revert a version
337
			if (!$fileInfo->isUpdateable()) {
338
				return false;
339
			}
340
341
			//first create a new version
342
			$version = 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename);
343
			if (!$users_view->file_exists($version)) {
344
				$users_view->copy('files'.$filename, 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename));
345
				$versionCreated = true;
346
			}
347
348
			$fileToRestore =  'files_versions' . $filename . '.v' . $revision;
349
350
			// Restore encrypted version of the old file for the newly restored file
351
			// This has to happen manually here since the file is manually copied below
352
			$oldVersion = $users_view->getFileInfo($fileToRestore)->getEncryptedVersion();
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface OCP\Files\FileInfo as the method getEncryptedVersion() does only exist in the following implementations of said interface: OC\Files\FileInfo.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
353
			$oldFileInfo = $users_view->getFileInfo($fileToRestore);
354
			$cache = $fileInfo->getStorage()->getCache();
355
			$cache->update(
356
				$fileInfo->getId(), [
357
					'encrypted' => $oldVersion,
358
					'encryptedVersion' => $oldVersion,
359
					'size' => $oldFileInfo->getSize()
360
				]
361
			);
362
363
			// rollback
364
			if (self::copyFileContents($users_view, $fileToRestore, 'files' . $filename)) {
365
				$files_view->touch($file, $revision);
366
				Storage::scheduleExpire($uid, $file);
367
				\OC_Hook::emit('\OCP\Versions', 'rollback', array(
368
					'path' => $filename,
369
					'revision' => $revision,
370
				));
371
				return true;
372
			} else if ($versionCreated) {
373
				self::deleteVersion($users_view, $version);
374
			}
375
		}
376
		return false;
377
378
	}
379
380
	/**
381
	 * Stream copy file contents from $path1 to $path2
382
	 *
383
	 * @param View $view view to use for copying
384
	 * @param string $path1 source file to copy
385
	 * @param string $path2 target file
386
	 *
387
	 * @return bool true for success, false otherwise
388
	 */
389
	private static function copyFileContents($view, $path1, $path2) {
390
		/** @var \OC\Files\Storage\Storage $storage1 */
391
		list($storage1, $internalPath1) = $view->resolvePath($path1);
392
		/** @var \OC\Files\Storage\Storage $storage2 */
393
		list($storage2, $internalPath2) = $view->resolvePath($path2);
394
395
		$view->lockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
396
		$view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
397
398
		// TODO add a proper way of overwriting a file while maintaining file ids
399
		if ($storage1->instanceOfStorage('\OC\Files\ObjectStore\ObjectStoreStorage') || $storage2->instanceOfStorage('\OC\Files\ObjectStore\ObjectStoreStorage')) {
400
			$source = $storage1->fopen($internalPath1, 'r');
401
			$target = $storage2->fopen($internalPath2, 'w');
402
			list(, $result) = \OC_Helper::streamCopy($source, $target);
0 ignored issues
show
Security Bug introduced by
It seems like $source can also be of type false; however, OC_Helper::streamCopy() does only seem to accept resource, did you maybe forget to handle an error condition?
Loading history...
Security Bug introduced by
It seems like $target can also be of type false; however, OC_Helper::streamCopy() does only seem to accept resource, did you maybe forget to handle an error condition?
Loading history...
403
			fclose($source);
404
			fclose($target);
405
406
			if ($result !== false) {
407
				$storage1->unlink($internalPath1);
408
			}
409
		} else {
410
			$result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2);
411
		}
412
413
		$view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE);
414
		$view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE);
415
416
		return ($result !== false);
417
	}
418
419
	/**
420
	 * get a list of all available versions of a file in descending chronological order
421
	 * @param string $uid user id from the owner of the file
422
	 * @param string $filename file to find versions of, relative to the user files dir
423
	 * @param string $userFullPath
424
	 * @return array versions newest version first
425
	 */
426
	public static function getVersions($uid, $filename, $userFullPath = '') {
427
		$versions = array();
428
		if (empty($filename)) {
429
			return $versions;
430
		}
431
		// fetch for old versions
432
		$view = new View('/' . $uid . '/');
433
434
		$pathinfo = pathinfo($filename);
435
		$versionedFile = $pathinfo['basename'];
436
437
		$dir = Filesystem::normalizePath(self::VERSIONS_ROOT . '/' . $pathinfo['dirname']);
438
439
		$dirContent = false;
440
		if ($view->is_dir($dir)) {
441
			$dirContent = $view->opendir($dir);
442
		}
443
444
		if ($dirContent === false) {
445
			return $versions;
446
		}
447
448
		if (is_resource($dirContent)) {
449
			while (($entryName = readdir($dirContent)) !== false) {
450
				if (!Filesystem::isIgnoredDir($entryName)) {
451
					$pathparts = pathinfo($entryName);
452
					$filename = $pathparts['filename'];
453
					if ($filename === $versionedFile) {
454
						$pathparts = pathinfo($entryName);
455
						$timestamp = substr($pathparts['extension'], 1);
456
						$filename = $pathparts['filename'];
457
						$key = $timestamp . '#' . $filename;
458
						$versions[$key]['version'] = $timestamp;
459
						$versions[$key]['humanReadableTimestamp'] = self::getHumanReadableTimestamp($timestamp);
460
						if (empty($userFullPath)) {
461
							$versions[$key]['preview'] = '';
462
						} else {
463
							$versions[$key]['preview'] = \OCP\Util::linkToRoute('core_ajax_versions_preview', array('file' => $userFullPath, 'version' => $timestamp));
0 ignored issues
show
Deprecated Code introduced by
The method OCP\Util::linkToRoute() has been deprecated with message: 8.1.0 Use \OC::$server->getURLGenerator()->linkToRoute($route, $parameters)

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
464
						}
465
						$versions[$key]['path'] = Filesystem::normalizePath($pathinfo['dirname'] . '/' . $filename);
466
						$versions[$key]['name'] = $versionedFile;
467
						$versions[$key]['size'] = $view->filesize($dir . '/' . $entryName);
468
					}
469
				}
470
			}
471
			closedir($dirContent);
472
		}
473
474
		// sort with newest version first
475
		krsort($versions);
476
477
		return $versions;
478
	}
479
480
	/**
481
	 * Expire versions that older than max version retention time
482
	 * @param string $uid
483
	 */
484
	public static function expireOlderThanMaxForUser($uid){
485
		$expiration = self::getExpiration();
486
		$threshold = $expiration->getMaxAgeAsTimestamp();
487
		$versions = self::getAllVersions($uid);
488
		if (!$threshold || !array_key_exists('all', $versions)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $threshold of type integer|false is loosely compared to false; this is ambiguous if the integer can be zero. You might want to explicitly use === null 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...
489
			return;
490
		}
491
492
		$toDelete = [];
493
		foreach (array_reverse($versions['all']) as $key => $version) {
494
			if (intval($version['version'])<$threshold) {
495
				$toDelete[$key] = $version;
496
			} else {
497
				//Versions are sorted by time - nothing mo to iterate.
498
				break;
499
			}
500
		}
501
502
		$view = new View('/' . $uid . '/files_versions');
503
		if (!empty($toDelete)) {
504
			foreach ($toDelete as $version) {
505
				\OC_Hook::emit('\OCP\Versions', 'preDelete', array('path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT));
506
				self::deleteVersion($view, $version['path'] . '.v' . $version['version']);
507
				\OC_Hook::emit('\OCP\Versions', 'delete', array('path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT));
508
			}
509
		}
510
	}
511
512
	/**
513
	 * translate a timestamp into a string like "5 days ago"
514
	 * @param int $timestamp
515
	 * @return string for example "5 days ago"
516
	 */
517
	private static function getHumanReadableTimestamp($timestamp) {
518
519
		$diff = time() - $timestamp;
520
521
		if ($diff < 60) { // first minute
522
			return  $diff . " seconds ago";
523
		} elseif ($diff < 3600) { //first hour
524
			return round($diff / 60) . " minutes ago";
525
		} elseif ($diff < 86400) { // first day
526
			return round($diff / 3600) . " hours ago";
527
		} elseif ($diff < 604800) { //first week
528
			return round($diff / 86400) . " days ago";
529
		} elseif ($diff < 2419200) { //first month
530
			return round($diff / 604800) . " weeks ago";
531
		} elseif ($diff < 29030400) { // first year
532
			return round($diff / 2419200) . " months ago";
533
		} else {
534
			return round($diff / 29030400) . " years ago";
535
		}
536
537
	}
538
539
	/**
540
	 * returns all stored file versions from a given user
541
	 * @param string $uid id of the user
542
	 * @return array with contains two arrays 'all' which contains all versions sorted by age and 'by_file' which contains all versions sorted by filename
543
	 */
544
	private static function getAllVersions($uid) {
545
		$view = new View('/' . $uid . '/');
546
		$dirs = array(self::VERSIONS_ROOT);
547
		$versions = array();
548
549
		while (!empty($dirs)) {
550
			$dir = array_pop($dirs);
551
			$files = $view->getDirectoryContent($dir);
552
553
			foreach ($files as $file) {
554
				$fileData = $file->getData();
555
				$filePath = $dir . '/' . $fileData['name'];
556
				if ($file['type'] === 'dir') {
557
					array_push($dirs, $filePath);
558
				} else {
559
					$versionsBegin = strrpos($filePath, '.v');
560
					$relPathStart = strlen(self::VERSIONS_ROOT);
561
					$version = substr($filePath, $versionsBegin + 2);
562
					$relpath = substr($filePath, $relPathStart, $versionsBegin - $relPathStart);
563
					$key = $version . '#' . $relpath;
564
					$versions[$key] = array('path' => $relpath, 'timestamp' => $version);
565
				}
566
			}
567
		}
568
569
		// newest version first
570
		krsort($versions);
571
572
		$result = array();
573
574
		foreach ($versions as $key => $value) {
575
			$size = $view->filesize(self::VERSIONS_ROOT.'/'.$value['path'].'.v'.$value['timestamp']);
576
			$filename = $value['path'];
577
578
			$result['all'][$key]['version'] = $value['timestamp'];
579
			$result['all'][$key]['path'] = $filename;
580
			$result['all'][$key]['size'] = $size;
581
582
			$result['by_file'][$filename][$key]['version'] = $value['timestamp'];
583
			$result['by_file'][$filename][$key]['path'] = $filename;
584
			$result['by_file'][$filename][$key]['size'] = $size;
585
		}
586
587
		return $result;
588
	}
589
590
	/**
591
	 * get list of files we want to expire
592
	 * @param array $versions list of versions
593
	 * @param integer $time
594
	 * @param bool $quotaExceeded is versions storage limit reached
595
	 * @return array containing the list of to deleted versions and the size of them
596
	 */
597
	protected static function getExpireList($time, $versions, $quotaExceeded = false) {
598
		$expiration = self::getExpiration();
599
600
		if ($expiration->shouldAutoExpire()) {
601
			list($toDelete, $size) = self::getAutoExpireList($time, $versions);
602
		} else {
603
			$size = 0;
604
			$toDelete = [];  // versions we want to delete
605
		}
606
607
		foreach ($versions as $key => $version) {
608
			if ($expiration->isExpired($version['version'], $quotaExceeded) && !isset($toDelete[$key])) {
609
				$size += $version['size'];
610
				$toDelete[$key] = $version['path'] . '.v' . $version['version'];
611
			}
612
		}
613
614
		return [$toDelete, $size];
615
	}
616
617
	/**
618
	 * get list of files we want to expire
619
	 * @param array $versions list of versions
620
	 * @param integer $time
621
	 * @return array containing the list of to deleted versions and the size of them
622
	 */
623
	protected static function getAutoExpireList($time, $versions) {
624
		$size = 0;
625
		$toDelete = array();  // versions we want to delete
626
627
		$interval = 1;
628
		$step = Storage::$max_versions_per_interval[$interval]['step'];
629 View Code Duplication
		if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] == -1) {
630
			$nextInterval = -1;
631
		} else {
632
			$nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
633
		}
634
635
		$firstVersion = reset($versions);
636
		$firstKey = key($versions);
637
		$prevTimestamp = $firstVersion['version'];
638
		$nextVersion = $firstVersion['version'] - $step;
639
		unset($versions[$firstKey]);
640
641
		foreach ($versions as $key => $version) {
642
			$newInterval = true;
643
			while ($newInterval) {
644
				if ($nextInterval == -1 || $prevTimestamp > $nextInterval) {
645
					if ($version['version'] > $nextVersion) {
646
						//distance between two version too small, mark to delete
647
						$toDelete[$key] = $version['path'] . '.v' . $version['version'];
648
						$size += $version['size'];
649
						\OCP\Util::writeLog('files_versions', 'Mark to expire '. $version['path'] .' next version should be ' . $nextVersion . " or smaller. (prevTimestamp: " . $prevTimestamp . "; step: " . $step, \OCP\Util::INFO);
650
					} else {
651
						$nextVersion = $version['version'] - $step;
652
						$prevTimestamp = $version['version'];
653
					}
654
					$newInterval = false; // version checked so we can move to the next one
655
				} else { // time to move on to the next interval
656
					$interval++;
657
					$step = Storage::$max_versions_per_interval[$interval]['step'];
658
					$nextVersion = $prevTimestamp - $step;
659 View Code Duplication
					if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] == -1) {
660
						$nextInterval = -1;
661
					} else {
662
						$nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'];
663
					}
664
					$newInterval = true; // we changed the interval -> check same version with new interval
665
				}
666
			}
667
		}
668
669
		return array($toDelete, $size);
670
	}
671
672
	/**
673
	 * Schedule versions expiration for the given file
674
	 *
675
	 * @param string $uid owner of the file
676
	 * @param string $fileName file/folder for which to schedule expiration
677
	 */
678
	private static function scheduleExpire($uid, $fileName) {
679
		// let the admin disable auto expire
680
		$expiration = self::getExpiration();
681
		if ($expiration->isEnabled()) {
682
			$command = new Expire($uid, $fileName);
683
			\OC::$server->getCommandBus()->push($command);
684
		}
685
	}
686
687
	/**
688
	 * Expire versions which exceed the quota
689
	 *
690
	 * @param string $filename
691
	 * @return bool|int|null
692
	 */
693
	public static function expire($filename) {
694
		$config = \OC::$server->getConfig();
695
		$expiration = self::getExpiration();
696
697
		if($config->getSystemValue('files_versions', Storage::DEFAULTENABLED)=='true' && $expiration->isEnabled()) {
698
699
			if (!Filesystem::file_exists($filename)) {
700
				return false;
701
			}
702
703
			list($uid, $filename) = self::getUidAndFilename($filename);
704
			if (empty($filename)) {
705
				// file maybe renamed or deleted
706
				return false;
707
			}
708
			$versionsFileview = new View('/'.$uid.'/files_versions');
709
710
			// get available disk space for user
711
			$user = \OC::$server->getUserManager()->get($uid);
712
			$softQuota = true;
713
			$quota = $user->getQuota();
714
			if ( $quota === null || $quota === 'none' ) {
715
				$quota = Filesystem::free_space('/');
716
				$softQuota = false;
717
			} else {
718
				$quota = \OCP\Util::computerFileSize($quota);
719
			}
720
721
			// make sure that we have the current size of the version history
722
			$versionsSize = self::getVersionsSize($uid);
723
724
			// calculate available space for version history
725
			// subtract size of files and current versions size from quota
726
			if ($quota >= 0) {
727
				if ($softQuota) {
728
					$files_view = new View('/' . $uid . '/files');
729
					$rootInfo = $files_view->getFileInfo('/', false);
730
					$free = $quota - $rootInfo['size']; // remaining free space for user
731
					if ($free > 0) {
732
						$availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $versionsSize; // how much space can be used for versions
733
					} else {
734
						$availableSpace = $free - $versionsSize;
735
					}
736
				} else {
737
					$availableSpace = $quota;
738
				}
739
			} else {
740
				$availableSpace = PHP_INT_MAX;
741
			}
742
743
			$allVersions = Storage::getVersions($uid, $filename);
744
745
			$time = time();
746
			list($toDelete, $sizeOfDeletedVersions) = self::getExpireList($time, $allVersions, $availableSpace <= 0);
747
748
			$availableSpace = $availableSpace + $sizeOfDeletedVersions;
749
			$versionsSize = $versionsSize - $sizeOfDeletedVersions;
750
751
			// if still not enough free space we rearrange the versions from all files
752
			if ($availableSpace <= 0) {
753
				$result = Storage::getAllVersions($uid);
754
				$allVersions = $result['all'];
755
756
				foreach ($result['by_file'] as $versions) {
757
					list($toDeleteNew, $size) = self::getExpireList($time, $versions, $availableSpace <= 0);
758
					$toDelete = array_merge($toDelete, $toDeleteNew);
759
					$sizeOfDeletedVersions += $size;
760
				}
761
				$availableSpace = $availableSpace + $sizeOfDeletedVersions;
762
				$versionsSize = $versionsSize - $sizeOfDeletedVersions;
763
			}
764
765
			foreach($toDelete as $key => $path) {
766
				\OC_Hook::emit('\OCP\Versions', 'preDelete', array('path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED));
767
				self::deleteVersion($versionsFileview, $path);
768
				\OC_Hook::emit('\OCP\Versions', 'delete', array('path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED));
769
				unset($allVersions[$key]); // update array with the versions we keep
770
				\OCP\Util::writeLog('files_versions', "Expire: " . $path, \OCP\Util::INFO);
771
			}
772
773
			// Check if enough space is available after versions are rearranged.
774
			// If not we delete the oldest versions until we meet the size limit for versions,
775
			// but always keep the two latest versions
776
			$numOfVersions = count($allVersions) -2 ;
777
			$i = 0;
778
			// sort oldest first and make sure that we start at the first element
779
			ksort($allVersions);
780
			reset($allVersions);
781
			while ($availableSpace < 0 && $i < $numOfVersions) {
782
				$version = current($allVersions);
783
				\OC_Hook::emit('\OCP\Versions', 'preDelete', array('path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED));
784
				self::deleteVersion($versionsFileview, $version['path'] . '.v' . $version['version']);
785
				\OC_Hook::emit('\OCP\Versions', 'delete', array('path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED));
786
				\OCP\Util::writeLog('files_versions', 'running out of space! Delete oldest version: ' . $version['path'].'.v'.$version['version'] , \OCP\Util::INFO);
787
				$versionsSize -= $version['size'];
788
				$availableSpace += $version['size'];
789
				next($allVersions);
790
				$i++;
791
			}
792
793
			return $versionsSize; // finally return the new size of the version history
794
		}
795
796
		return false;
797
	}
798
799
	/**
800
	 * Create recursively missing directories inside of files_versions
801
	 * that match the given path to a file.
802
	 *
803
	 * @param string $filename $path to a file, relative to the user's
804
	 * "files" folder
805
	 * @param View $view view on data/user/
806
	 */
807
	private static function createMissingDirectories($filename, $view) {
808
		$dirname = Filesystem::normalizePath(dirname($filename));
809
		$dirParts = explode('/', $dirname);
810
		$dir = "/files_versions";
811
		foreach ($dirParts as $part) {
812
			$dir = $dir . '/' . $part;
813
			if (!$view->file_exists($dir)) {
814
				$view->mkdir($dir);
815
			}
816
		}
817
	}
818
819
	/**
820
	 * Static workaround
821
	 * @return Expiration
822
	 */
823
	protected static function getExpiration(){
824
		if (is_null(self::$application)) {
825
			self::$application = new Application();
826
		}
827
		return self::$application->getContainer()->query('Expiration');
828
	}
829
830
}
831