2012-09-07 12:09:41 +00:00
< ? php
/**
2016-07-21 14:49:16 +00:00
* @ copyright Copyright ( c ) 2016 , ownCloud , Inc .
*
2016-05-26 17:56:05 +00:00
* @ author Arthur Schiwon < blizzz @ arthur - schiwon . de >
2015-03-26 10:44:34 +00:00
* @ author Bart Visscher < bartv @ thisnet . nl >
2016-07-21 14:49:16 +00:00
* @ author Bjoern Schiessle < bjoern @ schiessle . org >
2016-05-26 17:56:05 +00:00
* @ author Björn Schießle < bjoern @ schiessle . org >
2016-07-21 14:49:16 +00:00
* @ author Carlos Damken < carlos @ damken . com >
2015-03-26 10:44:34 +00:00
* @ author Felix Moeller < mail @ felixmoeller . de >
2016-07-21 14:49:16 +00:00
* @ author Joas Schilling < coding @ schilljs . com >
2015-03-26 10:44:34 +00:00
* @ author Jörn Friedrich Dreyer < jfd @ butonic . de >
2016-05-26 17:56:05 +00:00
* @ author Lukas Reschke < lukas @ statuscode . ch >
2015-03-26 10:44:34 +00:00
* @ author Morris Jobke < hey @ morrisjobke . de >
2016-07-21 16:13:36 +00:00
* @ author Robin Appelman < robin @ icewind . nl >
2016-01-12 14:02:16 +00:00
* @ author Robin McCorkell < robin @ mccorkell . me . uk >
2017-11-06 14:56:42 +00:00
* @ author Roeland Jago Douma < roeland @ famdouma . nl >
2015-03-26 10:44:34 +00:00
* @ author Thomas Müller < thomas . mueller @ tmit . eu >
2015-10-05 18:54:56 +00:00
* @ author Victor Dubiniuk < dubiniuk @ owncloud . com >
2015-03-26 10:44:34 +00:00
* @ author Vincent Petry < pvince81 @ owncloud . com >
*
* @ license AGPL - 3.0
*
* This code is free software : you can redistribute it and / or modify
* it under the terms of the GNU Affero General Public License , version 3 ,
* as published by the Free Software Foundation .
*
* This program is distributed in the hope that it will be useful ,
* but WITHOUT ANY WARRANTY ; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
* GNU Affero General Public License for more details .
*
* You should have received a copy of the GNU Affero General Public License , version 3 ,
* along with this program . If not , see < http :// www . gnu . org / licenses />
*
2015-02-26 10:37:37 +00:00
*/
/**
* Versions
2012-09-07 12:09:41 +00:00
*
2015-02-26 10:37:37 +00:00
* A class to handle the versioning of files .
2012-09-07 12:09:41 +00:00
*/
2015-02-26 10:37:37 +00:00
2012-09-19 18:59:57 +00:00
namespace OCA\Files_Versions ;
2012-09-07 12:09:41 +00:00
2015-12-10 13:14:54 +00:00
use OC\Files\Filesystem ;
2016-02-16 16:16:47 +00:00
use OC\Files\View ;
2015-08-17 18:40:03 +00:00
use OCA\Files_Versions\AppInfo\Application ;
2015-03-23 12:07:40 +00:00
use OCA\Files_Versions\Command\Expire ;
2017-09-15 10:14:08 +00:00
use OCA\Files_Versions\Events\CreateVersionEvent ;
2018-10-12 15:42:08 +00:00
use OCA\Files_Versions\Versions\IVersionManager ;
2016-04-25 11:22:54 +00:00
use OCP\Files\NotFoundException ;
2015-10-02 15:08:39 +00:00
use OCP\Lock\ILockingProvider ;
2016-02-16 16:16:47 +00:00
use OCP\User ;
2015-03-23 12:07:40 +00:00
2012-09-07 12:09:41 +00:00
class Storage {
const DEFAULTENABLED = true ;
2012-12-18 11:57:28 +00:00
const DEFAULTMAXSIZE = 50 ; // unit: percentage; 50% of available disk space/quota
2013-10-10 18:09:38 +00:00
const VERSIONS_ROOT = 'files_versions/' ;
2013-02-22 16:21:57 +00:00
2016-02-08 17:16:27 +00:00
const DELETE_TRIGGER_MASTER_REMOVED = 0 ;
const DELETE_TRIGGER_RETENTION_CONSTRAINT = 1 ;
const DELETE_TRIGGER_QUOTA_EXCEEDED = 2 ;
2013-11-28 12:17:19 +00:00
// files for which we can remove the versions after the delete operation was successful
private static $deletedFiles = array ();
2014-10-28 14:57:08 +00:00
private static $sourcePathAndUser = array ();
2013-01-11 13:23:28 +00:00
private static $max_versions_per_interval = array (
2013-02-21 23:21:06 +00:00
//first 10sec, one version every 2sec
1 => array ( 'intervalEndsAfter' => 10 , 'step' => 2 ),
//next minute, one version every 10sec
2 => array ( 'intervalEndsAfter' => 60 , 'step' => 10 ),
//next hour, one version every minute
3 => array ( 'intervalEndsAfter' => 3600 , 'step' => 60 ),
//next 24h, one version every hour
4 => array ( 'intervalEndsAfter' => 86400 , 'step' => 3600 ),
//next 30days, one version per day
5 => array ( 'intervalEndsAfter' => 2592000 , 'step' => 86400 ),
//until the end one version per week
6 => array ( 'intervalEndsAfter' => - 1 , 'step' => 604800 ),
);
2016-06-30 08:29:32 +00:00
2015-09-09 15:37:33 +00:00
/** @var \OCA\Files_Versions\AppInfo\Application */
private static $application ;
2012-09-07 12:09:41 +00:00
2015-06-15 12:10:10 +00:00
/**
2016-02-16 16:16:47 +00:00
* get the UID of the owner of the file and the path to the file relative to
* owners files folder
*
2015-06-15 12:10:10 +00:00
* @ param string $filename
* @ return array
* @ throws \OC\User\NoUserException
*/
2013-02-21 13:40:16 +00:00
public static function getUidAndFilename ( $filename ) {
2016-02-16 16:16:47 +00:00
$uid = Filesystem :: getOwner ( $filename );
$userManager = \OC :: $server -> getUserManager ();
// if the user with the UID doesn't exists, e.g. because the UID points
// to a remote user with a federated cloud ID we use the current logged-in
// user. We need a valid local user to create the versions
if ( ! $userManager -> userExists ( $uid )) {
$uid = User :: getUser ();
}
Filesystem :: initMountPoints ( $uid );
2017-05-10 12:05:14 +00:00
if ( $uid !== User :: getUser () ) {
2016-02-16 16:16:47 +00:00
$info = Filesystem :: getFileInfo ( $filename );
$ownerView = new View ( '/' . $uid . '/files' );
try {
$filename = $ownerView -> getPath ( $info [ 'fileid' ]);
2016-02-19 12:15:09 +00:00
// make sure that the file name doesn't end with a trailing slash
// can for example happen single files shared across servers
$filename = rtrim ( $filename , '/' );
2016-02-16 16:16:47 +00:00
} catch ( NotFoundException $e ) {
$filename = null ;
}
}
return [ $uid , $filename ];
2012-09-07 12:09:41 +00:00
}
2013-02-22 16:21:57 +00:00
2014-10-28 14:57:08 +00:00
/**
2014-10-28 22:32:57 +00:00
* Remember the owner and the owner path of the source file
2014-10-28 14:57:08 +00:00
*
* @ param string $source source path
*/
public static function setSourcePathAndUser ( $source ) {
list ( $uid , $path ) = self :: getUidAndFilename ( $source );
self :: $sourcePathAndUser [ $source ] = array ( 'uid' => $uid , 'path' => $path );
}
/**
2014-10-28 22:32:57 +00:00
* Gets the owner and the owner path from the source path
2014-10-28 14:57:08 +00:00
*
* @ param string $source source path
* @ return array with user id and path
*/
public static function getSourcePathAndUser ( $source ) {
if ( isset ( self :: $sourcePathAndUser [ $source ])) {
$uid = self :: $sourcePathAndUser [ $source ][ 'uid' ];
$path = self :: $sourcePathAndUser [ $source ][ 'path' ];
unset ( self :: $sourcePathAndUser [ $source ]);
} else {
$uid = $path = false ;
}
return array ( $uid , $path );
}
2013-02-21 11:20:29 +00:00
/**
* get current size of all versions from a given user
2013-08-17 09:57:50 +00:00
*
2014-05-13 11:29:25 +00:00
* @ param string $user user who owns the versions
2014-05-06 11:55:26 +00:00
* @ return int versions size
2013-02-21 11:20:29 +00:00
*/
private static function getVersionsSize ( $user ) {
2016-02-16 16:16:47 +00:00
$view = new View ( '/' . $user );
2014-05-06 11:55:26 +00:00
$fileInfo = $view -> getFileInfo ( '/files_versions' );
return isset ( $fileInfo [ 'size' ]) ? $fileInfo [ 'size' ] : 0 ;
2013-02-21 11:20:29 +00:00
}
2013-08-17 09:57:50 +00:00
2012-09-07 12:09:41 +00:00
/**
* store a new version of a file .
*/
2013-02-14 13:26:49 +00:00
public static function store ( $filename ) {
2013-08-17 09:57:50 +00:00
2017-07-25 12:24:31 +00:00
// if the file gets streamed we need to remove the .part extension
// to get the right target
$ext = pathinfo ( $filename , PATHINFO_EXTENSION );
if ( $ext === 'part' ) {
2018-01-25 21:23:48 +00:00
$filename = substr ( $filename , 0 , - 5 );
2017-07-25 12:24:31 +00:00
}
2015-12-14 13:16:06 +00:00
2017-07-25 12:24:31 +00:00
// we only handle existing files
if ( ! Filesystem :: file_exists ( $filename ) || Filesystem :: is_dir ( $filename )) {
return false ;
}
2013-08-17 09:57:50 +00:00
2017-07-25 12:24:31 +00:00
list ( $uid , $filename ) = self :: getUidAndFilename ( $filename );
2013-02-22 16:21:57 +00:00
2017-07-25 12:24:31 +00:00
$files_view = new View ( '/' . $uid . '/files' );
2012-09-07 12:09:41 +00:00
2017-09-15 10:14:08 +00:00
$eventDispatcher = \OC :: $server -> getEventDispatcher ();
2018-10-12 15:42:08 +00:00
$fileInfo = $files_view -> getFileInfo ( $filename );
$id = $fileInfo -> getId ();
2017-09-15 10:14:08 +00:00
$nodes = \OC :: $server -> getRootFolder () -> getById ( $id );
foreach ( $nodes as $node ) {
$event = new CreateVersionEvent ( $node );
$eventDispatcher -> dispatch ( 'OCA\Files_Versions::createVersion' , $event );
if ( $event -> shouldCreateVersion () === false ) {
return false ;
}
}
2017-07-25 12:24:31 +00:00
// no use making versions for empty files
2018-10-12 15:42:08 +00:00
if ( $fileInfo -> getSize () === 0 ) {
2017-07-25 12:24:31 +00:00
return false ;
}
2012-09-17 15:29:34 +00:00
2018-10-12 15:42:08 +00:00
/** @var IVersionManager $versionManager */
$versionManager = \OC :: $server -> query ( IVersionManager :: class );
$userManager = \OC :: $server -> getUserManager ();
$user = $userManager -> get ( $uid );
2013-06-25 07:39:01 +00:00
2018-10-12 15:42:08 +00:00
$versionManager -> createVersion ( $user , $fileInfo );
2012-09-07 12:09:41 +00:00
}
2013-01-10 17:04:30 +00:00
/**
2014-05-19 15:50:53 +00:00
* mark file as deleted so that we can remove the versions if the file is gone
2013-11-28 12:17:19 +00:00
* @ param string $path
2013-01-10 17:04:30 +00:00
*/
2013-11-28 12:17:19 +00:00
public static function markDeletedFile ( $path ) {
list ( $uid , $filename ) = self :: getUidAndFilename ( $path );
self :: $deletedFiles [ $path ] = array (
'uid' => $uid ,
'filename' => $filename );
}
2013-02-22 16:21:57 +00:00
2014-07-10 13:19:40 +00:00
/**
* delete the version from the storage and cache
*
2016-02-16 16:16:47 +00:00
* @ param View $view
2014-07-10 13:19:40 +00:00
* @ param string $path
*/
protected static function deleteVersion ( $view , $path ) {
$view -> unlink ( $path );
/**
* @ var \OC\Files\Storage\Storage $storage
* @ var string $internalPath
*/
list ( $storage , $internalPath ) = $view -> resolvePath ( $path );
$cache = $storage -> getCache ( $internalPath );
$cache -> remove ( $internalPath );
}
2013-11-28 12:17:19 +00:00
/**
* Delete versions of a file
*/
public static function delete ( $path ) {
$deletedFile = self :: $deletedFiles [ $path ];
$uid = $deletedFile [ 'uid' ];
$filename = $deletedFile [ 'filename' ];
2016-02-16 16:16:47 +00:00
if ( ! Filesystem :: file_exists ( $path )) {
2013-11-28 12:17:19 +00:00
2016-02-16 16:16:47 +00:00
$view = new View ( '/' . $uid . '/files_versions' );
2013-11-28 12:17:19 +00:00
2013-11-28 18:31:35 +00:00
$versions = self :: getVersions ( $uid , $filename );
if ( ! empty ( $versions )) {
2013-11-28 12:17:19 +00:00
foreach ( $versions as $v ) {
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'preDelete' , array ( 'path' => $path . $v [ 'version' ], 'trigger' => self :: DELETE_TRIGGER_MASTER_REMOVED ));
2014-07-10 13:19:40 +00:00
self :: deleteVersion ( $view , $filename . '.v' . $v [ 'version' ]);
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'delete' , array ( 'path' => $path . $v [ 'version' ], 'trigger' => self :: DELETE_TRIGGER_MASTER_REMOVED ));
2013-11-28 12:17:19 +00:00
}
2013-01-15 13:57:23 +00:00
}
2013-01-10 17:04:30 +00:00
}
2013-11-28 12:17:19 +00:00
unset ( self :: $deletedFiles [ $path ]);
2013-01-10 17:04:30 +00:00
}
2013-02-22 16:21:57 +00:00
2013-01-15 13:57:23 +00:00
/**
2015-04-27 10:51:17 +00:00
* Rename or copy versions of a file of the given paths
*
* @ param string $sourcePath source path of the file to move , relative to
* the currently logged in user ' s " files " folder
* @ param string $targetPath target path of the file to move , relative to
* the currently logged in user ' s " files " folder
2014-07-24 13:30:00 +00:00
* @ param string $operation can be 'copy' or 'rename'
2013-01-15 13:57:23 +00:00
*/
2015-04-27 10:51:17 +00:00
public static function renameOrCopy ( $sourcePath , $targetPath , $operation ) {
list ( $sourceOwner , $sourcePath ) = self :: getSourcePathAndUser ( $sourcePath );
2014-10-28 14:57:08 +00:00
// it was a upload of a existing file if no old path exists
// in this case the pre-hook already called the store method and we can
// stop here
2015-04-27 10:51:17 +00:00
if ( $sourcePath === false ) {
2014-10-28 14:57:08 +00:00
return true ;
}
2015-04-27 10:51:17 +00:00
list ( $targetOwner , $targetPath ) = self :: getUidAndFilename ( $targetPath );
$sourcePath = ltrim ( $sourcePath , '/' );
$targetPath = ltrim ( $targetPath , '/' );
2013-08-17 09:57:50 +00:00
2016-02-16 16:16:47 +00:00
$rootView = new View ( '' );
2014-10-28 14:57:08 +00:00
2015-04-27 10:51:17 +00:00
// did we move a directory ?
if ( $rootView -> is_dir ( '/' . $targetOwner . '/files/' . $targetPath )) {
// does the directory exists for versions too ?
if ( $rootView -> is_dir ( '/' . $sourceOwner . '/files_versions/' . $sourcePath )) {
// create missing dirs if necessary
2016-02-16 16:16:47 +00:00
self :: createMissingDirectories ( $targetPath , new View ( '/' . $targetOwner ));
2013-06-25 07:39:01 +00:00
2015-04-27 10:51:17 +00:00
// move the directory containing the versions
$rootView -> $operation (
'/' . $sourceOwner . '/files_versions/' . $sourcePath ,
'/' . $targetOwner . '/files_versions/' . $targetPath
);
}
} else if ( $versions = Storage :: getVersions ( $sourceOwner , '/' . $sourcePath )) {
2013-08-14 18:51:36 +00:00
// create missing dirs if necessary
2016-02-16 16:16:47 +00:00
self :: createMissingDirectories ( $targetPath , new View ( '/' . $targetOwner ));
2013-08-17 11:46:33 +00:00
2013-01-11 13:23:28 +00:00
foreach ( $versions as $v ) {
2015-04-27 10:51:17 +00:00
// move each version one by one to the target directory
$rootView -> $operation (
'/' . $sourceOwner . '/files_versions/' . $sourcePath . '.v' . $v [ 'version' ],
'/' . $targetOwner . '/files_versions/' . $targetPath . '.v' . $v [ 'version' ]
);
2013-01-15 13:57:23 +00:00
}
}
2014-07-24 16:54:12 +00:00
2015-04-27 10:51:17 +00:00
// if we moved versions directly for a file, schedule expiration check for that file
if ( ! $rootView -> is_dir ( '/' . $targetOwner . '/files/' . $targetPath )) {
2015-05-13 16:40:35 +00:00
self :: scheduleExpire ( $targetOwner , $targetPath );
2014-07-24 16:54:12 +00:00
}
2013-01-11 13:23:28 +00:00
}
2013-02-22 16:21:57 +00:00
2012-09-07 12:09:41 +00:00
/**
2015-04-08 15:56:20 +00:00
* Rollback to an old version of a file .
*
* @ param string $file file name
* @ param int $revision revision timestamp
2016-07-01 16:48:48 +00:00
* @ return bool
2012-09-07 12:09:41 +00:00
*/
2013-05-08 13:05:03 +00:00
public static function rollback ( $file , $revision ) {
2012-09-07 12:09:41 +00:00
2017-07-25 12:24:31 +00:00
// add expected leading slash
$file = '/' . ltrim ( $file , '/' );
list ( $uid , $filename ) = self :: getUidAndFilename ( $file );
if ( $uid === null || trim ( $filename , '/' ) === '' ) {
return false ;
}
2016-07-01 16:48:48 +00:00
2018-05-14 17:58:19 +00:00
// Fetch the userfolder to trigger view hooks
$userFolder = \OC :: $server -> getUserFolder ( $uid );
2017-07-25 12:24:31 +00:00
$users_view = new View ( '/' . $uid );
$files_view = new View ( '/' . User :: getUser () . '/files' );
2016-06-28 10:09:58 +00:00
2017-07-25 12:24:31 +00:00
$versionCreated = false ;
2013-02-22 16:21:57 +00:00
2017-07-25 12:24:31 +00:00
$fileInfo = $files_view -> getFileInfo ( $file );
2016-06-30 08:29:32 +00:00
2017-07-25 12:24:31 +00:00
// check if user has the permissions to revert a version
if ( ! $fileInfo -> isUpdateable ()) {
return false ;
}
2016-06-28 10:09:58 +00:00
2017-07-25 12:24:31 +00:00
//first create a new version
$version = 'files_versions' . $filename . '.v' . $users_view -> filemtime ( 'files' . $filename );
if ( ! $users_view -> file_exists ( $version )) {
$users_view -> copy ( 'files' . $filename , 'files_versions' . $filename . '.v' . $users_view -> filemtime ( 'files' . $filename ));
$versionCreated = true ;
}
2013-02-22 16:21:57 +00:00
2017-07-25 12:24:31 +00:00
$fileToRestore = 'files_versions' . $filename . '.v' . $revision ;
// Restore encrypted version of the old file for the newly restored file
// This has to happen manually here since the file is manually copied below
$oldVersion = $users_view -> getFileInfo ( $fileToRestore ) -> getEncryptedVersion ();
$oldFileInfo = $users_view -> getFileInfo ( $fileToRestore );
$cache = $fileInfo -> getStorage () -> getCache ();
$cache -> update (
$fileInfo -> getId (), [
'encrypted' => $oldVersion ,
'encryptedVersion' => $oldVersion ,
'size' => $oldFileInfo -> getSize ()
]
);
// rollback
if ( self :: copyFileContents ( $users_view , $fileToRestore , 'files' . $filename )) {
$files_view -> touch ( $file , $revision );
Storage :: scheduleExpire ( $uid , $file );
2018-05-14 17:58:19 +00:00
$node = $userFolder -> get ( $file );
// TODO: move away from those legacy hooks!
2017-07-25 12:24:31 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'rollback' , array (
'path' => $filename ,
'revision' => $revision ,
2018-05-14 17:58:19 +00:00
'node' => $node ,
2017-07-25 12:24:31 +00:00
));
return true ;
} else if ( $versionCreated ) {
self :: deleteVersion ( $users_view , $version );
2012-09-07 12:09:41 +00:00
}
2017-07-25 12:24:31 +00:00
2013-01-11 13:23:28 +00:00
return false ;
2012-09-07 12:09:41 +00:00
}
2015-04-08 15:56:20 +00:00
/**
* Stream copy file contents from $path1 to $path2
*
2016-02-16 16:16:47 +00:00
* @ param View $view view to use for copying
2015-04-08 15:56:20 +00:00
* @ param string $path1 source file to copy
* @ param string $path2 target file
*
* @ return bool true for success , false otherwise
*/
private static function copyFileContents ( $view , $path1 , $path2 ) {
2015-10-02 15:08:39 +00:00
/** @var \OC\Files\Storage\Storage $storage1 */
2015-04-08 15:56:20 +00:00
list ( $storage1 , $internalPath1 ) = $view -> resolvePath ( $path1 );
2015-10-02 15:08:39 +00:00
/** @var \OC\Files\Storage\Storage $storage2 */
2015-04-08 15:56:20 +00:00
list ( $storage2 , $internalPath2 ) = $view -> resolvePath ( $path2 );
2015-10-02 15:08:39 +00:00
$view -> lockFile ( $path1 , ILockingProvider :: LOCK_EXCLUSIVE );
$view -> lockFile ( $path2 , ILockingProvider :: LOCK_EXCLUSIVE );
2015-10-13 14:15:00 +00:00
// TODO add a proper way of overwriting a file while maintaining file ids
if ( $storage1 -> instanceOfStorage ( '\OC\Files\ObjectStore\ObjectStoreStorage' ) || $storage2 -> instanceOfStorage ( '\OC\Files\ObjectStore\ObjectStoreStorage' )) {
$source = $storage1 -> fopen ( $internalPath1 , 'r' );
$target = $storage2 -> fopen ( $internalPath2 , 'w' );
list (, $result ) = \OC_Helper :: streamCopy ( $source , $target );
fclose ( $source );
fclose ( $target );
if ( $result !== false ) {
$storage1 -> unlink ( $internalPath1 );
}
} else {
$result = $storage2 -> moveFromStorage ( $storage1 , $internalPath1 , $internalPath2 );
}
2015-04-08 15:56:20 +00:00
2015-10-02 15:08:39 +00:00
$view -> unlockFile ( $path1 , ILockingProvider :: LOCK_EXCLUSIVE );
$view -> unlockFile ( $path2 , ILockingProvider :: LOCK_EXCLUSIVE );
2015-04-08 15:56:20 +00:00
return ( $result !== false );
}
2012-09-07 12:09:41 +00:00
/**
2014-05-19 15:50:53 +00:00
* get a list of all available versions of a file in descending chronological order
2014-01-21 12:50:56 +00:00
* @ param string $uid user id from the owner of the file
* @ param string $filename file to find versions of , relative to the user files dir
* @ param string $userFullPath
2014-05-13 10:36:01 +00:00
* @ return array versions newest version first
2012-09-07 12:09:41 +00:00
*/
2014-01-21 12:50:56 +00:00
public static function getVersions ( $uid , $filename , $userFullPath = '' ) {
2013-10-10 12:43:40 +00:00
$versions = array ();
2015-05-29 11:15:12 +00:00
if ( empty ( $filename )) {
2015-05-07 12:31:21 +00:00
return $versions ;
}
2013-10-10 12:43:40 +00:00
// fetch for old versions
2016-02-16 16:16:47 +00:00
$view = new View ( '/' . $uid . '/' );
2013-10-10 12:43:40 +00:00
2013-10-10 18:06:42 +00:00
$pathinfo = pathinfo ( $filename );
2014-07-24 16:54:12 +00:00
$versionedFile = $pathinfo [ 'basename' ];
2013-10-10 18:06:42 +00:00
2016-02-16 16:16:47 +00:00
$dir = Filesystem :: normalizePath ( self :: VERSIONS_ROOT . '/' . $pathinfo [ 'dirname' ]);
2013-10-10 18:06:42 +00:00
2014-07-24 16:54:12 +00:00
$dirContent = false ;
if ( $view -> is_dir ( $dir )) {
$dirContent = $view -> opendir ( $dir );
}
if ( $dirContent === false ) {
return $versions ;
}
2013-10-10 12:43:40 +00:00
2014-07-24 16:54:12 +00:00
if ( is_resource ( $dirContent )) {
while (( $entryName = readdir ( $dirContent )) !== false ) {
2016-02-16 16:16:47 +00:00
if ( ! Filesystem :: isIgnoredDir ( $entryName )) {
2014-07-24 16:54:12 +00:00
$pathparts = pathinfo ( $entryName );
$filename = $pathparts [ 'filename' ];
if ( $filename === $versionedFile ) {
$pathparts = pathinfo ( $entryName );
$timestamp = substr ( $pathparts [ 'extension' ], 1 );
$filename = $pathparts [ 'filename' ];
$key = $timestamp . '#' . $filename ;
$versions [ $key ][ 'version' ] = $timestamp ;
$versions [ $key ][ 'humanReadableTimestamp' ] = self :: getHumanReadableTimestamp ( $timestamp );
if ( empty ( $userFullPath )) {
$versions [ $key ][ 'preview' ] = '' ;
} else {
2016-10-16 18:42:35 +00:00
$versions [ $key ][ 'preview' ] = \OC :: $server -> getURLGenerator ( 'files_version.Preview.getPreview' , [ 'file' => $userFullPath , 'version' => $timestamp ]);
2014-07-24 16:54:12 +00:00
}
2016-02-16 16:16:47 +00:00
$versions [ $key ][ 'path' ] = Filesystem :: normalizePath ( $pathinfo [ 'dirname' ] . '/' . $filename );
2014-07-24 16:54:12 +00:00
$versions [ $key ][ 'name' ] = $versionedFile ;
$versions [ $key ][ 'size' ] = $view -> filesize ( $dir . '/' . $entryName );
2016-11-29 19:35:19 +00:00
$versions [ $key ][ 'mimetype' ] = \OC :: $server -> getMimeTypeDetector () -> detectPath ( $versionedFile );
2014-01-21 12:50:56 +00:00
}
2012-09-07 12:09:41 +00:00
}
}
2014-07-24 16:54:12 +00:00
closedir ( $dirContent );
2012-09-07 12:09:41 +00:00
}
2013-10-10 18:06:42 +00:00
// sort with newest version first
krsort ( $versions );
2013-10-10 12:43:40 +00:00
2013-10-10 18:06:42 +00:00
return $versions ;
2012-09-07 12:09:41 +00:00
}
2015-09-16 14:22:17 +00:00
/**
* Expire versions that older than max version retention time
* @ param string $uid
*/
public static function expireOlderThanMaxForUser ( $uid ){
2015-08-31 20:52:00 +00:00
$expiration = self :: getExpiration ();
$threshold = $expiration -> getMaxAgeAsTimestamp ();
$versions = self :: getAllVersions ( $uid );
if ( ! $threshold || ! array_key_exists ( 'all' , $versions )) {
return ;
}
$toDelete = [];
foreach ( array_reverse ( $versions [ 'all' ]) as $key => $version ) {
2018-01-25 22:06:53 +00:00
if (( int ) $version [ 'version' ] < $threshold ) {
2015-08-31 20:52:00 +00:00
$toDelete [ $key ] = $version ;
} else {
//Versions are sorted by time - nothing mo to iterate.
break ;
}
}
2016-02-16 16:16:47 +00:00
$view = new View ( '/' . $uid . '/files_versions' );
2015-08-31 20:52:00 +00:00
if ( ! empty ( $toDelete )) {
foreach ( $toDelete as $version ) {
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'preDelete' , array ( 'path' => $version [ 'path' ] . '.v' . $version [ 'version' ], 'trigger' => self :: DELETE_TRIGGER_RETENTION_CONSTRAINT ));
2015-08-31 20:52:00 +00:00
self :: deleteVersion ( $view , $version [ 'path' ] . '.v' . $version [ 'version' ]);
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'delete' , array ( 'path' => $version [ 'path' ] . '.v' . $version [ 'version' ], 'trigger' => self :: DELETE_TRIGGER_RETENTION_CONSTRAINT ));
2015-08-31 20:52:00 +00:00
}
}
}
2013-07-25 08:35:19 +00:00
/**
2014-05-19 15:50:53 +00:00
* translate a timestamp into a string like " 5 days ago "
2013-07-25 08:35:19 +00:00
* @ param int $timestamp
* @ return string for example " 5 days ago "
*/
private static function getHumanReadableTimestamp ( $timestamp ) {
$diff = time () - $timestamp ;
if ( $diff < 60 ) { // first minute
return $diff . " seconds ago " ;
} elseif ( $diff < 3600 ) { //first hour
return round ( $diff / 60 ) . " minutes ago " ;
} elseif ( $diff < 86400 ) { // first day
return round ( $diff / 3600 ) . " hours ago " ;
} elseif ( $diff < 604800 ) { //first week
return round ( $diff / 86400 ) . " days ago " ;
} elseif ( $diff < 2419200 ) { //first month
return round ( $diff / 604800 ) . " weeks ago " ;
} elseif ( $diff < 29030400 ) { // first year
return round ( $diff / 2419200 ) . " months ago " ;
} else {
return round ( $diff / 29030400 ) . " years ago " ;
}
}
2013-04-11 10:36:08 +00:00
2013-01-09 16:11:46 +00:00
/**
2014-05-19 15:50:53 +00:00
* returns all stored file versions from a given user
2014-02-06 15:30:58 +00:00
* @ param string $uid id of the user
2013-01-09 16:11:46 +00:00
* @ return array with contains two arrays 'all' which contains all versions sorted by age and 'by_file' which contains all versions sorted by filename
*/
private static function getAllVersions ( $uid ) {
2016-02-16 16:16:47 +00:00
$view = new View ( '/' . $uid . '/' );
2013-10-10 18:09:38 +00:00
$dirs = array ( self :: VERSIONS_ROOT );
2014-06-18 11:23:53 +00:00
$versions = array ();
2013-10-10 12:43:40 +00:00
while ( ! empty ( $dirs )) {
$dir = array_pop ( $dirs );
$files = $view -> getDirectoryContent ( $dir );
foreach ( $files as $file ) {
2016-05-17 14:27:33 +00:00
$fileData = $file -> getData ();
$filePath = $dir . '/' . $fileData [ 'name' ];
2013-10-10 12:43:40 +00:00
if ( $file [ 'type' ] === 'dir' ) {
2018-01-25 21:36:03 +00:00
$dirs [] = $filePath ;
2013-10-10 12:43:40 +00:00
} else {
2016-05-17 14:27:33 +00:00
$versionsBegin = strrpos ( $filePath , '.v' );
2013-10-11 08:34:34 +00:00
$relPathStart = strlen ( self :: VERSIONS_ROOT );
2016-05-17 14:27:33 +00:00
$version = substr ( $filePath , $versionsBegin + 2 );
$relpath = substr ( $filePath , $relPathStart , $versionsBegin - $relPathStart );
2013-10-10 12:43:40 +00:00
$key = $version . '#' . $relpath ;
$versions [ $key ] = array ( 'path' => $relpath , 'timestamp' => $version );
2013-01-09 16:11:46 +00:00
}
2013-01-15 13:57:23 +00:00
}
2013-10-10 12:43:40 +00:00
}
2013-02-22 16:21:57 +00:00
2014-01-22 10:13:15 +00:00
// newest version first
krsort ( $versions );
2013-02-22 16:21:57 +00:00
2013-10-10 12:43:40 +00:00
$result = array ();
2013-02-22 16:21:57 +00:00
2013-10-10 12:43:40 +00:00
foreach ( $versions as $key => $value ) {
2014-01-22 10:10:23 +00:00
$size = $view -> filesize ( self :: VERSIONS_ROOT . '/' . $value [ 'path' ] . '.v' . $value [ 'timestamp' ]);
2013-10-10 12:43:40 +00:00
$filename = $value [ 'path' ];
2013-02-22 16:21:57 +00:00
2013-10-10 12:43:40 +00:00
$result [ 'all' ][ $key ][ 'version' ] = $value [ 'timestamp' ];
$result [ 'all' ][ $key ][ 'path' ] = $filename ;
$result [ 'all' ][ $key ][ 'size' ] = $size ;
2013-02-22 16:21:57 +00:00
2013-10-10 12:43:40 +00:00
$result [ 'by_file' ][ $filename ][ $key ][ 'version' ] = $value [ 'timestamp' ];
$result [ 'by_file' ][ $filename ][ $key ][ 'path' ] = $filename ;
$result [ 'by_file' ][ $filename ][ $key ][ 'size' ] = $size ;
2012-09-07 12:09:41 +00:00
}
2013-10-10 12:43:40 +00:00
return $result ;
2012-09-07 12:09:41 +00:00
}
2014-01-20 15:03:26 +00:00
/**
2014-05-19 15:50:53 +00:00
* get list of files we want to expire
2014-01-20 15:03:26 +00:00
* @ param array $versions list of versions
2014-02-19 08:31:54 +00:00
* @ param integer $time
2015-08-20 15:32:41 +00:00
* @ param bool $quotaExceeded is versions storage limit reached
2014-01-20 15:03:26 +00:00
* @ return array containing the list of to deleted versions and the size of them
*/
2015-08-18 21:00:18 +00:00
protected static function getExpireList ( $time , $versions , $quotaExceeded = false ) {
2015-08-20 15:32:41 +00:00
$expiration = self :: getExpiration ();
2014-01-20 15:03:26 +00:00
2015-08-17 18:40:03 +00:00
if ( $expiration -> shouldAutoExpire ()) {
2015-08-18 21:00:18 +00:00
list ( $toDelete , $size ) = self :: getAutoExpireList ( $time , $versions );
} else {
$size = 0 ;
$toDelete = []; // versions we want to delete
2015-08-17 18:40:03 +00:00
}
foreach ( $versions as $key => $version ) {
2015-08-18 21:00:18 +00:00
if ( $expiration -> isExpired ( $version [ 'version' ], $quotaExceeded ) && ! isset ( $toDelete [ $key ])) {
2015-08-17 18:40:03 +00:00
$size += $version [ 'size' ];
2015-08-18 21:00:18 +00:00
$toDelete [ $key ] = $version [ 'path' ] . '.v' . $version [ 'version' ];
2015-08-17 18:40:03 +00:00
}
}
2015-08-18 21:00:18 +00:00
return [ $toDelete , $size ];
2015-08-17 18:40:03 +00:00
}
/**
* get list of files we want to expire
* @ param array $versions list of versions
* @ param integer $time
* @ return array containing the list of to deleted versions and the size of them
*/
protected static function getAutoExpireList ( $time , $versions ) {
2014-01-20 15:03:26 +00:00
$size = 0 ;
$toDelete = array (); // versions we want to delete
$interval = 1 ;
$step = Storage :: $max_versions_per_interval [ $interval ][ 'step' ];
2017-05-10 12:05:14 +00:00
if ( Storage :: $max_versions_per_interval [ $interval ][ 'intervalEndsAfter' ] === - 1 ) {
2014-01-20 15:03:26 +00:00
$nextInterval = - 1 ;
} else {
$nextInterval = $time - Storage :: $max_versions_per_interval [ $interval ][ 'intervalEndsAfter' ];
}
$firstVersion = reset ( $versions );
$firstKey = key ( $versions );
$prevTimestamp = $firstVersion [ 'version' ];
$nextVersion = $firstVersion [ 'version' ] - $step ;
unset ( $versions [ $firstKey ]);
foreach ( $versions as $key => $version ) {
$newInterval = true ;
while ( $newInterval ) {
2017-05-10 12:05:14 +00:00
if ( $nextInterval === - 1 || $prevTimestamp > $nextInterval ) {
2014-01-20 15:03:26 +00:00
if ( $version [ 'version' ] > $nextVersion ) {
//distance between two version too small, mark to delete
$toDelete [ $key ] = $version [ 'path' ] . '.v' . $version [ 'version' ];
$size += $version [ 'size' ];
2018-04-20 12:35:37 +00:00
\OC :: $server -> getLogger () -> info ( 'Mark to expire ' . $version [ 'path' ] . ' next version should be ' . $nextVersion . " or smaller. (prevTimestamp: " . $prevTimestamp . " ; step: " . $step , [ 'app' => 'files_versions' ]);
2014-01-20 15:03:26 +00:00
} else {
$nextVersion = $version [ 'version' ] - $step ;
$prevTimestamp = $version [ 'version' ];
}
$newInterval = false ; // version checked so we can move to the next one
} else { // time to move on to the next interval
$interval ++ ;
$step = Storage :: $max_versions_per_interval [ $interval ][ 'step' ];
$nextVersion = $prevTimestamp - $step ;
2017-05-10 12:05:14 +00:00
if ( Storage :: $max_versions_per_interval [ $interval ][ 'intervalEndsAfter' ] === - 1 ) {
2014-01-20 15:03:26 +00:00
$nextInterval = - 1 ;
} else {
$nextInterval = $time - Storage :: $max_versions_per_interval [ $interval ][ 'intervalEndsAfter' ];
}
$newInterval = true ; // we changed the interval -> check same version with new interval
}
}
}
return array ( $toDelete , $size );
}
2012-09-07 12:09:41 +00:00
/**
2015-05-13 16:40:35 +00:00
* Schedule versions expiration for the given file
*
* @ param string $uid owner of the file
* @ param string $fileName file / folder for which to schedule expiration
2015-03-23 12:07:40 +00:00
*/
2018-10-12 15:42:08 +00:00
public static function scheduleExpire ( $uid , $fileName ) {
2015-08-17 18:40:03 +00:00
// let the admin disable auto expire
2015-08-20 15:32:41 +00:00
$expiration = self :: getExpiration ();
2015-08-17 18:40:03 +00:00
if ( $expiration -> isEnabled ()) {
2015-12-10 10:21:28 +00:00
$command = new Expire ( $uid , $fileName );
2015-08-17 18:40:03 +00:00
\OC :: $server -> getCommandBus () -> push ( $command );
}
2015-03-23 12:07:40 +00:00
}
/**
2016-11-22 09:22:00 +00:00
* Expire versions which exceed the quota .
2015-03-23 12:07:40 +00:00
*
2016-11-22 09:22:00 +00:00
* This will setup the filesystem for the given user but will not
* tear it down afterwards .
*
* @ param string $filename path to file to expire
* @ param string $uid user for which to expire the version
2015-03-23 12:07:40 +00:00
* @ return bool | int | null
2012-09-07 12:09:41 +00:00
*/
2016-11-22 09:22:00 +00:00
public static function expire ( $filename , $uid ) {
2015-08-20 15:32:41 +00:00
$expiration = self :: getExpiration ();
2016-06-30 08:29:32 +00:00
2017-07-25 12:24:31 +00:00
if ( $expiration -> isEnabled ()) {
2016-11-22 09:22:00 +00:00
// get available disk space for user
$user = \OC :: $server -> getUserManager () -> get ( $uid );
if ( is_null ( $user )) {
2018-04-20 12:35:37 +00:00
\OC :: $server -> getLogger () -> error ( 'Backends provided no user object for ' . $uid , [ 'app' => 'files_versions' ]);
2016-11-22 09:22:00 +00:00
throw new \OC\User\NoUserException ( 'Backends provided no user object for ' . $uid );
}
\OC_Util :: setupFS ( $uid );
2015-12-14 13:16:06 +00:00
if ( ! Filesystem :: file_exists ( $filename )) {
return false ;
}
2015-05-29 11:15:12 +00:00
if ( empty ( $filename )) {
// file maybe renamed or deleted
return false ;
}
2016-02-16 16:16:47 +00:00
$versionsFileview = new View ( '/' . $uid . '/files_versions' );
2013-02-22 16:21:57 +00:00
2013-04-16 11:52:46 +00:00
$softQuota = true ;
2016-02-09 16:16:43 +00:00
$quota = $user -> getQuota ();
2013-03-04 11:33:16 +00:00
if ( $quota === null || $quota === 'none' ) {
2016-02-16 16:16:47 +00:00
$quota = Filesystem :: free_space ( '/' );
2013-04-16 11:52:46 +00:00
$softQuota = false ;
2013-02-25 15:12:44 +00:00
} else {
$quota = \OCP\Util :: computerFileSize ( $quota );
2012-12-13 15:34:54 +00:00
}
2013-08-17 09:57:50 +00:00
2013-01-11 18:33:54 +00:00
// make sure that we have the current size of the version history
2015-12-10 10:21:28 +00:00
$versionsSize = self :: getVersionsSize ( $uid );
2013-01-11 18:33:54 +00:00
2013-01-15 13:57:23 +00:00
// calculate available space for version history
2013-04-16 11:52:46 +00:00
// subtract size of files and current versions size from quota
2015-10-14 11:51:20 +00:00
if ( $quota >= 0 ) {
if ( $softQuota ) {
2019-03-02 12:44:47 +00:00
$userFolder = \OC :: $server -> getUserFolder ( $uid );
if ( is_null ( $userFolder )) {
return 0 ;
}
$free = $quota - $userFolder -> getSize ( false ); // remaining free space for user
2015-10-14 11:51:20 +00:00
if ( $free > 0 ) {
2015-12-10 10:21:28 +00:00
$availableSpace = ( $free * self :: DEFAULTMAXSIZE / 100 ) - $versionsSize ; // how much space can be used for versions
2015-10-14 11:51:20 +00:00
} else {
2015-12-10 10:21:28 +00:00
$availableSpace = $free - $versionsSize ;
2015-10-14 11:51:20 +00:00
}
2013-04-16 11:52:46 +00:00
} else {
2015-12-10 10:21:28 +00:00
$availableSpace = $quota ;
2013-04-16 11:52:46 +00:00
}
2013-01-10 17:04:30 +00:00
} else {
2015-10-14 11:51:20 +00:00
$availableSpace = PHP_INT_MAX ;
2013-02-22 16:21:57 +00:00
}
2013-01-09 16:11:46 +00:00
2013-06-28 18:31:33 +00:00
$allVersions = Storage :: getVersions ( $uid , $filename );
2013-02-22 16:21:57 +00:00
2014-01-20 15:03:26 +00:00
$time = time ();
2015-08-18 21:00:18 +00:00
list ( $toDelete , $sizeOfDeletedVersions ) = self :: getExpireList ( $time , $allVersions , $availableSpace <= 0 );
2014-01-20 15:03:26 +00:00
2013-06-25 07:39:01 +00:00
$availableSpace = $availableSpace + $sizeOfDeletedVersions ;
$versionsSize = $versionsSize - $sizeOfDeletedVersions ;
2013-02-22 16:21:57 +00:00
2013-06-25 07:39:01 +00:00
// if still not enough free space we rearrange the versions from all files
2014-01-20 15:03:26 +00:00
if ( $availableSpace <= 0 ) {
2013-06-25 07:39:01 +00:00
$result = Storage :: getAllVersions ( $uid );
2013-06-28 18:31:33 +00:00
$allVersions = $result [ 'all' ];
2013-02-22 16:21:57 +00:00
2014-01-20 15:03:26 +00:00
foreach ( $result [ 'by_file' ] as $versions ) {
2015-08-18 21:00:18 +00:00
list ( $toDeleteNew , $size ) = self :: getExpireList ( $time , $versions , $availableSpace <= 0 );
2014-01-20 15:03:26 +00:00
$toDelete = array_merge ( $toDelete , $toDeleteNew );
$sizeOfDeletedVersions += $size ;
}
2013-06-25 07:39:01 +00:00
$availableSpace = $availableSpace + $sizeOfDeletedVersions ;
$versionsSize = $versionsSize - $sizeOfDeletedVersions ;
2012-11-04 17:42:18 +00:00
}
2013-02-22 16:21:57 +00:00
2018-04-20 12:35:37 +00:00
$logger = \OC :: $server -> getLogger ();
2014-01-20 15:03:26 +00:00
foreach ( $toDelete as $key => $path ) {
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'preDelete' , array ( 'path' => $path , 'trigger' => self :: DELETE_TRIGGER_QUOTA_EXCEEDED ));
2014-07-10 13:19:40 +00:00
self :: deleteVersion ( $versionsFileview , $path );
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'delete' , array ( 'path' => $path , 'trigger' => self :: DELETE_TRIGGER_QUOTA_EXCEEDED ));
2014-01-20 15:03:26 +00:00
unset ( $allVersions [ $key ]); // update array with the versions we keep
2018-04-20 12:35:37 +00:00
$logger -> info ( 'Expire: ' . $path , [ 'app' => 'files_versions' ]);
2014-01-20 15:03:26 +00:00
}
2013-03-04 16:20:14 +00:00
// Check if enough space is available after versions are rearranged.
// If not we delete the oldest versions until we meet the size limit for versions,
// but always keep the two latest versions
2013-06-28 18:31:33 +00:00
$numOfVersions = count ( $allVersions ) - 2 ;
2013-02-22 16:21:57 +00:00
$i = 0 ;
2014-10-09 17:15:58 +00:00
// sort oldest first and make sure that we start at the first element
ksort ( $allVersions );
reset ( $allVersions );
2013-03-04 16:20:14 +00:00
while ( $availableSpace < 0 && $i < $numOfVersions ) {
2013-06-28 18:31:33 +00:00
$version = current ( $allVersions );
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'preDelete' , array ( 'path' => $version [ 'path' ] . '.v' . $version [ 'version' ], 'trigger' => self :: DELETE_TRIGGER_QUOTA_EXCEEDED ));
2014-07-10 13:19:40 +00:00
self :: deleteVersion ( $versionsFileview , $version [ 'path' ] . '.v' . $version [ 'version' ]);
2016-02-08 17:16:27 +00:00
\OC_Hook :: emit ( '\OCP\Versions' , 'delete' , array ( 'path' => $version [ 'path' ] . '.v' . $version [ 'version' ], 'trigger' => self :: DELETE_TRIGGER_QUOTA_EXCEEDED ));
2018-04-20 12:35:37 +00:00
\OC :: $server -> getLogger () -> info ( 'running out of space! Delete oldest version: ' . $version [ 'path' ] . '.v' . $version [ 'version' ], [ 'app' => 'files_versions' ]);
2013-06-28 18:31:33 +00:00
$versionsSize -= $version [ 'size' ];
$availableSpace += $version [ 'size' ];
next ( $allVersions );
2012-12-17 15:32:09 +00:00
$i ++ ;
}
2013-02-22 16:21:57 +00:00
2013-01-11 18:33:54 +00:00
return $versionsSize ; // finally return the new size of the version history
2012-11-04 17:42:18 +00:00
}
2013-02-22 16:21:57 +00:00
2013-01-11 10:12:32 +00:00
return false ;
2012-09-07 12:09:41 +00:00
}
2013-06-25 07:39:01 +00:00
2013-08-17 11:28:35 +00:00
/**
2015-04-27 10:51:17 +00:00
* Create recursively missing directories inside of files_versions
* that match the given path to a file .
*
* @ param string $filename $path to a file , relative to the user ' s
* " files " folder
2016-02-16 16:16:47 +00:00
* @ param View $view view on data / user /
2013-08-17 11:28:35 +00:00
*/
2018-10-12 15:42:08 +00:00
public static function createMissingDirectories ( $filename , $view ) {
2016-02-16 16:16:47 +00:00
$dirname = Filesystem :: normalizePath ( dirname ( $filename ));
2013-08-17 11:28:35 +00:00
$dirParts = explode ( '/' , $dirname );
$dir = " /files_versions " ;
foreach ( $dirParts as $part ) {
$dir = $dir . '/' . $part ;
if ( ! $view -> file_exists ( $dir )) {
$view -> mkdir ( $dir );
}
}
}
2015-08-20 15:32:41 +00:00
/**
* Static workaround
* @ return Expiration
*/
protected static function getExpiration (){
2015-09-09 15:37:33 +00:00
if ( is_null ( self :: $application )) {
self :: $application = new Application ();
}
2018-05-11 20:25:07 +00:00
return self :: $application -> getContainer () -> query ( Expiration :: class );
2015-08-20 15:32:41 +00:00
}
2012-09-07 12:09:41 +00:00
}