Merge branch 'master-MDL-67211_v6' of https://github.com/golenkovm/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Aug 2020 04:47:59 +0000 (12:47 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Aug 2020 04:47:59 +0000 (12:47 +0800)
61 files changed:
admin/cli/restore_backup.php [new file with mode: 0644]
admin/settings/plugins.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/log/upgrade.txt
admin/tool/lp/tests/behat/course_competencies.feature
auth/tests/behat/login.feature
cache/upgrade.txt
calendar/upgrade.txt
cohort/index.php
competency/classes/api.php
course/upgrade.txt
grade/grading/form/upgrade.txt
h5p/classes/api.php
h5p/classes/editor_framework.php
h5p/classes/framework.php
h5p/tests/generator_test.php
h5p/upgrade.txt
lang/en/admin.php
lang/en/antivirus.php
lang/en/backup.php
lang/en/badges.php
lang/en/contentbank.php
lang/en/moodle.php
lib/antivirus/clamav/classes/scanner.php
lib/classes/antivirus/manager.php
lib/classes/antivirus/quarantine.php [new file with mode: 0644]
lib/classes/antivirus/scanner.php
lib/classes/event/virus_infected_data_detected.php [new file with mode: 0644]
lib/classes/event/virus_infected_file_detected.php [new file with mode: 0644]
lib/classes/plugin_manager.php
lib/classes/task/antivirus_cleanup_task.php [new file with mode: 0644]
lib/classes/task/backup_cleanup_task.php
lib/db/install.xml
lib/db/messages.php
lib/db/tasks.php
lib/db/upgrade.php
lib/pagelib.php
lib/templates/infected_file_email.mustache [new file with mode: 0644]
lib/tests/antivirus_test.php
lib/tests/moodle_page_test.php
lib/tests/scheduled_task_test.php
lib/upgrade.txt
mod/choice/lang/en/choice.php
mod/lesson/lang/en/lesson.php
mod/lti/view.php
question/behaviour/upgrade.txt
report/infectedfiles/classes/output/renderer.php [new file with mode: 0644]
report/infectedfiles/classes/privacy/provider.php [new file with mode: 0644]
report/infectedfiles/classes/table/infectedfiles_table.php [new file with mode: 0644]
report/infectedfiles/index.php [new file with mode: 0644]
report/infectedfiles/lang/en/report_infectedfiles.php [new file with mode: 0644]
report/infectedfiles/settings.php [new file with mode: 0644]
report/infectedfiles/version.php [new file with mode: 0644]
report/insights/classes/output/insights_list.php
search/upgrade.txt
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
theme/upgrade.txt
version.php

