diff --git a/lib/classes/file_storage_backup.php b/lib/classes/file_storage_backup.php new file mode 100644 index 0000000000000000000000000000000000000000..2e2d971f8cb24c7ffeb59a4d305decf5487c4f66 --- /dev/null +++ b/lib/classes/file_storage_backup.php @@ -0,0 +1,168 @@ +. + +/** + * Continuous content file backup class. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +/** + * This class implements continuous file storage backup and recovery, + * files are never deleted from the backup file pool. The backup directory + * may be shared by independent moodle installations. + * + * This class was created as a demonstration of the new file recovery class. + * + * Installation and configuration, edit config.php: + * $CFG->file_storage_recovery_classes = array('core_file_storage_backup'); + * $CFG->core_file_storage_backup_filedir = '/some/masterfiledir'; + * + * Note: you need to copy all files existing file from + * $CFG->filedir to $CFG->core_file_storage_backup_filedir + * before enabling this class. + */ +class core_file_storage_backup extends core_file_storage_recovery { + /** @var string backup file pool */ + protected $backupfiledir; + + public function __construct() { + global $CFG; + + if (empty($CFG->core_file_storage_backup_filedir)) { + debugging('Backup file directory is not configured', DEBUG_MINIMAL); + return; + } + + if (!is_dir($CFG->core_file_storage_backup_filedir) or !is_writable($CFG->core_file_storage_backup_filedir)) { + debugging('Backup file directory is not writable', DEBUG_MINIMAL); + return; + } + + $this->backupfiledir = $CFG->core_file_storage_backup_filedir; + } + + /** + * This method is called after adding new content + * file to file pool. + * + * @param string $contenthash hash of the file content + * @param string $contentfile path to the content file + */ + public function file_added($contenthash, $contentfile) { + global $CFG; + + // We trust the calling code to verify the hash matches the file content. + + if (!$this->backupfiledir) { + return; + } + + $target = $this->backup_file_from_hash($contenthash); + if (file_exists($target)) { + return; + } + + if (!file_exists(dirname($target))) { + if (!mkdir(dirname($target), $CFG->directorypermissions, true)) { + debugging('Can not create backup directory.'); + return; + } + } + + $prev = ignore_user_abort(true); + @unlink($target.'.tmp'); + if (!copy($contentfile, $target.'.tmp')) { + // Borked permissions or out of disk space. + @unlink($target.'.tmp'); + ignore_user_abort($prev); + debugging('Can not add file to backup directory.'); + return; + } + if (filesize($target.'.tmp') !== filesize($contentfile)) { + // This should not happen. + unlink($target.'.tmp'); + ignore_user_abort($prev); + debugging('Can not add file to backup directory.'); + return; + } + rename($target.'.tmp', $target); + chmod($target, $CFG->filepermissions); // Fix permissions if needed. + @unlink($target.'.tmp'); // Just in case anything fails in a weird way. + ignore_user_abort($prev); + } + + /** + * Attempt recovery of missing file content. + * + * Note: + * - this operation should be atomic, + * - use temporary files and rename at the end, + * - the target directory is already created, + * - file permissions are fixed by the calling code. + * + * @param string $contenthash + * @param string $contentfile location for recovered file + * @return bool success true if new file with given content hash restored + */ + public function attempt_recovery($contenthash, $contentfile) { + if (!$this->backupfiledir) { + return false; + } + + $source = $this->backup_file_from_hash($contenthash); + if (!is_readable($source)) { + return false; + } + + $prev = ignore_user_abort(true); + @unlink($contentfile.'.tmp'); + if (!copy($source, $contentfile.'.tmp')) { + // Borked permissions or out of disk space. + @unlink($contentfile.'.tmp'); + ignore_user_abort($prev); + return false; + } + if (filesize($contentfile.'.tmp') !== filesize($source)) { + // This should not happen. + unlink($contentfile.'.tmp'); + ignore_user_abort($prev); + return false; + } + rename($contentfile.'.tmp', $contentfile); + @unlink($contentfile.'.tmp'); // Just in case anything fails in a weird way. + ignore_user_abort($prev); + + return true; + } + + /** + * Return path to file with given hash. + * + * @param string $contenthash content hash + * @return string expected file location + */ + protected function backup_file_from_hash($contenthash) { + $l1 = $contenthash[0].$contenthash[1]; + $l2 = $contenthash[2].$contenthash[3]; + return "$this->backupfiledir/$l1/$l2/$contenthash"; + } +} diff --git a/lib/classes/file_storage_notrash.php b/lib/classes/file_storage_notrash.php new file mode 100644 index 0000000000000000000000000000000000000000..92ab50ba0f7a0a4b010caa2d03a82be5a4180fa3 --- /dev/null +++ b/lib/classes/file_storage_notrash.php @@ -0,0 +1,54 @@ +. + +/** + * Sample class with no file storage recovery. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +/** + * Use this class if you want the files to be deleted immediately + * instead of going to the trashdir first. + * + * Installation, edit config.php: + * $CFG->file_storage_recovery_classes = array('core_file_storage_notrash'); + * + * Note: this is not recommended for production servers, + * because there is a very small chance that the file + * content may be lost when creating and deleting + * the file at the same time in two separate requests. + */ +class core_file_storage_notrash extends core_file_storage_recovery { + + /** + * This method is called before deleting file from filedir. + * + * @param string $contenthash hash of the file content + * @param string $contentfile path to the content file + * @param bool $allowdelete performance hack, usually set for the last recovery plugin + */ + public function file_deleted($contenthash, $contentfile, $allowdelete = false) { + if ($allowdelete) { + unlink($contentfile); + } + } +} diff --git a/lib/classes/file_storage_recovery.php b/lib/classes/file_storage_recovery.php new file mode 100644 index 0000000000000000000000000000000000000000..6d87cbda7296654eee650320d7dddc5efd45c1f5 --- /dev/null +++ b/lib/classes/file_storage_recovery.php @@ -0,0 +1,87 @@ +. + +/** + * Content file recovery base class. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Extend when implementing file_storage recovery methods. + * + * The recovery methods used by file_storage may be configured + * in config.php, it defaults to: + * $CFG->core_file_storage_recovery_classes = array('core_file_storage_trash'); + * + * use following if you do not want to delete any files from $CFG->filedir: + * $CFG->core_file_storage_recovery_classes = array(); + */ +abstract class core_file_storage_recovery { + + /** + * This method is called after adding new content + * file to file pool. + * + * @param string $contenthash hash of the file content + * @param string $contentfile path to the content file + */ + public function file_added($contenthash, $contentfile) { + // Override in backup classes - simply send the file content to your storage. + } + + /** + * This method is called before deleting file from filedir. + * + * @param string $contenthash hash of the file content + * @param string $contentfile path to the content file + * @param bool $allowdelete performance hack, usually set for the last recovery plugin + */ + public function file_deleted($contenthash, $contentfile, $allowdelete) { + // Override in necessary. + } + + /** + * Attempt recovery of missing file content. + * + * Note: + * - this operation should be atomic, + * - use temporary files and rename at the end, + * - the target directory is already created, + * - file permissions are fixed by the calling code. + * + * @param string $contenthash + * @param string $contentfile location for recovered file + * @return bool success true if new file with given content hash restored + */ + public function attempt_recovery($contenthash, $contentfile) { + // Override if your recovery class can somehow restore missing file content. + return false; + } + + /** + * Called from file_storage cron. + * + * @param bool $cleanupexecuted true if standard file cleanup was executed in this cron. + */ + public function cron($cleanupexecuted) { + // Override if you need to do some cleanup periodically. + } +} diff --git a/lib/classes/file_storage_trash.php b/lib/classes/file_storage_trash.php new file mode 100644 index 0000000000000000000000000000000000000000..25470bf4599ce06abad8bb78b90bb85fa6b528ae --- /dev/null +++ b/lib/classes/file_storage_trash.php @@ -0,0 +1,137 @@ +. + +/** + * Filedir trash directory. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +/** + * This class works around race conditions when deleting filedir files. + * + * Note: this code is not necessary if you have some different + * recovery for your method such as file_storage_backup. + */ +class core_file_storage_trash extends core_file_storage_recovery { + /** @var string trash directory */ + protected $trashdir; + + public function __construct() { + global $CFG; + + if (isset($CFG->trashdir)) { + $this->trashdir = $CFG->trashdir; + } else { + $this->trashdir = $CFG->dataroot.'/trashdir'; + } + + if (!is_dir($this->trashdir)) { + if (!mkdir($this->trashdir, $CFG->directorypermissions, true)) { + // Permission trouble most probably. + throw new file_exception('storedfilecannotcreatefiledirs'); + } + } + } + + /** + * Attempt recovery of missing file content. + * + * Note: + * - this operation should be atomic, + * - use temporary files and rename at the end, + * - the target directory is already created, + * - file permissions are fixed by the calling code. + * + * @param string $contenthash + * @param string $contentfile location for recovered file + * @return bool success true if new file with given content hash restored + */ + public function attempt_recovery($contenthash, $contentfile) { + $trashfile = $this->trash_file_from_hash($contenthash); + if (!is_readable($trashfile)) { + if (!is_readable($this->trashdir.'/'.$contenthash)) { + return false; + } + $trashfile = $this->trashdir.'/'.$contenthash; + } + + return rename($trashfile, $contentfile); + } + + /** + * This method is called before deleting file from filedir. + * + * @param string $contenthash hash of the file content + * @param string $contentfile path to the content file + * @param bool $allowdelete performance hack, usually set for the last recovery plugin + */ + public function file_deleted($contenthash, $contentfile, $allowdelete = false) { + global $CFG; + + $trashfile = $this->trash_file_from_hash($contenthash); + if (file_exists($trashfile)) { + // We already have this content in trash, no need to copy it there. + return; + } + + if (!file_exists(dirname($trashfile))) { + if (!mkdir(dirname($trashfile), $CFG->directorypermissions, true)) { + return; + } + } + + if ($allowdelete) { + rename($contentfile, $trashfile); + } else { + copy($contentfile, $trashfile); + } + chmod($trashfile, $CFG->filepermissions); + } + + /** + * Called from file_storage cron. + * + * @param bool $cleanupexecuted true if standard file cleanup was executed in this cron. + */ + public function cron($cleanupexecuted) { + if (!$cleanupexecuted) { + return; + } + + mtrace('Deleting trash files... ', ''); + cron_trace_time_and_memory(); + remove_dir($this->trashdir, true); + mtrace('done.'); + } + + /** + * Return path to file with given hash. + * + * @param string $contenthash content hash + * @return string expected file location + */ + protected function trash_file_from_hash($contenthash) { + $l1 = $contenthash[0].$contenthash[1]; + $l2 = $contenthash[2].$contenthash[3]; + return "$this->trashdir/$l1/$l2/$contenthash"; + } +} diff --git a/lib/filestorage/file_storage.php b/lib/filestorage/file_storage.php index 967a3298e7176b4ee918c6c6f6d3d7365e09de48..dd56cde7c8219359816a95d60102574d6a5d9cfa 100644 --- a/lib/filestorage/file_storage.php +++ b/lib/filestorage/file_storage.php @@ -45,8 +45,8 @@ require_once("$CFG->libdir/filestorage/stored_file.php"); class file_storage { /** @var string Directory with file contents */ private $filedir; - /** @var string Contents of deleted files not needed any more */ - private $trashdir; + /** @var core_file_storage_recovery[] list of content backup/recovery instances */ + private $recoveryinstances; /** @var string tempdir */ private $tempdir; /** @var int Permissions for new directories */ @@ -58,18 +58,27 @@ class file_storage { * Constructor - do not use directly use {@link get_file_storage()} call instead. * * @param string $filedir full path to pool directory - * @param string $trashdir temporary storage of deleted area + * @param array $recoveryclasses list of classes used for backup/recovery * @param string $tempdir temporary storage of various files * @param int $dirpermissions new directory permissions * @param int $filepermissions new file permissions */ - public function __construct($filedir, $trashdir, $tempdir, $dirpermissions, $filepermissions) { + public function __construct($filedir, array $recoveryclasses, $tempdir, $dirpermissions, $filepermissions) { $this->filedir = $filedir; - $this->trashdir = $trashdir; $this->tempdir = $tempdir; $this->dirpermissions = $dirpermissions; $this->filepermissions = $filepermissions; + // No recovery instances means file is never deleted from filedir. + $this->recoveryinstances = array(); + foreach ($recoveryclasses as $class) { + if (class_exists($class)) { + $this->recoveryinstances[] = new $class(); + } else { + debugging("Unknown content recovery class $class"); + } + } + // make sure the file pool directory exists if (!is_dir($this->filedir)) { if (!mkdir($this->filedir, $this->dirpermissions, true)) { @@ -81,12 +90,6 @@ class file_storage { 'This directory contains the content of uploaded files and is controlled by Moodle code. Do not manually move, change or rename any of the files and subdirectories here.'); } } - // make sure the file pool directory exists - if (!is_dir($this->trashdir)) { - if (!mkdir($this->trashdir, $this->dirpermissions, true)) { - throw new file_exception('storedfilecannotcreatefiledirs'); // permission trouble - } - } } /** @@ -1708,6 +1711,10 @@ class file_storage { @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way. ignore_user_abort($prev); + foreach ($this->recoveryinstances as $recovery) { + $recovery->file_added($contenthash, $hashfile); + } + return array($contenthash, $filesize, $newfile); } @@ -1769,6 +1776,10 @@ class file_storage { @unlink($hashfile.'.tmp'); // Just in case anything fails in a weird way. ignore_user_abort($prev); + foreach ($this->recoveryinstances as $recovery) { + $recovery->file_added($contenthash, $hashfile); + } + return array($contenthash, $filesize, $newfile); } @@ -1815,51 +1826,49 @@ class file_storage { } /** - * Return path to file with given hash. - * - * NOTE: must not be public, files in pool must not be modified - * - * @param string $contenthash content hash - * @return string expected file location - */ - protected function trash_path_from_hash($contenthash) { - $l1 = $contenthash[0].$contenthash[1]; - $l2 = $contenthash[2].$contenthash[3]; - return "$this->trashdir/$l1/$l2"; - } - - /** - * Tries to recover missing content of file from trash. + * Tries to recover missing content of file from some backup. * * @param stored_file $file stored_file instance * @return bool success */ public function try_content_recovery($file) { $contenthash = $file->get_contenthash(); - $trashfile = $this->trash_path_from_hash($contenthash).'/'.$contenthash; - if (!is_readable($trashfile)) { - if (!is_readable($this->trashdir.'/'.$contenthash)) { - return false; - } - // nice, at least alternative trash file in trash root exists - $trashfile = $this->trashdir.'/'.$contenthash; - } - if (filesize($trashfile) != $file->get_filesize() or sha1_file($trashfile) != $contenthash) { - //weird, better fail early - return false; - } $contentdir = $this->path_from_hash($contenthash); $contentfile = $contentdir.'/'.$contenthash; + if (file_exists($contentfile)) { - //strange, no need to recover anything + // Strange, no need to recover anything. return true; } + if (!is_dir($contentdir)) { if (!mkdir($contentdir, $this->dirpermissions, true)) { return false; } } - return rename($trashfile, $contentfile); + + foreach ($this->recoveryinstances as $recovery) { + if ($recovery->attempt_recovery($contenthash, $contentfile)) { + @chmod($contentfile, $this->filepermissions); + if (sha1_file($contentfile) === $contenthash) { + // Great! + break; + } + // This is weird, the recovery plugin restored rubbish, + // we must delete it and look further. + unlink($contentfile); + } + } + + if (!file_exists($contentfile)) { + return false; + } + + foreach ($this->recoveryinstances as $recovery) { + $recovery->file_added($contenthash, $contentfile); + } + + return true; } /** @@ -1878,29 +1887,34 @@ class file_storage { } //Note: this section is critical - in theory file could be reused at the same - // time, if this happens we can still recover the file from trash + // time, if this happens we can still recover the file using recovery plugins. if ($DB->record_exists('files', array('contenthash'=>$contenthash))) { - // file content is still used + // File content is still used. return; } - //move content file to trash + $contentfile = $this->path_from_hash($contenthash).'/'.$contenthash; if (!file_exists($contentfile)) { - //weird, but no problem + // Weird, but no problem, recovery plugins just do not get any chance to react. return; } - $trashpath = $this->trash_path_from_hash($contenthash); - $trashfile = $trashpath.'/'.$contenthash; - if (file_exists($trashfile)) { - // we already have this content in trash, no need to move it there - unlink($contentfile); + + if (empty($this->recoveryinstances)) { + // Do not delete anything! + // This may be useful when multiple moodle instances are sharing the same filedir. return; } - if (!is_dir($trashpath)) { - mkdir($trashpath, $this->dirpermissions, true); + + // Allow last recovery plugin to rename the file instead of copy, + // this improves perf when using trash recovery. + $allowdelete = count($this->recoveryinstances) - 1; + foreach ($this->recoveryinstances as $i => $recovery) { + $recovery->file_deleted($contenthash, $contentfile, ($allowdelete == $i)); } - rename($contentfile, $trashfile); - chmod($trashfile, $this->filepermissions); // fix permissions if needed + + // Finally delete the file from filedir, + // it might have been already deleted by the last recovery plugin. + @unlink($contentfile); } /** @@ -2210,11 +2224,15 @@ class file_storage { $rs->close(); mtrace('done.'); - mtrace('Deleting trash files... ', ''); - cron_trace_time_and_memory(); - fulldelete($this->trashdir); - set_config('fileslastcleanup', time()); - mtrace('done.'); + $cleanupexecuted = true; + + } else { + $cleanupexecuted = false; + } + + // Execute all storage recovery crons. + foreach ($this->recoveryinstances as $recovery) { + $recovery->cron($cleanupexecuted); } } diff --git a/lib/moodlelib.php b/lib/moodlelib.php index 593c964f296b2fb6fc16b57cc430c2c4569a7df4..bfadd8a8efe902d500d302dd3c9402df85190d1e 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -6113,13 +6113,14 @@ function get_file_storage() { $filedir = $CFG->dataroot.'/filedir'; } - if (isset($CFG->trashdir)) { - $trashdirdir = $CFG->trashdir; + // Get the list of file recovery classes. + if (isset($CFG->core_file_storage_recovery_classes) and is_array($CFG->core_file_storage_recovery_classes)) { + $recoveryclasses = $CFG->core_file_storage_recovery_classes; } else { - $trashdirdir = $CFG->dataroot.'/trashdir'; + $recoveryclasses = array('core_file_storage_trash'); } - $fs = new file_storage($filedir, $trashdirdir, "$CFG->tempdir/filestorage", $CFG->directorypermissions, $CFG->filepermissions); + $fs = new file_storage($filedir, $recoveryclasses, "$CFG->tempdir/filestorage", $CFG->directorypermissions, $CFG->filepermissions); return $fs; } diff --git a/lib/tests/file_storage_recovery_test.php b/lib/tests/file_storage_recovery_test.php new file mode 100644 index 0000000000000000000000000000000000000000..c70f70da4821d3e424de297187421901112a48c8 --- /dev/null +++ b/lib/tests/file_storage_recovery_test.php @@ -0,0 +1,370 @@ +. + +/** + * core_file_storage_recovery related tests. + * + * @package core + * @category phpunit + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +class core_file_storage_recovery_testcase extends advanced_testcase { + public function test_trash() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + $filedir = "$CFG->dataroot/testfiledir"; + $trashdir = "$CFG->dataroot/testtrash"; + $CFG->trashdir = $trashdir; + + $this->assertFileNotExists($filedir); + $this->assertFileNotExists($trashdir); + + $this->assertTrue(check_dir_exists($filedir, true, true)); + $this->assertTrue(check_dir_exists($trashdir, true, true)); + + $fs = new file_storage($filedir, array('core_file_storage_trash'), $CFG->tempdir, $CFG->directorypermissions, $CFG->filepermissions); + + $syscontext = context_system::instance(); + + $file_record1 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test.txt', + ); + $file_record2 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test2.txt', + ); + $file_record3 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test3.txt', + ); + $content1 = 'test content 1'; + $content2 = 'test content 2'; + + $file1 = $fs->create_file_from_string($file_record1, $content1); + $file2 = $fs->create_file_from_string($file_record2, $content2); + $file3 = $fs->create_file_from_string($file_record3, $content2); + $f3raw = $DB->get_record('files', array('id'=>$file3->get_id())); + + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + + // Standard deleting. + + $file2->delete(); + $this->assertFalse($DB->record_exists('files', array('id'=>$file2->get_id()))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + + $file3->delete(); + $this->assertFalse($DB->record_exists('files', array('id'=>$file3->get_id()))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($trashdir, sha1($content2))); + + // Content recovery from trashdir. + + unset($f3raw->id); + $f3raw->id = $DB->insert_record('files', $f3raw); + $file3 = $fs->get_file($f3raw->contextid, $f3raw->component, $f3raw->filearea, $f3raw->itemid, $f3raw->filepath, $f3raw->filename); + $this->assertInstanceOf('stored_file', $file3); + $this->assertSame($content2, $file3->get_content()); + + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + } + + public function test_no_recovery() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + $filedir = "$CFG->dataroot/testfiledir"; + $trashdir = "$CFG->dataroot/testtrash"; + $CFG->trashdir = $trashdir; + + $this->assertFileNotExists($filedir); + $this->assertFileNotExists($trashdir); + + $this->assertTrue(check_dir_exists($filedir, true, true)); + $this->assertTrue(check_dir_exists($trashdir, true, true)); + + $fs = new file_storage($filedir, array(), $CFG->tempdir, $CFG->directorypermissions, $CFG->filepermissions); + + $syscontext = context_system::instance(); + + $file_record1 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test.txt', + ); + $file_record2 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test2.txt', + ); + $file_record3 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test3.txt', + ); + $content1 = 'test content 1'; + $content2 = 'test content 2'; + + $file1 = $fs->create_file_from_string($file_record1, $content1); + $file2 = $fs->create_file_from_string($file_record2, $content2); + $file3 = $fs->create_file_from_string($file_record3, $content2); + $f3raw = $DB->get_record('files', array('id'=>$file3->get_id())); + + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + + // Standard deleting. + + $file2->delete(); + $this->assertFalse($DB->record_exists('files', array('id'=>$file2->get_id()))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + + $file3->delete(); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + } + + public function test_notrash() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + $filedir = "$CFG->dataroot/testfiledir"; + $trashdir = "$CFG->dataroot/testtrash"; + $CFG->trashdir = $trashdir; + + $this->assertFileNotExists($filedir); + $this->assertFileNotExists($trashdir); + + $this->assertTrue(check_dir_exists($filedir, true, true)); + $this->assertTrue(check_dir_exists($trashdir, true, true)); + + $fs = new file_storage($filedir, array('core_file_storage_notrash'), $CFG->tempdir, $CFG->directorypermissions, $CFG->filepermissions); + + $syscontext = context_system::instance(); + + $file_record1 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test.txt', + ); + $file_record2 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test2.txt', + ); + $file_record3 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test3.txt', + ); + $content1 = 'test content 1'; + $content2 = 'test content 2'; + + $file1 = $fs->create_file_from_string($file_record1, $content1); + $file2 = $fs->create_file_from_string($file_record2, $content2); + $file3 = $fs->create_file_from_string($file_record3, $content2); + $f3raw = $DB->get_record('files', array('id'=>$file3->get_id())); + + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + + // Standard deleting. + + $file2->delete(); + $this->assertFalse($DB->record_exists('files', array('id'=>$file2->get_id()))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + + $file3->delete(); + $this->assertFalse($DB->record_exists('files', array('id'=>$file3->get_id()))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($trashdir, sha1($content2))); + + // Content recovery not done. + + unset($f3raw->id); + $f3raw->id = $DB->insert_record('files', $f3raw); + $file3 = $fs->get_file($f3raw->contextid, $f3raw->component, $f3raw->filearea, $f3raw->itemid, $f3raw->filepath, $f3raw->filename); + $this->assertInstanceOf('stored_file', $file3); + try { + $file3->get_content(); + $this->fail('Exception expected when reading file without content'); + } catch (moodle_exception $e) { + $this->assertInstanceOf('file_exception', $e); + } + } + + public function test_backup() { + global $CFG, $DB; + + $this->resetAfterTest(true); + + $filedir = "$CFG->dataroot/testfiledir"; + $backupdir = "$CFG->dataroot/testbackup"; + $CFG->core_file_storage_backup_filedir = $backupdir; + + $this->assertFileNotExists($filedir); + $this->assertFileNotExists($backupdir); + + $this->assertTrue(check_dir_exists($filedir, true, true)); + $this->assertTrue(check_dir_exists($backupdir, true, true)); + + $fs = new file_storage($filedir, array('core_file_storage_backup'), $CFG->tempdir, $CFG->directorypermissions, $CFG->filepermissions); + + $syscontext = context_system::instance(); + + $file_record1 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test.txt', + ); + $file_record2 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test2.txt', + ); + $file_record3 = array( + 'contextid' => $syscontext->id, + 'component' => 'core_test', + 'filearea' => 'testarea', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => 'test3.txt', + ); + $content1 = 'test content 1'; + $content2 = 'test content 2'; + $file1 = "$CFG->dataroot/content1"; + file_put_contents($file1, $content1); + $file2 = "$CFG->dataroot/content2"; + file_put_contents($file2, $content2); + + $file1 = $fs->create_file_from_string($file_record1, $content1); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content1))); + + $file2 = $fs->create_file_from_pathname($file_record2, $file2); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content2))); + + $file3 = $fs->create_file_from_string($file_record3, $content2); + $f3raw = $DB->get_record('files', array('id'=>$file3->get_id())); + + // Standard deleting. + + $file2->delete(); + $this->assertFalse($DB->record_exists('files', array('id'=>$file2->get_id()))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content2))); + + $file3->delete(); + $this->assertFalse($DB->record_exists('files', array('id'=>$file3->get_id()))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileNotExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content2))); + + // Content recovery from backup. + + unset($f3raw->id); + $f3raw->id = $DB->insert_record('files', $f3raw); + $file3 = $fs->get_file($f3raw->contextid, $f3raw->component, $f3raw->filearea, $f3raw->itemid, $f3raw->filepath, $f3raw->filename); + $this->assertInstanceOf('stored_file', $file3); + $this->assertSame($content2, $file3->get_content()); + + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($filedir, sha1($content2))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content1))); + $this->assertFileExists($this->get_file_from_hash($backupdir, sha1($content2))); + } + + protected function get_file_from_hash($dir, $contenthash) { + $l1 = $contenthash[0].$contenthash[1]; + $l2 = $contenthash[2].$contenthash[3]; + return "$dir/$l1/$l2/$contenthash"; + } +}