Merge branch 'MDL-62815_mod_lti' of git://github.com/davosmith/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Mon, 20 Jan 2020 01:53:27 +0000 (09:53 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Mon, 20 Jan 2020 01:53:27 +0000 (09:53 +0800)
196 files changed:
Gruntfile.js
GruntfileComponents.js [new file with mode: 0644]
admin/cli/upgrade.php
admin/index.php
admin/renderer.php
admin/roles/classes/define_role_table_advanced.php
admin/settings/server.php
admin/templates/setting_configpasswordunmask.mustache
admin/tool/customlang/locallib.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_request.php
admin/tool/dataprivacy/classes/task/process_data_request_task.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/db/install.xml
admin/tool/dataprivacy/db/upgrade.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/settings.php
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/behat/datadelete.feature
admin/tool/dataprivacy/tests/behat/dataexport.feature
admin/tool/dataprivacy/version.php
admin/tool/policy/amd/build/jquery-eu-cookie-law-popup.min.js
admin/tool/policy/amd/build/jquery-eu-cookie-law-popup.min.js.map
admin/tool/policy/amd/src/jquery-eu-cookie-law-popup.js
admin/tool/policy/readme_moodle.txt
admin/tool/policy/thirdpartylibs.xml
auth/cas/CAS/CAS.php
auth/cas/CAS/CAS/Client.php
auth/cas/CAS/README.md
auth/cas/CAS/moodle_readme.txt
babel-plugin-add-module-to-define.js
backup/backup.class.php
backup/backup.php
backup/controller/backup_controller.class.php
backup/controller/base_controller.class.php
backup/controller/restore_controller.class.php
backup/externallib.php
backup/restore.php
calendar/classes/local/event/strategies/raw_event_retrieval_strategy.php
calendar/lib.php
calendar/tests/lib_test.php
competency/tests/privacy_test.php
course/classes/category.php
course/lib.php
enrol/meta/lib.php
grade/amd/build/edittree_index.min.js
grade/amd/build/edittree_index.min.js.map
grade/amd/src/edittree_index.js
grade/edit/tree/lib.php
grade/report/singleview/templates/bulk_insert.mustache
grade/templates/edit_tree.mustache
install/lang/ar_wp/langconfig.php [new file with mode: 0644]
install/lang/km/admin.php
lang/en/error.php
lang/en/moodle.php
lang/en/plugin.php
lang/en/role.php
lib/adminlib.php
lib/amd/build/loglevel.min.js
lib/amd/build/loglevel.min.js.map
lib/amd/build/mustache.min.js
lib/amd/build/mustache.min.js.map
lib/amd/src/loglevel.js
lib/amd/src/mustache.js
lib/antivirus/clamav/adminlib.php
lib/antivirus/clamav/classes/scanner.php
lib/antivirus/clamav/lang/en/antivirus_clamav.php
lib/antivirus/clamav/settings.php
lib/antivirus/clamav/tests/scanner_test.php
lib/antivirus/clamav/version.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/base.php
lib/classes/task/adhoc_task.php
lib/classes/task/manager.php
lib/cronlib.php
lib/datalib.php
lib/db/access.php
lib/db/install.php
lib/db/install.xml
lib/db/upgrade.php
lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-debug.js
lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-min.js
lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button.js
lib/editor/atto/plugins/accessibilitychecker/yui/src/button/js/button.js
lib/editor/tests/fixtures/editor_form.php
lib/filelib.php
lib/filestorage/file_system.php
lib/form/duration.php
lib/form/filetypes.php
lib/form/templatable_form_element.php
lib/htmlpurifier/HTMLPurifier.php
lib/htmlpurifier/HTMLPurifier/AttrDef/HTML/Bool.php
lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php
lib/htmlpurifier/HTMLPurifier/CSSDefinition.php
lib/htmlpurifier/HTMLPurifier/Config.php
lib/htmlpurifier/HTMLPurifier/ConfigSchema.php
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema.ser
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/AutoFormat.RemoveEmpty.RemoveNbsp.txt
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.AllowParseManyTags.txt [new file with mode: 0644]
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.ColorKeywords.txt
lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer/README [changed mode: 0644->0755]
lib/htmlpurifier/HTMLPurifier/EntityParser.php
lib/htmlpurifier/HTMLPurifier/HTMLModule.php
lib/htmlpurifier/HTMLPurifier/HTMLModule/SafeScripting.php
lib/htmlpurifier/HTMLPurifier/Language/messages/en-x-test.php
lib/htmlpurifier/HTMLPurifier/Language/messages/en-x-testmini.php
lib/htmlpurifier/HTMLPurifier/Lexer/DOMLex.php
lib/htmlpurifier/HTMLPurifier/Printer/ConfigForm.php
lib/htmlpurifier/HTMLPurifier/VarParser.php
lib/htmlpurifier/HTMLPurifier/VarParser/Flexible.php
lib/htmlpurifier/readme_moodle.txt
lib/mlbackend/python/classes/processor.php
lib/mustache/readme_moodle.txt
lib/mustache/src/Mustache/Engine.php
lib/mustache/src/Mustache/Parser.php
lib/mustache/src/Mustache/Tokenizer.php
lib/scssphp/Cache.php
lib/scssphp/Colors.php
lib/scssphp/Compiler.php
lib/scssphp/Formatter.php
lib/scssphp/Formatter/Expanded.php
lib/scssphp/Formatter/Nested.php
lib/scssphp/Node/Number.php
lib/scssphp/Parser.php
lib/scssphp/SourceMap/Base64VLQ.php
lib/scssphp/SourceMap/Base64VLQEncoder.php [deleted file]
lib/scssphp/Version.php
lib/scssphp/moodle_readme.txt
lib/setup.php
lib/setuplib.php
lib/tests/adhoc_task_test.php
lib/tests/behat/app_behat_runtime.js
lib/tests/fixtures/repeated_events.ics [new file with mode: 0644]
lib/tests/fixtures/testable_plugin_manager.php
lib/tests/fixtures/testable_plugininfo_base.php
lib/tests/plugin_manager_test.php
lib/tests/plugininfo/base_test.php [new file with mode: 0644]
lib/tests/session_manager_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/upgradelib.php
lib/yui/build/moodle-core-chooserdialogue/moodle-core-chooserdialogue-debug.js
lib/yui/build/moodle-core-chooserdialogue/moodle-core-chooserdialogue-min.js
lib/yui/build/moodle-core-chooserdialogue/moodle-core-chooserdialogue.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js
lib/yui/src/chooserdialogue/js/chooserdialogue.js
lib/yui/src/notification/js/dialogue.js
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_overview_section.min.js.map
message/amd/src/message_drawer_view_overview_section.js
mod/assign/locallib.php
mod/feedback/classes/responses_table.php
mod/forum/amd/build/local/layout/fullscreen.min.js
mod/forum/amd/build/local/layout/fullscreen.min.js.map
mod/forum/amd/src/local/layout/fullscreen.js
mod/forum/classes/local/exporters/author.php
mod/forum/classes/local/exporters/discussion_summary.php
mod/forum/classes/local/factories/url.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/post_form.php
mod/forum/db/upgrade.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/post.php
mod/forum/templates/discussion_list.mustache
mod/forum/templates/forum_action_menu.mustache
mod/forum/templates/forum_discussion_favourite_toggle.mustache
mod/forum/templates/forum_discussion_nested_v2_first_post.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/inpage_reply.mustache
mod/forum/templates/inpage_reply_v2.mustache
mod/forum/templates/local/grades/local/grader/grading.mustache
mod/forum/templates/local/grades/local/grader/navigation.mustache
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/exporters_author_test.php
mod/forum/tests/externallib_test.php
mod/forum/version.php
mod/glossary/import_form.php
mod/quiz/addrandomform.php
mod/quiz/styles.css
mod/wiki/comments_form.php
mod/wiki/parser/parser.php
question/engine/questionusage.php
report/log/classes/table_log.php
report/loglive/classes/renderable.php
repository/dropbox/classes/dropbox.php
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/src/loader.js
theme/boost/templates/navbar-secure.mustache
theme/boost/templates/navbar.mustache
theme/classic/templates/navbar-secure.mustache
theme/classic/templates/navbar.mustache
version.php

index f05bb19..f45c261 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+/* eslint-env node */
+
 /**
- * Grunt configuration
+ * Calculate the cwd, taking into consideration the `root` option (for Windows).
+ *
+ * @param {Object} grunt
+ * @returns {String} The current directory as best we can determine
  */