diff --git a/admin/cli/restore_backup.php b/admin/cli/restore_backup.php
new file mode 100644 (file)
index 0000000..6f5de42
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This script allows to restore a course from CLI.
+ *
+ * @package    core
+ * @subpackage cli
+ * @copyright  2020 Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', 1);
+
+require(__DIR__ . '/../../config.php');
+require_once($CFG->libdir . '/clilib.php');
+require_once($CFG->dirroot . "/backup/util/includes/restore_includes.php");
+
+list($options, $unrecognized) = cli_get_params([
+    'file' => '',
+    'categoryid' => '',
+    'showdebugging' => false,
+    'help' => false,
+], [
+    'f' => 'file',
+    'c' => 'categoryid',
+    's' => 'showdebugging',
+    'h' => 'help',
+]);
+
+if ($unrecognized) {
+    $unrecognized = implode("\n  ", $unrecognized);
+    cli_error(get_string('cliunknowoption', 'admin', $unrecognized));
+}
+
+if ($options['help'] || !($options['file']) || !($options['categoryid'])) {
+    $help = <<<EOL
+Restore backup into provided category.
+
+Options:
+-f, --file=STRING           Path to the backup file.
+-c, --categoryid=INT        ID of the category to restore too.
+-s, --showdebugging         Show developer level debugging information
+-h, --help                  Print out this help.
+
+Example:
+\$sudo -u www-data /usr/bin/php admin/cli/restore_backup.php --file=/path/to/backup/file.mbz --categoryid=1\n
+EOL;
+
+    echo $help;
+    exit(0);
+}
+
+if ($options['showdebugging']) {
+    set_debugging(DEBUG_DEVELOPER, true);
+}
+
+if (!$admin = get_admin()) {
+    print_error('noadmins');
+}
+
+if (!file_exists($options['file'])) {
+    print_error('filenotfound');
+}
+
+if (!$category = $DB->get_record('course_categories', ['id' => $options['categoryid']], 'id')) {
+    print_error('invalidcategoryid');
+}
+
+$backupdir = "restore_" . uniqid();
+$path = $CFG->tempdir . DIRECTORY_SEPARATOR . "backup" . DIRECTORY_SEPARATOR . $backupdir;
+
+cli_heading(get_string('extractingbackupfileto', 'backup', $path));
+$fp = get_file_packer('application/vnd.moodle.backup');
+$fp->extract_to_pathname($options['file'], $path);
+
+cli_heading(get_string('preprocessingbackupfile'));
+try {
+    list($fullname, $shortname) = restore_dbops::calculate_course_names(0, get_string('restoringcourse', 'backup'),
+        get_string('restoringcourseshortname', 'backup'));
+
+    $courseid = restore_dbops::create_new_course($fullname, $shortname, $category->id);
+
+    $rc = new restore_controller($backupdir, $courseid, backup::INTERACTIVE_NO,
+        backup::MODE_GENERAL, $admin->id, backup::TARGET_NEW_COURSE);
+    $rc->execute_precheck();
+    $rc->execute_plan();
+    $rc->destroy();
+} catch (Exception $e) {
+    cli_heading(get_string('cleaningtempdata'));
+    fulldelete($path);
+    print_error('generalexceptionmessage', 'error', '', $e->getMessage());
+}
+
+cli_heading(get_string('restoredcourseid', 'backup', $courseid));
+exit(0);
index 3e7c68c..249bd53 100644 (file)
@@ -167,6 +167,42 @@ if ($hassiteconfig) {
     $ADMIN->add('modules', new admin_category('antivirussettings', new lang_string('antiviruses', 'antivirus')));
     $temp = new admin_settingpage('manageantiviruses', new lang_string('antivirussettings', 'antivirus'));
     $temp->add(new admin_setting_manageantiviruses());
+
+    // Common settings.
+    $temp->add(new admin_setting_heading('antiviruscommonsettings', new lang_string('antiviruscommonsettings', 'antivirus'), ''));
+
+    // Alert email.
+    $temp->add(
+        new admin_setting_configtext(
+            'antivirus/notifyemail',
+            new lang_string('notifyemail', 'antivirus'),
+            new lang_string('notifyemail_help', 'antivirus'),
+            '',
+            PARAM_EMAIL
+        )
+    );
+
+    // Enable quarantine.
+    $temp->add(
+        new admin_setting_configcheckbox(
+            'antivirus/enablequarantine',
+            new lang_string('enablequarantine', 'antivirus'),
+            new lang_string('enablequarantine_help', 'antivirus',
+            \core\antivirus\quarantine::DEFAULT_QUARANTINE_FOLDER),
+            0
+        )
+    );
+
+    // Quarantine time.
+    $temp->add(
+        new admin_setting_configduration(
+            'antivirus/quarantinetime',
+            new lang_string('quarantinetime', 'antivirus'),
+            new lang_string('quarantinetime_desc', 'antivirus'),
+            \core\antivirus\quarantine::DEFAULT_QUARANTINE_TIME
+        )
+    );
+
     $ADMIN->add('antivirussettings', $temp);
     $plugins = core_plugin_manager::instance()->get_plugins_of_type('antivirus');
     core_collator::asort_objects_by_property($plugins, 'displayname');
index 63d33fe..54d9c5b 100644 (file)
@@ -135,7 +135,7 @@ $string['effectiveretentionperioduser'] = '{$a} (since the last time the user ac
 $string['emailsalutation'] = 'Dear {$a},';
 $string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
 $string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
-$string['errorinvalidrequestcomments'] = 'Please ensure your comment contains plain text only.';
+$string['errorinvalidrequestcomments'] = 'The comments field may contain plain text only.';
 $string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
 $string['errorinvalidrequesttype'] = 'Invalid request type!';
index abaf3e2..cf9126a 100644 (file)
@@ -11,5 +11,5 @@ information provided here is intended especially for developers.
 
 === 3.6 ===
 
-* The legacy log store is in its first stage of deprecation and is due for removal in Moodle 4.0. Please use one of
+* The legacy log store is in its first stage of deprecation and is due for removal in Moodle 3.10. Please use one of
   the other log stores such as "standard" and "database".
index f765213..bb57d22 100644 (file)
@@ -12,8 +12,8 @@ Feature: See the competencies for an activity on the course competencies page.
       | Test-Comp1 | ID-FW1 |
       | Test-Comp2 | ID-FW1 |
     Given the following "courses" exist:
-      | shortname | fullname   |
-      | C1        | Course 1 |
+      | shortname | fullname   | enablecompletion |
+      | C1        | Course 1   | 1                |
     And the following "users" exist:
       | username | firstname | lastname | email |
       | student1 | Student | 1 | student1@example.com |
@@ -21,9 +21,9 @@ Feature: See the competencies for an activity on the course competencies page.
       | user | course | role |
       | student1 | C1 | student |
     And the following "activities" exist:
-      | activity | name       | intro      | course | idnumber |
-      | page     | PageName1  | PageDesc1  | C1     | PAGE1    |
-      | page     | PageName2  | PageDesc2  | C1     | PAGE2    |
+      | activity | name       | intro      | course | idnumber | completion | completionview |
+      | page     | PageName1  | PageDesc1  | C1     | PAGE1    | 1          | 1              |
+      | page     | PageName2  | PageDesc2  | C1     | PAGE2    | 1          | 1              |
     And I log in as "admin"
     And I am on site homepage
     And I follow "Course 1"
@@ -61,3 +61,15 @@ Feature: See the competencies for an activity on the course competencies page.
     And I should not see "Test-Comp1"
     And I should not see "Test-Comp2"
     And I should see "No competencies have been linked to this activity or resource."
+
+  @javascript
+  Scenario: None course competencies page.
+    When I log in as "student1"
+    And I am on site homepage
+    And I follow "Course 1"
+    And I follow "PageName1"
+    Then I should see "Test page content"
+    And I am on site homepage
+    And I follow "Course 1"
+    And I follow "PageName1"
+    Then I should see "Test page content"
index 5ebb83a..8397ac4 100644 (file)
@@ -61,10 +61,10 @@ Feature: Authentication
     # The following tests are all provided to ensure that the accessibility tests themselves are tested.
     # In normal tests only one of the following is required.
     Then the page should meet accessibility standards
-    And the page should meet "wcag131, wcag412" accessibility standards
-    And the page should meet accessibility standards with "wcag131, wcag412" extra tests
+    And the page should meet "wcag131, wcag141, wcag412" accessibility standards
+    And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
 
     And I follow "Log in"
     And the page should meet accessibility standards
-    And the page should meet "wcag131, wcag412" accessibility standards
-    And the page should meet accessibility standards with "wcag131, wcag412" extra tests
+    And the page should meet "wcag131, wcag141, wcag412" accessibility standards
+    And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
index 7563077..d92a97e 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /cache/stores/* - cache store plugins.
 Information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * The function supports_recursion() from the lock_factory interface has been deprecated including the related implementations.
 * The function extend_lock() from the lock_factory interface has been deprecated without replacement including the related
   implementations.
index 5e9f641..35fda4d 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /calendar/* ,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * The core_calendar\local\event\value_objects\times_interface class now has new method get_usermidnight_time() which
   returns the user midnight time for a given event.
 
index f611f98..0341bf9 100644 (file)
@@ -154,7 +154,7 @@ foreach($cohorts['cohorts'] as $cohort) {
         $cohortmanager = has_capability('moodle/cohort:manage', $cohortcontext);
         $cohortcanassign = has_capability('moodle/cohort:assign', $cohortcontext);
 
-        $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url());
+        $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url(false));
         $showhideurl = new moodle_url('/cohort/edit.php', $urlparams + array('sesskey' => sesskey()));
         if ($cohortmanager) {
             if ($cohort->visible) {
index ba22662..f5df09e 100644 (file)
@@ -4660,6 +4660,9 @@ class api {
                 $recommend = false;
                 $strdesc = 'evidence_coursemodulecompleted';
 
+                if ($outcome == course_module_competency::OUTCOME_NONE) {
+                    continue;
+                }
                 if ($outcome == course_module_competency::OUTCOME_EVIDENCE) {
                     $action = evidence::ACTION_LOG;
 
@@ -4720,6 +4723,9 @@ class api {
             $recommend = false;
             $strdesc = 'evidence_coursecompleted';
 
+            if ($outcome == course_module_competency::OUTCOME_NONE) {
+                continue;
+            }
             if ($outcome == course_competency::OUTCOME_EVIDENCE) {
                 $action = evidence::ACTION_LOG;
 
index 16e84d3..4af9f85 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /course/*,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 
 * The function make_categories_options() has now been deprecated. Please use \core_course_category::make_categories_list() instead.
 
index 7a8be2d..d3621a9 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /grade/grading/form/* - Advanced grading methods
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 
 * Removed gradingform_provider.
 * Removed the following deprecated functions:
index 6cd3484..cab3476 100644 (file)
@@ -164,6 +164,7 @@ class api {
             unset($library->major_version);
             $library->minorVersion = (int) $library->minorversion;
             unset($library->minorversion);
+            $library->metadataSettings = json_decode($library->metadatasettings);
 
             // If we already add this library means that it is an old version,as the previous query was sorted by version.
             if (isset($added[$library->name])) {
index c4b575c..8d7f9b4 100644 (file)
@@ -228,7 +228,7 @@ class editor_framework implements H5peditorStorage {
         if ($libraries !== null) {
             // Get details for the specified libraries.
             $librariesin = [];
-            $fields = 'title, runnable';
+            $fields = 'title, runnable, metadatasettings';
 
             foreach ($libraries as $library) {
                 $params = [
@@ -242,11 +242,12 @@ class editor_framework implements H5peditorStorage {
                 if ($details) {
                     $library->title = $details->title;
                     $library->runnable = $details->runnable;
+                    $library->metadataSettings = json_decode($details->metadatasettings);
                     $librariesin[] = $library;
                 }
             }
         } else {
-            $fields = 'id, machinename as name, title, majorversion, minorversion';
+            $fields = 'id, machinename as name, title, majorversion, minorversion, metadatasettings';
             $librariesin = api::get_contenttype_libraries($fields);
         }
 
index 2e4a2ca..4b05422 100644 (file)
@@ -685,6 +685,9 @@ class framework implements \H5PFrameworkInterface {
      *                           - dropLibraryCss(optional): list of associative arrays containing:
      *                             - machineName: machine name for the librarys that are to drop their css
      *                           - semantics(optional): Json describing the content structure for the library
+     *                           - metadataSettings(optional): object containing:
+     *                             - disable: 1 if metadata is disabled completely
+     *                             - disableExtraTitleField: 1 if the title field is hidden in the form
      * @param bool $new Whether it is a new or existing library.
      */
     public function saveLibraryData(&$librarydata, $new = true) {
@@ -722,6 +725,7 @@ class framework implements \H5PFrameworkInterface {
             'addto' => isset($librarydata['addTo']) ? json_encode($librarydata['addTo']) : null,
             'coremajor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['majorVersion'] : null,
             'coreminor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['minorVersion'] : null,
+            'metadatasettings' => isset($librarydata['metadataSettings']) ? $librarydata['metadataSettings'] : null,
         );
 
         if ($new) {
index 478ab57..dc266f5 100644 (file)
@@ -246,6 +246,7 @@ class generator_testcase extends \advanced_testcase {
             'addto' => '/regex11/',
             'coremajor' => null,
             'coreminor' => null,
+            'metadatasettings' => null,
         ];
 
         $this->assertEquals($expected, $data);
index 59eb0cb..ee5271c 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * Added a new cache for h5p_library_files (MDL-69207)
 
 === 3.9 ===
index 2066a80..f93e14c 100644 (file)
@@ -228,7 +228,7 @@ $string['configeditorfontlist'] = 'Select the fonts that should appear in the ed
 $string['configemailchangeconfirmation'] = 'Require an email confirmation step when users change their email address in their profile.';
 $string['configemailfromvia'] = 'Add via information in the "From" section of outgoing email. This informs the recipient from where this email came from and also helps combat recipients accidentally replying to no-reply email addresses.';
 $string['configemailsubjectprefix'] = 'Text to be prefixed to the subject line of all outgoing mail.';
-$string['configemailheaders'] = 'Raw email headers to be added verbatum to all outgoing email.';
+$string['configemailheaders'] = 'Raw email headers to be added verbatim to all outgoing email.';
 $string['configenablecalendarexport'] = 'Enable exporting or subscribing to calendars.';
 $string['configenablecomments'] = 'Enable comments';
 $string['configenablecourserequests'] = 'If enabled, users with the capability to request new courses (moodle/course:request) will have the option to request a course. This capability is not allowed for any of the default roles. It may be applied in the system or category context.';
@@ -469,7 +469,7 @@ $string['debugsqltrace'] = 'Show origin of SQL calls';
 $string['debugsqltrace1'] = 'Show only a single calling line';
 $string['debugsqltrace2'] = 'Show 2 lines of stack trace';
 $string['debugsqltrace100'] = 'Show full stack trace';
-$string['debugsqltrace_desc'] = 'If enabled adds either partial or full PHP stacktrace into the SQL as a comment';
+$string['debugsqltrace_desc'] = 'If enabled, a partial or full PHP stack trace is added into the SQL as a comment.';
 $string['debugstringids'] = 'Show origin of languages strings';
 $string['debugstringids_desc'] = 'If enabled, language string components and identifiers are displayed when ?strings=1 or &strings=1 is appended to the page URL.';
 $string['debugvalidators'] = 'Show validator links';
@@ -1125,7 +1125,7 @@ $string['searchincludeallcourses'] = 'Include all visible courses';
 $string['searchincludeallcourses_desc'] = 'If enabled, search results will include course information (name and summary) of courses which are visible to the user, even if they don\'t have access to the course content.';
 $string['searchalldeleted'] = 'All indexed contents have been deleted';
 $string['searchbannerenable'] = 'Display search information';
-$string['searchbannerenable_desc'] = 'If enabled, the below text will display at the top of the search screen for all users. This can be used to keep users informed while maintenance is being carried out on the search system.';
+$string['searchbannerenable_desc'] = 'If enabled, the text below will be displayed at the top of the search screen for all users. This can be used to inform users when search engine maintenance is being carried out.';
 $string['searchbanner'] = 'Search information';
 $string['searchareaenabled'] = 'Search area enabled';
 $string['searchareadisabled'] = 'Search area disabled';
index 634b0c7..907bf31 100644 (file)
 
 $string['actantivirushdr'] = 'Available antivirus plugins';
 $string['antiviruses'] = 'Antivirus plugins';
+$string['antiviruscommonsettings'] = 'Common antivirus settings';
 $string['antivirussettings'] = 'Manage antivirus plugins';
 $string['configantivirusplugins'] = 'Please choose the antivirus plugins you wish to use and arrange them in order of being applied.';
 $string['datastream'] = 'Data';
+$string['datainfecteddesc'] = 'Infected data was detected.';
+$string['datainfectedname'] = 'Data infected';
+$string['emailadditionalinfo'] = 'Additional details returned from the virus engine: ';
+$string['emailauthor'] = 'Uploaded by: ';
+$string['emailcontenthash'] = 'Content hash: ';
+$string['emailcontenttype'] = 'Content type: ';
+$string['emaildate'] = 'Date uploaded: ';
+$string['emailfilename'] = 'Filename: ';
+$string['emailfilesize'] = 'File size: ';
+$string['emailgeoinfo'] = 'Geolocation: ';
+$string['emailinfectedfiledetected'] = 'Infected file detected';
+$string['emailipaddress'] = 'IP Address: ';
+$string['emailreferer'] = 'Referer: ';
+$string['emailreport'] = 'Report: ';
+$string['emailscanner'] = 'Scanner: ';
+$string['emailscannererrordetected'] = 'A scanner error occured';
 $string['emailsubject'] = '{$a} :: Antivirus notification';
+$string['enablequarantine'] = 'Enable quarantine';
+$string['enablequarantine_help'] = 'When quarantine is enabled, any files which are detected as viruses will be kept in a quarantine folder for later inspection ([dataroot]/{$a}).
+The upload into Moodle will still fail.
+If you have any file system level virus scanning in place, the quarantine folder should be excluded from the antivirus check to avoid detecting the quarantined files.';
+$string['fileinfecteddesc'] = 'An infected file was detected.';
+$string['fileinfectedname'] = 'File infected';
+$string['notifyemail'] = 'Antivirus alert email';
+$string['notifyemail_help'] = 'If set, then only the specified email will be notified when a virus is detected.
+If blank, then all site admins will be notified by email when a virus is detected.';
 $string['privacy:metadata'] = 'The Antivirus system does not store any personal data.';
+$string['quarantinedisabled'] = 'Quarantine disabled, file not stored.';
+$string['quarantinedfiles'] = 'Antivirus quarantined files';
+$string['quarantinetime'] = 'Maximum quarantine time';
+$string['quarantinetime_desc'] = 'Quarantined files older than specified period will be removed.';
+$string['taskcleanup'] = 'Clean up quarantined files.';
+$string['unknown'] = 'Unknown';
 $string['virusfound'] = '{$a->item} has been scanned by a virus checker and found to be infected!';
index 4ff7fce..4bf99b4 100644 (file)
@@ -200,6 +200,7 @@ $string['errorinvalidformat'] = 'Unknown backup format';
 $string['errorinvalidformatinfo'] = 'The selected file is not a valid Moodle backup file and can\'t be restored.';
 $string['errorrestorefrontpagebackup'] = 'You can only restore front page backups on the front page';
 $string['executionsuccess'] = 'The backup file was successfully created.';
+$string['extractingbackupfileto'] = 'Extracting backup file to: {$a}';
 $string['failed'] = 'Backup failed';
 $string['filename'] = 'Filename';
 $string['filealiasesrestorefailures'] = 'Aliases restore failures';
@@ -302,6 +303,7 @@ $string['questionegorycannotberestored'] = 'The questions "{$a->name}" cannot be
 $string['restoreactivity'] = 'Restore activity';
 $string['restorecourse'] = 'Restore course';
 $string['restorecoursesettings'] = 'Course settings';
+$string['restoredcourseid'] = 'Restored course id: {$a}';
 $string['restoreexecutionsuccess'] = 'The course was restored successfully, clicking the continue button below will take you to view the course you restored.';
 $string['restorefileweremissing'] = 'Some files could not be restored because they were missing in the backup.';
 $string['restorenewcoursefullname'] = 'New course name';
index f0343ee..62e4b4f 100644 (file)
@@ -304,7 +304,7 @@ $string['error:invalidexpiredate'] = 'Expiry date has to be in the future.';
 $string['error:invalidexpireperiod'] = 'Expiry period cannot be negative or equal 0.';
 $string['error:invalidparambadge'] = 'Badge does not exist. ';
 $string['error:noactivities'] = 'There are no activities with completion criteria enabled in this course.';
-$string['error:nobadges'] = 'There are no course or site badges with access enabled to be added as criteria.';
+$string['error:nobadges'] = 'There are currently no badges with access enabled to be added as criteria. A site badge can only have other site badges as criteria. A course badge can have other course badges or site badges as criteria.';
 $string['error:invalidparamcohort'] = 'Cohort does not exist. ';
 $string['error:noactivities'] = 'There are no activities with completion criteria enabled in this course.';
 $string['error:nocohorts'] = 'No cohorts';
index 6411ece..f614161 100644 (file)
@@ -40,7 +40,7 @@ $string['eventcontentupdated'] = 'Content updated';
 $string['eventcontentuploaded'] = 'Content uploaded';
 $string['eventcontentviewed'] = 'Content viewed';
 $string['errordeletingcontentfromcategory'] = 'Error deleting content from category {$a}.';
-$string['errornofile'] = 'A compatible file is needed to create a content';
+$string['errornofile'] = 'A compatible file is needed to create content.';
 $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['displaydetails'] = 'Display content bank with file details';
index 24d2c03..9d7065e 100644 (file)
@@ -1240,6 +1240,7 @@ $string['messageprovider:gradenotifications'] = 'Grade notifications';
 $string['messageprovider:messagecontactrequests'] = 'Message contact requests notification';
 $string['messageprovider:notices'] = 'Notices about minor problems';
 $string['messageprovider:notices_help'] = 'These are notices that an administrator might be interested in seeing.';
+$string['messageprovider:infected'] = 'Antivirus failure notifications.';
 $string['messageprovider:insights'] = 'Insights generated by prediction models';
 $string['messageprovider:instantmessage'] = 'Personal messages between users';
 $string['messageprovider:instantmessage_help'] = 'This section configures what happens to messages that are sent to you directly from other users on this site.';
index 047cb49..7242743 100644 (file)
@@ -228,6 +228,9 @@ class scanner extends \core\antivirus\scanner {
             $notice .= "\n\n". implode("\n", $output);
             $this->set_scanning_notice($notice);
             return self::SCAN_RESULT_ERROR;
+        } else {
+            $notice = "\n\n". implode("\n", $output);
+            $this->set_scanning_notice($notice);
         }
 
         return (int)$return;
@@ -384,6 +387,8 @@ class scanner extends \core\antivirus\scanner {
             $parts = explode(' ', $message);
             $status = array_pop($parts);
             if ($status === 'FOUND') {
+                $notice = "\n\n" . $output;
+                $this->set_scanning_notice($notice);
                 return self::SCAN_RESULT_FOUND;
             } else {
                 $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code(2));
index 0f78513..d17c42e 100644 (file)
@@ -67,15 +67,50 @@ class manager {
      * @return void
      */
     public static function scan_file($file, $filename, $deleteinfected) {
+        global $USER;
         $antiviruses = self::get_enabled();
         foreach ($antiviruses as $antivirus) {
-            $result = $antivirus->scan_file($file, $filename);
+            // Attempt to scan, catching internal exceptions.
+            try {
+                $result = $antivirus->scan_file($file, $filename);
+            } catch (\core\antivirus\scanner_exception $e) {
+                // If there was a scanner exception (such as ClamAV denying upload), send messages and rethrow.
+                $notice = $antivirus->get_scanning_notice();
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+                throw $e;
+            }
+
+            $notice = $antivirus->get_scanning_notice();
             if ($result === $antivirus::SCAN_RESULT_FOUND) {
-                // Infection found.
+                // Infection found, send notification.
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                // Move to quarantine folder.
+                $zipfile = \core\antivirus\quarantine::quarantine_file($file, $filename, $incidentdetails, $notice);
+                // If file not stored due to disabled quarantine, store a message.
+                if (empty($zipfile)) {
+                    $zipfile = get_string('quarantinedisabled', 'antivirus');
+                }
+
+                // Log file infected event.
+                $params = [
+                    'context' => \context_system::instance(),
+                    'relateduserid' => $USER->id,
+                    'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidentdetails' => $incidentdetails],
+                ];
+                $event = \core\event\virus_infected_file_detected::create($params);
+                $event->trigger();
+
                 if ($deleteinfected) {
                     unlink($file);
                 }
                 throw new \core\antivirus\scanner_exception('virusfound', '', array('item' => $filename));
+            } else if ($result === $antivirus::SCAN_RESULT_ERROR) {
+                // Here we need to generate a different incident based on an error.
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
             }
         }
     }
@@ -83,16 +118,56 @@ class manager {
     /**
      * Scan data steam using all enabled antiviruses, throws exception in case of infected data.
      *
-     * @param string $data The varaible containing the data to scan.
+     * @param string $data The variable containing the data to scan.
      * @throws \core\antivirus\scanner_exception If data is infected.
      * @return void
      */
     public static function scan_data($data) {
+        global $USER;
         $antiviruses = self::get_enabled();
         foreach ($antiviruses as $antivirus) {
-            $result = $antivirus->scan_data($data);
+            // Attempt to scan, catching internal exceptions.
+            try {
+                $result = $antivirus->scan_data($data);
+            } catch (\core\antivirus\scanner_exception $e) {
+                // If there was a scanner exception (such as ClamAV denying upload), send messages and rethrow.
+                $notice = $antivirus->get_scanning_notice();
+                $filename = get_string('datastream', 'antivirus');
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                throw $e;
+            }
+
+            $filename = get_string('datastream', 'antivirus');
+            $notice = $antivirus->get_scanning_notice();
+
             if ($result === $antivirus::SCAN_RESULT_FOUND) {
+                // Infection found, send notification.
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                // Copy data to quarantine folder.
+                $zipfile = \core\antivirus\quarantine::quarantine_data($data, $filename, $incidentdetails, $notice);
+                // If file not stored due to disabled quarantine, store a message.
+                if (empty($zipfile)) {
+                    $zipfile = get_string('quarantinedisabled', 'antivirus');
+                }
+
+                // Log file infected event.
+                $params = [
+                    'context' => \context_system::instance(),
+                    'relateduserid' => $USER->id,
+                    'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidentdetails' => $incidentdetails],
+                ];
+                $event = \core\event\virus_infected_data_detected::create($params);
+                $event->trigger();
+
                 throw new \core\antivirus\scanner_exception('virusfound', '', array('item' => get_string('datastream', 'antivirus')));
+            } else if ($result === $antivirus::SCAN_RESULT_ERROR) {
+                // Here we need to generate a different incident based on an error.
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
             }
         }
     }
@@ -125,4 +200,53 @@ class manager {
         }
         return $antiviruses;
     }
+
+    /**
+     * This function puts all relevant information into the messages required, and sends them.
+     *
+     * @param \core\antivirus\scanner $antivirus the scanner engine.
+     * @param string $incidentdetails details of the incident.
+     * @return void
+     */
+    public static function send_antivirus_messages(\core\antivirus\scanner $antivirus, string $incidentdetails) {
+        $messages = $antivirus->get_messages();
+
+        // If there is no messages, and a virus is found, we should generate one, then send it.
+        if (empty($messages)) {
+            $antivirus->message_admins($antivirus->get_scanning_notice(), FORMAT_MOODLE, 'infected');
+            $messages = $antivirus->get_messages();
+        }
+
+        foreach ($messages as $message) {
+
+            // Check if the information is already in the current scanning notice.
+            if (!empty($antivirus->get_scanning_notice()) &&
+                strpos($antivirus->get_scanning_notice(), $message->fullmessage) === false) {
+                // This is some extra information. We should append this to the end of the incident details.
+                $incidentdetails .= \html_writer::tag('pre', $message->fullmessage);
+            }
+
+            // Now update the message to the detailed version, and format.
+            $message->name = 'infected';
+            $message->fullmessagehtml = $incidentdetails;
+            $message->fullmessageformat = FORMAT_MOODLE;
+            $message->fullmessage = format_text_email($incidentdetails, $message->fullmessageformat);
+
+            // Now we must check if message is going to a real account.
+            // It may be an email that needs to be sent to non-user address.
+            if ($message->userto->id === -1) {
+                // If this doesnt exist, send a regular email.
+                email_to_user(
+                    $message->userto,
+                    get_admin(),
+                    $message->subject,
+                    $message->fullmessage,
+                    $message->fullmessagehtml
+                );
+            } else {
+                // And now we can send.
+                message_send($message);
+            }
+        }
+    }
 }
diff --git a/lib/classes/antivirus/quarantine.php b/lib/classes/antivirus/quarantine.php
new file mode 100644 (file)
index 0000000..e3136ff
--- /dev/null
@@ -0,0 +1,326 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Quarantine file
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\antivirus;
+
+defined('MOODLE_INTERNAL') || die();
+require_once($CFG->libdir.'/filelib.php');
+
+/**
+ * Quarantine file
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class quarantine {
+
+    /** Default quarantine folder */
+    const DEFAULT_QUARANTINE_FOLDER = 'antivirus_quarantine';
+
+    /** Zip infected file  */
+    const FILE_ZIP_INFECTED = '_infected_file.zip';
+
+    /** Zip all infected file */
+    const FILE_ZIP_ALL_INFECTED = '_all_infected_files.zip';
+
+    /** Incident details file */
+    const FILE_HTML_DETAILS = '_details.html';
+
+    /** Incident details file */
+    const DEFAULT_QUARANTINE_TIME = DAYSECS * 28;
+
+    /** Date format in filename */
+    const FILE_NAME_DATE_FORMAT = '%Y%m%d%H%M%S';
+
+    /**
+     * Move the infected file to the quarantine folder.
+     *
+     * @param string $file infected file.
+     * @param string $filename infected file name.
+     * @param string $incidentdetails incident details.
+     * @param string $notice notice details.
+     * @return string|null the name of the newly created quarantined file.
+     * @throws \dml_exception
+     */
+    public static function quarantine_file(string $file, string $filename, string $incidentdetails, string $notice) : ?string {
+        if (!self::is_quarantine_enabled()) {
+            return null;
+        }
+        // Generate file names.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
+        $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
+        $detailsfilename = $date . self::FILE_HTML_DETAILS;
+
+        // Create Zip file.
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
+            $ziparchive->add_file_from_pathname($filename, $file);
+            $ziparchive->close();
+        }
+        $zipfile = basename($zipfilepath);
+        self::create_infected_file_record($filename, $zipfile, $notice);
+        return $zipfile;
+    }
+
+    /**
+     * Move the infected file to the quarantine folder.
+     *
+     * @param string $data data which is infected.
+     * @param string $filename infected file name.
+     * @param string $incidentdetails incident details.
+     * @param string $notice notice details.
+     * @return string|null the name of the newly created quarantined file.
+     * @throws \dml_exception
+     */
+    public static function quarantine_data(string $data, string $filename, string $incidentdetails, string $notice) : ?string {
+        if (!self::is_quarantine_enabled()) {
+            return null;
+        }
+        // Generate file names.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
+        $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
+        $detailsfilename = $date . self::FILE_HTML_DETAILS;
+
+        // Create Zip file.
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
+            $ziparchive->add_file_from_string($filename, $data);
+            $ziparchive->close();
+        }
+        $zipfile = basename($zipfilepath);
+        self::create_infected_file_record($filename, $zipfile, $notice);
+        return $zipfile;
+    }
+
+    /**
+     * Check if the virus quarantine is allowed
+     *
+     * @return bool
+     * @throws \dml_exception
+     */
+    public static function is_quarantine_enabled() : bool {
+        return !empty(get_config("antivirus", "enablequarantine"));
+    }
+
+    /**
+     * Get quarantine folder
+     *
+     * @return string path of quarantine folder
+     */
+    private static function get_quarantine_folder() : string {
+        global $CFG;
+        $quarantinefolder = $CFG->dataroot . DIRECTORY_SEPARATOR . self::DEFAULT_QUARANTINE_FOLDER;
+        if (!file_exists($quarantinefolder)) {
+            make_upload_directory(self::DEFAULT_QUARANTINE_FOLDER);
+        }
+        return $quarantinefolder . DIRECTORY_SEPARATOR;
+    }
+
+    /**
+     * Checks whether a file exists inside the antivirus quarantine folder.
+     *
+     * @param string $filename the filename to check.
+     * @return boolean whether file exists.
+     */
+    public static function quarantined_file_exists(string $filename) : bool {
+        $folder = self::get_quarantine_folder();
+        return file_exists($folder . $filename);
+    }
+
+    /**
+     * Download quarantined file.
+     *
+     * @param int $fileid the id of file to be downloaded.
+     */
+    public static function download_quarantined_file(int $fileid) {
+        global $DB;
+
+        // Get the filename to be downloaded.
+        $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
+        // If file record isnt found, user might be doing something naughty in params, or a stale request.
+        if (empty($filename)) {
+            return;
+        }
+
+        $file = self::get_quarantine_folder() . $filename;
+        send_file($file, $filename);
+    }
+
+    /**
+     * Delete quarantined file.
+     *
+     * @param int $fileid id of file to be deleted.
+     */
+    public static function delete_quarantined_file(int $fileid) {
+        global $DB;
+
+        // Get the filename to be deleted.
+        $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
+        // If file record isnt found, user might be doing something naughty in params, or a stale request.
+        if (empty($filename)) {
+            return;
+        }
+
+        // Delete the file from the folder.
+        $file = self::get_quarantine_folder() . $filename;
+        if (file_exists($file)) {
+            unlink($file);
+        }
+
+        // Now we are finished with the record, delete the quarantine information.
+        self::delete_infected_file_record($fileid);
+    }
+
+    /**
+     * Download all quarantined files.
+     *
+     * @return void
+     */
+    public static function download_all_quarantined_files() {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        // Add all infected files to a zip file.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT);
+        $zipfilename = $date . self::FILE_ZIP_ALL_INFECTED;
+        $zipfilepath = self::get_quarantine_folder() . DIRECTORY_SEPARATOR . $zipfilename;
+        $tempfilestocleanup = [];
+
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            foreach ($files as $file) {
+                if (!$file->isDot()) {
+                    // Only send the actual files.
+                    $filename = $file->getFilename();
+                    $filepath = $file->getPathname();
+                    $ziparchive->add_file_from_pathname($filename, $filepath);
+                }
+            }
+            $ziparchive->close();
+        }
+
+        // Clean up temp files.
+        foreach ($tempfilestocleanup as $tempfile) {
+            if (file_exists($tempfile)) {
+                unlink($tempfile);
+            }
+        }
+
+        send_temp_file($zipfilepath, $zipfilename);
+    }
+
+    /**
+     * Return array of quarantined files.
+     *
+     * @return array list of quarantined files.
+     */
+    public static function get_quarantined_files() : array {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        $filestosort = [];
+
+        // Grab all files that match the naming structure.
+        foreach ($files as $file) {
+            $filename = $file->getFilename();
+            if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
+                $filestosort[$filename] = $file->getPathname();
+            }
+        }
+
+        krsort($filestosort, SORT_NATURAL);
+        return $filestosort;
+    }
+
+    /**
+     * Clean up quarantine folder
+     *
+     * @param int $timetocleanup time to clean up
+     */
+    public static function clean_up_quarantine_folder(int $timetocleanup) {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        // Clean up the folder.
+        foreach ($files as $file) {
+            $filename = $file->getFilename();
+
+            // Only delete files that match the correct name structure.
+            if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
+                $modifiedtime = $file->getMTime();
+
+                if ($modifiedtime <= $timetocleanup) {
+                    unlink($file->getPathname());
+                }
+            }
+        }
+
+        // Lastly cleanup the infected files table as well.
+        self::clean_up_infected_records($timetocleanup);
+    }
+
+    /**
+     * This function removes any stale records from the infected files table.
+     *
+     * @param int $timetocleanup the time to cleanup from
+     * @return void
+     */
+    private static function clean_up_infected_records(int $timetocleanup) {
+        global $DB;
+
+        $select = "timecreated <= ?";
+        $DB->delete_records_select('infected_files', $select, [$timetocleanup]);
+    }
+
+    /**
+     * Create an infected file record
+     *
+     * @param string $filename original file name
+     * @param string $zipfile quarantined file name
+     * @param string $reason failure reason
+     * @throws \dml_exception
+     */
+    private static function create_infected_file_record(string $filename, string $zipfile, string $reason) {
+        global $DB, $USER;
+
+        $record = new \stdClass();
+        $record->filename = $filename;
+        $record->quarantinedfile = $zipfile;
+        $record->userid = $USER->id;
+        $record->reason = $reason;
+        $record->timecreated = time();
+
+        $DB->insert_record('infected_files', $record);
+    }
+
+    /**
+     * Delete the database record for an infected file.
+     *
+     * @param int $fileid quarantined file id
+     * @throws \dml_exception
+     */
+    private static function delete_infected_file_record(int $fileid) {
+        global $DB;
+        $DB->delete_records('infected_files', ['id' => $fileid]);
+    }
+}
index d42185c..462684f 100644 (file)
@@ -25,6 +25,7 @@
 namespace core\antivirus;
 
 defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '../../../../iplookup/lib.php');
 
 /**
  * Base abstract antivirus scanner class.
@@ -46,6 +47,8 @@ abstract class scanner {
     protected $config;
     /** @var string scanning notice */
     protected $scanningnotice = '';
