diff --git a/lib/editor/atto/plugins/managefiles/lang/en/atto_managefiles.php b/lib/editor/atto/plugins/managefiles/lang/en/atto_managefiles.php index 53db50bd918..f98243e0191 100644 --- a/lib/editor/atto/plugins/managefiles/lang/en/atto_managefiles.php +++ b/lib/editor/atto/plugins/managefiles/lang/en/atto_managefiles.php @@ -29,4 +29,5 @@ $string['managefiles'] = 'Manage files'; $string['missingfiles'] = 'Missing files'; $string['pluginname'] = 'Manage files'; $string['unusedfilesdesc'] = 'The following embedded files are not used in the text area:'; +$string['unusedfilesremovalnotice'] = 'Any unused files will be automatically deleted when saving changes.'; $string['unusedfilesheader'] = 'Unused files'; diff --git a/lib/editor/atto/plugins/managefiles/lib.php b/lib/editor/atto/plugins/managefiles/lib.php index bce1bd469fe..b6ff2a1a259 100644 --- a/lib/editor/atto/plugins/managefiles/lib.php +++ b/lib/editor/atto/plugins/managefiles/lib.php @@ -55,7 +55,8 @@ function atto_managefiles_params_for_js($elementid, $options, $fpoptions) { if (!$disabled) { $params['usercontext'] = context_user::instance($USER->id)->id; - foreach (array('itemid', 'context', 'areamaxbytes', 'maxbytes', 'subdirs', 'return_types') as $key) { + foreach (array('itemid', 'context', 'areamaxbytes', 'maxbytes', 'subdirs', 'return_types', + 'removeorphaneddrafts') as $key) { if (isset($options[$key])) { if ($key === 'context' && is_object($options[$key])) { // Just context id is enough. diff --git a/lib/editor/atto/plugins/managefiles/manage.php b/lib/editor/atto/plugins/managefiles/manage.php index ff6922bdf56..5dd39a49481 100644 --- a/lib/editor/atto/plugins/managefiles/manage.php +++ b/lib/editor/atto/plugins/managefiles/manage.php @@ -35,6 +35,7 @@ $return_types = optional_param('return_types', null, PARAM_INT); $areamaxbytes = optional_param('areamaxbytes', FILE_AREA_MAX_BYTES_UNLIMITED, PARAM_INT); $contextid = optional_param('context', SYSCONTEXTID, PARAM_INT); $elementid = optional_param('elementid', '', PARAM_TEXT); +$removeorphaneddrafts = optional_param('removeorphaneddrafts', 0, PARAM_INT); $context = context::instance_by_id($contextid); if ($context->contextlevel == CONTEXT_MODULE) { @@ -75,7 +76,7 @@ $options = array( 'accepted_types' => $accepted_types, 'areamaxbytes' => $areamaxbytes, 'return_types' => $return_types, - 'context' => $context, + 'context' => $context ); $usercontext = context_user::instance($USER->id); @@ -87,8 +88,8 @@ foreach ($files as $file) { } $mform = new atto_managefiles_manage_form(null, - array('options' => $options, 'draftitemid' => $itemid, 'files' => $filenames, 'elementid' => $elementid), - 'post', '', array('id' => 'atto_managefiles_manageform')); + array('options' => $options, 'draftitemid' => $itemid, 'files' => $filenames, 'elementid' => $elementid, + 'removeorphaneddrafts' => $removeorphaneddrafts), 'post', '', array('id' => 'atto_managefiles_manageform')); if ($data = $mform->get_data()) { if (!empty($data->deletefile)) { diff --git a/lib/editor/atto/plugins/managefiles/manage_form.php b/lib/editor/atto/plugins/managefiles/manage_form.php index 50bb8dd5dfa..a5569aa92a5 100644 --- a/lib/editor/atto/plugins/managefiles/manage_form.php +++ b/lib/editor/atto/plugins/managefiles/manage_form.php @@ -47,6 +47,7 @@ class atto_managefiles_manage_form extends moodleform { $elementid = $this->_customdata['elementid']; $options = $this->_customdata['options']; $files = $this->_customdata['files']; + $removeorphaneddrafts = $this->_customdata['removeorphaneddrafts']; $mform->addElement('header', 'filemanagerhdr', get_string('filemanager', 'atto_managefiles')); @@ -69,6 +70,12 @@ class atto_managefiles_manage_form extends moodleform { $mform->addElement('filemanager', 'files_filemanager', '', null, $options); + // Let the user know that any drafts not referenced in the text will be removed automatically. + if ($removeorphaneddrafts) { + $mform->addElement('static', '', '', + html_writer::tag('div', get_string('unusedfilesremovalnotice', 'atto_managefiles'))); + } + $mform->addElement('header', 'missingfileshdr', get_string('missingfiles', 'atto_managefiles')); $mform->addElement('static', '', '', html_writer::tag('div', diff --git a/lib/filelib.php b/lib/filelib.php index d2779ced516..4b74d41e7a7 100644 --- a/lib/filelib.php +++ b/lib/filelib.php @@ -237,6 +237,9 @@ function file_postupdate_standard_editor($data, $field, array $options, $context if (!isset($options['maxbytes'])) { $options['maxbytes'] = 0; // unlimited } + if (!isset($options['removeorphaneddrafts'])) { + $options['removeorphaneddrafts'] = false; // Don't remove orphaned draft files by default. + } if ($options['trusttext']) { $data->{$field.'trust'} = trusttext_trusted($context); @@ -249,6 +252,10 @@ function file_postupdate_standard_editor($data, $field, array $options, $context if ($options['maxfiles'] == 0 or is_null($filearea) or is_null($itemid) or empty($editor['itemid'])) { $data->{$field} = $editor['text']; } else { + // Clean the user drafts area of any files not referenced in the editor text. + if ($options['removeorphaneddrafts']) { + file_remove_editor_orphaned_files($editor); + } $data->{$field} = file_save_draft_area_files($editor['itemid'], $context->id, $component, $filearea, $itemid, $options, $editor['text'], $options['forcehttps']); } $data->{$field.'format'} = $editor['format']; @@ -792,6 +799,38 @@ function file_restore_source_field_from_draft_file($storedfile) { } return $storedfile; } + +/** + * Removes those files from the user drafts filearea which are not referenced in the editor text. + * + * @param stdClass $editor The online text editor element from the submitted form data. + */ +function file_remove_editor_orphaned_files($editor) { + global $CFG, $USER; + + // Find those draft files included in the text, and generate their hashes. + $context = context_user::instance($USER->id); + $baseurl = $CFG->wwwroot . '/draftfile.php/' . $context->id . '/user/draft/' . $editor['itemid'] . '/'; + $pattern = "/" . preg_quote($baseurl, '/') . "(.+?)[\?\"']/"; + preg_match_all($pattern, $editor['text'], $matches); + $usedfilehashes = []; + foreach ($matches[1] as $matchedfilename) { + $matchedfilename = urldecode($matchedfilename); + $usedfilehashes[] = \file_storage::get_pathname_hash($context->id, 'user', 'draft', $editor['itemid'], '/', + $matchedfilename); + } + + // Now, compare the hashes of all draft files, and remove those which don't match used files. + $fs = get_file_storage(); + $files = $fs->get_area_files($context->id, 'user', 'draft', $editor['itemid'], 'id', false); + foreach ($files as $file) { + $tmphash = $file->get_pathnamehash(); + if (!in_array($tmphash, $usedfilehashes)) { + $file->delete(); + } + } +} + /** * Saves files from a draft file area to a real one (merging the list of files). * Can rewrite URLs in some content at the same time if desired. diff --git a/lib/form/editor.php b/lib/form/editor.php index 69a78127219..d32527ab3e7 100644 --- a/lib/form/editor.php +++ b/lib/form/editor.php @@ -58,7 +58,7 @@ class MoodleQuickForm_editor extends HTML_QuickForm_element implements templatab /** @var array options provided to initalize filepicker */ protected $_options = array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 0, 'changeformat' => 0, 'areamaxbytes' => FILE_AREA_MAX_BYTES_UNLIMITED, 'context' => null, 'noclean' => 0, 'trusttext' => 0, - 'return_types' => 15, 'enable_filemanagement' => true); + 'return_types' => 15, 'enable_filemanagement' => true, 'removeorphaneddrafts' => false); // 15 is $_options['return_types'] = FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE | FILE_CONTROLLED_LINK. /** @var array values for editor */ diff --git a/lib/tests/filelib_test.php b/lib/tests/filelib_test.php index 87a37bd0fe2..cb40c8fe7e8 100644 --- a/lib/tests/filelib_test.php +++ b/lib/tests/filelib_test.php @@ -1340,6 +1340,51 @@ EOF; $this->assertEquals(0, $fileinfo['foldercount']); // No subdirectories inside the directory. $this->assertEquals($file->get_filesize(), $fileinfo['filesize_without_references']); } + + /** + * Test confirming that draft files not referenced in the editor text are removed. + */ + public function test_file_remove_editor_orphaned_files() { + global $USER, $CFG; + $this->resetAfterTest(true); + $this->setAdminUser(); + + // Create three draft files. + $filerecord = ['filename' => 'file1.png']; + $file = self::create_draft_file($filerecord); + $draftitemid = $file->get_itemid(); + + $filerecord['itemid'] = $draftitemid; + + $filerecord['filename'] = 'file2.png'; + self::create_draft_file($filerecord); + + $filerecord['filename'] = 'file 3.png'; + self::create_draft_file($filerecord); + + // Confirm the user drafts area lists 3 files. + $fs = get_file_storage(); + $usercontext = context_user::instance($USER->id); + $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'itemid', 0); + $this->assertCount(3, $draftfiles); + + // Now, spoof some editor text content, referencing 2 of the files; one requiring name encoding, one not. + $editor = [ + 'itemid' => $draftitemid, + 'text' => ' + + ' + ]; + + // Run the remove orphaned drafts function and confirm that only the referenced files remain in the user drafts. + $expected = ['file1.png', 'file 3.png']; // The drafts we expect will not be removed (are referenced in the online text). + file_remove_editor_orphaned_files($editor); + $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'itemid', 0); + $this->assertCount(2, $draftfiles); + foreach ($draftfiles as $file) { + $this->assertContains($file->get_filename(), $expected); + } + } } /** diff --git a/mod/assign/submission/onlinetext/locallib.php b/mod/assign/submission/onlinetext/locallib.php index 5ec7c6d0e08..17e342b7a13 100644 --- a/mod/assign/submission/onlinetext/locallib.php +++ b/mod/assign/submission/onlinetext/locallib.php @@ -169,12 +169,13 @@ class assign_submission_onlinetext extends assign_submission_plugin { * @return array */ private function get_edit_options() { - $editoroptions = array( - 'noclean' => false, - 'maxfiles' => EDITOR_UNLIMITED_FILES, - 'maxbytes' => $this->assignment->get_course()->maxbytes, - 'context' => $this->assignment->get_context(), - 'return_types' => (FILE_INTERNAL | FILE_EXTERNAL | FILE_CONTROLLED_LINK) + $editoroptions = array( + 'noclean' => false, + 'maxfiles' => EDITOR_UNLIMITED_FILES, + 'maxbytes' => $this->assignment->get_course()->maxbytes, + 'context' => $this->assignment->get_context(), + 'return_types' => (FILE_INTERNAL | FILE_EXTERNAL | FILE_CONTROLLED_LINK), + 'removeorphaneddrafts' => true // Whether or not to remove any draft files which aren't referenced in the text. ); return $editoroptions; }