+const getCwd = grunt => {
+    const fs = require('fs');
+    const path = require('path');
 
-/* eslint-env node */
-module.exports = function(grunt) {
-    var path = require('path'),
-        tasks = {},
-        cwd = process.env.PWD || process.cwd(),
-        async = require('async'),
-        DOMParser = require('xmldom').DOMParser,
-        xpath = require('xpath'),
-        semver = require('semver'),
-        watchman = require('fb-watchman'),
-        watchmanClient = new watchman.Client(),
-        gruntFilePath = process.cwd();
-
-    // Verify the node version is new enough.
-    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
-    var actual = semver.valid(process.version);
-    if (!semver.satisfies(actual, expected)) {
-        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
-    }
+    let cwd = fs.realpathSync(process.env.PWD || process.cwd());
 
     // Windows users can't run grunt in a subdirectory, so allow them to set
     // the root by passing --root=path/to/dir.
     if (grunt.option('root')) {
-        var root = grunt.option('root');
+        const root = grunt.option('root');
         if (grunt.file.exists(__dirname, root)) {
-            cwd = path.join(__dirname, root);
+            cwd = fs.realpathSync(path.join(__dirname, root));
             grunt.log.ok('Setting root to ' + cwd);
         } else {
             grunt.fail.fatal('Setting root to ' + root + ' failed - path does not exist');
         }
     }
 
+    return cwd;
+};
+
+/**
+ * Register any stylelint tasks.
+ *
+ * @param {Object} grunt
+ * @param {Array} files
+ * @param {String} fullRunDir
+ */
+const registerStyleLintTasks = (grunt, files, fullRunDir) => {
+    const getCssConfigForFiles = files => {
+        return {
+            stylelint: {
+                css: {
+                    // Use a fully-qualified path.
+                    src: files,
+                    options: {
+                        configOverrides: {
+                            rules: {
+                                // These rules have to be disabled in .stylelintrc for scss compat.
+                                "at-rule-no-unknown": true,
+                            }
+                        }
+                    }
+                },
+            },
+        };
+    };
+
+    const getScssConfigForFiles = files => {
+        return {
+            stylelint: {
+                scss: {
+                    options: {syntax: 'scss'},
+                    src: files,
+                },
+            },
+        };
+    };
+
+    let hasCss = true;
+    let hasScss = true;
+
+    if (files) {
+        // Specific files were passed. Just set them up.
+        grunt.config.merge(getCssConfigForFiles(files));
+        grunt.config.merge(getScssConfigForFiles(files));
+    } else {
+        // The stylelint system does not handle the case where there was no file to lint.
+        // Check whether there are any files to lint in the current directory.
+        const glob = require('glob');
+
+        const scssSrc = [];
+        glob.sync(`${fullRunDir}/**/*.scss`).forEach(path => scssSrc.push(path));
+
+        if (scssSrc.length) {
+            grunt.config.merge(getScssConfigForFiles(scssSrc));
+        } else {
+            hasScss = false;
+        }
+
+        const cssSrc = [];
+        glob.sync(`${fullRunDir}/**/*.css`).forEach(path => cssSrc.push(path));
+
+        if (cssSrc.length) {
+            grunt.config.merge(getCssConfigForFiles(cssSrc));
+        } else {
+            hasCss = false;
+        }
+    }
+
+    const scssTasks = ['sass'];
+    if (hasScss) {
+        scssTasks.unshift('stylelint:scss');
+    }
+    grunt.registerTask('scss', scssTasks);
+
+    const cssTasks = [];
+    if (hasCss) {
+        cssTasks.push('stylelint:css');
+    }
+    grunt.registerTask('rawcss', cssTasks);
+
+    grunt.registerTask('css', ['scss', 'rawcss']);
+};
+
+/**
+ * Grunt configuration.
+ *
+ * @param {Object} grunt
+ */
+module.exports = function(grunt) {
+    const path = require('path');
+    const tasks = {};
+    const async = require('async');
+    const DOMParser = require('xmldom').DOMParser;
+    const xpath = require('xpath');
+    const semver = require('semver');
+    const watchman = require('fb-watchman');
+    const watchmanClient = new watchman.Client();
+    const fs = require('fs');
+    const ComponentList = require(path.resolve('GruntfileComponents.js'));
+
+    // Verify the node version is new enough.
+    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
+    var actual = semver.valid(process.version);
+    if (!semver.satisfies(actual, expected)) {
+        grunt.fail.fatal('Node version not satisfied. Require ' + expected + ', version installed: ' + actual);
+    }
+
+    // Detect directories:
+    // * gruntFilePath          The real path on disk to this Gruntfile.js
+    // * cwd                    The current working directory, which can be overridden by the `root` option
+    // * relativeCwd            The cwd, relative to the Gruntfile.js
+    // * componentDirectory     The root directory of the component if the cwd is in a valid component
+    // * inComponent            Whether the cwd is in a valid component
+    // * runDir                 The componentDirectory or cwd if not in a component, relative to Gruntfile.js
+    // * fullRunDir             The full path to the runDir
+    const gruntFilePath = fs.realpathSync(process.cwd());
+    const cwd = getCwd(grunt);
+    const relativeCwd = cwd.replace(new RegExp(`${gruntFilePath}/?`), '');
+    const componentDirectory = ComponentList.getOwningComponentDirectory(relativeCwd);
+    const inComponent = !!componentDirectory;
+    const runDir = inComponent ? componentDirectory : relativeCwd;
+    const fullRunDir = fs.realpathSync(gruntFilePath + path.sep + runDir);
+    grunt.log.debug(`The cwd was detected as ${cwd} with a fullRunDir of ${fullRunDir}`);
+
+    if (inComponent) {
+        grunt.log.ok(`Running tasks for component directory ${componentDirectory}`);
+    }
+
     var files = null;
     if (grunt.option('files')) {
         // Accept a comma separated list of files to process.
         files = grunt.option('files').split(',');
     }
 
-    var inAMD = path.basename(cwd) == 'amd';
+    const inAMD = path.basename(cwd) == 'amd';
 
     // Globbing pattern for matching all AMD JS source files.
-    var amdSrc = [];
-    if (inAMD) {
-        amdSrc.push(cwd + "/src/*.js");
-        amdSrc.push(cwd + "/src/**/*.js");
+    let amdSrc = [];
+    if (inComponent) {
+        amdSrc.push(componentDirectory + "/amd/src/*.js");
+        amdSrc.push(componentDirectory + "/amd/src/**/*.js");
     } else {
-        amdSrc.push("**/amd/src/*.js");
-        amdSrc.push("**/amd/src/**/*.js");
+        amdSrc = ComponentList.getAmdSrcGlobList();
+    }
+
+    let yuiSrc = [];
+    if (inComponent) {
+        yuiSrc.push(componentDirectory + "/yui/src/**/*.js");
+    } else {
+        yuiSrc = ComponentList.getYuiSrcGlobList(gruntFilePath + '/');
     }
 
     /**
@@ -97,28 +225,29 @@ module.exports = function(grunt) {
      * @return {array} The list of thirdparty paths.
      */
     var getThirdPartyPathsFromXML = function() {
-        var thirdpartyfiles = grunt.file.expand('*/**/thirdpartylibs.xml');
-        var libs = ['node_modules/', 'vendor/'];
+        const thirdpartyfiles = ComponentList.getThirdPartyLibsList(gruntFilePath + '/');
+        const libs = ['node_modules/', 'vendor/'];
 
         thirdpartyfiles.forEach(function(file) {
-          var dirname = path.dirname(file);
+            const dirname = path.dirname(file);
 
-          var doc = new DOMParser().parseFromString(grunt.file.read(file));
-          var nodes = xpath.select("/libraries/library/location/text()", doc);
+            const doc = new DOMParser().parseFromString(grunt.file.read(file));
+            const nodes = xpath.select("/libraries/library/location/text()", doc);
 
-          nodes.forEach(function(node) {
-            var lib = path.join(dirname, node.toString());
-            if (grunt.file.isDir(lib)) {
-                // Ensure trailing slash on dirs.
-                lib = lib.replace(/\/?$/, '/');
-            }
+            nodes.forEach(function(node) {
+                let lib = path.join(dirname, node.toString());
+                if (grunt.file.isDir(lib)) {
+                    // Ensure trailing slash on dirs.
+                    lib = lib.replace(/\/?$/, '/');
+                }
 
-            // Look for duplicate paths before adding to array.
-            if (libs.indexOf(lib) === -1) {
-                libs.push(lib);
-            }
-          });
+                // Look for duplicate paths before adding to array.
+                if (libs.indexOf(lib) === -1) {
+                    libs.push(lib);
+                }
+            });
         });
+
         return libs;
     };
 
@@ -130,7 +259,7 @@ module.exports = function(grunt) {
             options: {quiet: !grunt.option('show-lint-warnings')},
             amd: {src: files ? files : amdSrc},
             // Check YUI module source files.
-            yui: {src: files ? files : ['**/yui/src/**/*.js', '!*/**/yui/src/*/meta/*.js']}
+            yui: {src: files ? files : yuiSrc},
         },
         babel: {
             options: {
@@ -198,30 +327,41 @@ module.exports = function(grunt) {
                 nospawn: true // We need not to spawn so config can be changed dynamically.
             },
             amd: {
-                files: ['**/amd/src/**/*.js'],
+                files: inComponent
+                    ? ['amd/src/*.js', 'amd/src/**/*.js']
+                    : ['**/amd/src/**/*.js'],
                 tasks: ['amd']
             },
             boost: {
-                files: ['**/theme/boost/scss/**/*.scss'],
+                files: [inComponent ? 'scss/**/*.scss' : 'theme/boost/scss/**/*.scss'],
                 tasks: ['scss']
             },
             rawcss: {
-                files: ['**/*.css', '**/theme/**/!(moodle.css|editor.css)'],
+                files: [
+                    '**/*.css',
+                ],
+                excludes: [
+                    '**/moodle.css',
+                    '**/editor.css',
+                ],
                 tasks: ['rawcss']
             },
             yui: {
-                files: ['**/yui/src/**/*.js'],
+                files: inComponent
+                    ? ['yui/src/*.json', 'yui/src/**/*.js']
+                    : ['**/yui/src/**/*.js'],
                 tasks: ['yui']
             },
             gherkinlint: {
-                files: ['**/tests/behat/*.feature'],
+                files: [inComponent ? 'tests/behat/*.feature' : '**/tests/behat/*.feature'],
                 tasks: ['gherkinlint']
             }
         },
         shifter: {
             options: {
                 recursive: true,
-                paths: files ? files : [cwd]
+                // Shifter takes a relative path.
+                paths: files ? files : [runDir]
             }
         },
         gherkinlint: {
@@ -229,42 +369,30 @@ module.exports = function(grunt) {
                 files: files ? files : ['**/tests/behat/*.feature'],
             }
         },
-        stylelint: {
-            scss: {
-                options: {syntax: 'scss'},
-                src: files ? files : ['*/**/*.scss']
-            },
-            css: {
-                src: files ? files : ['*/**/*.css'],
-                options: {
-                    configOverrides: {
-                        rules: {
-                            // These rules have to be disabled in .stylelintrc for scss compat.
-                            "at-rule-no-unknown": true,
-                        }
-                    }
-                }
-            }
-        }
     });
 
     /**
      * Generate ignore files (utilising thirdpartylibs.xml data)
      */
     tasks.ignorefiles = function() {
-      // An array of paths to third party directories.
-      var thirdPartyPaths = getThirdPartyPathsFromXML();
-      // Generate .eslintignore.
-      var eslintIgnores = ['# Generated by "grunt ignorefiles"', '*/**/yui/src/*/meta/', '*/**/build/'].concat(thirdPartyPaths);
-      grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
-      // Generate .stylelintignore.
-      var stylelintIgnores = [
-          '# Generated by "grunt ignorefiles"',
-          '**/yui/build/*',
-          'theme/boost/style/moodle.css',
-          'theme/classic/style/moodle.css',
-      ].concat(thirdPartyPaths);
-      grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+        // An array of paths to third party directories.
+        const thirdPartyPaths = getThirdPartyPathsFromXML();
+        // Generate .eslintignore.
+        const eslintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            '*/**/yui/src/*/meta/',
+            '*/**/build/',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+
+        // Generate .stylelintignore.
+        const stylelintIgnores = [
+            '# Generated by "grunt ignorefiles"',
+            '**/yui/build/*',
+            'theme/boost/style/moodle.css',
+            'theme/classic/style/moodle.css',
+        ].concat(thirdPartyPaths);
+        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
     };
 
     /**
@@ -428,7 +556,7 @@ module.exports = function(grunt) {
                             grunt: true,
                             // Run from current working dir and inherit stdio from process.
                             opts: {
-                                cwd: cwd,
+                                cwd: fullRunDir,
                                 stdio: 'inherit'
                             },
                             args: [task, filesOption]
@@ -454,18 +582,26 @@ module.exports = function(grunt) {
             );
         };
 
-        var watchConfig = grunt.config.get(['watch']);
-        watchConfig = Object.keys(watchConfig).reduce(function(carry, key) {
+        const originalWatchConfig = grunt.config.get(['watch']);
+        const watchConfig = Object.keys(originalWatchConfig).reduce(function(carry, key) {
             if (key == 'options') {
                 return carry;
             }
 
-            var value = watchConfig[key];
-            var fileGlobs = value.files;
-            var taskNames = value.tasks;
+            const value = originalWatchConfig[key];
+
+            const taskNames = value.tasks;
+            const files = value.files;
+            let excludes = [];
+            if (value.excludes) {
+                excludes = value.excludes;
+            }
 
             taskNames.forEach(function(taskName) {
-                carry[taskName] = fileGlobs;
+                carry[taskName] = {
+                    files,
+                    excludes,
+                };
             });
 
             return carry;
@@ -499,12 +635,14 @@ module.exports = function(grunt) {
             resp.files.forEach(function(file) {
                 grunt.log.ok('File changed: ' + file.name);
 
-                var fullPath = cwd + '/' + file.name;
+                var fullPath = fullRunDir + '/' + file.name;
                 Object.keys(watchConfig).forEach(function(task) {
-                    var fileGlobs = watchConfig[task];
-                    var match = fileGlobs.every(function(fileGlob) {
-                        return grunt.file.isMatch(fileGlob, fullPath);
+
+                    const fileGlobs = watchConfig[task].files;
+                    var match = fileGlobs.some(function(fileGlob) {
+                        return grunt.file.isMatch(`**/${fileGlob}`, fullPath);
                     });
+
                     if (match) {
                         // If we are watching a subdirectory then the file.name will be relative
                         // to that directory. However the grunt tasks  expect the file paths to be
@@ -536,7 +674,7 @@ module.exports = function(grunt) {
         });
 
         // Initiate the watch on the current directory.
-        watchmanClient.command(['watch-project', cwd], function(watchError, watchResponse) {
+        watchmanClient.command(['watch-project', fullRunDir], function(watchError, watchResponse) {
             if (watchError) {
                 grunt.log.error('Error initiating watch:', watchError);
                 watchTaskDone(1);
@@ -558,18 +696,40 @@ module.exports = function(grunt) {
                     return;
                 }
 
-                // Use the matching patterns specified in the watch config.
+                // Generate the expression query used by watchman.
+                // Documentation is limited, but see https://facebook.github.io/watchman/docs/expr/allof.html for examples.
+                // We generate an expression to match any value in the files list of all of our tasks, but excluding
+                // all value in the  excludes list of that task.
+                //
+                // [anyof, [
+                //      [allof, [
+                //          [anyof, [
+                //              ['match', validPath, 'wholename'],
+                //              ['match', validPath, 'wholename'],
+                //          ],
+                //          [not,
+                //              [anyof, [
+                //                  ['match', invalidPath, 'wholename'],
+                //                  ['match', invalidPath, 'wholename'],
+                //              ],
+                //          ],
+                //      ],
+                var matchWholeName = fileGlob => ['match', fileGlob, 'wholename'];
                 var matches = Object.keys(watchConfig).map(function(task) {
-                    var fileGlobs = watchConfig[task];
-                    var fileGlobMatches = fileGlobs.map(function(fileGlob) {
-                        return ['match', fileGlob, 'wholename'];
-                    });
+                    const matchAll = [];
+                    matchAll.push(['anyof'].concat(watchConfig[task].files.map(matchWholeName)));
 
-                    return ['allof'].concat(fileGlobMatches);
+                    if (watchConfig[task].excludes.length) {
+                        matchAll.push(['not', ['anyof'].concat(watchConfig[task].excludes.map(matchWholeName))]);
+                    }
+
+                    return ['allof'].concat(matchAll);
                 });
 
+                matches = ['anyof'].concat(matches);
+
                 var sub = {
-                    expression: ["anyof"].concat(matches),
+                    expression: matches,
                     // Which fields we're interested in.
                     fields: ["name", "size", "type"],
                     // Add our time constraint.
@@ -589,7 +749,7 @@ module.exports = function(grunt) {
                         return;
                     }
 
-                    grunt.log.ok('Listening for changes to files in ' + cwd);
+                    grunt.log.ok('Listening for changes to files in ' + fullRunDir);
                 });
             });
         });
@@ -634,10 +794,8 @@ module.exports = function(grunt) {
     grunt.registerTask('amd', ['eslint:amd', 'babel']);
     grunt.registerTask('js', ['amd', 'yui']);
 
-    // Register CSS taks.
-    grunt.registerTask('css', ['stylelint:scss', 'sass', 'stylelint:css']);
-    grunt.registerTask('scss', ['stylelint:scss', 'sass']);
-    grunt.registerTask('rawcss', ['stylelint:css']);
+    // Register CSS tasks.
+    registerStyleLintTasks(grunt, files, fullRunDir);
 
     // Register the startup task.
     grunt.registerTask('startup', 'Run the correct tasks for the current directory', tasks.startup);
diff --git a/GruntfileComponents.js b/GruntfileComponents.js
new file mode 100644 (file)
index 0000000..06ed999
--- /dev/null
@@ -0,0 +1,185 @@
+// 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/>.
+
+/**
+ * Helper functions for working with Moodle component names, directories, and sources.
+ *
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+"use strict";
+/* eslint-env node */
+
+/** @var {Object} A list of subsystems in Moodle */
+const componentData = {};
+
+/**
+ * Load details of all moodle modules.
+ *
+ * @returns {object}
+ */
+const fetchComponentData = () => {
+    const fs = require('fs');
+    const path = require('path');
+    const glob = require('glob');
+    const gruntFilePath = process.cwd();
+
+    if (!Object.entries(componentData).length) {
+        componentData.subsystems = {};
+        componentData.pathList = [];
+
+        // Fetch the component definiitions from the distributed JSON file.
+        const components = JSON.parse(fs.readFileSync(`${gruntFilePath}/lib/components.json`));
+
+        // Build the list of moodle subsystems.
+        componentData.subsystems.lib = 'core';
+        componentData.pathList.push(process.cwd() + path.sep + 'lib');
+        for (const [component, thisPath] of Object.entries(components.subsystems)) {
+            if (thisPath) {
+                // Prefix "core_" to the front of the subsystems.
+                componentData.subsystems[thisPath] = `core_${component}`;
+                componentData.pathList.push(process.cwd() + path.sep + thisPath);
+            }
+        }
+
+        // The list of components incldues the list of subsystems.
+        componentData.components = componentData.subsystems;
+
+        // Go through each of the plugintypes.
+        Object.entries(components.plugintypes).forEach(([pluginType, pluginTypePath]) => {
+            // We don't allow any code in this place..?
+            glob.sync(`${pluginTypePath}/*/version.php`).forEach(versionPath => {
+                const componentPath = fs.realpathSync(path.dirname(versionPath));
+                const componentName = path.basename(componentPath);
+                const frankenstyleName = `${pluginType}_${componentName}`;
+                componentData.components[`${pluginTypePath}/${componentName}`] = frankenstyleName;
+                componentData.pathList.push(componentPath);
+
+                // Look for any subplugins.
+                const subPluginConfigurationFile = `${componentPath}/db/subplugins.json`;
+                if (fs.existsSync(subPluginConfigurationFile)) {
+                    const subpluginList = JSON.parse(fs.readFileSync(fs.realpathSync(subPluginConfigurationFile)));
+
+                    Object.entries(subpluginList.plugintypes).forEach(([subpluginType, subpluginTypePath]) => {
+                        glob.sync(`${subpluginTypePath}/*/version.php`).forEach(versionPath => {
+                            const componentPath = fs.realpathSync(path.dirname(versionPath));
+                            const componentName = path.basename(componentPath);
+                            const frankenstyleName = `${subpluginType}_${componentName}`;
+
+                            componentData.components[`${subpluginTypePath}/${componentName}`] = frankenstyleName;
+                            componentData.pathList.push(componentPath);
+                        });
+                    });
+                }
+            });
+        });
+
+    }
+
+    return componentData;
+};
+
+/**
+ * Get the list of paths to build AMD sources.
+ *
+ * @returns {Array}
+ */
+const getAmdSrcGlobList = () => {
+    const globList = [];
+    fetchComponentData().pathList.forEach(componentPath => {
+        globList.push(`${componentPath}/amd/src/*.js`);
+        globList.push(`${componentPath}/amd/src/**/*.js`);
+    });
+
+    return globList;
+};
+
+/**
+ * Get the list of paths to build YUI sources.
+ *
+ * @param {String} relativeTo
+ * @returns {Array}
+ */
+const getYuiSrcGlobList = relativeTo => {
+    const globList = [];
+    fetchComponentData().pathList.forEach(componentPath => {
+        const relativeComponentPath = componentPath.replace(relativeTo, '');
+        globList.push(`${relativeComponentPath}/yui/src/**/*.js`);
+    });
+
+    return globList;
+};
+
+/**
+ * Get the list of paths to thirdpartylibs.xml.
+ *
+ * @param {String} relativeTo
+ * @returns {Array}
+ */
+const getThirdPartyLibsList = relativeTo => {
+    const fs = require('fs');
+
+    return fetchComponentData().pathList
+        .map(componentPath => componentPath.replace(relativeTo, '') + '/thirdpartylibs.xml')
+        .filter(path => fs.existsSync(path))
+        .sort();
+};
+
+/**
+ * Find the name of the component matching the specified path.
+ *
+ * @param {String} path
+ * @returns {String|null} Name of matching component.
+ */
+const getComponentFromPath = path => {
+    const componentList = fetchComponentData().components;
+
+    if (componentList.hasOwnProperty(path)) {
+        return componentList[path];
+    }
+
+    return null;
+};
+
+/**
+ * Check whether the supplied path, relative to the Gruntfile.js, is in a known component.
+ *
+ * @param {String} checkPath The path to check
+ * @returns {String|null}
+ */
+const getOwningComponentDirectory = checkPath => {
+    const path = require('path');
+
+    const pathList = fetchComponentData().components;
+    for (const componentPath of Object.keys(pathList)) {
+        if (checkPath === componentPath) {
+            return componentPath;
+        }
+        if (checkPath.startsWith(componentPath + path.sep)) {
+            return componentPath;
+        }
+    }
+
+    return null;
+};
+
+module.exports = {
+    getAmdSrcGlobList,
+    getComponentFromPath,
+    getOwningComponentDirectory,
+    getYuiSrcGlobList,
+    getThirdPartyLibsList,
+};
index d827e6d..12904e5 100644 (file)
@@ -137,7 +137,7 @@ if (!$envstatus) {
 
 // Test plugin dependencies.
 $failed = array();
-if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
     cli_problem(get_string('pluginscheckfailed', 'admin', array('pluginslist' => implode(', ', array_unique($failed)))));
     cli_error(get_string('pluginschecktodo', 'admin'));
 }
index c34a437..c95c73c 100644 (file)
@@ -241,7 +241,7 @@ if (!core_tables_exist()) {
 
     // check plugin dependencies
     $failed = array();
-    if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+    if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
         $PAGE->navbar->add(get_string('pluginscheck', 'admin'));
         $PAGE->set_title($strinstallation);
         $PAGE->set_heading($strinstallation . ' - Moodle ' . $CFG->target_release);
@@ -508,7 +508,7 @@ if (!$cache and $version > $CFG->version) {  // upgrade
     } else {
         // Always verify plugin dependencies!
         $failed = array();
-        if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+        if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
             echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
             die();
         }
@@ -682,7 +682,7 @@ if (!$cache and moodle_needs_upgrading()) {
 
         // Make sure plugin dependencies are always checked.
         $failed = array();
-        if (!$pluginman->all_plugins_ok($version, $failed)) {
+        if (!$pluginman->all_plugins_ok($version, $failed, $CFG->branch)) {
             $output = $PAGE->get_renderer('core', 'admin');
             echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
             die();
index 5ba612e..4dbe8c0 100644 (file)
@@ -983,7 +983,7 @@ class core_admin_renderer extends plugin_renderer_base {
      * @return string HTML code
      */
     public function plugins_check_table(core_plugin_manager $pluginman, $version, array $options = array()) {
-
+        global $CFG;
         $plugininfo = $pluginman->get_plugins();
 
         if (empty($plugininfo)) {
@@ -1069,8 +1069,10 @@ class core_admin_renderer extends plugin_renderer_base {
                 }
 
                 $coredependency = $plugin->is_core_dependency_satisfied($version);
+                $incompatibledependency = $plugin->is_core_compatible_satisfied($CFG->branch);
+
                 $otherpluginsdependencies = $pluginman->are_dependencies_satisfied($plugin->get_other_required_plugins());
-                $dependenciesok = $coredependency && $otherpluginsdependencies;
+                $dependenciesok = $coredependency && $otherpluginsdependencies && $incompatibledependency;
 
                 $statuscode = $plugin->get_status();
                 $row->attributes['class'] .= ' status-' . $statuscode;
@@ -1120,8 +1122,11 @@ class core_admin_renderer extends plugin_renderer_base {
                 }
 
                 $status = new html_table_cell($sourcelabel.' '.$status);
-
-                $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version));
+                if ($plugin->pluginsupported != null) {
+                    $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version, $CFG->branch));
+                } else {
+                    $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version));
+                }
 
                 $statusisboring = in_array($statuscode, array(
                         core_plugin_manager::PLUGIN_STATUS_NODB, core_plugin_manager::PLUGIN_STATUS_UPTODATE));
@@ -1452,15 +1457,17 @@ class core_admin_renderer extends plugin_renderer_base {
      * @param \core\plugininfo\base $plugin the plugin we are rendering the row for.
      * @param core_plugin_manager $pluginman provides data on all the plugins.
      * @param string $version
+     * @param int $branch the current Moodle branch
      * @return string HTML code
      */
-    protected function required_column(\core\plugininfo\base $plugin, core_plugin_manager $pluginman, $version) {
+    protected function required_column(\core\plugininfo\base $plugin, core_plugin_manager $pluginman, $version, $branch = null) {
 
         $requires = array();
         $displayuploadlink = false;
         $displayupdateslink = false;
 
-        foreach ($pluginman->resolve_requirements($plugin, $version) as $reqname => $reqinfo) {
+        $requirements = $pluginman->resolve_requirements($plugin, $version, $branch);
+        foreach ($requirements as $reqname => $reqinfo) {
             if ($reqname === 'core') {
                 if ($reqinfo->status == $pluginman::REQUIREMENT_STATUS_OK) {
                     $class = 'requires-ok';
@@ -1469,7 +1476,19 @@ class core_admin_renderer extends plugin_renderer_base {
                     $class = 'requires-failed';
                     $label = html_writer::span(get_string('dependencyfails', 'core_plugin'), 'badge badge-danger');
                 }
-                if ($reqinfo->reqver != ANY_VERSION) {
+
+                if ($branch != null && !$plugin->is_core_compatible_satisfied($branch)) {
+                    $requires[] = html_writer::tag('li',
+                    html_writer::span(get_string('incompatibleversion', 'core_plugin', $branch), 'dep dep-core').
+                    ' '.$label, array('class' => $class));
+
+                } else if ($branch != null && $plugin->pluginsupported != null) {
+                    $requires[] = html_writer::tag('li',
+                        html_writer::span(get_string('moodlebranch', 'core_plugin',
+                        array('min' => $plugin->pluginsupported[0], 'max' => $plugin->pluginsupported[1])), 'dep dep-core').
+                        ' '.$label, array('class' => $class));
+
+                } else if ($reqinfo->reqver != ANY_VERSION) {
                     $requires[] = html_writer::tag('li',
                         html_writer::span(get_string('moodleversion', 'core_plugin', $plugin->versionrequires), 'dep dep-core').
                         ' '.$label, array('class' => $class));
@@ -1556,6 +1575,13 @@ class core_admin_renderer extends plugin_renderer_base {
             );
         }
 
+        // Check if supports is present, and $branch is not in, only if $incompatible check was ok.
+        if ($plugin->pluginsupported != null && $class == 'requires-ok' && $branch != null) {
+            if ($pluginman->check_explicitly_supported($plugin, $branch) == $pluginman::VERSION_NOT_SUPPORTED) {
+                $out .= html_writer::div(get_string('notsupported', 'core_plugin', $branch));
+            }
+        }
+
         return $out;
 
     }
index c0e3d8d..6fb58c6 100644 (file)
@@ -158,19 +158,19 @@ class core_role_define_role_table_advanced extends core_role_capability_table_wi
         // Allowed roles.
         $allow = optional_param_array('allowassign', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowassign = $allow;
+            $this->allowassign = array_filter($allow);
         }
         $allow = optional_param_array('allowoverride', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowoverride = $allow;
+            $this->allowoverride = array_filter($allow);
         }
         $allow = optional_param_array('allowswitch', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowswitch = $allow;
+            $this->allowswitch = array_filter($allow);
         }
         $allow = optional_param_array('allowview', null, PARAM_INT);
         if (!is_null($allow)) {
-            $this->allowview = $allow;
+            $this->allowview = array_filter($allow);
         }
 
         // Now read the permissions for each capability.
@@ -619,7 +619,9 @@ class core_role_define_role_table_advanced extends core_role_capability_table_wi
         if ($this->roleid == 0) {
             $options[-1] = get_string('thisnewrole', 'core_role');
         }
-        return html_writer::select($options, 'allow'.$type.'[]', $selected, false, array('multiple' => 'multiple',
+        return
+            html_writer::empty_tag('input', array('type' => 'hidden', 'name' => 'allow'.$type.'[]', 'value' => "")) .
+            html_writer::select($options, 'allow'.$type.'[]', $selected, false, array('multiple' => 'multiple',
             'size' => 10, 'class' => 'form-control'));
     }
 
index 4b0f427..0d55217 100644 (file)
@@ -44,15 +44,10 @@ $temp = new admin_settingpage('sessionhandling', new lang_string('sessionhandlin
 if (empty($CFG->session_handler_class) and $DB->session_lock_supported()) {
     $temp->add(new admin_setting_configcheckbox('dbsessions', new lang_string('dbsessions', 'admin'), new lang_string('configdbsessions', 'admin'), 0));
 }
-$temp->add(new admin_setting_configselect('sessiontimeout', new lang_string('sessiontimeout', 'admin'), new lang_string('configsessiontimeout', 'admin'), 7200, array(14400 => new lang_string('numhours', '', 4),
-                                                                                                                                                      10800 => new lang_string('numhours', '', 3),
-                                                                                                                                                      7200 => new lang_string('numhours', '', 2),
-                                                                                                                                                      5400 => new lang_string('numhours', '', '1.5'),
-                                                                                                                                                      3600 => new lang_string('numminutes', '', 60),
-                                                                                                                                                      2700 => new lang_string('numminutes', '', 45),
-                                                                                                                                                      1800 => new lang_string('numminutes', '', 30),
-                                                                                                                                                      900 => new lang_string('numminutes', '', 15),
-                                                                                                                                                      300 => new lang_string('numminutes', '', 5))));
+
+$temp->add(new admin_setting_configduration('sessiontimeout', new lang_string('sessiontimeout', 'admin'),
+    new lang_string('configsessiontimeout', 'admin'), 8 * 60 * 60));
+
 $temp->add(new admin_setting_configtext('sessioncookie', new lang_string('sessioncookie', 'admin'), new lang_string('configsessioncookie', 'admin'), '', PARAM_ALPHANUM));
 $temp->add(new admin_setting_configtext('sessioncookiepath', new lang_string('sessioncookiepath', 'admin'), new lang_string('configsessioncookiepath', 'admin'), '', PARAM_RAW));
 $temp->add(new admin_setting_configtext('sessioncookiedomain', new lang_string('sessioncookiedomain', 'admin'), new lang_string('configsessioncookiedomain', 'admin'), '', PARAM_RAW, 50));
index 8cb2d66..7092e82 100644 (file)
     * size - form element size
     * value - form element value
     * id - element id
+    * forced - has value been defined in config.php
 
     Example context (json):
     {
         "name": "test",
         "id": "test0",
         "size": "8",
-        "value": "secret"
+        "value": "secret",
+        "forced": false
     }
 }}
+{{#forced}}
+    <div class="form-password">
+        <input type="text"
+               name = "{{ name }}"
+               id="{{ id }}"
+               value="********"
+               size="{{ size }}"
+               class="form-control"
+               disabled
+        >
+    </div>
+{{/forced}}
+{{^forced}}
 <div class="form-password">
     <span data-passwordunmask="wrapper" data-passwordunmaskid="{{ id }}">
         <span data-passwordunmask="editor">
@@ -61,3 +76,4 @@ require(['core_form/passwordunmask'], function(PasswordUnmask) {
     new PasswordUnmask("{{ id }}");
 });
 {{/js}}
+{{/forced}}
index 83e879a..2f02b45 100644 (file)
@@ -254,20 +254,19 @@ class tool_customlang_utils {
      *
      * @param string $component the name of the component
      * @param array $strings
+     * @return void
      */
     protected static function dump_strings($lang, $component, $strings) {
         global $CFG;
 
         if ($lang !== clean_param($lang, PARAM_LANG)) {
-            debugging('Unable to dump local strings for non-installed language pack .'.s($lang));
-            return false;
+            throw new moodle_exception('Unable to dump local strings for non-installed language pack .'.s($lang));
         }
         if ($component !== clean_param($component, PARAM_COMPONENT)) {
             throw new coding_exception('Incorrect component name');
         }
         if (!$filename = self::get_component_filename($component)) {
-            debugging('Unable to find the filename for the component '.s($component));
-            return false;
+            throw new moodle_exception('Unable to find the filename for the component '.s($component));
         }
         if ($filename !== clean_param($filename, PARAM_FILE)) {
             throw new coding_exception('Incorrect file name '.s($filename));
@@ -284,8 +283,7 @@ class tool_customlang_utils {
         }
 
         if (!$f = fopen($filepath, 'w')) {
-            debugging('Unable to write '.s($filepath));
-            return false;
+            throw new moodle_exception('Unable to write '.s($filepath));
         }
         fwrite($f, <<<EOF
 <?php
index 9ae8647..906dcb5 100644 (file)
@@ -258,7 +258,20 @@ class api {
         // The user making the request.
         $datarequest->set('requestedby', $requestinguser);
         // Set status.
-        $datarequest->set('status', self::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $status = self::DATAREQUEST_STATUS_AWAITING_APPROVAL;
+        if (self::is_automatic_request_approval_on($type)) {
+            // Set status to approved if automatic data request approval is enabled.
+            $status = self::DATAREQUEST_STATUS_APPROVED;
+            // Set the privacy officer field if the one making the data request is a privacy officer.
+            if (self::is_site_dpo($requestinguser)) {
+                $datarequest->set('dpo', $requestinguser);
+            }
+            // Mark this request as system approved.
+            $datarequest->set('systemapproved', true);
+            // No need to notify privacy officer(s) about automatically approved data requests.
+            $notify = false;
+        }
+        $datarequest->set('status', $status);
         // Set request type.
         $datarequest->set('type', $type);
         // Set request comments.
@@ -269,13 +282,22 @@ class api {
         // Store subject access request.
         $datarequest->create();
 
+        // Queue the ad-hoc task for automatically approved data requests.
+        if ($status == self::DATAREQUEST_STATUS_APPROVED) {
+            $userid = null;
+            if ($type == self::DATAREQUEST_TYPE_EXPORT) {
+                $userid = $foruser;
+            }
+            self::queue_data_request_task($datarequest->get('id'), $userid);
+        }
+
         if ($notify) {
             // Get the list of the site Data Protection Officers.
-            $dpos = api::get_site_dpos();
+            $dpos = self::get_site_dpos();
 
             // Email the data request to the Data Protection Officer(s)/Admin(s).
             foreach ($dpos as $dpo) {
-                api::notify_dpo($dpo, $datarequest);
+                self::notify_dpo($dpo, $datarequest);
             }
         }
 
@@ -624,12 +646,11 @@ class api {
         $result = self::update_request_status($requestid, self::DATAREQUEST_STATUS_APPROVED, $USER->id);
 
         // Fire an ad hoc task to initiate the data request process.
-        $task = new process_data_request_task();
-        $task->set_custom_data(['requestid' => $requestid]);
+        $userid = null;
         if ($request->get('type') == self::DATAREQUEST_TYPE_EXPORT) {
-            $task->set_userid($request->get('userid'));
+            $userid = $request->get('userid');
         }
-        manager::queue_adhoc_task($task, true);
+        self::queue_data_request_task($requestid, $userid);
 
         return $result;
     }
@@ -1277,4 +1298,35 @@ class api {
 
         return $formattedtime;
     }
+
+    /**
+     * Whether automatic data request approval is turned on or not for the given request type.
+     *
+     * @param int $type The request type.
+     * @return bool
+     */
+    public static function is_automatic_request_approval_on(int $type): bool {
+        switch ($type) {
+            case self::DATAREQUEST_TYPE_EXPORT:
+                return !empty(get_config('tool_dataprivacy', 'automaticdataexportapproval'));
+            case self::DATAREQUEST_TYPE_DELETE:
+                return !empty(get_config('tool_dataprivacy', 'automaticdatadeletionapproval'));
+        }
+        return false;
+    }
+
+    /**
+     * Creates an ad-hoc task for the data request.
+     *
+     * @param int $requestid The data request ID.
+     * @param int $userid Optional. The user ID to run the task as, if necessary.
+     */
+    public static function queue_data_request_task(int $requestid, int $userid = null): void {
+        $task = new process_data_request_task();
+        $task->set_custom_data(['requestid' => $requestid]);
+        if ($userid) {
+            $task->set_userid($userid);
+        }
+        manager::queue_adhoc_task($task, true);
+    }
 }
index 3901563..6a94fad 100644 (file)
@@ -118,6 +118,10 @@ class data_request extends persistent {
                 'type' => PARAM_INT,
                 'default' => FORMAT_PLAIN
             ],
+            'systemapproved' => [
+                'default' => false,
+                'type' => PARAM_BOOL,
+            ],
             'creationmethod' => [
                 'default' => self::DATAREQUEST_CREATION_MANUAL,
                 'choices' => [
index 032014c..bb29c6c 100644 (file)
@@ -146,14 +146,20 @@ class process_data_request_task extends adhoc_task {
         mtrace('The processing of the user data request has been completed...');
 
         // Create message to notify the user regarding the processing results.
-        $dpo = core_user::get_user($request->dpo);
         $message = new message();
         $message->courseid = $SITE->id;
         $message->component = 'tool_dataprivacy';
         $message->name = 'datarequestprocessingresults';
-        $message->userfrom = $dpo;
-        $message->replyto = $dpo->email;
-        $message->replytoname = fullname($dpo->email);
+        if (empty($request->dpo)) {
+            // Use the no-reply user as the sender if the privacy officer is not set. This is the case for automatically
+            // approved requests.
+            $fromuser = core_user::get_noreply_user();
+        } else {
+            $fromuser = core_user::get_user($request->dpo);
+            $message->replyto = $fromuser->email;
+            $message->replytoname = fullname($fromuser);
+        }
+        $message->userfrom = $fromuser;
 
         $typetext = null;
         // Prepare the context data for the email message body.
@@ -219,7 +225,7 @@ class process_data_request_task extends adhoc_task {
             if ($emailonly) {
                 // Do not sent an email if the user has been deleted. The user email has been previously deleted.
                 if (!$foruser->deleted) {
-                    $messagesent = email_to_user($foruser, $dpo, $subject, $message->fullmessage, $messagehtml);
+                    $messagesent = email_to_user($foruser, $fromuser, $subject, $message->fullmessage, $messagehtml);
                 }
             } else {
                 $messagesent = message_send($message);
@@ -265,7 +271,7 @@ class process_data_request_task extends adhoc_task {
 
                 // Send message.
                 if ($emailonly) {
-                    email_to_user($requestedby, $dpo, $subject, $message->fullmessage, $messagehtml);
+                    email_to_user($requestedby, $fromuser, $subject, $message->fullmessage, $messagehtml);
                 } else {
                     message_send($message);
                 }
index b32dc15..2124400 100644 (file)
@@ -94,7 +94,11 @@ if ($data = $mform->get_data()) {
     if ($manage) {
         $foruser = core_user::get_user($data->userid);
         $redirectmessage = get_string('datarequestcreatedforuser', 'tool_dataprivacy', fullname($foruser));
+    } else if (\tool_dataprivacy\api::is_automatic_request_approval_on($data->type)) {
+        // Let the user know that the request has been submitted and will be processed soon.
+        $redirectmessage = get_string('approvedrequestsubmitted', 'tool_dataprivacy');
     } else {
+        // Let the user know that the request has been submitted to the privacy officer.
         $redirectmessage = get_string('requestsubmitted', 'tool_dataprivacy');
     }
     redirect($returnurl, $redirectmessage);
index fc5e96b..a7b564c 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="admin/tool/dataprivacy/db" VERSION="20181107" COMMENT="XMLDB file for Moodle tool/dataprivacy"
+<XMLDB PATH="admin/tool/dataprivacy/db" VERSION="20191217" COMMENT="XMLDB file for Moodle tool/dataprivacy"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd"
 >
@@ -16,6 +16,7 @@
         <FIELD NAME="dpo" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="The user ID of the Data Protection Officer who is reviewing th request"/>
         <FIELD NAME="dpocomment" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="DPO's comments (e.g. reason for rejecting the request, etc.)"/>
         <FIELD NAME="dpocommentformat" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="systemapproved" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The user who created/modified this request object"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The time this data request was created"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The last time this data request was updated"/>
index 7b2a9d8..e90e82a 100644 (file)
@@ -322,5 +322,20 @@ function xmldb_tool_dataprivacy_upgrade($oldversion) {
     // Automatically generated Moodle v3.8.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2019121700) {
+
+        // Define field systemapproved to be added to tool_dataprivacy_request.
+        $table = new xmldb_table('tool_dataprivacy_request');
+        $field = new xmldb_field('systemapproved', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '0', 'dpocommentformat');
+
+        // Conditionally launch add field systemapproved.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Dataprivacy savepoint reached.
+        upgrade_plugin_savepoint(true, 2019121700, 'tool', 'dataprivacy');
+    }
+
     return true;
 }
index 5a0efd4..9a31758 100644 (file)
@@ -31,7 +31,12 @@ $string['addcategory'] = 'Add category';
 $string['addnewdefaults'] = 'Add a new module default';
 $string['addpurpose'] = 'Add purpose';
 $string['approve'] = 'Approve';
+$string['approvedrequestsubmitted'] = 'Your request has been submitted and will be processed soon';
 $string['approverequest'] = 'Approve request';
+$string['automaticdatadeletionapproval'] = 'Automatic data deletion request approval';
+$string['automaticdatadeletionapproval_desc'] = 'If enabled, data deletion requests are automatically approved.<br/>Note that the automatic approval will only apply to new data deletion requests with this setting enabled. Existing data deletion requests pending approval will still have to be manually approved by the privacy officer.';
+$string['automaticdataexportapproval'] = 'Automatic data export request approval';
+$string['automaticdataexportapproval_desc'] = 'If enabled, data export requests are automatically approved.<br/>Note that the automatic approval will only apply to new data export requests with this setting enabled. Existing data export requests pending approval will still have to be manually approved by the privacy officer.';
 $string['automaticdeletionrequests'] = 'Create automatic data deletion requests';
 $string['automaticdeletionrequests_desc'] = 'If enabled, a data deletion request will be created automatically for any user accounts deleted manually.';
 $string['bulkapproverequests'] = 'Approve requests';
index de3d715..0a335e3 100644 (file)
@@ -34,6 +34,16 @@ if ($hassiteconfig) {
                 new lang_string('contactdataprotectionofficer_desc', 'tool_dataprivacy'), 0)
         );
 
+        $privacysettings->add(new admin_setting_configcheckbox('tool_dataprivacy/automaticdataexportapproval',
+                new lang_string('automaticdataexportapproval', 'tool_dataprivacy'),
+                new lang_string('automaticdataexportapproval_desc', 'tool_dataprivacy'), 0)
+        );
+
+        $privacysettings->add(new admin_setting_configcheckbox('tool_dataprivacy/automaticdatadeletionapproval',
+                new lang_string('automaticdatadeletionapproval', 'tool_dataprivacy'),
+                new lang_string('automaticdatadeletionapproval_desc', 'tool_dataprivacy'), 0)
+        );
+
         // Automatically create delete data request for users upon user deletion.
         // Automatically create delete data request for pre-existing deleted users.
         // Enabled by default.
index a913f3e..7bf94cc 100644 (file)
@@ -301,6 +301,11 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $datarequest = api::create_data_request($student->id, api::DATAREQUEST_TYPE_EXPORT);
 
         $requestid = $datarequest->get('id');
+
+        // Login as a user without DPO role.
+        $this->setUser($teacher);
+        $this->expectException(required_capability_exception::class);
+        api::approve_data_request($requestid);
     }
 
     /**
@@ -550,32 +555,65 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
     }
 
     /**
-     * Test for api::create_data_request()
+     * Data provider for data request creation tests.
+     *
+     * @return array
      */
-    public function test_create_data_request() {
-        $this->resetAfterTest();
-
-        $generator = new testing_data_generator();
-        $user = $generator->create_user();
-        $comment = 'sample comment';
-
-        // Login as user.
-        $this->setUser($user->id);
-
-        // Test data request creation.
-        $datarequest = api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
-        $this->assertEquals($user->id, $datarequest->get('userid'));
-        $this->assertEquals($user->id, $datarequest->get('requestedby'));
-        $this->assertEquals(0, $datarequest->get('dpo'));
-        $this->assertEquals(api::DATAREQUEST_TYPE_EXPORT, $datarequest->get('type'));
-        $this->assertEquals(api::DATAREQUEST_STATUS_AWAITING_APPROVAL, $datarequest->get('status'));
-        $this->assertEquals($comment, $datarequest->get('comments'));
+    public function data_request_creation_provider() {
+        return [
+            'Export request by user, automatic approval off' => [
+                false, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Export request by user, automatic approval on' => [
+                false, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', true, 0,
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+            'Export request by PO, automatic approval off' => [
+                true, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Export request by PO, automatic approval on' => [
+                true, api::DATAREQUEST_TYPE_EXPORT, 'automaticdataexportapproval', true, 'dpo',
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+            'Delete request by user, automatic approval off' => [
+                false, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Delete request by user, automatic approval on' => [
+                false, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', true, 0,
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+            'Delete request by PO, automatic approval off' => [
+                true, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', false, 0,
+                api::DATAREQUEST_STATUS_AWAITING_APPROVAL, 0
+            ],
+            'Delete request by PO, automatic approval on' => [
+                true, api::DATAREQUEST_TYPE_DELETE, 'automaticdatadeletionapproval', true, 'dpo',
+                api::DATAREQUEST_STATUS_APPROVED, 1
+            ],
+        ];
     }
 
     /**
-     * Test for api::create_data_request() made by DPO.
+     * Test for api::create_data_request()
+     *
+     * @dataProvider data_request_creation_provider
+     * @param bool $asprivacyofficer Whether the request is made as the Privacy Officer or the user itself.
+     * @param string $type The data request type.
+     * @param string $setting The automatic approval setting.
+     * @param bool $automaticapproval Whether automatic data request approval is turned on or not.
+     * @param int|string $expecteddpoval The expected value for the 'dpo' field. 'dpo' means we'd the expected value would be the
+     *                                   user ID of the privacy officer which happens in the case where a PO requests on behalf of
+     *                                   someone else and automatic data request approval is turned on.
+     * @param int $expectedstatus The expected status of the data request.
+     * @param int $expectedtaskcount The number of expected queued data requests tasks.
+     * @throws coding_exception
+     * @throws invalid_persistent_exception
      */
-    public function test_create_data_request_by_dpo() {
+    public function test_create_data_request($asprivacyofficer, $type, $setting, $automaticapproval, $expecteddpoval,
+                                             $expectedstatus, $expectedtaskcount) {
         global $USER;
 
         $this->resetAfterTest();
@@ -584,16 +622,34 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $user = $generator->create_user();
         $comment = 'sample comment';
 
-        // Login as DPO (Admin is DPO by default).
-        $this->setAdminUser();
+        // Login.
+        if ($asprivacyofficer) {
+            $this->setAdminUser();
+        } else {
+            $this->setUser($user->id);
+        }
+
+        // Set the automatic data request approval setting value.
+        set_config($setting, $automaticapproval, 'tool_dataprivacy');
+
+        // If set to 'dpo' use the currently logged-in user's ID (which should be the admin user's ID).
+        if ($expecteddpoval === 'dpo') {
+            $expecteddpoval = $USER->id;
+        }
 
         // Test data request creation.
-        $datarequest = api::create_data_request($user->id, api::DATAREQUEST_TYPE_EXPORT, $comment);
+        $datarequest = api::create_data_request($user->id, $type, $comment);
         $this->assertEquals($user->id, $datarequest->get('userid'));
         $this->assertEquals($USER->id, $datarequest->get('requestedby'));
-        $this->assertEquals(api::DATAREQUEST_TYPE_EXPORT, $datarequest->get('type'));
-        $this->assertEquals(api::DATAREQUEST_STATUS_AWAITING_APPROVAL, $datarequest->get('status'));
+        $this->assertEquals($expecteddpoval, $datarequest->get('dpo'));
+        $this->assertEquals($type, $datarequest->get('type'));
+        $this->assertEquals($expectedstatus, $datarequest->get('status'));
         $this->assertEquals($comment, $datarequest->get('comments'));
+        $this->assertEquals($automaticapproval, $datarequest->get('systemapproved'));
+
+        // Test number of queued data request tasks.
+        $datarequesttasks = manager::get_adhoc_tasks(process_data_request_task::class);
+        $this->assertCount($expectedtaskcount, $datarequesttasks);
     }
 
     /**
@@ -2250,4 +2306,88 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         set_config('siteadmins', $child->id);
         $this->assertFalse(api::can_create_data_deletion_request_for_children($child->id));
     }
+
+    /**
+     * Data provider function for testing \tool_dataprivacy\api::queue_data_request_task().
+     *
+     * @return array
+     */
+    public function queue_data_request_task_provider() {
+        return [
+            'With user ID provided' => [true],
+            'Without user ID provided' => [false],
+        ];
+    }
+
+    /**
+     * Test for \tool_dataprivacy\api::queue_data_request_task().
+     *
+     * @dataProvider queue_data_request_task_provider
+     * @param bool $withuserid
+     */
+    public function test_queue_data_request_task(bool $withuserid) {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        if ($withuserid) {
+            $user = $this->getDataGenerator()->create_user();
+            api::queue_data_request_task(1, $user->id);
+            $expecteduserid = $user->id;
+        } else {
+            api::queue_data_request_task(1);
+            $expecteduserid = null;
+        }
+
+        // Test number of queued data request tasks.
+        $datarequesttasks = manager::get_adhoc_tasks(process_data_request_task::class);
+        $this->assertCount(1, $datarequesttasks);
+        $requesttask = reset($datarequesttasks);
+        $this->assertEquals($expecteduserid, $requesttask->get_userid());
+    }
+
+    /**
+     * Data provider for test_is_automatic_request_approval_on().
+     */
+    public function automatic_request_approval_setting_provider() {
+        return [
+            'Data export, not set' => [
+                'automaticdataexportapproval', api::DATAREQUEST_TYPE_EXPORT, null, false
+            ],
+            'Data export, turned on' => [
+                'automaticdataexportapproval', api::DATAREQUEST_TYPE_EXPORT, true, true
+            ],
+            'Data export, turned off' => [
+                'automaticdataexportapproval', api::DATAREQUEST_TYPE_EXPORT, false, false
+            ],
+            'Data deletion, not set' => [
+                'automaticdatadeletionapproval', api::DATAREQUEST_TYPE_DELETE, null, false
+            ],
+            'Data deletion, turned on' => [
+                'automaticdatadeletionapproval', api::DATAREQUEST_TYPE_DELETE, true, true
+            ],
+            'Data deletion, turned off' => [
+                'automaticdatadeletionapproval', api::DATAREQUEST_TYPE_DELETE, false, false
+            ],
+        ];
+    }
+
+    /**
+     * Test for \tool_dataprivacy\api::is_automatic_request_approval_on().
+     *
+     * @dataProvider automatic_request_approval_setting_provider
+     * @param string $setting The automatic approval setting.
+     * @param int $type The data request type.
+     * @param bool $value The setting's value.
+     * @param bool $expected The expected result.
+     */
+    public function test_is_automatic_request_approval_on($setting, $type, $value, $expected) {
+        $this->resetAfterTest();
+
+        if ($value !== null) {
+            set_config($setting, $value, 'tool_dataprivacy');
+        }
+
+        $this->assertEquals($expected, api::is_automatic_request_approval_on($type));
+    }
 }
index 0a4da1b..4add17f 100644 (file)
@@ -240,3 +240,13 @@ Feature: Data delete from the privacy API
     And I reload the page
     And I open the action menu in "region-main" "region"
     Then I should not see "Resubmit as new request"
+
+  Scenario: Request data deletion as student with automatic approval turned on
+    Given the following config values are set as admin:
+      | automaticdatadeletionapproval | 1  | tool_dataprivacy |
+    And I log in as "victim"
+    And I follow "Profile" in the user menu
+    And I follow "Delete my account"
+    When I press "Save changes"
+    Then I should see "Your request has been submitted and will be processed soon"
+    And I should see "Approved" in the "Delete all of my personal data" "table_row"
index b651201..65fec34 100644 (file)
@@ -151,3 +151,13 @@ Feature: Data export from the privacy API
     When I reload the page
     And I set the field "Search" to "University2"
     Then I should see "Victim User 2"
+
+  Scenario: Request data export as student with automatic approval turned on
+    Given the following config values are set as admin:
+      | automaticdataexportapproval | 1  | tool_dataprivacy |
+    And I log in as "victim"
+    And I follow "Profile" in the user menu
+    And I follow "Export all of my personal data"
+    When I press "Save changes"
+    Then I should see "Your request has been submitted and will be processed soon"
+    And I should see "Approved" in the "Export all of my personal data" "table_row"
index 636f744..5242cdf 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2019111800;
+$plugin->version   = 2019121700;
 $plugin->requires  = 2019111200;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index 22045d5..3774465 100644 (file)
Binary files a/admin/tool/policy/amd/build/jquery-eu-cookie-law-popup.min.js and b/admin/tool/policy/amd/build/jquery-eu-cookie-law-popup.min.js differ
index c676cc1..0fac0c4 100644 (file)
Binary files a/admin/tool/policy/amd/build/jquery-eu-cookie-law-popup.min.js.map and b/admin/tool/policy/amd/build/jquery-eu-cookie-law-popup.min.js.map differ
index 379c3c4..80f431f 100644 (file)
@@ -13,6 +13,7 @@
  * http://www.wimagguc.com/\r
  *\r
  */\r
+\r
 define(['jquery'], function($) {\r
 \r
 // for ie9 doesn't support debug console >>>\r
@@ -171,7 +172,7 @@ $.fn.euCookieLawPopup = (function() {
                var cookies = document.cookie.split(";");\r
                for (var i = 0; i < cookies.length; i++) {\r
                        var c = cookies[i].trim();\r
-                       if (c.indexOf(_self.vars.COOKIE_NAME) == 0) {\r
+                       if (c.indexOf(_self.vars.COOKIE_NAME) !== -1) {\r
                                userAcceptedCookies = c.substring(_self.vars.COOKIE_NAME.length + 1, c.length);\r
                        }\r
                }\r
index a4241d9..e53104c 100644 (file)
@@ -1,22 +1,19 @@
-jQuery EU Cookie Law popups 1.1.2
+jQuery EU Cookie Law popups 1.1.3
 -------------
 https://github.com/wimagguc/jquery-eu-cookie-law-popup
 
 Instructions to import 'jQuery EU Cookie Law popups' into Moodle:
 
-1. Download the latest release from https://github.com/wimagguc/jquery-eu-cookie-law-popup
+1. Download the latest release from https://github.com/wimagguc/jquery-eu-cookie-law-popup/releases
 2. Copy 'js/jquery-eu-cookie-law-popup.js' into 'amd/src/jquery-eu-cookie-law-popup.js':
 
    2.a. Replace jquery reference
 ------------------
 (function($) {
 ------------------
-
-to
+with
 ------------------
-define(
-['jquery'],
-function($) {
+define(['jquery'],function($) {
 ------------------
 
    2.b. Remove initialisation code. It will be added and configured only in the pages where is needed
@@ -29,6 +26,7 @@ $(document).ready( function() {
       'popupText' : 'We use them to give you the best experience. If you continue using our website, we\'ll assume that you are happy to receive all cookies on this website.'
     });
   }
+});
 ------------------
 
    2.c. Remove code
@@ -36,9 +34,16 @@ $(document).ready( function() {
 $(document).bind("user_cookie_consent_changed", function(event, object) {
   console.log("User cookie consent changed: " + $(object).attr('consent') );
 });
+------------------
 
+   2.d. Replace
+------------------
 }(jQuery));
 ------------------
+with
+------------------
+});
+------------------
 
 3. Copy the following styles from 'css/jquery-eu-cookie-law-popup.css' into the
 "jquery-eu-cookie-law-popup styles" section in 'styles.css':
@@ -54,3 +59,5 @@ $(document).bind("user_cookie_consent_changed", function(event, object) {
 
 4. Execute grunt to compile js
    grunt amd
+
+5. Update version number in admin/tool/policy/thirdpartylibs.xml
index 9d7a636..bcc98f2 100644 (file)
@@ -4,7 +4,7 @@
     <location>amd/src/jquery-eu-cookie-law-popup.js</location>
     <name>jQuery EU Cookie Law popups</name>
     <license>MIT</license>
-    <version>1.1.2</version>
+    <version>1.1.3</version>
     <licenseversion></licenseversion>
   </library>
 </libraries>
index 5d6f881..343a3eb 100644 (file)
@@ -61,7 +61,7 @@ if (!defined('E_USER_DEPRECATED')) {
 /**
  * phpCAS version. accessible for the user by phpCAS::getVersion().
  */
-define('PHPCAS_VERSION', '1.3.7+');
+define('PHPCAS_VERSION', '1.3.8');
 
 /**
  * @addtogroup public
index 338bd50..f06c154 100644 (file)
@@ -997,7 +997,18 @@ class CAS_Client
 
         // set to callback mode if PgtIou and PgtId CGI GET parameters are provided
         if ( $this->isProxy() ) {
-            $this->_setCallbackMode(!empty($_GET['pgtIou'])&&!empty($_GET['pgtId']));
+            if(!empty($_GET['pgtIou'])&&!empty($_GET['pgtId'])) {
+                $this->_setCallbackMode(true);
+                $this->_setCallbackModeUsingPost(false);
+            } elseif (!empty($_POST['pgtIou'])&&!empty($_POST['pgtId'])) {
+                $this->_setCallbackMode(true);
+                $this->_setCallbackModeUsingPost(true);
+            } else {
+                $this->_setCallbackMode(false);
+                $this->_setCallbackModeUsingPost(false);
+            }
+
+            
         }
 
         if ( $this->_isCallbackMode() ) {
@@ -2329,6 +2340,36 @@ class CAS_Client
         return $this->_callback_mode;
     }
 
+    /**
+     * @var bool a boolean to know if the CAS client is using POST parameters when in callback mode.
+     * Written by CAS_Client::_setCallbackModeUsingPost(), read by CAS_Client::_isCallbackModeUsingPost().
+     *
+     * @hideinitializer
+     */
+    private $_callback_mode_using_post = false;
+
+    /**
+     * This method sets/unsets usage of POST parameters in callback mode (default/false is GET parameters)
+     *
+     * @param bool $callback_mode_using_post true to use POST, false to use GET (default).
+     *
+     * @return void
+     */
+    private function _setCallbackModeUsingPost($callback_mode_using_post)
+    {
+        $this->_callback_mode_using_post = $callback_mode_using_post;
+    }
+
+    /**
+     * This method returns true when the callback mode is using POST, false otherwise.
+     *
+     * @return bool A boolean.
+     */
+    private function _isCallbackModeUsingPost()
+    {
+        return $this->_callback_mode_using_post;
+    }
+
     /**
      * the URL that should be used for the PGT callback (in fact the URL of the
      * current request without any CGI parameter). Written and read by
@@ -2387,23 +2428,39 @@ class CAS_Client
     private function _callback()
     {
         phpCAS::traceBegin();
-        if (preg_match('/^PGTIOU-[\.\-\w]+$/', $_GET['pgtIou'])) {
-            if (preg_match('/^[PT]GT-[\.\-\w]+$/', $_GET['pgtId'])) {
-                $this->printHTMLHeader('phpCAS callback');
-                $pgt_iou = $_GET['pgtIou'];
-                $pgt = $_GET['pgtId'];
-                phpCAS::trace('Storing PGT `'.$pgt.'\' (id=`'.$pgt_iou.'\')');
-                echo '<p>Storing PGT `'.$pgt.'\' (id=`'.$pgt_iou.'\').</p>';
-                $this->_storePGT($pgt, $pgt_iou);
-                $this->printHTMLFooter();
+        if ($this->_isCallbackModeUsingPost()) {
+            $pgtId = $_POST['pgtId'];
+            $pgtIou = $_POST['pgtIou'];
+        } else {
+            $pgtId = $_GET['pgtId'];
+            $pgtIou = $_GET['pgtIou'];
+        }
+        if (preg_match('/^PGTIOU-[\.\-\w]+$/', $pgtIou)) {
+            if (preg_match('/^[PT]GT-[\.\-\w]+$/', $pgtId)) {
+                phpCAS::trace('Storing PGT `'.$pgtId.'\' (id=`'.$pgtIou.'\')');
+                $this->_storePGT($pgtId, $pgtIou);
+                if (array_key_exists('HTTP_ACCEPT', $_SERVER) &&
+                    (   $_SERVER['HTTP_ACCEPT'] == 'application/xml' ||
+                        $_SERVER['HTTP_ACCEPT'] == 'text/xml'
+                    )
+                ) {
+                    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\r\n";
+                    echo '<proxySuccess xmlns="http://www.yale.edu/tp/cas" />';
+                    phpCAS::traceExit("XML response sent");
+                } else {
+                    $this->printHTMLHeader('phpCAS callback');
+                    echo '<p>Storing PGT `'.$pgtId.'\' (id=`'.$pgtIou.'\').</p>';
+                    $this->printHTMLFooter();
+                    phpCAS::traceExit("HTML response sent");
+                }
                 phpCAS::traceExit("Successfull Callback");
             } else {
-                phpCAS::error('PGT format invalid' . $_GET['pgtId']);
-                phpCAS::traceExit('PGT format invalid' . $_GET['pgtId']);
+                phpCAS::error('PGT format invalid' . $pgtId);
+                phpCAS::traceExit('PGT format invalid' . $pgtId);
             }
         } else {
-            phpCAS::error('PGTiou format invalid' . $_GET['pgtIou']);
-            phpCAS::traceExit('PGTiou format invalid' . $_GET['pgtIou']);
+            phpCAS::error('PGTiou format invalid' . $pgtIou);
+            phpCAS::traceExit('PGTiou format invalid' . $pgtIou);
         }
 
         // Flush the buffer to prevent from sending anything other then a 200
index 583c1dc..f425edc 100644 (file)
@@ -4,11 +4,16 @@ phpCAS
 phpCAS is an authentication library that allows PHP applications to easily authenticate
 users via a Central Authentication Service (CAS) server.
 
-Please see the phpCAS website for more information:
+Please see the wiki website for more information:
 
 https://wiki.jasig.org/display/CASC/phpCAS
 
-[![Build Status](https://travis-ci.org/Jasig/phpCAS.png)](https://travis-ci.org/Jasig/phpCAS)
+Api documentation can be found here:
+
+https://apereo.github.io/phpCAS/
+
+
+[![Build Status](https://travis-ci.org/apereo/phpCAS.png)](https://travis-ci.org/apereo/phpCAS)
 
 
 LICENSE
index 7894d1c..11cf506 100644 (file)
@@ -1,5 +1,3 @@
-Description of phpCAS 1.3.7 library import
+Description of phpCAS 1.3.8 library import
 
-* downloaded from http://downloads.jasig.org/cas-clients/php/current/
-* applied patch https://github.com/apereo/phpCAS/pull/247 for PHP 7.2 compatibility (MDL-60280)
-* applied patch https://github.com/apereo/phpCAS/pull/278 for PHP 7.3 compatibility (MDL-63422)
+* downloaded from http://downloads.jasig.org/cas-clients/php/current/
\ No newline at end of file
index cdbd8a7..dfe68c6 100644 (file)
 module.exports = ({template, types}) => {
     const fs = require('fs');
     const path = require('path');
-    const glob = require('glob');
     const cwd = process.cwd();
-
-    // Static variable to hold the modules.
-    let moodleSubsystems = null;
-    let moodlePlugins = null;
-
-    /**
-     * Parse Moodle's JSON files containing the lists of components.
-     *
-     * The values are stored in the static variables because we
-     * only need to load them once per transpiling run.
-     */
-    function loadMoodleModules() {
-        moodleSubsystems = {'lib': 'core'};
-        moodlePlugins = {};
-        let components = fs.readFileSync('lib/components.json');
-        components = JSON.parse(components);
-
-        for (const [component, path] of Object.entries(components.subsystems)) {
-            if (path) {
-                // Prefix "core_" to the front of the subsystems.
-                moodleSubsystems[path] = `core_${component}`;
-            }
-        }
-
-        for (const [component, path] of Object.entries(components.plugintypes)) {
-            if (path) {
-                moodlePlugins[path] = component;
-            }
-        }
-
-        for (const file of glob.sync('**/db/subplugins.json')) {
-            var rawContents = fs.readFileSync(file);
-            var subplugins = JSON.parse(rawContents);
-
-            for (const [component, path] of Object.entries(subplugins.plugintypes)) {
-                if (path) {
-                    moodlePlugins[path] = component;
-                }
-            }
-        }
-    }
+    const ComponentList = require(path.resolve('GruntfileComponents.js'));
 
     /**
      * Search the list of components that match the given file name
@@ -99,26 +58,14 @@ module.exports = ({template, types}) => {
         const fileName = file.replace('.js', '');
 
         // Check subsystems first which require an exact match.
-        if (moodleSubsystems.hasOwnProperty(componentPath)) {
-            return `${moodleSubsystems[componentPath]}/${fileName}`;
-        }
-
-        // It's not a subsystem so it must be a plugin. Moodle defines root folders
-        // where plugins can be installed so our path with be <plugin_root>/<plugin_name>.
-        // Let's separate the two.
-        let pathParts = componentPath.split('/');
-        const pluginName = pathParts.pop();
-        const pluginPath = pathParts.join('/');
-
-        // The plugin path mutch match exactly because some plugins are subplugins of
-        // other plugins which means their paths would partially match.
-        if (moodlePlugins.hasOwnProperty(pluginPath)) {
-            return `${moodlePlugins[pluginPath]}_${pluginName}/${fileName}`;
+        const componentName = ComponentList.getComponentFromPath(componentPath);
+        if (componentName) {
+            return `${componentName}/${fileName}`;
         }
 
         // This matches the previous PHP behaviour that would throw an exception
         // if it couldn't parse an AMD file.
-        throw new Error('Unable to find module name for ' + searchFileName);
+        throw new Error(`Unable to find module name for ${searchFileName} (${componentPath}::${file}}`);
     }
 
     /**
@@ -149,10 +96,6 @@ module.exports = ({template, types}) => {
         pre() {
             this.seenDefine = false;
             this.addedReturnForDefaultExport = false;
-
-            if (moodleSubsystems === null) {
-                loadMoodleModules();
-            }
         },
         visitor: {
             // Plugin ordering is only respected if we visit the "Program" node.
index ddea089..260058d 100644 (file)
@@ -49,6 +49,11 @@ abstract class backup implements checksumable {
     const INTERACTIVE_YES = true;
     const INTERACTIVE_NO  = false;
 
+    /** Release the session during backup/restore */
+    const RELEASESESSION_YES = true;
+    /** Don't release the session during backup/restore */
+    const RELEASESESSION_NO  = false;
+
     // Predefined modes (purposes) of the backup
     const MODE_GENERAL   = 10;
 
index cfcb3ff..c68bbfa 100644 (file)
@@ -122,7 +122,7 @@ if (!async_helper::is_async_pending($id, 'course', 'backup')) {
 
     if (!($bc = backup_ui::load_controller($backupid))) {
         $bc = new backup_controller($type, $id, backup::FORMAT_MOODLE,
-                backup::INTERACTIVE_YES, $backupmode, $USER->id);
+                backup::INTERACTIVE_YES, $backupmode, $USER->id, backup::RELEASESESSION_YES);
         // The backup id did not relate to a valid controller so we made a new controller.
         // Now we need to reset the backup id to match the new controller.
         $backupid = $bc->get_backupid();
index 52793b2..c2c7fdd 100644 (file)
@@ -79,8 +79,9 @@ class backup_controller extends base_controller {
      * @param bool $interactive Whether this backup will require user interaction; backup::INTERACTIVE_YES or INTERACTIVE_NO
      * @param int $mode One of backup::MODE_GENERAL, MODE_IMPORT, MODE_SAMESITE, MODE_HUB, MODE_AUTOMATED
      * @param int $userid The id of the user making the backup
+     * @param bool $releasesession Should release the session? backup::RELEASESESSION_YES or backup::RELEASESESSION_NO
      */
-    public function __construct($type, $id, $format, $interactive, $mode, $userid){
+    public function __construct($type, $id, $format, $interactive, $mode, $userid, $releasesession = backup::RELEASESESSION_NO) {
         $this->type = $type;
         $this->id   = $id;
         $this->courseid = backup_controller_dbops::get_courseid_from_type_id($this->type, $this->id);
@@ -88,6 +89,7 @@ class backup_controller extends base_controller {
         $this->interactive = $interactive;
         $this->mode = $mode;
         $this->userid = $userid;
+        $this->releasesession = $releasesession;
 
         // Apply some defaults
         $this->operation = backup::OPERATION_BACKUP;
@@ -359,6 +361,11 @@ class backup_controller extends base_controller {
         core_php_time_limit::raise(1 * 60 * 60); // 1 hour for 1 course initially granted
         raise_memory_limit(MEMORY_EXTRA);
 
+        // Release the session so other tabs in the same session are not blocked.
+        if ($this->get_releasesession() === backup::RELEASESESSION_YES) {
+            \core\session\manager::write_close();
+        }
+
         // If the controller has decided that we can include files, then check the setting, otherwise do not include files.
         if ($this->get_include_files()) {
             $this->set_include_files((bool) $this->get_plan()->get_setting('files')->get_value());
index 0998796..32aa06c 100644 (file)
@@ -33,6 +33,9 @@ abstract class base_controller extends backup implements loggable {
      */
     protected $logger;
 
+    /** @var bool Whether this backup should release the session. */
+    protected $releasesession = backup::RELEASESESSION_NO;
+
     /**
      * Gets the progress reporter, which can be used to report progress within
      * the backup or restore process.
@@ -82,4 +85,14 @@ abstract class base_controller extends backup implements loggable {
     public function log($message, $level, $a = null, $depth = null, $display = false) {
         backup_helper::log($message, $level, $a, $depth, $display, $this->logger);
     }
+
+    /**
+     * Returns the set value of releasesession.
+     * This is used to indicate if the session should be closed during the backup/restore.
+     *
+     * @return bool Indicates whether the session should be released.
+     */
+    public function get_releasesession() {
+        return $this->releasesession;
+    }
 }
index 1f63e57..cf37e55 100644 (file)
@@ -79,15 +79,17 @@ class restore_controller extends base_controller {
      * @param int $userid
      * @param int $target backup::TARGET_[ NEW_COURSE | CURRENT_ADDING | CURRENT_DELETING | EXISTING_ADDING | EXISTING_DELETING ]
      * @param \core\progress\base $progress Optional progress monitor
+     * @param bool $releasesession Should release the session? backup::RELEASESESSION_YES or backup::RELEASESESSION_NO
      */
     public function __construct($tempdir, $courseid, $interactive, $mode, $userid, $target,
-            \core\progress\base $progress = null) {
+            \core\progress\base $progress = null, $releasesession = backup::RELEASESESSION_NO) {
         $this->tempdir = $tempdir;
         $this->courseid = $courseid;
         $this->interactive = $interactive;
         $this->mode = $mode;
         $this->userid = $userid;
         $this->target = $target;
+        $this->releasesession = $releasesession;
 
         // Apply some defaults
         $this->type = '';
@@ -357,6 +359,11 @@ class restore_controller extends base_controller {
         core_php_time_limit::raise(1 * 60 * 60); // 1 hour for 1 course initially granted
         raise_memory_limit(MEMORY_EXTRA);
 
+        // Release the session so other tabs in the same session are not blocked.
+        if ($this->get_releasesession() === backup::RELEASESESSION_YES) {
+            \core\session\manager::write_close();
+        }
+
         // Do course cleanup precheck, if required. This was originally in restore_ui. Moved to handle async backup/restore.
         if ($this->get_target() == backup::TARGET_CURRENT_DELETING || $this->get_target() == backup::TARGET_EXISTING_DELETING) {
             $options = array();
index cb40f59..dd70910 100644 (file)
@@ -91,7 +91,6 @@ class core_backup_external extends external_api {
             require_capability('moodle/backup:backupactivity', $context);
         } else {
             require_capability('moodle/backup:backupcourse', $context);
-            $instanceid = $course->id;
         }
 
         $results = array();
index 4ee134e..a6b7d76 100644 (file)
@@ -55,9 +55,11 @@ require_capability('moodle/restore:restorecourse', $context);
 if (is_null($course)) {
     $coursefullname = $SITE->fullname;
     $courseshortname = $SITE->shortname;
+    $courseurl = new moodle_url('/');
 } else {
     $coursefullname = $course->fullname;
     $courseshortname = $course->shortname;
+    $courseurl = course_get_url($course->id);
 }
 
 // Show page header.
@@ -89,7 +91,7 @@ if ($stage & restore_ui::STAGE_CONFIRM + restore_ui::STAGE_DESTINATION) {
         $restore = restore_ui::engage_independent_stage($stage/2, $contextid);
         if ($restore->process()) {
             $rc = new restore_controller($restore->get_filepath(), $restore->get_course_id(), backup::INTERACTIVE_YES,
-                    $backupmode, $USER->id, $restore->get_target());
+                    $backupmode, $USER->id, $restore->get_target(), null, backup::RELEASESESSION_YES);
         }
     }
     if ($rc) {
@@ -173,7 +175,6 @@ if ($restore->get_stage() != restore_ui::STAGE_PROCESS) {
     \core\task\manager::queue_adhoc_task($asynctask);
 
     // Add ajax progress bar and initiate ajax via a template.
-    $courseurl = new moodle_url('/course/view.php', array('id' => $course->id));
     $restoreurl = new moodle_url('/backup/restorefile.php', array('contextid' => $contextid));
     $progresssetup = array(
             'backupid' => $restoreid,
@@ -182,7 +183,6 @@ if ($restore->get_stage() != restore_ui::STAGE_PROCESS) {
             'restoreurl' => $restoreurl->out()
     );
     echo $renderer->render_from_template('core/async_backup_status', $progresssetup);
-
 }
 
 $restore->destroy();
index 37d1a80..f442a64 100644 (file)
@@ -278,7 +278,12 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
 
         // Build the WHERE condition for the sub-query.
         if (!empty($subqueryconditions)) {
-            $subquerywhere = 'WHERE ' . implode(" OR ", $subqueryconditions);
+            $unionstartquery = "SELECT modulename, instance, eventtype, priority
+                                  FROM {event} ev
+                                 WHERE ";
+            $subqueryunion = $unionstartquery . implode(" UNION $unionstartquery ", $subqueryconditions);
+        } else {
+            $subqueryunion = '{event}';
         }
 
         // Merge subquery parameters to the parameters of the main query.
@@ -291,8 +296,7 @@ class raw_event_retrieval_strategy implements raw_event_retrieval_strategy_inter
                             ev.instance,
                             ev.eventtype,
                             MIN(ev.priority) as priority
-                       FROM {event} ev
-                      $subquerywhere
+                       FROM ($subqueryunion) ev
                    GROUP BY ev.modulename, ev.instance, ev.eventtype";
 
         // Build the main query.
index 43e9de8..f378c10 100644 (file)
@@ -3016,7 +3016,7 @@ function calendar_get_icalendar($url) {
  * Import events from an iCalendar object into a course calendar.
  *
  * @param iCalendar $ical The iCalendar object.
- * @param int $courseid The course ID for the calendar.
+ * @param int $unused Deprecated
  * @param int $subscriptionid The subscription ID.
  * @return string A log of the import progress, including errors.
  */
@@ -3066,7 +3066,8 @@ function calendar_import_icalendar_events($ical, $unused = null, $subscriptionid
         }
     }
 
-    if (!empty($subscriptionid)) {
+    $existing = $DB->get_field('event_subscriptions', 'lastupdated', ['id' => $subscriptionid]);
+    if (!empty($existing)) {
         $eventsuuids = $DB->get_records_menu('event', ['subscriptionid' => $subscriptionid], '', 'id, uuid');
 
         $icaleventscount = count($icaluuids);
index 96af607..94a3cb4 100644 (file)
@@ -227,6 +227,24 @@ class core_calendar_lib_testcase extends advanced_testcase {
         calendar_import_icalendar_events($ical, null, $sub->id);
         $count = $DB->count_records('event', array('subscriptionid' => $sub->id));
         $this->assertEquals($count, 1);
+
+        // Test for ICS file with repeated events.
+        $subscription = new stdClass();
+        $subscription->name = 'Repeated events';
+        $subscription->importfrom = CALENDAR_IMPORT_FROM_FILE;
+        $subscription->eventtype = 'site';
+        $id = calendar_add_subscription($subscription);
+        $calendar = file_get_contents($CFG->dirroot . '/lib/tests/fixtures/repeated_events.ics');
+        $ical = new iCalendar();
+        $ical->unserialize($calendar);
+        $this->assertEquals($ical->parser_errors, []);
+
+        $sub = calendar_get_subscription($id);
+        $output = calendar_import_icalendar_events($ical, null, $sub->id);
+        $this->assertStringNotContainsString('Events deleted: 17', $output);
+        $this->assertStringContainsString('Events imported: 1', $output);
+        $this->assertStringContainsString('Events skipped: 0', $output);
+        $this->assertStringContainsString('Events updated: 0', $output);
     }
 
     /**
index 77c5a5a..7105753 100644 (file)
@@ -2302,7 +2302,7 @@ class core_competency_privacy_testcase extends provider_testcase {
         $this->assertEquals('-', $comp['rating']['rating']);
         $comp = $data->competencies[2];
         $this->assertEquals($comp4->get('shortname'), $comp['name']);
-        $this->assertNull($comp['rating']['rating']);
+        $this->assertNull($comp['rating']);
         $data = writer::with_context($u1ctx)->get_data(array_merge($path, ["{$p1a->get('name')} ({$p1a->get('id')})",
             get_string('commentsubcontext', 'core_comment')]));
         $this->assert_exported_comments(['Hello.', 'It\'s me.', 'After all these years...'], $data->comments);
@@ -2320,7 +2320,7 @@ class core_competency_privacy_testcase extends provider_testcase {
         $this->assertEquals('C', $comp['rating']['rating']);
         $comp = $data->competencies[2];
         $this->assertEquals($comp4->get('shortname'), $comp['name']);
-        $this->assertNull($comp['rating']['rating']);
+        $this->assertNull($comp['rating']);
 
         // This plan is complete.
         $data = writer::with_context($u1ctx)->get_data(array_merge($path, ["{$p1c->get('name')} ({$p1c->get('id')})"]));
index 7c247c9..de9b8b7 100644 (file)
@@ -717,13 +717,49 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return mixed
      */
     protected static function get_tree($id) {
-        global $DB;
         $coursecattreecache = cache::make('core', 'coursecattree');
         $rv = $coursecattreecache->get($id);
         if ($rv !== false) {
             return $rv;
         }
+        // Might need to rebuild the tree. Put a lock in place to ensure other requests don't try and do this in parallel.
+        $lockfactory = \core\lock\lock_config::get_lock_factory('core_coursecattree');
+        $lock = $lockfactory->get_lock('core_coursecattree_cache',
+                course_modinfo::COURSE_CACHE_LOCK_WAIT, course_modinfo::COURSE_CACHE_LOCK_EXPIRY);
+        if ($lock === false) {
+            // Couldn't get a lock to rebuild the tree.
+            return [];
+        }
+        $rv = $coursecattreecache->get($id);
+        if ($rv !== false) {
+            // Tree was built while we were waiting for the lock.
+            $lock->release();
+            return $rv;
+        }
         // Re-build the tree.
+        try {
+            $all = self::rebuild_coursecattree_cache_contents();
+            $coursecattreecache->set_many($all);
+        } finally {
+            $lock->release();
+        }
+        if (array_key_exists($id, $all)) {
+            return $all[$id];
+        }
+        // Requested non-existing category.
+        return array();
+    }
+
+    /**
+     * Rebuild the course category tree as an array, including an extra "countall" field.
+     *
+     * @return array
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws moodle_exception
+     */
+    private static function rebuild_coursecattree_cache_contents() : array {
+        global $DB;
         $sql = "SELECT cc.id, cc.parent, cc.visible
                 FROM {course_categories} cc
                 ORDER BY cc.sortorder";
@@ -760,12 +796,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         }
         // We must add countall to all in case it was the requested ID.
         $all['countall'] = $count;
-        $coursecattreecache->set_many($all);
-        if (array_key_exists($id, $all)) {
-            return $all[$id];
-        }
-        // Requested non-existing category.
-        return array();
+        return $all;
     }
 
     /**
index 23088e5..9eca03f 100644 (file)
@@ -2209,6 +2209,7 @@ function move_courses($courseids, $categoryid) {
     foreach ($dbcourses as $dbcourse) {
         $course = new stdClass();
         $course->id = $dbcourse->id;
+        $course->timemodified = time();
         $course->category  = $category->id;
         $course->sortorder = $category->sortorder + MAX_COURSES_IN_CATEGORY - $i++;
         if ($category->visible == 0) {
index b64817b..632860a 100644 (file)
@@ -120,10 +120,12 @@ class enrol_meta_plugin extends enrol_plugin {
         require_once("$CFG->dirroot/enrol/meta/locallib.php");
 
         // Support creating multiple at once.
-        if (is_array($fields['customint1'])) {
+        if (isset($fields['customint1']) && is_array($fields['customint1'])) {
             $courses = array_unique($fields['customint1']);
-        } else {
+        } else if (isset($fields['customint1'])) {
             $courses = array($fields['customint1']);
+        } else {
+            $courses = array(null); // Strange? Yes, but that's how it's working or instance is not created ever.
         }
         foreach ($courses as $courseid) {
             if (!empty($fields['customint2']) && $fields['customint2'] == ENROL_META_CREATE_GROUP) {
index cc73e30..4692f4a 100644 (file)
Binary files a/grade/amd/build/edittree_index.min.js and b/grade/amd/build/edittree_index.min.js differ
index e3569f3..26c6df9 100644 (file)
Binary files a/grade/amd/build/edittree_index.min.js.map and b/grade/amd/build/edittree_index.min.js.map differ
index 9316f84..bb9aa79 100644 (file)
@@ -30,12 +30,6 @@ define([
      * @method edittree
      */
     var edittree = function() {
-        // Watch items and toggle the move menu accordingly.
-        $('body').on('change', '.itemselect.ignoredirty', edittree.checkMoveMenuState);
-
-        // Watch for the 'All' and 'None' links.
-        $('body').on('click', '[data-action="grade_edittree-index-bulkselect"]', edittree.toggleAllSelectItems);
-
         // Watch for the weight override checkboxes.
         $('body').on('change', '.weightoverride', edittree.toggleWeightInput);
 
@@ -47,9 +41,6 @@ define([
             bulkmove.val(1);
             form.submit();
         });
-
-        // CHeck the initial state of the move menu.
-        edittree.checkMoveMenuState();
     };
 
     /**
@@ -67,60 +58,6 @@ define([
         $('input[name="weight_' + row.data('itemid') + '"]').prop('disabled', !node.prop('checked'));
     };
 
-    /**
-     * Toggle all select boxes on or off.
-     *
-     * @method toggleAllSelectItems
-     * @param {EventFacade} e
-     * @private
-     */
-    edittree.toggleAllSelectItems = function(e) {
-        e.preventDefault();
-
-        var node = $(this),
-            row = node.closest('tr');
-        $('.' + row.data('category') + ' .itemselect').prop('checked', node.data('checked'));
-
-        edittree.checkMoveMenuState();
-    };
-
-    /**
-     * Get the move menu.
-     *
-     * @method getMoveMenu
-     * @private
-     * @return {jQuery}
-     */
-    edittree.getMoveMenu = function() {
-        return $('#menumoveafter');
-    };
-
-    /**
-     * Check whether any checkboxes are ticked.
-     *
-     * @method checkMoveMenuState
-     * @private
-     * @return {Boolean}
-     */
-    edittree.checkMoveMenuState = function() {
-        var menu = edittree.getMoveMenu();
-        if (!menu.length) {
-            return false;
-        }
-
-        var selected;
-        $('.itemselect').each(function() {
-            selected = $(this).prop('checked');
-
-            // Return early if any are checked.
-            return !selected;
-        });
-
-        menu.prop('disabled', !selected);
-
-        return selected;
-    };
-
     return /** @alias module:core_grades/edittree_index */ {
         enhance: edittree
     };
index 86cf33e..8eac5fe 100644 (file)
@@ -840,20 +840,39 @@ class grade_edit_tree_column_select extends grade_edit_tree_column {
     }
 
     public function get_category_cell($category, $levelclass, $params) {
+        global $OUTPUT;
+
         if (empty($params['eid'])) {
             throw new Exception('Array key (eid) missing from 3rd param of grade_edit_tree_column_select::get_category_cell($category, $levelclass, $params)');
         }
-        $selectall = html_writer::link('#', get_string('all'), [
-            'data-action' => 'grade_edittree-index-bulkselect',
-            'data-checked' => true,
-        ]);
-        $selectnone = html_writer::link('#', get_string('none'), [
-            'data-action' => 'grade_edittree-index-bulkselect',
-            'data-checked' => false,
+
+        // Get toggle group for this master checkbox.
+        $togglegroup = $this->get_checkbox_togglegroup($category);
+        // Set label for this master checkbox.
+        $masterlabel = get_string('all');
+        // Use category name if available.
+        if ($category->fullname !== '?') {
+            $masterlabel = format_string($category->fullname);
+            // Limit the displayed category name to prevent the Select column from getting too wide.
+            if (core_text::strlen($masterlabel) > 20) {
+                $masterlabel = get_string('textellipsis', 'core', core_text::substr($masterlabel, 0, 12));
+            }
+        }
+        // Build the master checkbox.
+        $mastercheckbox = new \core\output\checkbox_toggleall($togglegroup, true, [
+            'id' => $togglegroup,
+            'name' => $togglegroup,
+            'value' => 1,
+            'classes' => 'itemselect ignoredirty',
+            'label' => $masterlabel,
+            // Consistent label to prevent the select column from resizing.
+            'selectall' => $masterlabel,
+            'deselectall' => $masterlabel,
+            'labelclasses' => 'm-0',
         ]);
 
         $categorycell = parent::get_category_cell($category, $levelclass, $params);
-        $categorycell->text = $selectall . ' / ' . $selectnone;
+        $categorycell->text = $OUTPUT->render($mastercheckbox);
         return $categorycell;
     }
 
@@ -864,12 +883,43 @@ class grade_edit_tree_column_select extends grade_edit_tree_column {
         $itemcell = parent::get_item_cell($item, $params);
 
         if ($params['itemtype'] != 'course' && $params['itemtype'] != 'category') {
-            $itemcell->text = '<label class="accesshide" for="select_'.$params['eid'].'">'.
-                get_string('select', 'grades', $item->itemname).'</label>
-                <input class="itemselect ignoredirty" type="checkbox" name="select_'.$params['eid'].'" id="select_'.$params['eid'].
-                '"/>';
+            global $OUTPUT;
+
+            // Fetch the grade item's category.
+            $category = grade_category::fetch(['id' => $item->categoryid]);
+            $togglegroup = $this->get_checkbox_togglegroup($category);
+
+            $checkboxid = 'select_' . $params['eid'];
+            $checkbox = new \core\output\checkbox_toggleall($togglegroup, false, [
+                'id' => $checkboxid,
+                'name' => $checkboxid,
+                'label' => get_string('select', 'grades', $item->itemname),
+                'labelclasses' => 'accesshide',
+                'classes' => 'itemselect ignoredirty',
+            ]);
+            $itemcell->text = $OUTPUT->render($checkbox);
         }
         return $itemcell;
     }
+
+    /**
+     * Generates a toggle group name for a bulk-action checkbox based on the given grade category.
+     *
+     * @param grade_category $category The grade category.
+     * @return string
+     */
+    protected function get_checkbox_togglegroup(grade_category $category): string {
+        $levels = [];
+        $categories = explode('/', $category->path);
+        foreach ($categories as $categoryid) {
+            $level = 'category' . $categoryid;
+            if (!in_array($level, $levels)) {
+                $levels[] = 'category' . $categoryid;
+            }
+        }
+        $togglegroup = implode(' ', $levels);
+
+        return $togglegroup;
+    }
 }
 
index 82ce32f..a0123d1 100644 (file)
     }
 }}
 <div class="enable">
-    <input type="checkbox" name="{{applyname}}" value="1" id="{{applyname}}">
+    <input type="checkbox" name="{{applyname}}" value="1" id="{{applyname}}" class="ml-0">
     <label for="{{applyname}}">{{applylabel}}</label>
 </div>
 <fieldset class="form-inline">
     <legend class="accesshide">{{label}}</legend>
     <label for="{{menuname}}">{{menulabel}}</label>
-    <select name="{{menuname}}" id="{{menuname}}" class="form-control">
+    <select name="{{menuname}}" id="{{menuname}}" class="form-control custom-select">
         {{#menuoptions}}
             <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
         {{/menuoptions}}
index 5c4c539..5767d76 100644 (file)
 {{!
     @template core_grades/edit_tree
 
-    Edit tree.
+    Edit tree template.
+
+    Context variables required for this template:
+    * actionurl - string - Form action URL.
+    * sesskey - string - The session key.
+    * notification - object - Context data for the notification.
+    * showsave - boolean - Whether to show the save changes button.
+    * showbulkmove - boolean - Whether to show the bulk move select menu.
+    * table - string - HTML content of the grade items table.
+    * bulkmoveoptions - array - Key-value pair array for the options of the the bulk move select menu element.
 
     Example context (json):
     {
-        "actionurl": "https://domain.example/grade/edit/tree/index.php?id=4",
+        "actionurl": "#",
         "sesskey": "fakesesskey",
         "notification": "",
         "table": "<table class='generaltable simple setup-grades' id='grade_edit_tree_table'><thead> <tr><th>Name</th><th>Weights</th><th>Max grade</th><th>Actions</th> </tr></thead><tbody></tbody></table>",
@@ -48,7 +57,8 @@
                 <div class="form-inline mt-3">
                     <input type="hidden" name="bulkmove" value="0" id="bulkmoveinput">
                     <label for="menumoveafter">{{#str}}moveselectedto, grades{{/str}}</label>
-                    <select name="moveafter" id="menumoveafter" class="ignoredirty singleselect custom-select form-control">
+                    <select name="moveafter" id="menumoveafter" class="ignoredirty singleselect custom-select form-control"
+                            data-action="toggle" data-toggle="action" data-togglegroup="category" disabled>
                         {{#bulkmoveoptions}}
                             <option value="{{value}}">{{name}}</option>
                         {{/bulkmoveoptions}}
diff --git a/install/lang/ar_wp/langconfig.php b/install/lang/ar_wp/langconfig.php
new file mode 100644 (file)
index 0000000..798c70c
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'ar';
+$string['thislanguage'] = 'بالعربي workplace';
index 6b5b240..71c6d8f 100644 (file)
@@ -30,5 +30,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['clianswerno'] = 'ទេ';
 $string['environmentrequireinstall'] = 'ត្រូវតែបានដំឡើង និងអនុញ្ញាត';
 $string['environmentrequireversion'] = 'ត្រូវការកំណែ {$a->needed} ហើយអ្នកកំពុងរត់ {$a->current}';
index ef92a53..61674dd 100644 (file)
@@ -467,6 +467,7 @@ $string['pagenotexist'] = 'An unusual error occurred (tried to reach a page that
 $string['pathdoesnotstartslash'] = 'No valid arguments supplied, path does not start with slash!';
 $string['pleasereport'] = 'If you have time, please let us know what you were trying to do when the error occurred:';
 $string['pluginrequirementsnotmet'] = 'Plugin "{$a->pluginname}" ({$a->pluginversion}) could not be installed.  It requires a newer version of Moodle (currently you are using {$a->currentmoodle}, you need {$a->requiremoodle}).';
+$string['pluginunsupported'] = 'Plugin "{$a->pluginname}" {$a->pluginversion} does not support this version of Moodle {$a->moodleversion}. Seek plugin information to find supported versions.';
 $string['prefixcannotbeempty'] = '<p>Error: database table prefix cannot be empty ({$a})</p>
 <p>The site administrator must fix this problem.</p>';
 $string['prefixtoolong'] = '<p>Error: database table prefix is too long ({$a->dbfamily})</p>
@@ -604,4 +605,3 @@ $string['xmldberror'] = 'XMLDB error!';
 $string['alreadyloggedin'] = 'You are already logged in as {$a}, you need to log out before logging in as different user.';
 $string['youcannotdeletecategory'] = 'You cannot delete category \'{$a}\' because you can neither delete the contents, nor move them elsewhere.';
 $string['protected_cc_not_supported'] = 'Protected cartridges not supported.';
-
index 0b950d1..6c17f58 100644 (file)
@@ -1842,6 +1842,7 @@ $string['shortnameuser'] = 'User short name';
 $string['shortsitename'] = 'Short name for site (eg single word)';
 $string['show'] = 'Show';
 $string['showactions'] = 'Show actions';
+$string['showadvancededitor'] = 'Advanced';
 $string['showadvancedsettings'] = 'Show advanced settings';
 $string['showall'] = 'Show all {$a}';
 $string['showallcourses'] = 'Show all courses';
@@ -2002,6 +2003,7 @@ If \'plain text area\' is selected, a format for text input areas such as HTML o
 The list of available text editors is determined by the site administrator.';
 $string['texteditor'] = 'Use standard web forms';
 $string['textformat'] = 'Plain text format';
+$string['textellipsis'] = '{$a}...';
 $string['timesplitting:deciles'] = 'Last tenth';
 $string['timesplitting:deciles_help'] = 'This analysis interval divides the course into tenths (10 equal parts), with each prediction being based on the data of only the most recent previous tenth.';
 $string['timesplitting:decilesaccum'] = 'All previous tenths';
index c0d4da1..94c5616 100644 (file)
@@ -54,6 +54,7 @@ $string['err_response_http_code'] = 'Unable to fetch available updates data - un
 $string['filterall'] = 'Show all';
 $string['filtercontribonly'] = 'Show additional plugins only';
 $string['filterupdatesonly'] = 'Show updateable only';
+$string['incompatibleversion'] = 'Incompatible Moodle version: {$a}';
 $string['isenabled'] = 'Enabled?';
 $string['misdepinfoplugin'] = 'Plugin info';
 $string['misdepinfoversion'] = 'Version info';
@@ -61,12 +62,14 @@ $string['misdepsavail'] = 'Available missing dependencies';
 $string['misdepsunavail'] = 'Unavailable missing dependencies';
 $string['misdepsunavaillist'] = 'No version found to fulfill the dependency requirements: {$a}.';
 $string['misdepsunknownlist'] = 'Not in the Plugins directory: <strong>{$a}</strong>.';
+$string['moodlebranch'] = 'Moodle {$a->min} - {$a->max}';
 $string['moodleversion'] = 'Moodle {$a}';
 $string['noneinstalled'] = 'No plugins of this type are installed';
 $string['notes'] = 'Notes';
 $string['notdownloadable'] = 'Can not download the package';
 $string['notdownloadable_help'] = 'ZIP package with the update can not be downloaded automatically. Please refer to the documentation page for more help.';
 $string['notdownloadable_link'] = 'admin/mdeploy/notdownloadable';
+$string['notsupported'] = 'Plugin may not be compatible with Moodle version {$a}';
 $string['notwritable'] = 'Plugin files not writable';
 $string['notwritable_help'] = 'Plugin files are not writable by the web server. The web server process must have write access to the plugin folder and all its contents. Write access to the root folder of the given plugin type may also be required.';
 $string['otherplugin'] = '{$a->component}';
index 72edb50..96d071a 100644 (file)
@@ -423,6 +423,7 @@ $string['site:restore'] = 'Restore courses';
 $string['site:sendmessage'] = 'Send messages to any user';
 $string['site:trustcontent'] = 'Trust submitted content';
 $string['site:uploadusers'] = 'Upload new users from file';
+$string['site:viewanonymousevents'] = 'View anonymous events in reports';
 $string['site:viewfullnames'] = 'Always see full names of users';
 $string['site:viewparticipants'] = 'View participants';
 $string['site:viewreports'] = 'View reports';
index 047a574..da19957 100644 (file)
@@ -2641,13 +2641,25 @@ class admin_setting_configpasswordunmask extends admin_setting_configtext {
      * @return  string              Rendered HTML
      */
     public function output_html($data, $query='') {
-        global $OUTPUT;
+        global $OUTPUT, $CFG;
+        $forced = false;
+        if (empty($this->plugin)) {
+            if (array_key_exists($this->name, $CFG->config_php_settings)) {
+                $forced = true;
+            }
+        } else {
+            if (array_key_exists($this->plugin, $CFG->forced_plugin_settings)
+                and array_key_exists($this->name, $CFG->forced_plugin_settings[$this->plugin])) {
+                $forced = true;
+            }
+        }
         $context = (object) [
             'id' => $this->get_id(),
             'name' => $this->get_full_name(),
             'size' => $this->size,
-            'value' => $data,
+            'value' => $forced ? null : $data,
             'forceltr' => $this->get_force_ltr(),
+            'forced' => $forced
         ];
         $element = $OUTPUT->render_from_template('core_admin/setting_configpasswordunmask', $context);
         return format_admin_setting($this, $this->visiblename, $element, $this->description, true, '', null, $query);
index 0888103..456f345 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js and b/lib/amd/build/loglevel.min.js differ
index aa558f6..3fb6ea7 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js.map and b/lib/amd/build/loglevel.min.js.map differ
index a9e781f..ebe3aa2 100644 (file)
Binary files a/lib/amd/build/mustache.min.js and b/lib/amd/build/mustache.min.js differ
index 589186e..62d7036 100644 (file)
Binary files a/lib/amd/build/mustache.min.js.map and b/lib/amd/build/mustache.min.js.map differ
index 0c31946..e3d9fd7 100644 (file)
@@ -1,39 +1,32 @@
-// The MIT License
-//
 // Copyright (c) 2013 Tim Perry
 //
-// Permission is hereby granted, free of charge, to any person obtaining
-// a copy of this software and associated documentation files (the
-// "Software"), to deal in the Software without restriction, including
-// without limitation the rights to use, copy, modify, merge, publish,
-// distribute, sublicense, and/or sell copies of the Software, and to
-// permit persons to whom the Software is furnished to do so, subject to
-// the following conditions:
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
 //
 // The above copyright notice and this permission notice shall be
 // included in all copies or substantial portions of the Software.
 //
 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-//
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
 
 // Description of import into Moodle:
-// Download from https://github.com/pimterry/loglevel/dist
+// Download from https://github.com/pimterry/loglevel/tree/master/dist
 // Copy loglevel.js into lib/amd/src/ in Moodle folder.
 // Add the license as a comment to the file and these instructions.
 
-/*
- * loglevel - https://github.com/pimterry/loglevel
- *
- * Copyright (c) 2013 Tim Perry
- * Licensed under the MIT license.
- */
-/*! loglevel - v1.6.2 - https://github.com/pimterry/loglevel - (c) 2019 Tim Perry - licensed MIT */
+/*! loglevel - v1.6.6 - https://github.com/pimterry/loglevel - (c) 2019 Tim Perry - licensed MIT */
 (function (root, definition) {
     "use strict";
     if (typeof define === 'function' && define.amd) {
@@ -49,6 +42,9 @@
     // Slightly dubious tricks to cut down minimized file size
     var noop = function() {};
     var undefinedType = "undefined";
+    var isIE = (typeof window !== undefinedType) && (
+        /Trident\/|MSIE /.test(window.navigator.userAgent)
+    );
 
     var logMethods = [
         "trace",
         }
     }
 
+    // Trace() doesn't print the message in IE, so for that case we need to wrap it
+    function traceForIE() {
+        if (console.log) {
+            if (console.log.apply) {
+                console.log.apply(console, arguments);
+            } else {
+                // In old IE, native console methods themselves don't have apply().
+                Function.prototype.apply.apply(console.log, [console, arguments]);
+            }
+        }
+        if (console.trace) console.trace();
+    }
+
     // Build the best logging method possible for this env
     // Wherever possible we want to bind, not wrap, to preserve stack traces
     function realMethod(methodName) {
@@ -84,6 +93,8 @@
 
         if (typeof console === undefinedType) {
             return false; // No method possible, for now - fixed later by enableLoggingWhenConsoleArrives
+        } else if (methodName === 'trace' && isIE) {
+            return traceForIE;
         } else if (console[methodName] !== undefined) {
             return bindMethod(console, methodName);
         } else if (console.log !== undefined) {
     function defaultMethodFactory(methodName, level, loggerName) {
         /*jshint validthis:true */
         return realMethod(methodName) ||
-            enableLoggingWhenConsoleArrives.apply(this, arguments);
+               enableLoggingWhenConsoleArrives.apply(this, arguments);
     }
 
     function Logger(name, defaultLevel, factory) {
-        var self = this;
-        var currentLevel;
-        var storageKey = "loglevel";
-        if (name) {
-            storageKey += ":" + name;
-        }
-
-        function persistLevelIfPossible(levelNum) {
-            var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
-
-            if (typeof window === undefinedType) return;
-
-            // Use localStorage if available
-            try {
-                window.localStorage[storageKey] = levelName;
-                return;
-            } catch (ignore) {}
-
-            // Use session cookie as fallback
-            try {
-                window.document.cookie =
-                    encodeURIComponent(storageKey) + "=" + levelName + ";";
-            } catch (ignore) {}
-        }
-
-        function getPersistedLevel() {
-            var storedLevel;
-
-            if (typeof window === undefinedType) return;
-
-            try {
-                storedLevel = window.localStorage[storageKey];
-            } catch (ignore) {}
-
-            // Fallback to cookies if local storage gives us nothing
-            if (typeof storedLevel === undefinedType) {
-                try {
-                    var cookie = window.document.cookie;
-                    var location = cookie.indexOf(
-                        encodeURIComponent(storageKey) + "=");
-                    if (location !== -1) {
-                        storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
-                    }
-                } catch (ignore) {}
-            }
-
-            // If the stored level is not valid, treat it as if nothing was stored.
-            if (self.levels[storedLevel] === undefined) {
-                storedLevel = undefined;
-            }
-
-            return storedLevel;
-        }
-
-        /*
-         *
-         * Public logger API - see https://github.com/pimterry/loglevel for details
-         *
-         */
-
-        self.name = name;
-
-        self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
-            "ERROR": 4, "SILENT": 5};
-
-        self.methodFactory = factory || defaultMethodFactory;
-
-        self.getLevel = function () {
-            return currentLevel;
-        };
-
-        self.setLevel = function (level, persist) {
-            if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
-                level = self.levels[level.toUpperCase()];
-            }
-            if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
-                currentLevel = level;
-                if (persist !== false) {  // defaults to true
-                    persistLevelIfPossible(level);
-                }
-                replaceLoggingMethods.call(self, level, name);
-                if (typeof console === undefinedType && level < self.levels.SILENT) {
-                    return "No console available for logging";
-                }
-            } else {
-                throw "log.setLevel() called with invalid level: " + level;
-            }
-        };
-
-        self.setDefaultLevel = function (level) {
-            if (!getPersistedLevel()) {
-                self.setLevel(level, false);
-            }
-        };
-
-        self.enableAll = function(persist) {
-            self.setLevel(self.levels.TRACE, persist);
-        };
-
-        self.disableAll = function(persist) {
-            self.setLevel(self.levels.SILENT, persist);
-        };
-
-        // Initialize with the right level
-        var initialLevel = getPersistedLevel();
-        if (initialLevel == null) {
-            initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
-        }
-        self.setLevel(initialLevel, false);
+      var self = this;
+      var currentLevel;
+      var storageKey = "loglevel";
+      if (name) {
+        storageKey += ":" + name;
+      }
+
+      function persistLevelIfPossible(levelNum) {
+          var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
+
+          if (typeof window === undefinedType) return;
+
+          // Use localStorage if available
+          try {
+              window.localStorage[storageKey] = levelName;
+              return;
+          } catch (ignore) {}
+
+          // Use session cookie as fallback
+          try {
+              window.document.cookie =
+                encodeURIComponent(storageKey) + "=" + levelName + ";";
+          } catch (ignore) {}
+      }
+
+      function getPersistedLevel() {
+          var storedLevel;
+
+          if (typeof window === undefinedType) return;
+
+          try {
+              storedLevel = window.localStorage[storageKey];
+          } catch (ignore) {}
+
+          // Fallback to cookies if local storage gives us nothing
+          if (typeof storedLevel === undefinedType) {
+              try {
+                  var cookie = window.document.cookie;
+                  var location = cookie.indexOf(
+                      encodeURIComponent(storageKey) + "=");
+                  if (location !== -1) {
+                      storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
+                  }
+              } catch (ignore) {}
+          }
+
+          // If the stored level is not valid, treat it as if nothing was stored.
+          if (self.levels[storedLevel] === undefined) {
+              storedLevel = undefined;
+          }
+
+          return storedLevel;
+      }
+
+      /*
+       *
+       * Public logger API - see https://github.com/pimterry/loglevel for details
+       *
+       */
+
+      self.name = name;
+
+      self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
+          "ERROR": 4, "SILENT": 5};
+
+      self.methodFactory = factory || defaultMethodFactory;
+
+      self.getLevel = function () {
+          return currentLevel;
+      };
+
+      self.setLevel = function (level, persist) {
+          if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
+              level = self.levels[level.toUpperCase()];
+          }
+          if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
+              currentLevel = level;
+              if (persist !== false) {  // defaults to true
+                  persistLevelIfPossible(level);
+              }
+              replaceLoggingMethods.call(self, level, name);
+              if (typeof console === undefinedType && level < self.levels.SILENT) {
+                  return "No console available for logging";
+              }
+          } else {
+              throw "log.setLevel() called with invalid level: " + level;
+          }
+      };
+
+      self.setDefaultLevel = function (level) {
+          if (!getPersistedLevel()) {
+              self.setLevel(level, false);
+          }
+      };
+
+      self.enableAll = function(persist) {
+          self.setLevel(self.levels.TRACE, persist);
+      };
+
+      self.disableAll = function(persist) {
+          self.setLevel(self.levels.SILENT, persist);
+      };
+
+      // Initialize with the right level
+      var initialLevel = getPersistedLevel();
+      if (initialLevel == null) {
+          initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
+      }
+      self.setLevel(initialLevel, false);
     }
 
     /*
     var _loggersByName = {};
     defaultLogger.getLogger = function getLogger(name) {
         if (typeof name !== "string" || name === "") {
-            throw new TypeError("You must supply a name when creating a logger.");
+          throw new TypeError("You must supply a name when creating a logger.");
         }
 
         var logger = _loggersByName[name];
         if (!logger) {
-            logger = _loggersByName[name] = new Logger(
-                name, defaultLogger.getLevel(), defaultLogger.methodFactory);
+          logger = _loggersByName[name] = new Logger(
+            name, defaultLogger.getLevel(), defaultLogger.methodFactory);
         }
         return logger;
     };
     var _log = (typeof window !== undefinedType) ? window.log : undefined;
     defaultLogger.noConflict = function() {
         if (typeof window !== undefinedType &&
-            window.log === defaultLogger) {
+               window.log === defaultLogger) {
             window.log = _log;
         }
 
index 573ddca..360d7d1 100644 (file)
    * Tokens that are the root node of a subtree contain two more elements: 1) an
    * array of tokens in the subtree and 2) the index in the original template at
    * which the closing tag for that section begins.
+   *
+   * Tokens for partials also contain two more elements: 1) a string value of
+   * indendation prior to that tag and 2) the index of that tag on that line -
+   * eg a value of 2 indicates the partial is the third tag on this line.
    */
   function parseTemplate (template, tags) {
     if (!template)
       return [];
-
+    var lineHasNonSpace = false;
     var sections = [];     // Stack to hold section tokens
     var tokens = [];       // Buffer to hold the tokens
     var spaces = [];       // Indices of whitespace tokens on the current line
     var hasTag = false;    // Is there a {{tag}} on the current line?
     var nonSpace = false;  // Is there a non-space char on the current line?
+    var indentation = '';  // Tracks indentation for tags that use it
+    var tagIndex = 0;      // Stores a count of number of tags encountered on a line
 
     // Strips all whitespace tokens array for the current line
     // if there was a {{#tag}} on it and otherwise only space.
 
           if (isWhitespace(chr)) {
             spaces.push(tokens.length);
+            indentation += chr;
           } else {
             nonSpace = true;
+            lineHasNonSpace = true;
+            indentation += ' ';
           }
 
           tokens.push([ 'text', chr, start, start + 1 ]);
           start += 1;
 
           // Check for whitespace on the current line.
-          if (chr === '\n')
+          if (chr === '\n') {
             stripSpace();
+            indentation = '';
+            tagIndex = 0;
+            lineHasNonSpace = false;
+          }
         }
       }
 
       if (!scanner.scan(closingTagRe))
         throw new Error('Unclosed tag at ' + scanner.pos);
 
-      token = [ type, value, start, scanner.pos ];
+      if (type == '>') {
+        token = [ type, value, start, scanner.pos, indentation, tagIndex, lineHasNonSpace ];
+      } else {
+        token = [ type, value, start, scanner.pos ];
+      }
+      tagIndex++;
       tokens.push(token);
 
       if (type === '#' || type === '^' || type === '$' || type === '<') {
       }
     }
 
+    stripSpace();
+
     // Make sure there are no open sections when we're done.
     openSection = sections.pop();
 
       return this.renderTokens(token[4], context, partials, originalTemplate);
   };
 
+  Writer.prototype.indentPartial = function indentPartial (partial, indentation, lineHasNonSpace) {
+    var filteredIndentation = indentation.replace(/[^ \t]/g, '');
+    var partialByNl = partial.split('\n');
+    for (var i = 0; i < partialByNl.length; i++) {
+      if (partialByNl[i].length && (i > 0 || !lineHasNonSpace)) {
+        partialByNl[i] = filteredIndentation + partialByNl[i];
+      }
+    }
+    return partialByNl.join('\n');
+  };
+
   Writer.prototype.renderPartial = function renderPartial (token, context, partials, tags) {
     if (!partials) return;
 
     var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
-    if (value != null)
-      return this.renderTokens(this.parse(value, tags), context, partials, value);
+    if (value != null) {
+      var lineHasNonSpace = token[6];
+      var tagIndex = token[5];
+      var indentation = token[4];
+      var indentedValue = value;
+      if (tagIndex == 0 && indentation) {
+        indentedValue = this.indentPartial(value, indentation, lineHasNonSpace);
+      }
+      return this.renderTokens(this.parse(indentedValue, tags), context, partials, indentedValue);
+    }
   };
 
   Writer.prototype.renderBlock = function renderBlock (token, context, partials, originalTemplate) {
 
     var value = isFunction(partials) ? partials(token[1]) : partials[token[1]];
     if (value != null)
-      // Ignore any wrongly set block vars before we started.
-      context.clearBlockVars();
-      // We are only rendering to record the default block variables.
-      this.renderTokens(token[4], context, partials, originalTemplate);
-      // Now we render and return the result.
-      var result = this.renderTokens(this.parse(value), context, partials, value);
-      // Don't leak the block variables outside this include.
+    // Ignore any wrongly set block vars before we started.
       context.clearBlockVars();
-      return result;
+    // We are only rendering to record the default block variables.
+    this.renderTokens(token[4], context, partials, originalTemplate);
+    // Now we render and return the result.
+    var result = this.renderTokens(this.parse(value), context, partials, value);
+    // Don't leak the block variables outside this include.
+    context.clearBlockVars();
+    return result;
   };
 
   Writer.prototype.renderBlockVariable = function renderBlockVariable (token, context, partials, originalTemplate) {
   };
 
   mustache.name = 'mustache.js';
-  mustache.version = '3.0.1';
+  mustache.version = '3.1.0';
   mustache.tags = [ '{{', '}}' ];
 
   // All high-level mustache.* functions use this writer.
index f473cea..488ce71 100644 (file)
@@ -49,17 +49,56 @@ class antivirus_clamav_runningmethod_setting extends admin_setting_configselect
     /**
      * Validate data.
      *
-     * This ensures that unix socket transport is supported by this system.
+     * This ensures that the selected socket transport is supported by this system.
      *
      * @param string $data
      * @return mixed True on success, else error message.
      */
     public function validate($data) {
+        $supportedtransports = stream_get_transports();
         if ($data === 'unixsocket') {
-            $supportedtransports = stream_get_transports();
-            if (!array_search('unix', $supportedtransports)) {
+            if (array_search('unix', $supportedtransports) === false) {
                 return get_string('errornounixsocketssupported', 'antivirus_clamav');
             }
+        } else if ($data === 'tcpsocket') {
+            if (array_search('tcp', $supportedtransports) === false) {
+                return get_string('errornotcpsocketssupported', 'antivirus_clamav');
+            }
+        }
+        return true;
+    }
+}
+
+
+/**
+ * Abstract socket checking class
+ *
+ * @package    antivirus_clamav
+ * @copyright  2015 Ruslan Kabalin, Lancaster University.
+ * @copyright  2019 Didier Raboud, Liip AG.
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class antivirus_clamav_socket_setting extends admin_setting_configtext {
+    /**
+     * Ping ClamAV socket.
+     *
+     * This ensures that a socket setting is correct and that ClamAV is running.
+     *
+     * @param string $socketaddress Address to the socket to connect to (for stream_socket_client)
+     * @return mixed True on success, else error message.
+     */
+    protected function validate_clamav_socket($socketaddress) {
+        $socket = stream_socket_client($socketaddress, $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
+        if (!$socket) {
+            return get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
+        } else {
+            // Send PING query to ClamAV socket to check its running state.
+            fwrite($socket, "nPING\n");
+            $response = stream_get_line($socket, 4);
+            fclose($socket);
+            if ($response !== 'PONG') {
+                return get_string('errorclamavnoresponse', 'antivirus_clamav');
+            }
         }
         return true;
     }
@@ -71,7 +110,7 @@ class antivirus_clamav_runningmethod_setting extends admin_setting_configselect
  * @copyright  2015 Ruslan Kabalin, Lancaster University.
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class antivirus_clamav_pathtounixsocket_setting extends admin_setting_configtext {
+class antivirus_clamav_pathtounixsocket_setting extends antivirus_clamav_socket_setting {
     /**
      * Validate data.
      *
@@ -87,19 +126,38 @@ class antivirus_clamav_pathtounixsocket_setting extends admin_setting_configtext
         }
         $runningmethod = get_config('antivirus_clamav', 'runningmethod');
         if ($runningmethod === 'unixsocket') {
-            $socket = stream_socket_client('unix://' . $data, $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
-            if (!$socket) {
-                return get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
-            } else {
-                // Send PING query to ClamAV socket to check its running state.
-                fwrite($socket, "nPING\n");
-                $response = stream_get_line($socket, 4);
-                fclose($socket);
-                if ($response !== 'PONG') {
-                    return get_string('errorclamavnoresponse', 'antivirus_clamav');
-                }
-            }
+            return $this->validate_clamav_socket('unix://' . $data);
         }
         return true;
     }
 }
+
+/**
+ * Admin setting for Internet domain socket host, adds verification.
+ *
+ * @package    antivirus_clamav
+ * @copyright  2019 Didier Raboud, Liip AG.
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class antivirus_clamav_tcpsockethost_setting extends antivirus_clamav_socket_setting {
+    /**
+     * Validate data.
+     *
+     * This ensures that Internet domain socket setting is correct and ClamAV is running.
+     *
+     * @param string $data
+     * @return mixed True on success, else error message.
+     */
+    public function validate($data) {
+        $result = parent::validate($data);
+        if ($result !== true) {
+            return $result;
+        }
+        $runningmethod = get_config('antivirus_clamav', 'runningmethod');
+        $tcpport = get_config('antivirus_clamav', 'tcpsocketport');
+        if ($runningmethod === 'tcpsocket') {
+            return $this->validate_clamav_socket('tcp://' . $data . ':' . $tcpport);
+        }
+        return true;
+    }
+}
\ No newline at end of file
index 3e55ce4..1bd1cdb 100644 (file)
@@ -34,6 +34,7 @@ define('ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE', 1024);
 /**
  * Class implementing ClamAV antivirus.
  * @copyright  2015 Ruslan Kabalin, Lancaster University.
+ * @copyright  2019 Didier Raboud, Liip AG.
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class scanner extends \core\antivirus\scanner {
@@ -47,6 +48,8 @@ class scanner extends \core\antivirus\scanner {
             return (bool)$this->get_config('pathtoclam');
         } else if ($this->get_config('runningmethod') === 'unixsocket') {
             return (bool)$this->get_config('pathtounixsocket');
+        } else if ($this->get_config('runningmethod') === 'tcpsocket') {
+            return (bool)$this->get_config('tcpsockethost') && (bool)$this->get_config('tcpsocketport');
         }
         return false;
     }
@@ -67,12 +70,22 @@ class scanner extends \core\antivirus\scanner {
             return self::SCAN_RESULT_ERROR;
         }
 
-        // Execute the scan using preferable method.
-        $method = 'scan_file_execute_' . $this->get_config('runningmethod');
-        if (!method_exists($this, $method)) {
-            throw new \coding_exception('Attempting to call non-existing method ' . $method);
+        // We can do direct stream scanning if unixsocket or tcpsocket running methods are in use,
+        // if not, use default process.
+        $runningmethod = $this->get_config('runningmethod');
+        switch ($runningmethod) {
+            case 'unixsocket':
+            case 'tcpsocket':
+                $return = $this->scan_file_execute_socket($file, $runningmethod);
+                break;
+            case 'commandline':
+                $return = $this->scan_file_execute_commandline($file);
+                break;
+            default:
+                // This should not happen.
+                debugging('Unknown running method.');
+                return self::SCAN_RESULT_ERROR;
         }
-        $return = $this->$method($file);
 
         if ($return === self::SCAN_RESULT_ERROR) {
             $this->message_admins($this->get_scanning_notice());
@@ -92,10 +105,11 @@ class scanner extends \core\antivirus\scanner {
      * @return int Scanning result constant.
      */
     public function scan_data($data) {
-        // We can do direct stream scanning if unixsocket running method is in use,
+        // We can do direct stream scanning if unixsocket or tcpsocket running methods are in use,
         // if not, use default process.
-        if ($this->get_config('runningmethod') === 'unixsocket') {
-            $return = $this->scan_data_execute_unixsocket($data);
+        $runningmethod = $this->get_config('runningmethod');
+        if (in_array($runningmethod, array('unixsocket', 'tcpsocket'))) {
+            $return = $this->scan_data_execute_socket($data, $runningmethod);
 
             if ($return === self::SCAN_RESULT_ERROR) {
                 $this->message_admins($this->get_scanning_notice());
@@ -111,6 +125,24 @@ class scanner extends \core\antivirus\scanner {
         }
     }
 
+    /**
+     * Returns a Unix domain socket destination url
+     *
+     * @return string The socket url, fit for stream_socket_client()
+     */
+    private function get_unixsocket_destination() {
+        return 'unix://' . $this->get_config('pathtounixsocket');
+    }
+
+    /**
+     * Returns a Internet domain socket destination url
+     *
+     * @return string The socket url, fit for stream_socket_client()
+     */
+    private function get_tcpsocket_destination() {
+        return 'tcp://' . $this->get_config('tcpsockethost') . ':' . $this->get_config('tcpsocketport');
+    }
+
     /**
      * Returns the string equivalent of a numeric clam error code
      *
@@ -189,13 +221,27 @@ class scanner extends \core\antivirus\scanner {
     }
 
     /**
-     * Scan file using Unix domain sockets.
+     * Scan file using sockets.
      *
      * @param string $file Full path to the file.
+     * @param string $type Either 'tcpsocket' or 'unixsocket'
      * @return int Scanning result constant.
      */
-    public function scan_file_execute_unixsocket($file) {
-        $socket = stream_socket_client('unix://' . $this->get_config('pathtounixsocket'),
+    public function scan_file_execute_socket($file, $type) {
+        switch ($type) {
+            case "tcpsocket":
+                $socketurl = $this->get_tcpsocket_destination();
+                break;
+            case "unixsocket":
+                $socketurl = $this->get_unixsocket_destination();
+                break;
+            default;
+                // This should not happen.
+                debugging('Unknown socket type.');
+                return self::SCAN_RESULT_ERROR;
+        }
+
+        $socket = stream_socket_client($socketurl,
                 $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
         if (!$socket) {
             // Can't open socket for some reason, notify admins.
@@ -203,26 +249,57 @@ class scanner extends \core\antivirus\scanner {
             $this->set_scanning_notice($notice);
             return self::SCAN_RESULT_ERROR;
         } else {
-            // Execute scanning. We are running SCAN command and passing file as an argument,
-            // it is the fastest option, but clamav user need to be able to access it, so
-            // we give group read permissions first and assume 'clamav' user is in web server
-            // group (in Debian the default webserver group is 'www-data').
-            // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
-            // this is to avoid unexpected newline characters on different systems.
-            $perms = fileperms($file);
-            chmod($file, 0640);
-            fwrite($socket, "nSCAN ".$file."\n");
-            $output = stream_get_line($socket, 4096);
+            if ($type == "unixsocket") {
+                // Execute scanning. We are running SCAN command and passing file as an argument,
+                // it is the fastest option, but clamav user need to be able to access it, so
+                // we give group read permissions first and assume 'clamav' user is in web server
+                // group (in Debian the default webserver group is 'www-data').
+                // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
+                // this is to avoid unexpected newline characters on different systems.
+                $perms = fileperms($file);
+                chmod($file, 0640);
+
+                // Actual scan.
+                fwrite($socket, "nSCAN ".$file."\n");
+                // Get ClamAV answer.
+                $output = stream_get_line($socket, 4096);
+
+                // After scanning we revert permissions to initial ones.
+                chmod($file, $perms);
+            } else if ($type == "tcpsocket") {
+                // Execute scanning by passing the entire file through the TCP socket.
+                // This is not fast, but is the only possibility over a network.
+                // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
+                // this is to avoid unexpected newline characters on different systems.
+
+                // Actual scan.
+                fwrite($socket, "nINSTREAM\n");
+
+                // Open the file for reading.
+                $fhandle = fopen($file, 'rb');
+                while (!feof($fhandle)) {
+                    // Read it by chunks; write them to the TCP socket.
+                    $chunk = fread($fhandle, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
+                    $size = pack('N', strlen($chunk));
+                    fwrite($socket, $size);
+                    fwrite($socket, $chunk);
+                }
+                // Terminate streaming.
+                fwrite($socket, pack('N', 0));
+                // Get ClamAV answer.
+                $output = stream_get_line($socket, 4096);
+
+                fclose($fhandle);
+            }
+            // Free up the ClamAV socket.
             fclose($socket);
-            // After scanning we revert permissions to initial ones.
-            chmod($file, $perms);
             // Parse the output.
-            return $this->parse_unixsocket_response($output);
+            return $this->parse_socket_response($output);
         }
     }
 
     /**
-     * Scan data using unix socket.
+     * Scan data socket.
      *
      * We are running INSTREAM command and passing data stream in chunks.
      * The format of the chunk is: <length><data> where <length> is the size of the following
@@ -231,11 +308,25 @@ class scanner extends \core\antivirus\scanner {
      * Do not exceed StreamMaxLength as defined in clamd.conf, otherwise clamd will
      * reply with INSTREAM size limit exceeded and close the connection.
      *
-     * @param string $data The varaible containing the data to scan.
+     * @param string $data The variable containing the data to scan.
+     * @param string $type Either 'tcpsocket' or 'unixsocket'
      * @return int Scanning result constant.
      */
-    public function scan_data_execute_unixsocket($data) {
-        $socket = stream_socket_client('unix://' . $this->get_config('pathtounixsocket'), $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
+    public function scan_data_execute_socket($data, $type) {
+        switch ($type) {
+            case "tcpsocket":
+                $socketurl = $this->get_tcpsocket_destination();
+                break;
+            case "unixsocket":
+                $socketurl = $this->get_unixsocket_destination();
+                break;
+            default;
+                // This should not happen.
+                debugging('Unknown socket type!');
+                return self::SCAN_RESULT_ERROR;
+        }
+
+        $socket = stream_socket_client($socketurl, $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
         if (!$socket) {
             // Can't open socket for some reason, notify admins.
             $notice = get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
@@ -261,17 +352,17 @@ class scanner extends \core\antivirus\scanner {
             fclose($socket);
 
             // Parse the output.
-            return $this->parse_unixsocket_response($output);
+            return $this->parse_socket_response($output);
         }
     }
 
     /**
-     * Parse unix socket command response.
+     * Parse socket command response.
      *
-     * @param string $output The unix socket command response.
+     * @param string $output The socket response.
      * @return int Scanning result constant.
      */
-    private function parse_unixsocket_response($output) {
+    private function parse_socket_response($output) {
         $splitoutput = explode(': ', $output);
         $message = trim($splitoutput[1]);
         if ($message === 'OK') {
@@ -289,4 +380,35 @@ class scanner extends \core\antivirus\scanner {
             }
         }
     }
+
+    /**
+     * Scan data using Unix domain socket.
+     *
+     * @deprecated since Moodle 3.9 MDL-64075 - please do not use this function any more.
+     * @see antivirus_clamav\scanner::scan_data_execute_socket()
+     *
+     * @param string $data The variable containing the data to scan.
+     * @return int Scanning result constant.
+     */
+    public function scan_data_execute_unixsocket($data) {
+        debugging('antivirus_clamav\scanner::scan_data_execute_unixsocket() is deprecated. ' .
+                  'Use antivirus_clamav\scanner::scan_data_execute_socket() instead.', DEBUG_DEVELOPER);
+        return $this->scan_data_execute_socket($data, "unixsocket");
+    }
+
+    /**
+     * Scan file using Unix domain socket.
+     *
+     * @deprecated since Moodle 3.9 MDL-64075 - please do not use this function any more.
+     * @see antivirus_clamav\scanner::scan_file_execute_socket()
+     *
+     * @param string $file Full path to the file.
+     * @return int Scanning result constant.
+     */
+    public function scan_file_execute_unixsocket($file) {
+        debugging('antivirus_clamav\scanner::scan_file_execute_unixsocket() is deprecated. ' .
+                  'Use antivirus_clamav\scanner::scan_file_execute_socket() instead.', DEBUG_DEVELOPER);
+        return $this->scan_file_execute_socket($file, "unixsocket");
+    }
+
 }
index 0e90141..a7a397f 100644 (file)
@@ -42,4 +42,9 @@ $string['runningmethod'] = 'Running method';
 $string['runningmethoddesc'] = 'Method of running ClamAV. Command line is used by default, however on Unix systems better performance can be obtained by using system sockets.';
 $string['runningmethodcommandline'] = 'Command line';
 $string['runningmethodunixsocket'] = 'Unix domain socket';
+$string['runningmethodtcpsocket'] = 'TCP socket';
+$string['tcpsockethost'] = 'TCP socket hostname';
+$string['tcpsockethostdesc'] = 'Domain name of the ClamAV server';
+$string['tcpsocketport'] = 'TCP socket port';
+$string['tcpsocketportdesc'] = 'The port to use when connecting to ClamAV';
 $string['unknownerror'] = 'There was an unknown error with ClamAV.';
index 7bf877a..2c895c9 100644 (file)
@@ -32,6 +32,7 @@ if ($ADMIN->fulltree) {
     $runningmethodchoice = array(
         'commandline' => get_string('runningmethodcommandline', 'antivirus_clamav'),
         'unixsocket' => get_string('runningmethodunixsocket', 'antivirus_clamav'),
+        'tcpsocket' => get_string('runningmethodtcpsocket', 'antivirus_clamav'),
     );
     $settings->add(new antivirus_clamav_runningmethod_setting('antivirus_clamav/runningmethod',
             get_string('runningmethod', 'antivirus_clamav'),
@@ -47,6 +48,16 @@ if ($ADMIN->fulltree) {
             new lang_string('pathtounixsocket', 'antivirus_clamav'),
             new lang_string('pathtounixsocketdesc', 'antivirus_clamav'), '', PARAM_PATH));
 
+    // Hostname to reach ClamAV tcp socket (used in tcp socket running method).
+    $settings->add(new antivirus_clamav_tcpsockethost_setting('antivirus_clamav/tcpsockethost',
+            new lang_string('tcpsockethost', 'antivirus_clamav'),
+            new lang_string('tcpsockethostdesc', 'antivirus_clamav'), '', PARAM_HOST));
+
+    // Port to reach ClamAV tcp socket (used in tcp socket running method).
+    $settings->add(new admin_setting_configtext('antivirus_clamav/tcpsocketport',
+            new lang_string('tcpsocketport', 'antivirus_clamav'),
+            new lang_string('tcpsocketportdesc', 'antivirus_clamav'), 3310, PARAM_INT));
+
     // How to act on ClamAV failure.
     $options = array(
         'donothing' => new lang_string('configclamdonothing', 'antivirus_clamav'),
index a29edb2..4a8b9c1 100644 (file)
@@ -44,8 +44,8 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
 
     public function test_scan_file_not_exists() {
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods(array('scan_file_execute_commandline', 'message_admins'))
-                ->getMock();
+            ->setMethods(array('scan_file_execute_commandline', 'message_admins'))
+            ->getMock();
 
         // Test specifying file that does not exist.
         $nonexistingfile = $this->tempfile . '_';
@@ -58,21 +58,21 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
     public function test_scan_file_no_virus() {
         $methods = array(
             'scan_file_execute_commandline',
-            'scan_file_execute_unixsocket',
+            'scan_file_execute_socket',
             'message_admins',
             'get_config',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
         // Initiate mock scanning with configuration setting to use commandline.
         $configmap = array(array('runningmethod', 'commandline'));
         $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
-        // Configure scan_file_execute_commandline and scan_file_execute_unixsocket
+        // Configure scan_file_execute_commandline and scan_file_execute_socket
         // method stubs to behave as if no virus has been found (SCAN_RESULT_OK).
         $antivirus->method('scan_file_execute_commandline')->willReturn(0);
-        $antivirus->method('scan_file_execute_unixsocket')->willReturn(0);
+        $antivirus->method('scan_file_execute_socket')->willReturn(0);
 
         // Set expectation that message_admins is NOT called.
         $antivirus->expects($this->never())->method('message_admins');
@@ -87,26 +87,33 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
 
         // Run mock scanning.
         $this->assertEquals(0, $antivirus->scan_file($this->tempfile, ''));
+
+        // Initiate mock scanning with configuration setting to use tcpsocket.
+        $configmap = array(array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
+
+        // Run mock scanning.
+        $this->assertEquals(0, $antivirus->scan_file($this->tempfile, ''));
     }
 
     public function test_scan_file_virus() {
         $methods = array(
             'scan_file_execute_commandline',
-            'scan_file_execute_unixsocket',
+            'scan_file_execute_socket',
             'message_admins',
             'get_config',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
         // Initiate mock scanning with configuration setting to use commandline.
         $configmap = array(array('runningmethod', 'commandline'));
         $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
-        // Configure scan_file_execute_commandline and scan_file_execute_unixsocket
+        // Configure scan_file_execute_commandline and scan_file_execute_socket
         // method stubs to behave as if virus has been found (SCAN_RESULT_FOUND).
         $antivirus->method('scan_file_execute_commandline')->willReturn(1);
-        $antivirus->method('scan_file_execute_unixsocket')->willReturn(1);
+        $antivirus->method('scan_file_execute_socket')->willReturn(1);
 
         // Set expectation that message_admins is NOT called.
         $antivirus->expects($this->never())->method('message_admins');
@@ -121,24 +128,31 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
 
         // Run mock scanning.
         $this->assertEquals(1, $antivirus->scan_file($this->tempfile, ''));
+
+        // Initiate mock scanning with configuration setting to use tcpsocket.
+        $configmap = array(array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
+
+        // Run mock scanning.
+        $this->assertEquals(1, $antivirus->scan_file($this->tempfile, ''));
     }
 
     public function test_scan_file_error_donothing() {
         $methods = array(
             'scan_file_execute_commandline',
-            'scan_file_execute_unixsocket',
+            'scan_file_execute_socket',
             'message_admins',
             'get_config',
             'get_scanning_notice',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
 
-        // Configure scan_file_execute_commandline and scan_file_execute_unixsocket
+        // Configure scan_file_execute_commandline and scan_file_execute_socket
         // method stubs to behave as if there is a scanning error (SCAN_RESULT_ERROR).
         $antivirus->method('scan_file_execute_commandline')->willReturn(2);
-        $antivirus->method('scan_file_execute_unixsocket')->willReturn(2);
+        $antivirus->method('scan_file_execute_socket')->willReturn(2);
         $antivirus->method('get_scanning_notice')->willReturn('someerror');
 
         // Set expectation that message_admins is called.
@@ -160,24 +174,32 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
 
         // Run mock scanning.
         $this->assertEquals(2, $antivirus->scan_file($this->tempfile, ''));
+
+        // Initiate mock scanning with configuration setting to do nothing on
+        // scanning error and using tcpsocket.
+        $configmap = array(array('clamfailureonupload', 'donothing'), array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
+
+        // Run mock scanning.
+        $this->assertEquals(2, $antivirus->scan_file($this->tempfile, ''));
     }
 
     public function test_scan_file_error_actlikevirus() {
         $methods = array(
             'scan_file_execute_commandline',
-            'scan_file_execute_unixsocket',
+            'scan_file_execute_socket',
             'message_admins',
             'get_config',
             'get_scanning_notice',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
 
-        // Configure scan_file_execute_commandline and scan_file_execute_unixsocket
+        // Configure scan_file_execute_commandline and scan_file_execute_socket
         // method stubs to behave as if there is a scanning error (SCAN_RESULT_ERROR).
         $antivirus->method('scan_file_execute_commandline')->willReturn(2);
-        $antivirus->method('scan_file_execute_unixsocket')->willReturn(2);
+        $antivirus->method('scan_file_execute_socket')->willReturn(2);
         $antivirus->method('get_scanning_notice')->willReturn('someerror');
 
         // Set expectation that message_admins is called.
@@ -201,24 +223,43 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
         // Run mock scanning, we expect SCAN_RESULT_FOUND since configuration
         // require us to act like virus.
         $this->assertEquals(1, $antivirus->scan_file($this->tempfile, ''));
+
+        // Initiate mock scanning with configuration setting to act like virus on
+        // scanning error and using tcpsocket.
+        $configmap = array(array('clamfailureonupload', 'actlikevirus'), array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
+
+        // Run mock scanning, we expect SCAN_RESULT_FOUND since configuration
+        // require us to act like virus.
+        $this->assertEquals(1, $antivirus->scan_file($this->tempfile, ''));
     }
 
     public function test_scan_data_no_virus() {
         $methods = array(
-            'scan_data_execute_unixsocket',
+            'scan_data_execute_socket',
             'message_admins',
             'get_config',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
         // Initiate mock scanning with configuration setting to use unixsocket.
         $configmap = array(array('runningmethod', 'unixsocket'));
         $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
-        // Configure scan_data_execute_unixsocket method stubs to behave as if
+        // Configure scan_data_execute_socket method stubs to behave as if
         // no virus has been found (SCAN_RESULT_OK).
-        $antivirus->method('scan_data_execute_unixsocket')->willReturn(0);
+        $antivirus->method('scan_data_execute_socket')->willReturn(0);
+
+        // Set expectation that message_admins is NOT called.
+        $antivirus->expects($this->never())->method('message_admins');
+
+        // Run mock scanning.
+        $this->assertEquals(0, $antivirus->scan_data(''));
+
+        // Re-initiate mock scanning with configuration setting to use tcpsocket.
+        $configmap = array(array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
         // Set expectation that message_admins is NOT called.
         $antivirus->expects($this->never())->method('message_admins');
@@ -229,20 +270,30 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
 
     public function test_scan_data_virus() {
         $methods = array(
-            'scan_data_execute_unixsocket',
+            'scan_data_execute_socket',
             'message_admins',
             'get_config',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
         // Initiate mock scanning with configuration setting to use unixsocket.
         $configmap = array(array('runningmethod', 'unixsocket'));
         $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
-        // Configure scan_data_execute_unixsocket method stubs to behave as if
+        // Configure scan_data_execute_socket method stubs to behave as if
         // no virus has been found (SCAN_RESULT_FOUND).
-        $antivirus->method('scan_data_execute_unixsocket')->willReturn(1);
+        $antivirus->method('scan_data_execute_socket')->willReturn(1);
+
+        // Set expectation that message_admins is NOT called.
+        $antivirus->expects($this->never())->method('message_admins');
+
+        // Run mock scanning.
+        $this->assertEquals(1, $antivirus->scan_data(''));
+
+        // Re-initiate mock scanning with configuration setting to use tcpsocket.
+        $configmap = array(array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
         // Set expectation that message_admins is NOT called.
         $antivirus->expects($this->never())->method('message_admins');
@@ -253,22 +304,22 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
 
     public function test_scan_data_error_donothing() {
         $methods = array(
-            'scan_data_execute_unixsocket',
+            'scan_data_execute_socket',
             'message_admins',
             'get_config',
             'get_scanning_notice',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
         // Initiate mock scanning with configuration setting to do nothing on
         // scanning error and using unixsocket.
         $configmap = array(array('clamfailureonupload', 'donothing'), array('runningmethod', 'unixsocket'));
         $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
-        // Configure scan_data_execute_unixsocket method stubs to behave as if
+        // Configure scan_data_execute_socket method stubs to behave as if
         // there is a scanning error (SCAN_RESULT_ERROR).
-        $antivirus->method('scan_data_execute_unixsocket')->willReturn(2);
+        $antivirus->method('scan_data_execute_socket')->willReturn(2);
         $antivirus->method('get_scanning_notice')->willReturn('someerror');
 
         // Set expectation that message_admins is called.
@@ -276,27 +327,38 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
 
         // Run mock scanning.
         $this->assertEquals(2, $antivirus->scan_data(''));
+
+        // Re-initiate mock scanning with configuration setting to do nothing on
+        // scanning error and using tcsocket.
+        $configmap = array(array('clamfailureonupload', 'donothing'), array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
+
+        // Set expectation that message_admins is called.
+        $antivirus->expects($this->atLeastOnce())->method('message_admins')->with($this->equalTo('someerror'));
+
+        // Run mock scanning.
+        $this->assertEquals(2, $antivirus->scan_data(''));
     }
 
     public function test_scan_data_error_actlikevirus() {
         $methods = array(
-            'scan_data_execute_unixsocket',
+            'scan_data_execute_socket',
             'message_admins',
             'get_config',
             'get_scanning_notice',
         );
         $antivirus = $this->getMockBuilder('\antivirus_clamav\scanner')
-                ->setMethods($methods)
-                ->getMock();
+            ->setMethods($methods)
+            ->getMock();
 
         // Initiate mock scanning with configuration setting to act like virus on
         // scanning error and using unixsocket.
         $configmap = array(array('clamfailureonupload', 'actlikevirus'), array('runningmethod', 'unixsocket'));
         $antivirus->method('get_config')->will($this->returnValueMap($configmap));
 
-        // Configure scan_data_execute_unixsocket method stubs to behave as if
+        // Configure scan_data_execute_socket method stubs to behave as if
         // there is a scanning error (SCAN_RESULT_ERROR).
-        $antivirus->method('scan_data_execute_unixsocket')->willReturn(2);
+        $antivirus->method('scan_data_execute_socket')->willReturn(2);
         $antivirus->method('get_scanning_notice')->willReturn('someerror');
 
         // Set expectation that message_admins is called.
@@ -305,5 +367,17 @@ class antivirus_clamav_scanner_testcase extends advanced_testcase {
         // Run mock scanning, we expect SCAN_RESULT_FOUND since configuration
         // require us to act like virus.
         $this->assertEquals(1, $antivirus->scan_data(''));
+
+        // Re-initiate mock scanning with configuration setting to act like virus on
+        // scanning error and using tcpsocket.
+        $configmap = array(array('clamfailureonupload', 'actlikevirus'), array('runningmethod', 'tcpsocket'));
+        $antivirus->method('get_config')->will($this->returnValueMap($configmap));
+
+        // Set expectation that message_admins is called.
+        $antivirus->expects($this->atLeastOnce())->method('message_admins')->with($this->equalTo('someerror'));
+
+        // Run mock scanning, we expect SCAN_RESULT_FOUND since configuration
+        // require us to act like virus.
+        $this->assertEquals(1, $antivirus->scan_data(''));
     }
 }
index d19e7a6..5c52c89 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019111800;          // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019122900;          // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200;          // Requires this Moodle version.
 $plugin->component = 'antivirus_clamav';  // Full name of the plugin (used for diagnostics).
index 33e4a28..7ac652d 100644 (file)
@@ -59,12 +59,21 @@ class core_plugin_manager {
     const REQUIREMENT_STATUS_OUTDATED = 'outdated';
     /** the required dependency is not installed */
     const REQUIREMENT_STATUS_MISSING = 'missing';
+    /** the current Moodle version is too high for plugin. */
+    const REQUIREMENT_STATUS_NEWER = 'newer';
 
     /** the required dependency is available in the plugins directory */
     const REQUIREMENT_AVAILABLE = 'available';
     /** the required dependency is available in the plugins directory */
     const REQUIREMENT_UNAVAILABLE = 'unavailable';
 
+    /** the moodle version is explicitly supported */
+    const VERSION_SUPPORTED = 'supported';
+    /** the moodle version is not explicitly supported */
+    const VERSION_NOT_SUPPORTED = 'notsupported';
+    /** the plugin does not specify supports */
+    const VERSION_NO_SUPPORTS = 'nosupports';
+
     /** @var core_plugin_manager holds the singleton instance */
     protected static $singletoninstance;
     /** @var array of raw plugins information */
@@ -737,10 +746,21 @@ class core_plugin_manager {
      *
      * @param int $moodleversion the version from version.php.
      * @param array $failedplugins to return the list of plugins with non-satisfied dependencies
+     * @param int $branch the current moodle branch, null if not provided
      * @return bool true if all the dependencies are satisfied for all plugins.
      */
-    public function all_plugins_ok($moodleversion, &$failedplugins = array()) {
-
+    public function all_plugins_ok($moodleversion, &$failedplugins = array(), $branch = null) {
+        global $CFG;
+        if (empty($branch)) {
+            $branch = $CFG->branch;
+            if (empty($branch)) {
+                // During initial install there is no branch set.
+                require($CFG->dirroot . '/version.php');
+                $branch = (int)$branch;
+                // Force CFG->branch to int value during install.
+                $CFG->branch = $branch;
+            }
+        }
         $return = true;
         foreach ($this->get_plugins() as $type => $plugins) {
             foreach ($plugins as $plugin) {
@@ -754,6 +774,11 @@ class core_plugin_manager {
                     $return = false;
                     $failedplugins[] = $plugin->component;
                 }
+
+                if (!$plugin->is_core_compatible_satisfied($branch)) {
+                    $return = false;
+                    $failedplugins[] = $plugin->component;
+                }
             }
         }
 
@@ -794,7 +819,7 @@ class core_plugin_manager {
         }
 
         $reqs = array();
-        $reqcore = $this->resolve_core_requirements($plugin, $moodleversion);
+        $reqcore = $this->resolve_core_requirements($plugin, $moodleversion, $moodlebranch);
 
         if (!empty($reqcore)) {
             $reqs['core'] = $reqcore;
@@ -814,7 +839,7 @@ class core_plugin_manager {
      * @param string|int|double $moodleversion moodle core branch to check against
      * @return stdObject
      */
-    protected function resolve_core_requirements(\core\plugininfo\base $plugin, $moodleversion) {
+    protected function resolve_core_requirements(\core\plugininfo\base $plugin, $moodleversion, $moodlebranch) {
 
         $reqs = (object)array(
             'hasver' => null,
@@ -822,7 +847,6 @@ class core_plugin_manager {
             'status' => null,
             'availability' => null,
         );
-
         $reqs->hasver = $moodleversion;
 
         if (empty($plugin->versionrequires)) {
@@ -837,6 +861,14 @@ class core_plugin_manager {
             $reqs->status = self::REQUIREMENT_STATUS_OUTDATED;
         }
 
+        // Now check if there is an explicit incompatible, supersedes requires.
+        if (isset($plugin->pluginincompatible) && $plugin->pluginincompatible != null) {
+            if (!$plugin->is_core_compatible_satisfied($moodlebranch)) {
+
+                $reqs->status = self::REQUIREMENT_STATUS_NEWER;
+            }
+        }
+
         return $reqs;
     }
 
@@ -890,6 +922,49 @@ class core_plugin_manager {
         return $reqs;
     }
 
+    /**
+     * Helper method to determine whether a moodle version is explicitly supported.
+     *
+     * @param \core\plugininfo\base $plugin the plugin we are checking
+     * @param int $branch the moodle branch to check support for
+     * @return string
+     */
+    public function check_explicitly_supported($plugin, $branch) : string {
+        // Check for correctly formed supported.
+        if (isset($plugin->pluginsupported)) {
+            // Broken apart for readability.
+            $error = false;
+            if (!is_array($plugin->pluginsupported)) {
+                $error = true;
+            }
+            if (!is_int($plugin->pluginsupported[0]) || !is_int($plugin->pluginsupported[1])) {
+                $error = true;
+            }
+            if (count($plugin->pluginsupported) != 2) {
+                $error = true;
+            }
+            if ($error) {
+                throw new coding_exception(get_string('err_supported_syntax', 'core_plugin'));
+            }
+        }
+
+        if (isset($plugin->pluginsupported) && $plugin->pluginsupported != null) {
+            if ($plugin->pluginsupported[0] <= $branch && $branch <= $plugin->pluginsupported[1]) {
+                return self::VERSION_SUPPORTED;
+            } else {
+                return self::VERSION_NOT_SUPPORTED;
+            }
+        } else {
+            // If supports aren't specified, but incompatible is, return not supported if not incompatible.
+            if (!isset($plugin->pluginsupported) && isset($plugin->pluginincompatible) && !empty($plugin->pluginincompatible)) {
+                if (!$plugin->is_core_compatible_satisfied($branch)) {
+                    return self::VERSION_NOT_SUPPORTED;
+                }
+            }
+            return self::VERSION_NO_SUPPORTS;
+        }
+    }
+
     /**
      * Is the given plugin version available in the plugins directory?
      *
index cb80f29..75d980f 100644 (file)
@@ -53,6 +53,10 @@ abstract class base {
     public $versiondb;
     /** @var int|float|string required version of Moodle core  */
     public $versionrequires;
+    /** @var array explicitly supported branches of Moodle core  */
+    public $pluginsupported;
+    /** @var int first incompatible branch of Moodle core  */
+    public $pluginincompatible;
     /** @var mixed human-readable release information */
     public $release;
     /** @var array other plugins that this one depends on, lazy-loaded by {@link get_other_required_plugins()} */
@@ -218,6 +222,8 @@ abstract class base {
 
         $this->versiondisk = null;
         $this->versionrequires = null;
+        $this->pluginsupported = null;
+        $this->pluginincompatible = null;
         $this->dependencies = array();
 
         if (!isset($versions[$this->name])) {
@@ -238,6 +244,28 @@ abstract class base {
         if (isset($plugin->dependencies)) {
             $this->dependencies = $plugin->dependencies;
         }
+
+        // Check that supports and incompatible are wellformed, exception otherwise.
+        if (isset($plugin->supported)) {
+            // Checks for structure of supported.
+            $isint = (is_int($plugin->supported[0]) && is_int($plugin->supported[1]));
+            $isrange = ($plugin->supported[0] <= $plugin->supported[1] && count($plugin->supported) == 2);
+
+            if (is_array($plugin->supported) && $isint && $isrange) {
+                $this->pluginsupported = $plugin->supported;
+            } else {
+                throw new coding_exception('Incorrect syntax in plugin supported declaration in '."$this->name");
+            }
+        }
+
+        if (isset($plugin->incompatible) && $plugin->incompatible !== null) {
+            if ((ctype_digit($plugin->incompatible) || is_int($plugin->incompatible)) && (int) $plugin->incompatible > 0) {
+                $this->pluginincompatible = intval($plugin->incompatible);
+            } else {
+                throw new coding_exception('Incorrect syntax in plugin incompatible declaration in '."$this->name");
+            }
+        }
+
     }
 
     /**
@@ -341,6 +369,20 @@ abstract class base {
         }
     }
 
+    /**
+     * Returns true if the the given moodle branch is not stated incompatible with the plugin
+     *
+     * @param int $branch the moodle branch number
+     * @return bool true if not incompatible with moodle branch
+     */
+    public function is_core_compatible_satisfied(int $branch) : bool {
+        if (!empty($this->pluginincompatible) && ($branch >= $this->pluginincompatible)) {
+            return false;
+        } else {
+            return true;
+        }
+    }
+
     /**
      * Returns the status of the plugin
      *
index 2e1e042..bd3e8af 100644 (file)
@@ -43,6 +43,9 @@ abstract class adhoc_task extends task_base {
     /** @var integer|null $userid - Adhoc tasks may choose to run as a specific user. */
     private $userid = null;
 
+    /** @var \core\lock\lock The concurrency task lock for this task. */
+    private $concurrencylock = null;
+
     /**
      * Setter for $id.
      * @param int|null $id
@@ -107,4 +110,51 @@ abstract class adhoc_task extends task_base {
         $this->userid = $userid;
     }
 
+    /**
+     * Returns default concurrency limit for this task.
+     *
+     * @return int default concurrency limit
+     */
+    protected function get_default_concurrency_limit(): int {
+        global $CFG;
+
+        if (isset($CFG->task_concurrency_limit_default)) {
+            return (int) $CFG->task_concurrency_limit_default;
+        }
+        return 0;
+    }
+
+    /**
+     * Returns effective concurrency limit for this task.
+     *
+     * @return int effective concurrency limit for this task
+     */
+    final public function get_concurrency_limit(): int {
+        global $CFG;
+
+        $classname = get_class($this);
+
+        if (isset($CFG->task_concurrency_limit[$classname])) {
+            return (int) $CFG->task_concurrency_limit[$classname];
+        }
+        return $this->get_default_concurrency_limit();
+    }
+
+    /**
+     * Sets concurrency task lock.
+     *
+     * @param   \core\lock\lock $lock concurrency lock to be set
+     */
+    final public function set_concurrency_lock(\core\lock\lock $lock): void {
+        $this->concurrencylock = $lock;
+    }
+
+    /**
+     * Release the concurrency lock for this task type.
+     */
+    final public function release_concurrency_lock(): void {
+        if ($this->concurrencylock) {
+            $this->concurrencylock->release();
+        }
+    }
 }
index 091974b..e0ab0e4 100644 (file)
@@ -552,9 +552,11 @@ class manager {
      * {@link adhoc_task_failed} or {@link adhoc_task_complete} to release the lock and reschedule the task.
      *
      * @param int $timestart
+     * @param bool $checklimits Should we check limits?
      * @return \core\task\adhoc_task or null if not found
+     * @throws \moodle_exception
      */
-    public static function get_next_adhoc_task($timestart) {
+    public static function get_next_adhoc_task($timestart, $checklimits = true) {
         global $DB;
 
         $where = '(nextruntime IS NULL OR nextruntime < :timestart1)';
@@ -564,14 +566,17 @@ class manager {
         $records = self::ensure_adhoc_task_qos($records);
 
         $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
-        if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
-            throw new \moodle_exception('locktimeout');
-        }
+
+        $skipclasses = array();
 
         foreach ($records as $record) {
 
+            if (in_array($record->classname, $skipclasses)) {
+                // Skip the task if it can't be started due to per-task concurrency limit.
+                continue;
+            }
+
             if ($lock = $cronlockfactory->get_lock('adhoc_' . $record->id, 0)) {
-                $classname = '\\' . $record->classname;
 
                 // Safety check, see if the task has been already processed by another cron run.
                 $record = $DB->get_record('task_adhoc', array('id' => $record->id));
@@ -587,6 +592,26 @@ class manager {
                     continue;
                 }
 
+                $tasklimit = $task->get_concurrency_limit();
+                if ($checklimits && $tasklimit > 0) {
+                    if ($concurrencylock = self::get_concurrent_task_lock($task)) {
+                        $task->set_concurrency_lock($concurrencylock);
+                    } else {
+                        // Unable to obtain a concurrency lock.
+                        mtrace("Skipping $record->classname adhoc task class as the per-task limit of $tasklimit is reached.");
+                        $skipclasses[] = $record->classname;
+                        $lock->release();
+                        continue;
+                    }
+                }
+
+                // The global cron lock is under the most contention so request it
+                // as late as possible and release it as soon as possible.
+                if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
+                    $lock->release();
+                    throw new \moodle_exception('locktimeout');
+                }
+
                 $task->set_lock($lock);
                 if (!$task->is_blocking()) {
                     $cronlock->release();
@@ -597,8 +622,6 @@ class manager {
             }
         }
 
-        // No tasks.
-        $cronlock->release();
         return null;
     }
 
@@ -609,15 +632,12 @@ class manager {
      *
      * @param int $timestart - The start of the cron process - do not repeat any tasks that have been run more recently than this.
      * @return \core\task\scheduled_task or null
+     * @throws \moodle_exception
      */
     public static function get_next_scheduled_task($timestart) {
         global $DB;
         $cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
 
-        if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
-            throw new \moodle_exception('locktimeout');
-        }
-
         $where = "(lastruntime IS NULL OR lastruntime < :timestart1)
                   AND (nextruntime IS NULL OR nextruntime < :timestart2)
                   AND disabled = 0
@@ -656,6 +676,13 @@ class manager {
                     continue;
                 }
 
+                // The global cron lock is under the most contention so request it
+                // as late as possible and release it as soon as possible.
+                if (!$cronlock = $cronlockfactory->get_lock('core_cron', 10)) {
+                    $lock->release();
+                    throw new \moodle_exception('locktimeout');
+                }
+
                 if (!$task->is_blocking()) {
                     $cronlock->release();
                 } else {
@@ -665,8 +692,6 @@ class manager {
             }
         }
 
-        // No tasks.
-        $cronlock->release();
         return null;
     }
 
@@ -691,13 +716,13 @@ class manager {
             $delay = 86400;
         }
 
-        $classname = self::get_canonical_class_name($task);
-
+        // Reschedule and then release the locks.
         $task->set_next_run_time(time() + $delay);
         $task->set_fail_delay($delay);
         $record = self::record_from_adhoc_task($task);
         $DB->update_record('task_adhoc', $record);
 
+        $task->release_concurrency_lock();
         if ($task->is_blocking()) {
             $task->get_cron_lock()->release();
         }
@@ -721,7 +746,8 @@ class manager {
         // Delete the adhoc task record - it is finished.
         $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
 
-        // Reschedule and then release the locks.
+        // Release the locks.
+        $task->release_concurrency_lock();
         if ($task->is_blocking()) {
             $task->get_cron_lock()->release();
         }
@@ -858,4 +884,24 @@ class manager {
         }
         return $classname;
     }
+
+    /**
+     * Gets the concurrent lock required to run an adhoc task.
+     *
+     * @param   adhoc_task $task The task to obtain the lock for
+     * @return  \core\lock\lock The lock if one was obtained successfully
+     * @throws  \coding_exception
+     */
+    protected static function get_concurrent_task_lock(adhoc_task $task): ?\core\lock\lock {
+        $adhoclock = null;
+        $cronlockfactory = \core\lock\lock_config::get_lock_factory(get_class($task));
+
+        for ($run = 0; $run < $task->get_concurrency_limit(); $run++) {
+            if ($adhoclock = $cronlockfactory->get_lock("concurrent_run_{$run}", 0)) {
+                return $adhoclock;
+            }
+        }
+
+        return null;
+    }
 }
index db0193f..0adbcc9 100644 (file)
@@ -87,6 +87,7 @@ function cron_run() {
  * Execute all queued scheduled tasks, applying necessary concurrency limits and time limits.
  *
  * @param   int     $timenow The time this process started.
+ * @throws \moodle_exception
  */
 function cron_run_scheduled_tasks(int $timenow) {
     // Allow a restriction on the number of scheduled task runners at once.
@@ -96,7 +97,10 @@ function cron_run_scheduled_tasks(int $timenow) {
 
     $scheduledlock = null;
     for ($run = 0; $run < $maxruns; $run++) {
-        if ($scheduledlock = $cronlockfactory->get_lock("scheduled_task_runner_{$run}", 1)) {
+        // If we can't get a lock instantly it means runner N is already running
+        // so fail as fast as possible and try N+1 so we don't limit the speed at
+        // which we bring new runners into the pool.
+        if ($scheduledlock = $cronlockfactory->get_lock("scheduled_task_runner_{$run}", 0)) {
             break;
         }
     }
@@ -109,19 +113,21 @@ function cron_run_scheduled_tasks(int $timenow) {
     $starttime = time();
 
     // Run all scheduled tasks.
-    while (!\core\task\manager::static_caches_cleared_since($timenow) &&
-            $task = \core\task\manager::get_next_scheduled_task($timenow)) {
-        cron_run_inner_scheduled_task($task);
-        unset($task);
+    try {
+        while (!\core\task\manager::static_caches_cleared_since($timenow) &&
+                $task = \core\task\manager::get_next_scheduled_task($timenow)) {
+            cron_run_inner_scheduled_task($task);
+            unset($task);
 
-        if ((time() - $starttime) > $maxruntime) {
-            mtrace("Stopping processing of scheduled tasks as time limit has been reached.");
-            break;
+            if ((time() - $starttime) > $maxruntime) {
+                mtrace("Stopping processing of scheduled tasks as time limit has been reached.");
+                break;
+            }
         }
+    } finally {
+        // Release the scheduled task runner lock.
+        $scheduledlock->release();
     }
-
-    // Release the scheduled task runner lock.
-    $scheduledlock->release();
 }
 
 /**
@@ -130,6 +136,7 @@ function cron_run_scheduled_tasks(int $timenow) {
  * @param   int     $timenow The time this process started.
  * @param   int     $keepalive Keep this function alive for N seconds and poll for new adhoc tasks.
  * @param   bool    $checklimits Should we check limits?
+ * @throws \moodle_exception
  */
 function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true) {
     // Allow a restriction on the number of adhoc task runners at once.
@@ -140,7 +147,10 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
     if ($checklimits) {
         $adhoclock = null;
         for ($run = 0; $run < $maxruns; $run++) {
-            if ($adhoclock = $cronlockfactory->get_lock("adhoc_task_runner_{$run}", 1)) {
+            // If we can't get a lock instantly it means runner N is already running
+            // so fail as fast as possible and try N+1 so we don't limit the speed at
+            // which we bring new runners into the pool.
+            if ($adhoclock = $cronlockfactory->get_lock("adhoc_task_runner_{$run}", 0)) {
                 break;
             }
         }
@@ -168,7 +178,15 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
             break;
         }
 
-        $task = \core\task\manager::get_next_adhoc_task(time());
+        try {
+            $task = \core\task\manager::get_next_adhoc_task(time(), $checklimits);
+        } catch (Exception $e) {
+            if ($adhoclock) {
+                // Release the adhoc task runner lock.
+                $adhoclock->release();
+            }
+            throw $e;
+        }
 
         if ($task) {
             if ($waiting) {
index 52946fa..2ca8e54 100644 (file)
@@ -1639,6 +1639,10 @@ function print_object($object) {
     if (CLI_SCRIPT) {
         fwrite(STDERR, print_r($object, true));
         fwrite(STDERR, PHP_EOL);
+    } else if (AJAX_SCRIPT) {
+        foreach (explode("\n", print_r($object, true)) as $line) {
+            error_log($line);
+        }
     } else {
         echo html_writer::tag('pre', s(print_r($object, true)), array('class' => 'notifytiny'));
     }
index dacaf5b..8e33d64 100644 (file)
@@ -391,6 +391,17 @@ $capabilities = array(
         )
     ),
 
+    'moodle/site:viewanonymousevents' => array(
+
+        'riskbitmask' => RISK_PERSONAL,
+
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+        )
+    ),
+
     'moodle/site:viewfullnames' => array(
 
         'captype' => 'read',
index be9692d..0722a2a 100644 (file)
@@ -125,8 +125,8 @@ function xmldb_main_install() {
         'backup_version'        => 2008111700,
         'backup_release'        => '2.0 dev',
         'mnet_dispatcher_mode'  => 'off',
-        'sessiontimeout'        => 7200, // must be present during roles installation
-        'stringfilters'         => '', // These two are managed in a strange way by the filters
+        'sessiontimeout'        => 8 * 60 * 60, // Must be present during roles installation.
+        'stringfilters'         => '', // These two are managed in a strange way by the filters.
         'filterall'             => 0, // setting page, so have to be initialised here.
         'texteditors'           => 'atto,tinymce,textarea',
         'antiviruses'           => '',
index fcef7da..9a30e74 100644 (file)
         <INDEX NAME="uuid" UNIQUE="false" FIELDS="uuid"/>
         <INDEX NAME="type-timesort" UNIQUE="false" FIELDS="type, timesort"/>
         <INDEX NAME="groupid-courseid-categoryid-visible-userid" UNIQUE="false" FIELDS="groupid, courseid, categoryid, visible, userid" COMMENT="used for calendar view"/>
+        <INDEX NAME="eventtype" UNIQUE="false" FIELDS="eventtype"/>
+        <INDEX NAME="modulename-instance" UNIQUE="false" FIELDS="modulename, instance"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="cache_filters" COMMENT="For keeping information about cached data">
index 4c56b39..a80612c 100644 (file)
@@ -2145,5 +2145,22 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019122000.01);
     }
 
+    if ($oldversion < 2020010900.02) {
+        $table = new xmldb_table('event');
+
+        // This index will improve the performance when the Events API retrieves category and group events.
+        $index = new xmldb_index('eventtype', XMLDB_INDEX_NOTUNIQUE, ['eventtype']);
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        // This index improves the performance of backups, deletion and visibilty changes on activities.
+        $index = new xmldb_index('modulename-instance', XMLDB_INDEX_NOTUNIQUE, ['modulename', 'instance']);
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        upgrade_main_savepoint(true, 2020010900.02);
+    }
     return true;
 }
index cdf67fa..c8d7bb6 100644 (file)
Binary files a/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-debug.js and b/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-debug.js differ
index 2940890..e4a9c3b 100644 (file)
Binary files a/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-min.js and b/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button-min.js differ
index 7c14998..89d365c 100644 (file)
Binary files a/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button.js and b/lib/editor/atto/plugins/accessibilitychecker/yui/build/moodle-atto_accessibilitychecker-button/moodle-atto_accessibilitychecker-button.js differ
index 1634ac7..ce6a4e2 100644 (file)
@@ -127,8 +127,11 @@ Y.namespace('M.atto_accessibilitychecker').Button = Y.Base.create('button', Y.M.
 
             // Check for non-empty text.
             if (Y.Lang.trim(node.get('text')) !== '') {
-                foreground = node.getComputedStyle('color');
-                background = node.getComputedStyle('backgroundColor');
+                foreground = Y.Color.fromArray(
+                    this._getComputedBackgroundColor(node, node.getComputedStyle('color')),
+                    Y.Color.TYPES.RGBA
+                );
+                background = Y.Color.fromArray(this._getComputedBackgroundColor(node), Y.Color.TYPES.RGBA);
 
                 lum1 = this._getLuminanceFromCssColor(foreground);
                 lum2 = this._getLuminanceFromCssColor(background);
@@ -237,7 +240,7 @@ Y.namespace('M.atto_accessibilitychecker').Button = Y.Base.create('button', Y.M.
      * Generate the HTML that lists the found warnings.
      *
      * @method _addWarnings
-     * @param {Node} A Node to append the html to.
+     * @param {Node} list Node to append the html to.
      * @param {String} description Description of this failure.
      * @param {array} nodes An array of failing nodes.
      * @param {boolean} imagewarnings true if the warnings are related to images, false if text.
@@ -307,5 +310,43 @@ Y.namespace('M.atto_accessibilitychecker').Button = Y.Base.create('button', Y.M.
             b1 = part1(color[2]);
 
         return 0.2126 * r1 + 0.7152 * g1 + 0.0722 * b1;
+    },
+
+    /**
+     * Get the computed RGB converted to full alpha value, considering the node hierarchy.
+     *
+     * @method _getComputedBackgroundColor
+     * @param {Node} node
+     * @param {String} color The initial colour. If not specified, fetches the backgroundColor from the node.
+     * @return {Array} Colour in Array form (RGBA)
+     * @private
+     */
+    _getComputedBackgroundColor: function(node, color) {
+        color = color || node.getComputedStyle('backgroundColor');
+
+        if (color.toLowerCase() === 'transparent') {
+            // Y.Color doesn't handle 'transparent' properly.
+            color = 'rgba(1, 1, 1, 0)';
+        }
+
+        // Convert the colour to its constituent parts in RGBA format, then fetch the alpha.
+        var colorParts = Y.Color.toArray(color);
+        var alpha = colorParts[3];
+
+        if (alpha === 1) {
+            // If the alpha of the background is already 1, then the parent background colour does not change anything.
+            return colorParts;
+        }
+
+        // Fetch the computed background colour of the parent and use it to calculate the RGB of this item.
+        var parentColor = this._getComputedBackgroundColor(node.get('parentNode'));
+        return [
+            // RGB = (alpha * R|G|B) + (1 - alpha * solid parent colour).
+            (1 - alpha) * parentColor[0] + alpha * colorParts[0],
+            (1 - alpha) * parentColor[1] + alpha * colorParts[1],
+            (1 - alpha) * parentColor[2] + alpha * colorParts[2],
+            // We always return a colour with full alpha.
+            1
+        ];
     }
 });
index 645728a..cc81222 100644 (file)
@@ -44,7 +44,7 @@ class editor_form extends moodleform {
      */
     protected function definition() {
         $mform = $this->_form;
-        $editoroptions = $this->_customdata['editoroptions'];
+        $editoroptions = $this->_customdata['editoroptions'] ?? null;
 
         // Add header.
         $mform->addElement('header', 'myheader', 'Editor in Moodle form');
index 3dcebbf..c8e98f5 100644 (file)
@@ -2219,20 +2219,20 @@ function readfile_accel($file, $mimetype, $accelerate) {
             if ($ranges) {
                 if (is_object($file)) {
                     $handle = $file->get_content_file_handle();
+                    if ($handle === false) {
+                        throw new file_exception('storedfilecannotreadfile', $file->get_filename());
+                    }
                 } else {
                     $handle = fopen($file, 'rb');
+                    if ($handle === false) {
+                        throw new file_exception('cannotopenfile', $file);
+                    }
                 }
                 byteserving_send_file($handle, $mimetype, $ranges, $filesize);
             }
         }
     }
 
-    header('Content-Length: '.$filesize);
-
-    if (!empty($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'HEAD') {
-        exit;
-    }
-
     if ($filesize > 10000000) {
         // for large files try to flush and close all buffers to conserve memory
         while(@ob_get_level()) {
@@ -2242,11 +2242,21 @@ function readfile_accel($file, $mimetype, $accelerate) {
         }
     }
 
+    // Send this header after we have flushed the buffers so that if we fail
+    // later can remove this because it wasn't sent.
+    header('Content-Length: ' . $filesize);
+
+    if (!empty($_SERVER['REQUEST_METHOD']) and $_SERVER['REQUEST_METHOD'] === 'HEAD') {
+        exit;
+    }
+
     // send the whole file content
     if (is_object($file)) {
         $file->readfile();
     } else {
-        readfile_allow_large($file, $filesize);
+        if (readfile_allow_large($file, $filesize) === false) {
+            throw new file_exception('cannotopenfile', $file);
+        }
     }
 }
 
index ae1d362..5015696 100644 (file)
@@ -63,7 +63,9 @@ abstract class file_system {
         } else {
             $path = $this->get_remote_path_from_storedfile($file);
         }
-        readfile_allow_large($path, $file->get_filesize());
+        if (readfile_allow_large($path, $file->get_filesize()) === false) {
+            throw new file_exception('storedfilecannotreadfile', $file->get_filename());
+        }
     }
 
     /**
@@ -416,11 +418,16 @@ abstract class file_system {
     protected function get_imageinfo_from_path($path) {
         $imageinfo = getimagesize($path);
 
+        if (!is_array($imageinfo)) {
+            return false; // Nothing to process, the file was not recognised as image by GD.
+        }
+
         $image = array(
                 'width'     => $imageinfo[0],
                 'height'    => $imageinfo[1],
                 'mimetype'  => image_type_to_mime_type($imageinfo[2]),
             );
+
         if (empty($image['width']) or empty($image['height']) or empty($image['mimetype'])) {
             // GD can not parse it, sorry.
             return false;
index d1c8eee..91ed768 100644 (file)
@@ -200,7 +200,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
                 break;
 
             case 'createElement':
-                if ($arg[2]['optional']) {
+                if (!empty($arg[2]['optional'])) {
                     $caller->disabledIf($arg[0], $arg[0] . '[enabled]');
                 }
                 $caller->setType($arg[0] . '[number]', PARAM_FLOAT);
index 9e4e3b0..9c70b5a 100644 (file)
@@ -221,6 +221,8 @@ class MoodleQuickForm_filetypes extends MoodleQuickForm_group {
      */
     public function validateSubmitValue($value) {
 
+        $value = $value ?? ['filetypes' => null]; // A null $value can arrive here. Coalesce, creating the default array.
+
         if (!$this->allowall) {
             // Assert that there is an actual list provided.
             $normalized = $this->util->normalize_file_types($value['filetypes']);
index 447a822..870897d 100644 (file)
@@ -82,7 +82,8 @@ trait templatable_form_element {
         $context['type'] = $this->getType();
         $context['attributes'] = implode(' ', $otherattributes);
         $context['emptylabel'] = ($this->getLabel() === '');
-        $context['iderror'] = preg_replace('/^id_/', 'id_error_', $context['id']);
+        $context['iderror'] = preg_replace('/_id_/', '_id_error_', $context['id']);
+        $context['iderror'] = preg_replace('/^id_/', 'id_error_', $context['iderror']);
 
         // Elements with multiple values need array syntax.
         if ($this->getAttribute('multiple')) {
index 6f0fa81..a8ccb0d 100644 (file)
@@ -19,7 +19,7 @@
  */\r
 \r
 /*\r
-    HTML Purifier 4.10.0 - Standards Compliant HTML Filtering\r
+    HTML Purifier 4.12.0 - Standards Compliant HTML Filtering\r
     Copyright (C) 2006-2008 Edward Z. Yang\r
 \r
     This library is free software; you can redistribute it and/or\r
@@ -58,12 +58,12 @@ class HTMLPurifier
      * Version of HTML Purifier.\r
      * @type string\r
      */\r
-    public $version = '4.10.0';\r
+    public $version = '4.12.0';\r
 \r
     /**\r
      * Constant with version of HTML Purifier.\r
      */\r
-    const VERSION = '4.10.0';\r
+    const VERSION = '4.12.0';\r
 \r
     /**\r
      * Global configuration object.\r
@@ -240,12 +240,16 @@ class HTMLPurifier
     public function purifyArray($array_of_html, $config = null)\r
     {\r
         $context_array = array();\r
-        foreach ($array_of_html as $key => $html) {\r
-            $array_of_html[$key] = $this->purify($html, $config);\r
+        foreach($array_of_html as $key=>$value){\r
+            if (is_array($value)) {\r
+                $array[$key] = $this->purifyArray($value, $config);\r
+            } else {\r
+                $array[$key] = $this->purify($value, $config);\r
+            }\r
             $context_array[$key] = $this->context;\r
         }\r
         $this->context = $context_array;\r
-        return $array_of_html;\r
+        return $array;\r
     }\r
 \r
     /**\r
index 953a36a..bf54e8e 100644 (file)
@@ -7,7 +7,7 @@ class HTMLPurifier_AttrDef_HTML_Bool extends HTMLPurifier_AttrDef
 {\r
 \r
     /**\r
-     * @type bool\r
+     * @type string\r
      */\r
     protected $name;\r
 \r
@@ -17,7 +17,7 @@ class HTMLPurifier_AttrDef_HTML_Bool extends HTMLPurifier_AttrDef
     public $minimized = true;\r
 \r
     /**\r
-     * @param bool $name\r
+     * @param bool|string $name\r
      */\r
     public function __construct($name = false)\r
     {\r
index c1e2e3c..a61111e 100644 (file)
@@ -97,7 +97,11 @@ class HTMLPurifier_AttrDef_URI_Host extends HTMLPurifier_AttrDef
 \r
         // PHP 5.3 and later support this functionality natively\r
         if (function_exists('idn_to_ascii')) {\r
-            $string = idn_to_ascii($string, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);\r
+            if (defined('IDNA_NONTRANSITIONAL_TO_ASCII') && defined('INTL_IDNA_VARIANT_UTS46')) {\r
+                $string = idn_to_ascii($string, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);\r
+            } else {\r
+                $string = idn_to_ascii($string);\r
+            }\r
 \r
         // If we have Net_IDNA2 support, we can support IRIs by\r
         // punycoding them. (This is the most portable thing to do,\r
index 5e7490e..ca3dcd6 100644 (file)
@@ -220,15 +220,25 @@ class HTMLPurifier_CSSDefinition extends HTMLPurifier_Definition
             array(\r
                 new HTMLPurifier_AttrDef_CSS_Length('0'),\r
                 new HTMLPurifier_AttrDef_CSS_Percentage(true),\r
-                new HTMLPurifier_AttrDef_Enum(array('auto'))\r
+                new HTMLPurifier_AttrDef_Enum(array('auto', 'initial', 'inherit'))\r
+            )\r
+        );\r
+        $trusted_min_wh = new HTMLPurifier_AttrDef_CSS_Composite(\r
+            array(\r
+                new HTMLPurifier_AttrDef_CSS_Length('0'),\r
+                new HTMLPurifier_AttrDef_CSS_Percentage(true),\r
+                new HTMLPurifier_AttrDef_Enum(array('initial', 'inherit'))\r
+            )\r
+        );\r
+        $trusted_max_wh = new HTMLPurifier_AttrDef_CSS_Composite(\r
+            array(\r
+                new HTMLPurifier_AttrDef_CSS_Length('0'),\r
+                new HTMLPurifier_AttrDef_CSS_Percentage(true),\r
+                new HTMLPurifier_AttrDef_Enum(array('none', 'initial', 'inherit'))\r
             )\r
         );\r
         $max = $config->get('CSS.MaxImgLength');\r
 \r
-        $this->info['min-width'] =\r
-        $this->info['max-width'] =\r
-        $this->info['min-height'] =\r
-        $this->info['max-height'] =\r
         $this->info['width'] =\r
         $this->info['height'] =\r
             $max === null ?\r
@@ -245,6 +255,38 @@ class HTMLPurifier_CSSDefinition extends HTMLPurifier_Definition
                     // For everyone else:\r
                     $trusted_wh\r
                 );\r
+        $this->info['min-width'] =\r
+        $this->info['min-height'] =\r
+            $max === null ?\r
+                $trusted_min_wh :\r
+                new HTMLPurifier_AttrDef_Switch(\r
+                    'img',\r
+                    // For img tags:\r
+                    new HTMLPurifier_AttrDef_CSS_Composite(\r
+                        array(\r
+                            new HTMLPurifier_AttrDef_CSS_Length('0', $max),\r
+                            new HTMLPurifier_AttrDef_Enum(array('initial', 'inherit'))\r
+                        )\r
+                    ),\r
+                    // For everyone else:\r
+                    $trusted_min_wh\r
+                );\r
+        $this->info['max-width'] =\r
+        $this->info['max-height'] =\r
+            $max === null ?\r
+                $trusted_max_wh :\r
+                new HTMLPurifier_AttrDef_Switch(\r
+                    'img',\r
+                    // For img tags:\r
+                    new HTMLPurifier_AttrDef_CSS_Composite(\r
+                        array(\r
+                            new HTMLPurifier_AttrDef_CSS_Length('0', $max),\r
+                            new HTMLPurifier_AttrDef_Enum(array('none', 'initial', 'inherit'))\r
+                        )\r
+                    ),\r
+                    // For everyone else:\r
+                    $trusted_max_wh\r
+                );\r
 \r
         $this->info['text-decoration'] = new HTMLPurifier_AttrDef_CSS_TextDecoration();\r
 \r
index 0d89bc2..0a7e09f 100644 (file)
@@ -21,7 +21,7 @@ class HTMLPurifier_Config
      * HTML Purifier's version\r
      * @type string\r
      */\r
-    public $version = '4.10.0';\r
+    public $version = '4.12.0';\r
 \r
     /**\r
      * Whether or not to automatically finalize\r
@@ -890,7 +890,7 @@ class HTMLPurifier_Config
             // zip(tail(trace), trace) -- but PHP is not Haskell har har\r
             for ($i = 0, $c = count($trace); $i < $c - 1; $i++) {\r
                 // XXX this is not correct on some versions of HTML Purifier\r
-                if ($trace[$i + 1]['class'] === 'HTMLPurifier_Config') {\r
+                if (isset($trace[$i + 1]['class']) && $trace[$i + 1]['class'] === 'HTMLPurifier_Config') {\r
                     continue;\r
                 }\r
                 $frame = $trace[$i];\r
index cc8f471..446cdf3 100644 (file)
@@ -100,7 +100,7 @@ class HTMLPurifier_ConfigSchema
      * @param string $key Name of directive\r
      * @param mixed $default Default value of directive\r
      * @param string $type Allowed type of the directive. See\r
-     *      HTMLPurifier_DirectiveDef::$type for allowed values\r
+     *      HTMLPurifier_VarParser::$types for allowed values\r
      * @param bool $allow_null Whether or not to allow null values\r
      */\r
     public function add($key, $default, $type, $allow_null)\r
index 371e948..47bd259 100644 (file)
Binary files a/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema.ser and b/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema.ser differ
index 5f355d6..e557ad2 100644 (file)
@@ -6,7 +6,7 @@ DEFAULT: false
 <p>\r
   When enabled, HTML Purifier will treat any elements that contain only\r
   non-breaking spaces as well as regular whitespace as empty, and remove\r
-  them when %AutoForamt.RemoveEmpty is enabled.\r
+  them when %AutoFormat.RemoveEmpty is enabled.\r
 </p>\r
 <p>\r
   See %AutoFormat.RemoveEmpty.RemoveNbsp.Exceptions for a list of elements\r
diff --git a/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.AllowParseManyTags.txt b/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.AllowParseManyTags.txt
new file mode 100644 (file)
index 0000000..b4b9b10
--- /dev/null
@@ -0,0 +1,12 @@
+Core.AllowParseManyTags\r
+TYPE: bool\r
+DEFAULT: false\r
+VERSION: 4.10.1\r
+--DESCRIPTION--\r
+<p>\r
+    This directive allows parsing of many nested tags.\r
+    If you set true, relaxes any hardcoded limit from the parser.\r
+    However, in that case it may cause a Dos attack.\r
+    Be careful when enabling it.\r
+</p>\r
+--# vim: et sw=4 sts=4\r
index f782398..fc10086 100644 (file)
@@ -3,23 +3,154 @@ TYPE: hash
 VERSION: 2.0.0\r
 --DEFAULT--\r
 array (\r
-  'maroon' => '#800000',\r
-  'red' => '#FF0000',\r
-  'orange' => '#FFA500',\r
-  'yellow' => '#FFFF00',\r
-  'olive' => '#808000',\r
-  'purple' => '#800080',\r
+  'aliceblue' => '#F0F8FF',\r
+  'antiquewhite' => '#FAEBD7',\r
+  'aqua' => '#00FFFF',\r
+  'aquamarine' => '#7FFFD4',\r
+  'azure' => '#F0FFFF',\r
+  'beige' => '#F5F5DC',\r
+  'bisque' => '#FFE4C4',\r
+  'black' => '#000000',\r
+  'blanchedalmond' => '#FFEBCD',\r
+  'blue' => '#0000FF',\r
+  'blueviolet' => '#8A2BE2',\r
+  'brown' => '#A52A2A',\r
+  'burlywood' => '#DEB887',\r
+  'cadetblue' => '#5F9EA0',\r
+  'chartreuse' => '#7FFF00',\r
+  'chocolate' => '#D2691E',\r
+  'coral' => '#FF7F50',\r
+  'cornflowerblue' => '#6495ED',\r
+  'cornsilk' => '#FFF8DC',\r
+  'crimson' => '#DC143C',\r
+  'cyan' => '#00FFFF',\r
+  'darkblue' => '#00008B',\r
+  'darkcyan' => '#008B8B',\r
+  'darkgoldenrod' => '#B8860B',\r
+  'darkgray' => '#A9A9A9',\r
+  'darkgrey' => '#A9A9A9',\r
+  'darkgreen' => '#006400',\r
+  'darkkhaki' => '#BDB76B',\r
+  'darkmagenta' => '#8B008B',\r
+  'darkolivegreen' => '#556B2F',\r
+  'darkorange' => '#FF8C00',\r
+  'darkorchid' => '#9932CC',\r
+  'darkred' => '#8B0000',\r
+  'darksalmon' => '#E9967A',\r
+  'darkseagreen' => '#8FBC8F',\r
+  'darkslateblue' => '#483D8B',\r
+  'darkslategray' => '#2F4F4F',\r
+  'darkslategrey' => '#2F4F4F',\r
+  'darkturquoise' => '#00CED1',\r
+  'darkviolet' => '#9400D3',\r
+  'deeppink' => '#FF1493',\r
+  'deepskyblue' => '#00BFFF',\r
+  'dimgray' => '#696969',\r
+  'dimgrey' => '#696969',\r
+  'dodgerblue' => '#1E90FF',\r
+  'firebrick' => '#B22222',\r
+  'floralwhite' => '#FFFAF0',\r
+  'forestgreen' => '#228B22',\r
   'fuchsia' => '#FF00FF',\r
-  'white' => '#FFFFFF',\r
-  'lime' => '#00FF00',\r
+  'gainsboro' => '#DCDCDC',\r
+  'ghostwhite' => '#F8F8FF',\r
+  'gold' => '#FFD700',\r
+  'goldenrod' => '#DAA520',\r
+  'gray' => '#808080',\r
+  'grey' => '#808080',\r
   'green' => '#008000',\r
+  'greenyellow' => '#ADFF2F',\r
+  'honeydew' => '#F0FFF0',\r
+  'hotpink' => '#FF69B4',\r
+  'indianred' => '#CD5C5C',\r
+  'indigo' => '#4B0082',\r
+  'ivory' => '#FFFFF0',\r
+  'khaki' => '#F0E68C',\r
+  'lavender' => '#E6E6FA',\r
+  'lavenderblush' => '#FFF0F5',\r
+  'lawngreen' => '#7CFC00',\r
+  'lemonchiffon' => '#FFFACD',\r
+  'lightblue' => '#ADD8E6',\r
+  'lightcoral' => '#F08080',\r
+  'lightcyan' => '#E0FFFF',\r
+  'lightgoldenrodyellow' => '#FAFAD2',\r
+  'lightgray' => '#D3D3D3',\r
+  'lightgrey' => '#D3D3D3',\r
+  'lightgreen' => '#90EE90',\r
+  'lightpink' => '#FFB6C1',\r
+  'lightsalmon' => '#FFA07A',\r
+  'lightseagreen' => '#20B2AA',\r
+  'lightskyblue' => '#87CEFA',\r
+  'lightslategray' => '#778899',\r
+  'lightslategrey' => '#778899',\r
+  'lightsteelblue' => '#B0C4DE',\r
+  'lightyellow' => '#FFFFE0',\r
+  'lime' => '#00FF00',\r
+  'limegreen' => '#32CD32',\r
+  'linen' => '#FAF0E6',\r
+  'magenta' => '#FF00FF',\r
+  'maroon' => '#800000',\r
+  'mediumaquamarine' => '#66CDAA',\r
+  'mediumblue' => '#0000CD',\r
+  'mediumorchid' => '#BA55D3',\r
+  'mediumpurple' => '#9370DB',\r
+  'mediumseagreen' => '#3CB371',\r
+  'mediumslateblue' => '#7B68EE',\r
+  'mediumspringgreen' => '#00FA9A',\r
+  'mediumturquoise' => '#48D1CC',\r
+  'mediumvioletred' => '#C71585',\r
+  'midnightblue' => '#191970',\r
+  'mintcream' => '#F5FFFA',\r
+  'mistyrose' => '#FFE4E1',\r
+  'moccasin' => '#FFE4B5',\r
+  'navajowhite' => '#FFDEAD',\r
   'navy' => '#000080',\r
-  'blue' => '#0000FF',\r
-  'aqua' => '#00FFFF',\r
-  'teal' => '#008080',\r
-  'black' => '#000000',\r
+  'oldlace' => '#FDF5E6',\r
+  'olive' => '#808000',\r
+  'olivedrab' => '#6B8E23',\r
+  'orange' => '#FFA500',\r
+  'orangered' => '#FF4500',\r
+  'orchid' => '#DA70D6',\r
+  'palegoldenrod' => '#EEE8AA',\r
+  'palegreen' => '#98FB98',\r
+  'paleturquoise' => '#AFEEEE',\r
+  'palevioletred' => '#DB7093',\r
+  'papayawhip' => '#FFEFD5',\r
+  'peachpuff' => '#FFDAB9',\r
+  'peru' => '#CD853F',\r
+  'pink' => '#FFC0CB',\r
+  'plum' => '#DDA0DD',\r
+  'powderblue' => '#B0E0E6',\r
+  'purple' => '#800080',\r
+  'rebeccapurple' => '#663399',\r
+  'red' => '#FF0000',\r
+  'rosybrown' => '#BC8F8F',\r
+  'royalblue' => '#4169E1',\r
+  'saddlebrown' => '#8B4513',\r
+  'salmon' => '#FA8072',\r
+  'sandybrown' => '#F4A460',\r
+  'seagreen' => '#2E8B57',\r
+  'seashell' => '#FFF5EE',\r
+  'sienna' => '#A0522D',\r
   'silver' => '#C0C0C0',\r
-  'gray' => '#808080',\r
+  'skyblue' => '#87CEEB',\r
+  'slateblue' => '#6A5ACD',\r
+  'slategray' => '#708090',\r
+  'slategrey' => '#708090',\r
+  'snow' => '#FFFAFA',\r
+  'springgreen' => '#00FF7F',\r
+  'steelblue' => '#4682B4',\r
+  'tan' => '#D2B48C',\r
+  'teal' => '#008080',\r
+  'thistle' => '#D8BFD8',\r
+  'tomato' => '#FF6347',\r
+  'turquoise' => '#40E0D0',\r
+  'violet' => '#EE82EE',\r
+  'wheat' => '#F5DEB3',\r
+  'white' => '#FFFFFF',\r
+  'whitesmoke' => '#F5F5F5',\r
+  'yellow' => '#FFFF00',\r
+  'yellowgre