+    /** @var array any admin messages generated by a plugin. */
+    protected $messages = [];
 
     /**
      * Class constructor.
@@ -130,30 +133,109 @@ abstract class scanner {
     }
 
     /**
-     * Email admins about antivirus scan outcomes.
+     * This function pushes given messages into the message queue, which will be sent by the antivirus manager.
      *
      * @param string $notice The body of the email to be sent.
+     * @param string $format The body format.
+     * @param string $eventname event name
      * @return void
+     * @throws \coding_exception
+     * @throws \moodle_exception
      */
-    public function message_admins($notice) {
-
+    public function message_admins($notice, $format = FORMAT_PLAIN, $eventname = 'errors') {
+        $noticehtml = $format !== FORMAT_PLAIN ? format_text($notice, $format) : '';
         $site = get_site();
 
         $subject = get_string('emailsubject', 'antivirus', format_string($site->fullname));
-        $admins = get_admins();
+        $notifyemail = get_config('antivirus', 'notifyemail');
+        // If one email address is specified, construct a message to fake account.
+        if (!empty($notifyemail)) {
+            $user = new \stdClass();
+            $user->id = -1;
+            $user->email = $notifyemail;
+            $user->mailformat = 1;
+            $admins = [$user];
+        } else {
+            // Otherwise, we message all admins.
+            $admins = get_admins();
+        }
+
         foreach ($admins as $admin) {
             $eventdata = new \core\message\message();
             $eventdata->courseid          = SITEID;
             $eventdata->component         = 'moodle';
-            $eventdata->name              = 'errors';
+            $eventdata->name              = $eventname;
             $eventdata->userfrom          = get_admin();
             $eventdata->userto            = $admin;
             $eventdata->subject           = $subject;
             $eventdata->fullmessage       = $notice;
-            $eventdata->fullmessageformat = FORMAT_PLAIN;
-            $eventdata->fullmessagehtml   = '';
+            $eventdata->fullmessageformat = $format;
+            $eventdata->fullmessagehtml   = $noticehtml;
             $eventdata->smallmessage      = '';
-            message_send($eventdata);
+
+            // Now add the message to an array to be sent by the antivirus manager.
+            $this->messages[] = $eventdata;
         }
     }
-}
\ No newline at end of file
+
+    /**
+     * Return incident details
+     *
+     * @param string $file full path to the file
+     * @param string $filename original name of the file
+     * @param string $notice notice from antivirus
+     * @param string $virus if this template is due to a virus found.
+     * @return string the incident details
+     * @throws \coding_exception
+     */
+    public function get_incident_details($file = '', $filename = '', $notice = '', $virus = true) {
+        global $OUTPUT, $USER;
+        if (empty($notice)) {
+            $notice = $this->get_scanning_notice();
+        }
+        $classname = get_class($this);
+        $component = explode('\\', $classname)[0];
+
+        $content = new \stdClass();
+        $unknown = get_string('unknown', 'antivirus');
+        $content->header = get_string('emailinfectedfiledetected', 'antivirus');
+        $content->filename = !empty($filename) ? $filename : $unknown;
+        $content->scanner = $component;
+        // Check for empty file, or file not uploaded.
+        if (!empty($file) && filesize($file) !== false) {
+            $content->filesize = display_size(filesize($file));
+            $content->contenthash = \file_storage::hash_from_string(file_get_contents($file));
+            $content->contenttype = mime_content_type($file);
+        } else {
+            $content->filesize = $unknown;
+            $content->contenthash = $unknown;
+            $content->contenttype = $unknown;
+        }
+
+        $content->author = \core_user::is_real_user($USER->id) ? fullname($USER) . " ($USER->username)" : $unknown;
+        $content->ipaddress = getremoteaddr();
+        $geoinfo = iplookup_find_location(getremoteaddr());
+        $content->geoinfo = $geoinfo['city'] . ', ' . $geoinfo['country'];
+        $content->date = userdate(time(), get_string('strftimedatetimeshort'));
+        $content->referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : $unknown;
+        $content->notice = $notice;
+        $report = new \moodle_url('/report/infectedfiles/index.php');
+        $content->report = $report->out();
+
+        // If this is not due to a virus, we need to change the header line.
+        if (!$virus) {
+            $content->header = get_string('emailscannererrordetected', 'antivirus');
+        }
+
+        return $OUTPUT->render_from_template('core/infected_file_email', $content);
+    }
+
+    /**
+     * Getter method for messages queued by the antivirus scanner.
+     *
+     * @return array
+     */
+    public function get_messages() : array {
+        return $this->messages;
+    }
+}
diff --git a/lib/classes/event/virus_infected_data_detected.php b/lib/classes/event/virus_infected_data_detected.php
new file mode 100644 (file)
index 0000000..66d3fe3
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Data infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Data infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class virus_infected_data_detected extends \core\event\base {
+    /**
+     * Event data
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return event description
+     *
+     * @return string description
+     * @throws \coding_exception
+     */
+    public function get_description() {
+        if (isset($this->other['incidentdetails'])) {
+            return format_text($this->other['incidentdetails'], FORMAT_MOODLE);
+        } else {
+            return get_string('datainfecteddesc', 'antivirus');
+        }
+    }
+
+    /**
+     * Return event name
+     *
+     * @return string name
+     * @throws \coding_exception
+     */
+    public static function get_name() {
+        return get_string('datainfectedname', 'antivirus');
+    }
+
+    /**
+     * Return event report link
+     * @return \moodle_url
+     * @throws \moodle_exception
+     */
+    public function get_url() {
+        return new \moodle_url('/report/infectedfiles/index.php');
+    }
+}
diff --git a/lib/classes/event/virus_infected_file_detected.php b/lib/classes/event/virus_infected_file_detected.php
new file mode 100644 (file)
index 0000000..3acd52a
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Fle infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Fle infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class virus_infected_file_detected extends \core\event\base {
+    /**
+     * Event data
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return event description
+     *
+     * @return string description
+     * @throws \coding_exception
+     */
+    public function get_description() {
+        if (isset($this->other['incidentdetails'])) {
+            return format_text($this->other['incidentdetails'], FORMAT_MOODLE);
+        } else {
+            return get_string('fileinfecteddesc', 'antivirus');
+        }
+    }
+
+    /**
+     * Return event name
+     *
+     * @return string name
+     * @throws \coding_exception
+     */
+    public static function get_name() {
+        return get_string('fileinfectedname', 'antivirus');
+    }
+
+    /**
+     * Return event report link
+     * @return \moodle_url
+     * @throws \moodle_exception
+     */
+    public function get_url() {
+        return new \moodle_url('/report/infectedfiles/index.php');
+    }
+}
index 4d69a12..886b442 100644 (file)
@@ -1966,8 +1966,8 @@ class core_plugin_manager {
 
             'report' => array(
                 'backups', 'competency', 'completion', 'configlog', 'courseoverview', 'eventlist',
-                'insights', 'log', 'loglive', 'outline', 'participation', 'progress', 'questioninstances',
-                'security', 'stats', 'status', 'performance', 'usersessions'
+                'infectedfiles', 'insights', 'log', 'loglive', 'outline', 'participation', 'progress',
+                'questioninstances', 'security', 'stats', 'status', 'performance', 'usersessions'
             ),
 
             'repository' => array(
diff --git a/lib/classes/task/antivirus_cleanup_task.php b/lib/classes/task/antivirus_cleanup_task.php
new file mode 100644 (file)
index 0000000..6a321d3
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Clean up task for core antivirus
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Clean up task for core antivirus
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class antivirus_cleanup_task extends scheduled_task {
+
+    /**
+     * Get a descriptive name for this task.
+     *
+     * @return string
+     */
+    public function get_name() {
+        return get_string('taskcleanup', 'antivirus');
+    }
+
+    /**
+     * Processes workflows.
+     */
+    public function execute() {
+        $quarantinetime = get_config('antivirus', 'quarantinetime');
+        if (empty($quarantinetime)) {
+            $quarantinetime = \core\antivirus\quarantine::DEFAULT_QUARANTINE_TIME;
+            set_config('quarantinetime', $quarantinetime, 'antivirus');
+        }
+        $timetocleanup = time() - $quarantinetime;
+        \core\antivirus\quarantine::clean_up_quarantine_folder($timetocleanup);
+    }
+
+}
index 1df8fbb..6876b98 100644 (file)
@@ -44,24 +44,27 @@ class backup_cleanup_task extends scheduled_task {
     public function execute() {
         global $DB;
 
-        $timenow = time();
-
-        // Delete old backup_controllers and logs.
         $loglifetime = get_config('backup', 'loglifetime');
-        if (!empty($loglifetime)) {  // Value in days.
-            $loglifetime = $timenow - ($loglifetime * 3600 * 24);
-            // Delete child records from backup_logs.
-            $DB->execute("DELETE FROM {backup_logs}
-                           WHERE EXISTS (
-                               SELECT 'x'
-                                 FROM {backup_controllers} bc
-                                WHERE bc.backupid = {backup_logs}.backupid
-                                  AND bc.timecreated < ?)", array($loglifetime));
-            // Delete records from backup_controllers.
-            $DB->execute("DELETE FROM {backup_controllers}
-                          WHERE timecreated < ?", array($loglifetime));
+
+        if (empty($loglifetime)) {
+            throw new coding_exception('The \'loglifetime\' config is not set. Can\'t proceed and delete old backup records.');
         }
 
+        // First, get the list of all backupids older than loglifetime.
+        $timecreated = time() - ($loglifetime * DAYSECS);
+        $records = $DB->get_records_select('backup_controllers', 'timecreated < ?', array($timecreated), 'id', 'id, backupid');
+
+        foreach ($records as $record) {
+            // Check if there is no incomplete adhoc task relying on the given backupid.
+            $params = array('%' . $record->backupid . '%');
+            $select = $DB->sql_like('customdata', '?', false);
+            $count = $DB->count_records_select('task_adhoc',  $select, $params);
+            if ($count === 0) {
+                // Looks like there is no adhoc task, so we can delete logs and controllers for this backupid.
+                $DB->delete_records('backup_logs', array('backupid' => $record->backupid));
+                $DB->delete_records('backup_controllers', array('backupid' => $record->backupid));
+            }
+        }
     }
 
 }
index 31d1bce..43e82bc 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20200707" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20200804" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="addto" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Plugin configuration data"/>
         <FIELD NAME="coremajor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API major version required"/>
         <FIELD NAME="coreminor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API minor version required"/>
+        <FIELD NAME="metadatasettings" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Library metadata settings"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <INDEX NAME="instance" UNIQUE="false" FIELDS="contextid, contenttype, instanceid"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="infected_files" COMMENT="Table to store infected file details.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="filename" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Original file name"/>
+        <FIELD NAME="quarantinedfile" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Quarantine zip file"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The user that uploaded the infected file."/>
+        <FIELD NAME="reason" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="The reason for the antivirus failure"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The time the infected file was uploaded."/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id" COMMENT="Foreign key for the userid"/>
+      </KEYS>
+    </TABLE>
   </TABLES>
 </XMLDB>
\ No newline at end of file
index b892988..eeef52e 100644 (file)
@@ -142,4 +142,9 @@ $messageproviders = array (
             'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
         ),
     ],
+
+    // Infected files.
+    'infected' => array(
+        'capability'  => 'moodle/site:config',
+    ),
 );
index 171d0a5..9c473ea 100644 (file)
@@ -401,4 +401,13 @@ $tasks = array(
         'dayofweek' => '*',
         'month' => '*'
     ),
+    array(
+        'classname' => 'core\task\antivirus_cleanup_task',
+        'blocking' => 0,
+        'minute' => 'R',
+        'hour' => '0',
+        'day' => '*',
+        'dayofweek' => '*',
+        'month' => '*',
+    ),
 );
index dd0f687..5c2dcca 100644 (file)
@@ -2576,6 +2576,46 @@ function xmldb_main_upgrade($oldversion) {
     }
 
     if ($oldversion < 2021052500.04) {
+        // Define field metadatasettings to be added to h5p_libraries.
+        $table = new xmldb_table('h5p_libraries');
+        $field = new xmldb_field('metadatasettings', XMLDB_TYPE_TEXT, null, null, null, null, null, 'coreminor');
+
+        // Conditionally launch add field metadatasettings.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Get installed library files that have no metadata settings value.
+        $params = [
+            'component' => 'core_h5p',
+            'filearea' => 'libraries',
+            'filename' => 'library.json',
+        ];
+        $sql = "SELECT l.id, f.id as fileid
+                  FROM {files} f
+             LEFT JOIN {h5p_libraries} l ON f.itemid = l.id
+                 WHERE f.component = :component
+                       AND f.filearea = :filearea
+                       AND f.filename = :filename";
+        $libraries = $DB->get_records_sql($sql, $params);
+
+        // Update metadatasettings field when the attribute is present in the library.json file.
+        $fs = get_file_storage();
+        foreach ($libraries as $library) {
+            $jsonfile = $fs->get_file_by_id($library->fileid);
+            $jsoncontent = json_decode($jsonfile->get_content());
+            if (isset($jsoncontent->metadataSettings)) {
+                unset($library->fileid);
+                $library->metadatasettings = json_encode($jsoncontent->metadataSettings);
+                $DB->update_record('h5p_libraries', $library);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.04);
+    }
+
+    if ($oldversion < 2021052500.05) {
         // Define fields to be added to task_scheduled.
         $table = new xmldb_table('task_scheduled');
         $field = new xmldb_field('timestarted', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'disabled');
@@ -2618,7 +2658,30 @@ function xmldb_main_upgrade($oldversion) {
         }
 
         // Main savepoint reached.
-        upgrade_main_savepoint(true, 2021052500.04);
+        upgrade_main_savepoint(true, 2021052500.05);
+    }
+
+    if ($oldversion < 2021052500.06) {
+        // Define table to store virus infected details.
+        $table = new xmldb_table('infected_files');
+
+        // Adding fields to table infected_files.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('filename', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('quarantinedfile', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('reason', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+
+        // Adding keys to table infected_files.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+
+        // Conditionally launch create table for infected_files.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+        upgrade_main_savepoint(true, 2021052500.06);
     }
 
     return true;
index 99366fc..8378da8 100644 (file)
@@ -1241,9 +1241,10 @@ class moodle_page {
      * This is normally used as the main heading at the top of the content.
      *
      * @param string $heading the main heading that should be displayed at the top of the <body>.
+     * @param bool $applyformatting apply format_string() - by default true.
      */
-    public function set_heading($heading) {
-        $this->_heading = format_string($heading);
+    public function set_heading($heading, bool $applyformatting = true) {
+        $this->_heading = $applyformatting ? format_string($heading) : clean_text($heading);
     }
 
     /**
diff --git a/lib/templates/infected_file_email.mustache b/lib/templates/infected_file_email.mustache
new file mode 100644 (file)
index 0000000..f538386
--- /dev/null
@@ -0,0 +1,80 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle 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 General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/infected_file_email
+
+    Moodle template for infected files emails.
+
+    Context variables required for this template:
+    * report - Hyperlink to the report page,
+    * scanner - Scanning engine that found this file,
+    * filename - Name of the infected file,
+    * filesize - Size of the file,
+    * contenthash - File content hash,
+    * contenttype - Type of uploaded file,
+    * author - User that uploaded the file,
+    * ipaddress - IP address the file was uploaded from,
+    * geoinfo - Geo information about IP address,
+    * referer - The referring page,
+    * identityproviders - List of identiy providers,
+    * date - Date of upload
+    * notice - Notice returned from the scanning engine
+    * additionalinfo - Any additional information from the scanning engine
+
+    Example context (json):
+    {
+        "header": "Infected file detected",
+        "report": "http://example.moodle/report/infectedfiles/index.php",
+        "scanner": "antivirus_clamav",
+        "filename": "virus.txt",
+        "filesize": 100,
+        "contenthash": "3395856ce81f2b7382dee72602f798b642f14140",
+        "contenttype": "text/plain",
+        "author": "Example User (exampleuser)",
+        "ipaddress": "192.168.0.1",
+        "geoinfo": "Brisbane, Australia",
+        "referer": "http://example.moodle/user/files.php",
+        "date": "28/05/20, 11:19",
+        "notice": "Clamav scanning has tried 1 time(s). ClamAV has failed to run.",
+        "additionalinfo": "Here is the output from ClamAV: /tmp/phpElIcr2: Not a regular file ERROR"
+    }
+}}
+
+<div>
+<b>{{header}}</b>
+    <div>
+        <p><b>{{#str}} emailreport, antivirus {{/str}}</b><a href={{report}}>{{report}}</a></p>
+        <p><b>{{#str}} emailscanner, antivirus {{/str}}</b>{{scanner}}</p>
+        <br>
+        <p><b>{{#str}} emailfilename, antivirus {{/str}}</b>{{filename}}</p>
+        <p><b>{{#str}} emailfilesize, antivirus {{/str}}</b>{{filesize}}</p>
+        <p><b>{{#str}} emailcontenthash, antivirus {{/str}}</b>{{contenthash}}</p>
+        <p><b>{{#str}} emailcontenttype, antivirus {{/str}}</b>{{contenttype}}</p>
+        <p><b>{{#str}} emaildate, antivirus {{/str}}</b>{{date}}</p>
+        <br>
+        <p><b>{{#str}} emailauthor, antivirus {{/str}}</b>{{author}}</p>
+        <p><b>{{#str}} emailipaddress, antivirus {{/str}}</b>{{ipaddress}}</p>
+        <p><b>{{#str}} emailgeoinfo, antivirus {{/str}}</b>{{geoinfo}}</p>
+        <p><b>{{#str}} emailreferer, antivirus {{/str}}</b><a href={{referer}}>{{referer}}</a></p>
+    </div>
+    <div>
+        <br>
+        <pre>{{notice}}</pre>
+        <br>
+        <p><b>{{#str}} emailadditionalinfo, antivirus {{/str}} </b></p>
+    </div>
+</div>
index 9192029..96fb1ce 100644 (file)
@@ -85,6 +85,67 @@ class core_antivirus_testcase extends advanced_testcase {
         $this->assertFileNotExists($this->tempfile);
     }
 
+    public function test_manager_send_message_to_user_email_scan_file_virus() {
+        $sink = $this->redirectEmails();
+        $exception = null;
+        try {
+            set_config('notifyemail', 'fake@example.com', 'antivirus');
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $this->assertCount(1, $result);
+        $this->assertContains('fake@example.com', $result[0]->to);
+        $sink->close();
+    }
+
+    public function test_manager_send_message_to_admin_email_scan_file_virus() {
+        $sink = $this->redirectMessages();
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $admins = array_keys(get_admins());
+        $this->assertCount(1, $admins);
+        $this->assertCount(1, $result);
+        $this->assertEquals($result[0]->useridto, reset($admins));
+        $sink->close();
+    }
+
+    public function test_manager_quarantine_file_virus() {
+        try {
+            set_config('enablequarantine', true, 'antivirus');
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(1, count($quarantinedfiles));
+        // Clean up.
+        \core\antivirus\quarantine::clean_up_quarantine_folder(time());
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
+    public function test_manager_none_quarantine_file_virus() {
+        try {
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
     public function test_manager_scan_data_no_virus() {
         // Run mock scanning.
         $this->assertEmpty(\core\antivirus\manager::scan_data('OK'));
@@ -100,4 +161,69 @@ class core_antivirus_testcase extends advanced_testcase {
         $this->expectException(\core\antivirus\scanner_exception::class);
         $this->assertEmpty(\core\antivirus\manager::scan_data('FOUND'));
     }
+
+    public function test_manager_send_message_to_user_email_scan_data_virus() {
+        $sink = $this->redirectEmails();
+        set_config('notifyemail', 'fake@example.com', 'antivirus');
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $this->assertCount(1, $result);
+        $this->assertContains('fake@example.com', $result[0]->to);
+        $sink->close();
+    }
+
+    public function test_manager_send_message_to_admin_email_scan_data_virus() {
+        $sink = $this->redirectMessages();
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $admins = array_keys(get_admins());
+        $this->assertCount(1, $admins);
+        $this->assertCount(1, $result);
+        $this->assertEquals($result[0]->useridto, reset($admins));
+        $sink->close();
+    }
+
+    public function test_manager_quarantine_data_virus() {
+        set_config('enablequarantine', true, 'antivirus');
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(1, count($quarantinedfiles));
+        // Clean up.
+        \core\antivirus\quarantine::clean_up_quarantine_folder(time());
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
+
+    public function test_manager_none_quarantine_data_virus() {
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // No Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
 }
index a801a62..bd68624 100644 (file)
@@ -311,6 +311,14 @@ class core_moodle_page_testcase extends advanced_testcase {
         $this->testpage->set_heading('a heading');
         // Validated.
         $this->assertSame('a heading', $this->testpage->heading);
+
+        // By default formatting is applied and tags are removed.
+        $this->testpage->set_heading('a heading <a href="#">edit</a><p>');
+        $this->assertSame('a heading edit', $this->testpage->heading);
+
+        // Without formatting the tags are preserved but cleaned.
+        $this->testpage->set_heading('a heading <a href="#">edit</a><p>', false);
+        $this->assertSame('a heading <a href="#">edit</a><p></p>', $this->testpage->heading);
     }
 
     public function test_set_title() {
index 10ce44b..6b46272 100644 (file)
@@ -148,73 +148,87 @@ class core_scheduled_task_testcase extends advanced_testcase {
         $this->assertContains('2:15 AM', core_text::strtoupper($userdate));
     }
 
-    public function test_reset_scheduled_tasks_for_component() {
-        global $DB;
-
+    public function test_reset_scheduled_tasks_for_component_customised(): void {
         $this->resetAfterTest(true);
-        // Remember the defaults.
-        $defaulttasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
-        $initcount = count($defaulttasks);
+
+        $tasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
+
         // Customise a task.
-        $firsttask = reset($defaulttasks);
-        $firsttask->set_minute('1');
-        $firsttask->set_hour('2');
-        $firsttask->set_month('3');
-        $firsttask->set_day_of_week('4');
-        $firsttask->set_day('5');
-        $firsttask->set_customised('1');
-        \core\task\manager::configure_scheduled_task($firsttask);
-        $firsttaskrecord = \core\task\manager::record_from_scheduled_task($firsttask);
-        // We reset this field, because we do not want to compare it.
-        $firsttaskrecord->nextruntime = '0';
+        $task = reset($tasks);
+        $task->set_minute('1');
+        $task->set_hour('2');
+        $task->set_month('3');
+        $task->set_day_of_week('4');
+        $task->set_day('5');
+        $task->set_customised('1');
+        \core\task\manager::configure_scheduled_task($task);
+
+        // Now call reset.
+        \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+        // Fetch the task again.
+        $taskafterreset = \core\task\manager::get_scheduled_task(get_class($task));
+
+        // The task should still be the same as the customised.
+        $this->assertTaskEquals($task, $taskafterreset);
+    }
+
+    public function test_reset_scheduled_tasks_for_component_deleted(): void {
+        global $DB;
+        $this->resetAfterTest(true);
 
         // Delete a task to simulate the fact that its new.
-        $secondtask = next($defaulttasks);
-        $DB->delete_records('task_scheduled', array('classname' => '\\' . trim(get_class($secondtask), '\\')));
-        $this->assertFalse(\core\task\manager::get_scheduled_task(get_class($secondtask)));
+        $tasklist = \core\task\manager::load_scheduled_tasks_for_component('moodle');
 
-        // Edit a task to simulate a change in its definition (as if it was not customised).
-        $thirdtask = next($defaulttasks);
-        $thirdtask->set_minute('1');
-        $thirdtask->set_hour('2');
-        $thirdtask->set_month('3');
-        $thirdtask->set_day_of_week('4');
-        $thirdtask->set_day('5');
-        $thirdtaskbefore = \core\task\manager::get_scheduled_task(get_class($thirdtask));
-        $thirdtaskbefore->set_next_run_time(null);      // Ignore this value when comparing.
-        \core\task\manager::configure_scheduled_task($thirdtask);
-        $thirdtask = \core\task\manager::get_scheduled_task(get_class($thirdtask));
-        $thirdtask->set_next_run_time(null);            // Ignore this value when comparing.
-        $this->assertNotEquals($thirdtaskbefore, $thirdtask);
+        // Note: This test must use a task which does not use any random values.
+        $task = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        $DB->delete_records('task_scheduled', array('classname' => '\\' . trim(get_class($task), '\\')));
+        $this->assertFalse(\core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class));
 
         // Now call reset on all the tasks.
         \core\task\manager::reset_scheduled_tasks_for_component('moodle');
 
-        // Load the tasks again.
-        $defaulttasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
-        $finalcount = count($defaulttasks);
-        // Compare the first task.
-        $newfirsttask = reset($defaulttasks);
-        $newfirsttaskrecord = \core\task\manager::record_from_scheduled_task($newfirsttask);
-        // We reset this field, because we do not want to compare it.
-        $newfirsttaskrecord->nextruntime = '0';
+        // Assert that the second task was added back.
+        $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+        $this->assertNotFalse($taskafterreset);
 
-        // Assert a customised task was not altered by reset.
-        $this->assertEquals($firsttaskrecord, $newfirsttaskrecord);
+        $this->assertTaskEquals($task, $taskafterreset);
+        $this->assertCount(count($tasklist), \core\task\manager::load_scheduled_tasks_for_component('moodle'));
+    }
 
-        // Assert that the second task was added back.
-        $secondtaskafter = \core\task\manager::get_scheduled_task(get_class($secondtask));
-        $secondtaskafter->set_next_run_time(null);   // Do not compare the nextruntime.
-        $secondtask->set_next_run_time(null);
-        $this->assertEquals($secondtask, $secondtaskafter);
-
-        // Assert that the third task edits were overridden.
-        $thirdtaskafter = \core\task\manager::get_scheduled_task(get_class($thirdtask));
-        $thirdtaskafter->set_next_run_time(null);
-        $this->assertEquals($thirdtaskbefore, $thirdtaskafter);
-
-        // Assert we have the same number of tasks.
-        $this->assertEquals($initcount, $finalcount);
+    public function test_reset_scheduled_tasks_for_component_changed_in_source(): void {
+        $this->resetAfterTest(true);
+
+        // Delete a task to simulate the fact that its new.
+        // Note: This test must use a task which does not use any random values.
+        $task = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // Get a copy of the task before maing changes for later comparison.
+        $taskbeforechange = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // Edit a task to simulate a change in its definition (as if it was not customised).
+        $task->set_minute('1');
+        $task->set_hour('2');
+        $task->set_month('3');
+        $task->set_day_of_week('4');
+        $task->set_day('5');
+        \core\task\manager::configure_scheduled_task($task);
+
+        // Fetch the task out for comparison.
+        $taskafterchange = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // The task should now be different to the original.
+        $this->assertTaskNotEquals($taskbeforechange, $taskafterchange);
+
+        // Now call reset.
+        \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+        // Fetch the task again.
+        $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // The task should now be the same as the original.
+        $this->assertTaskEquals($taskbeforechange, $taskafterreset);
     }
 
     /**
@@ -502,4 +516,56 @@ class core_scheduled_task_testcase extends advanced_testcase {
         $this->assertEquals(0, $task->get_fail_delay());
         $this->assertLessThan($before + 70, $task->get_next_run_time());
     }
+
+    /**
+     * Assert that the specified tasks are equal.
+     *
+     * @param   \core\task\task_base $task
+     * @param   \core\task\task_base $comparisontask
+     */
+    public function assertTaskEquals(\core\task\task_base $task, \core\task\task_base $comparisontask): void {
+        // Convert both to an object.
+        $task = \core\task\manager::record_from_scheduled_task($task);
+        $comparisontask = \core\task\manager::record_from_scheduled_task($comparisontask);
+
+        // Reset the nextruntime field as it is intentionally dynamic.
+        $task->nextruntime = null;
+        $comparisontask->nextruntime = null;
+
+        $args = array_merge(
+            [
+                $task,
+                $comparisontask,
+            ],
+            array_slice(func_get_args(), 2)
+        );
+
+        call_user_func_array([$this, 'assertEquals'], $args);
+    }
+
+    /**
+     * Assert that the specified tasks are not equal.
+     *
+     * @param   \core\task\task_base $task
+     * @param   \core\task\task_base $comparisontask
+     */
+    public function assertTaskNotEquals(\core\task\task_base $task, \core\task\task_base $comparisontask): void {
+        // Convert both to an object.
+        $task = \core\task\manager::record_from_scheduled_task($task);
+        $comparisontask = \core\task\manager::record_from_scheduled_task($comparisontask);
+
+        // Reset the nextruntime field as it is intentionally dynamic.
+        $task->nextruntime = null;
+        $comparisontask->nextruntime = null;
+
+        $args = array_merge(
+            [
+                $task,
+                $comparisontask,
+            ],
+            array_slice(func_get_args(), 2)
+        );
+
+        call_user_func_array([$this, 'assertNotEquals'], $args);
+    }
 }
index fef4e50..520f527 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * Added function setScrollable in core/modal. This function can be used to set the modal's body to be scrollable or not
   when the modal's height exceeds the browser's height. This is also supported in core/modal_factory through the
   'scrollable' config parameter which can be set to either true or false. If not explicitly defined, the default value
index 8601148..eba9b28 100644 (file)
@@ -145,7 +145,7 @@ $string['savemychoice'] = 'Save my choice';
 $string['search:activity'] = 'Choice - activity information';
 $string['selectalloption'] = 'Select all "{$a}"';
 $string['showavailable'] = 'Show available spaces';
-$string['showavailable_help'] = 'Allow students to see how many available spaces there are per option.';
+$string['showavailable_help'] = 'Show participants the limit for each option and the number of responses for it so far.';
 $string['showpreview'] = 'Show preview';
 $string['showpreview_help'] = 'Allow students to preview the available options before the choice is opened for submission.';
 $string['showunanswered'] = 'Show column for unanswered';
index 997176c..36dd037 100644 (file)
@@ -399,7 +399,7 @@ $string['numberofpagesviewed'] = 'Number of questions answered: {$a}';
 $string['numberofpagesviewedheader'] = 'Number of questions answered';
 $string['numberofpagesviewednotice'] = 'Number of questions answered: {$a->nquestions} (You should answer at least {$a->minquestions})';
 $string['numerical'] = 'Numerical';
-$string['numericanswer_help'] = 'You can specify a number, or a range of numbers by using colon. For example 2:5 means any answer between 2 and 5 including them are correct.';
+$string['numericanswer_help'] = 'You can specify a single number, or a range of numbers by using colon. For example 2:5 means any answer between 2 and 5 and including 2 and 5 is correct.';
 $string['numericanswer'] = 'Numeric answer';
 $string['offlinedatamessage'] = 'You have worked on this attempt using a mobile device. Data was last saved to this site {$a} ago. Please check that you do not have any unsaved work.';
 $string['ongoing'] = 'Display ongoing score';
index 5bdceb5..5775363 100644 (file)
@@ -145,9 +145,35 @@ if (($launchcontainer == LTI_LAUNCH_CONTAINER_WINDOW) &&
         $content = lti_initiate_login($cm->course, $id, $lti, $config);
     }
 
+    // Build the allowed URL, since we know what it will be from $lti->toolurl,
+    // If the specified toolurl is invalid the iframe won't load, but we still want to avoid parse related errors here.
+    // So we set an empty default allowed url, and only build a real one if the parse is successful.
+    $ltiallow = '';
+    $urlparts = parse_url($lti->toolurl);
+    if ($urlparts && array_key_exists('scheme', $urlparts) && array_key_exists('host', $urlparts)) {
+        $ltiallow = $urlparts['scheme'] . '://' . $urlparts['host'];
+        // If a port has been specified we append that too.
+        if (array_key_exists('port', $urlparts)) {
+            $ltiallow .= ':' . $urlparts['port'];
+        }
+    }
+
     // Request the launch content with an iframe tag.
-    echo '<iframe id="contentframe" height="600px" width="100%" src="launch.php?id=' . $cm->id .
-         "&triggerview=0\" webkitallowfullscreen mozallowfullscreen allowfullscreen>{$content}</iframe>";
+    $attributes = [];
+    $attributes['id'] = "contentframe";
+    $attributes['height'] = '600px';
+    $attributes['width'] = '100%';
+    $attributes['src'] = 'launch.php?id=' . $cm->id . '&triggerview=0';
+    $attributes['allow'] = "microphone $ltiallow; " .
+        "camera $ltiallow; " .
+        "geolocation $ltiallow; " .
+        "midi $ltiallow; " .
+        "encrypted-media $ltiallow; " .
+        "autoplay $ltiallow";
+    $attributes['allowfullscreen'] = 1;
+    $iframehtml = html_writer::tag('iframe', $content, $attributes);
+    echo $iframehtml;
+
 
     // Output script to make the iframe tag be as large as possible.
     $resize = '
index e29055e..5c5e7be 100644 (file)
@@ -1,6 +1,6 @@
 This files describes API changes for question behaviour plugins.
 
-=== 4.0 ===
+=== 3.10 ===
 
 1) The slot parameter of method M.core_question_engine.init_submit_button now removed.
    The method will get the unique id by using the 'Check' button element.
diff --git a/report/infectedfiles/classes/output/renderer.php b/report/infectedfiles/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..a820166
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report renderer
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\output;
+use report_infectedfiles\table\infectedfiles_table;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Infected file report renderer
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+
+    /**
+     * Render the table
+     *
+     * @param infectedfiles_table $table table of infected files
+     * @return false|string return html code of the table
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    protected function render_infectedfiles_table(infectedfiles_table $table) {
+        ob_start();
+        $table->display($table->pagesize, false);
+        $o = ob_get_contents();
+        ob_end_clean();
+        return $o;
+    }
+}
diff --git a/report/infectedfiles/classes/privacy/provider.php b/report/infectedfiles/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..3631c1d
--- /dev/null
@@ -0,0 +1,172 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        \core_privacy\local\metadata\provider,
+        request\plugin\provider,
+        request\core_userlist_provider {
+
+    /**
+     * This plugin stores the userid of infected users.
+     *
+     * @param collection $collection the collection object to add data to.
+     * @return collection The populated collection.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table(
+            'infected_files',
+            [
+                'userid' => 'privacy:metadata:infected_files:userid',
+                'filename' => 'privacy:metadata:infected_files:filename',
+                'timecreated' => 'privacy:metadata:infected_files:timecreated',
+            ],
+            'privacy:metadata:infected_files'
+        );
+
+        return $collection;
+    }
+
+    /**
+     * This function gets the contexts containing data for a userid.
+     *
+     * @param int $userid The userid to get contexts for.
+     * @return request\contextlist the context list for the user.
+     */
+    public static function get_contexts_for_userid(int $userid) : request\contextlist {
+        $contextlist = new request\contextlist();
+
+        // The system context is the only context where information is stored.
+        $contextlist->add_system_context();
+        return $contextlist;
+    }
+
+    /**
+     * This function exports user data on infected files from the contextlist provided.
+     *
+     * @param request\approved_contextlist $contextlist
+     * @return void
+     */
+    public static function export_user_data(request\approved_contextlist $contextlist) {
+        global $DB;
+
+        foreach ($contextlist as $context) {
+            // We only export from system context.
+            if ($context->contextlevel === CONTEXT_SYSTEM) {
+
+                $userid = $contextlist->get_user()->id;
+                $exportdata = [];
+
+                $records = $DB->get_records('infected_files', ['userid' => $userid]);
+                foreach ($records as $record) {
+                    // Export only the data that does not expose internal information.
+                    $data = [];
+                    $data['userid'] = $record->userid;
+                    $data['timecreated'] = $record->timecreated;
+                    $data['filename'] = $record->filename;
+
+                    $exportdata[] = $data;
+                }
+
+                // Now export this data in the infected files table as subcontext.
+                request\writer::with_context($context)->export_data(
+                    [get_string('privacy:metadata:infected_files_subcontext', 'report_infectedfiles')],
+                    (object) $exportdata
+                );
+            }
+        }
+    }
+
+    /**
+     * As this report tracks potential attempted security violations,
+     * This data should not be deleted at request. This would allow for an
+     * avenue for a malicious user to cover their tracks. This function deliberately
+     * does no deletes.
+     *
+     * @param \context $context the context to delete for.
+     * @return void
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        return;
+    }
+
+    /**
+     * As this report tracks potential attempted security violations,
+     * This data should not be deleted at request. This would allow for an
+     * avenue for a malicious user to cover their tracks. This function deliberately
+     * does no deletes.
+     *
+     * @param \core_privacy\local\request\approved_contextlist $contextlist the contextlist to delete for.
+     * @return void
+     */
+    public static function delete_data_for_user(request\approved_contextlist $contextlist) {
+        return;
+    }
+
+    /**
+     * This gets the list of users inside of the provided context. In this case, its only system context
+     * which contains users.
+     *
+     * @param \core_privacy\local\request\userlist $userlist
+     * @return void
+     */
+    public static function get_users_in_context(request\userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if ($context->contextlevel === CONTEXT_SYSTEM) {
+            // If we are checking system context, we need to get all distinct userids from the table.
+            $sql = 'SELECT DISTINCT userid
+                      FROM {infected_files}';
+
+            $userlist->add_from_sql('userid', $sql, []);
+        }
+    }
+
+    /**
+     * As this report tracks potential attempted security violations,
+     * This data should not be deleted at request. This would allow for an
+     * avenue for a malicious user to cover their tracks. This function deliberately
+     * does no deletes.
+     *
+     * @param request\approved_userlist $userlist
+     * @return void
+     */
+    public static function delete_data_for_users(request\approved_userlist $userlist) {
+        return;
+    }
+}
diff --git a/report/infectedfiles/classes/table/infectedfiles_table.php b/report/infectedfiles/classes/table/infectedfiles_table.php
new file mode 100644 (file)
index 0000000..79e5f7a
--- /dev/null
@@ -0,0 +1,260 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\table;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/tablelib.php');
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class infectedfiles_table extends \table_sql implements \renderable {
+    /**
+     * Table constructor
+     *
+     * @param int $uniqueid table id
+     * @param \moodle_url $url page url
+     * @param int $page current page
+     * @param int $perpage number or record per page
+     * @throws \coding_exception
+     */
+    public function __construct($uniqueid, \moodle_url $url, $page = 0, $perpage = 30) {
+        parent::__construct($uniqueid);
+
+        $this->set_attribute('class', 'report_infectedfiles');
+
+        // Set protected properties.
+        $this->pagesize = $perpage;
+        $this->page = $page;
+
+        // Define columns in the table.
+        $this->define_table_columns();
+
+        // Define configs.
+        $this->define_table_configs($url);
+    }
+
+    /**
+     * Table columns and corresponding headers
+     *
+     * @throws \coding_exception
+     */
+    protected function define_table_columns() {
+        $cols = array(
+            'filename' => get_string('filename', 'report_infectedfiles'),
+            'author' => get_string('author', 'report_infectedfiles'),
+            'reason' => get_string('reason', 'report_infectedfiles'),
+            'timecreated' => get_string('timecreated', 'report_infectedfiles'),
+            'actions' => get_string('actions'),
+        );
+
+        $this->define_columns(array_keys($cols));
+        $this->define_headers(array_values($cols));
+    }
+
+    /**
+     * Define table configuration
+     *
+     * @param \moodle_url $url
+     */
+    protected function define_table_configs(\moodle_url $url) {
+        // Set table url.
+        $this->define_baseurl($url);
+
+        // Set table configs.
+        $this->collapsible(false);
+        $this->sortable(false);
+        $this->pageable(true);
+    }
+
+    /**
+     * Builds the SQL query.
+     *
+     * @param bool $count When true, return the count SQL.
+     * @return array containing sql to use and an array of params.
+     */
+    protected function get_sql_and_params($count = false) : array {
+        if ($count) {
+            $select = "COUNT(1)";
+        } else {
+            $select = "*";
+        }
+
+        $sql = "SELECT $select
+                  FROM {infected_files}";
+
+        $params = array();
+
+        if (!$count) {
+            $sql .= " ORDER BY timecreated DESC";
+        }
+
+        return array($sql, $params);
+    }
+
+    /**
+     * Get data.
+     *
+     * @param int $pagesize number of records to fetch
+     * @param bool $useinitialsbar initial bar
+     * @throws \dml_exception
+     */
+    public function query_db($pagesize, $useinitialsbar = true) {
+        global $DB;
+
+        list($countsql, $countparams) = $this->get_sql_and_params(true);
+        list($sql, $params) = $this->get_sql_and_params();
+        $total = $DB->count_records_sql($countsql, $countparams);
+        $this->pagesize($pagesize, $total);
+        $this->rawdata = $DB->get_records_sql($sql, $params, $this->get_page_start(), $this->get_page_size());
+
+        // Set initial bars.
+        if ($useinitialsbar) {
+            $this->initialbars($total > $pagesize);
+        }
+    }
+
+    /**
+     * Column to display the authors fullname from userid.
+     *
+     * @param \stdClass $row the row from sql.
+     * @return string the authors name.
+     */
+    protected function col_author($row) : string {
+        // Get user fullname from ID.
+        $user = \core_user::get_user($row->userid);
+        $url = new \moodle_url('/user/profile.php', ['id' => $row->userid]);
+        return \html_writer::link($url, fullname($user));
+    }
+
+    /**
+     * Column to display the failure reason.
+     *
+     * @param \stdClass $row the row from sql.
+     * @return string the formatted reason.
+     */
+    protected function col_reason($row) {
+        return format_text($row->reason);
+    }
+
+    /**
+     * Custom actions column
+     *
+     * @param \stdClass $row an incident record.
+     * @return string content of action column.
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    protected function col_actions($row) : string {
+        global $OUTPUT;
+        $filename = $row->quarantinedfile;
+        $fileid = $row->id;
+        // If the file isn't found, we can do nothing in this column.
+        // This shouldn't happen, unless the file is manually deleted from the server externally.
+        if (!\core\antivirus\quarantine::quarantined_file_exists($filename)) {
+            return '';
+        }
+        $links = '';
+        $managefilepage = new \moodle_url('/report/infectedfiles/index.php');
+
+        // Download.
+        $downloadparams = ['file' => $fileid, 'action' => 'download', 'sesskey' => sesskey()];
+        $downloadurl = new \moodle_url($managefilepage, $downloadparams);
+
+        $downloadconfirm = new \confirm_action(get_string('confirmdownload', 'report_infectedfiles'));
+        $links .= $OUTPUT->action_icon(
+            $downloadurl,
+            new \pix_icon('t/download', get_string('download')),
+            $downloadconfirm
+        );
+
+        // Delete.
+        $deleteparams = ['file' => $fileid, 'action' => 'delete', 'sesskey' => sesskey()];
+        $deleteurl = new \moodle_url($managefilepage, $deleteparams);
+        $deleteconfirm = new \confirm_action(get_string('confirmdelete', 'report_infectedfiles'));
+        $links .= $OUTPUT->action_icon(
+            $deleteurl,
+            new \pix_icon('t/delete', get_string('delete')),
+            $deleteconfirm
+        );
+
+        return $links;
+    }
+
+    /**
+     * Custom time column.
+     *
+     * @param \stdClass $row an incident record.
+     * @return string time created in user-friendly format.
+     */
+    protected function col_timecreated($row) : string {
+        return userdate($row->timecreated);
+    }
+
+    /**
+     * Display table with download all and delete all buttons
+     *
+     * @param int $pagesize number or records perpage
+     * @param bool $useinitialsbar use the bar or not
+     * @param string $downloadhelpbutton help button
+     * @return void
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    public function display($pagesize, $useinitialsbar, $downloadhelpbutton='') {
+        global $OUTPUT;
+        // Output the table, and then display buttons.
+        $this->out($pagesize, $useinitialsbar, $downloadhelpbutton);
+        $managefilepage = new \moodle_url('/report/infectedfiles/index.php');
+
+        // If there are no rows, dont bother rendering extra buttons.
+        if (empty($this->rawdata)) {
+            return;
+        }
+
+        // Delete All.
+        $deleteallparams = ['action' => 'deleteall', 'sesskey' => sesskey()];
+        $deleteallurl = new \moodle_url($managefilepage, $deleteallparams);
+        $deletebutton = new \single_button($deleteallurl, get_string('deleteall'), 'post', true);
+        $deletebutton->add_confirm_action(get_string('confirmdeleteall', 'report_infectedfiles'));
+        echo $OUTPUT->render($deletebutton);
+
+        echo "&nbsp";
+
+        // Download All.
+        $downloadallparams = ['action' => 'downloadall', 'sesskey' => sesskey()];
+        $downloadallurl = new \moodle_url($managefilepage, $downloadallparams);
+        $downloadbutton = new \single_button($downloadallurl, get_string('downloadall'), 'post', true);
+        $downloadbutton->add_confirm_action(get_string('confirmdownloadall', 'report_infectedfiles'));
+        echo $OUTPUT->render($downloadbutton);
+    }
+
+}
diff --git a/report/infectedfiles/index.php b/report/infectedfiles/index.php
new file mode 100644 (file)
index 0000000..5f36af7
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(__DIR__.'/../../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+use \core\antivirus\quarantine;
+
+admin_externalpage_setup('reportinfectedfiles', '', null, '', array('pagelayout' => 'report'));
+
+$action = optional_param('action', '', PARAM_TEXT);
+// If action exists, we need to process the actions safely.
+if (!empty($action)) {
+    // Nothing can be done without a valid sesskey.
+    require_sesskey();
+    // For any cancel actions, just reload the page clean.
+    $cancel = $PAGE->url;
+
+    // Decide on page action.
+    switch ($action) {
+        case 'download':
+            $fileid = required_param('file', PARAM_INT);
+            quarantine::download_quarantined_file($fileid);
+            break;
+
+        case 'downloadall':
+            quarantine::download_all_quarantined_files();
+            break;
+
+        case 'delete':
+            $fileid = required_param('file', PARAM_INT);
+            quarantine::delete_quarantined_file($fileid);
+            break;
+
+        case 'deleteall':
+            // Remove file until current time.
+            quarantine::clean_up_quarantine_folder(time());
+            break;
+    }
+
+    // Reload page cleanly once actions are processed.
+    redirect($PAGE->url);
+}
+
+// Once actions are dealt with, display the page.
+$page = optional_param('page', 0, PARAM_INT);
+
+echo $OUTPUT->header();
+echo $OUTPUT->heading(get_string('infectedfiles', 'report_infectedfiles'));
+$table = new \report_infectedfiles\table\infectedfiles_table('report-infectedfiles-report-table', $PAGE->url, $page);
+$table->define_baseurl($PAGE->url);
+echo $PAGE->get_renderer('report_infectedfiles')->render($table);
+echo $OUTPUT->footer();
diff --git a/report/infectedfiles/lang/en/report_infectedfiles.php b/report/infectedfiles/lang/en/report_infectedfiles.php
new file mode 100644 (file)
index 0000000..dd487fa
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+$string['author'] = 'Author';
+$string['confirmdelete'] = 'Do you really wish to delete this file?';
+$string['confirmdeleteall'] = 'Do you really wish to delete all files?';
+$string['confirmdownload'] = 'Do you really wish to download this file?';
+$string['confirmdownloadall'] = 'Do you really wish to download all files?';
+$string['filename'] = 'File name';
+$string['infectedfiles'] = 'Antivirus failures';
+$string['privacy:metadata:infected_files'] = 'This table stores information on antivirus failures detected by the system.';
+$string['privacy:metadata:infected_files:filename'] = 'The name of the infected file uploaded by the user.';
+$string['privacy:metadata:infected_files:timecreated'] = 'The timestamp of when a user uploaded an infected file.';
+$string['privacy:metadata:infected_files:userid'] = 'The userid of the user who uploaded an infected file.';
+$string['privacy:metadata:infected_files_subcontext'] = 'Antivirus failures';
+$string['pluginname'] = 'Infected files';
+$string['quarantinedfile'] = 'Quarantined file';
+$string['reason'] = 'Failure reason';
+$string['timecreated'] = 'Time created';
diff --git a/report/infectedfiles/settings.php b/report/infectedfiles/settings.php
new file mode 100644 (file)
index 0000000..d849a3c
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$ADMIN->add('reports', new admin_externalpage('reportinfectedfiles',
+    get_string('infectedfiles', 'report_infectedfiles'),
+    "$CFG->wwwroot/report/infectedfiles/index.php"));
+
+$settings = null;
diff --git a/report/infectedfiles/version.php b/report/infectedfiles/version.php
new file mode 100644 (file)
index 0000000..9fcf2d9
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$plugin->version   = 2021052500;
+$plugin->requires  = 2019111200;
+$plugin->component = 'report_infectedfiles';
index 60f3cea..55227ec 100644 (file)
@@ -199,6 +199,8 @@ class insights_list implements \renderable, \templatable {
             $data->noinsights = $notification->export_for_template($output);
         }
 
+        $url = $PAGE->url;
+
         if ($this->othermodels) {
 
             $options = array();
@@ -207,14 +209,15 @@ class insights_list implements \renderable, \templatable {
             }
 
             // New moodle_url instance returned by magic_get_url.
-            $url = $PAGE->url;
             $url->remove_params('modelid');
             $modelselector = new \single_select($url, 'modelid', $options, '',
                 array('' => get_string('selectotherinsights', 'report_insights')));
             $data->modelselector = $modelselector->export_for_template($output);
         }
 
-        $data->pagingbar = $output->render(new \paging_bar($total, $this->page, $this->perpage, $PAGE->url));
+        // Add the 'perpage' parameter to the url which is later used to generate the pagination links.
+        $url->param('perpage', $this->perpage);
+        $data->pagingbar = $output->render(new \paging_bar($total, $this->page, $this->perpage, $url));
 
         return $data;
     }
index d56cdd9..d7037ad 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /search/*,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 
 * Search indexing now supports sending multiple documents to the server in a batch. This is implemented
   for the Solr search engine, where it significantly increases performance. For this to work, engines
index bd0cf66..bf407ec 100644 (file)
@@ -20,7 +20,7 @@ $orange:  #f0ad4e !default;
 $yellow:  #ff7518 !default;
 $green:   #398439 !default;
 $teal:    #20c997 !default;
-$cyan:    #5bc0de !default;
+$cyan:    #008196 !default;
 
 $primary:       $blue !default;
 $success:       $green !default;
@@ -37,7 +37,7 @@ $enable-rounded: false !default;
 $enable-responsive-font-sizes: true !default;
 
 // Body
-$body-color:    $gray-800 !default;
+$body-color:    $gray-900 !default;
 
 // Fonts
 $font-size-base: 0.9375rem !default;
index 4039bfe..eb140de 100644 (file)
   --yellow: #ff7518;
   --green: #398439;
   --teal: #20c997;
-  --cyan: #5bc0de;
+  --cyan: #008196;
   --white: #fff;
   --gray: #6c757d;
   --gray-dark: #343a40;
   --primary: #1177d1;
   --secondary: #ced4da;
   --success: #398439;
-  --info: #5bc0de;
+  --info: #008196;
   --warning: #f0ad4e;
   --danger: #d43f3a;
   --light: #f8f9fa;
@@ -2382,7 +2382,7 @@ body {
   font-size: 0.9375rem;
   font-weight: 400;
   line-height: 1.5;
-  color: #343a40;
+  color: #212529;
   text-align: left;
   background-color: #fff; }
   @media (max-width: 1200px) {
@@ -3513,7 +3513,7 @@ pre {
 .table {
   width: 100%;
   margin-bottom: 1rem;
-  color: #343a40; }
+  color: #212529; }
   .table th,
   .table td {
     padding: 0.75rem;
@@ -3548,7 +3548,7 @@ pre {
   background-color: rgba(0, 0, 0, 0.05); }
 
 .table-hover tbody tr:hover {
-  color: #343a40;
+  color: #212529;
   background-color: rgba(0, 0, 0, 0.075); }
 
 .table-primary,
@@ -3605,19 +3605,19 @@ pre {
 .table-info,
 .table-info > th,
 .table-info > td {
-  background-color: #d1edf6; }
+  background-color: #b8dce2; }
 
 .table-info th,
 .table-info td,
 .table-info thead th,
 .table-info tbody + tbody {
-  border-color: #aadeee; }
+  border-color: #7abdc8; }
 
 .table-hover .table-info:hover {
-  background-color: #bce5f2; }
+  background-color: #a6d3db; }
   .table-hover .table-info:hover > td,
   .table-hover .table-info:hover > th {
-    background-color: #bce5f2; }
+    background-color: #a6d3db; }
 
 .table-warning,
 .table-warning > th,
@@ -3850,7 +3850,7 @@ select.form-control:focus::-ms-value {
   margin-bottom: 0;
   font-size: 0.9375rem;
   line-height: 1.5;
-  color: #343a40;
+  color: #212529;
   background-color: transparent;
   border: solid transparent;
   border-width: 1px 0; }
@@ -4132,7 +4132,7 @@ textarea.form-control {
 .btn {
   display: inline-block;
   font-weight: 400;
-  color: #343a40;
+  color: #212529;
   text-align: center;
   vertical-align: middle;
   user-select: none;
@@ -4150,7 +4150,7 @@ textarea.form-control {
     .btn {
       transition: none; } }
   .btn:hover {
-    color: #343a40;
+    color: #212529;
     text-decoration: none; }
   .btn:focus, .btn.focus {
     outline: 0;
@@ -4243,30 +4243,30 @@ fieldset:disabled a.btn {
       box-shadow: 0 0 0 0.2rem rgba(87, 150, 87, 0.5); }
 
 .btn-info {
-  color: #212529;
-  background-color: #5bc0de;
-  border-color: #5bc0de; }
+  color: #fff;
+  background-color: #008196;
+  border-color: #008196; }
   .btn-info:hover {
     color: #fff;
-    background-color: #3bb4d8;
-    border-color: #31b0d5; }
+    background-color: #006070;
+    border-color: #005563; }
   .btn-info:focus, .btn-info.focus {
     color: #fff;
-    background-color: #3bb4d8;
-    border-color: #31b0d5;
-    box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+    background-color: #006070;
+    border-color: #005563;
+    box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
   .btn-info.disabled, .btn-info:disabled {
-    color: #212529;
-    background-color: #5bc0de;
-    border-color: #5bc0de; }
+    color: #fff;
+    background-color: #008196;
+    border-color: #008196; }
   .btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active,
   .show > .btn-info.dropdown-toggle {
     color: #fff;
-    background-color: #31b0d5;
-    border-color: #2aaacf; }
+    background-color: #005563;
+    border-color: #004a56; }
     .btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus,
     .show > .btn-info.dropdown-toggle:focus {
-      box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+      box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
 
 .btn-warning {
   color: #212529;
@@ -4436,25 +4436,25 @@ fieldset:disabled a.btn {
       box-shadow: 0 0 0 0.2rem rgba(57, 132, 57, 0.5); }
 
 .btn-outline-info {
-  color: #5bc0de;
-  border-color: #5bc0de; }
+  color: #008196;
+  border-color: #008196; }
   .btn-outline-info:hover {
-    color: #212529;
-    background-color: #5bc0de;
-    border-color: #5bc0de; }
+    color: #fff;
+    background-color: #008196;
+    border-color: #008196; }
   .btn-outline-info:focus, .btn-outline-info.focus {
-    box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.5); }
+    box-shadow: 0 0 0 0.2rem rgba(0, 129, 150, 0.5); }
   .btn-outline-info.disabled, .btn-outline-info:disabled {
-    color: #5bc0de;
+    color: #008196;
     background-color: transparent; }
   .btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active,
   .show > .btn-outline-info.dropdown-toggle {
-    color: #212529;
-    background-color: #5bc0de;
-    border-color: #5bc0de; }
+    color: #fff;
+    background-color: #008196;
+    border-color: #008196; }
     .btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus,
     .show > .btn-outline-info.dropdown-toggle:focus {
-      box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.5); }
+      box-shadow: 0 0 0 0.2rem rgba(0, 129, 150, 0.5); }
 
 .btn-outline-warning {
   color: #f0ad4e;
@@ -4630,7 +4630,7 @@ input[type="button"].btn-block {
   padding: 0.5rem 0;
   margin: 0.125rem 0 0;
   font-size: 0.9375rem;
-  color: #343a40;
+  color: #212529;
   text-align: left;
   list-style: none;
   background-color: #fff;
@@ -5897,14 +5897,14 @@ input[type="button"].btn-block {
     box-shadow: 0 0 0 0.2rem rgba(57, 132, 57, 0.5); }
 
 .badge-info {
-  color: #212529;
-  background-color: #5bc0de; }
+  color: #fff;
+  background-color: #008196; }
   a.badge-info:hover, a.badge-info:focus {
-    color: #212529;
-    background-color: #31b0d5; }
+    color: #fff;
+    background-color: #005563; }
   a.badge-info:focus, a.badge-info.focus {
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.5); }
+    box-shadow: 0 0 0 0.2rem rgba(0, 129, 150, 0.5); }
 
 .badge-warning {
   color: #212529;
@@ -6007,13 +6007,13 @@ input[type="button"].btn-block {
     color: #0f210f; }
 
 .alert-info {
-  color: #2f6473;
-  background-color: #def2f8;
-  border-color: #d1edf6; }
+  color: #00434e;
+  background-color: #cce6ea;
+  border-color: #b8dce2; }
   .alert-info hr {
-    border-top-color: #bce5f2; }
+    border-top-color: #a6d3db; }
   .alert-info .alert-link {
-    color: #20454f; }
+    color: #00171b; }
 
 .alert-warning {
   color: #7d5a29;
@@ -6112,7 +6112,7 @@ input[type="button"].btn-block {
     text-decoration: none;
     background-color: #f8f9fa; }
   .list-group-item-action:active {
-    color: #343a40;
+    color: #212529;
     background-color: #e9ecef; }
 
 .list-group-item {
@@ -6234,15 +6234,15 @@ input[type="button"].btn-block {
     border-color: #1e451e; }
 
 .list-group-item-info {
-  color: #2f6473;
-  background-color: #d1edf6; }
+  color: #00434e;
+  background-color: #b8dce2; }
   .list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus {
-    color: #2f6473;
-    background-color: #bce5f2; }
+    color: #00434e;
+    background-color: #a6d3db; }
   .list-group-item-info.list-group-item-action.active {
     color: #fff;
-    background-color: #2f6473;
-    border-color: #2f6473; }
+    background-color: #00434e;
+    border-color: #00434e; }
 
 .list-group-item-warning {
   color: #7d5a29;
@@ -6694,7 +6694,7 @@ a.close.disabled {
 
 .popover-body {
   padding: 0.5rem 0.75rem;
-  color: #343a40; }
+  color: #212529; }
 
 .carousel {
   position: relative; }
@@ -6929,12 +6929,12 @@ button.bg-success:focus {
   background-color: #2a602a !important; }
 
 .bg-info {
-  background-color: #5bc0de !important; }
+  background-color: #008196 !important; }
 
 a.bg-info:hover, a.bg-info:focus,
 button.bg-info:hover,
 button.bg-info:focus {
-  background-color: #31b0d5 !important; }
+  background-color: #005563 !important; }
 
 .bg-warning {
   background-color: #f0ad4e !important; }
@@ -7014,7 +7014,7 @@ button.bg-dark:focus {
   border-color: #398439 !important; }
 
 .border-info {
-  border-color: #5bc0de !important; }
+  border-color: #008196 !important; }
 
 .border-warning {
   border-color: #f0ad4e !important; }
@@ -9484,10 +9484,10 @@ a.text-success:hover, a.text-success:focus {
   color: #224f22 !important; }
 
 .text-info {
-  color: #5bc0de !important; }
+  color: #008196 !important; }
 
 a.text-info:hover, a.text-info:focus {
-  color: #28a1c5 !important; }
+  color: #003f4a !important; }
 
 .text-warning {
   color: #f0ad4e !important; }
@@ -9514,7 +9514,7 @@ a.text-dark:hover, a.text-dark:focus {
   color: #121416 !important; }
 
 .text-body {
-  color: #343a40 !important; }
+  color: #212529 !important; }
 
 .text-muted {
   color: #6c757d !important; }
@@ -9860,7 +9860,7 @@ div.dropdown-item:focus-within {
   color: #398439; }
 
 .highlight {
-  color: #5bc0de; }
+  color: #008196; }
 
 .fitem.advanced .text-info {
   font-weight: bold; }
@@ -11505,7 +11505,7 @@ ul {
   #page-footer a .icon {
     color: #fff; }
   #page-footer a:focus .icon {
-    color: #343a40; }
+    color: #212529; }
 
 .bg-inverse a {
   color: #fff;
@@ -11519,7 +11519,7 @@ ul {
 .dropdown-item a {
   display: block;
   width: 100%;
-  color: #343a40; }
+  color: #212529; }
 
 .dropdown-item:active a {
   color: #fff; }
@@ -11685,7 +11685,7 @@ input[disabled] {
 
 .matchtext {
   background-color: #b5d9f9;
-  color: #343a40;
+  color: #212529;
   height: 1.5rem; }
 
 .emoji-picker {
@@ -11754,7 +11754,7 @@ input[disabled] {
   color: #0f210f; }
 
 .alert-info a {
-  color: #20454f; }
+  color: #00171b; }
 
 .alert-warning a {
   color: #573e1c; }
@@ -12266,11 +12266,11 @@ input[disabled] {
   width: 4em; }
 
 #adminthemeselector .selectedtheme td.c0 {
-  border: 1px solid #d1edf6;
+  border: 1px solid #b8dce2;
   border-right-width: 0; }
 
 #adminthemeselector .selectedtheme td.c1 {
-  border: 1px solid #d1edf6;
+  border: 1px solid #b8dce2;
   border-left-width: 0; }
 
 .admin_colourpicker,
@@ -12289,12 +12289,12 @@ input[disabled] {
     box-sizing: content-box; }
   .admin_colourpicker .colourdialogue {
     float: left;
-    border: 1px solid #d1edf6; }
+    border: 1px solid #b8dce2; }
   .admin_colourpicker .previewcolour {
-    border: 1px solid #d1edf6;
+    border: 1px solid #b8dce2;
     margin-left: 301px; }
   .admin_colourpicker .currentcolour {
-    border: 1px solid #d1edf6;
+    border: 1px solid #b8dce2;
     margin-left: 301px;
     border-top-width: 0; } }
 
@@ -12367,7 +12367,7 @@ input[disabled] {
 
 #plugins-check-page .pluginupdateinfo,
 #plugins-control-panel .pluginupdateinfo {
-  background-color: #def2f8;
+  background-color: #cce6ea;
   padding: 5px;
   margin: 10px 0; }
   #plugins-check-page .pluginupdateinfo.maturity50,
@@ -12484,7 +12484,7 @@ input[disabled] {
 
 .block .block-controls .dropdown-toggle {
   /* So that the caret takes the colour of the icon. */
-  color: #343a40; }
+  color: #212529; }
 
 [data-region="blocks-column"] {
   width: 360px;
@@ -13512,11 +13512,11 @@ span.editinstructions {
   margin-left: 30px;
   font-size: 0.8203125rem;
   padding: .1em .4em;
-  background-color: #def2f8;
-  color: #5bc0de;
+  background-color: #cce6ea;
+  color: #008196;
   text-decoration: none;
   z-index: 9999;
-  border: 1px solid #d1edf6; }
+  border: 1px solid #b8dce2; }
 
 /* Course drag and drop upload styles */
 #dndupload-status {
@@ -13525,10 +13525,10 @@ span.editinstructions {
   width: 40%;
   margin: 0 30%;
   padding: 6px;
-  border: 1px solid #d1edf6;
+  border: 1px solid #b8dce2;
   text-align: center;
-  background: #def2f8;
-  color: #5bc0de;
+  background: #cce6ea;
+  color: #008196;
   z-index: 1; }
 
 .dndupload-preview {
@@ -13847,33 +13847,33 @@ span.editinstructions {
   #course-category-listings .listing-pagination {
     text-align: center; }
     #course-category-listings .listing-pagination .yui3-button {
-      color: #212529;
-      background-color: #5bc0de;
-      border-color: #5bc0de;
+      color: #fff;
+      background-color: #008196;
+      border-color: #008196;
       border: 0;
       margin: 0.4rem 0.2rem 0.45rem;
       font-size: 10.4px; }
       #course-category-listings .listing-pagination .yui3-button:hover {
         color: #fff;
-        background-color: #3bb4d8;
-        border-color: #31b0d5; }
+        background-color: #006070;
+        border-color: #005563; }
       #course-category-listings .listing-pagination .yui3-button:focus, #course-category-listings .listing-pagination .yui3-button.focus {
         color: #fff;
-        background-color: #3bb4d8;
-        border-color: #31b0d5;
-        box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+        background-color: #006070;
+        border-color: #005563;
+        box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
       #course-category-listings .listing-pagination .yui3-button.disabled, #course-category-listings .listing-pagination .yui3-button:disabled {
-        color: #212529;
-        background-color: #5bc0de;
-        border-color: #5bc0de; }
+        color: #fff;
+        background-color: #008196;
+        border-color: #008196; }
       #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled):active, #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled).active,
       .show > #course-category-listings .listing-pagination .yui3-button.dropdown-toggle {
         color: #fff;
-        background-color: #31b0d5;
-        border-color: #2aaacf; }
+        background-color: #005563;
+        border-color: #004a56; }
         #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled):active:focus, #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled).active:focus,
         .show > #course-category-listings .listing-pagination .yui3-button.dropdown-toggle:focus {
-          box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+          box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
       #course-category-listings .listing-pagination .yui3-button.active-page {
         color: #fff;
         background-color: #1177d1;
@@ -15625,14 +15625,14 @@ body.path-question-type {
     color: #573e1c; }
 
 .que .formulation {
-  color: #2f6473;
-  background-color: #def2f8;
-  border-color: #d1edf6;
+  color: #00434e;
+  background-color: #cce6ea;
+  border-color: #b8dce2;
   /* stylelint-disable-line max-line-length */ }
   .que .formulation hr {
-    border-top-color: #bce5f2; }
+    border-top-color: #a6d3db; }
   .que .formulation .alert-link {
-    color: #20454f; }
+    color: #00171b; }
 
 .que.multichoice .answer div.r0 .icon.fa-check,
 .que.multichoice .answer div.r1 .icon.fa-check,
@@ -15791,7 +15791,7 @@ body.jsenabled .questionflag input[type=checkbox] {
     margin: 0; }
 
 #page-mod-quiz-edit .questionbankwindow div.header .title {
-  color: #343a40; }
+  color: #212529; }
 
 #page-mod-quiz-edit div.container div.generalbox {
   background-color: transparent;
@@ -16455,7 +16455,7 @@ fieldset.coursesearchbox label {
   padding: 0.2em;
   margin: 0;
   cursor: pointer;
-  color: #343a40; }
+  color: #212529; }
 
 .form-autocomplete-suggestions li:hover {
   background-color: #3f9def;
@@ -16466,7 +16466,7 @@ fieldset.coursesearchbox label {
   color: #495057; }
 
 .form-autocomplete-downarrow {
-  color: #343a40;
+  color: #212529;
   top: 0.2rem;
   right: 0.5rem;
   cursor: pointer; }
@@ -16668,10 +16668,10 @@ select {
   font-weight: inherit; }
 
 .path-mod-forum .subscriptionmode {
-  color: #343a40; }
+  color: #212529; }
 
 .path-mod-forum .activesetting {
-  color: #343a40;
+  color: #212529;
   font-weight: bold; }
 
 .discussion-settings-container .custom-select {
@@ -17571,14 +17571,14 @@ div#dock {
   padding: 0.75rem 1.25rem;
   margin-bottom: 1rem;
   border: 0 solid transparent;
-  color: #2f6473;
-  background-color: #def2f8;
-  border-color: #d1edf6;
+  color: #00434e;
+  background-color: #cce6ea;
+  border-color: #b8dce2;
   /* stylelint-disable-line max-line-length */ }
   .assignfeedback_editpdf_widget .label hr {
-    border-top-color: #bce5f2; }
+    border-top-color: #a6d3db; }
   .assignfeedback_editpdf_widget .label .alert-link {
-    color: #20454f; }
+    color: #00171b; }
 
 .assignfeedback_editpdf_menu {
   padding: 0; }
@@ -17831,7 +17831,7 @@ div#dock {
 .generaltable {
   width: 100%;
   margin-bottom: 1rem;
-  color: #343a40; }
+  color: #212529; }
   .generaltable th,
   .generaltable td {
     padding: 0.75rem;
@@ -17848,7 +17848,7 @@ div#dock {
   .generaltable.table-sm td {
     padding: 0.3rem; }
   .generaltable tbody tr:hover {
-    color: #343a40;
+    color: #212529;
     background-color: rgba(0, 0, 0, 0.075); }
 
 table caption {
@@ -17990,7 +17990,7 @@ p.arrow_button {
   box-shadow: inset 0 0 0 2px #fff; }
 
 .btn-info:focus, .btn-info.focus {
-  outline: 0.2rem solid #124a5b;
+  outline: 0.2rem solid black;
   box-shadow: inset 0 0 0 2px #fff; }
 
 .btn-warning:focus, .btn-warning.focus {
@@ -18022,7 +18022,7 @@ p.arrow_button {
   box-shadow: inset 0 0 0 2px #343a40; }
 
 .btn-outline-info:focus, .btn-outline-info.focus {
-  outline: 0.2rem solid #124a5b;
+  outline: 0.2rem solid black;
   box-shadow: inset 0 0 0 2px #343a40; }
 
 .btn-outline-warning:focus, .btn-outline-warning.focus {
index c5a370d..59eb010 100644 (file)
@@ -20,7 +20,7 @@ $orange:  #f0ad4e !default;
 $yellow:  #ff7518 !default;
 $green:   #398439 !default;
 $teal:    #20c997 !default;
-$cyan:    #5bc0de !default;
+$cyan:    #008196 !default;
 
 $primary:       $blue !default;
 $success:       $green !default;
@@ -37,7 +37,7 @@ $enable-rounded: true !default;
 $enable-responsive-font-sizes: true !default;
 
 // Body
-$body-color:    $gray-800 !default;
+$body-color:    $gray-900 !default;
 
 // Fonts
 $font-size-base: 0.9375rem !default;
index 0ad4ba9..452d83c 100644 (file)
   --yellow: #ff7518;
   --green: #398439;
   --teal: #20c997;
-  --cyan: #5bc0de;
+  --cyan: #008196;
   --white: #fff;
   --gray: #6c757d;
   --gray-dark: #343a40;
   --primary: #1177d1;
   --secondary: #ced4da;
   --success: #398439;
-  --info: #5bc0de;
+  --info: #008196;
   --warning: #f0ad4e;
   --danger: #d43f3a;
   --light: #f8f9fa;
@@ -2382,7 +2382,7 @@ body {
   font-size: 0.9375rem;
   font-weight: 400;
   line-height: 1.5;
-  color: #343a40;
+  color: #212529;
   text-align: left;
   background-color: #fff; }
   @media (max-width: 1200px) {
@@ -3515,7 +3515,7 @@ pre {
 .table {
   width: 100%;
   margin-bottom: 1rem;
-  color: #343a40; }
+  color: #212529; }
   .table th,
   .table td {
     padding: 0.75rem;
@@ -3550,7 +3550,7 @@ pre {
   background-color: rgba(0, 0, 0, 0.05); }
 
 .table-hover tbody tr:hover {
-  color: #343a40;
+  color: #212529;
   background-color: rgba(0, 0, 0, 0.075); }
 
 .table-primary,
@@ -3607,19 +3607,19 @@ pre {
 .table-info,
 .table-info > th,
 .table-info > td {
-  background-color: #d1edf6; }
+  background-color: #b8dce2; }
 
 .table-info th,
 .table-info td,
 .table-info thead th,
 .table-info tbody + tbody {
-  border-color: #aadeee; }
+  border-color: #7abdc8; }
 
 .table-hover .table-info:hover {
-  background-color: #bce5f2; }
+  background-color: #a6d3db; }
   .table-hover .table-info:hover > td,
   .table-hover .table-info:hover > th {
-    background-color: #bce5f2; }
+    background-color: #a6d3db; }
 
 .table-warning,
 .table-warning > th,
@@ -3852,7 +3852,7 @@ select.form-control:focus::-ms-value {
   margin-bottom: 0;
   font-size: 0.9375rem;
   line-height: 1.5;
-  color: #343a40;
+  color: #212529;
   background-color: transparent;
   border: solid transparent;
   border-width: 1px 0; }
@@ -4138,7 +4138,7 @@ textarea.form-control {
 .btn {
   display: inline-block;
   font-weight: 400;
-  color: #343a40;
+  color: #212529;
   text-align: center;
   vertical-align: middle;
   user-select: none;
@@ -4156,7 +4156,7 @@ textarea.form-control {
     .btn {
       transition: none; } }
   .btn:hover {
-    color: #343a40;
+    color: #212529;
     text-decoration: none; }
   .btn:focus, .btn.focus {
     outline: 0;
@@ -4249,30 +4249,30 @@ fieldset:disabled a.btn {
       box-shadow: 0 0 0 0.2rem rgba(87, 150, 87, 0.5); }
 
 .btn-info {
-  color: #212529;
-  background-color: #5bc0de;
-  border-color: #5bc0de; }
+  color: #fff;
+  background-color: #008196;
+  border-color: #008196; }
   .btn-info:hover {
     color: #fff;
-    background-color: #3bb4d8;
-    border-color: #31b0d5; }
+    background-color: #006070;
+    border-color: #005563; }
   .btn-info:focus, .btn-info.focus {
     color: #fff;
-    background-color: #3bb4d8;
-    border-color: #31b0d5;
-    box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+    background-color: #006070;
+    border-color: #005563;
+    box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
   .btn-info.disabled, .btn-info:disabled {
-    color: #212529;
-    background-color: #5bc0de;
-    border-color: #5bc0de; }
+    color: #fff;
+    background-color: #008196;
+    border-color: #008196; }
   .btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active,
   .show > .btn-info.dropdown-toggle {
     color: #fff;
-    background-color: #31b0d5;
-    border-color: #2aaacf; }
+    background-color: #005563;
+    border-color: #004a56; }
     .btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus,
     .show > .btn-info.dropdown-toggle:focus {
-      box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+      box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
 
 .btn-warning {
   color: #212529;
@@ -4442,25 +4442,25 @@ fieldset:disabled a.btn {
       box-shadow: 0 0 0 0.2rem rgba(57, 132, 57, 0.5); }
 
 .btn-outline-info {
-  color: #5bc0de;
-  border-color: #5bc0de; }
+  color: #008196;
+  border-color: #008196; }
   .btn-outline-info:hover {
-    color: #212529;
-    background-color: #5bc0de;
-    border-color: #5bc0de; }
+    color: #fff;
+    background-color: #008196;
+    border-color: #008196; }
   .btn-outline-info:focus, .btn-outline-info.focus {
-    box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.5); }
+    box-shadow: 0 0 0 0.2rem rgba(0, 129, 150, 0.5); }
   .btn-outline-info.disabled, .btn-outline-info:disabled {
-    color: #5bc0de;
+    color: #008196;
     background-color: transparent; }
   .btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active,
   .show > .btn-outline-info.dropdown-toggle {
-    color: #212529;
-    background-color: #5bc0de;
-    border-color: #5bc0de; }
+    color: #fff;
+    background-color: #008196;
+    border-color: #008196; }
     .btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus,
     .show > .btn-outline-info.dropdown-toggle:focus {
-      box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.5); }
+      box-shadow: 0 0 0 0.2rem rgba(0, 129, 150, 0.5); }
 
 .btn-outline-warning {
   color: #f0ad4e;
@@ -4636,7 +4636,7 @@ input[type="button"].btn-block {
   padding: 0.5rem 0;
   margin: 0.125rem 0 0;
   font-size: 0.9375rem;
-  color: #343a40;
+  color: #212529;
   text-align: left;
   list-style: none;
   background-color: #fff;
@@ -6047,14 +6047,14 @@ input[type="button"].btn-block {
     box-shadow: 0 0 0 0.2rem rgba(57, 132, 57, 0.5); }
 
 .badge-info {
-  color: #212529;
-  background-color: #5bc0de; }
+  color: #fff;
+  background-color: #008196; }
   a.badge-info:hover, a.badge-info:focus {
-    color: #212529;
-    background-color: #31b0d5; }
+    color: #fff;
+    background-color: #005563; }
   a.badge-info:focus, a.badge-info.focus {
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(91, 192, 222, 0.5); }
+    box-shadow: 0 0 0 0.2rem rgba(0, 129, 150, 0.5); }
 
 .badge-warning {
   color: #212529;
@@ -6160,13 +6160,13 @@ input[type="button"].btn-block {
     color: #0f210f; }
 
 .alert-info {
-  color: #2f6473;
-  background-color: #def2f8;
-  border-color: #d1edf6; }
+  color: #00434e;
+  background-color: #cce6ea;
+  border-color: #b8dce2; }
   .alert-info hr {
-    border-top-color: #bce5f2; }
+    border-top-color: #a6d3db; }
   .alert-info .alert-link {
-    color: #20454f; }
+    color: #00171b; }
 
 .alert-warning {
   color: #7d5a29;
@@ -6267,7 +6267,7 @@ input[type="button"].btn-block {
     text-decoration: none;
     background-color: #f8f9fa; }
   .list-group-item-action:active {
-    color: #343a40;
+    color: #212529;
     background-color: #e9ecef; }
 
 .list-group-item {
@@ -6427,15 +6427,15 @@ input[type="button"].btn-block {
     border-color: #1e451e; }
 
 .list-group-item-info {
-  color: #2f6473;
-  background-color: #d1edf6; }
+  color: #00434e;
+  background-color: #b8dce2; }
   .list-group-item-info.list-group-item-action:hover, .list-group-item-info.list-group-item-action:focus {
-    color: #2f6473;
-    background-color: #bce5f2; }
+    color: #00434e;
+    background-color: #a6d3db; }
   .list-group-item-info.list-group-item-action.active {
     color: #fff;
-    background-color: #2f6473;
-    border-color: #2f6473; }
+    background-color: #00434e;
+    border-color: #00434e; }
 
 .list-group-item-warning {
   color: #7d5a29;
@@ -6897,7 +6897,7 @@ a.close.disabled {
 
 .popover-body {
   padding: 0.5rem 0.75rem;
-  color: #343a40; }
+  color: #212529; }
 
 .carousel {
   position: relative; }
@@ -7132,12 +7132,12 @@ button.bg-success:focus {
   background-color: #2a602a !important; }
 
 .bg-info {
-  background-color: #5bc0de !important; }
+  background-color: #008196 !important; }
 
 a.bg-info:hover, a.bg-info:focus,
 button.bg-info:hover,
 button.bg-info:focus {
-  background-color: #31b0d5 !important; }
+  background-color: #005563 !important; }
 
 .bg-warning {
   background-color: #f0ad4e !important; }
@@ -7217,7 +7217,7 @@ button.bg-dark:focus {
   border-color: #398439 !important; }
 
 .border-info {
-  border-color: #5bc0de !important; }
+  border-color: #008196 !important; }
 
 .border-warning {
   border-color: #f0ad4e !important; }
@@ -9687,10 +9687,10 @@ a.text-success:hover, a.text-success:focus {
   color: #224f22 !important; }
 
 .text-info {
-  color: #5bc0de !important; }
+  color: #008196 !important; }
 
 a.text-info:hover, a.text-info:focus {
-  color: #28a1c5 !important; }
+  color: #003f4a !important; }
 
 .text-warning {
   color: #f0ad4e !important; }
@@ -9717,7 +9717,7 @@ a.text-dark:hover, a.text-dark:focus {
   color: #121416 !important; }
 
 .text-body {
-  color: #343a40 !important; }
+  color: #212529 !important; }
 
 .text-muted {
   color: #6c757d !important; }
@@ -10064,7 +10064,7 @@ div.dropdown-item:focus-within {
   color: #398439; }
 
 .highlight {
-  color: #5bc0de; }
+  color: #008196; }
 
 .fitem.advanced .text-info {
   font-weight: bold; }
@@ -11715,7 +11715,7 @@ ul {
   #page-footer a .icon {
     color: #fff; }
   #page-footer a:focus .icon {
-    color: #343a40; }
+    color: #212529; }
 
 .bg-inverse a {
   color: #fff;
@@ -11729,7 +11729,7 @@ ul {
 .dropdown-item a {
   display: block;
   width: 100%;
-  color: #343a40; }
+  color: #212529; }
 
 .dropdown-item:active a {
   color: #fff; }
@@ -11895,7 +11895,7 @@ input[disabled] {
 
 .matchtext {
   background-color: #b5d9f9;
-  color: #343a40;
+  color: #212529;
   height: 1.5rem; }
 
 .border-radius {
@@ -11967,7 +11967,7 @@ input[disabled] {
   color: #0f210f; }
 
 .alert-info a {
-  color: #20454f; }
+  color: #00171b; }
 
 .alert-warning a {
   color: #573e1c; }
@@ -12479,11 +12479,11 @@ input[disabled] {
   width: 4em; }
 
 #adminthemeselector .selectedtheme td.c0 {
-  border: 1px solid #d1edf6;
+  border: 1px solid #b8dce2;
   border-right-width: 0; }
 
 #adminthemeselector .selectedtheme td.c1 {
-  border: 1px solid #d1edf6;
+  border: 1px solid #b8dce2;
   border-left-width: 0; }
 
 .admin_colourpicker,
@@ -12502,12 +12502,12 @@ input[disabled] {
     box-sizing: content-box; }
   .admin_colourpicker .colourdialogue {
     float: left;
-    border: 1px solid #d1edf6; }
+    border: 1px solid #b8dce2; }
   .admin_colourpicker .previewcolour {
-    border: 1px solid #d1edf6;
+    border: 1px solid #b8dce2;
     margin-left: 301px; }
   .admin_colourpicker .currentcolour {
-    border: 1px solid #d1edf6;
+    border: 1px solid #b8dce2;
     margin-left: 301px;
     border-top-width: 0; } }
 
@@ -12580,7 +12580,7 @@ input[disabled] {
 
 #plugins-check-page .pluginupdateinfo,
 #plugins-control-panel .pluginupdateinfo {
-  background-color: #def2f8;
+  background-color: #cce6ea;
   padding: 5px;
   margin: 10px 0;
   border-radius: 5px; }
@@ -12698,7 +12698,7 @@ input[disabled] {
 
 .block .block-controls .dropdown-toggle {
   /* So that the caret takes the colour of the icon. */
-  color: #343a40; }
+  color: #212529; }
 
 [data-region="blocks-column"] {
   width: 360px;
@@ -13726,11 +13726,11 @@ span.editinstructions {
   margin-left: 30px;
   font-size: 0.8203125rem;
   padding: .1em .4em;
-  background-color: #def2f8;
-  color: #5bc0de;
+  background-color: #cce6ea;
+  color: #008196;
   text-decoration: none;
   z-index: 9999;
-  border: 1px solid #d1edf6; }
+  border: 1px solid #b8dce2; }
 
 /* Course drag and drop upload styles */
 #dndupload-status {
@@ -13739,10 +13739,10 @@ span.editinstructions {
   width: 40%;
   margin: 0 30%;
   padding: 6px;
-  border: 1px solid #d1edf6;
+  border: 1px solid #b8dce2;
   text-align: center;
-  background: #def2f8;
-  color: #5bc0de;
+  background: #cce6ea;
+  color: #008196;
   z-index: 1;
   border-radius: 8px; }
 
@@ -14063,33 +14063,33 @@ span.editinstructions {
   #course-category-listings .listing-pagination {
     text-align: center; }
     #course-category-listings .listing-pagination .yui3-button {
-      color: #212529;
-      background-color: #5bc0de;
-      border-color: #5bc0de;
+      color: #fff;
+      background-color: #008196;
+      border-color: #008196;
       border: 0;
       margin: 0.4rem 0.2rem 0.45rem;
       font-size: 10.4px; }
       #course-category-listings .listing-pagination .yui3-button:hover {
         color: #fff;
-        background-color: #3bb4d8;
-        border-color: #31b0d5; }
+        background-color: #006070;
+        border-color: #005563; }
       #course-category-listings .listing-pagination .yui3-button:focus, #course-category-listings .listing-pagination .yui3-button.focus {
         color: #fff;
-        background-color: #3bb4d8;
-        border-color: #31b0d5;
-        box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+        background-color: #006070;
+        border-color: #005563;
+        box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
       #course-category-listings .listing-pagination .yui3-button.disabled, #course-category-listings .listing-pagination .yui3-button:disabled {
-        color: #212529;
-        background-color: #5bc0de;
-        border-color: #5bc0de; }
+        color: #fff;
+        background-color: #008196;
+        border-color: #008196; }
       #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled):active, #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled).active,
       .show > #course-category-listings .listing-pagination .yui3-button.dropdown-toggle {
         color: #fff;
-        background-color: #31b0d5;
-        border-color: #2aaacf; }
+        background-color: #005563;
+        border-color: #004a56; }
         #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled):active:focus, #course-category-listings .listing-pagination .yui3-button:not(:disabled):not(.disabled).active:focus,
         .show > #course-category-listings .listing-pagination .yui3-button.dropdown-toggle:focus {
-          box-shadow: 0 0 0 0.2rem rgba(82, 169, 195, 0.5); }
+          box-shadow: 0 0 0 0.2rem rgba(38, 148, 166, 0.5); }
       #course-category-listings .listing-pagination .yui3-button.active-page {
         color: #fff;
         background-color: #1177d1;
@@ -15847,14 +15847,14 @@ body.path-question-type {
     color: #573e1c; }
 
 .que .formulation {
-  color: #2f6473;
-  background-color: #def2f8;
-  border-color: #d1edf6;
+  color: #00434e;
+  background-color: #cce6ea;
+  border-color: #b8dce2;
   /* stylelint-disable-line max-line-length */ }
   .que .formulation hr {
-    border-top-color: #bce5f2; }
+    border-top-color: #a6d3db; }
   .que .formulation .alert-link {
-    color: #20454f; }
+    color: #00171b; }
 
 .que.multichoice .answer div.r0 .icon.fa-check,
 .que.multichoice .answer div.r1 .icon.fa-check,
@@ -16015,7 +16015,7 @@ body.jsenabled .questionflag input[type=checkbox] {
     margin: 0; }
 
 #page-mod-quiz-edit .questionbankwindow div.header .title {
-  color: #343a40; }
+  color: #212529; }
 
 #page-mod-quiz-edit div.container div.generalbox {
   background-color: transparent;
@@ -16682,7 +16682,7 @@ fieldset.coursesearchbox label {
   padding: 0.2em;
   margin: 0;
   cursor: pointer;
-  color: #343a40; }
+  color: #212529; }
 
 .form-autocomplete-suggestions li:hover {
   background-color: #3f9def;
@@ -16693,7 +16693,7 @@ fieldset.coursesearchbox label {
   color: #495057; }
 
 .form-autocomplete-downarrow {
-  color: #343a40;
+  color: #212529;
   top: 0.2rem;
   right: 0.5rem;
   cursor: pointer; }
@@ -16896,10 +16896,10 @@ select {
   font-weight: inherit; }
 
 .path-mod-forum .subscriptionmode {
-  color: #343a40; }
+  color: #212529; }
 
 .path-mod-forum .activesetting {
-  color: #343a40;
+  color: #212529;
   font-weight: bold; }
 
 .discussion-settings-container .custom-select {
@@ -17801,14 +17801,14 @@ div#dock {
   margin-bottom: 1rem;
   border: 0 solid transparent;
   border-radius: 0.25rem;
-  color: #2f6473;
-  background-color: #def2f8;
-  border-color: #d1edf6;
+  color: #00434e;
+  background-color: #cce6ea;
+  border-color: #b8dce2;
   /* stylelint-disable-line max-line-length */ }
   .assignfeedback_editpdf_widget .label hr {
-    border-top-color: #bce5f2; }
+    border-top-color: #a6d3db; }
   .assignfeedback_editpdf_widget .label .alert-link {
-    color: #20454f; }
+    color: #00171b; }
 
 .assignfeedback_editpdf_menu {
   padding: 0; }
@@ -18064,7 +18064,7 @@ div#dock {
 .generaltable {
   width: 100%;
   margin-bottom: 1rem;
-  color: #343a40; }
+  color: #212529; }
   .generaltable th,
   .generaltable td {
     padding: 0.75rem;
@@ -18081,7 +18081,7 @@ div#dock {
   .generaltable.table-sm td {
     padding: 0.3rem; }
   .generaltable tbody tr:hover {
-    color: #343a40;
+    color: #212529;
     background-color: rgba(0, 0, 0, 0.075); }
 
 table caption {
@@ -18223,7 +18223,7 @@ p.arrow_button {
   box-shadow: inset 0 0 0 2px #fff; }
 
 .btn-info:focus, .btn-info.focus {
-  outline: 0.2rem solid #124a5b;
+  outline: 0.2rem solid black;
   box-shadow: inset 0 0 0 2px #fff; }
 
 .btn-warning:focus, .btn-warning.focus {
@@ -18255,7 +18255,7 @@ p.arrow_button {
   box-shadow: inset 0 0 0 2px #343a40; }
 
 .btn-outline-info:focus, .btn-outline-info.focus {
-  outline: 0.2rem solid #124a5b;
+  outline: 0.2rem solid black;
   box-shadow: inset 0 0 0 2px #343a40; }
 
 .btn-outline-warning:focus, .btn-outline-warning.focus {
index e0af226..a67d8a8 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /theme/* themes,
 information provided here is intended especially for theme designer.
 
-=== 4.0 ===
+=== 3.10 ===
 * The Bootstrap legacy css utilities from Bootstrap 2 and 4alpha have been removed.
 The syntax for the new Bootstrap 4.5 utility classes is {property}{sides}-{breakpoint}-{size} for sm, md, lg, and xl.
 The size values are:
index 7d5bcfe..6fc3910 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021052500.04;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021052500.06;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '4.0dev (Build: 20200822)'; // Human-friendly version name