diff --git a/.upgradenotes/MDL-66151-2024090301594788.yml b/.upgradenotes/MDL-66151-2024090301594788.yml new file mode 100644 index 0000000000000..c9f58e93037d5 --- /dev/null +++ b/.upgradenotes/MDL-66151-2024090301594788.yml @@ -0,0 +1,15 @@ +issueNumber: MDL-66151 +notes: + core_role: + - message: | + Move all session management to the \core\session\manager class. + This removes the dependancy to use the "sessions" table. + Session management plugins (like redis) now need to inherit + the base \core\session\handler class which implements + SessionHandlerInterface and override methods as required. + The following methods in \core\session\manager have been deprecated: + * kill_all_sessions use destroy_all instead + * kill_session use destroy instead + * kill_sessions_for_auth_plugin use destroy_by_auth_plugin instead + * kill_user_sessions use destroy_user_sessions instead + type: improved diff --git a/.upgradenotes/MDL-75850-2024082809421816.yml b/.upgradenotes/MDL-75850-2024082809421816.yml new file mode 100644 index 0000000000000..0f0223835c039 --- /dev/null +++ b/.upgradenotes/MDL-75850-2024082809421816.yml @@ -0,0 +1,13 @@ +issueNumber: MDL-75850 +notes: + core_files: + - message: | + The following are the changes made: + - New hook after_file_created + - In the \core_files\file_storage, new additional param $notify (default is true) added to: + - ::create_file_from_storedfile() + - ::create_file_from_pathname() + - ::create_file_from_string() + - ::create_file() + If true, it will trigger the after_file_created hook to re-create the image. + type: improved diff --git a/UPGRADING.md b/UPGRADING.md index 0863c3908b75e..15065c72aed96 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -427,6 +427,23 @@ The format of this change log follows the advice given at [Keep a CHANGELOG](htt For more information see [MDL-82857](https://tracker.moodle.org/browse/MDL-82857) +### core_role + +#### Added + +- Move all session management to the \core\session\manager class. + This removes the dependancy to use the "sessions" table. + Session management plugins (like redis) now need to inherit + the base \core\session\handler class which implements + SessionHandlerInterface and override methods as required. + The following methods in \core\session\manager have been deprecated: + * kill_all_sessions use destroy_all instead + * kill_session use destroy instead + * kill_sessions_for_auth_plugin use destroy_by_auth_plugin instead + * kill_user_sessions use destroy_user_sessions instead + + For more information see [MDL-66151](https://tracker.moodle.org/browse/MDL-66151) + ### tool_oauth2 #### Added @@ -631,6 +648,21 @@ The format of this change log follows the advice given at [Keep a CHANGELOG](htt For more information see [MDL-75025](https://tracker.moodle.org/browse/MDL-75025) +### core_files + +#### Added + +- The following are the changes made: + - New hook after_file_created + - In the \core_files\file_storage, new additional param $notify (default is true) added to: + - ::create_file_from_storedfile() + - ::create_file_from_pathname() + - ::create_file_from_string() + - ::create_file() + If true, it will trigger the after_file_created hook to re-create the image. + + For more information see [MDL-75850](https://tracker.moodle.org/browse/MDL-75850) + ### core_user #### Deprecated diff --git a/admin/roles/UPGRADING.md b/admin/roles/UPGRADING.md new file mode 100644 index 0000000000000..1b9fd378dfaad --- /dev/null +++ b/admin/roles/UPGRADING.md @@ -0,0 +1,19 @@ +# core_role (subsystem) Upgrade notes + +## 4.5dev+ + +### Added + +- Move all session management to the \core\session\manager class. + This removes the dependancy to use the "sessions" table. + Session management plugins (like redis) now need to inherit + the base \core\session\handler class which implements + SessionHandlerInterface and override methods as required. + The following methods in \core\session\manager have been deprecated: + * kill_all_sessions use destroy_all instead + * kill_session use destroy instead + * kill_sessions_for_auth_plugin use destroy_by_auth_plugin instead + * kill_user_sessions use destroy_user_sessions instead + + For more information see [MDL-66151](https://tracker.moodle.org/browse/MDL-66151) + diff --git a/admin/settings/fileredact.php b/admin/settings/fileredact.php new file mode 100644 index 0000000000000..b558e284032ec --- /dev/null +++ b/admin/settings/fileredact.php @@ -0,0 +1,43 @@ +. + +/** + * Configure the settings for fileredact. + * + * @package core_admin + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +if ($hassiteconfig) { + if (!$ADMIN->locate('fileredact')) { + $ADMIN->add('server', new admin_category('fileredact', get_string('fileredact', 'core_files'))); + } + // Get settings from each service. + $servicesdir = "{$CFG->libdir}/classes/fileredact/services/"; + $servicefiles = glob("{$servicesdir}*_service.php"); + foreach ($servicefiles as $servicefile) { + $servicename = basename($servicefile, '_service.php'); + $classname = "\\core\\fileredact\\services\\{$servicename}_service"; + if (class_exists($classname)) { + $fileredactsettings = new admin_settingpage($servicename, new lang_string("fileredact:$servicename", 'core_files')); + call_user_func("{$classname}::add_settings", $fileredactsettings); + $ADMIN->add('fileredact', $fileredactsettings); + } + } +} diff --git a/admin/tool/mfa/factor/email/email.php b/admin/tool/mfa/factor/email/email.php index be0517a85bf51..66fdbf0b3c24d 100644 --- a/admin/tool/mfa/factor/email/email.php +++ b/admin/tool/mfa/factor/email/email.php @@ -79,7 +79,7 @@ $DB->set_field('tool_mfa', 'revoked', 1, ['userid' => $user->id, 'factor' => 'email']); // Remotely logout all sessions for user. - $manager = \core\session\manager::kill_user_sessions($instance->userid); + \core\session\manager::destroy_user_sessions($instance->userid); // Log event. $ip = $instance->createdfromip; diff --git a/admin/tool/uploaduser/classes/process.php b/admin/tool/uploaduser/classes/process.php index a13013f59a43e..562e58956290c 100644 --- a/admin/tool/uploaduser/classes/process.php +++ b/admin/tool/uploaduser/classes/process.php @@ -931,7 +931,7 @@ public function process_line(array $line) { } if ($dologout) { - \core\session\manager::kill_user_sessions($existinguser->id); + \core\session\manager::destroy_user_sessions($existinguser->id); } } else { diff --git a/admin/user.php b/admin/user.php index 266377600f386..43925756ae7eb 100644 --- a/admin/user.php +++ b/admin/user.php @@ -131,7 +131,7 @@ if (!is_siteadmin($user) and $USER->id != $user->id and $user->suspended != 1) { $user->suspended = 1; // Force logout. - \core\session\manager::kill_user_sessions($user->id); + \core\session\manager::destroy_user_sessions($user->id); user_update_user($user, false); } } diff --git a/auth/cas/CAS/readme_moodle.txt b/auth/cas/CAS/readme_moodle.txt index 1c0d35aaa121d..ef425eedc00f4 100644 --- a/auth/cas/CAS/readme_moodle.txt +++ b/auth/cas/CAS/readme_moodle.txt @@ -4,23 +4,27 @@ Last release can be found at https://github.com/apereo/phpCAS/releases NOTICE: * Before running composer command, make sure you have the composer version updated. - * Composer version 2.2.4 2022-01-08 12:30:42 + * Composer version 2.7.7 2024-06-16 19:06:42 STEPS: - * Make sure you're using the lowest supported PHP version for the given release (e.g. PHP 7.4 for Moodle 4.1) - * Create a temporary folder outside your Moodle installation - * Execute 'composer require apereo/phpcas:VERSION' + * Make sure you're using the lowest supported PHP version for the given release (e.g. PHP 8.1 for Moodle 4.5) + * Create a temporary folder outside your Moodle installation e.g. /tmp/phpcas + * Create a composer.json file with the following content inside the temporary folder (you will need to replace X.YY to the proper version to be upgraded): +{ + "require": { + "apereo/phpcas": "X.YY" + }, + "replace": { + "psr/log": "*" + } +} + * Execute 'composer require apereo/phpcas' inside the temporary folder. + * Check to make sure the following directory hasn't been created + - vendor/psr/log * Check any new libraries that have been added and make sure they do not exist in Moodle already. - * Remove the old 'vendor' directory in auth/cas/CAS/ - * Copy contents of 'vendor' directory + * Remove the 'vendor' directory in auth/cas/CAS/ + * Copy the '/tmp/phpcas/vendor' directory into auth/cas/CAS/ * Create a commit with only the library changes. - Note: Make sure to check the list of unversioned files and add any new files to the staging area. * Update auth/cas/thirdpartylibs.xml - * Apply the modifications described in the CHANGES section - * Create another commit with the previous two steps of changes - -CHANGES: - * Remove all the hidden folders and files in vendor/apereo/phpcas/ (find . -name ".*"): - - .codecov.yml - - .gitattributes - - .github \ No newline at end of file + * Create another commit with the previous change diff --git a/auth/cas/CAS/vendor/apereo/phpcas/composer.json b/auth/cas/CAS/vendor/apereo/phpcas/composer.json index 89ab7b9f61d61..bf8a17c4985cb 100644 --- a/auth/cas/CAS/vendor/apereo/phpcas/composer.json +++ b/auth/cas/CAS/vendor/apereo/phpcas/composer.json @@ -33,12 +33,12 @@ "phpstan/phpstan" : "^1.5" }, "autoload" : { + "files": ["source/CAS.php"], "classmap" : [ "source/" ] }, "autoload-dev" : { - "files": ["source/CAS.php"], "psr-4" : { "PhpCas\\" : "test/CAS/" } diff --git a/auth/cas/CAS/vendor/apereo/phpcas/phpunit.xml.dist b/auth/cas/CAS/vendor/apereo/phpcas/phpunit.xml.dist deleted file mode 100644 index f0431f153faf9..0000000000000 --- a/auth/cas/CAS/vendor/apereo/phpcas/phpunit.xml.dist +++ /dev/null @@ -1,13 +0,0 @@ - - - - - source/ - - - - - test/CAS/Tests/ - - - diff --git a/auth/cas/CAS/vendor/apereo/phpcas/source/CAS.php b/auth/cas/CAS/vendor/apereo/phpcas/source/CAS.php index 8243a83e345bb..71c04755a895c 100644 --- a/auth/cas/CAS/vendor/apereo/phpcas/source/CAS.php +++ b/auth/cas/CAS/vendor/apereo/phpcas/source/CAS.php @@ -57,7 +57,7 @@ /** * phpCAS version. accessible for the user by phpCAS::getVersion(). */ -define('PHPCAS_VERSION', '1.6.0'); +define('PHPCAS_VERSION', '1.6.1'); /** * @addtogroup public diff --git a/auth/cas/CAS/vendor/apereo/phpcas/source/CAS/Client.php b/auth/cas/CAS/vendor/apereo/phpcas/source/CAS/Client.php index 91642ee529f81..8ca9711f432df 100644 --- a/auth/cas/CAS/vendor/apereo/phpcas/source/CAS/Client.php +++ b/auth/cas/CAS/vendor/apereo/phpcas/source/CAS/Client.php @@ -973,11 +973,6 @@ public function __construct( session_start(); phpCAS :: trace("Starting a new session " . session_id()); } - // init phpCAS session array - if (!isset($_SESSION[static::PHPCAS_SESSION_PREFIX]) - || !is_array($_SESSION[static::PHPCAS_SESSION_PREFIX])) { - $_SESSION[static::PHPCAS_SESSION_PREFIX] = array(); - } } // Only for debug purposes @@ -1198,9 +1193,21 @@ protected function setSessionValue($key, $value) { $this->validateSession($key); + $this->ensureSessionArray(); $_SESSION[static::PHPCAS_SESSION_PREFIX][$key] = $value; } + /** + * Ensure that the session array is initialized before writing to it. + */ + protected function ensureSessionArray() { + // init phpCAS session array + if (!isset($_SESSION[static::PHPCAS_SESSION_PREFIX]) + || !is_array($_SESSION[static::PHPCAS_SESSION_PREFIX])) { + $_SESSION[static::PHPCAS_SESSION_PREFIX] = array(); + } + } + /** * Remove a session value with the given key. * diff --git a/auth/cas/CAS/vendor/autoload.php b/auth/cas/CAS/vendor/autoload.php index 0fad5e45dce14..6f0a232201d04 100644 --- a/auth/cas/CAS/vendor/autoload.php +++ b/auth/cas/CAS/vendor/autoload.php @@ -22,4 +22,4 @@ require_once __DIR__ . '/composer/autoload_real.php'; -return ComposerAutoloaderInit8c729390e3f26f25c6e8fe4b9504a4d9::getLoader(); +return ComposerAutoloaderInitf37716eaa137347b44822643660c1de5::getLoader(); diff --git a/auth/cas/CAS/vendor/composer/ClassLoader.php b/auth/cas/CAS/vendor/composer/ClassLoader.php index afef3fa2ad83f..7824d8f7eafe8 100644 --- a/auth/cas/CAS/vendor/composer/ClassLoader.php +++ b/auth/cas/CAS/vendor/composer/ClassLoader.php @@ -42,35 +42,37 @@ */ class ClassLoader { - /** @var ?string */ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ private $vendorDir; // PSR-4 /** - * @var array[] - * @psalm-var array> + * @var array> */ private $prefixLengthsPsr4 = array(); /** - * @var array[] - * @psalm-var array> + * @var array> */ private $prefixDirsPsr4 = array(); /** - * @var array[] - * @psalm-var array + * @var list */ private $fallbackDirsPsr4 = array(); // PSR-0 /** - * @var array[] - * @psalm-var array> + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> */ private $prefixesPsr0 = array(); /** - * @var array[] - * @psalm-var array + * @var list */ private $fallbackDirsPsr0 = array(); @@ -78,8 +80,7 @@ class ClassLoader private $useIncludePath = false; /** - * @var string[] - * @psalm-var array + * @var array */ private $classMap = array(); @@ -87,29 +88,29 @@ class ClassLoader private $classMapAuthoritative = false; /** - * @var bool[] - * @psalm-var array + * @var array */ private $missingClasses = array(); - /** @var ?string */ + /** @var string|null */ private $apcuPrefix; /** - * @var self[] + * @var array */ private static $registeredLoaders = array(); /** - * @param ?string $vendorDir + * @param string|null $vendorDir */ public function __construct($vendorDir = null) { $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); } /** - * @return string[] + * @return array> */ public function getPrefixes() { @@ -121,8 +122,7 @@ public function getPrefixes() } /** - * @return array[] - * @psalm-return array> + * @return array> */ public function getPrefixesPsr4() { @@ -130,8 +130,7 @@ public function getPrefixesPsr4() } /** - * @return array[] - * @psalm-return array + * @return list */ public function getFallbackDirs() { @@ -139,8 +138,7 @@ public function getFallbackDirs() } /** - * @return array[] - * @psalm-return array + * @return list */ public function getFallbackDirsPsr4() { @@ -148,8 +146,7 @@ public function getFallbackDirsPsr4() } /** - * @return string[] Array of classname => path - * @psalm-return array + * @return array Array of classname => path */ public function getClassMap() { @@ -157,8 +154,7 @@ public function getClassMap() } /** - * @param string[] $classMap Class to filename map - * @psalm-param array $classMap + * @param array $classMap Class to filename map * * @return void */ @@ -175,24 +171,25 @@ public function addClassMap(array $classMap) * Registers a set of PSR-0 directories for a given prefix, either * appending or prepending to the ones previously set for this prefix. * - * @param string $prefix The prefix - * @param string[]|string $paths The PSR-0 root directories - * @param bool $prepend Whether to prepend the directories + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories * * @return void */ public function add($prefix, $paths, $prepend = false) { + $paths = (array) $paths; if (!$prefix) { if ($prepend) { $this->fallbackDirsPsr0 = array_merge( - (array) $paths, + $paths, $this->fallbackDirsPsr0 ); } else { $this->fallbackDirsPsr0 = array_merge( $this->fallbackDirsPsr0, - (array) $paths + $paths ); } @@ -201,19 +198,19 @@ public function add($prefix, $paths, $prepend = false) $first = $prefix[0]; if (!isset($this->prefixesPsr0[$first][$prefix])) { - $this->prefixesPsr0[$first][$prefix] = (array) $paths; + $this->prefixesPsr0[$first][$prefix] = $paths; return; } if ($prepend) { $this->prefixesPsr0[$first][$prefix] = array_merge( - (array) $paths, + $paths, $this->prefixesPsr0[$first][$prefix] ); } else { $this->prefixesPsr0[$first][$prefix] = array_merge( $this->prefixesPsr0[$first][$prefix], - (array) $paths + $paths ); } } @@ -222,9 +219,9 @@ public function add($prefix, $paths, $prepend = false) * Registers a set of PSR-4 directories for a given namespace, either * appending or prepending to the ones previously set for this namespace. * - * @param string $prefix The prefix/namespace, with trailing '\\' - * @param string[]|string $paths The PSR-4 base directories - * @param bool $prepend Whether to prepend the directories + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories * * @throws \InvalidArgumentException * @@ -232,17 +229,18 @@ public function add($prefix, $paths, $prepend = false) */ public function addPsr4($prefix, $paths, $prepend = false) { + $paths = (array) $paths; if (!$prefix) { // Register directories for the root namespace. if ($prepend) { $this->fallbackDirsPsr4 = array_merge( - (array) $paths, + $paths, $this->fallbackDirsPsr4 ); } else { $this->fallbackDirsPsr4 = array_merge( $this->fallbackDirsPsr4, - (array) $paths + $paths ); } } elseif (!isset($this->prefixDirsPsr4[$prefix])) { @@ -252,18 +250,18 @@ public function addPsr4($prefix, $paths, $prepend = false) throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); } $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; - $this->prefixDirsPsr4[$prefix] = (array) $paths; + $this->prefixDirsPsr4[$prefix] = $paths; } elseif ($prepend) { // Prepend directories for an already registered namespace. $this->prefixDirsPsr4[$prefix] = array_merge( - (array) $paths, + $paths, $this->prefixDirsPsr4[$prefix] ); } else { // Append directories for an already registered namespace. $this->prefixDirsPsr4[$prefix] = array_merge( $this->prefixDirsPsr4[$prefix], - (array) $paths + $paths ); } } @@ -272,8 +270,8 @@ public function addPsr4($prefix, $paths, $prepend = false) * Registers a set of PSR-0 directories for a given prefix, * replacing any others previously set for this prefix. * - * @param string $prefix The prefix - * @param string[]|string $paths The PSR-0 base directories + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories * * @return void */ @@ -290,8 +288,8 @@ public function set($prefix, $paths) * Registers a set of PSR-4 directories for a given namespace, * replacing any others previously set for this namespace. * - * @param string $prefix The prefix/namespace, with trailing '\\' - * @param string[]|string $paths The PSR-4 base directories + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories * * @throws \InvalidArgumentException * @@ -425,7 +423,8 @@ public function unregister() public function loadClass($class) { if ($file = $this->findFile($class)) { - includeFile($file); + $includeFile = self::$includeFile; + $includeFile($file); return true; } @@ -476,9 +475,9 @@ public function findFile($class) } /** - * Returns the currently registered loaders indexed by their corresponding vendor directories. + * Returns the currently registered loaders keyed by their corresponding vendor directories. * - * @return self[] + * @return array */ public static function getRegisteredLoaders() { @@ -555,18 +554,26 @@ private function findFileWithExtension($class, $ext) return false; } -} -/** - * Scope isolated include. - * - * Prevents access to $this/self from included files. - * - * @param string $file - * @return void - * @private - */ -function includeFile($file) -{ - include $file; + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } } diff --git a/auth/cas/CAS/vendor/composer/InstalledVersions.php b/auth/cas/CAS/vendor/composer/InstalledVersions.php index c6b54af7ba2e1..51e734a774b3e 100644 --- a/auth/cas/CAS/vendor/composer/InstalledVersions.php +++ b/auth/cas/CAS/vendor/composer/InstalledVersions.php @@ -98,7 +98,7 @@ public static function isInstalled($packageName, $includeDevRequirements = true) { foreach (self::getInstalled() as $installed) { if (isset($installed['versions'][$packageName])) { - return $includeDevRequirements || empty($installed['versions'][$packageName]['dev_requirement']); + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; } } @@ -119,7 +119,7 @@ public static function isInstalled($packageName, $includeDevRequirements = true) */ public static function satisfies(VersionParser $parser, $packageName, $constraint) { - $constraint = $parser->parseConstraints($constraint); + $constraint = $parser->parseConstraints((string) $constraint); $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); return $provided->matches($constraint); @@ -328,7 +328,9 @@ private static function getInstalled() if (isset(self::$installedByVendor[$vendorDir])) { $installed[] = self::$installedByVendor[$vendorDir]; } elseif (is_file($vendorDir.'/composer/installed.php')) { - $installed[] = self::$installedByVendor[$vendorDir] = require $vendorDir.'/composer/installed.php'; + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + $installed[] = self::$installedByVendor[$vendorDir] = $required; if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { self::$installed = $installed[count($installed) - 1]; } @@ -340,12 +342,17 @@ private static function getInstalled() // only require the installed.php file if this file is loaded from its dumped location, // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 if (substr(__DIR__, -8, 1) !== 'C') { - self::$installed = require __DIR__ . '/installed.php'; + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; } else { self::$installed = array(); } } - $installed[] = self::$installed; + + if (self::$installed !== array()) { + $installed[] = self::$installed; + } return $installed; } diff --git a/auth/cas/CAS/vendor/composer/autoload_files.php b/auth/cas/CAS/vendor/composer/autoload_files.php new file mode 100644 index 0000000000000..a20d5e56f4f2c --- /dev/null +++ b/auth/cas/CAS/vendor/composer/autoload_files.php @@ -0,0 +1,10 @@ + $vendorDir . '/apereo/phpcas/source/CAS.php', +); diff --git a/auth/cas/CAS/vendor/composer/autoload_psr4.php b/auth/cas/CAS/vendor/composer/autoload_psr4.php index 6670ffa45de03..3890ddc2409b7 100644 --- a/auth/cas/CAS/vendor/composer/autoload_psr4.php +++ b/auth/cas/CAS/vendor/composer/autoload_psr4.php @@ -6,5 +6,4 @@ $baseDir = dirname($vendorDir); return array( - 'Psr\\Log\\' => array($vendorDir . '/psr/log/Psr/Log'), ); diff --git a/auth/cas/CAS/vendor/composer/autoload_real.php b/auth/cas/CAS/vendor/composer/autoload_real.php index 42a74927b3493..201a52c8de340 100644 --- a/auth/cas/CAS/vendor/composer/autoload_real.php +++ b/auth/cas/CAS/vendor/composer/autoload_real.php @@ -2,7 +2,7 @@ // autoload_real.php @generated by Composer -class ComposerAutoloaderInit8c729390e3f26f25c6e8fe4b9504a4d9 +class ComposerAutoloaderInitf37716eaa137347b44822643660c1de5 { private static $loader; @@ -24,15 +24,27 @@ public static function getLoader() require __DIR__ . '/platform_check.php'; - spl_autoload_register(array('ComposerAutoloaderInit8c729390e3f26f25c6e8fe4b9504a4d9', 'loadClassLoader'), true, true); + spl_autoload_register(array('ComposerAutoloaderInitf37716eaa137347b44822643660c1de5', 'loadClassLoader'), true, true); self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__)); - spl_autoload_unregister(array('ComposerAutoloaderInit8c729390e3f26f25c6e8fe4b9504a4d9', 'loadClassLoader')); + spl_autoload_unregister(array('ComposerAutoloaderInitf37716eaa137347b44822643660c1de5', 'loadClassLoader')); require __DIR__ . '/autoload_static.php'; - call_user_func(\Composer\Autoload\ComposerStaticInit8c729390e3f26f25c6e8fe4b9504a4d9::getInitializer($loader)); + call_user_func(\Composer\Autoload\ComposerStaticInitf37716eaa137347b44822643660c1de5::getInitializer($loader)); $loader->register(true); + $filesToLoad = \Composer\Autoload\ComposerStaticInitf37716eaa137347b44822643660c1de5::$files; + $requireFile = \Closure::bind(static function ($fileIdentifier, $file) { + if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { + $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; + + require $file; + } + }, null, null); + foreach ($filesToLoad as $fileIdentifier => $file) { + $requireFile($fileIdentifier, $file); + } + return $loader; } } diff --git a/auth/cas/CAS/vendor/composer/autoload_static.php b/auth/cas/CAS/vendor/composer/autoload_static.php index 539d9edb2f00b..44f231e8dca20 100644 --- a/auth/cas/CAS/vendor/composer/autoload_static.php +++ b/auth/cas/CAS/vendor/composer/autoload_static.php @@ -4,20 +4,10 @@ namespace Composer\Autoload; -class ComposerStaticInit8c729390e3f26f25c6e8fe4b9504a4d9 +class ComposerStaticInitf37716eaa137347b44822643660c1de5 { - public static $prefixLengthsPsr4 = array ( - 'P' => - array ( - 'Psr\\Log\\' => 8, - ), - ); - - public static $prefixDirsPsr4 = array ( - 'Psr\\Log\\' => - array ( - 0 => __DIR__ . '/..' . '/psr/log/Psr/Log', - ), + public static $files = array ( + '344f11dc3484aaed5cbde58e23513be4' => __DIR__ . '/..' . '/apereo/phpcas/source/CAS.php', ); public static $classMap = array ( @@ -79,9 +69,7 @@ class ComposerStaticInit8c729390e3f26f25c6e8fe4b9504a4d9 public static function getInitializer(ClassLoader $loader) { return \Closure::bind(function () use ($loader) { - $loader->prefixLengthsPsr4 = ComposerStaticInit8c729390e3f26f25c6e8fe4b9504a4d9::$prefixLengthsPsr4; - $loader->prefixDirsPsr4 = ComposerStaticInit8c729390e3f26f25c6e8fe4b9504a4d9::$prefixDirsPsr4; - $loader->classMap = ComposerStaticInit8c729390e3f26f25c6e8fe4b9504a4d9::$classMap; + $loader->classMap = ComposerStaticInitf37716eaa137347b44822643660c1de5::$classMap; }, null, ClassLoader::class); } diff --git a/auth/cas/CAS/vendor/composer/installed.json b/auth/cas/CAS/vendor/composer/installed.json index 68a0853a9c632..f61130534d86d 100644 --- a/auth/cas/CAS/vendor/composer/installed.json +++ b/auth/cas/CAS/vendor/composer/installed.json @@ -2,17 +2,17 @@ "packages": [ { "name": "apereo/phpcas", - "version": "1.6.0", - "version_normalized": "1.6.0.0", + "version": "1.6.1", + "version_normalized": "1.6.1.0", "source": { "type": "git", "url": "https://github.com/apereo/phpCAS.git", - "reference": "f817c72a961484afef95ac64a9257c8e31f063b9" + "reference": "c129708154852656aabb13d8606cd5b12dbbabac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/apereo/phpCAS/zipball/f817c72a961484afef95ac64a9257c8e31f063b9", - "reference": "f817c72a961484afef95ac64a9257c8e31f063b9", + "url": "https://api.github.com/repos/apereo/phpCAS/zipball/c129708154852656aabb13d8606cd5b12dbbabac", + "reference": "c129708154852656aabb13d8606cd5b12dbbabac", "shasum": "" }, "require": { @@ -26,7 +26,7 @@ "phpstan/phpstan": "^1.5", "phpunit/phpunit": ">=7.5" }, - "time": "2022-10-31T20:39:27+00:00", + "time": "2023-02-19T19:52:35+00:00", "type": "library", "extra": { "branch-alias": { @@ -35,6 +35,9 @@ }, "installation-source": "dist", "autoload": { + "files": [ + "source/CAS.php" + ], "classmap": [ "source/" ] @@ -67,62 +70,9 @@ ], "support": { "issues": "https://github.com/apereo/phpCAS/issues", - "source": "https://github.com/apereo/phpCAS/tree/1.6.0" + "source": "https://github.com/apereo/phpCAS/tree/1.6.1" }, "install-path": "../apereo/phpcas" - }, - { - "name": "psr/log", - "version": "1.1.4", - "version_normalized": "1.1.4.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "time": "2021-05-03T11:20:27+00:00", - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" - } - }, - "installation-source": "dist", - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" - }, - "install-path": "../psr/log" } ], "dev": true, diff --git a/auth/cas/CAS/vendor/composer/installed.php b/auth/cas/CAS/vendor/composer/installed.php index 2875eb129886f..18ee8ffc25de6 100644 --- a/auth/cas/CAS/vendor/composer/installed.php +++ b/auth/cas/CAS/vendor/composer/installed.php @@ -20,22 +20,19 @@ 'dev_requirement' => false, ), 'apereo/phpcas' => array( - 'pretty_version' => '1.6.0', - 'version' => '1.6.0.0', - 'reference' => 'f817c72a961484afef95ac64a9257c8e31f063b9', + 'pretty_version' => '1.6.1', + 'version' => '1.6.1.0', + 'reference' => 'c129708154852656aabb13d8606cd5b12dbbabac', 'type' => 'library', 'install_path' => __DIR__ . '/../apereo/phpcas', 'aliases' => array(), 'dev_requirement' => false, ), 'psr/log' => array( - 'pretty_version' => '1.1.4', - 'version' => '1.1.4.0', - 'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11', - 'type' => 'library', - 'install_path' => __DIR__ . '/../psr/log', - 'aliases' => array(), 'dev_requirement' => false, + 'replaced' => array( + 0 => '*', + ), ), ), ); diff --git a/auth/cas/CAS/vendor/psr/log/LICENSE b/auth/cas/CAS/vendor/psr/log/LICENSE deleted file mode 100644 index 474c952b4b50c..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2012 PHP Framework Interoperability Group - -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. diff --git a/auth/cas/CAS/vendor/psr/log/Psr/Log/AbstractLogger.php b/auth/cas/CAS/vendor/psr/log/Psr/Log/AbstractLogger.php deleted file mode 100644 index e02f9daf3d514..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/Psr/Log/AbstractLogger.php +++ /dev/null @@ -1,128 +0,0 @@ -log(LogLevel::EMERGENCY, $message, $context); - } - - /** - * Action must be taken immediately. - * - * Example: Entire website down, database unavailable, etc. This should - * trigger the SMS alerts and wake you up. - * - * @param string $message - * @param mixed[] $context - * - * @return void - */ - public function alert($message, array $context = array()) - { - $this->log(LogLevel::ALERT, $message, $context); - } - - /** - * Critical conditions. - * - * Example: Application component unavailable, unexpected exception. - * - * @param string $message - * @param mixed[] $context - * - * @return void - */ - public function critical($message, array $context = array()) - { - $this->log(LogLevel::CRITICAL, $message, $context); - } - - /** - * Runtime errors that do not require immediate action but should typically - * be logged and monitored. - * - * @param string $message - * @param mixed[] $context - * - * @return void - */ - public function error($message, array $context = array()) - { - $this->log(LogLevel::ERROR, $message, $context); - } - - /** - * Exceptional occurrences that are not errors. - * - * Example: Use of deprecated APIs, poor use of an API, undesirable things - * that are not necessarily wrong. - * - * @param string $message - * @param mixed[] $context - * - * @return void - */ - public function warning($message, array $context = array()) - { - $this->log(LogLevel::WARNING, $message, $context); - } - - /** - * Normal but significant events. - * - * @param string $message - * @param mixed[] $context - * - * @return void - */ - public function notice($message, array $context = array()) - { - $this->log(LogLevel::NOTICE, $message, $context); - } - - /** - * Interesting events. - * - * Example: User logs in, SQL logs. - * - * @param string $message - * @param mixed[] $context - * - * @return void - */ - public function info($message, array $context = array()) - { - $this->log(LogLevel::INFO, $message, $context); - } - - /** - * Detailed debug information. - * - * @param string $message - * @param mixed[] $context - * - * @return void - */ - public function debug($message, array $context = array()) - { - $this->log(LogLevel::DEBUG, $message, $context); - } -} diff --git a/auth/cas/CAS/vendor/psr/log/Psr/Log/InvalidArgumentException.php b/auth/cas/CAS/vendor/psr/log/Psr/Log/InvalidArgumentException.php deleted file mode 100644 index 67f852d1dbc66..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/Psr/Log/InvalidArgumentException.php +++ /dev/null @@ -1,7 +0,0 @@ -logger = $logger; - } -} diff --git a/auth/cas/CAS/vendor/psr/log/Psr/Log/LoggerInterface.php b/auth/cas/CAS/vendor/psr/log/Psr/Log/LoggerInterface.php deleted file mode 100644 index 2206cfde41aec..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/Psr/Log/LoggerInterface.php +++ /dev/null @@ -1,125 +0,0 @@ -log(LogLevel::EMERGENCY, $message, $context); - } - - /** - * Action must be taken immediately. - * - * Example: Entire website down, database unavailable, etc. This should - * trigger the SMS alerts and wake you up. - * - * @param string $message - * @param array $context - * - * @return void - */ - public function alert($message, array $context = array()) - { - $this->log(LogLevel::ALERT, $message, $context); - } - - /** - * Critical conditions. - * - * Example: Application component unavailable, unexpected exception. - * - * @param string $message - * @param array $context - * - * @return void - */ - public function critical($message, array $context = array()) - { - $this->log(LogLevel::CRITICAL, $message, $context); - } - - /** - * Runtime errors that do not require immediate action but should typically - * be logged and monitored. - * - * @param string $message - * @param array $context - * - * @return void - */ - public function error($message, array $context = array()) - { - $this->log(LogLevel::ERROR, $message, $context); - } - - /** - * Exceptional occurrences that are not errors. - * - * Example: Use of deprecated APIs, poor use of an API, undesirable things - * that are not necessarily wrong. - * - * @param string $message - * @param array $context - * - * @return void - */ - public function warning($message, array $context = array()) - { - $this->log(LogLevel::WARNING, $message, $context); - } - - /** - * Normal but significant events. - * - * @param string $message - * @param array $context - * - * @return void - */ - public function notice($message, array $context = array()) - { - $this->log(LogLevel::NOTICE, $message, $context); - } - - /** - * Interesting events. - * - * Example: User logs in, SQL logs. - * - * @param string $message - * @param array $context - * - * @return void - */ - public function info($message, array $context = array()) - { - $this->log(LogLevel::INFO, $message, $context); - } - - /** - * Detailed debug information. - * - * @param string $message - * @param array $context - * - * @return void - */ - public function debug($message, array $context = array()) - { - $this->log(LogLevel::DEBUG, $message, $context); - } - - /** - * Logs with an arbitrary level. - * - * @param mixed $level - * @param string $message - * @param array $context - * - * @return void - * - * @throws \Psr\Log\InvalidArgumentException - */ - abstract public function log($level, $message, array $context = array()); -} diff --git a/auth/cas/CAS/vendor/psr/log/Psr/Log/NullLogger.php b/auth/cas/CAS/vendor/psr/log/Psr/Log/NullLogger.php deleted file mode 100644 index c8f7293b1c668..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/Psr/Log/NullLogger.php +++ /dev/null @@ -1,30 +0,0 @@ -logger) { }` - * blocks. - */ -class NullLogger extends AbstractLogger -{ - /** - * Logs with an arbitrary level. - * - * @param mixed $level - * @param string $message - * @param array $context - * - * @return void - * - * @throws \Psr\Log\InvalidArgumentException - */ - public function log($level, $message, array $context = array()) - { - // noop - } -} diff --git a/auth/cas/CAS/vendor/psr/log/Psr/Log/Test/DummyTest.php b/auth/cas/CAS/vendor/psr/log/Psr/Log/Test/DummyTest.php deleted file mode 100644 index 9638c11018611..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/Psr/Log/Test/DummyTest.php +++ /dev/null @@ -1,18 +0,0 @@ - ". - * - * Example ->error('Foo') would yield "error Foo". - * - * @return string[] - */ - abstract public function getLogs(); - - public function testImplements() - { - $this->assertInstanceOf('Psr\Log\LoggerInterface', $this->getLogger()); - } - - /** - * @dataProvider provideLevelsAndMessages - */ - public function testLogsAtAllLevels($level, $message) - { - $logger = $this->getLogger(); - $logger->{$level}($message, array('user' => 'Bob')); - $logger->log($level, $message, array('user' => 'Bob')); - - $expected = array( - $level.' message of level '.$level.' with context: Bob', - $level.' message of level '.$level.' with context: Bob', - ); - $this->assertEquals($expected, $this->getLogs()); - } - - public function provideLevelsAndMessages() - { - return array( - LogLevel::EMERGENCY => array(LogLevel::EMERGENCY, 'message of level emergency with context: {user}'), - LogLevel::ALERT => array(LogLevel::ALERT, 'message of level alert with context: {user}'), - LogLevel::CRITICAL => array(LogLevel::CRITICAL, 'message of level critical with context: {user}'), - LogLevel::ERROR => array(LogLevel::ERROR, 'message of level error with context: {user}'), - LogLevel::WARNING => array(LogLevel::WARNING, 'message of level warning with context: {user}'), - LogLevel::NOTICE => array(LogLevel::NOTICE, 'message of level notice with context: {user}'), - LogLevel::INFO => array(LogLevel::INFO, 'message of level info with context: {user}'), - LogLevel::DEBUG => array(LogLevel::DEBUG, 'message of level debug with context: {user}'), - ); - } - - /** - * @expectedException \Psr\Log\InvalidArgumentException - */ - public function testThrowsOnInvalidLevel() - { - $logger = $this->getLogger(); - $logger->log('invalid level', 'Foo'); - } - - public function testContextReplacement() - { - $logger = $this->getLogger(); - $logger->info('{Message {nothing} {user} {foo.bar} a}', array('user' => 'Bob', 'foo.bar' => 'Bar')); - - $expected = array('info {Message {nothing} Bob Bar a}'); - $this->assertEquals($expected, $this->getLogs()); - } - - public function testObjectCastToString() - { - if (method_exists($this, 'createPartialMock')) { - $dummy = $this->createPartialMock('Psr\Log\Test\DummyTest', array('__toString')); - } else { - $dummy = $this->getMock('Psr\Log\Test\DummyTest', array('__toString')); - } - $dummy->expects($this->once()) - ->method('__toString') - ->will($this->returnValue('DUMMY')); - - $this->getLogger()->warning($dummy); - - $expected = array('warning DUMMY'); - $this->assertEquals($expected, $this->getLogs()); - } - - public function testContextCanContainAnything() - { - $closed = fopen('php://memory', 'r'); - fclose($closed); - - $context = array( - 'bool' => true, - 'null' => null, - 'string' => 'Foo', - 'int' => 0, - 'float' => 0.5, - 'nested' => array('with object' => new DummyTest), - 'object' => new \DateTime, - 'resource' => fopen('php://memory', 'r'), - 'closed' => $closed, - ); - - $this->getLogger()->warning('Crazy context data', $context); - - $expected = array('warning Crazy context data'); - $this->assertEquals($expected, $this->getLogs()); - } - - public function testContextExceptionKeyCanBeExceptionOrOtherValues() - { - $logger = $this->getLogger(); - $logger->warning('Random message', array('exception' => 'oops')); - $logger->critical('Uncaught Exception!', array('exception' => new \LogicException('Fail'))); - - $expected = array( - 'warning Random message', - 'critical Uncaught Exception!' - ); - $this->assertEquals($expected, $this->getLogs()); - } -} diff --git a/auth/cas/CAS/vendor/psr/log/Psr/Log/Test/TestLogger.php b/auth/cas/CAS/vendor/psr/log/Psr/Log/Test/TestLogger.php deleted file mode 100644 index 1be3230496b70..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/Psr/Log/Test/TestLogger.php +++ /dev/null @@ -1,147 +0,0 @@ - $level, - 'message' => $message, - 'context' => $context, - ]; - - $this->recordsByLevel[$record['level']][] = $record; - $this->records[] = $record; - } - - public function hasRecords($level) - { - return isset($this->recordsByLevel[$level]); - } - - public function hasRecord($record, $level) - { - if (is_string($record)) { - $record = ['message' => $record]; - } - return $this->hasRecordThatPasses(function ($rec) use ($record) { - if ($rec['message'] !== $record['message']) { - return false; - } - if (isset($record['context']) && $rec['context'] !== $record['context']) { - return false; - } - return true; - }, $level); - } - - public function hasRecordThatContains($message, $level) - { - return $this->hasRecordThatPasses(function ($rec) use ($message) { - return strpos($rec['message'], $message) !== false; - }, $level); - } - - public function hasRecordThatMatches($regex, $level) - { - return $this->hasRecordThatPasses(function ($rec) use ($regex) { - return preg_match($regex, $rec['message']) > 0; - }, $level); - } - - public function hasRecordThatPasses(callable $predicate, $level) - { - if (!isset($this->recordsByLevel[$level])) { - return false; - } - foreach ($this->recordsByLevel[$level] as $i => $rec) { - if (call_user_func($predicate, $rec, $i)) { - return true; - } - } - return false; - } - - public function __call($method, $args) - { - if (preg_match('/(.*)(Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)(.*)/', $method, $matches) > 0) { - $genericMethod = $matches[1] . ('Records' !== $matches[3] ? 'Record' : '') . $matches[3]; - $level = strtolower($matches[2]); - if (method_exists($this, $genericMethod)) { - $args[] = $level; - return call_user_func_array([$this, $genericMethod], $args); - } - } - throw new \BadMethodCallException('Call to undefined method ' . get_class($this) . '::' . $method . '()'); - } - - public function reset() - { - $this->records = []; - $this->recordsByLevel = []; - } -} diff --git a/auth/cas/CAS/vendor/psr/log/README.md b/auth/cas/CAS/vendor/psr/log/README.md deleted file mode 100644 index a9f20c437b385..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/README.md +++ /dev/null @@ -1,58 +0,0 @@ -PSR Log -======= - -This repository holds all interfaces/classes/traits related to -[PSR-3](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md). - -Note that this is not a logger of its own. It is merely an interface that -describes a logger. See the specification for more details. - -Installation ------------- - -```bash -composer require psr/log -``` - -Usage ------ - -If you need a logger, you can use the interface like this: - -```php -logger = $logger; - } - - public function doSomething() - { - if ($this->logger) { - $this->logger->info('Doing work'); - } - - try { - $this->doSomethingElse(); - } catch (Exception $exception) { - $this->logger->error('Oh no!', array('exception' => $exception)); - } - - // do something useful - } -} -``` - -You can then pick one of the implementations of the interface to get a logger. - -If you want to implement the interface, you can require this package and -implement `Psr\Log\LoggerInterface` in your code. Please read the -[specification text](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md) -for details. diff --git a/auth/cas/CAS/vendor/psr/log/composer.json b/auth/cas/CAS/vendor/psr/log/composer.json deleted file mode 100644 index ca05695377036..0000000000000 --- a/auth/cas/CAS/vendor/psr/log/composer.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "psr/log", - "description": "Common interface for logging libraries", - "keywords": ["psr", "psr-3", "log"], - "homepage": "https://github.com/php-fig/log", - "license": "MIT", - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "require": { - "php": ">=5.3.0" - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, - "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" - } - } -} diff --git a/auth/cas/auth.php b/auth/cas/auth.php index 7378b4fa43647..a7b76eb9b410d 100644 --- a/auth/cas/auth.php +++ b/auth/cas/auth.php @@ -30,7 +30,6 @@ require_once($CFG->dirroot.'/auth/ldap/auth.php'); require_once($CFG->dirroot.'/auth/cas/CAS/vendor/autoload.php'); -require_once($CFG->dirroot.'/auth/cas/CAS/vendor/apereo/phpcas/source/CAS.php'); /** * CAS authentication plugin. diff --git a/auth/cas/thirdpartylibs.xml b/auth/cas/thirdpartylibs.xml index 7133c36e295c3..ab154811baab4 100644 --- a/auth/cas/thirdpartylibs.xml +++ b/auth/cas/thirdpartylibs.xml @@ -4,7 +4,7 @@ CAS CAS phpCAS library to support CAS authentication plugin. - 1.6.0 + 1.6.1 Apache 2.0 https://github.com/apereo/phpCAS diff --git a/auth/ldap/auth.php b/auth/ldap/auth.php index 927968ccd87ac..c423e0166d055 100644 --- a/auth/ldap/auth.php +++ b/auth/ldap/auth.php @@ -839,7 +839,7 @@ public function sync_users_update_callback(?callable $updatecallback = null): bo $updateuser->suspended = 1; user_update_user($updateuser, false); echo "\t"; print_string('auth_dbsuspenduser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)); echo "\n"; - \core\session\manager::kill_user_sessions($user->id); + \core\session\manager::destroy_user_sessions($user->id); } } else { print_string('nouserentriestoremove', 'auth_ldap'); diff --git a/auth/mnet/auth.php b/auth/mnet/auth.php index 676368ebc60f0..f5060d5ab6b6a 100644 --- a/auth/mnet/auth.php +++ b/auth/mnet/auth.php @@ -868,7 +868,7 @@ function kill_children($username, $useragent) { array('useragent'=>$useragent, 'userid'=>$userid)); if (isset($remoteclient) && isset($remoteclient->id)) { - \core\session\manager::kill_user_sessions($userid); + \core\session\manager::destroy_user_sessions($userid); } return $returnstring; } @@ -888,7 +888,7 @@ function kill_child($username, $useragent) { $session = $DB->get_record('mnet_session', array('username'=>$username, 'mnethostid'=>$remoteclient->id, 'useragent'=>$useragent)); $DB->delete_records('mnet_session', array('username'=>$username, 'mnethostid'=>$remoteclient->id, 'useragent'=>$useragent)); if (false != $session) { - \core\session\manager::kill_session($session->session_id); + \core\session\manager::destroy($session->session_id); return true; } return false; @@ -905,7 +905,7 @@ function end_local_sessions(&$sessionArray) { global $CFG; if (is_array($sessionArray)) { while($session = array_pop($sessionArray)) { - \core\session\manager::kill_session($session->session_id); + \core\session\manager::destroy($session->session_id); } return true; } diff --git a/auth/shibboleth/classes/helper.php b/auth/shibboleth/classes/helper.php index 297146fb22168..4be2c3d68a847 100644 --- a/auth/shibboleth/classes/helper.php +++ b/auth/shibboleth/classes/helper.php @@ -98,7 +98,7 @@ public static function logout_db_session($spsessionid) { // If there is a match, kill the session. if ($usersession['SESSION']->shibboleth_session_id == trim($spsessionid)) { // Delete this user's sessions. - \core\session\manager::kill_user_sessions($session->userid); + \core\session\manager::destroy_user_sessions($session->userid); } } } diff --git a/availability/condition/grade/tests/behat/availability_grade.feature b/availability/condition/grade/tests/behat/availability_grade.feature index a01e871273e7a..e2a547d1422eb 100644 --- a/availability/condition/grade/tests/behat/availability_grade.feature +++ b/availability/condition/grade/tests/behat/availability_grade.feature @@ -85,12 +85,10 @@ Feature: availability_grade # Log back in as teacher. When I am on the "A1" "assign activity" page logged in as teacher1 + And I change window size to "large" # Give the assignment 40%. - And I navigate to "Submissions" in current page administration - # Pick the grade link in the row that has s@example.com in it. - And I change window size to "large" - And I click on "Grade" "link" in the "s@example.com" "table_row" + And I go to "s@example.com" "A1" activity advanced grading page And I change window size to "medium" And I set the field "Grade out of 100" to "40" And I click on "Save changes" "button" diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index d433ff2841a9b..cd097aba62be0 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -1424,14 +1424,19 @@ protected function define_structure() { FROM {groups} g JOIN {backup_ids_temp} bi ON g.id = bi.itemid WHERE bi.backupid = ? - AND bi.itemname = 'groupfinal'", array(backup::VAR_BACKUPID)); + AND bi.itemname = 'groupfinal'", + [backup_helper::is_sqlparam($this->get_backupid())] + ); $grouping->set_source_sql(" SELECT g.* FROM {groupings} g JOIN {backup_ids_temp} bi ON g.id = bi.itemid WHERE bi.backupid = ? - AND bi.itemname = 'groupingfinal'", array(backup::VAR_BACKUPID)); + AND bi.itemname = 'groupingfinal'", + [backup_helper::is_sqlparam($this->get_backupid())] + ); + $groupinggroup->set_source_table('groupings_groups', array('groupingid' => backup::VAR_PARENTID)); // This only happens if we are including users. @@ -1439,9 +1444,17 @@ protected function define_structure() { $member->set_source_table('groups_members', array('groupid' => backup::VAR_PARENTID)); } - $courseid = $this->task->get_courseid(); - $groupcustomfield->set_source_array($this->get_group_custom_fields_for_backup($courseid)); - $groupingcustomfield->set_source_array($this->get_grouping_custom_fields_for_backup($courseid)); + $groupcustomfieldarray = $this->get_group_custom_fields_for_backup( + $group->get_source_sql(), + [$this->get_backupid()] + ); + $groupcustomfield->set_source_array($groupcustomfieldarray); + + $groupingcustomfieldarray = $this->get_grouping_custom_fields_for_backup( + $grouping->get_source_sql(), + [$this->get_backupid()] + ); + $groupingcustomfield->set_source_array($groupingcustomfieldarray); } // Define id annotations (as final) @@ -1460,14 +1473,16 @@ protected function define_structure() { /** * Get custom fields array for group - * @param int $courseid + * + * @param string $groupsourcesql + * @param array $groupsourceparams * @return array */ - protected function get_group_custom_fields_for_backup(int $courseid): array { + protected function get_group_custom_fields_for_backup(string $groupsourcesql, array $groupsourceparams): array { global $DB; $handler = \core_group\customfield\group_handler::create(); $fieldsforbackup = []; - if ($groups = $DB->get_records('groups', ['courseid' => $courseid], '', 'id')) { + if ($groups = $DB->get_records_sql($groupsourcesql, $groupsourceparams)) { foreach ($groups as $group) { $fieldsforbackup = array_merge($fieldsforbackup, $handler->get_instance_data_for_backup($group->id)); } @@ -1477,14 +1492,16 @@ protected function get_group_custom_fields_for_backup(int $courseid): array { /** * Get custom fields array for grouping - * @param int $courseid + * + * @param string $groupingsourcesql + * @param array $groupingsourceparams * @return array */ - protected function get_grouping_custom_fields_for_backup(int $courseid): array { + protected function get_grouping_custom_fields_for_backup(string $groupingsourcesql, array $groupingsourceparams): array { global $DB; $handler = \core_group\customfield\grouping_handler::create(); $fieldsforbackup = []; - if ($groupings = $DB->get_records('groupings', ['courseid' => $courseid], '', 'id')) { + if ($groupings = $DB->get_records_sql($groupingsourcesql, $groupingsourceparams)) { foreach ($groupings as $grouping) { $fieldsforbackup = array_merge($fieldsforbackup, $handler->get_instance_data_for_backup($grouping->id)); } diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index 98cac314e7335..1856dededda43 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -1235,9 +1235,11 @@ public function process_group($data) { */ public function process_groupcustomfield($data) { $newgroup = $this->get_mapping('group', $data['groupid']); - $data['groupid'] = $newgroup->newitemid ?? $data['groupid']; - $handler = \core_group\customfield\group_handler::create(); - $handler->restore_instance_data_from_backup($this->task, $data); + if ($newgroup && $newgroup->newitemid) { + $data['groupid'] = $newgroup->newitemid; + $handler = \core_group\customfield\group_handler::create(); + $handler->restore_instance_data_from_backup($this->task, $data); + } } public function process_grouping($data) { @@ -1291,10 +1293,12 @@ public function process_grouping($data) { * @return void */ public function process_groupingcustomfield($data) { - $newgroup = $this->get_mapping('grouping', $data['groupingid']); - $data['groupingid'] = $newgroup->newitemid ?? $data['groupingid']; - $handler = \core_group\customfield\grouping_handler::create(); - $handler->restore_instance_data_from_backup($this->task, $data); + $newgrouping = $this->get_mapping('grouping', $data['groupingid']); + if ($newgrouping && $newgrouping->newitemid) { + $data['groupingid'] = $newgrouping->newitemid; + $handler = \core_group\customfield\grouping_handler::create(); + $handler->restore_instance_data_from_backup($this->task, $data); + } } public function process_grouping_group($data) { diff --git a/badges/classes/reportbuilder/local/systemreports/badges.php b/badges/classes/reportbuilder/local/systemreports/badges.php index 832f374350f9f..01d0a8753fb49 100644 --- a/badges/classes/reportbuilder/local/systemreports/badges.php +++ b/badges/classes/reportbuilder/local/systemreports/badges.php @@ -77,8 +77,8 @@ protected function initialise(): void { $this->add_filters(); $this->add_actions(); - // Set initial sorting by name. $this->set_initial_sort_column('badge:namewithlink', SORT_ASC); + $this->set_default_no_results_notice(new lang_string('nomatchingbadges', 'core_badges')); // Set if report can be downloaded. $this->set_downloadable(false); @@ -155,13 +155,12 @@ protected function add_columns(): void { * unique identifier */ protected function add_filters(): void { - $filters = [ + $this->add_filters_from_entities([ 'badge:name', 'badge:version', 'badge:status', 'badge:expiry', - ]; - $this->add_filters_from_entities($filters); + ]); } /** diff --git a/badges/classes/reportbuilder/local/systemreports/course_badges.php b/badges/classes/reportbuilder/local/systemreports/course_badges.php index 3bb42aa68f651..f415e287b668d 100644 --- a/badges/classes/reportbuilder/local/systemreports/course_badges.php +++ b/badges/classes/reportbuilder/local/systemreports/course_badges.php @@ -70,9 +70,12 @@ protected function initialise(): void { $this->add_base_fields("{$badgeissuedalias}.uniquehash"); // Now we can call our helper methods to add the content we want to include in the report. - $this->add_columns($badgeissuedalias); + $this->add_columns(); $this->add_filters(); + $this->set_initial_sort_column('badge:name', SORT_ASC); + $this->set_default_no_results_notice(new lang_string('nomatchingbadges', 'core_badges')); + // Set if report can be downloaded. $this->set_downloadable(false); } @@ -91,18 +94,17 @@ protected function can_view(): bool { * * They are provided by the entities we previously added in the {@see initialise} method, referencing each by their * unique identifier. If custom columns are needed just for this report, they can be defined here. - * - * @param string $badgeissuedalias */ - public function add_columns(string $badgeissuedalias): void { - $columns = [ + protected function add_columns(): void { + $badgeissuedalias = $this->get_entity('badge_issued')->get_table_alias('badge_issued'); + + $this->add_columns_from_entities([ 'badge:image', 'badge:name', 'badge:description', 'badge:criteria', 'badge_issued:issued', - ]; - $this->add_columns_from_entities($columns); + ]); $this->get_column('badge_issued:issued') ->set_title(new lang_string('awardedtoyou', 'core_badges')) @@ -119,8 +121,6 @@ public function add_columns(string $badgeissuedalias): void { $icon = new pix_icon('i/valid', get_string('dateearned', 'badges', $date)); return $OUTPUT->action_icon($badgeurl, $icon, null, null, true); }); - - $this->set_initial_sort_column('badge:name', SORT_ASC); } /** @@ -130,11 +130,9 @@ public function add_columns(string $badgeissuedalias): void { * unique identifier */ protected function add_filters(): void { - $filters = [ + $this->add_filters_from_entities([ 'badge:name', 'badge_issued:issued', - ]; - - $this->add_filters_from_entities($filters); + ]); } } diff --git a/badges/classes/reportbuilder/local/systemreports/recipients.php b/badges/classes/reportbuilder/local/systemreports/recipients.php index b2b2fbb2d0454..2b4837f90e9a9 100644 --- a/badges/classes/reportbuilder/local/systemreports/recipients.php +++ b/badges/classes/reportbuilder/local/systemreports/recipients.php @@ -62,6 +62,9 @@ protected function initialise(): void { $this->add_filters(); $this->add_actions(); + $this->set_initial_sort_column('badge_issued:issued', SORT_DESC); + $this->set_default_no_results_notice(new lang_string('nomatchingawards', 'core_badges')); + // Set if report can be downloaded. $this->set_downloadable(false); } @@ -81,14 +84,11 @@ protected function can_view(): bool { * They are provided by the entities we previously added in the {@see initialise} method, referencing each by their * unique identifier. If custom columns are needed just for this report, they can be defined here. */ - public function add_columns(): void { - $columns = [ + protected function add_columns(): void { + $this->add_columns_from_entities([ 'user:fullnamewithlink', 'badge_issued:issued', - ]; - - $this->add_columns_from_entities($columns); - $this->set_initial_sort_column('badge_issued:issued', SORT_DESC); + ]); } /** @@ -98,12 +98,10 @@ public function add_columns(): void { * unique identifier */ protected function add_filters(): void { - $filters = [ + $this->add_filters_from_entities([ 'user:fullname', 'badge_issued:issued', - ]; - - $this->add_filters_from_entities($filters); + ]); } /** diff --git a/badges/edit.php b/badges/edit.php index a65fdd9b1283f..7bd2676796c13 100644 --- a/badges/edit.php +++ b/badges/edit.php @@ -60,6 +60,8 @@ } else { require_capability('moodle/badges:configuredetails', $context); } + + $cancelurl = new moodle_url('/badges/overview.php', ['id' => $badgeid]); } else { // New badge. if ($courseid) { @@ -79,6 +81,8 @@ // Check capabilities. require_capability('moodle/badges:createbadge', $context); + + $cancelurl = new moodle_url('/badges/index.php', ['type' => $badge->type, 'id' => $courseid]); } // Check if course badges are enabled. @@ -141,7 +145,7 @@ $form = new $formclass($currenturl, $params); if ($form->is_cancelled()) { - redirect(new moodle_url('/badges/overview.php', ['id' => $badgeid])); + redirect($cancelurl); } else if ($form->is_submitted() && $form->is_validated() && ($data = $form->get_data())) { switch ($action) { case 'new': diff --git a/badges/index.php b/badges/index.php index f6946207ad2ff..c3a3b77eac461 100644 --- a/badges/index.php +++ b/badges/index.php @@ -89,6 +89,8 @@ } $PAGE->set_title($hdr); + +/** @var core_badges_renderer $output */ $output = $PAGE->get_renderer('core', 'badges'); if ($delete || $archive) { @@ -159,9 +161,8 @@ } $report = system_report_factory::create(badges::class, $PAGE->context); -$report->set_default_no_results_notice(new lang_string('nobadges', 'badges')); - echo $report->output(); + $PAGE->requires->js_call_amd('core_badges/actions', 'init'); echo $OUTPUT->footer(); diff --git a/badges/recipients.php b/badges/recipients.php index 6a1120a19db97..12cac6eb3db6a 100644 --- a/badges/recipients.php +++ b/badges/recipients.php @@ -24,6 +24,9 @@ * @author Yuliya Bozhko */ +use core_badges\reportbuilder\local\systemreports\recipients; +use core_reportbuilder\system_report_factory; + require_once(__DIR__ . '/../config.php'); require_once($CFG->libdir . '/badgeslib.php'); @@ -63,6 +66,7 @@ $PAGE->set_title($badge->name); $PAGE->navbar->add($badge->name); +/** @var core_badges_renderer $output */ $output = $PAGE->get_renderer('core', 'badges'); echo $output->header(); @@ -73,9 +77,7 @@ echo $OUTPUT->heading(print_badge_image($badge, $context, 'small') . ' ' . $badge->name); echo $output->print_badge_status_box($badge); -$report = \core_reportbuilder\system_report_factory::create(\core_badges\reportbuilder\local\systemreports\recipients::class, - $PAGE->context, '', '', 0, ['badgeid' => $badge->id]); -$report->set_default_no_results_notice(new lang_string('noawards', 'badges')); +$report = system_report_factory::create(recipients::class, $PAGE->context, '', '', 0, ['badgeid' => $badge->id]); echo $report->output(); echo $output->footer(); diff --git a/badges/tests/behat/add_badge.feature b/badges/tests/behat/add_badge.feature index d5d8f0ec0258f..785b44c1180cc 100644 --- a/badges/tests/behat/add_badge.feature +++ b/badges/tests/behat/add_badge.feature @@ -17,7 +17,7 @@ Feature: Add badges to the system And I add the "Navigation" block if not present And I click on "Site pages" "list_item" in the "Navigation" "block" Given I click on "Site badges" "link" in the "Navigation" "block" - Then I should see "There are currently no badges available for users to earn." + Then I should see "There are no matching badges available for users to earn" @javascript @_file_upload Scenario: Add a site badge @@ -53,7 +53,7 @@ Feature: Add badges to the system And I should see "Math" And I should see "Physics" And I navigate to "Badges > Manage badges" in site administration - And I should not see "There are currently no badges available for users to earn." + And I should not see "There are no matching badges available for users to earn" @javascript @_file_upload Scenario: Add a badge related @@ -67,7 +67,6 @@ Feature: Add badges to the system | Image caption | Test caption image | And I upload "badges/tests/behat/badge.png" file to "Image" filemanager And I press "Create badge" - And I wait until the page is ready And I navigate to "Badges > Manage badges" in site administration And I press "Add a new badge" And I set the following fields to these values: @@ -83,7 +82,6 @@ Feature: Add badges to the system And I should see "This badge does not have any related badges." And I press "Add related badge" And I follow "Related badges" - And I wait until the page is ready And I follow "Related badges" And I set the field "relatedbadgeids[]" to "Test Badge 1 (version: v1, language: French, Site badges)" When I press "Save changes" @@ -165,7 +163,7 @@ Feature: Add badges to the system And I should see "Alignments (0)" And I should not see "Create badge" And I navigate to "Badges > Manage badges" in site administration - And I should not see "There are currently no badges available for users to earn." + And I should not see "There are no matching badges available for users to earn" # See buttons from the "Site badges" page. And I am on homepage When I click on "Site pages" "list_item" in the "Navigation" "block" @@ -220,3 +218,42 @@ Feature: Add badges to the system | badges_defaultissuername | Test Badge Site | And I navigate to "Badges > Add a new badge" in site administration And the field "Issuer name" matches value "Test Badge Site" + + Scenario: Cancel button behaviour when creating badges + Given the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + And the following "core_badges > Badge" exists: + | name | Site Badge 1 | + | description | Site badge 1 description | + | image | badges/tests/behat/badge.png | + | status | 0 | + | type | 1 | + And the following "core_badges > Badge" exists: + | name | Course Badge 1 | + | course | C1 | + | description | Course badge 1 description | + | image | badges/tests/behat/badge.png | + | status | 0 | + | type | 2 | + # Site badge: cancel when creating. + When I navigate to "Badges > Add a new badge" in site administration + And I click on "Cancel" "button" + Then I should see "Manage badges" + And I should see "Add a new badge" + # Site badge: cancel when editing. + And I press "Edit" action in the "Site Badge 1" report row + And I click on "Cancel" "button" + And I should see "Site badge 1" + And I should not see "Save changes" + # Course badge: cancel when creating. + And I am on the "Course 1" "course" page + And I navigate to "Badges > Manage badges > Add a new badge" in current page administration + And I click on "Cancel" "button" + Then I should see "Manage badges" + And I should see "Add a new badge" + # Course badge: cancel when editing. + And I press "Edit" action in the "Course Badge 1" report row + And I click on "Cancel" "button" + And I should see "Course badge 1" + And I should not see "Save changes" diff --git a/badges/tests/behat/award_badge.feature b/badges/tests/behat/award_badge.feature index 3a6a99a951109..d6c2c8498e159 100644 --- a/badges/tests/behat/award_badge.feature +++ b/badges/tests/behat/award_badge.feature @@ -90,7 +90,7 @@ Feature: Award badges And I add the "Navigation" block if not present And I click on "Site pages" "list_item" in the "Navigation" "block" And I click on "Site badges" "link" in the "Navigation" "block" - Then I should see "There are currently no badges available for users to earn." + Then I should see "There are no matching badges available for users to earn" And I should not see "Manage badges" And I should not see "Add a new badge" @@ -127,7 +127,6 @@ Feature: Award badges And I press "Update profile" And I follow "Profile" in the user menu Then I should see "Profile Badge" - And I should not see "There are currently no badges available for users to earn." @javascript Scenario: Award site badge diff --git a/badges/tests/behat/delete_awarded_badge.feature b/badges/tests/behat/delete_awarded_badge.feature index 39ae200b387de..9d4b7c31a903a 100644 --- a/badges/tests/behat/delete_awarded_badge.feature +++ b/badges/tests/behat/delete_awarded_badge.feature @@ -44,8 +44,7 @@ Feature: Delete course badge already awarded # Navigate to Badges page to confirm that no badges exist, hence, Manage badges would not exist And I navigate to "Badges" in current page administration # Confirm that badges are sucessfully deleted - And I should see "There are currently no badges available for users to earn." - + And I should see "There are no matching badges available for users to earn" Examples: | badgename | deleteoption | visibility | | Badge 1 | Delete and keep existing issued badges | should | diff --git a/badges/tests/behat/manage_badges.feature b/badges/tests/behat/manage_badges.feature index 21bf0d38b0a1e..e6c4f00be5187 100644 --- a/badges/tests/behat/manage_badges.feature +++ b/badges/tests/behat/manage_badges.feature @@ -45,7 +45,7 @@ Feature: Manage badges And I navigate to "Badges > Manage badges" in site administration And I press "Delete" action in the "Badge #1" report row And I press "Delete and remove existing issued badges" - Then I should see "There are currently no badges available for users to earn" + Then I should see "There are no matching badges available for users to earn" Scenario Outline: Filter managed badges Given the following "core_badges > Badges" exist: diff --git a/badges/tests/behat/nobadge_navigation.feature b/badges/tests/behat/nobadge_navigation.feature index 119e2d9298ce2..bb22d9b2a5f1e 100644 --- a/badges/tests/behat/nobadge_navigation.feature +++ b/badges/tests/behat/nobadge_navigation.feature @@ -4,7 +4,7 @@ Feature: Manage badges is not shown when there are no existing badges. Scenario: Check navigation at site level with no badges Given I log in as "admin" When I navigate to "Badges > Manage badges" in site administration - And I should see "There are currently no badges available for users to earn" + And I should see "There are no matching badges available for users to earn" Then "Manage badges" "button" should not exist Scenario: Check navigation at course level with no badges @@ -99,7 +99,7 @@ Feature: Manage badges is not shown when there are no existing badges. And I follow "Badges" And "Manage badges" "button" should not exist And "Add a new badge" "button" should not exist - And I should not see "There are currently no badges available for users to earn." - And the following should exist in the "reportbuilder-table" table: + And I should not see "There are no matching badges available for users to earn" + And the following should exist in the "Course badges" table: | Name | Description | Criteria | | Testing course badge | Testing course badge description | Awarded by: Teacher | diff --git a/badges/view.php b/badges/view.php index ff734c9eaf7ab..9ce804abdcfee 100644 --- a/badges/view.php +++ b/badges/view.php @@ -24,6 +24,9 @@ * @author Yuliya Bozhko */ +use core_badges\reportbuilder\local\systemreports\course_badges; +use core_reportbuilder\system_report_factory; + require_once(__DIR__ . '/../config.php'); require_once($CFG->libdir . '/badgeslib.php'); @@ -67,6 +70,8 @@ require_capability('moodle/badges:viewbadges', $PAGE->context); $PAGE->set_title($title); + +/** @var core_badges_renderer $output */ $output = $PAGE->get_renderer('core', 'badges'); // Display "Manage badges" button to users with proper capabilities. @@ -98,9 +103,8 @@ echo $OUTPUT->box(get_string('error:notifycoursedate', 'badges'), 'generalbox notifyproblem'); } -$report = \core_reportbuilder\system_report_factory::create(\core_badges\reportbuilder\local\systemreports\course_badges::class, - $PAGE->context, '', '', 0, ['type' => $type, 'courseid' => $courseid]); -$report->set_default_no_results_notice(new lang_string('nobadges', 'badges')); +$report = system_report_factory::create(course_badges::class, $PAGE->context, '', '', 0, + ['type' => $type, 'courseid' => $courseid]); echo $report->output(); // Trigger event, badge listing viewed. diff --git a/blocks/myoverview/classes/output/main.php b/blocks/myoverview/classes/output/main.php index abe98d9e20910..c7e2ad85730e7 100644 --- a/blocks/myoverview/classes/output/main.php +++ b/blocks/myoverview/classes/output/main.php @@ -503,83 +503,70 @@ public function export_for_zero_state_template(renderer_base $output) { $nocoursesimg = $output->image_url('courses', 'block_myoverview'); + $buttons = []; $coursecat = \core_course_category::user_top(); if ($coursecat) { + // Request a course button. $category = \core_course_category::get_nearest_editable_subcategory($coursecat, ['moodle/course:request']); if ($category && $category->can_request_course()) { - // Add Request a course button. - $button = new \single_button( + $requestbutton = new \single_button( new \moodle_url('/course/request.php', ['category' => $category->id]), get_string('requestcourse'), 'post', \single_button::BUTTON_PRIMARY ); + $buttons[] = $requestbutton->export_for_template($output); return $this->generate_zero_state_data( $nocoursesimg, - [$button->export_for_template($output)], - ['title' => 'zero_request_title', 'intro' => 'zero_request_intro'] + $buttons, + [ + 'title' => 'zero_request_title', + 'intro' => ($CFG->coursecreationguide ? 'zero_request_intro' : 'zero_nocourses_intro'), + ], ); } $totalcourses = $DB->count_records_select('course', 'category > 0'); - if (!$totalcourses && ($category = \core_course_category::get_nearest_editable_subcategory($coursecat, ['create']))) { - // Add Quickstart guide and Create course buttons. - $quickstarturl = $CFG->coursecreationguide; - if ($quickstarturl) { - $quickstartbutton = new \single_button( - new \moodle_url($quickstarturl, ['lang' => current_language()]), - get_string('viewquickstart', 'block_myoverview'), - 'get', + if ($coursecat) { + // Manage courses or categories button. + $managebuttonname = get_string('managecategories'); + if ($totalcourses) { + $managebuttonname = get_string('managecourses'); + } + if ($categorytomanage = \core_course_category::get_nearest_editable_subcategory($coursecat, ['manage'])) { + $managebutton = new \single_button( + new \moodle_url('/course/management.php', ['category' => $categorytomanage->id]), + $managebuttonname, ); - $buttons = [$quickstartbutton->export_for_template($output)]; + $buttons[] = $managebutton->export_for_template($output); } + } + // Create course button. + if ($category = \core_course_category::get_nearest_editable_subcategory($coursecat, ['create'])) { $createbutton = new \single_button( new \moodle_url('/course/edit.php', ['category' => $category->id]), get_string('createcourse', 'block_myoverview'), 'post', - \single_button::BUTTON_PRIMARY + \single_button::BUTTON_PRIMARY, ); $buttons[] = $createbutton->export_for_template($output); - return $this->generate_zero_state_data( - $nocoursesimg, - $buttons, - ['title' => 'zero_nocourses_title', 'intro' => 'zero_nocourses_intro'] - ); - } - if ($categorytocreate = \core_course_category::get_nearest_editable_subcategory($coursecat, ['create'])) { - $createbutton = new \single_button( - new \moodle_url('/course/edit.php', ['category' => $categorytocreate->id]), - get_string('createcourse', 'block_myoverview'), - 'post', - \single_button::BUTTON_PRIMARY - ); - $buttons = [$createbutton->export_for_template($output)]; - if ($categorytomanage = \core_course_category::get_nearest_editable_subcategory($coursecat, ['manage'])) { - // Add a Manage course button. - $managebutton = new \single_button( - new \moodle_url('/course/management.php', ['category' => $categorytomanage->id]), - get_string('managecourses') - ); - $buttons[] = $managebutton->export_for_template($output); - return $this->generate_zero_state_data( - $nocoursesimg, - array_reverse($buttons), - ['title' => 'zero_default_title', 'intro' => 'zero_default_intro'] - ); - } + $title = $totalcourses ? 'zero_default_title' : 'zero_nocourses_title'; + $intro = $totalcourses ? 'zero_default_intro' : + ($CFG->coursecreationguide ? 'zero_request_intro' : 'zero_nocourses_intro'); return $this->generate_zero_state_data( $nocoursesimg, $buttons, - ['title' => 'zero_default_title', 'intro' => 'zero_default_intro'] + ['title' => $title, 'intro' => $intro], ); } + } return $this->generate_zero_state_data( $nocoursesimg, - [], + $buttons, ['title' => 'zero_default_title', 'intro' => 'zero_default_intro'] ); } @@ -596,15 +583,20 @@ private function generate_zero_state_data(\moodle_url $imageurl, array $buttons, global $CFG; // Documentation data. $dochref = new \moodle_url($CFG->docroot, ['lang' => current_language()]); - $quickstart = new \moodle_url($CFG->coursecreationguide, ['lang' => current_language()]); $docparams = [ - 'quickhref' => $quickstart->out(), - 'quicktitle' => get_string('viewquickstart', 'block_myoverview'), - 'quicktarget' => '_blank', 'dochref' => $dochref->out(), 'doctitle' => get_string('documentation'), 'doctarget' => $CFG->doctonewwindow ? '_blank' : '_self', ]; + if ($CFG->coursecreationguide) { + // Add quickstart guide link. + $quickstart = new \moodle_url($CFG->coursecreationguide, ['lang' => current_language()]); + $docparams = [ + 'quickhref' => $quickstart->out(), + 'quicktitle' => get_string('viewquickstart', 'block_myoverview'), + 'quicktarget' => '_blank', + ]; + } return [ 'nocoursesimg' => $imageurl->out(), 'title' => ($strings['title']) ? get_string($strings['title'], 'block_myoverview') : '', diff --git a/blocks/myoverview/lang/en/block_myoverview.php b/blocks/myoverview/lang/en/block_myoverview.php index 08be768497b6b..65162442526ae 100644 --- a/blocks/myoverview/lang/en/block_myoverview.php +++ b/blocks/myoverview/lang/en/block_myoverview.php @@ -90,7 +90,7 @@ $string['viewquickstart'] = 'View Quickstart guide'; $string['zero_default_title'] = 'You\'re not enrolled in any course'; $string['zero_default_intro'] = 'Once you\'re enrolled in a course, it will appear here.'; +$string['zero_nocourses_intro'] = 'Need help getting started? Check out the Moodle documentation.'; +$string['zero_nocourses_title'] = 'Create your first course'; $string['zero_request_title'] = 'Request your first course'; $string['zero_request_intro'] = 'Need help getting started? Check out the Moodle documentation or take your first steps with our Quickstart guide.'; -$string['zero_nocourses_title'] = 'Create your first course'; -$string['zero_nocourses_intro'] = 'Need help getting started? Check out the Moodle documentation or take your first steps with our Quickstart guide.'; diff --git a/blocks/myoverview/tests/behat/block_myoverview_zerostate.feature b/blocks/myoverview/tests/behat/block_myoverview_zerostate.feature index 79cece88b26fc..3991a0ead22dd 100644 --- a/blocks/myoverview/tests/behat/block_myoverview_zerostate.feature +++ b/blocks/myoverview/tests/behat/block_myoverview_zerostate.feature @@ -1,4 +1,4 @@ -@block @block_myoverview @javascript +@block @block_myoverview Feature: Zero state on my overview block In order to know what should be the next step As a user @@ -31,15 +31,29 @@ Feature: Zero state on my overview block And "Request a course" "button" should exist And I click on "Request a course" "button" And I should see "Details of the course" + # Quickstart guide link should not be displayed when $CFG->coursecreationguide is empty. + But the following config values are set as admin: + | coursecreationguide | | + And I am on the "My courses" page + And "Moodle documentation" "link" should exist + And "Quickstart guide" "link" should not exist Scenario: Users with permissions to create a course when there is no course created Given I am on the "My courses" page logged in as "manager" When I should see "Create your first course" Then "Moodle documentation" "link" should exist - And "View Quickstart guide" "button" should exist + And "Quickstart guide" "link" should exist + And "Manage courses" "button" should not exist + And "Manage course categories" "button" should exist And "Create course" "button" should exist And I click on "Create course" "button" And I should see "Add a new course" + # Quickstart guide link should not be displayed when $CFG->coursecreationguide is empty. + But the following config values are set as admin: + | coursecreationguide | | + And I am on the "My courses" page + And "Moodle documentation" "link" should exist + And "Quickstart guide" "link" should not exist Scenario: Users with permissions to create a course but is not enrolled in any existing course Given the following "course" exists: @@ -71,7 +85,7 @@ Feature: Zero state on my overview block And I click on "Create course" "button" And I should see "Add a new course" - @accessibility + @javascript @accessibility Scenario: Evaluate the accessibility of the My courses (zero state) When I am on the "My courses" page logged in as "manager" Then the page should meet accessibility standards diff --git a/cache/UPGRADING.md b/cache/UPGRADING.md index 70a103dc31800..b91ecf9e3b207 100644 --- a/cache/UPGRADING.md +++ b/cache/UPGRADING.md @@ -41,4 +41,3 @@ | `\cache_mode_mappings_form` | `\core_cache\form/cache_mode_mappings_form` | For more information see [MDL-82158](https://tracker.moodle.org/browse/MDL-82158) - diff --git a/cache/stores/redis/lib.php b/cache/stores/redis/lib.php index 0c96afe220d33..28e5a8833e602 100644 --- a/cache/stores/redis/lib.php +++ b/cache/stores/redis/lib.php @@ -604,7 +604,8 @@ public function has_all(array $keys) { * @return bool True if the lock was acquired, false if it was not. */ public function acquire_lock($key, $ownerid) { - $timelimit = time() + $this->lockwait; + $clock = \core\di::get(\core\clock::class); + $timelimit = $clock->time() + $this->lockwait; do { // If the key doesn't already exist, grab it and return true. if ($this->redis->setnx($key, $ownerid)) { @@ -620,7 +621,7 @@ public function acquire_lock($key, $ownerid) { } // Wait 1 second then retry. sleep(1); - } while (time() < $timelimit); + } while ($clock->time() < $timelimit); return false; } diff --git a/completion/tests/behat/activity_completion_criteria.feature b/completion/tests/behat/activity_completion_criteria.feature index 416dfb715658e..cad0dbd05d994 100644 --- a/completion/tests/behat/activity_completion_criteria.feature +++ b/completion/tests/behat/activity_completion_criteria.feature @@ -52,8 +52,7 @@ Feature: Allow to mark course as completed without cron for activity completion @javascript Scenario: Update course completion when teacher grades a single assignment Given I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "student1@example.com" "table_row" + And I go to "student1@example.com" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "40" And I click on "Save changes" "button" And I am on "Completion course" course homepage @@ -79,15 +78,13 @@ Feature: Allow to mark course as completed without cron for activity completion And I set the field "Assignment - Test assignment name2" to "1" And I press "Save changes" And I am on the "Test assignment name" "assign activity" page - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "student1@example.com" "table_row" + And I go to "student1@example.com" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "40" And I click on "Save changes" "button" And I am on the "Completion course" course page logged in as student1 And I should see "Status: In progress" And I am on the "Test assignment name2" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "student1@example.com" "table_row" + And I go to "student1@example.com" "Test assignment name2" activity advanced grading page And I set the field "Grade out of 100" to "40" And I click on "Save changes" "button" When I am on the "Completion course" course page logged in as student1 diff --git a/completion/tests/behat/restrict_activity_by_grade.feature b/completion/tests/behat/restrict_activity_by_grade.feature index 437f863d3ec09..8628a4077a496 100644 --- a/completion/tests/behat/restrict_activity_by_grade.feature +++ b/completion/tests/behat/restrict_activity_by_grade.feature @@ -44,9 +44,8 @@ Feature: Restrict activity availability through grade conditions And I should see "Submitted for grading" And I am on the "Grade assignment" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student First" "table_row" + And I go to "Student First" "Grade assignment" activity advanced grading page And I change window size to "medium" And I set the following fields to these values: | Grade | 21 | diff --git a/completion/tests/behat/restrict_section_availability.feature b/completion/tests/behat/restrict_section_availability.feature index 5ebca538c1a5a..ccea4200b881b 100644 --- a/completion/tests/behat/restrict_section_availability.feature +++ b/completion/tests/behat/restrict_section_availability.feature @@ -67,9 +67,8 @@ Feature: Restrict sections availability through completion or grade conditions And I should see "Submitted for grading" And I log out And I am on the "Grade assignment" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student First" "table_row" + And I go to "Student First" "Grade assignment" activity advanced grading page And I change window size to "medium" And I set the following fields to these values: | Grade | 21 | diff --git a/enrol/lti/classes/local/ltiadvantage/entity/nrps_info.php b/enrol/lti/classes/local/ltiadvantage/entity/nrps_info.php index d267f66fd7e9a..632959005e296 100644 --- a/enrol/lti/classes/local/ltiadvantage/entity/nrps_info.php +++ b/enrol/lti/classes/local/ltiadvantage/entity/nrps_info.php @@ -108,7 +108,7 @@ private function set_service_versions(array $serviceversions): void { * @return \moodle_url the service URL. */ public function get_context_memberships_url(): \moodle_url { - return $this->contextmembershipsurl; + return clone $this->contextmembershipsurl; } /** diff --git a/enrol/lti/tests/local/ltiadvantage/entity/nrps_info_test.php b/enrol/lti/tests/local/ltiadvantage/entity/nrps_info_test.php index cc6c58ea97433..df0b436a5ac26 100644 --- a/enrol/lti/tests/local/ltiadvantage/entity/nrps_info_test.php +++ b/enrol/lti/tests/local/ltiadvantage/entity/nrps_info_test.php @@ -98,4 +98,21 @@ public function instantiation_data_provider(): array { ] ]; } + + /** + * Verify that the contextmembershipurl property can be gotten and is immutable. + * + * @covers ::get_context_memberships_url + */ + public function test_get_context_memberships_url(): void { + $nrpsendpoint = 'https://lms.example.com/45/memberships'; + $nrpsinfo = nrps_info::create(new \moodle_url($nrpsendpoint)); + $membershipsurlcopy = $nrpsinfo->get_context_memberships_url(); + $this->assertEquals($nrpsendpoint, $membershipsurlcopy->out(false)); + $rlid = '01234567-1234-5678-90ab-123456789abc'; + $membershipsurlcopy->param('rlid', $rlid); + $this->assertEquals($nrpsendpoint . '?rlid=' . $rlid, $membershipsurlcopy->out(false)); + $this->assertEquals($nrpsendpoint, $nrpsinfo->get_context_memberships_url()->out(false)); + } + } diff --git a/files/UPGRADING.md b/files/UPGRADING.md new file mode 100644 index 0000000000000..e850905b53055 --- /dev/null +++ b/files/UPGRADING.md @@ -0,0 +1,17 @@ +# core_files (subsystem) Upgrade notes + +## 4.5dev+ + +### Added + +- The following are the changes made: + - New hook after_file_created + - In the \core_files\file_storage, new additional param $notify (default is true) added to: + - ::create_file_from_storedfile() + - ::create_file_from_pathname() + - ::create_file_from_string() + - ::create_file() + If true, it will trigger the after_file_created hook to re-create the image. + + For more information see [MDL-75850](https://tracker.moodle.org/browse/MDL-75850) + diff --git a/grade/grading/tests/behat/behat_grading.php b/grade/grading/tests/behat/behat_grading.php index 18f32849eff2e..48c88c60df11a 100644 --- a/grade/grading/tests/behat/behat_grading.php +++ b/grade/grading/tests/behat/behat_grading.php @@ -87,19 +87,19 @@ public function i_go_to_advanced_grading_definition_page($activityname) { public function i_go_to_activity_advanced_grading_page($userfullname, $activityname) { // Step to access the user grade page from the grading page. - $gradetext = get_string('gradeverb'); - $this->execute('behat_navigation::go_to_breadcrumb_location', $this->escape($activityname)); $this->execute('behat_general::click_link', get_string('gradeitem:submissions', 'mod_assign')); $this->execute('behat_general::i_click_on_in_the', array( - $this->escape($gradetext), - 'link', + $this->escape(get_string('gradeactions', 'assign')), + 'actionmenu', $this->escape($userfullname), 'table_row' )); + + $this->execute('behat_action_menu::i_choose_in_the_open_action_menu', get_string('gradeverb')); } /** diff --git a/grade/report/singleview/tests/behat/bulk_insert_grades.feature b/grade/report/singleview/tests/behat/bulk_insert_grades.feature index 854aedc136807..7ef0d60bed904 100644 --- a/grade/report/singleview/tests/behat/bulk_insert_grades.feature +++ b/grade/report/singleview/tests/behat/bulk_insert_grades.feature @@ -54,8 +54,7 @@ Feature: We can bulk insert grades for students in a course Scenario: I can bulk insert grades and check their override flags for grade view. Given I am on the "Test assignment one" "assign activity" page - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment one" activity advanced grading page And I set the following fields to these values: | Grade out of 100 | 50 | And I press "Save changes" @@ -97,8 +96,7 @@ Feature: We can bulk insert grades for students in a course Scenario: I can bulk insert grades and check their override flags for user view. Given I am on the "Test assignment two" "assign activity" page - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment two" activity advanced grading page And I set the following fields to these values: | Grade out of 100 | 50 | And I press "Save changes" diff --git a/grade/tests/behat/grade_feedback.feature b/grade/tests/behat/grade_feedback.feature index 9903d1c28bed2..0cab7de6879f9 100644 --- a/grade/tests/behat/grade_feedback.feature +++ b/grade/tests/behat/grade_feedback.feature @@ -33,8 +33,7 @@ Feature: Display feedback on the Grader report | gradeitem | user | grade | feedback | | Grade item 1 | student1 | | Longer feedback text content | And I am on the "Test assignment name 1" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name 1" activity advanced grading page And I set the following fields to these values: | Grade out of 100 | 50 | | Feedback comments | This is feedback | diff --git a/grade/tests/behat/grade_item_form_unhide.feature b/grade/tests/behat/grade_item_form_unhide.feature index 688de85a53204..e7d366b4d23ca 100644 --- a/grade/tests/behat/grade_item_form_unhide.feature +++ b/grade/tests/behat/grade_item_form_unhide.feature @@ -20,9 +20,8 @@ Feature: Teacher can unhide grades on the edit page allowing students to view th | activity | course | idnumber | name | intro | assignfeedback_comments_enabled | | assign | C1 | assign1 | Test assignment name | Test assignment description | 1 | And I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the following fields to these values: | Grade out of 100 | 50 | | Feedback comments | I'm the teacher feedback | diff --git a/grade/tests/behat/grade_regrade_do_not_override.feature b/grade/tests/behat/grade_regrade_do_not_override.feature index 34bfcffb3a2a9..c4489132c664c 100644 --- a/grade/tests/behat/grade_regrade_do_not_override.feature +++ b/grade/tests/behat/grade_regrade_do_not_override.feature @@ -24,8 +24,7 @@ Feature: Regrading grades does not unnecessarily mark some as overriden And I set the field "Available aggregation types" to "Weighted mean of grades" And I press "Save changes" And I am on the "Assignment 1" "assign activity" page - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Assignment 1" activity advanced grading page And I set the field "Grade out of 100" to "80" And I press "Save and show next" And I set the field "Grade out of 100" to "60" @@ -55,8 +54,7 @@ Feature: Regrading grades does not unnecessarily mark some as overriden @javascript Scenario: Confirm overridden course total does not get regraded when activity grade is changed Given I am on the "Assignment 1" "assign activity" page - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Assignment 1" activity advanced grading page And I set the field "Grade out of 100" to "90" And I press "Save and show next" And I set the field "Grade out of 100" to "70" diff --git a/grade/tests/behat/grade_scales.feature b/grade/tests/behat/grade_scales.feature index 1bbe9f8cdc97b..5cfbb13020760 100644 --- a/grade/tests/behat/grade_scales.feature +++ b/grade/tests/behat/grade_scales.feature @@ -46,8 +46,7 @@ Feature: View gradebook when scales are used And I set the field "grade[modgrade_type]" to "Scale" And I set the field "grade[modgrade_scale]" to "Letterscale" And I press "Save and display" - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment one" activity advanced grading page And I set the field "Grade" to "A" And I press "Save changes" And I click on "[data-action=next-user]" "css_element" diff --git a/grade/tests/behat/grade_single_item_scales.feature b/grade/tests/behat/grade_single_item_scales.feature index 9f9e271253f7c..d16651e517842 100644 --- a/grade/tests/behat/grade_single_item_scales.feature +++ b/grade/tests/behat/grade_single_item_scales.feature @@ -44,8 +44,7 @@ Feature: View gradebook when single item scales are used And I set the field "grade[modgrade_type]" to "Scale" And I set the field "grade[modgrade_scale]" to "EN Singleitem" And I press "Save and display" - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment one" activity advanced grading page And I set the field "Grade" to "A" And I press "Save changes" When I am on the "Course 1" "grades > course grade settings" page diff --git a/lang/en/badges.php b/lang/en/badges.php index f1c90b58d2d16..d0bfe834c86af 100644 --- a/lang/en/badges.php +++ b/lang/en/badges.php @@ -424,11 +424,13 @@ $string['newimage'] = 'New image'; $string['noalignment'] = 'This badge does not have any external skills or standards specified.'; $string['noawards'] = 'This badge has not been earned yet.'; +$string['nomatchingawards'] = 'There are no matching recipients who have earned this badge yet'; $string['nobackpack'] = 'There is no backpack service connected to this account.
'; $string['nobackpackbadgessummary'] = 'There are no badges in the collections you have selected.'; $string['nobackpackcollectionssummary'] = 'No badge collections have been selected.'; $string['nobackpacks'] = 'There are no backpacks available'; $string['nobadges'] = 'There are currently no badges available for users to earn.'; +$string['nomatchingbadges'] = 'There are no matching badges available for users to earn'; $string['nocompetencies'] = 'No competencies selected.'; $string['nocriteria'] = 'Criteria for this badge have not been set up yet.'; $string['noendorsement'] = 'This badge does not have an endorsement.'; diff --git a/lang/en/deprecated.txt b/lang/en/deprecated.txt index 0c838f1cd6430..f8121dbed38b0 100644 --- a/lang/en/deprecated.txt +++ b/lang/en/deprecated.txt @@ -129,3 +129,4 @@ newpasswordtext,core cannotsetpassword,core_error registerwithmoodleorginfoapp,core_hub registration_help,core_admin +coursemanagementoptions,core_my diff --git a/lang/en/files.php b/lang/en/files.php index 8cc465821f786..57b564773db3b 100644 --- a/lang/en/files.php +++ b/lang/en/files.php @@ -28,6 +28,29 @@ $string['contenthash'] = 'Content hash'; $string['eventfileaddedtodraftarea'] = 'File added to draft area'; $string['eventfiledeletedfromdraftarea'] = 'File deleted from draft area'; +$string['fileredact'] = 'File redact'; +$string['fileredact:exifremover'] = 'EXIF remover'; +$string['fileredact:exifremover:emptyremovetags'] = 'Remove tags can not be empty!'; +$string['fileredact:exifremover:enabled'] = 'Enable EXIF remover'; +$string['fileredact:exifremover:enabled_desc'] = 'By default, EXIF Remover only supports JPG files using PHP GD, or ExifTool if it is configured. +This degrades the quality of the image and removes the orientation tag. + +To enhance the performance of EXIF Remover, please configure the ExifTool settings below. + +More information about installing ExifTool can be found at {$a->link}'; +$string['fileredact:exifremover:failedprocessexiftool'] = 'Redaction failed: failed to process file with ExifTool!'; +$string['fileredact:exifremover:failedprocessgd'] = 'Redaction failed: failed to process file with PHP gd!'; +$string['fileredact:exifremover:heading'] = 'ExifTool'; +$string['fileredact:exifremover:mimetype'] = 'Supported MIME types'; +$string['fileredact:exifremover:mimetype_desc'] = 'To add new MIME types, ensure they\'re included in the File Types.'; +$string['fileredact:exifremover:removetags'] = 'The EXIF tags that will be removed.'; +$string['fileredact:exifremover:removetags_desc'] = 'The EXIF tags that need to be removed.'; +$string['fileredact:exifremover:tag:all'] = 'All'; +$string['fileredact:exifremover:tag:gps'] = 'GPS only'; +$string['fileredact:exifremover:tooldoesnotexist'] = 'Redaction failed: ExifTool does not exist!'; +$string['fileredact:exifremover:toolpath'] = 'Path to ExifTool'; +$string['fileredact:exifremover:toolpath_desc'] = 'To use the ExifTool, please provide the path to the ExifTool executable. +Typically, on Unix/Linux systems, the path is /usr/bin/exiftool.'; $string['privacy:metadata:file_conversions'] = 'A record of the file conversions performed by a user.'; $string['privacy:metadata:file_conversion:usermodified'] = 'The user who started the file conversion.'; $string['privacy:metadata:files'] = 'A record of the files uploaded or shared by users'; diff --git a/lang/en/my.php b/lang/en/my.php index 519f203993aae..5fd569d2183b7 100644 --- a/lang/en/my.php +++ b/lang/en/my.php @@ -22,7 +22,6 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -$string['coursemanagementoptions'] = 'Course management options'; $string['error:dashboardisdisabled'] = 'The Dashboard has been disabled by an administrator.'; $string['mymoodle'] = 'Dashboard'; $string['nocourses'] = 'No course information to show.'; @@ -42,3 +41,6 @@ $string['resetpage'] = 'Reset page to default'; $string['reseterror'] = 'There was an error resetting your page'; $string['privacy:metadata:core_my:preference:user_home_page_preference'] = 'The user home page preference.'; + +// Deprecated since Moodle 4.5. +$string['coursemanagementoptions'] = 'Course management options'; diff --git a/lib/behat/classes/behat_session_trait.php b/lib/behat/classes/behat_session_trait.php index 7ec03499b403e..830b7b4645430 100644 --- a/lib/behat/classes/behat_session_trait.php +++ b/lib/behat/classes/behat_session_trait.php @@ -1163,11 +1163,11 @@ protected function get_session_user() { if (empty($sid)) { throw new coding_exception('failed to get moodle session'); } - $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]); - if (empty($userid)) { - throw new coding_exception('failed to get user from seession id '.$sid); + $session = \core\session\manager::get_session_by_sid($sid); + if (empty($session->userid)) { + throw new coding_exception('failed to get user from session id: '.$sid); } - return $DB->get_record('user', ['id' => $userid]); + return $DB->get_record('user', ['id' => $session->userid]); } /** diff --git a/lib/classes/component.php b/lib/classes/component.php index f7f31a5a8c79f..1346e2f160c0d 100644 --- a/lib/classes/component.php +++ b/lib/classes/component.php @@ -102,6 +102,7 @@ class component { ]; /** @var array> associative array of PRS-4 namespaces and corresponding paths. */ protected static $psr4namespaces = [ + \Html2Text::class => 'lib/html2text/src', \MaxMind::class => 'lib/maxmind/MaxMind', \GeoIp2::class => 'lib/maxmind/GeoIp2', \Sabberworm\CSS::class => 'lib/php-css-parser', diff --git a/lib/classes/fileredact/hook_listener.php b/lib/classes/fileredact/hook_listener.php new file mode 100644 index 0000000000000..cdb340d77f37c --- /dev/null +++ b/lib/classes/fileredact/hook_listener.php @@ -0,0 +1,50 @@ +. + +namespace core\fileredact; + +use core\hook\filestorage\after_file_created; + +/** + * Allow the plugin to call as soon as possible before the file is created. + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_listener { + /** + * Execute the available services after creating the file. + * + * @param after_file_created $hook + */ + public static function redact_after_file_created(after_file_created $hook): void { + $storedfile = $hook->storedfile; + + // The file mime-type must be present. Otherwise, bypass the process. + if (empty($storedfile->get_mimetype())) { + return; + } + + $manager = new manager($storedfile); + $manager->execute(); + + // Iterates through the errors returned by the manager and outputs each error message. + foreach ($manager->get_errors() as $e) { + debugging($e->getMessage()); + } + } +} diff --git a/lib/classes/fileredact/manager.php b/lib/classes/fileredact/manager.php new file mode 100644 index 0000000000000..6b27a9f0b1d63 --- /dev/null +++ b/lib/classes/fileredact/manager.php @@ -0,0 +1,93 @@ +. + +namespace core\fileredact; + +use stored_file; + +/** + * Fileredact manager. + * + * Manages and executes redaction services. + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager { + + /** @var array Holds an array of error messages. */ + private $errors = []; + + /** + * Constructor. + * + * @param stored_file $filerecord The file record as a stdClass object, or null if not available. + */ + public function __construct( + /** @var stored_file $filerecord File record. */ + private readonly stored_file $filerecord + ) { + } + + /** + * Executes redaction services. + */ + public function execute(): void { + // Get the file redact services. + $services = $this->get_services(); + foreach ($services as $serviceclass) { + try { + if (class_exists($serviceclass)) { + $service = new $serviceclass($this->filerecord); + // For the given service, execute them if they are enabled, and the given mime type is supported. + if ($service->is_enabled() && $service->is_mimetype_supported($this->filerecord->get_mimetype())) { + $service->execute(); + } + } + } catch (\Throwable $e) { + $this->errors[] = $e; + } + } + } + + /** + * Returns a list of applicable redaction services. + * + * @return string[] return list of services. + */ + protected function get_services(): array { + global $CFG; + $servicesdir = "{$CFG->libdir}/classes/fileredact/services/"; + $servicefiles = glob("{$servicesdir}*_service.php"); + $services = []; + foreach ($servicefiles as $servicefile) { + $servicename = basename($servicefile, '_service.php'); + $serviceclass = "\\core\\fileredact\\services\\{$servicename}_service"; + $services[] = $serviceclass; + } + return $services; + } + + /** + * Retrieves an array of error messages. + * + * @return array An array of error messages. + */ + public function get_errors(): array { + return $this->errors; + } +} diff --git a/lib/classes/fileredact/services/exifremover_service.php b/lib/classes/fileredact/services/exifremover_service.php new file mode 100644 index 0000000000000..81c342cb33e81 --- /dev/null +++ b/lib/classes/fileredact/services/exifremover_service.php @@ -0,0 +1,372 @@ +. + +namespace core\fileredact\services; + +use stored_file; + +/** + * Remove EXIF data from supported image files using PHP GD, or ExifTool if it is configured. + * + * The PHP GD stripping has minimal configuration and removes all EXIF data. + * More stripping is made available when using ExifTool. + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class exifremover_service extends service { + + /** @var array REMOVE_TAGS Tags to remove and their corresponding values. */ + const REMOVE_TAGS = [ + "gps" => '"-gps*="', + "all" => "-all=", + ]; + + /** @var string DEFAULT_REMOVE_TAGS Default tags that will be removed. */ + const DEFAULT_REMOVE_TAGS = "gps"; + + /** @var string DEFAULT_MIMETYPE Default MIME type for images. */ + const DEFAULT_MIMETYPE = "image/jpeg"; + + /** + * PRESERVE_TAGS Tag to preserve when stripping EXIF data. + * + * To add a new tag, add the tag with space as a separator. + * For example, if the model tag is preserved, then the value is "-Orientation -Model". + * + * @var string + */ + const PRESERVE_TAGS = "-Orientation"; + + /** @var int DEFAULT_JPEG_COMPRESSION Default JPEG compression quality. */ + const DEFAULT_JPEG_COMPRESSION = 90; + + /** @var bool $useexiftool Flag indicating whether to use ExifTool. */ + private bool $useexiftool = false; + + /** + * Class constructor. + * + * @param stored_file $storedfile The file record. + */ + public function __construct( + /** @var stored_file The file record. */ + private readonly stored_file $storedfile, + ) { + parent::__construct($storedfile); + + // To decide whether to use ExifTool or PHP GD, check the ExifTool path. + if (!empty($this->get_exiftool_path())) { + $this->useexiftool = true; + } + } + + /** + * Performs redaction on the specified file. + */ + public function execute(): void { + if ($this->useexiftool) { + // Use the ExifTool executable to remove the desired EXIF tags. + $this->execute_exiftool(); + } else { + // Use PHP GD lib to remove all EXIF tags. + $this->execute_gd(); + } + } + + /** + * Executes ExifTool to remove metadata from the original file. + * + * @throws \moodle_exception If the ExifTool process fails or the destination file is not created. + */ + private function execute_exiftool(): void { + $tmpfilepath = make_request_directory(); + $filerecordname = $this->clean_filename($this->storedfile->get_filename()); + $neworiginalfile = $tmpfilepath . DIRECTORY_SEPARATOR . 'new_' . $filerecordname; + $destinationfile = $tmpfilepath . DIRECTORY_SEPARATOR . $filerecordname; + + // Copy the original file to a new file. + try { + $this->storedfile->copy_content_to($neworiginalfile); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + + // Prepare the ExifTool command. + $command = $this->get_exiftool_command($neworiginalfile, $destinationfile); + // Run the command. + exec($command, $output, $resultcode); + // If the return code was not zero or the destination file was not successfully created. + if ($resultcode !== 0 || !file_exists($destinationfile)) { + throw new \moodle_exception( + errorcode: 'fileredact:exifremover:failedprocessexiftool', + module: 'core_files', + a: get_class($this), + debuginfo: implode($output), + ); + } + // Replacing the EXIF processed file to the original file. + $this->persist_redacted_file(file_get_contents($destinationfile)); + } + + /** + * Executes GD library to remove metadata from the original file. + */ + private function execute_gd(): void { + $imagedata = $this->recreate_image_gd(); + if (!$imagedata) { + throw new \moodle_exception( + errorcode: 'fileredact:exifremover:failedprocessgd', + module: 'core_files', + a: get_class($this), + ); + } + // Put the image string object data to the original file. + $this->persist_redacted_file($imagedata); + } + /** + * Gets the ExifTool command to strip the file of EXIF data. + * + * @param string $source The source path of the file. + * @param string $destination The destination path of the file. + * @return string The command to use to remove EXIF data from the file. + */ + private function get_exiftool_command(string $source, string $destination): string { + $exiftoolexec = escapeshellarg($this->get_exiftool_path()); + $removetags = $this->get_remove_tags(); + $tempdestination = escapeshellarg($destination); + $tempsource = escapeshellarg($source); + $preservetagsoption = "-tagsfromfile @ " . self::PRESERVE_TAGS; + $command = "$exiftoolexec $removetags $preservetagsoption -o $tempdestination -- $tempsource"; + $command .= " 2> /dev/null"; // Do not output any errors. + return $command; + } + + /** + * Retrieves the remove tag options based on configuration. + * + * @return string The remove tag options. + */ + private function get_remove_tags(): string { + $removetags = get_config('core_fileredact', 'exifremoverremovetags'); + // If the remove tags value is empty or not empty but does not exist in the array, then set the default. + if (!$removetags || ($removetags && !array_key_exists($removetags, self::REMOVE_TAGS))) { + $removetags = self::DEFAULT_REMOVE_TAGS; + } + return self::REMOVE_TAGS[$removetags]; + } + + /** + * Retrieves the path to the ExifTool executable. + * + * @return string The path to the ExifTool executable. + */ + private function get_exiftool_path(): string { + $toolpathconfig = get_config('core_fileredact', 'exifremovertoolpath'); + if (!empty($toolpathconfig) && is_executable($toolpathconfig)) { + return $toolpathconfig; + } + return ''; + } + + /** + * Recreate the image using PHP GD library to strip all EXIF data. + * + * @return string|false The recreated image data as a string if successful, false otherwise. + */ + private function recreate_image_gd(): string|false { + $content = $this->storedfile->get_content(); + // Fetch the image information for this image. + $imageinfo = @getimagesizefromstring($content); + if (empty($imageinfo)) { + return false; + } + // Create a new image from the file. + $image = @imagecreatefromstring($content); + + // Capture the image as a string object, rather than straight to file. + ob_start(); + if (!imagejpeg( + image: $image, + quality: self::DEFAULT_JPEG_COMPRESSION, + ) + ) { + ob_end_clean(); + return false; + } + $data = ob_get_clean(); + imagedestroy($image); + return $data; + } + + /** + * Persists the redacted file to the file storage. + * + * @param string $content File content. + */ + private function persist_redacted_file(string $content): void { + $filerecord = (object) [ + 'id' => $this->storedfile->get_id(), + 'mimetype' => $this->storedfile->get_mimetype(), + 'userid' => $this->storedfile->get_userid(), + 'source' => $this->storedfile->get_source(), + 'contextid' => $this->storedfile->get_contextid(), + 'component' => $this->storedfile->get_component(), + 'filearea' => $this->storedfile->get_filearea(), + 'itemid' => $this->storedfile->get_itemid(), + 'filepath' => $this->storedfile->get_filepath(), + 'filename' => $this->storedfile->get_filename(), + ]; + $fs = get_file_storage(); + $existingfile = $fs->get_file( + $filerecord->contextid, + $filerecord->component, + $filerecord->filearea, + $filerecord->itemid, + $filerecord->filepath, + $filerecord->filename, + ); + if ($existingfile) { + $existingfile->delete(); + } + $redactedfile = $fs->create_file_from_string($filerecord, $content, false); + $this->storedfile->replace_file_with($redactedfile); + } + + /** + * Clean up a file name if it starts with a dash (U+002D) or a Unicode minus sign (U+2212). + * + * According to https://exiftool.org/#security, ensure that input file names do not start with + * a dash (U+002D) or a Unicode minus sign (U+2212). If found, remove the leading dash or Unicode minus sign. + * + * @param string $filename The file name to clean. + * @return string The cleaned file name. + */ + private function clean_filename(string $filename): string { + $pattern = '/^[\x{002D}\x{2212}]/u'; + if (preg_match($pattern, $filename)) { + $filename = preg_replace($pattern, '', $filename); + } + return clean_param($filename, PARAM_PATH); + } + + /** + * Returns true if the service is enabled, and false if it is not. + * + * @return bool + */ + public function is_enabled(): bool { + return (bool) get_config('core_fileredact', 'exifremoverenabled'); + } + + /** + * Determines whether a certain mime-type is supported by the service. + * It will return true if the mime-type is supported, and false if it is not. + * + * @param string $mimetype The mime type of file. + * @return bool + */ + public function is_mimetype_supported(string $mimetype): bool { + if ($mimetype === self::DEFAULT_MIMETYPE) { + return true; + } + + if ($this->useexiftool) { + // Get the supported MIME types from the config if using ExifTool. + $supportedmimetypesconfig = get_config('core_fileredact', 'exifremovermimetype'); + $supportedmimetypes = array_filter(array_map('trim', explode("\n", $supportedmimetypesconfig))); + return in_array($mimetype, $supportedmimetypes) ?? false; + } + + return false; + } + + /** + * Adds settings to the provided admin settings page. + * + * @param \admin_settingpage $settings The admin settings page to which settings are added. + */ + public static function add_settings(\admin_settingpage $settings): void { + global $OUTPUT; + + // Enabled for a fresh install, disabled for an upgrade. + $defaultenabled = 1; + if (!during_initial_install() && empty(get_config('core_fileredact', 'exifremoverenabled'))) { + $defaultenabled = 0; + } + + $icon = $OUTPUT->pix_icon('i/externallink', get_string('opensinnewwindow')); + $a = new \stdClass; + $a->link = \html_writer::link( + url: 'https://exiftool.sourceforge.net/install.html', + text: "https://exiftool.sourceforge.net/install.html $icon", + attributes: ['role' => 'opener', 'rel' => 'noreferrer', 'target' => '_blank'], + ); + + $settings->add( + new \admin_setting_configcheckbox( + name: 'core_fileredact/exifremoverenabled', + visiblename: get_string('fileredact:exifremover:enabled', 'core_files'), + description: get_string('fileredact:exifremover:enabled_desc', 'core_files', $a), + defaultsetting: $defaultenabled, + ), + ); + + $settings->add( + new \admin_setting_heading( + name: 'exifremoverheading', + heading: get_string('fileredact:exifremover:heading', 'core_files'), + information: '', + ) + ); + + $settings->add( + new \admin_setting_configexecutable( + name: 'core_fileredact/exifremovertoolpath', + visiblename: get_string('fileredact:exifremover:toolpath', 'core_files'), + description: get_string('fileredact:exifremover:toolpath_desc', 'core_files'), + defaultdirectory: '', + ) + ); + + foreach (array_keys(self::REMOVE_TAGS) as $key) { + $removedtagchoices[$key] = get_string("fileredact:exifremover:tag:$key", 'core_files'); + } + $settings->add( + new \admin_setting_configselect( + name: 'core_fileredact/exifremoverremovetags', + visiblename: get_string('fileredact:exifremover:removetags', 'core_files'), + description: get_string('fileredact:exifremover:removetags_desc', 'core_files'), + defaultsetting: self::DEFAULT_REMOVE_TAGS, + choices: $removedtagchoices, + ), + ); + + $mimetypedefault = <<add( + new \admin_setting_configtextarea( + name: 'core_fileredact/exifremovermimetype', + visiblename: get_string('fileredact:exifremover:mimetype', 'core_files'), + description: get_string('fileredact:exifremover:mimetype_desc', 'core_files'), + defaultsetting: $mimetypedefault, + ), + ); + } +} diff --git a/lib/classes/fileredact/services/service.php b/lib/classes/fileredact/services/service.php new file mode 100644 index 0000000000000..a713af38a2db7 --- /dev/null +++ b/lib/classes/fileredact/services/service.php @@ -0,0 +1,68 @@ +. + +namespace core\fileredact\services; + +use stored_file; +/** + * The interface of the redaction service outlines the necessary methods for each redaction blueprint. + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class service { + /** + * Class constructor. + * + * @param stored_file $storedfile The file record object. + */ + public function __construct( + /** @var stored_file $storedfile The file record object. */ + private readonly stored_file $storedfile, + ) { + } + + /** + * Performs redaction on the specified file. + */ + abstract public function execute(): void; + + /** + * Returns true if the service is enabled, and false if it is not. + * + * @return bool + */ + abstract public function is_enabled(): bool; + + /** + * Determines whether a certain mime-type is supported by the service. + * It will return true if the mime-type is supported, and false if it is not. + * + * @param string $mimetype + * @return bool + */ + public function is_mimetype_supported(string $mimetype): bool { + return false; + } + + /** + * Adds settings to the provided admin settings page. + * + * @param \admin_settingpage $settings The admin settings page to which settings are added. + */ + abstract public static function add_settings(\admin_settingpage $settings): void; +} diff --git a/lib/classes/hook/filestorage/after_file_created.php b/lib/classes/hook/filestorage/after_file_created.php new file mode 100644 index 0000000000000..c3e588b713fc8 --- /dev/null +++ b/lib/classes/hook/filestorage/after_file_created.php @@ -0,0 +1,60 @@ +. + +namespace core\hook\filestorage; + +use core\attribute; +use core\hook\stoppable_trait; + +/** + * Class after_file_created + * + * @package core + * @copyright 2024 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[attribute\label('Allows subscribers to modify file after it is created')] +#[attribute\tags('file')] +#[attribute\hook\replaces_callbacks('after_file_created')] +final class after_file_created { + use stoppable_trait; + /** + * Hook to allow subscribers to modify file after it is created. + * + * @param \stored_file $storedfile The stored file. + * @param \stdClass $filerecord The file record. + */ + public function __construct( + /** @var \stored_file The stored file. */ + public readonly \stored_file $storedfile, + /** @var \stdClass The file record. */ + public readonly \stdClass $filerecord, + ) { + } + + /** + * Process legacy callbacks. + */ + public function process_legacy_callbacks(): void { + if ($pluginsfunction = get_plugins_with_function(function: 'after_file_created', migratedtohook: true)) { + foreach ($pluginsfunction as $plugintype => $plugins) { + foreach ($plugins as $pluginfunction) { + $pluginfunction($this->filerecord); + } + } + } + } +} diff --git a/lib/classes/plugininfo/auth.php b/lib/classes/plugininfo/auth.php index 1c7a4e71be6e6..f85013d569ef3 100644 --- a/lib/classes/plugininfo/auth.php +++ b/lib/classes/plugininfo/auth.php @@ -87,6 +87,7 @@ public static function enable_plugin(string $pluginname, int $enabled): bool { $new = implode(',', array_flip($plugins)); add_to_config_log('auth', $CFG->auth, $new, 'core'); set_config('auth', $new); + \core\session\manager::destroy_by_auth_plugin($pluginname); // Remove stale sessions. \core\session\manager::gc(); // Reset caches. @@ -160,6 +161,7 @@ public function uninstall_cleanup() { $value = implode(',', $auths); add_to_config_log('auth', $CFG->auth, $value, 'core'); set_config('auth', $value); + \core\session\manager::destroy_by_auth_plugin($this->name); } if (!empty($CFG->registerauth) and $CFG->registerauth === $this->name) { diff --git a/lib/classes/session/database.php b/lib/classes/session/database.php index 6b15884018bac..d46ea67427621 100644 --- a/lib/classes/session/database.php +++ b/lib/classes/session/database.php @@ -14,14 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Database based session handler. - * - * @package core - * @copyright 2013 Petr Skoda {@link http://skodak.org} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core\session; use SessionHandlerInterface; @@ -34,7 +26,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class database extends handler implements SessionHandlerInterface { - /** @var \stdClass $record session record */ + + /** @var int $record session record */ protected $recordid = null; /** @var \moodle_database $database session database */ @@ -62,9 +55,7 @@ public function __construct() { } } - /** - * Init session handler. - */ + #[\Override] public function init() { if (!$this->database->session_lock_supported()) { throw new exception('sessionhandlerproblem', 'error', '', null, 'Database does not support session locking'); @@ -76,35 +67,36 @@ public function init() { } } - /** - * Check the backend contains data for this session id. - * - * Note: this is intended to be called from manager::session_exists() only. - * - * @param string $sid - * @return bool true if session found. - */ + #[\Override] public function session_exists($sid) { // It was already checked in the calling code that the record in sessions table exists. return true; } - /** - * Kill all active sessions, the core sessions table is - * purged afterwards. - */ - public function kill_all_sessions() { - // Nothing to do, the sessions table is cleared from core. - return; - } + #[\Override] + public function destroy(string $id): bool { + if (!$session = $this->database->get_record('sessions', ['sid' => $id], 'id, sid')) { + if ($id == session_id()) { + $this->recordid = null; + $this->lasthash = null; + } + return true; + } - /** - * Kill one session, the session record is removed afterwards. - * @param string $sid - */ - public function kill_session($sid) { - // Nothing to do, the sessions table is purged afterwards. - return; + if ($this->recordid && ($session->id == $this->recordid)) { + try { + $this->database->release_session_lock($this->recordid); + } catch (\Exception $ex) { + // Log and ignore any problems. + mtrace('Failed to release session lock: '.$ex->getMessage()); + } + $this->recordid = null; + $this->lasthash = null; + } + + $this->database->delete_records('sessions', ['id' => $session->id]); + + return true; } /** @@ -151,7 +143,7 @@ public function close(): bool { */ public function read(string $sid): string|false { try { - if (!$record = $this->database->get_record('sessions', array('sid'=>$sid), 'id')) { + if (!$record = $this->get_session_by_sid($sid)) { // Let's cheat and skip locking if this is the first access, // do not create the record here, let the manager do it after session init. $this->failed = false; @@ -252,56 +244,4 @@ public function write(string $id, string $data): bool { return true; } - /** - * Destroy session handler. - * - * {@see http://php.net/manual/en/function.session-set-save-handler.php} - * - * @param string $id - * @return bool success - */ - public function destroy(string $id): bool { - if (!$session = $this->database->get_record('sessions', ['sid' => $id], 'id, sid')) { - if ($id == session_id()) { - $this->recordid = null; - $this->lasthash = null; - } - return true; - } - - if ($this->recordid && ($session->id == $this->recordid)) { - try { - $this->database->release_session_lock($this->recordid); - } catch (\Exception $ex) { - // Ignore problems. - } - $this->recordid = null; - $this->lasthash = null; - } - - $this->database->delete_records('sessions', ['id' => $session->id]); - - return true; - } - - /** - * GC session handler. - * - * {@see http://php.net/manual/en/function.session-set-save-handler.php} - * - * @param int $max_lifetime moodle uses special timeout rules - * @return bool success - */ - // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameUnderscore - public function gc(int $max_lifetime): int|false { - // This should do something only if cron is not running properly... - if (!$stalelifetime = ini_get('session.gc_maxlifetime')) { - return false; - } - $params = ['purgebefore' => (time() - $stalelifetime)]; - $count = $this->database->count_records_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params); - $this->database->delete_records_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params); - - return $count; - } } diff --git a/lib/classes/session/file.php b/lib/classes/session/file.php index b454e0e7b90ec..b84e543a09352 100644 --- a/lib/classes/session/file.php +++ b/lib/classes/session/file.php @@ -14,18 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * File based session handler. - * - * @package core - * @copyright 2013 Petr Skoda {@link http://skodak.org} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core\session; -defined('MOODLE_INTERNAL') || die(); - /** * File based session handler. * @@ -34,6 +24,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class file extends handler { + /** @var string session dir */ protected $sessiondir; @@ -50,9 +41,7 @@ public function __construct() { } } - /** - * Init session handler. - */ + #[\Override] public function init() { if (preg_match('/^[0-9]+;/', $this->sessiondir)) { throw new exception('sessionhandlerproblem', 'error', '', null, 'Multilevel session directories are not supported'); @@ -75,14 +64,7 @@ public function init() { ini_set('session.save_path', $this->sessiondir); } - /** - * Check the backend contains data for this session id. - * - * Note: this is intended to be called from manager::session_exists() only. - * - * @param string $sid - * @return bool true if session found. - */ + #[\Override] public function session_exists($sid) { $sid = clean_param($sid, PARAM_FILE); if (!$sid) { @@ -92,30 +74,28 @@ public function session_exists($sid) { return file_exists($sessionfile); } - /** - * Kill all active sessions, the core sessions table is - * purged afterwards. - */ - public function kill_all_sessions() { + #[\Override] + public function destroy_all(): bool { if (is_dir($this->sessiondir)) { foreach (glob("$this->sessiondir/sess_*") as $filename) { @unlink($filename); } } + + return true; } - /** - * Kill one session, the session record is removed afterwards. - * @param string $sid - */ - public function kill_session($sid) { - $sid = clean_param($sid, PARAM_FILE); + #[\Override] + public function destroy(string $id): bool { + $sid = clean_param($id, PARAM_FILE); if (!$sid) { - return; + return false; } $sessionfile = "$this->sessiondir/sess_$sid"; if (file_exists($sessionfile)) { @unlink($sessionfile); } + + return true; } } diff --git a/lib/classes/session/handler.php b/lib/classes/session/handler.php index 07cec66d30bfb..1736bd0c47f2a 100644 --- a/lib/classes/session/handler.php +++ b/lib/classes/session/handler.php @@ -14,17 +14,11 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Session handler base. - * - * @package core - * @copyright 2013 Petr Skoda {@link http://skodak.org} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core\session; -defined('MOODLE_INTERNAL') || die(); +use core\clock; +use core\di; +use stdClass; /** * Session handler base. @@ -79,6 +73,220 @@ public function set_requires_write_lock($requireswritelock) { $this->requireswritelock = $requireswritelock; } + /** + * Returns all session records. + * + * @return \Iterator + */ + public function get_all_sessions(): \Iterator { + global $DB; + + $rs = $DB->get_recordset('sessions'); + foreach ($rs as $row) { + yield $row; + } + $rs->close(); + } + + /** + * Returns a single session record for this session id. + * + * @param string $sid + * @return stdClass + */ + public function get_session_by_sid(string $sid): stdClass { + global $DB; + + return $DB->get_record('sessions', ['sid' => $sid]) ?: new stdClass(); + } + + /** + * Returns all the session records for this user id. + * + * @param int $userid + * @return array + */ + public function get_sessions_by_userid(int $userid): array { + global $DB; + + return $DB->get_records('sessions', ['userid' => $userid]); + } + + /** + * Insert new empty session record. + * + * @param int $userid + * @return stdClass the new record + */ + public function add_session(int $userid): stdClass { + global $DB; + + $record = new stdClass(); + $record->state = 0; + $record->sid = session_id(); + $record->sessdata = null; + $record->userid = $userid; + $record->timecreated = $record->timemodified = di::get(clock::class)->time(); + $record->firstip = $record->lastip = getremoteaddr(); + + $record->id = $DB->insert_record('sessions', $record); + + return $record; + } + + /** + * Update a session record. + * + * @param stdClass $record + * @return bool + */ + public function update_session(stdClass $record): bool { + global $DB; + + if (!isset($record->id) && isset($record->sid)) { + $record->id = $DB->get_field('sessions', 'id', ['sid' => $record->sid]); + } + + return $DB->update_record('sessions', $record); + } + + /** + * Destroy a specific session and delete this session record for this session id. + * + * @param string $id session id + * @return bool + */ + public function destroy(string $id): bool { + global $DB; + + return $DB->delete_records('sessions', ['sid' => $id]); + } + + /** + * Destroy all sessions, and delete all the session data. + * + * @return bool + */ + public function destroy_all(): bool { + global $DB; + + return $DB->delete_records('sessions'); + } + + /** + * Clean up expired sessions. + * + * @param int $purgebefore Sessions that have not updated for the last purgebefore timestamp will be removed. + * @param int $userid + */ + protected function destroy_expired_user_sessions(int $purgebefore, int $userid): void { + $sessions = $this->get_sessions_by_userid($userid); + foreach ($sessions as $session) { + if ($session->timemodified < $purgebefore) { + $this->destroy($session->sid); + } + } + } + + /** + * Clean up all expired sessions. + * + * @param int $purgebefore + */ + protected function destroy_all_expired_sessions(int $purgebefore): void { + global $DB, $CFG; + + $authsequence = get_enabled_auth_plugins(); + $authsequence = array_flip($authsequence); + unset($authsequence['nologin']); // No login means user cannot login. + $authsequence = array_flip($authsequence); + $authplugins = []; + foreach ($authsequence as $authname) { + $authplugins[$authname] = get_auth_plugin($authname); + } + $sql = "SELECT u.*, s.sid, s.timecreated AS s_timecreated, s.timemodified AS s_timemodified + FROM {user} u + JOIN {sessions} s ON s.userid = u.id + WHERE s.timemodified < :purgebefore AND u.id <> :guestid"; + $params = ['purgebefore' => $purgebefore, 'guestid' => $CFG->siteguest]; + + $rs = $DB->get_recordset_sql($sql, $params); + foreach ($rs as $user) { + foreach ($authplugins as $authplugin) { + if ($authplugin->ignore_timeout_hook($user, $user->sid, $user->s_timecreated, $user->s_timemodified)) { + continue 2; + } + } + $this->destroy($user->sid); + } + $rs->close(); + } + + /** + * Destroy all sessions for a given plugin. + * Typically used when a plugin is disabled or uninstalled, so all sessions (users) for that plugin are logged out. + * + * @param string $pluginname Auth plugin name. + */ + public function destroy_by_auth_plugin(string $pluginname): void { + global $DB; + + $rs = $DB->get_recordset('user', ['auth' => $pluginname], 'id ASC', 'id'); + foreach ($rs as $user) { + $sessions = $this->get_sessions_by_userid($user->id); + foreach ($sessions as $session) { + $this->destroy($session->sid); + } + } + $rs->close(); + } + + // phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameUnderscore + /** + * Periodic timed-out session cleanup. + * + * @param int $max_lifetime Sessions that have not updated for the last max_lifetime seconds will be removed. + * @return int|false Number of deleted sessions or false if an error occurred. + */ + public function gc(int $max_lifetime = 0): int|false { + global $CFG; + + // This may take a long time. + \core_php_time_limit::raise(); + + if ($max_lifetime === 0) { + $max_lifetime = $CFG->sessiontimeout; + } + + try { + // Delete expired sessions for guest user account, give them larger timeout, there is no security risk here. + $purgebefore = di::get(clock::class)->time() - ($max_lifetime * 5); + $this->destroy_expired_user_sessions($purgebefore, $CFG->siteguest); + + // Delete expired sessions for userid = 0 (not logged in), better kill them asap to release memory. + $purgebefore = di::get(clock::class)->time() - $max_lifetime; + $this->destroy_expired_user_sessions($purgebefore, 0); + + // Clean up expired sessions for real users only. + $this->destroy_all_expired_sessions($purgebefore); + + // Cleanup leftovers from the first browser access because it may set multiple cookies and then use only one. + $purgebefore = di::get(clock::class)->time() - (60 * 3); + $sessions = $this->get_sessions_by_userid(0); + foreach ($sessions as $session) { + if ($session->timemodified == $session->timecreated && $session->timemodified < $purgebefore) { + $this->destroy($session->sid); + } + } + + } catch (\Exception $ex) { + debugging('Error gc-ing sessions: '.$ex->getMessage(), DEBUG_NORMAL, $ex->getTrace()); + } + + return 0; + } + // phpcs:enable + /** * Has this session been opened with a writelock? Your handler should call this during * start() if you support read-only sessions. @@ -102,16 +310,4 @@ abstract public function init(); * @return bool true if session found. */ abstract public function session_exists($sid); - - /** - * Kill all active sessions, the core sessions table is - * purged afterwards. - */ - abstract public function kill_all_sessions(); - - /** - * Kill one session, the session record is removed afterwards. - * @param string $sid - */ - abstract public function kill_session($sid); } diff --git a/lib/classes/session/manager.php b/lib/classes/session/manager.php index 4ce12f52ac810..7070ec9c42db2 100644 --- a/lib/classes/session/manager.php +++ b/lib/classes/session/manager.php @@ -14,18 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Session manager class. - * - * @package core - * @copyright 2013 Petr Skoda {@link http://skodak.org} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core\session; -defined('MOODLE_INTERNAL') || die(); - +use core\clock; +use core\di; use html_writer; /** @@ -252,14 +244,14 @@ public static function get_handler_class() { global $CFG, $DB; if (PHPUNIT_TEST) { - return '\core\session\file'; + return \core\tests\session\mock_handler::class; } else if (!empty($CFG->session_handler_class)) { return $CFG->session_handler_class; - } else if (!empty($CFG->dbsessions) and $DB->session_lock_supported()) { - return '\core\session\database'; + } else if (!empty($CFG->dbsessions) && $DB->session_lock_supported()) { + return database::class; } - return '\core\session\file'; + return file::class; } /** @@ -273,6 +265,9 @@ protected static function load_handler() { // Find out which handler to use. $class = self::get_handler_class(); self::$handler = new $class(); + if (!self::$handler instanceof \core\session\handler) { + throw new exception("$class must implement the \core\session\handler"); + } } /** @@ -419,7 +414,7 @@ protected static function prepare_cookies() { * @param bool $newsid is this a new session in first http request? */ protected static function initialise_user_session($newsid) { - global $CFG, $DB; + global $CFG; $sid = session_id(); if (!$sid) { @@ -428,8 +423,8 @@ protected static function initialise_user_session($newsid) { self::init_empty_session($newsid); return; } - - if (!$record = $DB->get_record('sessions', array('sid'=>$sid), 'id, sid, state, userid, lastip, timecreated, timemodified')) { + $record = self::get_session_by_sid($sid); + if (!isset($record->sid)) { if (!$newsid) { if (!empty($_SESSION['USER']->id)) { // This should not happen, just log it, we MUST not produce any output here! @@ -456,7 +451,7 @@ protected static function initialise_user_session($newsid) { // Ignore guest and not-logged in timeouts, there is very little risk here. $timeout = false; - } else if ($record->timemodified < time() - $maxlifetime) { + } else if ($record->timemodified < di::get(clock::class)->time() - $maxlifetime) { $timeout = true; $authsequence = get_enabled_auth_plugins(); // Auths, in sequence. foreach ($authsequence as $authname) { @@ -474,11 +469,9 @@ protected static function initialise_user_session($newsid) { } session_regenerate_id(true); $_SESSION = array(); - $DB->delete_records('sessions', array('id'=>$record->id)); - + self::destroy($record->sid); } else { // Update session tracking record. - $update = new \stdClass(); $updated = false; @@ -493,33 +486,34 @@ protected static function initialise_user_session($newsid) { $updated = true; } + $time = di::get(clock::class)->time(); + $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency; if ($record->timemodified == $record->timecreated) { // Always do first update of existing record. - $update->timemodified = $record->timemodified = time(); + $update->timemodified = $record->timemodified = $time; $updated = true; - } else if ($record->timemodified < time() - $updatefreq) { + } else if ($record->timemodified < $time - $updatefreq) { // Update the session modified flag only once every 20 seconds. - $update->timemodified = $record->timemodified = time(); + $update->timemodified = $record->timemodified = $time; $updated = true; } if ($updated && (!defined('NO_SESSION_UPDATE') || !NO_SESSION_UPDATE)) { $update->id = $record->id; - $DB->update_record('sessions', $update); + $update->userid = $record->userid; + self::$handler->update_session($update); } return; } - } else { - if ($record) { - // This happens when people switch session handlers... - session_regenerate_id(true); - $_SESSION = array(); - $DB->delete_records('sessions', array('id'=>$record->id)); - } + } else if (isset($record->sid)) { + // This happens when people switch session handlers... + session_regenerate_id(true); + $_SESSION = []; + self::destroy($record->sid); } unset($record); @@ -551,10 +545,10 @@ protected static function initialise_user_session($newsid) { // Setup $USER and insert the session tracking record. if ($user) { self::set_user($user); - self::add_session_record($user->id); + self::add_session($user->id); } else { self::init_empty_session($newsid); - self::add_session_record(0); + self::add_session(0); } if ($timedout) { @@ -562,24 +556,44 @@ protected static function initialise_user_session($newsid) { } } + /** + * Returns a single session record for this session id. + * + * @param string $sid + * @return \stdClass + */ + public static function get_session_by_sid(string $sid): \stdClass { + return self::$handler->get_session_by_sid($sid); + } + + /** + * Returns all the session records for this user id. + * + * @param int $userid + * @return array + */ + public static function get_sessions_by_userid(int $userid): array { + return self::$handler->get_sessions_by_userid($userid); + } + /** * Insert new empty session record. + * * @param int $userid * @return \stdClass the new record */ - protected static function add_session_record($userid) { - global $DB; - $record = new \stdClass(); - $record->state = 0; - $record->sid = session_id(); - $record->sessdata = null; - $record->userid = $userid; - $record->timecreated = $record->timemodified = time(); - $record->firstip = $record->lastip = getremoteaddr(); - - $record->id = $DB->insert_record('sessions', $record); + public static function add_session(int $userid): \stdClass { + return self::$handler->add_session($userid); + } - return $record; + /** + * Update a session record. + * + * @param \stdClass $record + * @return bool + */ + public static function update_session(\stdClass $record): bool { + return self::$handler->update_session($record); } /** @@ -619,8 +633,8 @@ public static function login_user(\stdClass $user) { $sid = session_id(); session_regenerate_id(true); - $DB->delete_records('sessions', array('sid'=>$sid)); - self::add_session_record($user->id); + self::destroy($sid); + self::add_session($user->id); // Let enrol plugins deal with new enrolments if necessary. enrol_check_plugins($user); @@ -679,9 +693,9 @@ public static function terminate_current() { // Write new empty session and make sure the old one is deleted. $sid = session_id(); session_regenerate_id(true); - $DB->delete_records('sessions', array('sid'=>$sid)); + self::destroy($sid); self::init_empty_session(); - self::add_session_record($_SESSION['USER']->id); // Do not use $USER here because it may not be set up yet. + self::add_session($_SESSION['USER']->id); // Do not use $USER here because it may not be set up yet. self::write_close(); } @@ -814,13 +828,14 @@ public static function session_exists($sid) { } // Note: add sessions->state checking here if it gets implemented. - if (!$record = $DB->get_record('sessions', array('sid' => $sid), 'id, userid, timemodified')) { + $record = self::get_session_by_sid($sid); + if (!isset($record->sid)) { return false; } if (empty($record->userid) or isguestuser($record->userid)) { // Ignore guest and not-logged-in timeouts, there is very little risk here. - } else if ($record->timemodified < time() - $CFG->sessiontimeout) { + } else if ($record->timemodified < di::get(clock::class)->time() - $CFG->sessiontimeout) { return false; } @@ -842,7 +857,7 @@ public static function time_remaining($sid) { } // Note: add sessions->state checking here if it gets implemented. - if (!$record = $DB->get_record('sessions', array('sid' => $sid), 'id, userid, timemodified')) { + if (!$record = self::get_session_by_sid($sid)) { return ['userid' => 0, 'timeremaining' => $CFG->sessiontimeout]; } @@ -850,7 +865,10 @@ public static function time_remaining($sid) { // Ignore guest and not-logged-in timeouts, there is very little risk here. return ['userid' => 0, 'timeremaining' => $CFG->sessiontimeout]; } else { - return ['userid' => $record->userid, 'timeremaining' => $CFG->sessiontimeout - (time() - $record->timemodified)]; + return [ + 'userid' => $record->userid, + 'timeremaining' => $CFG->sessiontimeout - (di::get(clock::class)->time() - $record->timemodified), + ]; } } @@ -859,64 +877,137 @@ public static function time_remaining($sid) { * @param string $sid */ public static function touch_session($sid) { - global $DB; - // Timeouts depend on core sessions table only, no need to update anything in external stores. - - $sql = "UPDATE {sessions} SET timemodified = :now WHERE sid = :sid"; - $DB->execute($sql, array('now'=>time(), 'sid'=>$sid)); + self::$handler->update_session((object) [ + 'sid' => $sid, + 'timemodified' => di::get(clock::class)->time(), + ]); } /** * Terminate all sessions unconditionally. + * + * @return void + * @deprecated since Moodle 4.5 See MDL-66161 + * @todo Remove in MDL-81848 */ - public static function kill_all_sessions() { - global $DB; + #[\core\attribute\deprecated( + replacement: 'destroy_all', + since: '4.5', + )] + public static function kill_all_sessions(): void { + \core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]); + self::destroy_all(); + } - self::terminate_current(); + /** + * Terminate give session unconditionally. + * + * @param string $sid + * @return void + * @deprecated since Moodle 4.5 See MDL-66161 + * @todo Remove in MDL-81848 + */ + #[\core\attribute\deprecated( + replacement: 'destroy', + since: '4.5', + )] + public static function kill_session($sid): void { + \core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]); + self::destroy($sid); + } + + /** + * Kill sessions of users with disabled plugins. + * + * @param string $pluginname + * @return void + * @deprecated since Moodle 4.5 See MDL-66161 + * @todo Remove in MDL-81848 + */ + #[\core\attribute\deprecated( + replacement: 'destroy_by_auth_plugin', + since: '4.5', + )] + public static function kill_sessions_for_auth_plugin(string $pluginname): void { + \core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]); + self::destroy_by_auth_plugin($pluginname); + } + + /** + * Terminate all sessions of given user unconditionally. + * + * @param int $userid + * @param string $keepsid keep this sid if present + * @deprecated since Moodle 4.5 See MDL-66161 + * @todo Remove in MDL-81848 + */ + #[\core\attribute\deprecated( + replacement: 'destroy_user_sessions', + since: '4.5', + )] + public static function kill_user_sessions($userid, $keepsid = null) { + \core\deprecation::emit_deprecation_if_present([self::class, __FUNCTION__]); + self::destroy_user_sessions($userid, $keepsid); + } + + /** + * Destroy all sessions for a given plugin. + * Typically used when a plugin is disabled or uninstalled, so all sessions (users) for that plugin are logged out. + * + * @param string $pluginname Auth plugin name. + */ + public static function destroy_by_auth_plugin(string $pluginname): void { + self::$handler->destroy_by_auth_plugin($pluginname); + } + /** + * Destroy all sessions, and delete all the session data. + * + * @return bool + */ + public static function destroy_all(): bool { + self::terminate_current(); self::load_handler(); - self::$handler->kill_all_sessions(); try { - $DB->delete_records('sessions'); - } catch (\dml_exception $ignored) { + $result = self::$handler->destroy_all(); + } catch (\moodle_exception $ignored) { // Do not show any warnings - might be during upgrade/installation. + $result = true; } + + return $result; } /** - * Terminate give session unconditionally. - * @param string $sid + * Destroy a specific session and delete this session record for this session id. + * + * @param string $id + * @return bool */ - public static function kill_session($sid) { - global $DB; - + public static function destroy(string $id): bool { self::load_handler(); - if ($sid === session_id()) { + if ($id === session_id()) { self::write_close(); } - self::$handler->kill_session($sid); - - $DB->delete_records('sessions', array('sid'=>$sid)); + return self::$handler->destroy($id); } /** - * Terminate all sessions of given user unconditionally. + * Destroy all sessions of given user unconditionally. * @param int $userid * @param string $keepsid keep this sid if present */ - public static function kill_user_sessions($userid, $keepsid = null) { - global $DB; - - $sessions = $DB->get_records('sessions', array('userid'=>$userid), 'id DESC', 'id, sid'); + public static function destroy_user_sessions($userid, $keepsid = null) { + $sessions = self::get_sessions_by_userid($userid); foreach ($sessions as $session) { if ($keepsid and $keepsid === $session->sid) { continue; } - self::kill_session($session->sid); + self::destroy($session->sid); } } @@ -949,30 +1040,35 @@ public static function apply_concurrent_login_limit($userid, $sid = null) { return; } - $count = $DB->count_records('sessions', array('userid' => $userid)); + $sessions = self::get_sessions_by_userid($userid); + + $count = count($sessions); if ($count <= $CFG->limitconcurrentlogins) { return; } $i = 0; - $select = "userid = :userid"; - $params = array('userid' => $userid); if ($sid) { - if ($DB->record_exists('sessions', array('sid' => $sid, 'userid' => $userid))) { - $select .= " AND sid <> :sid"; - $params['sid'] = $sid; - $i = 1; + foreach ($sessions as $key => $session) { + if ($session->sid == $sid && $session->userid == $userid) { + $i = 1; + unset($sessions[$key]); + } } } - $sessions = $DB->get_records_select('sessions', $select, $params, 'timecreated DESC', 'id, sid'); + // Order records by timecreated DESC. + usort($sessions, function($a, $b){ + return $b->timecreated <=> $a->timecreated; + }); + foreach ($sessions as $session) { $i++; if ($i <= $CFG->limitconcurrentlogins) { continue; } - self::kill_session($session->sid); + self::destroy($session->sid); } } @@ -1006,86 +1102,18 @@ public static function set_user(\stdClass $user) { /** * Periodic timed-out session cleanup. + * + * @param int $maxlifetime Sessions that have not updated for the last max_lifetime seconds will be removed. + * @return void */ - public static function gc() { - global $CFG, $DB; - - // This may take a long time... - \core_php_time_limit::raise(); - - $maxlifetime = $CFG->sessiontimeout; - - try { - // Kill all sessions of deleted and suspended users without any hesitation. - $rs = $DB->get_recordset_select('sessions', "userid IN (SELECT id FROM {user} WHERE deleted <> 0 OR suspended <> 0)", array(), 'id DESC', 'id, sid'); - foreach ($rs as $session) { - self::kill_session($session->sid); - } - $rs->close(); - - // Kill sessions of users with disabled plugins. - $authsequence = get_enabled_auth_plugins(); - $authsequence = array_flip($authsequence); - unset($authsequence['nologin']); // No login means user cannot login. - $authsequence = array_flip($authsequence); - - list($notplugins, $params) = $DB->get_in_or_equal($authsequence, SQL_PARAMS_QM, '', false); - $rs = $DB->get_recordset_select('sessions', "userid IN (SELECT id FROM {user} WHERE auth $notplugins)", $params, 'id DESC', 'id, sid'); - foreach ($rs as $session) { - self::kill_session($session->sid); - } - $rs->close(); - - // Now get a list of time-out candidates - real users only. - $sql = "SELECT u.*, s.sid, s.timecreated AS s_timecreated, s.timemodified AS s_timemodified - FROM {user} u - JOIN {sessions} s ON s.userid = u.id - WHERE s.timemodified < :purgebefore AND u.id <> :guestid"; - $params = array('purgebefore' => (time() - $maxlifetime), 'guestid'=>$CFG->siteguest); - - $authplugins = array(); - foreach ($authsequence as $authname) { - $authplugins[$authname] = get_auth_plugin($authname); - } - $rs = $DB->get_recordset_sql($sql, $params); - foreach ($rs as $user) { - foreach ($authplugins as $authplugin) { - /** @var \auth_plugin_base $authplugin*/ - if ($authplugin->ignore_timeout_hook($user, $user->sid, $user->s_timecreated, $user->s_timemodified)) { - continue 2; - } - } - self::kill_session($user->sid); - } - $rs->close(); - - // Delete expired sessions for guest user account, give them larger timeout, there is no security risk here. - $params = array('purgebefore' => (time() - ($maxlifetime * 5)), 'guestid'=>$CFG->siteguest); - $rs = $DB->get_recordset_select('sessions', 'userid = :guestid AND timemodified < :purgebefore', $params, 'id DESC', 'id, sid'); - foreach ($rs as $session) { - self::kill_session($session->sid); - } - $rs->close(); - - // Delete expired sessions for userid = 0 (not logged in), better kill them asap to release memory. - $params = array('purgebefore' => (time() - $maxlifetime)); - $rs = $DB->get_recordset_select('sessions', 'userid = 0 AND timemodified < :purgebefore', $params, 'id DESC', 'id, sid'); - foreach ($rs as $session) { - self::kill_session($session->sid); - } - $rs->close(); - - // Cleanup letfovers from the first browser access because it may set multiple cookies and then use only one. - $params = array('purgebefore' => (time() - 60*3)); - $rs = $DB->get_recordset_select('sessions', 'userid = 0 AND timemodified = timecreated AND timemodified < :purgebefore', $params, 'id ASC', 'id, sid'); - foreach ($rs as $session) { - self::kill_session($session->sid); - } - $rs->close(); + public static function gc(int $maxlifetime = 0): void { + global $CFG; - } catch (\Exception $ex) { - debugging('Error gc-ing sessions: '.$ex->getMessage(), DEBUG_NORMAL, $ex->getTrace()); + // If max lifetime is not provided, use the default session timeout. + if ($maxlifetime == 0) { + $maxlifetime = $CFG->sessiontimeout; } + self::$handler->gc($maxlifetime); } /** @@ -1214,7 +1242,7 @@ private static function create_login_token() { $state = [ 'token' => random_string(32), - 'created' => time() // Server time - not user time. + 'created' => di::get(clock::class)->time(), // Server time - not user time. ]; if (!isset($SESSION->logintoken)) { @@ -1254,7 +1282,7 @@ public static function get_login_token() { } // Check token lifespan. - if ($state['created'] < (time() - $CFG->sessiontimeout)) { + if ($state['created'] < (di::get(clock::class)->time() - $CFG->sessiontimeout)) { $state = self::create_login_token(); } diff --git a/lib/classes/session/memcached.php b/lib/classes/session/memcached.php index 7b76be86e5c72..91fb2d5dd2f6b 100644 --- a/lib/classes/session/memcached.php +++ b/lib/classes/session/memcached.php @@ -14,18 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Memcached based session handler. - * - * @package core - * @copyright 2013 Petr Skoda {@link http://skodak.org} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core\session; -defined('MOODLE_INTERNAL') || die(); - /** * Memcached based session handler. * @@ -96,10 +86,7 @@ public function __construct() { } } - /** - * Start the session. - * @return bool success - */ + #[\Override] public function start() { ini_set('memcached.sess_locking', $this->requires_write_lock() ? '1' : '0'); @@ -132,9 +119,7 @@ public function start() { return $result; } - /** - * Init session handler. - */ + #[\Override] public function init() { if (!extension_loaded('memcached')) { throw new exception('sessionhandlerproblem', 'error', '', null, 'memcached extension is not loaded'); @@ -178,14 +163,7 @@ public function init() { } - /** - * Check the backend contains data for this session id. - * - * Note: this is intended to be called from manager::session_exists() only. - * - * @param string $sid - * @return bool true if session found. - */ + #[\Override] public function session_exists($sid) { if (!$this->servers) { return false; @@ -209,14 +187,11 @@ public function session_exists($sid) { return false; } - /** - * Kill all active sessions, the core sessions table is - * purged afterwards. - */ - public function kill_all_sessions() { + #[\Override] + public function destroy_all(): bool { global $DB; if (!$this->servers) { - return; + return false; } // Go through the list of all servers because @@ -245,15 +220,14 @@ public function kill_all_sessions() { foreach ($memcacheds as $memcached) { $memcached->quit(); } + + return true; } - /** - * Kill one session, the session record is removed afterwards. - * @param string $sid - */ - public function kill_session($sid) { + #[\Override] + public function destroy(string $id): bool { if (!$this->servers) { - return; + return false; } // Go through the list of all servers because @@ -264,9 +238,11 @@ public function kill_session($sid) { list($host, $port) = $server; $memcached = new \Memcached(); $memcached->addServer($host, $port); - $memcached->delete($this->prefix . $sid); + $memcached->delete($this->prefix . $id); $memcached->quit(); } + + return true; } /** diff --git a/lib/classes/session/redis.php b/lib/classes/session/redis.php index bd69c3f3ee4e5..579ddd52873c6 100644 --- a/lib/classes/session/redis.php +++ b/lib/classes/session/redis.php @@ -14,29 +14,21 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Redis based session handler. - * - * @package core - * @copyright 2015 Russell Smith - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace core\session; -use RedisException; +use coding_exception; +use core\di; +use core\clock; +use RedisCluster; use RedisClusterException; +use RedisException; use SessionHandlerInterface; /** * Redis based session handler. * - * The default Redis session handler does not handle locking in 2.2.7, so we have written a php session handler - * that uses locking. The places where locking is used was modeled from the memcached code that is used in Moodle - * https://github.com/php-memcached-dev/php-memcached/blob/master/php_memcached_session.c - * * @package core - * @copyright 2016 Russell Smith + * @copyright Russell Smith * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class redis extends handler implements SessionHandlerInterface { @@ -53,14 +45,11 @@ class redis extends handler implements SessionHandlerInterface { */ const COMPRESSION_ZSTD = 'zstd'; - /** - * Minimum version of the Redis extension required. - */ - public const REDIS_EXTENSION_MIN_VERSION = '2.2.4'; - /** - * Minimum version of the Redis extension required. - */ - private const REDIS_SERVER_MIN_VERSION = '2.6.12'; + /** @var string Minimum server version */ + const REDIS_MIN_SERVER_VERSION = "5.0.0"; + + /** @var string Minimum extension version */ + const REDIS_MIN_EXTENSION_VERSION = "5.1.0"; /** @var array $host save_path string */ protected array $host = []; @@ -74,6 +63,13 @@ class redis extends handler implements SessionHandlerInterface { protected $database = 0; /** @var array $servers list of servers parsed from save_path */ protected $prefix = ''; + + /** @var string $sessionkeyprefix the prefix for the session key */ + protected string $sessionkeyprefix = 'session_'; + + /** @var string $userkeyprefix the prefix for the user key */ + protected string $userkeyprefix = 'user_'; + /** @var int $acquiretimeout how long to wait for session lock in seconds */ protected $acquiretimeout = 120; /** @var int $acquirewarn how long before warning when waiting for a lock in seconds */ @@ -87,14 +83,18 @@ class redis extends handler implements SessionHandlerInterface { /** @var string $lasthash hash of the session data content */ protected $lasthash = null; + /** @var int $gcbatchsize The number of redis keys that will be processed each time the garbage collector is executed. */ + protected int $gcbatchsize = 100; + /** - * @var int $lockexpire how long to wait in seconds before expiring the lock automatically - * so that other requests may continue execution, ignored if PECL redis is below version 2.2.0. + * How long to wait in seconds before expiring the lock automatically so that other requests may continue execution. + * + * @var int $lockexpire */ - protected $lockexpire; + protected int $lockexpire; - /** @var Redis|RedisCluster Connection */ - protected $connection = null; + /** @var \Redis|\RedisCluster|null Connection */ + protected \Redis|\RedisCluster|null $connection = null; /** @var array $locks List of currently held locks by this page. */ protected $locks = array(); @@ -108,6 +108,12 @@ class redis extends handler implements SessionHandlerInterface { /** @var int Maximum number of retries for cache store operations. */ const MAX_RETRIES = 5; + /** @var int $firstaccesstimeout The initial timeout (seconds) for the first browser access without login. */ + protected int $firstaccesstimeout = 180; + + /** @var clock A clock instance */ + protected clock $clock; + /** * Create new instance of handler. */ @@ -157,12 +163,6 @@ public function __construct() { $this->serializer = \Redis::SERIALIZER_IGBINARY; // Set igbinary serializer if phpredis supports it. } - // The following configures the session lifetime in redis to allow some - // wriggle room in the user noticing they've been booted off and - // letting them log back in before they lose their session entirely. - $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency; - $this->timeout = $CFG->sessiontimeout + $updatefreq + MINSECS; - // This sets the Redis session lock expiry time to whatever is lower, either // the PHP execution time `max_execution_time`, if the value was defined in // the `php.ini` or the globally configured `sessiontimeout`. Setting it to @@ -187,37 +187,33 @@ public function __construct() { if (isset($CFG->session_redis_compressor)) { $this->compressor = $CFG->session_redis_compressor; } - } - /** - * Start the session. - * - * @return bool success - */ - public function start() { - $result = parent::start(); - - return $result; + $this->clock = di::get(clock::class); } - /** - * Init session handler. - */ - public function init() { + #[\Override] + public function init(): bool { if (!extension_loaded('redis')) { throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension is not loaded'); } if (empty($this->host)) { - throw new exception('sessionhandlerproblem', 'error', '', null, - '$CFG->session_redis_host must be specified in config.php'); + throw new exception( + 'sessionhandlerproblem', + 'error', + '', + null, + '$CFG->session_redis_host must be specified in config.php', + ); } - // The session handler requires a version of PHP Redis extension with support for SET command options (at least 2.2.4). $version = phpversion('Redis'); - if (!$version || version_compare($version, self::REDIS_EXTENSION_MIN_VERSION) <= 0) { - throw new exception('sessionhandlerproblem', 'error', '', null, - 'redis extension version must be at least ' . self::REDIS_EXTENSION_MIN_VERSION); + if (!$version || version_compare($version, self::REDIS_MIN_EXTENSION_VERSION) <= 0) { + throw new exception( + errorcode: 'sessionhandlerproblem', + module: 'error', + debuginfo: sprintf('redis extension version must be at least %s', self::REDIS_MIN_EXTENSION_VERSION), + ); } $result = session_set_save_handler($this); @@ -275,12 +271,26 @@ public function init() { try { // Create a $redis object of a RedisCluster or Redis class. if ($this->clustermode) { - $this->connection = new \RedisCluster(null, $trimmedservers, 1, 1, true, - $this->auth, !empty($opts) ? $opts : null); + $this->connection = new \RedisCluster( + name: null, + seeds: $trimmedservers, + timeout: 1, + readTimeout: 1, + persistent: true, + auth: $this->auth, + context: !empty($opts) ? $opts : null, + ); } else { $delay = rand(100, 500); $this->connection = new \Redis(); - $this->connection->connect($server, $port, 1, null, $delay, 1, $opts); + $this->connection->connect( + host: $server, + port: $port, + timeout: 1, + retry_interval: $delay, + read_timeout: 1, + context: $opts, + ); if ($this->auth !== '' && !$this->connection->auth($this->auth)) { throw new $exceptionclass('Unable to authenticate.'); } @@ -295,12 +305,25 @@ public function init() { throw new $exceptionclass('Unable to set the Redis Prefix option.'); } } - if ($this->sslopts && !$this->connection->ping('Ping')) { - // In case of a TLS connection, - // if phpredis client does not communicate immediately with the server the connection hangs. - // See https://github.com/phpredis/phpredis/issues/2332. - throw new $exceptionclass("Ping failed"); + $info = $this->connection->info('server'); + if (!$info) { + throw new $exceptionclass("Failed to fetch server information"); + } + + // Check the server version. + // Note: In case of a TLS connection, + // if phpredis client does not communicate immediately with the server the connection hangs. + // See https://github.com/phpredis/phpredis/issues/2332. + // This version check satisfies that requirement. + $version = $info['redis_version']; + if (!$version || version_compare($version, static::REDIS_MIN_SERVER_VERSION) <= 0) { + throw new $exceptionclass(sprintf( + "Version %s is not supported. The minimum version required is %s.", + $version, + static::REDIS_MIN_SERVER_VERSION, + )); } + if ($this->database !== 0) { if (!$this->connection->select($this->database)) { throw new $exceptionclass('Unable to select the Redis database ' . $this->database . '.'); @@ -309,9 +332,9 @@ public function init() { // The session handler requires a version of Redis server with support for SET command options (at least 2.6.12). $serverversion = $this->connection->info('server')['redis_version']; - if (version_compare($serverversion, self::REDIS_SERVER_MIN_VERSION) <= 0) { + if (version_compare($serverversion, self::REDIS_MIN_SERVER_VERSION) <= 0) { throw new exception('sessionhandlerproblem', 'error', '', null, - 'redis server version must be at least ' . self::REDIS_SERVER_MIN_VERSION); + 'redis server version must be at least ' . self::REDIS_MIN_SERVER_VERSION); } return true; } catch (RedisException | RedisClusterException $e) { @@ -334,6 +357,7 @@ public function init() { if (!$result) { throw new exception('redissessionhandlerproblem', 'error'); } + return false; } /** @@ -356,7 +380,7 @@ public function close(): bool { $this->lasthash = null; try { foreach ($this->locks as $id => $expirytime) { - if ($expirytime > $this->time()) { + if ($expirytime > $this->clock->time()) { $this->unlock_session($id); } unset($this->locks[$id]); @@ -373,29 +397,45 @@ public function close(): bool { * Read the session data from storage * * @param string $id The session id to read from storage. - * @return string The session data for PHP to process. + * @return string|false The session data for PHP to process or false. * * @throws RedisException when we are unable to talk to the Redis server. */ public function read(string $id): string|false { try { if ($this->requires_write_lock()) { - $this->lock_session($id); + $this->lock_session($this->sessionkeyprefix . $id); } - $sessiondata = $this->uncompress($this->connection->get($id)); + + $keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'sessdata']); + $userid = $keys['userid']; + $sessiondata = $this->uncompress($keys['sessdata']); if ($sessiondata === false) { if ($this->requires_write_lock()) { - $this->unlock_session($id); + $this->unlock_session($this->sessionkeyprefix . $id); } $this->lasthash = sha1(''); return ''; } - $this->connection->expire($id, $this->timeout); + + // Do not update expiry if non-login user (0). This would affect the first access timeout. + if ($userid != 0) { + $maxlifetime = $this->get_maxlifetime($userid); + $this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime); + $this->connection->expire($this->userkeyprefix . $userid, $maxlifetime); + } } catch (RedisException | RedisClusterException $e) { error_log('Failed talking to redis: '.$e->getMessage()); throw $e; } + + // Update last hash. + if ($sessiondata === null) { + // As of PHP 8.1 we can't pass null to base64_encode. + $sessiondata = ''; + } + $this->lasthash = sha1(base64_encode($sessiondata)); return $sessiondata; } @@ -406,7 +446,7 @@ public function read(string $id): string|false { * @param mixed $value * @return string */ - private function compress($value) { + private function compress($value): string { switch ($this->compressor) { case self::COMPRESSION_NONE: return $value; @@ -455,7 +495,6 @@ private function uncompress($value) { * @return bool true on write success, false on failure */ public function write(string $id, string $data): bool { - $hash = sha1(base64_encode($data)); // If the content has not changed don't bother writing. @@ -475,8 +514,16 @@ public function write(string $id, string $data): bool { // address that in the future. try { $data = $this->compress($data); - - $this->connection->setex($id, $this->timeout, $data); + $this->connection->hset($this->sessionkeyprefix . $id, 'sessdata', $data); + $keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'timecreated', 'timemodified']); + $userid = $keys['userid']; + + // Don't update expiry if still first access. + if ($keys['timecreated'] != $keys['timemodified']) { + $maxlifetime = $this->get_maxlifetime($userid); + $this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime); + $this->connection->expire($this->userkeyprefix . $userid, $maxlifetime); + } } catch (RedisException | RedisClusterException $e) { error_log('Failed talking to redis: '.$e->getMessage()); return false; @@ -484,16 +531,124 @@ public function write(string $id, string $data): bool { return true; } - /** - * Handle destroying a session. - * - * @param string $id the session id to destroy. - * @return bool true if the session was deleted, false otherwise. - */ + #[\Override] + public function get_session_by_sid(string $sid): \stdClass { + $keys = ["id", "state", "sid", "userid", "sessdata", "timecreated", "timemodified", "firstip", "lastip"]; + $sessiondata = $this->connection->hmget($this->sessionkeyprefix . $sid, $keys); + + return (object)$sessiondata; + } + + #[\Override] + public function add_session(int $userid): \stdClass { + $timestamp = $this->clock->time(); + $sid = session_id(); + $maxlifetime = $this->get_maxlifetime($userid, true); + $sessiondata = [ + 'id' => $sid, + 'state' => '0', + 'sid' => $sid, + 'userid' => $userid, + 'sessdata' => null, + 'timecreated' => $timestamp, + 'timemodified' => $timestamp, + 'firstip' => getremoteaddr(), + 'lastip' => getremoteaddr(), + ]; + + $userhashkey = $this->userkeyprefix . $userid; + $this->connection->hSet($userhashkey, $sid, $timestamp); + $this->connection->expire($userhashkey, $maxlifetime); + + $sessionhashkey = $this->sessionkeyprefix . $sid; + $this->connection->hmSet($sessionhashkey, $sessiondata); + $this->connection->expire($sessionhashkey, $maxlifetime); + + return (object)$sessiondata; + } + + #[\Override] + public function get_sessions_by_userid(int $userid): array { + $this->init_redis_if_required(); + + $userhashkey = $this->userkeyprefix . $userid; + $sessions = $this->connection->hGetAll($userhashkey); + $records = []; + foreach (array_keys($sessions) as $session) { + $item = $this->connection->hGetAll($this->sessionkeyprefix . $session); + if (!empty($item)) { + $records[] = (object) $item; + } + } + return $records; + } + + #[\Override] + public function update_session(\stdClass $record): bool { + if (!isset($record->sid) && isset($record->id)) { + $record->sid = $record->id; + } + + // If record does not have userid set, we need to get it from the session. + if (!isset($record->userid)) { + $session = $this->get_session_by_sid($record->sid); + $record->userid = $session->userid; + } + + $sessionhashkey = $this->sessionkeyprefix . $record->sid; + $userhashkey = $this->userkeyprefix . $record->userid; + + $recordata = (array) $record; + unset($recordata['sid']); + $this->connection->hmSet($sessionhashkey, $recordata); + + // Update the expiry time. + $maxlifetime = $this->get_maxlifetime($record->userid); + $this->connection->expire($sessionhashkey, $maxlifetime); + $this->connection->expire($userhashkey, $maxlifetime); + + return true; + } + + + #[\Override] + public function get_all_sessions(): \Iterator { + $sessions = []; + $iterator = null; + while (false !== ($keys = $this->connection->scan($iterator, '*' . $this->sessionkeyprefix . '*'))) { + foreach ($keys as $key) { + $sessions[] = $key; + } + } + return new \ArrayIterator($sessions); + } + + #[\Override] + public function destroy_all(): bool { + $this->init_redis_if_required(); + + $sessions = $this->get_all_sessions(); + foreach ($sessions as $session) { + // Remove the prefixes from the session id, as destroy expects the raw session id. + if (str_starts_with($session, $this->prefix . $this->sessionkeyprefix)) { + $session = substr($session, strlen($this->prefix . $this->sessionkeyprefix)); + } + + $this->destroy($session); + } + return true; + } + + #[\Override] public function destroy(string $id): bool { + $this->init_redis_if_required(); $this->lasthash = null; try { - $this->connection->del($id); + $sessionhashkey = $this->sessionkeyprefix . $id; + $userid = $this->connection->hget($sessionhashkey, "userid"); + $userhashkey = $this->userkeyprefix . $userid; + $this->connection->hDel($userhashkey, $id); + $this->connection->unlink($sessionhashkey); $this->unlock_session($id); } catch (RedisException | RedisClusterException $e) { error_log('Failed talking to redis: '.$e->getMessage()); @@ -503,15 +658,53 @@ public function destroy(string $id): bool { return true; } + // phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameUnderscore + #[\Override] + public function gc(int $max_lifetime = 0): int|false { + return 0; + } + // phpcs:enable + /** - * Garbage collect sessions. We don't we any as Redis does it for us. + * Get session maximum lifetime in seconds. * - * @param integer $max_lifetime All sessions older than this should be removed. - * @return bool true, as Redis handles expiry for us. + * @param int|null $userid The user id to calculate the max lifetime for. + * @param bool $firstbrowseraccess This indicates that this is calculating the expiry when the key is first added. + * The first access made by the browser has a shorter timeout to reduce abandoned sessions. + * @return float|int */ - // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameUnderscore - public function gc(int $max_lifetime): int|false { - return false; + private function get_maxlifetime(?int $userid = null, bool $firstbrowseraccess = false): float|int { + global $CFG; + + // Guest user. + if ($userid == $CFG->siteguest) { + return $CFG->sessiontimeout * 5; + } + + // All other users. + if ($userid == 0 && $firstbrowseraccess) { + $maxlifetime = $this->firstaccesstimeout; + } else { + // As per MDL-56823 - The following configures the session lifetime in redis to allow some + // wriggle room in the user noticing they've been booted off and + // letting them log back in before they lose their session entirely. + $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency; + $maxlifetime = (int) $CFG->sessiontimeout + $updatefreq + MINSECS; + } + + return $maxlifetime; + } + + /** + * Connection will be null if these methods are called from cli or where NO_MOODLE_COOKIES is used. + * We need to check for this and initialize the connection if required. + * + * @return void + */ + private function init_redis_if_required(): void { + if (is_null($this->connection)) { + $this->init(); + } } /** @@ -521,7 +714,7 @@ public function gc(int $max_lifetime): int|false { */ protected function unlock_session($id) { if (isset($this->locks[$id])) { - $this->connection->del($id.".lock"); + $this->connection->unlink("{$id}.lock"); unset($this->locks[$id]); } } @@ -534,25 +727,30 @@ protected function unlock_session($id) { * @throws exception When we are unable to obtain a session lock. */ protected function lock_session($id) { - $lockkey = $id.".lock"; + $lockkey = "{$id}.lock"; + + $haslock = isset($this->locks[$id]) && $this->clock->time() < $this->locks[$id]; + if ($haslock) { + return true; + } - $haslock = isset($this->locks[$id]) && $this->time() < $this->locks[$id]; - $startlocktime = $this->time(); + $startlocktime = $this->clock->time(); - /* To be able to ensure sessions don't write out of order we must obtain an exclusive lock - * on the session for the entire time it is open. If another AJAX call, or page is using - * the session then we just wait until it finishes before we can open the session. - */ + // To be able to ensure sessions don't write out of order we must obtain an exclusive lock + // on the session for the entire time it is open. If another AJAX call, or page is using + // the session then we just wait until it finishes before we can open the session. // Store the current host, process id and the request URI so it's easy to track who has the lock. $hostname = gethostname(); if ($hostname === false) { $hostname = 'UNKNOWN HOST'; } + $pid = getmypid(); if ($pid === false) { $pid = 'UNKNOWN'; } + $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri'; $whoami = "[pid {$pid}] {$hostname}:$uri"; @@ -563,38 +761,43 @@ protected function lock_session($id) { $haslock = $this->connection->set($lockkey, $whoami, ['nx', 'ex' => $this->lockexpire]); if ($haslock) { - $this->locks[$id] = $this->time() + $this->lockexpire; + $this->locks[$id] = $this->clock->time() + $this->lockexpire; return true; } - if (!empty($this->acquirewarn) && !$haswarned && $this->time() > $startlocktime + $this->acquirewarn) { + if (!empty($this->acquirewarn) && !$haswarned && $this->clock->time() > $startlocktime + $this->acquirewarn) { // This is a warning to better inform users. $whohaslock = $this->connection->get($lockkey); // phpcs:ignore - error_log("Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " . - "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released."); + error_log( + "Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " . + "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.", + ); $haswarned = true; } - if ($this->time() > $startlocktime + $this->acquiretimeout) { + if ($this->clock->time() > $startlocktime + $this->acquiretimeout) { // This is a fatal error, better inform users. // It should not happen very often - all pages that need long time to execute // should close session immediately after access control checks. $whohaslock = $this->connection->get($lockkey); // phpcs:ignore - error_log("Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " . - "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released."); + error_log( + "Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " . + "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.", + ); $acquiretimeout = format_time($this->acquiretimeout); $lockexpire = format_time($this->lockexpire); $a = (object)[ 'id' => substr($id, 0, 10), 'acquiretimeout' => $acquiretimeout, 'whohaslock' => $whohaslock, - 'lockexpire' => $lockexpire]; + 'lockexpire' => $lockexpire, + ]; throw new exception("sessioncannotobtainlock", 'error', '', $a); } - if ($this->time() < $startlocktime + 5) { + if ($this->clock->time() < $startlocktime + 5) { // We want a random delay to stagger the polling load. Ideally // this delay should be a fraction of the average response // time. If it is too small we will poll too much and if it is @@ -610,63 +813,20 @@ protected function lock_session($id) { usleep($delay * 1000); } + throw new coding_exception('Unable to lock session'); } - /** - * Return the current time. - * - * @return int the current time as a unixtimestamp. - */ - protected function time() { - return time(); - } - - /** - * Check the backend contains data for this session id. - * - * Note: this is intended to be called from manager::session_exists() only. - * - * @param string $sid - * @return bool true if session found. - */ + #[\Override] public function session_exists($sid) { if (!$this->connection) { return false; } try { - return !empty($this->connection->exists($sid)); + $sessionhashkey = $this->sessionkeyprefix . $sid; + return !empty($this->connection->exists($sessionhashkey)); } catch (RedisException | RedisClusterException $e) { return false; } } - - /** - * Kill all active sessions, the core sessions table is purged afterwards. - */ - public function kill_all_sessions() { - global $DB; - if (!$this->connection) { - return; - } - - $rs = $DB->get_recordset('sessions', array(), 'id DESC', 'id, sid'); - foreach ($rs as $record) { - $this->destroy($record->sid); - } - $rs->close(); - } - - /** - * Kill one session, the session record is removed afterwards. - * - * @param string $sid - */ - public function kill_session($sid) { - if (!$this->connection) { - return; - } - - $this->destroy($sid); - } } diff --git a/lib/db/hooks.php b/lib/db/hooks.php index b7f8e7bf31bd0..1f5bfa71898d5 100644 --- a/lib/db/hooks.php +++ b/lib/db/hooks.php @@ -110,4 +110,8 @@ 'hook' => \core\hook\di_configuration::class, 'callback' => [\core\router\hook_callbacks::class, 'provide_di_configuration'], ], + [ + 'hook' => \core\hook\filestorage\after_file_created::class, + 'callback' => \core\fileredact\hook_listener::class . '::redact_after_file_created', + ], ]; diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 922534a1c92d6..14cfa0c5c5fa0 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -1240,14 +1240,14 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2024080500.00); } - if ($oldversion < 2024082300.00) { + if ($oldversion < 2024082900.01) { // If filter_tidy is no longer present, remove it. if (!file_exists($CFG->dirroot . '/filter/tidy/version.php')) { // Clean config. - unset_all_config_for_plugin('filter_tidy'); + uninstall_plugin('filter', 'tidy'); } - upgrade_main_savepoint(true, 2024082300.00); + upgrade_main_savepoint(true, 2024082900.01); } return true; diff --git a/lib/editor/tiny/amd/build/editor.min.js b/lib/editor/tiny/amd/build/editor.min.js index 4978279163bb6..5e45115cd47ec 100644 --- a/lib/editor/tiny/amd/build/editor.min.js +++ b/lib/editor/tiny/amd/build/editor.min.js @@ -1,3 +1,3 @@ -define("editor_tiny/editor",["exports","jquery","core/pending","./defaults","./loader","./options","./utils"],(function(_exports,_jquery,_pending,_defaults,_loader,Options,_utils){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending),Options=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Options);var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.delete(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);setTimeout((()=>setupForTarget(target,options)),1)};(async()=>{const lang=document.querySelector("html").lang,[tinyMCE,langData]=await Promise.all([(0,_loader.getTinyMCE)(),(language=lang,fetch("".concat(M.cfg.wwwroot,"/lib/editor/tiny/lang.php/").concat(M.cfg.langrev,"/").concat(language)).then((response=>response.json())))]);var language;tinyMCE.addI18n(lang,langData)})();const getPlugins=function(){let{plugins:plugins=null}=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return plugins||(defaultOptions.plugins?defaultOptions.plugins:{})},getStandardConfig=(target,tinyMCE,options,plugins)=>{const lang=document.querySelector("html").lang,config=Object.assign({},(0,_defaults.getDefaultConfiguration)(),{base_url:_loader.baseUrl,target:target,min_height:175,height:target.clientHeight||"auto",language:lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,extended_valid_elements:"script[*],p[*],i[*]",xss_sanitization:!1,quickbars_insert_toolbar:"",quickbars_selection_toolbar:target.rows>5&&(0,_defaults.getDefaultQuickbarsSelectionToolbar)(),block_formats:"Paragraph=p;Heading 3=h3;Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre",plugins:[...plugins],skin:"oxide",help_accessibility:!1,promotion:!1,branding:options.branding,table_header_type:"sectionCells",entity_encoding:"raw",ui_mode:"split",browser_spellcheck:!0,setup:editor=>{Options.register(editor,options),editor.on("PreInit",(function(){this.contentWindow=this.iframeElement.contentWindow})),editor.on("init",(function(){(0,_utils.removeSubmenuItem)(editor,"align","tiny:justify"),((editor,target)=>{let expectedEditingAreaHeight=0;expectedEditingAreaHeight=target.clientHeight?target.clientHeight:target.rows*(parseFloat(window.getComputedStyle(target).lineHeight)||22),editor.getContainer().querySelector(".tox-sidebar-wrap").clientHeight{const{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);return instanceConfig.menu.file&&(instanceConfig.menu.file.items=""),instanceConfig.menu.format&&(instanceConfig.menu.format.items=instanceConfig.menu.format.items.replace(/forecolor ?/,"").replace(/backcolor ?/,"").replace(/fontfamily ?/,"").replace(/fontsize ?/,"").replace(/styles ?/,"").replaceAll(/\| *\|/g,"|")),!1!==instanceConfig.quickbars_selection_toolbar&&(instanceConfig.quickbars_selection_toolbar=instanceConfig.quickbars_selection_toolbar.replace("h2 h3","h3 h4 h5 h6")),pluginConfig.filter((pluginConfig=>"function"==typeof pluginConfig.configure)).forEach((pluginConfig=>{const pluginInstanceOverride=pluginConfig.configure(instanceConfig,options);Object.assign(instanceConfig,pluginInstanceOverride)})),Object.assign(instanceConfig,Options.getInitialPluginConfiguration(options)),instanceConfig},isModalMode=target=>!!target.closest('[data-region="modal"]'),setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]);tinyMCE.get().filter((editor=>!editor.getElement().isConnected)).forEach((editor=>{editor.remove()}));const existingEditor=tinyMCE.EditorManager.get(target.id);if(existingEditor){if(existingEditor.getElement()===target)return pendingPromise.resolve(),Promise.resolve(existingEditor);throw pendingPromise.resolve(),new Error("TinyMCE instance already exists for different target with same ID")}const instanceConfig=getEditorConfiguration(target,0,options,pluginValues),[editor]=await tinyMCE.init(instanceConfig);return target.dataset.fieldtype="editor",instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm),target.targetElm.dataset.fieldtype=null})),target.form&&(0,_jquery.default)(target.form).on("submit",(()=>{editor.save()})),editor.on("blur",(()=>{editor.save()})),editor.on("OpenWindow",(()=>{const modals=document.querySelectorAll('[data-region="modal"]');modals&&modals.forEach((modal=>{modal.classList.contains("hide")||modal.classList.add("hide")}))})),editor.on("CloseWindow",(()=>{if(isModalMode(target)){const modals=document.querySelectorAll('[data-region="modal"]');modals&&modals.forEach((modal=>{modal.classList.contains("hide")&&modal.classList.remove("hide")}))}})),pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}})); +define("editor_tiny/editor",["exports","jquery","core/pending","./defaults","./loader","./options","./utils"],(function(_exports,_jquery,_pending,_defaults,_loader,Options,_utils){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending),Options=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Options);var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.delete(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);setTimeout((()=>setupForTarget(target,options)),1)};(async()=>{const lang=document.querySelector("html").lang,[tinyMCE,langData]=await Promise.all([(0,_loader.getTinyMCE)(),(language=lang,fetch("".concat(M.cfg.wwwroot,"/lib/editor/tiny/lang.php/").concat(M.cfg.langrev,"/").concat(language)).then((response=>response.json())))]);var language;tinyMCE.addI18n(lang,langData)})();const getPlugins=function(){let{plugins:plugins=null}=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return plugins||(defaultOptions.plugins?defaultOptions.plugins:{})},getStandardConfig=(target,tinyMCE,options,plugins)=>{const lang=document.querySelector("html").lang,config=Object.assign({},(0,_defaults.getDefaultConfiguration)(),{base_url:_loader.baseUrl,target:target,min_height:175,height:target.clientHeight||"auto",language:lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,extended_valid_elements:"script[*],p[*],i[*]",xss_sanitization:!1,quickbars_insert_toolbar:"",quickbars_selection_toolbar:target.rows>5&&(0,_defaults.getDefaultQuickbarsSelectionToolbar)(),block_formats:"Paragraph=p;Heading 3=h3;Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre;Blockquote=blockquote",plugins:[...plugins],skin:"oxide",help_accessibility:!1,promotion:!1,branding:options.branding,table_header_type:"sectionCells",entity_encoding:"raw",ui_mode:"split",browser_spellcheck:!0,setup:editor=>{Options.register(editor,options),editor.on("PreInit",(function(){this.contentWindow=this.iframeElement.contentWindow})),editor.on("init",(function(){(0,_utils.removeSubmenuItem)(editor,"align","tiny:justify"),((editor,target)=>{let expectedEditingAreaHeight=0;expectedEditingAreaHeight=target.clientHeight?target.clientHeight:target.rows*(parseFloat(window.getComputedStyle(target).lineHeight)||22),editor.getContainer().querySelector(".tox-sidebar-wrap").clientHeight{const{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);return instanceConfig.menu.file&&(instanceConfig.menu.file.items=""),instanceConfig.menu.format&&(instanceConfig.menu.format.items=instanceConfig.menu.format.items.replace(/forecolor ?/,"").replace(/backcolor ?/,"").replace(/fontfamily ?/,"").replace(/fontsize ?/,"").replace(/styles ?/,"").replaceAll(/\| *\|/g,"|")),!1!==instanceConfig.quickbars_selection_toolbar&&(instanceConfig.quickbars_selection_toolbar=instanceConfig.quickbars_selection_toolbar.replace("h2 h3","h3 h4 h5 h6")),pluginConfig.filter((pluginConfig=>"function"==typeof pluginConfig.configure)).forEach((pluginConfig=>{const pluginInstanceOverride=pluginConfig.configure(instanceConfig,options);Object.assign(instanceConfig,pluginInstanceOverride)})),Object.assign(instanceConfig,Options.getInitialPluginConfiguration(options)),instanceConfig},isModalMode=target=>!!target.closest('[data-region="modal"]'),setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]);tinyMCE.get().filter((editor=>!editor.getElement().isConnected)).forEach((editor=>{editor.remove()}));const existingEditor=tinyMCE.EditorManager.get(target.id);if(existingEditor){if(existingEditor.getElement()===target)return pendingPromise.resolve(),Promise.resolve(existingEditor);throw pendingPromise.resolve(),new Error("TinyMCE instance already exists for different target with same ID")}const instanceConfig=getEditorConfiguration(target,0,options,pluginValues),[editor]=await tinyMCE.init(instanceConfig);return target.dataset.fieldtype="editor",instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm),target.targetElm.dataset.fieldtype=null})),target.form&&(0,_jquery.default)(target.form).on("submit",(()=>{editor.save()})),editor.on("blur",(()=>{editor.save()})),editor.on("OpenWindow",(()=>{const modals=document.querySelectorAll('[data-region="modal"]');modals&&modals.forEach((modal=>{modal.classList.contains("hide")||modal.classList.add("hide")}))})),editor.on("CloseWindow",(()=>{if(isModalMode(target)){const modals=document.querySelectorAll('[data-region="modal"]');modals&&modals.forEach((modal=>{modal.classList.contains("hide")&&modal.classList.remove("hide")}))}})),pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}})); //# sourceMappingURL=editor.min.js.map \ No newline at end of file diff --git a/lib/editor/tiny/amd/build/editor.min.js.map b/lib/editor/tiny/amd/build/editor.min.js.map index 7e7b26db133a1..5352a008bbe2a 100644 --- a/lib/editor/tiny/amd/build/editor.min.js.map +++ b/lib/editor/tiny/amd/build/editor.min.js.map @@ -1 +1 @@ -{"version":3,"file":"editor.min.js","sources":["../src/editor.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * TinyMCE Editor Manager.\n *\n * @module editor_tiny/editor\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport jQuery from 'jquery';\nimport Pending from 'core/pending';\nimport {getDefaultConfiguration, getDefaultQuickbarsSelectionToolbar} from './defaults';\nimport {getTinyMCE, baseUrl} from './loader';\nimport * as Options from './options';\nimport {addToolbarButton, addToolbarButtons, addToolbarSection,\n removeToolbarButton, removeSubmenuItem, updateEditorState} from './utils';\n\n/**\n * Storage for the TinyMCE instances on the page.\n * @type {Map}\n */\nconst instanceMap = new Map();\n\n/**\n * The default editor configuration.\n * @type {Object}\n */\nlet defaultOptions = {};\n\n/**\n * Require the modules for the named set of TinyMCE plugins.\n *\n * @param {string[]} pluginList The list of plugins\n * @return {Promise[]} A matching set of Promises relating to the requested plugins\n */\nconst importPluginList = async(pluginList) => {\n // Fetch all of the plugins from the list of plugins.\n // If a plugin contains a '/' then it is assumed to be a Moodle AMD module to import.\n const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {\n if (pluginPath.indexOf('/') === -1) {\n // A standard TinyMCE Plugin.\n return Promise.resolve(pluginPath);\n }\n\n return import(pluginPath);\n }));\n\n // Normalise the plugin data to a list of plugin names.\n // Two formats are supported:\n // - a string; and\n // - an array whose first element is the plugin name, and the second element is the plugin configuration.\n const pluginNames = pluginHandlers.map((pluginConfig) => {\n if (typeof pluginConfig === 'string') {\n return pluginConfig;\n }\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[0];\n }\n return null;\n }).filter((value) => value);\n\n // Fetch the list of pluginConfig handlers.\n const pluginConfig = pluginHandlers.map((pluginConfig) => {\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[1];\n }\n return null;\n }).filter((value) => value);\n\n return {\n pluginNames,\n pluginConfig,\n };\n};\n\n/**\n * Fetch the language data for the specified language.\n *\n * @param {string} language The language identifier\n * @returns {object}\n */\nconst fetchLanguage = (language) => fetch(\n `${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`\n).then(response => response.json());\n\n/**\n * Get a list of all Editors in a Map, keyed by the DOM Node that the Editor is associated with.\n *\n * @returns {Map}\n */\nexport const getAllInstances = () => new Map(instanceMap.entries());\n\n/**\n * Get the TinyMCE instance for the specified Node ID.\n *\n * @param {string} elementId\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElementId = elementId => getInstanceForElement(document.getElementById(elementId));\n\n/*\n * Get the TinyMCE instance for the specified HTMLElement.\n *\n * @param {HTMLElement} element\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElement = element => {\n const instance = instanceMap.get(element);\n if (instance && instance.removed) {\n instanceMap.delete(element);\n return undefined;\n }\n return instance;\n};\n\n/**\n * Set up TinyMCE for the selector at the specified HTML Node id.\n *\n * @param {object} config The configuration required to setup the editor\n * @param {string} config.elementId The HTML Node ID\n * @param {Object} config.options The editor plugin configuration\n */\nexport const setupForElementId = ({elementId, options}) => {\n const target = document.getElementById(elementId);\n // We will need to wrap the setupForTarget and editor.remove() calls in a setTimeout.\n // Because other events callbacks will still try to run on the removed instance.\n // This will cause an error on Firefox.\n // We need to make TinyMCE to remove itself outside the event loop.\n // @see https://github.com/tinymce/tinymce/issues/3129 for more details.\n setTimeout(() => {\n return setupForTarget(target, options);\n }, 1);\n};\n\n/**\n * Initialise the page with standard TinyMCE requirements.\n *\n * Currently this includes the language taken from the HTML lang property.\n */\nconst initialisePage = async() => {\n const lang = document.querySelector('html').lang;\n\n const [tinyMCE, langData] = await Promise.all([getTinyMCE(), fetchLanguage(lang)]);\n tinyMCE.addI18n(lang, langData);\n};\ninitialisePage();\n\n/**\n * Get the list of plugins to load for the specified configuration.\n *\n * If the specified configuration does not include a plugin configuration, then return the default configuration.\n *\n * @param {object} options\n * @param {array} [options.plugins=null] The plugin list\n * @returns {object}\n */\nconst getPlugins = ({plugins = null} = {}) => {\n if (plugins) {\n return plugins;\n }\n\n if (defaultOptions.plugins) {\n return defaultOptions.plugins;\n }\n\n return {};\n};\n\n/**\n * Adjust the editor size base on the target element.\n *\n * @param {TinyMCE} editor TinyMCE editor\n * @param {Node} target Target element\n */\nconst adjustEditorSize = (editor, target) => {\n let expectedEditingAreaHeight = 0;\n if (target.clientHeight) {\n expectedEditingAreaHeight = target.clientHeight;\n } else {\n // If the target element is hidden, we cannot get the lineHeight of the target element.\n // We don't have a proper way to retrieve the general lineHeight of the theme, so we use 22 here, it's equivalent to 1.5em.\n expectedEditingAreaHeight = target.rows * (parseFloat(window.getComputedStyle(target).lineHeight) || 22);\n }\n const currentEditingAreaHeight = editor.getContainer().querySelector('.tox-sidebar-wrap').clientHeight;\n if (currentEditingAreaHeight < expectedEditingAreaHeight) {\n // Change the height based on the target element's height.\n editor.getContainer().querySelector('.tox-sidebar-wrap').style.height = `${expectedEditingAreaHeight}px`;\n }\n};\n\n/**\n * Get the standard configuration for the specified options.\n *\n * @param {Node} target\n * @param {tinyMCE} tinyMCE\n * @param {object} options\n * @param {Array} plugins\n * @returns {object}\n */\nconst getStandardConfig = (target, tinyMCE, options, plugins) => {\n const lang = document.querySelector('html').lang;\n\n const config = Object.assign({}, getDefaultConfiguration(), {\n // eslint-disable-next-line camelcase\n base_url: baseUrl,\n\n // Set the editor target.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target\n target,\n\n // https://www.tiny.cloud/docs/tinymce/6/customize-ui/#set-maximum-and-minimum-heights-and-widths\n // Set the minimum height to the smallest height that we can fit the Menu bar, Tool bar, Status bar and the text area.\n // eslint-disable-next-line camelcase\n min_height: 175,\n\n // Base the height on the size of the text area.\n // In some cases, E.g.: The target is an advanced element, it will be hidden. We cannot get the height at this time.\n // So set the height to auto, and adjust it later by adjustEditorSize().\n height: target.clientHeight || 'auto',\n\n // Set the language.\n // https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language\n // eslint-disable-next-line camelcase\n language: lang,\n\n // Load the editor stylesheet into the editor iframe.\n // https://www.tiny.cloud/docs/tinymce/6/add-css-options/\n // eslint-disable-next-line camelcase\n content_css: [\n options.css,\n ],\n\n // Do not convert URLs to relative URLs.\n // https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls\n // eslint-disable-next-line camelcase\n convert_urls: false,\n\n // Enabled 'advanced' a11y options.\n // This includes allowing role=\"presentation\" from the image uploader.\n // https://www.tiny.cloud/docs/tinymce/6/accessibility/\n // eslint-disable-next-line camelcase\n a11y_advanced_options: true,\n\n // Add specific rules to the valid elements.\n // eslint-disable-next-line camelcase\n extended_valid_elements: 'script[*],p[*],i[*]',\n\n // Disable XSS Sanitisation.\n // We do this in PHP.\n // https://www.tiny.cloud/docs/tinymce/6/security/#turning-dompurify-off\n // Note: This feature has been backported from TinyMCE 6.4.0.\n // eslint-disable-next-line camelcase\n xss_sanitization: false,\n\n // Disable quickbars entirely.\n // The UI is not ideal and we'll wait for it to improve in future before we enable it in Moodle.\n // eslint-disable-next-line camelcase\n quickbars_insert_toolbar: '',\n\n // If the target element is too small, disable the quickbars selection toolbar.\n // The quickbars selection toolbar is not displayed correctly if the target element is too small.\n // See: https://github.com/tinymce/tinymce/issues/9693.\n quickbars_selection_toolbar: target.rows > 5 ? getDefaultQuickbarsSelectionToolbar() : false,\n\n // Override the standard block formats property (removing h1 & h2).\n // https://www.tiny.cloud/docs/tinymce/6/user-formatting-options/#block_formats\n // eslint-disable-next-line camelcase\n block_formats: 'Paragraph=p;Heading 3=h3;Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre',\n\n // The list of plugins to include in the instance.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins\n plugins: [\n ...plugins,\n ],\n\n // Skins\n skin: 'oxide',\n\n // Do not show the help link in the status bar.\n // https://www.tiny.cloud/docs/tinymce/latest/accessibility/#help_accessibility\n // eslint-disable-next-line camelcase\n help_accessibility: false,\n\n // Remove the \"Upgrade\" link for Tiny.\n // https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/\n promotion: false,\n\n // Allow the administrator to disable branding.\n // https://www.tiny.cloud/docs/tinymce/6/statusbar-configuration-options/#branding\n branding: options.branding,\n\n // Put th cells in a thead element.\n // https://www.tiny.cloud/docs/tinymce/6/table-options/#table_header_type\n // eslint-disable-next-line camelcase\n table_header_type: 'sectionCells',\n\n // Stored text in non-entity form.\n // https://www.tiny.cloud/docs/tinymce/6/content-filtering/#entity_encoding\n // eslint-disable-next-line camelcase\n entity_encoding: \"raw\",\n\n // Enable support for editors in scrollable containers.\n // https://www.tiny.cloud/docs/tinymce/6/ui-mode-configuration-options/#ui_mode\n // eslint-disable-next-line camelcase\n ui_mode: 'split',\n\n // Enable browser-supported spell checking.\n // https://www.tiny.cloud/docs/tinymce/latest/spelling/\n // eslint-disable-next-line camelcase\n browser_spellcheck: true,\n\n setup: (editor) => {\n Options.register(editor, options);\n\n editor.on('PreInit', function() {\n // Work around a bug in TinyMCE with Firefox.\n // When an editor is removed, and replaced with an identically attributed editor (same ID),\n // and the Firefox window is freshly opened (e.g. Behat, Private browsing), the wrong contentWindow\n // is assigned to the editor instance leading to an NS_ERROR_UNEXPECTED error in Firefox.\n // This is a workaround for that issue.\n this.contentWindow = this.iframeElement.contentWindow;\n });\n editor.on('init', function() {\n // Hide justify alignment sub-menu.\n removeSubmenuItem(editor, 'align', 'tiny:justify');\n // Adjust the editor size.\n adjustEditorSize(editor, target);\n });\n\n target.addEventListener('form:editorUpdated', function() {\n updateEditorState(editor, target);\n });\n\n target.dispatchEvent(new Event('form:editorUpdated'));\n },\n });\n\n config.toolbar = addToolbarSection(config.toolbar, 'content', 'formatting', true);\n config.toolbar = addToolbarButton(config.toolbar, 'content', 'link');\n\n // Add directionality plugins, always.\n config.toolbar = addToolbarSection(config.toolbar, 'directionality', 'alignment', true);\n config.toolbar = addToolbarButtons(config.toolbar, 'directionality', ['ltr', 'rtl']);\n\n // Remove the align justify button from the toolbar.\n config.toolbar = removeToolbarButton(config.toolbar, 'alignment', 'alignjustify');\n\n return config;\n};\n\n/**\n * Fetch the TinyMCE configuration for this editor instance.\n *\n * @param {HTMLElement} target\n * @param {TinyMCE} tinyMCE The TinyMCE API\n * @param {Object} options The editor plugin configuration\n * @param {object} pluginValues\n * @param {object} pluginValues.pluginConfig The list of plugin configuration\n * @param {object} pluginValues.pluginNames The list of plugins to load\n * @returns {object} The TinyMCE Configuration\n */\nconst getEditorConfiguration = (target, tinyMCE, options, pluginValues) => {\n const {\n pluginNames,\n pluginConfig,\n } = pluginValues;\n\n // Allow plugins to modify the configuration.\n // This seems a little strange, but we must double-process the config slightly.\n\n // First we fetch the standard configuration.\n const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);\n\n // Next we make any standard changes.\n // Here we remove the file menu, as it doesn't offer any useful functionality.\n // We only empty the items list so that a plugin may choose to add to it themselves later if they wish.\n if (instanceConfig.menu.file) {\n instanceConfig.menu.file.items = '';\n }\n\n // We disable the styles, backcolor, and forecolor plugins from the format menu.\n // These are not useful for Moodle and we don't want to encourage their use.\n if (instanceConfig.menu.format) {\n instanceConfig.menu.format.items = instanceConfig.menu.format.items\n // Remove forecolor and backcolor.\n .replace(/forecolor ?/, '')\n .replace(/backcolor ?/, '')\n\n // Remove fontfamily for now.\n .replace(/fontfamily ?/, '')\n\n // Remove fontsize for now.\n .replace(/fontsize ?/, '')\n\n // Remove styles - it just duplicates the format menu in a way which does not respect configuration\n .replace(/styles ?/, '')\n\n // Remove any duplicate separators.\n .replaceAll(/\\| *\\|/g, '|');\n }\n\n if (instanceConfig.quickbars_selection_toolbar !== false) {\n // eslint-disable-next-line camelcase\n instanceConfig.quickbars_selection_toolbar = instanceConfig.quickbars_selection_toolbar.replace('h2 h3', 'h3 h4 h5 h6');\n }\n\n // Next we call the `configure` function for any plugin which defines it.\n // We pass the current instanceConfig in here, to allow them to make certain changes to the global configuration.\n // For example, to add themselves to any menu, toolbar, and so on.\n // Any plugin which wishes to have configuration options must register those options here.\n pluginConfig.filter((pluginConfig) => typeof pluginConfig.configure === 'function').forEach((pluginConfig) => {\n const pluginInstanceOverride = pluginConfig.configure(instanceConfig, options);\n Object.assign(instanceConfig, pluginInstanceOverride);\n });\n\n // Next we convert the plugin configuration into a format that TinyMCE understands.\n Object.assign(instanceConfig, Options.getInitialPluginConfiguration(options));\n\n return instanceConfig;\n};\n\n/**\n * Check if the target for TinyMCE is in a modal or not.\n *\n * @param {HTMLElement} target Target to check\n * @returns {boolean} True if the target is in a modal form.\n */\nconst isModalMode = (target) => {\n return !!target.closest('[data-region=\"modal\"]');\n};\n\n/**\n * Set up TinyMCE for the HTML Element.\n *\n * @param {HTMLElement} target\n * @param {Object} [options={}] The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForTarget = async(target, options = {}) => {\n const instance = getInstanceForElement(target);\n if (instance) {\n return Promise.resolve(instance);\n }\n\n // Register a new pending promise to ensure that Behat waits for the editor setup to complete before continuing.\n const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');\n\n // Get the list of plugins.\n const plugins = getPlugins(options);\n\n // Fetch the tinyMCE API, and instantiate the plugins.\n const [tinyMCE, pluginValues] = await Promise.all([\n getTinyMCE(),\n importPluginList(Object.keys(plugins)),\n ]);\n\n // TinyMCE uses the element ID as a map key internally, even if the target has changed.\n // In the case where we have an editor in a modal form which has been detached from the DOM, but the editor not removed,\n // we need to manually destroy the editor.\n // We could theoretically do this with a Mutation Observer, but in some cases the Node may be moved,\n // or added back elsewhere in the DOM.\n\n // First remove any detached editors.\n tinyMCE.get().filter((editor) => !editor.getElement().isConnected).forEach((editor) => {\n editor.remove();\n });\n\n // Now check for any existing editor which shares the same ID.\n const existingEditor = tinyMCE.EditorManager.get(target.id);\n if (existingEditor) {\n if (existingEditor.getElement() === target) {\n pendingPromise.resolve();\n return Promise.resolve(existingEditor);\n } else {\n pendingPromise.resolve();\n throw new Error('TinyMCE instance already exists for different target with same ID');\n }\n }\n\n // Get the editor configuration for this editor.\n const instanceConfig = getEditorConfiguration(target, tinyMCE, options, pluginValues);\n\n // Initialise the editor instance for the given configuration.\n // At this point any plugin which has configuration options registered will have them applied for this instance.\n const [editor] = await tinyMCE.init(instanceConfig);\n\n // Update the textarea when the editor to set the field type for Behat.\n target.dataset.fieldtype = 'editor';\n\n // Store the editor instance in the instanceMap and register a listener on removal to remove it from the map.\n instanceMap.set(target, editor);\n editor.on('remove', ({target}) => {\n // Handle removal of the editor from the map on destruction.\n instanceMap.delete(target.targetElm);\n target.targetElm.dataset.fieldtype = null;\n });\n\n // If the editor is part of a form, also listen to the jQuery submit event.\n // The jQuery submit event will not trigger the native submit event, and therefore the content will not be saved.\n // We cannot rely on listening to the bubbled submit event on the document because other events on child nodes may\n // consume the data before it is saved.\n if (target.form) {\n jQuery(target.form).on('submit', () => {\n editor.save();\n });\n }\n\n // Save the editor content to the textarea when the editor is blurred.\n editor.on('blur', () => {\n editor.save();\n });\n\n // If the editor is in a modal, we need to hide the modal when window editor's window is opened.\n editor.on('OpenWindow', () => {\n const modals = document.querySelectorAll('[data-region=\"modal\"]');\n if (modals) {\n modals.forEach((modal) => {\n if (!modal.classList.contains('hide')) {\n modal.classList.add('hide');\n }\n });\n }\n });\n\n // If the editor's window is closed, we need to show the hidden modal back.\n editor.on('CloseWindow', () => {\n if (isModalMode(target)) {\n const modals = document.querySelectorAll('[data-region=\"modal\"]');\n if (modals) {\n modals.forEach((modal) => {\n if (modal.classList.contains('hide')) {\n modal.classList.remove('hide');\n }\n });\n }\n }\n });\n\n pendingPromise.resolve();\n return editor;\n};\n\n/**\n * Set the default editor configuration.\n *\n * This configuration is used when an editor is initialised without any configuration.\n *\n * @param {object} [options={}]\n */\nexport const configureDefaultEditor = (options = {}) => {\n defaultOptions = options;\n};\n"],"names":["instanceMap","Map","defaultOptions","importPluginList","async","pluginHandlers","Promise","all","pluginList","map","pluginPath","indexOf","resolve","pluginNames","pluginConfig","Array","isArray","filter","value","entries","elementId","getInstanceForElement","document","getElementById","element","instance","get","removed","delete","_ref","options","target","setTimeout","setupForTarget","lang","querySelector","tinyMCE","langData","language","fetch","M","cfg","wwwroot","langrev","then","response","json","addI18n","initialisePage","getPlugins","plugins","getStandardConfig","config","Object","assign","base_url","baseUrl","min_height","height","clientHeight","content_css","css","convert_urls","a11y_advanced_options","extended_valid_elements","xss_sanitization","quickbars_insert_toolbar","quickbars_selection_toolbar","rows","block_formats","skin","help_accessibility","promotion","branding","table_header_type","entity_encoding","ui_mode","browser_spellcheck","setup","editor","Options","register","on","contentWindow","this","iframeElement","expectedEditingAreaHeight","parseFloat","window","getComputedStyle","lineHeight","getContainer","style","adjustEditorSize","addEventListener","dispatchEvent","Event","toolbar","getEditorConfiguration","pluginValues","instanceConfig","menu","file","items","format","replace","replaceAll","configure","forEach","pluginInstanceOverride","getInitialPluginConfiguration","isModalMode","closest","pendingPromise","Pending","keys","getElement","isConnected","remove","existingEditor","EditorManager","id","Error","init","dataset","fieldtype","set","_ref2","targetElm","form","save","modals","querySelectorAll","modal","classList","contains","add"],"mappings":"4oDAmCMA,YAAc,IAAIC,QAMpBC,eAAiB,SAQfC,iBAAmBC,MAAAA,mBAGfC,qBAAuBC,QAAQC,IAAIC,WAAWC,KAAIC,aACnB,IAA7BA,WAAWC,QAAQ,KAEZL,QAAQM,QAAQF,4NAGbA,4WAAAA,gBAOZG,YAAcR,eAAeI,KAAKK,cACR,iBAAjBA,aACAA,aAEPC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,cAUd,CACHL,YAAAA,YACAC,aATiBT,eAAeI,KAAKK,cACjCC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,mCAuBM,IAAM,IAAIjB,IAAID,YAAYmB,4CAQlBC,WAAaC,sBAAsBC,SAASC,eAAeH,kBAQrFC,sBAAwBG,gBAC3BC,SAAWzB,YAAY0B,IAAIF,aAC7BC,WAAYA,SAASE,eAIlBF,SAHHzB,YAAY4B,OAAOJ,0FAaMK,WAACT,UAACA,UAADU,QAAYA,oBACpCC,OAAST,SAASC,eAAeH,WAMvCY,YAAW,IACAC,eAAeF,OAAQD,UAC/B,IAQgB1B,iBACb8B,KAAOZ,SAASa,cAAc,QAAQD,MAErCE,QAASC,gBAAkB/B,QAAQC,IAAI,EAAC,yBA7D5B+B,SA6DwDJ,KA7D3CK,gBAC7BC,EAAEC,IAAIC,6CAAoCF,EAAEC,IAAIE,oBAAWL,WAChEM,MAAKC,UAAYA,SAASC,YAFLR,IAAAA,SA8DnBF,QAAQW,QAAQb,KAAMG,WAE1BW,SAWMC,WAAa,eAACC,QAACA,QAAU,6DAAQ,UAC/BA,UAIAhD,eAAegD,QACRhD,eAAegD,QAGnB,KAkCLC,kBAAoB,CAACpB,OAAQK,QAASN,QAASoB,iBAC3ChB,KAAOZ,SAASa,cAAc,QAAQD,KAEtCkB,OAASC,OAAOC,OAAO,IAAI,uCAA2B,CAExDC,SAAUC,gBAIVzB,OAAAA,OAKA0B,WAAY,IAKZC,OAAQ3B,OAAO4B,cAAgB,OAK/BrB,SAAUJ,KAKV0B,YAAa,CACT9B,QAAQ+B,KAMZC,cAAc,EAMdC,uBAAuB,EAIvBC,wBAAyB,sBAOzBC,kBAAkB,EAKlBC,yBAA0B,GAK1BC,4BAA6BpC,OAAOqC,KAAO,IAAI,mDAK/CC,cAAe,mFAIfnB,QAAS,IACFA,SAIPoB,KAAM,QAKNC,oBAAoB,EAIpBC,WAAW,EAIXC,SAAU3C,QAAQ2C,SAKlBC,kBAAmB,eAKnBC,gBAAiB,MAKjBC,QAAS,QAKTC,oBAAoB,EAEpBC,MAAQC,SACJC,QAAQC,SAASF,OAAQjD,SAEzBiD,OAAOG,GAAG,WAAW,gBAMZC,cAAgBC,KAAKC,cAAcF,iBAE5CJ,OAAOG,GAAG,QAAQ,wCAEIH,OAAQ,QAAS,gBAtJ1B,EAACA,OAAQhD,cAC1BuD,0BAA4B,EAE5BA,0BADAvD,OAAO4B,aACqB5B,OAAO4B,aAIP5B,OAAOqC,MAAQmB,WAAWC,OAAOC,iBAAiB1D,QAAQ2D,aAAe,IAExEX,OAAOY,eAAexD,cAAc,qBAAqBwB,aAC3D2B,4BAE3BP,OAAOY,eAAexD,cAAc,qBAAqByD,MAAMlC,iBAAY4B,kCA4InEO,CAAiBd,OAAQhD,WAG7BA,OAAO+D,iBAAiB,sBAAsB,wCACxBf,OAAQhD,WAG9BA,OAAOgE,cAAc,IAAIC,MAAM,iCAIvC5C,OAAO6C,SAAU,4BAAkB7C,OAAO6C,QAAS,UAAW,cAAc,GAC5E7C,OAAO6C,SAAU,2BAAiB7C,OAAO6C,QAAS,UAAW,QAG7D7C,OAAO6C,SAAU,4BAAkB7C,OAAO6C,QAAS,iBAAkB,aAAa,GAClF7C,OAAO6C,SAAU,4BAAkB7C,OAAO6C,QAAS,iBAAkB,CAAC,MAAO,QAG7E7C,OAAO6C,SAAU,8BAAoB7C,OAAO6C,QAAS,YAAa,gBAE3D7C,QAcL8C,uBAAyB,CAACnE,OAAQK,QAASN,QAASqE,sBAChDtF,YACFA,YADEC,aAEFA,cACAqF,aAMEC,eAAiBjD,kBAAkBpB,OAAQK,EAASN,QAASjB,oBAK/DuF,eAAeC,KAAKC,OACpBF,eAAeC,KAAKC,KAAKC,MAAQ,IAKjCH,eAAeC,KAAKG,SACpBJ,eAAeC,KAAKG,OAAOD,MAAQH,eAAeC,KAAKG,OAAOD,MAEzDE,QAAQ,cAAe,IACvBA,QAAQ,cAAe,IAGvBA,QAAQ,eAAgB,IAGxBA,QAAQ,aAAc,IAGtBA,QAAQ,WAAY,IAGpBC,WAAW,UAAW,OAGoB,IAA/CN,eAAejC,8BAEfiC,eAAejC,4BAA8BiC,eAAejC,4BAA4BsC,QAAQ,QAAS,gBAO7G3F,aAAaG,QAAQH,cAAmD,mBAA3BA,aAAa6F,YAA0BC,SAAS9F,qBACnF+F,uBAAyB/F,aAAa6F,UAAUP,eAAgBtE,SACtEuB,OAAOC,OAAO8C,eAAgBS,2BAIlCxD,OAAOC,OAAO8C,eAAgBpB,QAAQ8B,8BAA8BhF,UAE7DsE,gBASLW,YAAehF,UACRA,OAAOiF,QAAQ,yBAUf/E,eAAiB7B,eAAM2B,YAAQD,+DAAU,SAC5CL,SAAWJ,sBAAsBU,WACnCN,gBACOnB,QAAQM,QAAQa,gBAIrBwF,eAAiB,IAAIC,iBAAQ,qCAG7BhE,QAAUD,WAAWnB,UAGpBM,QAAS+D,oBAAsB7F,QAAQC,IAAI,EAC9C,wBACAJ,iBAAiBkD,OAAO8D,KAAKjE,YAUjCd,QAAQV,MAAMT,QAAQ8D,SAAYA,OAAOqC,aAAaC,cAAaT,SAAS7B,SACxEA,OAAOuC,kBAILC,eAAiBnF,QAAQoF,cAAc9F,IAAIK,OAAO0F,OACpDF,eAAgB,IACZA,eAAeH,eAAiBrF,cAChCkF,eAAerG,UACRN,QAAQM,QAAQ2G,sBAEvBN,eAAerG,UACT,IAAI8G,MAAM,2EAKlBtB,eAAiBF,uBAAuBnE,OAAQK,EAASN,QAASqE,eAIjEpB,cAAgB3C,QAAQuF,KAAKvB,uBAGpCrE,OAAO6F,QAAQC,UAAY,SAG3B7H,YAAY8H,IAAI/F,OAAQgD,QACxBA,OAAOG,GAAG,UAAU6C,YAAChG,OAACA,cAElB/B,YAAY4B,OAAOG,OAAOiG,WAC1BjG,OAAOiG,UAAUJ,QAAQC,UAAY,QAOrC9F,OAAOkG,0BACAlG,OAAOkG,MAAM/C,GAAG,UAAU,KAC7BH,OAAOmD,UAKfnD,OAAOG,GAAG,QAAQ,KACdH,OAAOmD,UAIXnD,OAAOG,GAAG,cAAc,WACdiD,OAAS7G,SAAS8G,iBAAiB,yBACrCD,QACAA,OAAOvB,SAASyB,QACPA,MAAMC,UAAUC,SAAS,SAC1BF,MAAMC,UAAUE,IAAI,cAOpCzD,OAAOG,GAAG,eAAe,QACjB6B,YAAYhF,QAAS,OACfoG,OAAS7G,SAAS8G,iBAAiB,yBACrCD,QACAA,OAAOvB,SAASyB,QACRA,MAAMC,UAAUC,SAAS,SACzBF,MAAMC,UAAUhB,OAAO,eAO3CL,eAAerG,UACRmE,+EAU2B,eAACjD,+DAAU,GAC7C5B,eAAiB4B"} \ No newline at end of file +{"version":3,"file":"editor.min.js","sources":["../src/editor.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * TinyMCE Editor Manager.\n *\n * @module editor_tiny/editor\n * @copyright 2022 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport jQuery from 'jquery';\nimport Pending from 'core/pending';\nimport {getDefaultConfiguration, getDefaultQuickbarsSelectionToolbar} from './defaults';\nimport {getTinyMCE, baseUrl} from './loader';\nimport * as Options from './options';\nimport {addToolbarButton, addToolbarButtons, addToolbarSection,\n removeToolbarButton, removeSubmenuItem, updateEditorState} from './utils';\n\n/**\n * Storage for the TinyMCE instances on the page.\n * @type {Map}\n */\nconst instanceMap = new Map();\n\n/**\n * The default editor configuration.\n * @type {Object}\n */\nlet defaultOptions = {};\n\n/**\n * Require the modules for the named set of TinyMCE plugins.\n *\n * @param {string[]} pluginList The list of plugins\n * @return {Promise[]} A matching set of Promises relating to the requested plugins\n */\nconst importPluginList = async(pluginList) => {\n // Fetch all of the plugins from the list of plugins.\n // If a plugin contains a '/' then it is assumed to be a Moodle AMD module to import.\n const pluginHandlers = await Promise.all(pluginList.map(pluginPath => {\n if (pluginPath.indexOf('/') === -1) {\n // A standard TinyMCE Plugin.\n return Promise.resolve(pluginPath);\n }\n\n return import(pluginPath);\n }));\n\n // Normalise the plugin data to a list of plugin names.\n // Two formats are supported:\n // - a string; and\n // - an array whose first element is the plugin name, and the second element is the plugin configuration.\n const pluginNames = pluginHandlers.map((pluginConfig) => {\n if (typeof pluginConfig === 'string') {\n return pluginConfig;\n }\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[0];\n }\n return null;\n }).filter((value) => value);\n\n // Fetch the list of pluginConfig handlers.\n const pluginConfig = pluginHandlers.map((pluginConfig) => {\n if (Array.isArray(pluginConfig)) {\n return pluginConfig[1];\n }\n return null;\n }).filter((value) => value);\n\n return {\n pluginNames,\n pluginConfig,\n };\n};\n\n/**\n * Fetch the language data for the specified language.\n *\n * @param {string} language The language identifier\n * @returns {object}\n */\nconst fetchLanguage = (language) => fetch(\n `${M.cfg.wwwroot}/lib/editor/tiny/lang.php/${M.cfg.langrev}/${language}`\n).then(response => response.json());\n\n/**\n * Get a list of all Editors in a Map, keyed by the DOM Node that the Editor is associated with.\n *\n * @returns {Map}\n */\nexport const getAllInstances = () => new Map(instanceMap.entries());\n\n/**\n * Get the TinyMCE instance for the specified Node ID.\n *\n * @param {string} elementId\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElementId = elementId => getInstanceForElement(document.getElementById(elementId));\n\n/*\n * Get the TinyMCE instance for the specified HTMLElement.\n *\n * @param {HTMLElement} element\n * @returns {TinyMCE|undefined}\n */\nexport const getInstanceForElement = element => {\n const instance = instanceMap.get(element);\n if (instance && instance.removed) {\n instanceMap.delete(element);\n return undefined;\n }\n return instance;\n};\n\n/**\n * Set up TinyMCE for the selector at the specified HTML Node id.\n *\n * @param {object} config The configuration required to setup the editor\n * @param {string} config.elementId The HTML Node ID\n * @param {Object} config.options The editor plugin configuration\n */\nexport const setupForElementId = ({elementId, options}) => {\n const target = document.getElementById(elementId);\n // We will need to wrap the setupForTarget and editor.remove() calls in a setTimeout.\n // Because other events callbacks will still try to run on the removed instance.\n // This will cause an error on Firefox.\n // We need to make TinyMCE to remove itself outside the event loop.\n // @see https://github.com/tinymce/tinymce/issues/3129 for more details.\n setTimeout(() => {\n return setupForTarget(target, options);\n }, 1);\n};\n\n/**\n * Initialise the page with standard TinyMCE requirements.\n *\n * Currently this includes the language taken from the HTML lang property.\n */\nconst initialisePage = async() => {\n const lang = document.querySelector('html').lang;\n\n const [tinyMCE, langData] = await Promise.all([getTinyMCE(), fetchLanguage(lang)]);\n tinyMCE.addI18n(lang, langData);\n};\ninitialisePage();\n\n/**\n * Get the list of plugins to load for the specified configuration.\n *\n * If the specified configuration does not include a plugin configuration, then return the default configuration.\n *\n * @param {object} options\n * @param {array} [options.plugins=null] The plugin list\n * @returns {object}\n */\nconst getPlugins = ({plugins = null} = {}) => {\n if (plugins) {\n return plugins;\n }\n\n if (defaultOptions.plugins) {\n return defaultOptions.plugins;\n }\n\n return {};\n};\n\n/**\n * Adjust the editor size base on the target element.\n *\n * @param {TinyMCE} editor TinyMCE editor\n * @param {Node} target Target element\n */\nconst adjustEditorSize = (editor, target) => {\n let expectedEditingAreaHeight = 0;\n if (target.clientHeight) {\n expectedEditingAreaHeight = target.clientHeight;\n } else {\n // If the target element is hidden, we cannot get the lineHeight of the target element.\n // We don't have a proper way to retrieve the general lineHeight of the theme, so we use 22 here, it's equivalent to 1.5em.\n expectedEditingAreaHeight = target.rows * (parseFloat(window.getComputedStyle(target).lineHeight) || 22);\n }\n const currentEditingAreaHeight = editor.getContainer().querySelector('.tox-sidebar-wrap').clientHeight;\n if (currentEditingAreaHeight < expectedEditingAreaHeight) {\n // Change the height based on the target element's height.\n editor.getContainer().querySelector('.tox-sidebar-wrap').style.height = `${expectedEditingAreaHeight}px`;\n }\n};\n\n/**\n * Get the standard configuration for the specified options.\n *\n * @param {Node} target\n * @param {tinyMCE} tinyMCE\n * @param {object} options\n * @param {Array} plugins\n * @returns {object}\n */\nconst getStandardConfig = (target, tinyMCE, options, plugins) => {\n const lang = document.querySelector('html').lang;\n\n const config = Object.assign({}, getDefaultConfiguration(), {\n // eslint-disable-next-line camelcase\n base_url: baseUrl,\n\n // Set the editor target.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target\n target,\n\n // https://www.tiny.cloud/docs/tinymce/6/customize-ui/#set-maximum-and-minimum-heights-and-widths\n // Set the minimum height to the smallest height that we can fit the Menu bar, Tool bar, Status bar and the text area.\n // eslint-disable-next-line camelcase\n min_height: 175,\n\n // Base the height on the size of the text area.\n // In some cases, E.g.: The target is an advanced element, it will be hidden. We cannot get the height at this time.\n // So set the height to auto, and adjust it later by adjustEditorSize().\n height: target.clientHeight || 'auto',\n\n // Set the language.\n // https://www.tiny.cloud/docs/tinymce/6/ui-localization/#language\n // eslint-disable-next-line camelcase\n language: lang,\n\n // Load the editor stylesheet into the editor iframe.\n // https://www.tiny.cloud/docs/tinymce/6/add-css-options/\n // eslint-disable-next-line camelcase\n content_css: [\n options.css,\n ],\n\n // Do not convert URLs to relative URLs.\n // https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls\n // eslint-disable-next-line camelcase\n convert_urls: false,\n\n // Enabled 'advanced' a11y options.\n // This includes allowing role=\"presentation\" from the image uploader.\n // https://www.tiny.cloud/docs/tinymce/6/accessibility/\n // eslint-disable-next-line camelcase\n a11y_advanced_options: true,\n\n // Add specific rules to the valid elements.\n // eslint-disable-next-line camelcase\n extended_valid_elements: 'script[*],p[*],i[*]',\n\n // Disable XSS Sanitisation.\n // We do this in PHP.\n // https://www.tiny.cloud/docs/tinymce/6/security/#turning-dompurify-off\n // Note: This feature has been backported from TinyMCE 6.4.0.\n // eslint-disable-next-line camelcase\n xss_sanitization: false,\n\n // Disable quickbars entirely.\n // The UI is not ideal and we'll wait for it to improve in future before we enable it in Moodle.\n // eslint-disable-next-line camelcase\n quickbars_insert_toolbar: '',\n\n // If the target element is too small, disable the quickbars selection toolbar.\n // The quickbars selection toolbar is not displayed correctly if the target element is too small.\n // See: https://github.com/tinymce/tinymce/issues/9693.\n quickbars_selection_toolbar: target.rows > 5 ? getDefaultQuickbarsSelectionToolbar() : false,\n\n // Override the standard block formats property (removing h1 & h2).\n // https://www.tiny.cloud/docs/tinymce/6/user-formatting-options/#block_formats\n // eslint-disable-next-line camelcase\n block_formats: 'Paragraph=p;Heading 3=h3;Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre;Blockquote=blockquote',\n\n // The list of plugins to include in the instance.\n // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins\n plugins: [\n ...plugins,\n ],\n\n // Skins\n skin: 'oxide',\n\n // Do not show the help link in the status bar.\n // https://www.tiny.cloud/docs/tinymce/latest/accessibility/#help_accessibility\n // eslint-disable-next-line camelcase\n help_accessibility: false,\n\n // Remove the \"Upgrade\" link for Tiny.\n // https://www.tiny.cloud/docs/tinymce/6/editor-premium-upgrade-promotion/\n promotion: false,\n\n // Allow the administrator to disable branding.\n // https://www.tiny.cloud/docs/tinymce/6/statusbar-configuration-options/#branding\n branding: options.branding,\n\n // Put th cells in a thead element.\n // https://www.tiny.cloud/docs/tinymce/6/table-options/#table_header_type\n // eslint-disable-next-line camelcase\n table_header_type: 'sectionCells',\n\n // Stored text in non-entity form.\n // https://www.tiny.cloud/docs/tinymce/6/content-filtering/#entity_encoding\n // eslint-disable-next-line camelcase\n entity_encoding: \"raw\",\n\n // Enable support for editors in scrollable containers.\n // https://www.tiny.cloud/docs/tinymce/6/ui-mode-configuration-options/#ui_mode\n // eslint-disable-next-line camelcase\n ui_mode: 'split',\n\n // Enable browser-supported spell checking.\n // https://www.tiny.cloud/docs/tinymce/latest/spelling/\n // eslint-disable-next-line camelcase\n browser_spellcheck: true,\n\n setup: (editor) => {\n Options.register(editor, options);\n\n editor.on('PreInit', function() {\n // Work around a bug in TinyMCE with Firefox.\n // When an editor is removed, and replaced with an identically attributed editor (same ID),\n // and the Firefox window is freshly opened (e.g. Behat, Private browsing), the wrong contentWindow\n // is assigned to the editor instance leading to an NS_ERROR_UNEXPECTED error in Firefox.\n // This is a workaround for that issue.\n this.contentWindow = this.iframeElement.contentWindow;\n });\n editor.on('init', function() {\n // Hide justify alignment sub-menu.\n removeSubmenuItem(editor, 'align', 'tiny:justify');\n // Adjust the editor size.\n adjustEditorSize(editor, target);\n });\n\n target.addEventListener('form:editorUpdated', function() {\n updateEditorState(editor, target);\n });\n\n target.dispatchEvent(new Event('form:editorUpdated'));\n },\n });\n\n config.toolbar = addToolbarSection(config.toolbar, 'content', 'formatting', true);\n config.toolbar = addToolbarButton(config.toolbar, 'content', 'link');\n\n // Add directionality plugins, always.\n config.toolbar = addToolbarSection(config.toolbar, 'directionality', 'alignment', true);\n config.toolbar = addToolbarButtons(config.toolbar, 'directionality', ['ltr', 'rtl']);\n\n // Remove the align justify button from the toolbar.\n config.toolbar = removeToolbarButton(config.toolbar, 'alignment', 'alignjustify');\n\n return config;\n};\n\n/**\n * Fetch the TinyMCE configuration for this editor instance.\n *\n * @param {HTMLElement} target\n * @param {TinyMCE} tinyMCE The TinyMCE API\n * @param {Object} options The editor plugin configuration\n * @param {object} pluginValues\n * @param {object} pluginValues.pluginConfig The list of plugin configuration\n * @param {object} pluginValues.pluginNames The list of plugins to load\n * @returns {object} The TinyMCE Configuration\n */\nconst getEditorConfiguration = (target, tinyMCE, options, pluginValues) => {\n const {\n pluginNames,\n pluginConfig,\n } = pluginValues;\n\n // Allow plugins to modify the configuration.\n // This seems a little strange, but we must double-process the config slightly.\n\n // First we fetch the standard configuration.\n const instanceConfig = getStandardConfig(target, tinyMCE, options, pluginNames);\n\n // Next we make any standard changes.\n // Here we remove the file menu, as it doesn't offer any useful functionality.\n // We only empty the items list so that a plugin may choose to add to it themselves later if they wish.\n if (instanceConfig.menu.file) {\n instanceConfig.menu.file.items = '';\n }\n\n // We disable the styles, backcolor, and forecolor plugins from the format menu.\n // These are not useful for Moodle and we don't want to encourage their use.\n if (instanceConfig.menu.format) {\n instanceConfig.menu.format.items = instanceConfig.menu.format.items\n // Remove forecolor and backcolor.\n .replace(/forecolor ?/, '')\n .replace(/backcolor ?/, '')\n\n // Remove fontfamily for now.\n .replace(/fontfamily ?/, '')\n\n // Remove fontsize for now.\n .replace(/fontsize ?/, '')\n\n // Remove styles - it just duplicates the format menu in a way which does not respect configuration\n .replace(/styles ?/, '')\n\n // Remove any duplicate separators.\n .replaceAll(/\\| *\\|/g, '|');\n }\n\n if (instanceConfig.quickbars_selection_toolbar !== false) {\n // eslint-disable-next-line camelcase\n instanceConfig.quickbars_selection_toolbar = instanceConfig.quickbars_selection_toolbar.replace('h2 h3', 'h3 h4 h5 h6');\n }\n\n // Next we call the `configure` function for any plugin which defines it.\n // We pass the current instanceConfig in here, to allow them to make certain changes to the global configuration.\n // For example, to add themselves to any menu, toolbar, and so on.\n // Any plugin which wishes to have configuration options must register those options here.\n pluginConfig.filter((pluginConfig) => typeof pluginConfig.configure === 'function').forEach((pluginConfig) => {\n const pluginInstanceOverride = pluginConfig.configure(instanceConfig, options);\n Object.assign(instanceConfig, pluginInstanceOverride);\n });\n\n // Next we convert the plugin configuration into a format that TinyMCE understands.\n Object.assign(instanceConfig, Options.getInitialPluginConfiguration(options));\n\n return instanceConfig;\n};\n\n/**\n * Check if the target for TinyMCE is in a modal or not.\n *\n * @param {HTMLElement} target Target to check\n * @returns {boolean} True if the target is in a modal form.\n */\nconst isModalMode = (target) => {\n return !!target.closest('[data-region=\"modal\"]');\n};\n\n/**\n * Set up TinyMCE for the HTML Element.\n *\n * @param {HTMLElement} target\n * @param {Object} [options={}] The editor plugin configuration\n * @return {Promise} The TinyMCE instance\n */\nexport const setupForTarget = async(target, options = {}) => {\n const instance = getInstanceForElement(target);\n if (instance) {\n return Promise.resolve(instance);\n }\n\n // Register a new pending promise to ensure that Behat waits for the editor setup to complete before continuing.\n const pendingPromise = new Pending('editor_tiny/editor:setupForTarget');\n\n // Get the list of plugins.\n const plugins = getPlugins(options);\n\n // Fetch the tinyMCE API, and instantiate the plugins.\n const [tinyMCE, pluginValues] = await Promise.all([\n getTinyMCE(),\n importPluginList(Object.keys(plugins)),\n ]);\n\n // TinyMCE uses the element ID as a map key internally, even if the target has changed.\n // In the case where we have an editor in a modal form which has been detached from the DOM, but the editor not removed,\n // we need to manually destroy the editor.\n // We could theoretically do this with a Mutation Observer, but in some cases the Node may be moved,\n // or added back elsewhere in the DOM.\n\n // First remove any detached editors.\n tinyMCE.get().filter((editor) => !editor.getElement().isConnected).forEach((editor) => {\n editor.remove();\n });\n\n // Now check for any existing editor which shares the same ID.\n const existingEditor = tinyMCE.EditorManager.get(target.id);\n if (existingEditor) {\n if (existingEditor.getElement() === target) {\n pendingPromise.resolve();\n return Promise.resolve(existingEditor);\n } else {\n pendingPromise.resolve();\n throw new Error('TinyMCE instance already exists for different target with same ID');\n }\n }\n\n // Get the editor configuration for this editor.\n const instanceConfig = getEditorConfiguration(target, tinyMCE, options, pluginValues);\n\n // Initialise the editor instance for the given configuration.\n // At this point any plugin which has configuration options registered will have them applied for this instance.\n const [editor] = await tinyMCE.init(instanceConfig);\n\n // Update the textarea when the editor to set the field type for Behat.\n target.dataset.fieldtype = 'editor';\n\n // Store the editor instance in the instanceMap and register a listener on removal to remove it from the map.\n instanceMap.set(target, editor);\n editor.on('remove', ({target}) => {\n // Handle removal of the editor from the map on destruction.\n instanceMap.delete(target.targetElm);\n target.targetElm.dataset.fieldtype = null;\n });\n\n // If the editor is part of a form, also listen to the jQuery submit event.\n // The jQuery submit event will not trigger the native submit event, and therefore the content will not be saved.\n // We cannot rely on listening to the bubbled submit event on the document because other events on child nodes may\n // consume the data before it is saved.\n if (target.form) {\n jQuery(target.form).on('submit', () => {\n editor.save();\n });\n }\n\n // Save the editor content to the textarea when the editor is blurred.\n editor.on('blur', () => {\n editor.save();\n });\n\n // If the editor is in a modal, we need to hide the modal when window editor's window is opened.\n editor.on('OpenWindow', () => {\n const modals = document.querySelectorAll('[data-region=\"modal\"]');\n if (modals) {\n modals.forEach((modal) => {\n if (!modal.classList.contains('hide')) {\n modal.classList.add('hide');\n }\n });\n }\n });\n\n // If the editor's window is closed, we need to show the hidden modal back.\n editor.on('CloseWindow', () => {\n if (isModalMode(target)) {\n const modals = document.querySelectorAll('[data-region=\"modal\"]');\n if (modals) {\n modals.forEach((modal) => {\n if (modal.classList.contains('hide')) {\n modal.classList.remove('hide');\n }\n });\n }\n }\n });\n\n pendingPromise.resolve();\n return editor;\n};\n\n/**\n * Set the default editor configuration.\n *\n * This configuration is used when an editor is initialised without any configuration.\n *\n * @param {object} [options={}]\n */\nexport const configureDefaultEditor = (options = {}) => {\n defaultOptions = options;\n};\n"],"names":["instanceMap","Map","defaultOptions","importPluginList","async","pluginHandlers","Promise","all","pluginList","map","pluginPath","indexOf","resolve","pluginNames","pluginConfig","Array","isArray","filter","value","entries","elementId","getInstanceForElement","document","getElementById","element","instance","get","removed","delete","_ref","options","target","setTimeout","setupForTarget","lang","querySelector","tinyMCE","langData","language","fetch","M","cfg","wwwroot","langrev","then","response","json","addI18n","initialisePage","getPlugins","plugins","getStandardConfig","config","Object","assign","base_url","baseUrl","min_height","height","clientHeight","content_css","css","convert_urls","a11y_advanced_options","extended_valid_elements","xss_sanitization","quickbars_insert_toolbar","quickbars_selection_toolbar","rows","block_formats","skin","help_accessibility","promotion","branding","table_header_type","entity_encoding","ui_mode","browser_spellcheck","setup","editor","Options","register","on","contentWindow","this","iframeElement","expectedEditingAreaHeight","parseFloat","window","getComputedStyle","lineHeight","getContainer","style","adjustEditorSize","addEventListener","dispatchEvent","Event","toolbar","getEditorConfiguration","pluginValues","instanceConfig","menu","file","items","format","replace","replaceAll","configure","forEach","pluginInstanceOverride","getInitialPluginConfiguration","isModalMode","closest","pendingPromise","Pending","keys","getElement","isConnected","remove","existingEditor","EditorManager","id","Error","init","dataset","fieldtype","set","_ref2","targetElm","form","save","modals","querySelectorAll","modal","classList","contains","add"],"mappings":"4oDAmCMA,YAAc,IAAIC,QAMpBC,eAAiB,SAQfC,iBAAmBC,MAAAA,mBAGfC,qBAAuBC,QAAQC,IAAIC,WAAWC,KAAIC,aACnB,IAA7BA,WAAWC,QAAQ,KAEZL,QAAQM,QAAQF,4NAGbA,4WAAAA,gBAOZG,YAAcR,eAAeI,KAAKK,cACR,iBAAjBA,aACAA,aAEPC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,cAUd,CACHL,YAAAA,YACAC,aATiBT,eAAeI,KAAKK,cACjCC,MAAMC,QAAQF,cACPA,aAAa,GAEjB,OACRG,QAAQC,OAAUA,mCAuBM,IAAM,IAAIjB,IAAID,YAAYmB,4CAQlBC,WAAaC,sBAAsBC,SAASC,eAAeH,kBAQrFC,sBAAwBG,gBAC3BC,SAAWzB,YAAY0B,IAAIF,aAC7BC,WAAYA,SAASE,eAIlBF,SAHHzB,YAAY4B,OAAOJ,0FAaMK,WAACT,UAACA,UAADU,QAAYA,oBACpCC,OAAST,SAASC,eAAeH,WAMvCY,YAAW,IACAC,eAAeF,OAAQD,UAC/B,IAQgB1B,iBACb8B,KAAOZ,SAASa,cAAc,QAAQD,MAErCE,QAASC,gBAAkB/B,QAAQC,IAAI,EAAC,yBA7D5B+B,SA6DwDJ,KA7D3CK,gBAC7BC,EAAEC,IAAIC,6CAAoCF,EAAEC,IAAIE,oBAAWL,WAChEM,MAAKC,UAAYA,SAASC,YAFLR,IAAAA,SA8DnBF,QAAQW,QAAQb,KAAMG,WAE1BW,SAWMC,WAAa,eAACC,QAACA,QAAU,6DAAQ,UAC/BA,UAIAhD,eAAegD,QACRhD,eAAegD,QAGnB,KAkCLC,kBAAoB,CAACpB,OAAQK,QAASN,QAASoB,iBAC3ChB,KAAOZ,SAASa,cAAc,QAAQD,KAEtCkB,OAASC,OAAOC,OAAO,IAAI,uCAA2B,CAExDC,SAAUC,gBAIVzB,OAAAA,OAKA0B,WAAY,IAKZC,OAAQ3B,OAAO4B,cAAgB,OAK/BrB,SAAUJ,KAKV0B,YAAa,CACT9B,QAAQ+B,KAMZC,cAAc,EAMdC,uBAAuB,EAIvBC,wBAAyB,sBAOzBC,kBAAkB,EAKlBC,yBAA0B,GAK1BC,4BAA6BpC,OAAOqC,KAAO,IAAI,mDAK/CC,cAAe,yGAIfnB,QAAS,IACFA,SAIPoB,KAAM,QAKNC,oBAAoB,EAIpBC,WAAW,EAIXC,SAAU3C,QAAQ2C,SAKlBC,kBAAmB,eAKnBC,gBAAiB,MAKjBC,QAAS,QAKTC,oBAAoB,EAEpBC,MAAQC,SACJC,QAAQC,SAASF,OAAQjD,SAEzBiD,OAAOG,GAAG,WAAW,gBAMZC,cAAgBC,KAAKC,cAAcF,iBAE5CJ,OAAOG,GAAG,QAAQ,wCAEIH,OAAQ,QAAS,gBAtJ1B,EAACA,OAAQhD,cAC1BuD,0BAA4B,EAE5BA,0BADAvD,OAAO4B,aACqB5B,OAAO4B,aAIP5B,OAAOqC,MAAQmB,WAAWC,OAAOC,iBAAiB1D,QAAQ2D,aAAe,IAExEX,OAAOY,eAAexD,cAAc,qBAAqBwB,aAC3D2B,4BAE3BP,OAAOY,eAAexD,cAAc,qBAAqByD,MAAMlC,iBAAY4B,kCA4InEO,CAAiBd,OAAQhD,WAG7BA,OAAO+D,iBAAiB,sBAAsB,wCACxBf,OAAQhD,WAG9BA,OAAOgE,cAAc,IAAIC,MAAM,iCAIvC5C,OAAO6C,SAAU,4BAAkB7C,OAAO6C,QAAS,UAAW,cAAc,GAC5E7C,OAAO6C,SAAU,2BAAiB7C,OAAO6C,QAAS,UAAW,QAG7D7C,OAAO6C,SAAU,4BAAkB7C,OAAO6C,QAAS,iBAAkB,aAAa,GAClF7C,OAAO6C,SAAU,4BAAkB7C,OAAO6C,QAAS,iBAAkB,CAAC,MAAO,QAG7E7C,OAAO6C,SAAU,8BAAoB7C,OAAO6C,QAAS,YAAa,gBAE3D7C,QAcL8C,uBAAyB,CAACnE,OAAQK,QAASN,QAASqE,sBAChDtF,YACFA,YADEC,aAEFA,cACAqF,aAMEC,eAAiBjD,kBAAkBpB,OAAQK,EAASN,QAASjB,oBAK/DuF,eAAeC,KAAKC,OACpBF,eAAeC,KAAKC,KAAKC,MAAQ,IAKjCH,eAAeC,KAAKG,SACpBJ,eAAeC,KAAKG,OAAOD,MAAQH,eAAeC,KAAKG,OAAOD,MAEzDE,QAAQ,cAAe,IACvBA,QAAQ,cAAe,IAGvBA,QAAQ,eAAgB,IAGxBA,QAAQ,aAAc,IAGtBA,QAAQ,WAAY,IAGpBC,WAAW,UAAW,OAGoB,IAA/CN,eAAejC,8BAEfiC,eAAejC,4BAA8BiC,eAAejC,4BAA4BsC,QAAQ,QAAS,gBAO7G3F,aAAaG,QAAQH,cAAmD,mBAA3BA,aAAa6F,YAA0BC,SAAS9F,qBACnF+F,uBAAyB/F,aAAa6F,UAAUP,eAAgBtE,SACtEuB,OAAOC,OAAO8C,eAAgBS,2BAIlCxD,OAAOC,OAAO8C,eAAgBpB,QAAQ8B,8BAA8BhF,UAE7DsE,gBASLW,YAAehF,UACRA,OAAOiF,QAAQ,yBAUf/E,eAAiB7B,eAAM2B,YAAQD,+DAAU,SAC5CL,SAAWJ,sBAAsBU,WACnCN,gBACOnB,QAAQM,QAAQa,gBAIrBwF,eAAiB,IAAIC,iBAAQ,qCAG7BhE,QAAUD,WAAWnB,UAGpBM,QAAS+D,oBAAsB7F,QAAQC,IAAI,EAC9C,wBACAJ,iBAAiBkD,OAAO8D,KAAKjE,YAUjCd,QAAQV,MAAMT,QAAQ8D,SAAYA,OAAOqC,aAAaC,cAAaT,SAAS7B,SACxEA,OAAOuC,kBAILC,eAAiBnF,QAAQoF,cAAc9F,IAAIK,OAAO0F,OACpDF,eAAgB,IACZA,eAAeH,eAAiBrF,cAChCkF,eAAerG,UACRN,QAAQM,QAAQ2G,sBAEvBN,eAAerG,UACT,IAAI8G,MAAM,2EAKlBtB,eAAiBF,uBAAuBnE,OAAQK,EAASN,QAASqE,eAIjEpB,cAAgB3C,QAAQuF,KAAKvB,uBAGpCrE,OAAO6F,QAAQC,UAAY,SAG3B7H,YAAY8H,IAAI/F,OAAQgD,QACxBA,OAAOG,GAAG,UAAU6C,YAAChG,OAACA,cAElB/B,YAAY4B,OAAOG,OAAOiG,WAC1BjG,OAAOiG,UAAUJ,QAAQC,UAAY,QAOrC9F,OAAOkG,0BACAlG,OAAOkG,MAAM/C,GAAG,UAAU,KAC7BH,OAAOmD,UAKfnD,OAAOG,GAAG,QAAQ,KACdH,OAAOmD,UAIXnD,OAAOG,GAAG,cAAc,WACdiD,OAAS7G,SAAS8G,iBAAiB,yBACrCD,QACAA,OAAOvB,SAASyB,QACPA,MAAMC,UAAUC,SAAS,SAC1BF,MAAMC,UAAUE,IAAI,cAOpCzD,OAAOG,GAAG,eAAe,QACjB6B,YAAYhF,QAAS,OACfoG,OAAS7G,SAAS8G,iBAAiB,yBACrCD,QACAA,OAAOvB,SAASyB,QACRA,MAAMC,UAAUC,SAAS,SACzBF,MAAMC,UAAUhB,OAAO,eAO3CL,eAAerG,UACRmE,+EAU2B,eAACjD,+DAAU,GAC7C5B,eAAiB4B"} \ No newline at end of file diff --git a/lib/editor/tiny/amd/src/editor.js b/lib/editor/tiny/amd/src/editor.js index 13d92eead9a46..029f79dadad33 100644 --- a/lib/editor/tiny/amd/src/editor.js +++ b/lib/editor/tiny/amd/src/editor.js @@ -279,7 +279,7 @@ const getStandardConfig = (target, tinyMCE, options, plugins) => { // Override the standard block formats property (removing h1 & h2). // https://www.tiny.cloud/docs/tinymce/6/user-formatting-options/#block_formats // eslint-disable-next-line camelcase - block_formats: 'Paragraph=p;Heading 3=h3;Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre', + block_formats: 'Paragraph=p;Heading 3=h3;Heading 4=h4;Heading 5=h5;Heading 6=h6;Preformatted=pre;Blockquote=blockquote', // The list of plugins to include in the instance. // https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#plugins diff --git a/lib/editor/tiny/editor_styles.css b/lib/editor/tiny/editor_styles.css index 84bc14158b7d3..b10fad8d9d69a 100644 --- a/lib/editor/tiny/editor_styles.css +++ b/lib/editor/tiny/editor_styles.css @@ -10,4 +10,11 @@ body.mce-content-body a[data-mce-selected="inline-boundary"] { body.mce-content-body a { color: #0f6cbf; } -/** */ \ No newline at end of file + +/** blockquote */ +.mce-content-body blockquote { + margin: 0 0.5rem 1rem; + padding-left: 1rem; + color: #495057; + border-left: 5px solid #8f959e; +} diff --git a/lib/filestorage/file_storage.php b/lib/filestorage/file_storage.php index 5a3ec1928907d..732490063b5cf 100644 --- a/lib/filestorage/file_storage.php +++ b/lib/filestorage/file_storage.php @@ -1069,19 +1069,23 @@ public function create_directory($contextid, $component, $filearea, $itemid, $fi * Add new file record to database and handle callbacks. * * @param stdClass $newrecord + * @param bool $notify Notify the hook about the new file or not */ - protected function create_file($newrecord) { + protected function create_file($newrecord, bool $notify = true) { global $DB; $newrecord->id = $DB->insert_record('files', $newrecord); if ($newrecord->filename !== '.') { - // Callback for file created. - if ($pluginsfunction = get_plugins_with_function('after_file_created')) { - foreach ($pluginsfunction as $plugintype => $plugins) { - foreach ($plugins as $pluginfunction) { - $pluginfunction($newrecord); - } - } + if (defined('PHPUNIT_TEST') && PHPUNIT_TEST) { + return; + } + if ($notify) { + // The $fileinstance is needed for the legacy callback. + $fileinstance = $this->get_file_instance($newrecord); + // Dispatch the new Hook implementation immediately after the legacy callback. + $hook = new \core\hook\filestorage\after_file_created($fileinstance, $newrecord); + \core\di::get(\core\hook\manager::class)->dispatch($hook); + $hook->process_legacy_callbacks(); } } } @@ -1091,9 +1095,10 @@ protected function create_file($newrecord) { * * @param stdClass|array $filerecord object or array describing changes * @param stored_file|int $fileorid id or stored_file instance of the existing local file + * @param bool $notify Notify the hook about the new file or not * @return stored_file instance of newly created file */ - public function create_file_from_storedfile($filerecord, $fileorid) { + public function create_file_from_storedfile($filerecord, $fileorid, bool $notify = true) { global $DB; if ($fileorid instanceof stored_file) { @@ -1200,7 +1205,7 @@ public function create_file_from_storedfile($filerecord, $fileorid) { } try { - $this->create_file($newrecord); + $this->create_file($newrecord, $notify); } catch (dml_exception $e) { throw new stored_file_creation_exception($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename, $e->debuginfo); @@ -1272,9 +1277,10 @@ public function create_file_from_url($filerecord, $url, ?array $options = null, * * @param stdClass|array $filerecord object or array describing file * @param string $pathname path to file or content of file + * @param bool $notify Notify the hook about the new file or not. * @return stored_file */ - public function create_file_from_pathname($filerecord, $pathname) { + public function create_file_from_pathname($filerecord, $pathname, bool $notify = true) { global $DB; $filerecord = (array)$filerecord; // Do not modify the submitted record, this cast unlinks objects. @@ -1368,7 +1374,7 @@ public function create_file_from_pathname($filerecord, $pathname) { $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename); try { - $this->create_file($newrecord); + $this->create_file($newrecord, $notify); } catch (dml_exception $e) { if ($newfile) { $this->filesystem->remove_file($newrecord->contenthash); @@ -1387,9 +1393,10 @@ public function create_file_from_pathname($filerecord, $pathname) { * * @param stdClass|array $filerecord object or array describing file * @param string $content content of file + * @param bool $notify Notify the hook about the new file or not. * @return stored_file */ - public function create_file_from_string($filerecord, $content) { + public function create_file_from_string($filerecord, $content, bool $notify = true) { global $DB; $filerecord = (array)$filerecord; // Do not modify the submitted record, this cast unlinks objects. @@ -1487,7 +1494,7 @@ public function create_file_from_string($filerecord, $content) { $newrecord->pathnamehash = $this->get_pathname_hash($newrecord->contextid, $newrecord->component, $newrecord->filearea, $newrecord->itemid, $newrecord->filepath, $newrecord->filename); try { - $this->create_file($newrecord); + $this->create_file($newrecord, $notify); } catch (dml_exception $e) { if ($newfile) { $this->filesystem->remove_file($newrecord->contenthash); diff --git a/lib/form/tests/behat/modgrade_validation.feature b/lib/form/tests/behat/modgrade_validation.feature index 9339d9ef3b6dd..56d0deee5d687 100644 --- a/lib/form/tests/behat/modgrade_validation.feature +++ b/lib/form/tests/behat/modgrade_validation.feature @@ -88,8 +88,7 @@ Feature: Using the activity grade form element | grade[modgrade_scale] | ABCDEF | And I press "Save and display" And I am on the "Test assignment name" "assign activity" page - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "Grade" to "C" And I press "Save changes" And I follow "Edit settings" @@ -120,8 +119,7 @@ Feature: Using the activity grade form element @javascript Scenario: Attempting to change the maximum grade when no rescaling option has been chosen Given I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "50" And I press "Save changes" And I follow "Edit settings" diff --git a/lib/html2text/README.md b/lib/html2text/README.md new file mode 100644 index 0000000000000..cb619b3fa16b7 --- /dev/null +++ b/lib/html2text/README.md @@ -0,0 +1,26 @@ +# Html2Text + +A PHP library for converting HTML to formatted plain text. + +[![Build status](https://github.com/mtibben/html2text/actions/workflows/ci.yml/badge.svg)](https://github.com/mtibben/html2text/actions/workflows/ci.yml) + +## Installing + +``` +composer require html2text/html2text +``` + +## Basic Usage +```php +$html = new \Html2Text\Html2Text('Hello, "world"'); + +echo $html->getText(); // Hello, "WORLD" +``` + +## History + +This library started life on the blog of Jon Abernathy http://www.chuggnutt.com/html2text + +A number of projects picked up the library and started using it - among those was RoundCube mail. They made a number of updates to it over time to suit their webmail client. + +Now it has been extracted as a standalone library. Hopefully it can be of use to others. diff --git a/lib/html2text/composer.json b/lib/html2text/composer.json new file mode 100644 index 0000000000000..7cfb7fe10665d --- /dev/null +++ b/lib/html2text/composer.json @@ -0,0 +1,23 @@ +{ + "name": "html2text/html2text", + "description": "Converts HTML to formatted plain text", + "type": "library", + "license": "GPL-2.0-or-later", + "autoload": { + "psr-4": { + "Html2Text\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Html2Text\\": "test/" + } + }, + "require-dev": { + "phpunit/phpunit": "~4|^9.0" + }, + "suggest": { + "ext-mbstring": "For best performance", + "symfony/polyfill-mbstring": "If you can't install ext-mbstring" + } +} \ No newline at end of file diff --git a/lib/html2text/lib.php b/lib/html2text/lib.php index 6c4588e22a685..e96d677136a7a 100644 --- a/lib/html2text/lib.php +++ b/lib/html2text/lib.php @@ -14,21 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Wrapper for Html2Text - * - * This wrapper allows us to modify the upstream library without hacking it too much. - * - * @package core - * @copyright 2015 Andrew Nicols - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -defined('MOODLE_INTERNAL') || die(); - -require_once($CFG->libdir . '/html2text/Html2Text.php'); -require_once(__DIR__ . '/override.php'); - /** * Wrapper for Html2Text * @@ -39,7 +24,6 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class core_html2text extends \Html2Text\Html2Text { - /** * Constructor. * @@ -58,14 +42,4 @@ function __construct($html = '', $options = array()) { $this->entSearch[] = '/[ ]+([\n\t])/'; $this->entReplace[] = '\\1'; } - - /** - * Strtoupper multibyte wrapper function with HTML entities handling. - * - * @param string $str Text to convert - * @return string Converted text - */ - protected function strtoupper($str) { - return core_text::strtoupper($str); - } } diff --git a/lib/html2text/override.php b/lib/html2text/override.php deleted file mode 100644 index 46bea72230c9f..0000000000000 --- a/lib/html2text/override.php +++ /dev/null @@ -1,133 +0,0 @@ -. - -/** - * Run time overrides for Html2Text - * - * This allows us to monkey patch the mb_* functions used in Html2Text to use Moodle's core_text functionality. - * - * @package core - * @copyright 2016 Andrew Nicols - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -namespace Html2Text; - -/** - * Set the encoding to be used by our monkey patched mb_ functions. - * - * When called with $encoding !== null, we set the static $intenalencoding - * variable, which is used for subsequent calls. - * - * When called with no value for $encoding, we return the previously defined - * $internalencoding. - * - * This is necessary as we need to maintain the state of mb_internal_encoding - * across calls to other mb_* functions. Check how it is used in the other mb_* - * functions defined here - if no encoding is provided we fallback to what was - * set here, otherwise we used the given encoding. - * - * @staticvar string $internalencoding The encoding to be used across mb_* calls. - * @param string $encoding When given, sets $internalencoding - * @return mixed - */ -function mb_internal_encoding($encoding = null) { - static $internalencoding = 'utf-8'; - if ($encoding !== null) { - $internalencoding = $encoding; - return true; - } else { - return $internalencoding; - } -} - -/** - * Performs a multi-byte safe substr() operation based on number of characters. - * Position is counted from the beginning of str. First character's position is - * 0. Second character position is 1, and so on. - * - * @param string $str The string to extract the substring from. - * @param int $start If start is non-negative, the returned string will - * start at the start'th position in string, counting - * from zero. For instance, in the string 'abcdef', - * the character at position 0 is 'a', the character - * at position 2 is 'c', and so forth. - * @param int $length Maximum number of characters to use from str. If - * omitted or NULL is passed, extract all characters - * to the end of the string. - * @param string $encoding The encoding parameter is the character encoding. - * If it is omitted, the internal character encoding - * value will be used. - * - * @return string The portion of str specified by the start and length parameters. - */ -function mb_substr($str, $start, $length = null, $encoding = null) { - if ($encoding === null) { - $encoding = mb_internal_encoding(); - } - return \core_text::substr($str, $start, $length, $encoding); -} - -/** - * Gets the length of a string. - * - * @param string $str The string being checked for length. - * @param string $encoding The encoding parameter is the character encoding. - * If it is omitted, the internal character encoding - * value will be used. - * - * @return int The number of characters in str having character encoding $encoding. - * A multibyte character is counted as 1. - */ -function mb_strlen($str, $encoding = null) { - if ($encoding === null) { - $encoding = mb_internal_encoding(); - } - return \core_text::strlen($str, $encoding); -} - -/** - * Returns $str with all alphabetic chatacters converted to lowercase. - * - * @param string $str The string being lowercased. - * @param string $encoding The encoding parameter is the character encoding. - * If it is omitted, the internal character encoding - * value will be used. - * - * @return string The string with all alphabetic characters converted to lowercase. - */ -function mb_strtolower($str, $encoding = null) { - if ($encoding === null) { - $encoding = mb_internal_encoding(); - } - return \core_text::strtolower($str, $encoding); -} - -/** - * - * @param string The string being uppercased - * @param string $encoding The encoding parameter is the character encoding. - * If it is omitted, the internal character encoding - * value will be used. - * - * @return string The string with all alphabetic characters converted to uppercase. - */ -function mb_strtoupper($str, $encoding = null) { - if ($encoding === null) { - $encoding = mb_internal_encoding(); - } - return \core_text::strtoupper($str, $encoding); -} diff --git a/lib/html2text/readme_moodle.txt b/lib/html2text/readme_moodle.txt index 37cab254a255b..3ec4d309e94e5 100644 --- a/lib/html2text/readme_moodle.txt +++ b/lib/html2text/readme_moodle.txt @@ -1,14 +1,11 @@ -Description of Html2Text v4.3.1 library import into Moodle - -Please note that we override some mb_* functions in Html2Text's namespace at -run time. Until Html2Text adds some sort of fallback for the mb_* functions -(or we make mbstring a hard requirement) we are forced to do this so that people -running PHP without mbstring don't see nasty undefined function errors. +Description of Html2Text library import into Moodle Instructions ------------ 1. Download the latest release of Html2Text from https://github.com/mtibben/html2text/releases/ 2. Extract the contents of the release archive into a directory. -3. Copy src/Html2Text.php to lib/html2text/ +3. Copy src to lib/html2text/src +4. Update README +5. Update composer.json Imported from: https://github.com/mtibben/html2text/releases/ diff --git a/lib/html2text/Html2Text.php b/lib/html2text/src/Html2Text.php similarity index 97% rename from lib/html2text/Html2Text.php rename to lib/html2text/src/Html2Text.php index 9fd9123592788..6e0f9e50e8384 100644 --- a/lib/html2text/Html2Text.php +++ b/lib/html2text/src/Html2Text.php @@ -236,16 +236,19 @@ private function legacyConstruct($html = '', $fromFile = false, array $options = */ public function __construct($html = '', $options = array()) { + $this->htmlFuncFlags = (PHP_VERSION_ID < 50400) + ? ENT_QUOTES + : ENT_QUOTES | ENT_HTML5; + // for backwards compatibility if (!is_array($options)) { - return call_user_func_array(array($this, 'legacyConstruct'), func_get_args()); + // phpcs:ignore (PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.NeedsInspection + call_user_func_array(array($this, 'legacyConstruct'), func_get_args()); + return; } $this->html = $html; $this->options = array_merge($this->options, $options); - $this->htmlFuncFlags = (PHP_VERSION_ID < 50400) - ? ENT_COMPAT - : ENT_COMPAT | ENT_HTML5; } /** @@ -351,7 +354,11 @@ protected function doConvert() { $this->linkList = array(); - $text = trim($this->html); + if ($this->html === null) { + $text = ''; + } else { + $text = trim($this->html); + } $this->converter($text); @@ -389,6 +396,9 @@ protected function converter(&$text) $text = preg_replace("/[\n]{3,}/", "\n\n", $text); // remove leading empty lines (can be produced by eg. P tag on the beginning) + if ($text === null) { + $text = ''; + } $text = ltrim($text, "\n"); if ($this->options['width'] > 0) { @@ -417,7 +427,7 @@ protected function buildlinkList($link, $display, $linkOverride = null) } // Ignored link types - if (preg_match('!^(javascript:|mailto:|#)!i', html_entity_decode($link))) { + if (preg_match('!^(javascript:|mailto:|#)!i', html_entity_decode($link, $this->htmlFuncFlags, self::ENCODING))) { return $display; } diff --git a/lib/moodlelib.php b/lib/moodlelib.php index c7259cc754470..8d6bf0992d952 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -2689,7 +2689,8 @@ function require_logout() { 'other' => array('sessionid' => $sid), ) ); - if ($session = $DB->get_record('sessions', array('sid'=>$sid))) { + $session = \core\session\manager::get_session_by_sid($sid); + if (isset($session->id)) { $event->add_record_snapshot('sessions', $session); } @@ -3675,7 +3676,7 @@ function delete_user(stdClass $user) { } // Force logout - may fail if file based sessions used, sorry. - \core\session\manager::kill_user_sessions($user->id); + \core\session\manager::destroy_user_sessions($user->id); // Generate username from email address, or a fake email. $delemail = !empty($user->email) ? $user->email : $user->username . '.' . $user->id . '@unknownemail.invalid'; diff --git a/lib/tests/classes/session/mock_handler.php b/lib/tests/classes/session/mock_handler.php new file mode 100644 index 0000000000000..b484b42b01a49 --- /dev/null +++ b/lib/tests/classes/session/mock_handler.php @@ -0,0 +1,86 @@ +. + +namespace core\tests\session; + +use core\clock; +use core\di; +use core\session\database; + +/** + * Mock handler methods class. + * + * @package core + * @author Darren Cocco + * @author Trisha Milan + * @copyright 2022 Monash University (http://www.monash.edu) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mock_handler extends database { + #[\Override] + public function init(): bool { + // Nothing special to do in the mock. + return true; + } + + #[\Override] + public function session_exists($sid): bool { + global $DB; + + return $DB->record_exists('sessions', ['sid' => $sid]); + } + + /** + * Insert a new session record to be used in unit tests. + * + * @param \stdClass $record + * @return int Inserted record id. + */ + public function add_test_session(\stdClass $record): int { + global $DB, $USER; + + $data = new \stdClass(); + $data->state = $record->state ?? 0; + $data->sid = $record->sid ?? session_id(); + $data->sessdata = $record->sessdata ?? null; + $data->userid = $record->userid ?? $USER->id; + $data->timecreated = $record->timecreated ?? di::get(clock::class)->time(); + $data->timemodified = $record->timemodified ?? di::get(clock::class)->time(); + $data->firstip = $record->firstip ?? getremoteaddr(); + $data->lastip = $record->lastip ?? getremoteaddr(); + + return $DB->insert_record('sessions', $data); + } + + #[\Override] + public function get_all_sessions(): \Iterator { + global $DB; + + $records = $DB->get_records('sessions'); + return new \ArrayIterator($records); + } + + /** + * Returns the number of all sessions stored. + * + * @return int + */ + public function count_sessions(): int { + global $DB; + + return $DB->count_records('sessions'); + } +} diff --git a/lib/tests/fileredact/exifremover_service_test.php b/lib/tests/fileredact/exifremover_service_test.php new file mode 100644 index 0000000000000..deba6afa478b9 --- /dev/null +++ b/lib/tests/fileredact/exifremover_service_test.php @@ -0,0 +1,310 @@ +. + +namespace core\fileredact; + +use file_storage; +use stored_file; + +/** + * Tests for the EXIF remover service. + * + * If you wish to use these unit tests all you need to do is add the following definition to + * your config.php file: + * + * define('TEST_PATH_TO_EXIFTOOL', '/usr/bin/exiftool'); + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \core\fileredact\services\exifremover_service + */ +final class exifremover_service_test extends \advanced_testcase { + + /** @var file_storage File storage. */ + private file_storage $fs; + + /** + * Set up the test environment. + */ + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + $this->fs = get_file_storage(); + } + + /** + * Creates a temporary file for testing purposes. + * + * @return stored_file The stored file. + */ + private function create_test_file(): stored_file { + $filename = 'dummy.jpg'; + $path = __DIR__ . '/../fixtures/fileredact/' . $filename; + $filerecord = (object) [ + 'contextid' => \context_user::instance(get_admin()->id)->id, + 'component' => 'user', + 'filearea' => 'unittest', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + ]; + $file = $this->fs->get_file($filerecord->contextid, $filerecord->component, $filerecord->filearea, $filerecord->itemid, + $filerecord->filepath, $filerecord->filename); + if ($file) { + $file->delete(); + } + + return $this->fs->create_file_from_pathname($filerecord, $path); + } + + /** + * Creates a temporary invalid file for testing purposes. + * + * @return stored_file The stored file. + */ + private function create_invalid_test_file(): stored_file { + $filename = 'dummy_invalid.jpg'; + $filerecord = (object) [ + 'contextid' => \context_user::instance(get_admin()->id)->id, + 'component' => 'user', + 'filearea' => 'unittest', + 'itemid' => 0, + 'filepath' => '/', + 'filename' => $filename, + ]; + $file = $this->fs->get_file($filerecord->contextid, $filerecord->component, $filerecord->filearea, $filerecord->itemid, + $filerecord->filepath, $filerecord->filename); + if ($file) { + $file->delete(); + } + + return $this->fs->create_file_from_string($filerecord, 'Dummy content'); + } + + /** + * Tests the `exifremover_service` functionality using PHP GD. + * + * This test verifies the ability of the `exifremover_service` to remove all EXIF + * tags from an image file when using PHP GD. It ensures that all tags, including + * GPSLatitude, GPSLongitude, and Orientation, are removed from the EXIF data. + * + * @return void + */ + public function test_exifremover_service_with_gd(): void { + $file = $this->create_test_file(); + // Get the EXIF data from the new file. + $currentexif = $this->get_new_exif($file->get_content()); + $this->assertStringContainsString('GPSLatitude', $currentexif); + $this->assertStringContainsString('GPSLongitude', $currentexif); + $this->assertStringContainsString('Orientation', $currentexif); + + $exifremoverservice = new services\exifremover_service($file); + $exifremoverservice->execute(); + // Get the EXIF data from the new file. + $newexif = $this->get_new_exif($file->get_content()); + + // Removing the "all" tags will result in removing all existing tags. + $this->assertStringNotContainsString('GPSLatitude', $newexif); + $this->assertStringNotContainsString('GPSLongitude', $newexif); + $this->assertStringNotContainsString('Orientation', $newexif); + } + + /** + * Tests the `exifremover_service` functionality using ExifTool. + * + * This test verifies the ability of the `exifremover_service` to remove specific + * EXIF tags from an image file when configured to use ExifTool. The test includes + * scenarios for removing all EXIF tags and for removing only GPS tags. + */ + public function test_exifremover_service_with_exiftool(): void { + if ( (defined('TEST_PATH_TO_EXIFTOOL') && TEST_PATH_TO_EXIFTOOL && !is_executable(TEST_PATH_TO_EXIFTOOL)) + || (!defined('TEST_PATH_TO_EXIFTOOL'))) { + $this->markTestSkipped('Could not test the EXIF remover service, missing configuration. ' . + "Example: define('TEST_PATH_TO_EXIFTOOL', '/usr/bin/exiftool');"); + } + + set_config('exifremovertoolpath', TEST_PATH_TO_EXIFTOOL, 'core_fileredact'); + + // Remove All tags. + set_config('exifremoverremovetags', 'all', 'core_fileredact'); + $file1 = $this->create_test_file(); + $exifremoverservice = new services\exifremover_service($file1); + $exifremoverservice->execute(); + // Get the EXIF data from the new file. + $newexif = $this->get_new_exif($file1->get_content()); + // Removing the "all" tags will result in removing all existing tags. + $this->assertStringNotContainsString('GPSLatitude', $newexif); + $this->assertStringNotContainsString('GPSLongitude', $newexif); + $this->assertStringNotContainsString('Aperture', $newexif); + // Orientation is a preserve tag. Ensure it always exists. + $this->assertStringContainsString('Orientation', $newexif); + + // Remove the GPS tag only. + set_config('exifremoverremovetags', 'gps', 'core_fileredact'); + $file2 = $this->create_test_file(); + $exifremoverservice = new services\exifremover_service($file2); + $exifremoverservice->execute(); + // Get the EXIF data from the new file. + $newexif = $this->get_new_exif($file2->get_content()); + // The GPS tag only removal will remove the tag containing "GPS" keyword. + $this->assertStringNotContainsString('GPSLatitude', $newexif); + $this->assertStringNotContainsString('GPSLongitude', $newexif); + // And keep the other tags remaining. + $this->assertStringContainsString('Aperture', $newexif); + // Orientation is a preserve tag. Ensure it always exists. + $this->assertStringContainsString('Orientation', $newexif); + } + + /** + * Tests the `is_mimetype_supported` method. + * + * This test initializes the `exifremover_service` and verifies if the given + * MIME types are supported for EXIF removal using both PHP GD and ExifTool. + */ + public function test_exifremover_service_is_mimetype_supported(): void { + $file = $this->create_test_file(); + // Init uals(false, $resultthe service. + $exifremoverservice = new services\exifremover_service($file); + + // Test using PHP GD. + $rc = new \ReflectionClass(services\exifremover_service::class); + $rcexifremover = $rc->getMethod('is_mimetype_supported'); + // As default, the exif remover only accepts the default mime type. + $result = $rcexifremover->invokeArgs($exifremoverservice, [services\exifremover_service::DEFAULT_MIMETYPE]); + $this->assertEquals(true, $result); + // Other than the default, the function will returns false. + $result = $rcexifremover->invokeArgs($exifremoverservice, ['image/tiff']); + $this->assertEquals(false, $result); + + // Test using ExifTool. + $useexiftool = $rc->getProperty('useexiftool'); + $useexiftool->setValue($exifremoverservice, true); + // Set the supported mime types. + set_config('exifremovermimetype', 'image/tiff', 'core_fileredact'); + // Other than the `image/tiff`, the function will returns false. + $result = $rcexifremover->invokeArgs($exifremoverservice, ['image/png']); + $this->assertEquals(false, $result); + + } + + /** + * Tests the `clean_filename` method. + * + * This test initializes the `exifremover_service` with a mock file record and + * invokes the `clean_filename` method via reflection to ensure it correctly + * processes the given filename. + * + * @dataProvider exifremover_service_clean_filename_provider + * + * @param string $filename The filename to be cleaned by the `clean_filename` method. + * @param string $expected The expected result after cleaning the filename. + */ + public function test_exifremover_service_clean_filename($filename, $expected): void { + $file = $this->create_test_file(); + // Init the service. + $exifremoverservice = new services\exifremover_service($file); + + $rc = new \ReflectionClass(services\exifremover_service::class); + $rccleanfilename = $rc->getMethod('clean_filename'); + + $result = $rccleanfilename->invokeArgs($exifremoverservice, [$filename]); + $this->assertEquals($expected, $result); + } + + /** + * Tests that the EXIF remover service alters the content hash of a file + * when a new file is created from the original. + */ + public function test_exifremover_contenthash(): void { + $file = $this->create_test_file(); + $beforehash = $file->get_contenthash(); + $exifremoverservice = new services\exifremover_service($file); + $exifremoverservice->execute(); + $afterhash = $file->get_contenthash(); + $this->assertNotSame($beforehash, $afterhash); + } + + /** + * Tests the EXIF remover service with an unknown filename and a valid EXIF tool path. + */ + public function test_exiftool_filename_unknown(): void { + if ( (defined('TEST_PATH_TO_EXIFTOOL') && TEST_PATH_TO_EXIFTOOL && !is_executable(TEST_PATH_TO_EXIFTOOL)) + || (!defined('TEST_PATH_TO_EXIFTOOL'))) { + $this->markTestSkipped('Could not test the EXIF remover service, missing configuration. ' . + "Example: define('TEST_PATH_TO_EXIFTOOL', '/usr/bin/exiftool');"); + } + set_config('exifremovertoolpath', TEST_PATH_TO_EXIFTOOL, 'core_fileredact'); + $invalidfile = $this->create_invalid_test_file(); + $exifremoverservice = new services\exifremover_service($invalidfile); + $this->expectException(\Exception::class); + $exifremoverservice->execute(); + } + + /** + * Tests the EXIF remover service with an unknown filename and an invalid EXIF tool path. + */ + public function test_exiftool_notfound_filename_unknown(): void { + set_config('exifremovertoolpath', 'fakeexiftool', 'core_fileredact'); + $invalidfile = $this->create_invalid_test_file(); + $exifremoverservice = new services\exifremover_service($invalidfile); + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage(get_string('fileredact:exifremover:failedprocessgd', 'core_files')); + $exifremoverservice->execute(); + } + + /** + * Retrieves the EXIF metadata of a file. + * + * @param string $content the content of file. + * @return string The EXIF metadata as a string. + */ + private function get_new_exif(string $content): string { + $logpath = make_request_directory() . '/temp.jpg'; + file_put_contents($logpath, $content); + $exif = exif_read_data($logpath); + $string = ""; + foreach ($exif as $key => $value) { + if (is_array($value)) { + foreach ($value as $subkey => $subvalue) { + $string .= "$subkey: $subvalue\n"; + } + } else { + $string .= "$key: $value\n"; + } + } + return $string; + } + + /** + * Data provider for test_exifremover_service_clean_filename(). + * + * @return array + */ + public static function exifremover_service_clean_filename_provider(): array { + return [ + 'Hyphen minus -' => [ + 'filename' => '-if \'$LensModel eq "18-35mm"\'', + 'expected' => 'if $LensModel eq 18-35mm', + ], + 'Minus −' => [ + 'filename' => '−filename.jpg', + 'expected' => 'filename.jpg', + ], + ]; + } +} diff --git a/lib/tests/fileredact/manager_test.php b/lib/tests/fileredact/manager_test.php new file mode 100644 index 0000000000000..7ba5a5b3cf508 --- /dev/null +++ b/lib/tests/fileredact/manager_test.php @@ -0,0 +1,92 @@ +. + +namespace core\fileredact; + +use stored_file; + +/** + * Tests for fileredact manager class. + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \core\fileredact\manager + */ +final class manager_test extends \advanced_testcase { + + /** @var stored_file Stored file object. */ + private stored_file $storedfile; + + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + + $file = new \stdClass; + $file->contextid = \context_user::instance(get_admin()->id)->id; + $file->component = 'user'; + $file->filearea = 'private'; + $file->itemid = 0; + $file->filepath = '/'; + $file->filename = 'test.jpg'; + $file->source = 'test'; + + $fs = get_file_storage(); + $this->storedfile = $fs->create_file_from_string($file, 'file1 content'); + } + + /** + * Tests the `get_services` method. + * + * This test initializes the `manager` and verifies that the `get_services` method. + */ + public function test_get_services(): void { + // Init the manager. + $manager = new \core\fileredact\manager($this->storedfile); + + $rc = new \ReflectionClass(\core\fileredact\manager::class); + $rcm = $rc->getMethod('get_services'); + $services = $rcm->invoke($manager); + + $this->assertGreaterThan(0, count($services)); + + } + + /** + * Tests the `execute` method and error handling. + * + * This test mocks the `manager` class to return a dummy service for `get_services` + * and verifies that the `execute` method runs without errors. + */ + public function test_execute(): void { + $managermock = $this->getMockBuilder(\core\fileredact\manager::class) + ->onlyMethods(['get_services']) + ->setConstructorArgs([$this->storedfile]) + ->getMock(); + + $managermock->expects($this->once()) + ->method('get_services') + ->willReturn(['\\core\fileredact\\services\\dummy_service']); + + /** @var \core\fileredact\manager $managermock */ + $managermock->execute(); + $errors = $managermock->get_errors(); + + // If execution is OK, then no errors. + $this->assertEquals([], $errors); + } +} diff --git a/lib/tests/fixtures/fileredact/dummy.jpg b/lib/tests/fixtures/fileredact/dummy.jpg new file mode 100644 index 0000000000000..c25d80ba9cc37 Binary files /dev/null and b/lib/tests/fixtures/fileredact/dummy.jpg differ diff --git a/lib/tests/fixtures/fileredact/dummy_service.php b/lib/tests/fixtures/fileredact/dummy_service.php new file mode 100644 index 0000000000000..eed5fabbf30cd --- /dev/null +++ b/lib/tests/fixtures/fileredact/dummy_service.php @@ -0,0 +1,63 @@ +. + +namespace core\fileredact\services; + +/** + * Dummy service for testing only. + * + * @package core + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dummy_service extends service { + + /** + * Performs redaction on the specified file. + */ + public function execute(): void { + // The function body. + } + + /** + * Returns true if the service is enabled, and "false" if it is not. + * + * @return bool + */ + public function is_enabled(): bool { + return true; + } + + /** + * Determines whether a certain mime-type is supported by the service. + * It will return true if the mime-type is supported, and false if it is not. + * + * @param string $mimetype + * @return bool + */ + public function is_mimetype_supported(string $mimetype): bool { + return true; + } + + /** + * Adds settings to the provided admin settings page. + * + * @param \admin_settingpage $settings The admin settings page to which settings are added. + */ + public static function add_settings(\admin_settingpage $settings): void { + // The function body. + } +} diff --git a/lib/tests/html2text_test.php b/lib/tests/html2text_test.php index 51d462d5473bb..295a0698c5bcd 100644 --- a/lib/tests/html2text_test.php +++ b/lib/tests/html2text_test.php @@ -25,49 +25,164 @@ * @category test * @copyright 2012 Petr Skoda {@link http://skodak.org} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers ::html_to_text */ -class html2text_test extends \basic_testcase { - +final class html2text_test extends \basic_testcase { /** - * ALT as image replacements. + * Data provider for general tests. + * + * @return array */ - public function test_images(): void { - $this->assertSame('[edit]', html_to_text('edit')); - - $text = 'xxsome gifxx'; - $result = html_to_text($text, null, false, false); - $this->assertSame($result, 'xx[some gif]xx'); - } - - /** - * No magic quotes messing. - */ - public function test_no_strip_slashes(): void { - $this->assertSame('[\edit]', html_to_text('\edit')); + public static function examples_provider(): array { + // Used in the line wrapping tests. + // phpcs:ignore Generic.Files.LineLength.TooLong + $long = "Here is a long string, more than 75 characters long, since by default html_to_text wraps text at 75 chars."; + // phpcs:ignore Generic.Files.LineLength.TooLong + $wrapped = "Here is a long string, more than 75 characters long, since by default\nhtml_to_text wraps text at 75 chars."; - $text = '\\magic\\quotes\\are\\\\horrible'; - $result = html_to_text($text, null, false, false); - $this->assertSame($result, $text); - } + // These two are used in the PRE parsing tests. + // phpcs:ignore Generic.Files.LineLength.TooLong + $strorig = 'Consider the following function:
void FillMeUp(char* in_string) {'.
+            '
int i = 0;
while (in_string[i] != \'\0\') {
in_string[i] = \'X\';
i++;
}
'. + '}
What would happen if a non-terminated string were input to this function?

'; - /** - * core_text integration. - */ - public function test_core_text(): void { - $text = 'Žluťoučký koníček'; - $result = html_to_text($text, null, false, false); - $this->assertSame($result, 'ŽLUŤOUČKÝ KONÍČEK'); + // Note, the spaces in the
 section are Unicode NBSPs - they may not be displayed in your editor.
+        $strconv = << [
+                '[edit]',
+                [],
+                'edit',
+            ],
+            'Image alt tag between strings' => [
+                'xx[some gif]xx',
+                [
+                    'dolinks' => false,
+                ],
+                'xxsome gifxx',
+            ],
+            'core_text integration' => [
+                'ŽLUŤOUČKÝ KONÍČEK',
+                ['dolinks' => false],
+                'Žluťoučký koníček',
+            ],
+            'No strip slashes in a tag' => [
+                '[\edit]',
+                [],
+                '\edit',
+            ],
+            'No strip slashes in a string' => [
+                '\\magic\\quotes\\are\\\\horrible',
+                [],
+                '\\magic\\quotes\\are\\\\horrible',
+            ],
+            'Protect "0"' => [
+                '0',
+                ['dolinks' => false],
+                '0',
+            ],
+            'Invalid HTML 1' => [
+                'Gin & Tonic',
+                [],
+                'Gin & Tonic',
+            ],
+            'Invalid HTML 2' => [
+                'Gin > Tonic',
+                [],
+                'Gin > Tonic',
+            ],
+            'Invalid HTML 3' => [
+                'Gin < Tonic',
+                [],
+                'Gin < Tonic',
+            ],
+            'Simple test 1' => [
+                "_Hello_ WORLD!\n",
+                [],
+                '

Hello world!

', + ], + 'Simple test 2' => [ + "All the WORLD’S a stage.\n\n-- William Shakespeare\n", + [], + '

All the world’s a stage.

-- William Shakespeare

', + ], + 'Simple test 3' => [ + "HELLO WORLD!\n\n", + [], + '

Hello world!

', + ], + 'Simple test 4' => [ + "Hello\nworld!", + [], + 'Hello
world!', + ], + 'No wrapping when width set to 0' => [ + $long, + ['width' => 0], + $long, + ], + 'Wrapping when width set to default' => [ + $wrapped, + [], + $long, + ], + 'Trailing whitespace removal' => [ + 'With trailing whitespace and some more text', + [], + "With trailing whitespace \nand some more text", + ], + 'PRE parsing' => [ + $strconv, + [], + $strorig, + ], + 'Strip script tags' => [ + 'Interesting text', + [], + 'Interesting text', + ], + 'Trailing spaces before newline or tab' => [ + "Some text with trailing space\n\nAnd some more text\n", + [], + '

Some text with trailing space

And some more text

', + ], + 'Trailing spaces before newline or tab (list)' => [ + "\t* Some text with trailing space\n\t* And some more text\n\n", + [], + '
  • Some text with trailing space
  • And some more text
', + ], + ]; } /** - * Protect 0. + * Test html2text with various examples. + * + * @dataProvider examples_provider + * @param string $expected + * @param array $options + * @param string $html */ - public function test_zero(): void { - $text = '0'; - $result = html_to_text($text, null, false, false); - $this->assertSame($result, $text); - - $this->assertSame('0', html_to_text('0')); + public function test_runner( + string $expected, + array $options, + string $html, + ): void { + $this->assertSame($expected, html_to_text($html, ...$options)); } /** @@ -108,81 +223,4 @@ public function test_build_link_list(): void { $this->assertSame(1, preg_match('|^'.preg_quote('[4] https://www.google.fr').'$|m', $result)); $this->assertSame(false, strpos($result, '[5]')); } - - /** - * Various invalid HTML typed by users that ignore html strict. - **/ - public function test_invalid_html(): void { - $text = 'Gin & Tonic'; - $result = html_to_text($text, null, false, false); - $this->assertSame($result, $text); - - $text = 'Gin > Tonic'; - $result = html_to_text($text, null, false, false); - $this->assertSame($result, $text); - - $text = 'Gin < Tonic'; - $result = html_to_text($text, null, false, false); - $this->assertSame($result, $text); - } - - /** - * Basic text formatting. - */ - public function test_simple(): void { - $this->assertSame("_Hello_ WORLD!\n", html_to_text('

Hello world!

')); - $this->assertSame("All the WORLD’S a stage.\n\n-- William Shakespeare\n", html_to_text('

All the world’s a stage.

-- William Shakespeare

')); - $this->assertSame("HELLO WORLD!\n\n", html_to_text('

Hello world!

')); - $this->assertSame("Hello\nworld!", html_to_text('Hello
world!')); - } - - /** - * Test line wrapping. - */ - public function test_text_nowrap(): void { - $long = "Here is a long string, more than 75 characters long, since by default html_to_text wraps text at 75 chars."; - $wrapped = "Here is a long string, more than 75 characters long, since by default\nhtml_to_text wraps text at 75 chars."; - $this->assertSame($long, html_to_text($long, 0)); - $this->assertSame($wrapped, html_to_text($long)); - } - - /** - * Whitespace removal. - */ - public function test_trailing_whitespace(): void { - $this->assertSame('With trailing whitespace and some more text', html_to_text("With trailing whitespace \nand some more text", 0)); - } - - /** - * PRE parsing. - */ - public function test_html_to_text_pre_parsing_problem(): void { - $strorig = 'Consider the following function:
void FillMeUp(char* in_string) {'.
-            '
int i = 0;
while (in_string[i] != \'\0\') {
in_string[i] = \'X\';
i++;
}
'. - '}
What would happen if a non-terminated string were input to this function?

'; - - // Note, the spaces in the
 section are Unicode NBSPs - they may not be displayed in your editor.
-        $strconv = 'Consider the following function:
-
-void FillMeUp(char* in_string) {
-  int i = 0;
-  while (in_string[i] != \'\0\') {
-    in_string[i] = \'X\';
-    i++;
-  }
-}
-What would happen if a non-terminated string were input to this function?
-
-';
-
-        $this->assertSame($strconv, html_to_text($strorig));
-    }
-
-    /**
-     * Scripts should be stripped.
-     */
-    public function test_strip_scripts(): void {
-        $this->assertSame('Interesting text',
-                html_to_text('Interesting  text', 0));
-    }
 }
diff --git a/lib/tests/session_manager_test.php b/lib/tests/session/manager_test.php
similarity index 73%
rename from lib/tests/session_manager_test.php
rename to lib/tests/session/manager_test.php
index 5da9d95749abe..0c685c59863bd 100644
--- a/lib/tests/session_manager_test.php
+++ b/lib/tests/session/manager_test.php
@@ -14,7 +14,9 @@
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see .
 
-namespace core;
+namespace core\session;
+
+use core\tests\session\mock_handler;
 
 /**
  * Unit tests for session manager class.
@@ -23,8 +25,18 @@
  * @category   test
  * @copyright  2013 Petr Skoda {@link http://skodak.org}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers     \core\session\manager
  */
-class session_manager_test extends \advanced_testcase {
+final class manager_test extends \advanced_testcase {
+
+    /** @var mock_handler $mockhandler Dedicated testing handler. */
+    protected mock_handler $mockhandler;
+
+    protected function setUp(): void {
+        parent::setUp();
+        $this->mockhandler = new mock_handler();
+    }
+
     public function test_start(): void {
         $this->resetAfterTest();
         // Session must be started only once...
@@ -185,23 +197,23 @@ public function test_session_exists(): void {
         $record->sid = $sid;
         $record->timecreated = time();
         $record->timemodified = $record->timecreated;
-        $record->id = $DB->insert_record('sessions', $record);
+        $record->id = $this->mockhandler->add_test_session($record);
 
         $this->assertTrue(\core\session\manager::session_exists($sid));
 
         $record->timecreated = time() - $CFG->sessiontimeout - 100;
         $record->timemodified = $record->timecreated + 10;
-        $DB->update_record('sessions', $record);
+        \core\session\manager::update_session($record);
 
         $this->assertTrue(\core\session\manager::session_exists($sid));
 
         $record->userid = $guest->id;
-        $DB->update_record('sessions', $record);
+        \core\session\manager::update_session($record);
 
         $this->assertTrue(\core\session\manager::session_exists($sid));
 
         $record->userid = $user->id;
-        $DB->update_record('sessions', $record);
+        \core\session\manager::update_session($record);
 
         $this->assertFalse(\core\session\manager::session_exists($sid));
 
@@ -211,7 +223,6 @@ public function test_session_exists(): void {
     }
 
     public function test_touch_session(): void {
-        global $DB;
         $this->resetAfterTest();
 
         $sid = md5('hokus');
@@ -223,17 +234,23 @@ public function test_touch_session(): void {
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 30;
         $record->firstip      = $record->lastip = '10.0.0.1';
-        $record->id = $DB->insert_record('sessions', $record);
+        $record->id = $this->mockhandler->add_test_session($record);
 
         $now = time();
         \core\session\manager::touch_session($sid);
-        $updated = $DB->get_field('sessions', 'timemodified', array('id'=>$record->id));
+        $session = \core\session\manager::get_session_by_sid($sid);
 
-        $this->assertGreaterThanOrEqual($now, $updated);
-        $this->assertLessThanOrEqual(time(), $updated);
+        $this->assertGreaterThanOrEqual($now, $session->timemodified);
+        $this->assertLessThanOrEqual(time(), $session->timemodified);
     }
 
-    public function test_kill_session(): void {
+    /**
+     * Test destroy method.
+     *
+     * @return void
+     * @throws \dml_exception
+     */
+    public function test_destroy(): void {
         global $DB, $USER;
         $this->resetAfterTest();
 
@@ -249,23 +266,23 @@ public function test_kill_session(): void {
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 30;
         $record->firstip      = $record->lastip = '10.0.0.1';
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->userid       = 0;
         $record->sid          = md5('pokus');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
-        $this->assertEquals(2, $DB->count_records('sessions'));
+        $this->assertEquals(2, $this->mockhandler->count_sessions());
 
-        \core\session\manager::kill_session($sid);
-
-        $this->assertEquals(1, $DB->count_records('sessions'));
-        $this->assertFalse($DB->record_exists('sessions', array('sid'=>$sid)));
+        \core\session\manager::destroy($sid);
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertEquals(1, count($sessions));
+        $this->assertFalse($this->contains_session(['sid' => $sid], $sessions));
 
         $this->assertSame($userid, $USER->id);
     }
 
-    public function test_kill_user_sessions(): void {
+    public function test_destroy_user_sessions(): void {
         global $DB, $USER;
         $this->resetAfterTest();
 
@@ -281,40 +298,44 @@ public function test_kill_user_sessions(): void {
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 30;
         $record->firstip      = $record->lastip = '10.0.0.1';
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus2');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->userid       = 0;
         $record->sid          = md5('pokus');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $this->assertEquals(3, $DB->count_records('sessions'));
 
-        \core\session\manager::kill_user_sessions($userid);
+        \core\session\manager::destroy_user_sessions($userid);
 
-        $this->assertEquals(1, $DB->count_records('sessions'));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $userid)));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertEquals(1, count($sessions));
+        $this->assertFalse($this->contains_session(['userid' => $userid], $sessions));
 
         $record->userid       = $userid;
         $record->sid          = md5('pokus3');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->userid       = $userid;
         $record->sid          = md5('pokus4');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->userid       = $userid;
         $record->sid          = md5('pokus5');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
-        $this->assertEquals(3, $DB->count_records('sessions', array('userid' => $userid)));
+        $sessions = \core\session\manager::get_sessions_by_userid($userid);
+        $this->assertCount(3, $sessions);
 
-        \core\session\manager::kill_user_sessions($userid, md5('pokus5'));
+        \core\session\manager::destroy_user_sessions($userid, md5('pokus5'));
 
-        $this->assertEquals(1, $DB->count_records('sessions', array('userid' => $userid)));
-        $this->assertEquals(1, $DB->count_records('sessions', array('userid' => $userid, 'sid' => md5('pokus5'))));
+        $sessions = \core\session\manager::get_sessions_by_userid($userid);
+        $session = reset($sessions);
+        $this->assertCount(1, $sessions);
+        $this->assertEquals(md5('pokus5'), $session->sid);
     }
 
     public function test_apply_concurrent_login_limit(): void {
@@ -334,41 +355,41 @@ public function test_apply_concurrent_login_limit(): void {
 
         $record->sid = md5('hokus1');
         $record->timecreated = 20;
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('hokus2');
         $record->timecreated = 10;
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('hokus3');
         $record->timecreated = 30;
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->userid = $user2->id;
         $record->sid = md5('pokus1');
         $record->timecreated = 20;
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('pokus2');
         $record->timecreated = 10;
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('pokus3');
         $record->timecreated = 30;
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->timecreated = 10;
         $record->userid = $guest->id;
         $record->sid = md5('g1');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('g2');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('g3');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->userid = 0;
         $record->sid = md5('nl1');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('nl2');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
         $record->sid = md5('nl3');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         set_config('limitconcurrentlogins', 0);
         $this->assertCount(12, $DB->get_records('sessions'));
@@ -390,57 +411,103 @@ public function test_apply_concurrent_login_limit(): void {
         set_config('limitconcurrentlogins', 2);
 
         \core\session\manager::apply_concurrent_login_limit($user1->id);
-        $this->assertCount(11, $DB->get_records('sessions'));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 20)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 30)));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 10)));
-
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(11, $sessions);
+        $this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 20], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 30], $sessions));
+        $this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 10], $sessions));
+
+        $this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
         set_config('limitconcurrentlogins', 2);
         \core\session\manager::apply_concurrent_login_limit($user2->id, md5('pokus2'));
-        $this->assertCount(10, $DB->get_records('sessions'));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(10, $sessions);
+        $this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
 
         \core\session\manager::apply_concurrent_login_limit($guest->id);
         \core\session\manager::apply_concurrent_login_limit(0);
-        $this->assertCount(10, $DB->get_records('sessions'));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(10, $sessions);
 
         set_config('limitconcurrentlogins', 1);
 
         \core\session\manager::apply_concurrent_login_limit($user1->id, md5('grrr'));
-        $this->assertCount(9, $DB->get_records('sessions'));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 20)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 30)));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 10)));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(9, $sessions);
+        $this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 20], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 30], $sessions));
+        $this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 10], $sessions));
 
         \core\session\manager::apply_concurrent_login_limit($user1->id);
-        $this->assertCount(9, $DB->get_records('sessions'));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 20)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 30)));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user1->id, 'timecreated' => 10)));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(9, $sessions);
+        $this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 20], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user1->id, 'timecreated' => 30], $sessions));
+        $this->assertFalse($this->contains_session(['userid' => $user1->id, 'timecreated' => 10], $sessions));
 
         \core\session\manager::apply_concurrent_login_limit($user2->id, md5('pokus2'));
-        $this->assertCount(8, $DB->get_records('sessions'));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(8, $sessions);
+        $this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
+        $this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
 
         \core\session\manager::apply_concurrent_login_limit($user2->id);
-        $this->assertCount(8, $DB->get_records('sessions'));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 20)));
-        $this->assertFalse($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 30)));
-        $this->assertTrue($DB->record_exists('sessions', array('userid' => $user2->id, 'timecreated' => 10)));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(8, $sessions);
+        $this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 20], $sessions));
+        $this->assertFalse($this->contains_session(['userid' => $user2->id, 'timecreated' => 30], $sessions));
+        $this->assertTrue($this->contains_session(['userid' => $user2->id, 'timecreated' => 10], $sessions));
 
         \core\session\manager::apply_concurrent_login_limit($guest->id);
         \core\session\manager::apply_concurrent_login_limit(0);
-        $this->assertCount(8, $DB->get_records('sessions'));
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertCount(8, $sessions);
     }
 
-    public function test_kill_all_sessions(): void {
+    /**
+     * Helper method to check if the sessions array contains a session with the given conditions.
+     *
+     * @param array $conditions Conditions to match.
+     * @param null|\Iterator $sessions Sessions to check.
+     * @return bool
+     */
+    protected function contains_session(array $conditions, ?\Iterator $sessions = null): bool {
+        foreach ($sessions as $session) {
+            if ($this->matches_session($conditions, $session)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Helper method to check if the session matches the given conditions.
+     *
+     * @param array $conditions Conditions to match.
+     * @param \stdClass $session Session to check.
+     * @return bool
+     */
+    protected function matches_session(array $conditions, \stdClass $session): bool {
+        foreach ($conditions as $key => $value) {
+            if ($session->$key != $value) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Test destroy_all method.
+     *
+     * @return void
+     * @throws \dml_exception
+     */
+    public function test_destroy_all(): void {
         global $DB, $USER;
         $this->resetAfterTest();
 
@@ -456,25 +523,25 @@ public function test_kill_all_sessions(): void {
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 30;
         $record->firstip      = $record->lastip = '10.0.0.1';
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus2');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $record->userid       = 0;
         $record->sid          = md5('pokus');
-        $DB->insert_record('sessions', $record);
+        $this->mockhandler->add_test_session($record);
 
         $this->assertEquals(3, $DB->count_records('sessions'));
 
-        \core\session\manager::kill_all_sessions();
+        \core\session\manager::destroy_all();
 
         $this->assertEquals(0, $DB->count_records('sessions'));
         $this->assertSame(0, $USER->id);
     }
 
     public function test_gc(): void {
-        global $CFG, $DB, $USER;
+        global $CFG, $USER;
         $this->resetAfterTest();
 
         $this->setAdminUser();
@@ -483,6 +550,8 @@ public function test_gc(): void {
         $guestid = $USER->id;
         $this->setUser(0);
 
+        // Set sessions timeout to 600 (10 minutes) seconds.
+        // We will test if sessions not modified for 600 seconds are removed.
         $CFG->sessiontimeout = 60*10;
 
         $record = new \stdClass();
@@ -493,53 +562,53 @@ public function test_gc(): void {
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 30;
         $record->firstip      = $record->lastip = '10.0.0.1';
-        $r1 = $DB->insert_record('sessions', $record);
+        $r1 = $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus2');
         $record->userid       = $adminid;
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 60*20;
-        $r2 = $DB->insert_record('sessions', $record);
+        $r2 = $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus3');
         $record->userid       = $guestid;
         $record->timecreated  = time() - 60*60*60;
         $record->timemodified = time() - 60*20;
-        $r3 = $DB->insert_record('sessions', $record);
+        $r3 = $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus4');
         $record->userid       = $guestid;
         $record->timecreated  = time() - 60*60*60;
         $record->timemodified = time() - 60*10*5 - 60;
-        $r4 = $DB->insert_record('sessions', $record);
+        $r4 = $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus5');
         $record->userid       = 0;
         $record->timecreated  = time() - 60*5;
         $record->timemodified = time() - 60*5;
-        $r5 = $DB->insert_record('sessions', $record);
+        $r5 = $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus6');
         $record->userid       = 0;
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 60*10 -10;
-        $r6 = $DB->insert_record('sessions', $record);
+        $r6 = $this->mockhandler->add_test_session($record);
 
         $record->sid          = md5('hokus7');
         $record->userid       = 0;
         $record->timecreated  = time() - 60*60;
         $record->timemodified = time() - 60*9;
-        $r7 = $DB->insert_record('sessions', $record);
-
-        \core\session\manager::gc();
-
-        $this->assertTrue($DB->record_exists('sessions', array('id'=>$r1)));
-        $this->assertFalse($DB->record_exists('sessions', array('id'=>$r2)));
-        $this->assertTrue($DB->record_exists('sessions', array('id'=>$r3)));
-        $this->assertFalse($DB->record_exists('sessions', array('id'=>$r4)));
-        $this->assertFalse($DB->record_exists('sessions', array('id'=>$r5)));
-        $this->assertFalse($DB->record_exists('sessions', array('id'=>$r6)));
-        $this->assertTrue($DB->record_exists('sessions', array('id'=>$r7)));
+        $r7 = $this->mockhandler->add_test_session($record);
+
+        \core\session\manager::gc($CFG->sessiontimeout);
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertTrue($this->contains_session(['id' => $r1], $sessions));
+        $this->assertFalse($this->contains_session(['id' => $r2], $sessions));
+        $this->assertTrue($this->contains_session(['id' => $r3], $sessions));
+        $this->assertFalse($this->contains_session(['id' => $r4], $sessions));
+        $this->assertFalse($this->contains_session(['id' => $r5], $sessions));
+        $this->assertFalse($this->contains_session(['id' => $r6], $sessions));
+        $this->assertTrue($this->contains_session(['id' => $r7], $sessions));
     }
 
     /**
@@ -648,7 +717,7 @@ public function test_get_realuser(): void {
      *
      * @return array
      */
-    public function pages_sessionlocks() {
+    public function pages_sessionlocks(): array {
         return [
             [
                 'url'      => '/good.php',
@@ -738,7 +807,7 @@ public function test_get_session_lock_info(): void {
      *
      * @return array
      */
-    public function sessionlock_history() {
+    public function sessionlock_history(): array {
         return [
             [
                 'url'      => '/good.php',
@@ -849,7 +918,7 @@ public function test_cleanup_recent_session_locks(): void {
      *
      * @return array
      */
-    public function array_session_diff_provider() {
+    public static function array_session_diff_provider(): array {
         // Create an instance of this object so the comparison object's identities are the same.
         // Used in one of the tests below.
         $compareobjectb = (object) ['array' => 'b'];
@@ -920,4 +989,44 @@ public function test_array_session_diff(array $a, array $b, array $expected): vo
         $result = $method->invokeArgs(null, [$a, $b]);
         $this->assertSame($expected, $result);
     }
+
+    /**
+     * Test destroy by auth plugin method.
+     */
+    public function test_destroy_by_auth_plugin(): void {
+        $this->resetAfterTest();
+        global $DB;
+
+        // Create test users.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user(['auth' => 'db']);
+
+        // Create sessions for the users.
+        $user1sid = md5('hokus');
+        $record = new \stdClass();
+        $record->state        = 0;
+        $record->sid          = $user1sid;
+        $record->sessdata     = null;
+        $record->userid       = $user1->id;
+        $record->timecreated  = time() - 60 * 60;
+        $record->timemodified = time() - 30;
+        $record->firstip      = $record->lastip = '10.0.0.1';
+        $this->mockhandler->add_test_session($record);
+
+        $record->sid          = md5('pokus');
+        $record->userid       = $user2->id;
+        $this->mockhandler->add_test_session($record);
+
+        // Check sessions.
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertEquals(2, count($sessions));
+
+        // Destroy the session for the user with manual auth plugin.
+        \core\session\manager::destroy_by_auth_plugin('manual');
+
+        // Check that the session for the user with manual auth plugin is destroyed.
+        $sessions = $this->mockhandler->get_all_sessions();
+        $this->assertEquals(1, count($sessions));
+        $this->assertFalse($this->contains_session(['sid' => $user1sid], $sessions));
+    }
 }
diff --git a/lib/tests/session_redis_test.php b/lib/tests/session/redis_test.php
similarity index 62%
rename from lib/tests/session_redis_test.php
rename to lib/tests/session/redis_test.php
index d86eefc04cf65..a2346ba85863a 100644
--- a/lib/tests/session_redis_test.php
+++ b/lib/tests/session/redis_test.php
@@ -14,8 +14,9 @@
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see .
 
-namespace core;
+namespace core\session;
 
+use core\tests\session\mock_handler;
 use Redis;
 use RedisException;
 
@@ -34,21 +35,21 @@
  * @copyright 2016 Russell Smith
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  * @runClassInSeparateProcess
+ * @covers \core\session\redis
  */
-class session_redis_test extends \advanced_testcase {
-
-    /** @var $keyprefix This key prefix used when testing Redis */
-    protected $keyprefix = null;
-    /** @var $redis The current testing redis connection */
-    protected $redis = null;
+final class redis_test extends \advanced_testcase {
+    /** @var string $keyprefix This key prefix used when testing Redis */
+    protected string $keyprefix = '';
+    /** @var ?Redis $redis The current testing redis connection */
+    protected ?Redis $redis = null;
     /** @var bool $encrypted Is the current testing redis connection encrypted*/
-    protected $encrypted = false;
+    protected bool $encrypted = false;
     /** @var int $acquiretimeout how long we wait for session lock in seconds when testing Redis */
-    protected $acquiretimeout = 1;
+    protected int $acquiretimeout = 1;
     /** @var int $lockexpire how long to wait in seconds before expiring the lock when testing Redis */
-    protected $lockexpire = 70;
-
+    protected int $lockexpire = 70;
 
+    #[\Override]
     public function setUp(): void {
         global $CFG;
         parent::setUp();
@@ -62,8 +63,8 @@ public function setUp(): void {
         $version = phpversion('Redis');
         if (!$version) {
             $this->markTestSkipped('Redis extension version missing');
-        } else if (version_compare($version, \core\session\redis::REDIS_EXTENSION_MIN_VERSION) <= 0) {
-            $this->markTestSkipped('Redis extension version must be at least ' . \core\session\redis::REDIS_EXTENSION_MIN_VERSION .
+        } else if (version_compare($version, \core\session\redis::REDIS_MIN_EXTENSION_VERSION) <= 0) {
+            $this->markTestSkipped('Redis extension version must be at least ' . \core\session\redis::REDIS_MIN_EXTENSION_VERSION .
                 ': now running "' . $version . '"');
         }
 
@@ -135,7 +136,7 @@ public function test_normal_session_start_stop_works(): void {
         $this->assertSame('DATA', $sess->read('sess1'));
         $this->assertTrue($sess->write('sess1', 'DATA-new'));
         $this->assertTrue($sess->close());
-        $this->assertSessionNoLocks();
+        $this->assert_session_no_locks();
     }
 
     public function test_compression_read_and_write_works(): void {
@@ -187,16 +188,16 @@ public function test_session_blocks_with_existing_session(): void {
             $sessblocked->read('sess1');
             $this->fail('Session lock must fail to be obtained.');
         } catch (\core\session\exception $e) {
-            $this->assertStringContainsString("Unable to obtain lock for session id sess1", $e->getMessage());
+            $this->assertStringContainsString("Unable to obtain lock for session id session_se", $e->getMessage());
             $this->assertStringContainsString('within 1 sec.', $e->getMessage());
             $this->assertStringContainsString('session lock timeout (1 min 10 secs) ', $e->getMessage());
-            $this->assertStringContainsString('Cannot obtain session lock for sid: sess1', file_get_contents($errorlog));
+            $this->assertStringContainsString('Cannot obtain session lock for sid: session_sess1', file_get_contents($errorlog));
         }
 
         $this->assertTrue($sessblocked->close());
         $this->assertTrue($sess->write('sess1', 'DATA-new'));
         $this->assertTrue($sess->close());
-        $this->assertSessionNoLocks();
+        $this->assert_session_no_locks();
     }
 
     public function test_session_is_destroyed_when_it_does_not_exist(): void {
@@ -205,7 +206,7 @@ public function test_session_is_destroyed_when_it_does_not_exist(): void {
         $sess->set_requires_write_lock(true);
         $this->assertTrue($sess->open('Not used', 'Not used'));
         $this->assertTrue($sess->destroy('sess-destroy'));
-        $this->assertSessionNoLocks();
+        $this->assert_session_no_locks();
     }
 
     public function test_session_is_destroyed_when_we_have_it_open(): void {
@@ -216,7 +217,7 @@ public function test_session_is_destroyed_when_we_have_it_open(): void {
         $this->assertSame('', $sess->read('sess-destroy'));
         $this->assertTrue($sess->destroy('sess-destroy'));
         $this->assertTrue($sess->close());
-        $this->assertSessionNoLocks();
+        $this->assert_session_no_locks();
     }
 
     public function test_multiple_sessions_do_not_interfere_with_each_other(): void {
@@ -260,7 +261,7 @@ public function test_multiple_sessions_do_not_interfere_with_each_other(): void
         $this->assertTrue($sess2->close());
 
         // Read the session again to ensure locking did what it should.
-        $this->assertSessionNoLocks();
+        $this->assert_session_no_locks();
     }
 
     public function test_multiple_sessions_work_with_a_single_instance(): void {
@@ -279,7 +280,7 @@ public function test_multiple_sessions_work_with_a_single_instance(): void {
         $this->assertTrue($sess->destroy('sess2'));
 
         $this->assertTrue($sess->close());
-        $this->assertSessionNoLocks();
+        $this->assert_session_no_locks();
 
         $this->assertTrue($sess->close());
     }
@@ -299,12 +300,14 @@ public function test_session_exists_returns_valid_values(): void {
         $this->assertFalse($sess->session_exists('sess1'), 'Session should be destroyed.');
     }
 
-    public function test_kill_sessions_removes_the_session_from_redis(): void {
+    public function test_destroy_removes_the_session_from_redis(): void {
         global $DB;
 
         $sess = new \core\session\redis();
         $sess->init();
 
+        $mockhandler = new mock_handler();
+
         $this->assertTrue($sess->open('Not used', 'Not used'));
         $this->assertTrue($sess->write('sess1', 'DATA'));
         $this->assertTrue($sess->write('sess2', 'DATA'));
@@ -316,22 +319,27 @@ public function test_kill_sessions_removes_the_session_from_redis(): void {
         $sessiondata->timemodified = time();
 
         $sessiondata->sid = 'sess1';
-        $DB->insert_record('sessions', $sessiondata);
+        $mockhandler->add_test_session($sessiondata);
         $sessiondata->sid = 'sess2';
-        $DB->insert_record('sessions', $sessiondata);
+        $mockhandler->add_test_session($sessiondata);
         $sessiondata->sid = 'sess3';
-        $DB->insert_record('sessions', $sessiondata);
+        $mockhandler->add_test_session($sessiondata);
 
         $this->assertNotEquals('', $sess->read('sess1'));
-        $sess->kill_session('sess1');
+        $sess->destroy('sess1');
         $this->assertEquals('', $sess->read('sess1'));
 
         $this->assertEmpty($this->redis->keys($this->keyprefix.'sess1.lock'));
 
-        $sess->kill_all_sessions();
+        $sess->destroy_all();
 
-        $this->assertEquals(3, $DB->count_records('sessions'), 'Moodle handles session database, plugin must not change it.');
-        $this->assertSessionNoLocks();
+        $mockhandler = new mock_handler();
+        $this->assertEquals(
+            3,
+            $mockhandler->count_sessions(),
+            'Moodle handles session database, plugin must not change it.',
+        );
+        $this->assert_session_no_locks();
         $this->assertEmpty($this->redis->keys($this->keyprefix.'*'), 'There should be no session data left.');
     }
 
@@ -360,7 +368,7 @@ public function test_exception_when_connection_attempts_exceeded(): void {
     /**
      * Assert that we don't have any session locks in Redis.
      */
-    protected function assertSessionNoLocks() {
+    protected function assert_session_no_locks(): void {
         $this->assertEmpty($this->redis->keys($this->keyprefix.'*.lock'));
     }
 
@@ -375,4 +383,208 @@ public function test_session_redis_encrypt(): void {
 
         $this->assertEquals($CFG->session_redis_encrypt, $prop->getValue($sess));
     }
+
+    /**
+     * Test the get maxlifetime method.
+     */
+    public function test_get_maxlifetime(): void {
+        global $CFG;
+
+        // Set the timeout to something known for the test.
+        set_config('sessiontimeout', 100);
+
+        // Generate a test user.
+        $user = $this->getDataGenerator()->create_user();
+
+        // Create a new redis session object.
+        $session = new \core\session\redis();
+        $session->init();
+
+        // The get_maxlifetime is private, so we need to use reflection to access it.
+        $method = new \ReflectionMethod(\core\session\redis::class, 'get_maxlifetime');
+
+        // Test guest timeout, which should be longer.
+        $result = $method->invoke($session, $CFG->siteguest);
+        $this->assertEquals(500, $result);
+
+        // Test first access timeout.
+        $result = $method->invoke($session, 0, true);
+        $this->assertEquals(180, $result);
+
+        // Test with a real user.
+        $result = $method->invoke($session, $user->id);
+        $this->assertEquals(180, $result);
+
+    }
+
+    /**
+     * Test the add session method.
+     */
+    public function test_add_session(): void {
+
+        // Set the timeout to something known for the test.
+        set_config('sessiontimeout', 100);
+
+        // Generate a test user.
+        $user = $this->getDataGenerator()->create_user();
+
+        // Create a new redis session object.
+        $session = new \core\session\redis();
+        $session->init();
+
+        // Create two sessions for the user.
+        session_id('id1');
+        $session1data = $session->add_session($user->id);
+        session_id('id2');
+        $session2data = $session->add_session($user->id);
+
+        $session1 = $session->get_session_by_sid('id1');
+        $session2 = $session->get_session_by_sid('id2');
+
+        // Assert that the sessions were created and have expected data.
+        $this->assertEqualsCanonicalizing((array)$session1data, (array)$session1);
+        $this->assertEqualsCanonicalizing((array)$session2data, (array)$session2);
+
+        // Check that the session hash has a ttl set.
+        $this->assertGreaterThan(-1, $this->redis->ttl($this->keyprefix . 'session_id1'));
+
+        // Check that the session ttl is less or equal to what we set it.
+        $this->assertLessThanOrEqual(180, $this->redis->ttl($this->keyprefix . 'session_id1'));
+
+    }
+
+    /**
+     * Test writing session data.
+     */
+    public function test_write(): void {
+        // Set the timeout to something known for the test.
+        set_config('sessiontimeout', 100);
+
+        // Generate a test user.
+        $user = $this->getDataGenerator()->create_user();
+
+        // Create a new redis session object.
+        $session = new \core\session\redis();
+        $session->init();
+
+        // Create two sessions for the user.
+        session_id('id1');
+        $session->add_session($user->id);
+        session_id('id2');
+        $session->add_session($user->id);
+
+        $testdata = 'some test data';
+
+        // Write some data to the store.
+        $result = $session->write('id2', $testdata);
+
+        // Check that the write was successful.
+        $this->assertTrue($result);
+
+        // Check that the data was written to the store.
+        $getdata = $this->redis->hget($this->keyprefix . 'session_id2', 'sessdata');
+        $this->assertStringContainsString($testdata, $getdata);
+    }
+
+    /**
+     * Test reading session data.
+     */
+    public function test_read(): void {
+        // Set the timeout to something known for the test.
+        set_config('sessiontimeout', 100);
+
+        // Generate a test user.
+        $user = $this->getDataGenerator()->create_user();
+
+        // Create a new redis session object.
+        $session = new \core\session\redis();
+        $session->init();
+
+        // Create two sessions for the user.
+        session_id('id1');
+        $session->add_session($user->id);
+        session_id('id2');
+        $session->add_session($user->id);
+
+        $testdata = 'some test data';
+
+        // Write some session data to the store.
+        $session->write('id2', $testdata);
+
+        // Read the session data.
+        $result = $session->read('id2');
+
+        // Check that the read was successful.
+        $this->assertEquals($result, $testdata);
+
+    }
+
+    /**
+     * Test updating a session.
+     */
+    public function test_update_session(): void {
+        // Set the timeout to something known for the test.
+        set_config('sessiontimeout', 100);
+
+        // Generate a test user.
+        $user = $this->getDataGenerator()->create_user();
+
+        // Create a new redis session object.
+        $session = new \core\session\redis();
+        $session->init();
+
+        // Create two sessions for the user.
+        session_id('id1');
+        $session->add_session($user->id);
+        session_id('id2');
+        $sessiondata = $session->add_session($user->id);
+
+        // Update the session data.
+        $sessiondata->lastip = '8.8.8.8';
+        $session->update_session($sessiondata);
+
+        // Check the value was updated.
+        $updatedsession = $session->get_session_by_sid('id2');
+        $this->assertEquals('8.8.8.8', $updatedsession->lastip);
+
+        // Test session update when userid is not set, should not error.
+        unset($sessiondata->userid);
+        $session->update_session($sessiondata);
+        $this->assertDebuggingNotCalled();
+    }
+
+    /**
+     * Test destroying a session by auth plugin.
+     */
+    public function test_destroy_by_auth_plugin(): void {
+        // Create test users.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user(['auth' => 'db']);
+
+        // Create a new redis session object.
+        $session = new \core\session\redis();
+        $session->init();
+
+        // Create  sessions for the users.
+        session_id('id1');
+        $session1data = $session->add_session($user1->id);
+        session_id('id2');
+        $session2data = $session->add_session($user2->id);
+
+        $session1 = $session->get_session_by_sid('id1');
+        $session2 = $session->get_session_by_sid('id2');
+
+        // Assert that the sessions were created and have expected data.
+        $this->assertEqualsCanonicalizing((array) $session1data, (array) $session1);
+        $this->assertEqualsCanonicalizing((array) $session2data, (array) $session2);
+
+        // Destroy the session by auth plugin.
+        $session->destroy_by_auth_plugin('manual');
+
+        // Check that the session was destroyed.
+        $this->assertFalse($session->session_exists('id1'));
+
+        // Check the session with db auth plugin was not destroyed.
+        $this->assertTrue($session->session_exists('id2'));
+    }
 }
diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml
index 0cdc56ea07f17..a1dd9781934cd 100644
--- a/lib/thirdpartylibs.xml
+++ b/lib/thirdpartylibs.xml
@@ -235,7 +235,7 @@
     html2text
     HTML2Text
     PHP script to convert HTML into an approximate text equivalent.
-    4.3.1
+    4.3.2
     GPL
     2.0+
     https://github.com/mtibben/html2text
diff --git a/login/change_password.php b/login/change_password.php
index 98d107d8ad1fe..d378092b316c1 100644
--- a/login/change_password.php
+++ b/login/change_password.php
@@ -119,7 +119,7 @@
 
     // Log out all other sessions if mandated by admin, or if set by the user.
     if (!empty($CFG->passwordchangelogout) || !empty($data->logoutothersessions)) {
-        \core\session\manager::kill_user_sessions($USER->id, session_id());
+        \core\session\manager::destroy_user_sessions($USER->id, session_id());
     }
 
     if (!empty($data->signoutofotherservices)) {
diff --git a/login/lib.php b/login/lib.php
index 9aaf2bfad6d9e..8c879975ac115 100644
--- a/login/lib.php
+++ b/login/lib.php
@@ -288,7 +288,7 @@ function core_login_process_password_set($token) {
         }
         user_add_password_history($user->id, $data->password);
         if (!empty($CFG->passwordchangelogout) || !empty($data->logoutothersessions)) {
-            \core\session\manager::kill_user_sessions($user->id, session_id());
+            \core\session\manager::destroy_user_sessions($user->id, session_id());
         }
         // Reset login lockout (if present) before a new password is set.
         login_unlock_account($user);
diff --git a/mod/assign/classes/downloader.php b/mod/assign/classes/downloader.php
index 7d29461f8f7fc..420f5fbd7ae17 100644
--- a/mod/assign/classes/downloader.php
+++ b/mod/assign/classes/downloader.php
@@ -64,7 +64,7 @@ class downloader {
      * Class constructor.
      *
      * @param assign $manager the instance manager
-     * @param array|null $userids the user ids to download.
+     * @param int[]|null $userids the user ids to download.
      */
     public function __construct(assign $manager, ?array $userids = null) {
         $this->manager = $manager;
diff --git a/mod/assign/classes/form/grading_options_temp_form.php b/mod/assign/classes/form/grading_options_temp_form.php
index a94d4b977fd59..688f0056ba803 100644
--- a/mod/assign/classes/form/grading_options_temp_form.php
+++ b/mod/assign/classes/form/grading_options_temp_form.php
@@ -75,13 +75,6 @@ public function definition() {
             $mform->setDefault('showonlyactiveenrol', $instance['showonlyactiveenrol']);
         }
 
-        // Place student downloads in seperate folders.
-        if ($instance['submissionsenabled']) {
-            $mform->addElement('checkbox', 'downloadasfolders', get_string('downloadasfolders', 'assign'));
-            $mform->addHelpButton('downloadasfolders', 'downloadasfolders', 'assign');
-            $mform->setDefault('downloadasfolders', $instance['downloadasfolders']);
-        }
-
         // Hidden params.
         $mform->addElement('hidden', 'contextid', $instance['contextid']);
         $mform->setType('contextid', PARAM_INT);
diff --git a/mod/assign/classes/output/grading_actionmenu.php b/mod/assign/classes/output/grading_actionmenu.php
index 4c5aa1e8f740d..542a02843b18f 100644
--- a/mod/assign/classes/output/grading_actionmenu.php
+++ b/mod/assign/classes/output/grading_actionmenu.php
@@ -48,6 +48,8 @@ class grading_actionmenu implements templatable, renderable {
     protected int $submissioncount;
     /** @var assign The assign instance. */
     protected assign $assign;
+    /** @var bool If there are submissions to download. */
+    protected bool $showdownload;
 
     /**
      * Constructor for this object.
@@ -69,6 +71,7 @@ public function __construct(
             $assign = new assign($context, null, null);
         }
         $this->assign = $assign;
+        $this->showdownload = $this->assign->is_any_submission_plugin_enabled() && $this->assign->count_submissions();
     }
 
     /**
@@ -162,6 +165,23 @@ public function export_for_template(\renderer_base $output): array {
             ];
         }
 
+        if ($this->showdownload) {
+            $downloadasfoldersbaseurl = new moodle_url('/mod/assign/view.php', [
+                'id' => $this->assign->get_course_module()->id,
+                'action' => 'grading',
+            ]);
+            if ($userid) {
+                $downloadasfoldersbaseurl->param('userid', $userid);
+            } else if ($usersearch) {
+                $downloadasfoldersbaseurl->param('search', $usersearch);
+            }
+            $downloadasfolders = get_user_preferences('assign_downloadasfolders', 1);
+            $data['downloadasfolders'] = [
+                'baseurl' => $downloadasfoldersbaseurl->out(false),
+                'enabled' => $downloadasfolders,
+            ];
+        }
+
         $actions = $this->get_actions();
         if ($actions) {
             $menu = new \action_menu();
@@ -219,7 +239,7 @@ private function get_actions() {
                 }
             }
         }
-        if ($this->assign->is_any_submission_plugin_enabled() && $this->assign->count_submissions()) {
+        if ($this->showdownload) {
             $url = new moodle_url('/mod/assign/view.php', [
                 'id' => $this->assign->get_course_module()->id,
                 'action' => 'downloadall',
diff --git a/mod/assign/feedback/comments/tests/behat/preserve_changes_on_validation_failure.feature b/mod/assign/feedback/comments/tests/behat/preserve_changes_on_validation_failure.feature
index 68600fcaad860..d4b06b57a1873 100644
--- a/mod/assign/feedback/comments/tests/behat/preserve_changes_on_validation_failure.feature
+++ b/mod/assign/feedback/comments/tests/behat/preserve_changes_on_validation_failure.feature
@@ -22,9 +22,8 @@ Feature: Check that any changes to assignment feedback comments are not lost
       | activity | name                 | course | assignfeedback_comments_enabled |
       | assign   | Test assignment name | C1     | 1                               |
     And I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
     And I change window size to "large"
-    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I go to "Student 1" "Test assignment name" activity advanced grading page
     When I set the following fields to these values:
       | Grade out of 100  | 101                    |
       | Feedback comments | Feedback from teacher. |
diff --git a/mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature b/mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
index eb935070a35fc..ab85c8cc2f57e 100644
--- a/mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
+++ b/mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
@@ -35,9 +35,8 @@ Feature: In an assignment, teacher can annotate PDF files during grading
       | file    | mod/assign/feedback/editpdf/tests/fixtures/testgs.pdf  |
 
     When I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
     And I change window size to "large"
-    And I click on "Grade" "link" in the "Submitted for grading" "table_row"
+    And I go to "Submitted for grading" "Test assignment name" activity advanced grading page
     And I change window size to "medium"
     Then I should see "Page 1 of 1"
     And I wait for the complete PDF to load
@@ -55,9 +54,8 @@ Feature: In an assignment, teacher can annotate PDF files during grading
     And I follow "View annotated PDF..."
     Then I should see "Page 1 of 1"
     And I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
     And I change window size to "large"
-    And I click on "Grade" "link" in the "Submitted for grading" "table_row"
+    And I go to "Submitted for grading" "Test assignment name" activity advanced grading page
     And I change window size to "medium"
     Then I should see "Page 1 of 3"
     And I wait for the complete PDF to load
@@ -111,9 +109,8 @@ Feature: In an assignment, teacher can annotate PDF files during grading
     And I log out
 
     When I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
     And I change window size to "large"
-    And I click on "Grade" "link" in the "Submitted for grading" "table_row"
+    And I go to "Submitted for grading" "Test assignment name" activity advanced grading page
     And I change window size to "medium"
     Then I should see "Page 1 of 3"
     And I click on ".navigate-next-button" "css_element"
@@ -183,9 +180,7 @@ Feature: In an assignment, teacher can annotate PDF files during grading
       | file    | mod/assign/feedback/editpdf/tests/fixtures/submission.pdf  |
 
     And I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
-    And I open the action menu in "Student 2" "table_row"
-    And I click on "Grade" "link" in the "Student 2" "table_row"
+    And I go to "Student 1" "Test assignment name" activity advanced grading page
     And I wait for the complete PDF to load
     And I click on ".linebutton" "css_element"
     And I draw on the pdf
diff --git a/mod/assign/feedback/editpdf/tests/behat/comment_popup_menu.feature b/mod/assign/feedback/editpdf/tests/behat/comment_popup_menu.feature
index b58215ac6018a..d7ce9a6252e4f 100644
--- a/mod/assign/feedback/editpdf/tests/behat/comment_popup_menu.feature
+++ b/mod/assign/feedback/editpdf/tests/behat/comment_popup_menu.feature
@@ -32,9 +32,8 @@ Feature: Ensure that a comment remains visible if its popup menu is open
       | file    | mod/assign/feedback/editpdf/tests/fixtures/submission.pdf  |
 
     And I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
     And I change window size to "large"
-    And I click on "Grade" "link" in the "Submitted for grading" "table_row"
+    And I go to "Submitted for grading" "Test assignment name" activity advanced grading page
     And I change window size to "medium"
     And I wait for the complete PDF to load
 
diff --git a/mod/assign/feedback/editpdf/tests/behat/group_annotations.feature b/mod/assign/feedback/editpdf/tests/behat/group_annotations.feature
index b3c915672fa99..b3d7474d396a3 100644
--- a/mod/assign/feedback/editpdf/tests/behat/group_annotations.feature
+++ b/mod/assign/feedback/editpdf/tests/behat/group_annotations.feature
@@ -42,8 +42,7 @@ Feature: In a group assignment, teacher can annotate PDF files for all users
       | file    | mod/assign/feedback/editpdf/tests/fixtures/submission.pdf  |
 
     And I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
-    And I click on "Grade" "link" in the "Submitted for grading" "table_row"
+    And I go to "Submitted for grading" "Test assignment name" activity advanced grading page
     And I wait for the complete PDF to load
     And I click on ".navigate-next-button" "css_element"
     And I wait until the page is ready
diff --git a/mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature b/mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
index a3e51d157b503..0d4ec17f26027 100644
--- a/mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
+++ b/mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
@@ -36,9 +36,8 @@ Feature: In an assignment, teacher can view the feedback for a previous attempt.
       | file    | mod/assign/feedback/editpdf/tests/fixtures/submission.pdf, mod/assign/feedback/editpdf/tests/fixtures/testgs.pdf  |
 
     When I am on the "Test assignment name" Activity page logged in as teacher1
-    And I navigate to "Submissions" in current page administration
     And I change window size to "large"
-    And I click on "Grade" "link" in the "Submitted for grading" "table_row"
+    And I go to "Submitted for grading" "Test assignment name" activity advanced grading page
     And I change window size to "medium"
     Then I should see "Page 1 of 3"
     And I click on ".navigate-next-button" "css_element"
diff --git a/mod/assign/gradingtable.php b/mod/assign/gradingtable.php
index 7be5633e8e182..6e519ea573d1a 100644
--- a/mod/assign/gradingtable.php
+++ b/mod/assign/gradingtable.php
@@ -991,7 +991,6 @@ public function col_grademax(stdClass $row) {
      * @return string
      */
     public function col_grade(stdClass $row): string {
-        $separator = $this->output->spacer(array(), true);
         $gradingdisabled = $this->assignment->grading_disabled($row->id, true, $this->gradinginfo);
         $displaygrade = $this->display_grade($row->grade, $this->quickgrading && !$gradingdisabled, $row->userid, $row->timemarked);
 
@@ -1012,10 +1011,8 @@ public function col_grade(stdClass $row): string {
             }
             $url = new moodle_url('/mod/assign/view.php', $urlparams);
 
-            // The grade button.
-            $gradebutton = html_writer::link($url, get_string('gradeverb'), ['class' => 'btn btn-primary']);
             // The container with the grade information.
-            $gradecontainer = $this->output->container($gradebutton . $separator . $displaygrade, 'w-100');
+            $gradecontainer = $this->output->container($displaygrade, 'w-100');
 
             $menu = new action_menu();
             $menu->set_owner_selector('.gradingtable-actionmenu');
diff --git a/mod/assign/lang/en/assign.php b/mod/assign/lang/en/assign.php
index 3a9b312cb1d29..aae8816e64b3e 100644
--- a/mod/assign/lang/en/assign.php
+++ b/mod/assign/lang/en/assign.php
@@ -190,7 +190,6 @@
 $string['downloadall'] = 'Download all submissions';
 $string['download all submissions'] = 'Download all submissions in a zip file.';
 $string['downloadasfolders'] = 'Download submissions in folders';
-$string['downloadasfolders_help'] = 'Assignment submissions may be downloaded in folders. Each submission is then put in a separate folder, with the folder structure kept for any subfolders, and files are not renamed.';
 $string['downloadselectedsubmissions'] = 'Download selected submissions';
 $string['duedate'] = 'Due date';
 $string['duedatecolon'] = 'Due date: {$a}';
@@ -686,7 +685,6 @@
 $string['viewfeedbackforuser'] = 'View feedback for user: {$a}';
 $string['viewfullgradingpage'] = 'Open the full grading page to provide feedback';
 $string['viewgradebook'] = 'View gradebook';
-$string['viewgrader'] = 'View Grader';
 $string['viewgradingformforstudent'] = 'View grading page for student: (id={$a->id}, fullname={$a->fullname}).';
 $string['viewgrading'] = 'View all submissions';
 $string['viewownsubmissionform'] = 'View own submit assignment page.';
@@ -703,6 +701,8 @@
 // Deprecated since Moodle 4.5.
 $string['attemptreopenmethod_none'] = 'Never';
 $string['choosegradingaction'] = 'Grading action';
+$string['downloadasfolders_help'] = 'Assignment submissions may be downloaded in folders. Each submission is then put in a separate folder, with the folder structure kept for any subfolders, and files are not renamed.';
 $string['groupoverridesdeleted'] = 'Group overrides deleted';
 $string['updategrade'] = 'Update grade';
 $string['useroverridesdeleted'] = 'User overrides deleted';
+$string['viewgrader'] = 'View Grader';
diff --git a/mod/assign/lang/en/deprecated.txt b/mod/assign/lang/en/deprecated.txt
index bf86c650b524c..594403cfe355a 100644
--- a/mod/assign/lang/en/deprecated.txt
+++ b/mod/assign/lang/en/deprecated.txt
@@ -3,3 +3,5 @@ groupoverridesdeleted,mod_assign
 useroverridesdeleted,mod_assign
 choosegradingaction,mod_assign
 updategrade,mod_assign
+viewgrader,mod_assign
+downloadasfolders_help,mod_assign
diff --git a/mod/assign/locallib.php b/mod/assign/locallib.php
index 95125ae84a847..3d601d035618f 100644
--- a/mod/assign/locallib.php
+++ b/mod/assign/locallib.php
@@ -3740,7 +3740,7 @@ public function can_grade($user = null) {
     /**
      * Download a zip file of all assignment submissions.
      *
-     * @param array|null $userids Array of user ids to download assignment submissions in a zip file
+     * @param int[]|null $userids Array of user ids to download assignment submissions in a zip file
      * @return string - If an error occurs, this will contain the error page.
      */
     protected function download_submissions($userids = null) {
@@ -4499,6 +4499,11 @@ protected function view_grading_table() {
             set_user_preference('assign_quickgrading', $submittedquickgrading);
         }
 
+        $submitteddownloadasfolders = optional_param('downloadasfolders', null, PARAM_BOOL);
+        if (isset($submitteddownloadasfolders)) {
+            set_user_preference('assign_downloadasfolders', $submitteddownloadasfolders);
+        }
+
         $o = '';
         $cmid = $this->get_course_module()->id;
 
@@ -4520,7 +4525,6 @@ protected function view_grading_table() {
         $showquickgrading = empty($controller) && $this->can_grade();
         $quickgrading = get_user_preferences('assign_quickgrading', false);
         $showonlyactiveenrolopt = has_capability('moodle/course:viewsuspendedusers', $this->context);
-        $downloadasfolders = get_user_preferences('assign_downloadasfolders', 1);
 
         $markingallocation = $this->get_instance()->markingworkflow &&
             $this->get_instance()->markingallocation &&
@@ -4548,12 +4552,10 @@ protected function view_grading_table() {
             'cm' => $cmid,
             'contextid' => $this->context->id,
             'userid' => $USER->id,
-            'submissionsenabled' => $this->is_any_submission_plugin_enabled(),
             'markingworkflowopt' => $markingworkflowoptions,
             'markingallocationopt' => $markingallocationoptions,
             'showonlyactiveenrolopt' => $showonlyactiveenrolopt,
             'showonlyactiveenrol' => $this->show_only_active_users(),
-            'downloadasfolders' => $downloadasfolders,
         ];
 
         $classoptions = array('class'=>'gradingoptionsform');
@@ -7416,12 +7418,10 @@ protected function process_save_grading_options() {
             'cm' => $this->get_course_module()->id,
             'contextid' => $this->context->id,
             'userid' => $USER->id,
-            'submissionsenabled' => $this->is_any_submission_plugin_enabled(),
             'markingworkflowopt' => $markingworkflowoptions,
             'markingallocationopt' => $markingallocationoptions,
             'showonlyactiveenrolopt' => $showonlyactiveenrolopt,
             'showonlyactiveenrol' => $this->show_only_active_users(),
-            'downloadasfolders' => get_user_preferences('assign_downloadasfolders', 1),
         ];
         $mform = new mod_assign\form\grading_options_temp_form(null, $gradingoptionsparams);
         if ($formdata = $mform->get_data()) {
@@ -7429,11 +7429,6 @@ protected function process_save_grading_options() {
             if (isset($formdata->markerfilter)) {
                 set_user_preference('assign_markerfilter', $formdata->markerfilter);
             }
-            if (isset($formdata->downloadasfolders)) {
-                set_user_preference('assign_downloadasfolders', 1); // Enabled.
-            } else {
-                set_user_preference('assign_downloadasfolders', 0); // Disabled.
-            }
             if (!empty($showonlyactiveenrolopt)) {
                 $showonlyactiveenrol = isset($formdata->showonlyactiveenrol);
                 set_user_preference('grade_report_showonlyactiveenrol', $showonlyactiveenrol);
diff --git a/mod/assign/templates/grading_actionmenu.mustache b/mod/assign/templates/grading_actionmenu.mustache
index 53124402b00e7..6653fff89ed8a 100644
--- a/mod/assign/templates/grading_actionmenu.mustache
+++ b/mod/assign/templates/grading_actionmenu.mustache
@@ -30,6 +30,7 @@
     * pagereset - (optional) URL to reset the page
     * graderurl - (optional) URL to the grader page
     * quickgrading - (optional) Includes the baseurl and enabled properties for the quick grading checkbox
+    * downloadasfolders - (optional) Includes the baseurl and enabled properties for the download as folders checkbox
     * actions - (optional) HTML that outputs the bulk action menu
 
     Example context (json):
@@ -57,6 +58,10 @@
             "baseurl": "http://moodle.local/mod/assign/view.php?id=2&action=grading",
             "enabled": true
         },
+        "downloadasfolders": {
+            "baseurl": "http://moodle.local/mod/assign/view.php?id=2&action=grading",
+            "enabled": true
+        },
         "actions": "
" } @@ -103,9 +108,9 @@ {{/pagereset}} {{#graderurl}} - + {{/graderurl}} @@ -127,6 +132,22 @@ }); {{/js}} {{/quickgrading}} + {{#downloadasfolders}} + + + {{#js}} + document.querySelector('#downloadasfolders-{{uniqid}}').addEventListener('change', function(e) { + var url = new URL('{{{baseurl}}}'); + url.searchParams.set('downloadasfolders', e.target.checked ? 1 : 0); + window.location.href = url; + }); + {{/js}} + {{/downloadasfolders}} {{#actions}} {{/actions}} diff --git a/mod/assign/tests/behat/allow_another_attempt.feature b/mod/assign/tests/behat/allow_another_attempt.feature index 7303083c6aa58..a2d68dbf62a9c 100644 --- a/mod/assign/tests/behat/allow_another_attempt.feature +++ b/mod/assign/tests/behat/allow_another_attempt.feature @@ -33,9 +33,8 @@ Feature: In an assignment, students start a new attempt based on their previous | Test assignment name | student1 | I'm the student first submission | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I change window size to "medium" And I set the following fields to these values: | Allow another attempt | 1 | @@ -50,9 +49,8 @@ Feature: In an assignment, students start a new attempt based on their previous And I log out And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I change window size to "medium" And I should see "I'm the student first submission" @@ -134,7 +132,8 @@ Feature: In an assignment, students start a new attempt based on their previous And "Student 2" row "Status" column of "generaltable" table should contain "Reopened" And "Student 3" row "Status" column of "generaltable" table should contain "Submitted for grading" And "Student 4" row "Status" column of "generaltable" table should contain "Submitted for grading" - And I click on "Grade" "link" in the "Student 3" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 3" "table_row" + And I choose "Grade" in the open action menu And I set the following fields to these values: | Allow another attempt | 1 | And I press "Save changes" diff --git a/mod/assign/tests/behat/assign_activity_completion.feature b/mod/assign/tests/behat/assign_activity_completion.feature index 833e849f716be..c583189796c94 100644 --- a/mod/assign/tests/behat/assign_activity_completion.feature +++ b/mod/assign/tests/behat/assign_activity_completion.feature @@ -121,8 +121,7 @@ Feature: View activity completion in the assignment activity And the "Receive a grade" completion condition of "Music history" is displayed as "todo" And I log out And I am on the "Music history" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Vinnie Student1" "table_row" + And I go to "Vinnie Student1" "Music history" activity advanced grading page And I set the field "Grade out of 100" to "33" And I set the field "Notify student" to "0" And I press "Save changes" @@ -145,8 +144,7 @@ Feature: View activity completion in the assignment activity And the "Make a submission" completion condition of "Music history 2" is displayed as "done" And I log out And I am on the "Music history 2" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Vinnie Student1" "table_row" + And I go to "Vinnie Student1" "Music history 2" activity advanced grading page And I set the field "Grade out of 100" to "33" And I set the field "Notify student" to "0" And I set the field "Allow another attempt" to "Yes" diff --git a/mod/assign/tests/behat/assign_anonymous_submission.feature b/mod/assign/tests/behat/assign_anonymous_submission.feature index 929c0fb5cfcd1..a4ec447a56ee1 100644 --- a/mod/assign/tests/behat/assign_anonymous_submission.feature +++ b/mod/assign/tests/behat/assign_anonymous_submission.feature @@ -34,7 +34,8 @@ Feature: Teacher can enable anonymous submissions for an assignment And I navigate to "Submissions" in current page administration # Confirm that Participant [n] is displayed instead of Student One - student name is hidden And I should not see "Student One" in the "Participant" "table_row" - And I click on "Grade" "link" in the "Participant" "table_row" + And I click on "Grade actions" "actionmenu" in the "Participant" "table_row" + And I choose "Grade" in the open action menu And I set the field "Grade out of 100" to "70" And I press "Save changes" And I am on the "Assign 1" "assign activity" page diff --git a/mod/assign/tests/behat/assign_settings.feature b/mod/assign/tests/behat/assign_settings.feature index b05f880d06976..5310f2391ef3a 100644 --- a/mod/assign/tests/behat/assign_settings.feature +++ b/mod/assign/tests/behat/assign_settings.feature @@ -47,8 +47,7 @@ Feature: Assignments settings can be changed And I press "Save changes" And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "40" And I set the field "Notify student" to "0" And I press "Save changes" @@ -81,8 +80,7 @@ Feature: Assignments settings can be changed And I press "Save changes" And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 2" "table_row" + And I go to "Student 2" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "40" And I set the field "Notify student" to "0" And I press "Save changes" @@ -106,9 +104,8 @@ Feature: Assignments settings can be changed And I press "Save changes" When I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 3" "table_row" + And I go to "Student 3" "Test assignment name" activity advanced grading page And I change window size to "medium" And I set the field "Grade out of 100" to "40" And I set the field "Notify student" to "0" diff --git a/mod/assign/tests/behat/assign_table_preferences.feature b/mod/assign/tests/behat/assign_table_preferences.feature index 3eb6220d0fc0f..cb386f46124a3 100644 --- a/mod/assign/tests/behat/assign_table_preferences.feature +++ b/mod/assign/tests/behat/assign_table_preferences.feature @@ -49,7 +49,8 @@ Feature: In an assignment, teachers can use table preferences. And I navigate to "Submissions" in current page administration And I click on "T" "link" in the ".lastinitial" "css_element" And I change window size to "large" - And I click on "Grade" "link" in the "Student Two" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student Two" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I should see "This is a submission for Student Two" And I should see "1 of 1" diff --git a/mod/assign/tests/behat/bulk_release_anon_submissions.feature b/mod/assign/tests/behat/bulk_release_anon_submissions.feature index bb293a1f64043..23d6424f012c7 100644 --- a/mod/assign/tests/behat/bulk_release_anon_submissions.feature +++ b/mod/assign/tests/behat/bulk_release_anon_submissions.feature @@ -40,7 +40,8 @@ Feature: Bulk released grades should not be sent to gradebook while submissions And I am on the "Test assignment name" "assign activity" page logged in as "teacher1" And I navigate to "Submissions" in current page administration Then I should see "Not marked" in the "I'm student1's submission" "table_row" - And I click on "Grade" "link" in the "I'm student1's submission" "table_row" + And I click on "Grade actions" "actionmenu" in the "I'm student1's submission" "table_row" + And I choose "Grade" in the open action menu And I set the field "Grade out of 100" to "50" And I set the field "Marking workflow state" to "In review" And I set the field "Feedback comments" to "Great job!" @@ -50,7 +51,8 @@ Feature: Bulk released grades should not be sent to gradebook while submissions And I navigate to "Submissions" in current page administration Then I should see "Not marked" in the "I'm student2's submission" "table_row" And I change window size to "large" - And I click on "Grade" "link" in the "I'm student2's submission" "table_row" + And I click on "Grade actions" "actionmenu" in the "I'm student2's submission" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I set the field "Grade out of 100" to "50" And I set the field "Marking workflow state" to "In review" diff --git a/mod/assign/tests/behat/comment_inline.feature b/mod/assign/tests/behat/comment_inline.feature index 1a04749dd0fea..5371412da1504 100644 --- a/mod/assign/tests/behat/comment_inline.feature +++ b/mod/assign/tests/behat/comment_inline.feature @@ -32,9 +32,8 @@ Feature: In an assignment, teachers can edit a students submission inline | Test assignment name | student1 | I'm the student first submission | When I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I change window size to "medium" And I set the following fields to these values: | Grade out of 100 | 50 | diff --git a/mod/assign/tests/behat/display_error_message_onbadformat.feature b/mod/assign/tests/behat/display_error_message_onbadformat.feature index 7417cc332bc78..cc6eaf6a6575d 100644 --- a/mod/assign/tests/behat/display_error_message_onbadformat.feature +++ b/mod/assign/tests/behat/display_error_message_onbadformat.feature @@ -28,9 +28,8 @@ Feature: Check that the assignment grade can not be input in a wrong format. | markingworkflow | 1 | | submissiondrafts | 0 | When I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "50,,6" And I press "Save changes" Then I should see "The grade provided could not be understood: 50,,6" @@ -59,9 +58,8 @@ Feature: Check that the assignment grade can not be input in a wrong format. | markingworkflow | 1 | | submissiondrafts | 0 | When I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "50..6" And I press "Save changes" Then I should see "The grade provided could not be understood: 50..6" diff --git a/mod/assign/tests/behat/display_grade.feature b/mod/assign/tests/behat/display_grade.feature index c63080492e645..ad6c265a4000f 100644 --- a/mod/assign/tests/behat/display_grade.feature +++ b/mod/assign/tests/behat/display_grade.feature @@ -28,9 +28,8 @@ Feature: Check that the assignment grade can be updated correctly | markingworkflow | 1 | | submissiondrafts | 0 | And I am on the "Test assignment name" Activity page logged in as teacher1 - Then I navigate to "Submissions" in current page administration - And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + Then I change window size to "large" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "50" And I set the field "Notify student" to "0" And I press "Save changes" @@ -63,9 +62,8 @@ Feature: Check that the assignment grade can be updated correctly | teamsubmission | 1 | | groupmode | 0 | And I am on the "Test assignment name" Activity page logged in as teacher1 - When I navigate to "Submissions" in current page administration - And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + When I change window size to "large" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I change window size to "medium" And I set the field "Grade out of 100" to "50" And I set the field "Notify student" to "0" diff --git a/mod/assign/tests/behat/edit_previous_feedback.feature b/mod/assign/tests/behat/edit_previous_feedback.feature index d21c947103865..f6031c489cfb1 100644 --- a/mod/assign/tests/behat/edit_previous_feedback.feature +++ b/mod/assign/tests/behat/edit_previous_feedback.feature @@ -33,9 +33,8 @@ Feature: In an assignment, teachers can edit feedback for a students previous su | assign | user | onlinetext | | Test assignment name | student2 | I'm the student first submission | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 2" "table_row" + And I go to "Student 2" "Test assignment name" activity advanced grading page And I change window size to "medium" And I set the following fields to these values: | Grade | 49 | @@ -50,9 +49,8 @@ Feature: In an assignment, teachers can edit feedback for a students previous su And I log out And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration And I change window size to "large" - And I click on "Grade" "link" in the "Student 2" "table_row" + And I go to "Student 2" "Test assignment name" activity advanced grading page And I change window size to "medium" And I click on "View a different attempt" "link" And I click on "Attempt 1" "radio" in the "View a different attempt" "dialogue" diff --git a/mod/assign/tests/behat/filter_by_marker.feature b/mod/assign/tests/behat/filter_by_marker.feature index 3e2017caa1e43..964d29d921a09 100644 --- a/mod/assign/tests/behat/filter_by_marker.feature +++ b/mod/assign/tests/behat/filter_by_marker.feature @@ -32,8 +32,7 @@ Feature: In an assignment, teachers can filter displayed submissions by assigned | markingworkflow | 1 | | markingallocation | 1 | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "allocatedmarker" to "Marker 1" And I set the field "Notify student" to "0" And I press "Save changes" diff --git a/mod/assign/tests/behat/filter_by_marking_workflow.feature b/mod/assign/tests/behat/filter_by_marking_workflow.feature index b1b32d6bf8772..ef909fb90cbc4 100644 --- a/mod/assign/tests/behat/filter_by_marking_workflow.feature +++ b/mod/assign/tests/behat/filter_by_marking_workflow.feature @@ -53,14 +53,12 @@ Feature: In an assignment, teachers can filter displayed submissions by marking And I expand all fieldsets And I set the field "Use marking workflow" to "Yes" And I press "Save and display" - And I navigate to "Submissions" in current page administration And I change window size to "large" # Change the marking workflow state for Student 2 and Student 3. - And I click on "Grade" "link" in the "Student 2" "table_row" + And I go to "Student 2" "Test assignment" activity advanced grading page And I set the field "Marking workflow state" to "In marking" And I press "Save changes" - And I follow "View all submissions" - And I click on "Grade" "link" in the "Student 3" "table_row" + And I go to "Student 3" "Test assignment" activity advanced grading page And I set the field "Marking workflow state" to "Marking completed" And I press "Save changes" And I follow "View all submissions" @@ -129,10 +127,9 @@ Feature: In an assignment, teachers can filter displayed submissions by marking And I expand all fieldsets And I set the field "Use marking workflow" to "Yes" And I press "Save and display" - And I navigate to "Submissions" in current page administration And I change window size to "large" # Change the marking workflow state for Student 2. - And I click on "Grade" "link" in the "Student 2" "table_row" + And I go to "Student 2" "Test assignment" activity advanced grading page And I set the field "Marking workflow state" to "In marking" And I press "Save changes" And I follow "View all submissions" diff --git a/mod/assign/tests/behat/filter_drafts.feature b/mod/assign/tests/behat/filter_drafts.feature index 63cc1e46b1f80..7f52a2e34ca3b 100644 --- a/mod/assign/tests/behat/filter_drafts.feature +++ b/mod/assign/tests/behat/filter_drafts.feature @@ -51,8 +51,7 @@ Feature: In an assignment, teachers can filter displayed submissions and see dra @javascript Scenario: View assignments with draft status in the grader Given I am on the "Test assignment" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment" activity advanced grading page When I click on "[data-region=user-filters]" "css_element" And I set the field "filter" to "Draft" Then I should see "1 of 1" diff --git a/mod/assign/tests/behat/grading_app_filters.feature b/mod/assign/tests/behat/grading_app_filters.feature index 2ae36988c9991..5a56ee1409726 100644 --- a/mod/assign/tests/behat/grading_app_filters.feature +++ b/mod/assign/tests/behat/grading_app_filters.feature @@ -35,8 +35,7 @@ Feature: In an assignment, teachers can change filters in the grading app @javascript Scenario: Set filters in the grading table and see them in the grading app Given I am on the "Test assignment name &" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name &" activity advanced grading page And I should not see "Course 1 &" And the "title" attribute of "a[title='Course: Course 1 &']" "css_element" should not contain "&" And I should not see "Test assignment name &" @@ -53,7 +52,8 @@ Feature: In an assignment, teachers can change filters in the grading app And I set the field "Marking state" in the ".extrafilters .dropdown-menu" "css_element" to "In marking" And I click on "Apply" "button" in the ".extrafilters .dropdown-menu" "css_element" And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" Then the field "filter" matches value "Not submitted" And the field "markerfilter" matches value "Marker 1" @@ -62,16 +62,14 @@ Feature: In an assignment, teachers can change filters in the grading app @javascript Scenario: Set filters in the grading app and see them in the grading table Given I am on the "Test assignment name &" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name &" activity advanced grading page And I set the field "allocatedmarker" to "Marker 1" And I set the field "workflowstate" to "In marking" And I set the field "Notify student" to "0" And I press "Save changes" And I am on the "Test assignment name &" Activity page - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name &" activity advanced grading page And I click on "[data-region=user-filters]" "css_element" And I set the field "filter" to "Not submitted" And I set the field "markerfilter" to "Marker 1" diff --git a/mod/assign/tests/behat/grading_status.feature b/mod/assign/tests/behat/grading_status.feature index 6320dff2bd88a..7f52a37e47892 100644 --- a/mod/assign/tests/behat/grading_status.feature +++ b/mod/assign/tests/behat/grading_status.feature @@ -38,7 +38,8 @@ Feature: View the grading status of an assignment And I am on the "Test assignment name" "assign activity" page logged in as teacher1 And I navigate to "Submissions" in current page administration And I should see "Not marked" in the "Student 1" "table_row" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I should see "1 of 2" And I click on "Change filters" "link" And I set the field "Filter" to "submitted" @@ -62,7 +63,8 @@ Feature: View the grading status of an assignment And I navigate to "Submissions" in current page administration And I should see "In review" in the "Student 1" "table_row" And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I should see "1 of 1" And I set the field "Marking workflow state" to "Released" @@ -80,7 +82,8 @@ Feature: View the grading status of an assignment And I navigate to "Submissions" in current page administration And I should see "Released" in the "Student 1" "table_row" And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I should see "1 of 1" And I set the field "Marking workflow state" to "In marking" @@ -93,7 +96,8 @@ Feature: View the grading status of an assignment And "Student 1" row "Grade" column of "generaltable" table should contain "50.00" And "Student 1" row "Final grade" column of "generaltable" table should contain "-" And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I click on "Change filters" "link" And I set the field "Workflow filter" to "In review" @@ -118,7 +122,8 @@ Feature: View the grading status of an assignment And I am on the "Test assignment name" "assign activity" page logged in as teacher1 And I navigate to "Submissions" in current page administration And I should not see "Graded" in the "Student 1" "table_row" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I should see "1 of 2" And I click on "Change filters" "link" And I set the field "Filter" to "submitted" @@ -149,7 +154,8 @@ Feature: View the grading status of an assignment And I should see "Graded - resubmitted" in the "Student 1" "table_row" And I wait "10" seconds And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I should see "1 of 1" And I set the field "Grade out of 100" to "99.99" diff --git a/mod/assign/tests/behat/group_submission.feature b/mod/assign/tests/behat/group_submission.feature index 4d615dea99bb5..c1f5855af1ea5 100644 --- a/mod/assign/tests/behat/group_submission.feature +++ b/mod/assign/tests/behat/group_submission.feature @@ -205,8 +205,7 @@ Feature: Group assignment submissions | assign | user | onlinetext | | Test assignment name | student1 | I'm the student's first submission | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the following fields to these values: | Grade out of 100 | 50.0 | | Apply grades and feedback to entire group | 1 | diff --git a/mod/assign/tests/behat/hide_grader.feature b/mod/assign/tests/behat/hide_grader.feature index b583964dd8cef..f8df6acfe311e 100644 --- a/mod/assign/tests/behat/hide_grader.feature +++ b/mod/assign/tests/behat/hide_grader.feature @@ -39,7 +39,8 @@ Feature: Hide grader identities identity from students And I navigate to "Submissions" in current page administration And I should not see "Graded" in the "Student 1" "table_row" And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I set the field "Grade out of 100" to "50" And I set the field "Feedback comments" to "Catch for us the foxes." diff --git a/mod/assign/tests/behat/outcome_grading.feature b/mod/assign/tests/behat/outcome_grading.feature index b38977636c07a..32d5c3519a3de 100644 --- a/mod/assign/tests/behat/outcome_grading.feature +++ b/mod/assign/tests/behat/outcome_grading.feature @@ -46,8 +46,7 @@ Feature: Outcome grading | Online text | My online text | And I press "Save changes" When I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 0" "table_row" + And I go to "Student 0" "Test assignment name" activity advanced grading page And I set the following fields to these values: | Outcome Test: | Excellent | And I press "Save changes" @@ -87,8 +86,7 @@ Feature: Outcome grading | Online text | My online text | And I press "Save changes" When I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 0" "table_row" + And I go to "Student 0" "Test assignment name" activity advanced grading page And I set the following fields to these values: | Outcome Test: | Excellent | | Apply grades and feedback to entire group | Yes | @@ -98,7 +96,8 @@ Feature: Outcome grading Then I should see "Outcome Test: Excellent" in the "Student 0" "table_row" And I should see "Outcome Test: Excellent" in the "Student 1" "table_row" And I should not see "Outcome Test: Excellent" in the "Student 2" "table_row" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I set the following fields to these values: | Outcome Test: | Disappointing | | Apply grades and feedback to entire group | No | diff --git a/mod/assign/tests/behat/page_titles.feature b/mod/assign/tests/behat/page_titles.feature index 459fb9aa9cbbc..2edd5a99ad0b3 100644 --- a/mod/assign/tests/behat/page_titles.feature +++ b/mod/assign/tests/behat/page_titles.feature @@ -25,10 +25,11 @@ Feature: In an assignment, page titles are informative And I press "Add submission" And the page title should contain "C1: History of ants - Edit submission" + @javascript Scenario: I view an assignment as a teacher and take an action When I am on the "History of ants" Activity page logged in as teacher1 Then the page title should contain "C1: History of ants" And I navigate to "Submissions" in current page administration And the page title should contain "C1: History of ants - Submissions" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade" "link" in the ".tertiary-navigation" "css_element" And the page title should contain "C1: History of ants - Grading" diff --git a/mod/assign/tests/behat/quickgrading.feature b/mod/assign/tests/behat/quickgrading.feature index 06e76d7c46f40..f6172a73e1360 100644 --- a/mod/assign/tests/behat/quickgrading.feature +++ b/mod/assign/tests/behat/quickgrading.feature @@ -29,9 +29,8 @@ Feature: In an assignment, teachers grade multiple students on one page | Test assignment name | student1 | I'm the student1 submission | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration When I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I change window size to "medium" And I press "Save changes" And I am on the "Test assignment name" "assign activity" page @@ -87,8 +86,7 @@ Feature: In an assignment, teachers grade multiple students on one page | Online text | I'm the student2 submission | And I press "Save changes" And I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the following fields to these values: | Grade out of 100 | 50.0 | | M8d skillZ! | 1337 | diff --git a/mod/assign/tests/behat/rescale_grades.feature b/mod/assign/tests/behat/rescale_grades.feature index 1a7dea077ee9c..7945a542f9ef3 100644 --- a/mod/assign/tests/behat/rescale_grades.feature +++ b/mod/assign/tests/behat/rescale_grades.feature @@ -28,8 +28,7 @@ Feature: Check that the assignment grade can be rescaled when the max grade is c | submissiondrafts | 0 | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the field "Grade out of 100" to "40" And I press "Save changes" And I follow "View all submissions" @@ -71,9 +70,8 @@ Feature: Check that the assignment grade can be rescaled when the max grade is c Then "Student 1" row "Grade" column of "generaltable" table should contain "20.00" Scenario: Rescaling should not produce negative grades - Given I navigate to "Submissions" in current page administration - And I change window size to "large" - And I click on "Grade" "link" in the "Student 2" "table_row" + Given I change window size to "large" + And I go to "Student 2" "Test assignment name" activity advanced grading page And I change window size to "medium" And I wait until the page is ready And I am on the "Test assignment name" "assign activity" page diff --git a/mod/assign/tests/behat/steps_blind_marking.feature b/mod/assign/tests/behat/steps_blind_marking.feature index 9b0daf7d1eccc..8fd2209d96290 100644 --- a/mod/assign/tests/behat/steps_blind_marking.feature +++ b/mod/assign/tests/behat/steps_blind_marking.feature @@ -36,7 +36,8 @@ Feature: Assignments correctly add feedback to the grade report when workflow an And I am on the "Test assignment name" Activity page logged in as teacher1 And I navigate to "Submissions" in current page administration And I should see "Not marked" in the "I'm the student's first submission" "table_row" - And I click on "Grade" "link" in the "I'm the student's first submission" "table_row" + And I click on "Grade actions" "actionmenu" in the "I'm the student's first submission" "table_row" + And I choose "Grade" in the open action menu And I set the field "Grade out of 100" to "50" And I set the field "Marking workflow state" to "In review" And I set the field "Feedback comments" to "Great job! Lol, not really." @@ -47,13 +48,15 @@ Feature: Assignments correctly add feedback to the grade report when workflow an @javascript Scenario: Student identities are revealed after releasing the grades. - When I click on "Grade" "link" in the "I'm the student's first submission" "table_row" + When I click on "Grade actions" "actionmenu" in the "I'm the student's first submission" "table_row" + And I choose "Grade" in the open action menu And I set the field "Marking workflow state" to "Ready for release" And I set the field "Notify student" to "0" And I press "Save changes" And I follow "View all submissions" And I should see "Ready for release" in the "I'm the student's first submission" "table_row" - And I click on "Grade" "link" in the "I'm the student's first submission" "table_row" + And I click on "Grade actions" "actionmenu" in the "I'm the student's first submission" "table_row" + And I choose "Grade" in the open action menu And I set the field "Marking workflow state" to "Released" And I press "Save changes" And I follow "View all submissions" @@ -66,7 +69,8 @@ Feature: Assignments correctly add feedback to the grade report when workflow an @javascript Scenario: Student identities are revealed before releasing the grades. - When I click on "Grade" "link" in the "I'm the student's first submission" "table_row" + When I click on "Grade actions" "actionmenu" in the "I'm the student's first submission" "table_row" + And I choose "Grade" in the open action menu And I set the field "Marking workflow state" to "Ready for release" And I set the field "Notify student" to "0" And I press "Save changes" @@ -75,7 +79,8 @@ Feature: Assignments correctly add feedback to the grade report when workflow an And I choose the "Reveal student identities" item in the "Actions" action menu And I press "Continue" And I change window size to "large" - And I click on "Grade" "link" in the "Student 1" "table_row" + And I click on "Grade actions" "actionmenu" in the "Student 1" "table_row" + And I choose "Grade" in the open action menu And I change window size to "medium" And I set the field "Marking workflow state" to "Released" And I press "Save changes" diff --git a/mod/assign/tests/behat/submission_comments.feature b/mod/assign/tests/behat/submission_comments.feature index f37da51e05220..7009cb43fb398 100644 --- a/mod/assign/tests/behat/submission_comments.feature +++ b/mod/assign/tests/behat/submission_comments.feature @@ -53,8 +53,7 @@ Feature: In an assignment, students can comment in their submissions | assign | user | onlinetext | | Test assignment name | student1 | student one submission | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I click on ".comment-link" "css_element" When I set the field "content" to "Teacher feedback first comment" And I press "Save changes" @@ -71,8 +70,7 @@ Feature: In an assignment, students can comment in their submissions | Test assignment name | student1 | I'm the student submission | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I click on ".comment-link" "css_element" When I set the field "content" to "Teacher feedback first comment" # click the save and show next twice as we have only 2 students @@ -88,8 +86,7 @@ Feature: In an assignment, students can comment in their submissions | activity | course | name | assignsubmission_onlinetext_enabled | assignmentsubmission_file_enabled | assignfeedback_comments_enabled | | assign | C1 | Test assignment name | 0 | 0 | 1 | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page When I set the following fields to these values: | Grade out of 100 | 50 | | Feedback comments | I'm the teacher feedback | @@ -103,8 +100,7 @@ Feature: In an assignment, students can comment in their submissions | activity | course | name | assignsubmission_onlinetext_enabled | assignmentsubmission_file_enabled | assignfeedback_comments_enabled | | assign | C1 | Test assignment name | 0 | 0 | 1 | And I am on the "Test assignment name" Activity page logged in as teacher1 - And I navigate to "Submissions" in current page administration - And I click on "Grade" "link" in the "Student 1" "table_row" + And I go to "Student 1" "Test assignment name" activity advanced grading page And I set the following fields to these values: | Grade out of 100 | 0 | And I press "Save changes" diff --git a/mod/subsection/lib.php b/mod/subsection/lib.php index b3e259bb69aab..0ec8838b56469 100644 --- a/mod/subsection/lib.php +++ b/mod/subsection/lib.php @@ -37,7 +37,7 @@ function subsection_supports($feature) { FEATURE_GROUPS => false, FEATURE_GROUPINGS => false, FEATURE_MOD_INTRO => false, - FEATURE_COMPLETION_TRACKS_VIEWS => true, + FEATURE_COMPLETION_TRACKS_VIEWS => false, FEATURE_GRADE_HAS_GRADE => false, FEATURE_GRADE_OUTCOMES => false, FEATURE_BACKUP_MOODLE2 => true, diff --git a/mod/subsection/mod_form.php b/mod/subsection/mod_form.php index 2cd1927b4bc12..002d56df07656 100644 --- a/mod/subsection/mod_form.php +++ b/mod/subsection/mod_form.php @@ -26,6 +26,8 @@ require_once($CFG->dirroot.'/course/moodleform_mod.php'); +use mod_subsection\manager; + /** * Module instance settings form. * @@ -41,27 +43,37 @@ class mod_subsection_mod_form extends moodleform_mod { public function definition() { global $CFG; - $mform = $this->_form; + // Showing edit form. Redirect to the edit section page. + if (!empty($this->current->instance)) { + $manager = manager::create_from_id($this->current->course, $this->current->id); + $editurl = new moodle_url('/course/editsection.php', ['id' => $manager->get_delegated_section_info()->id]); + redirect($editurl->out()); + } else { + $mform = $this->_form; - // Adding the "general" fieldset, where all the common settings are shown. - $mform->addElement('header', 'general', get_string('general', 'form')); + // Adding the "general" fieldset, where all the common settings are shown. + $mform->addElement('header', 'general', get_string('general', 'form')); - // Adding the standard "name" field. - $mform->addElement('text', 'name', get_string('subsectionname', 'mod_subsection'), ['size' => '64']); + // Adding the standard "name" field. + $mform->addElement('text', 'name', get_string('subsectionname', 'mod_subsection'), ['size' => '64']); - if (!empty($CFG->formatstringstriptags)) { - $mform->setType('name', PARAM_TEXT); - } else { - $mform->setType('name', PARAM_CLEANHTML); - } + if (!empty($CFG->formatstringstriptags)) { + $mform->setType('name', PARAM_TEXT); + } else { + $mform->setType('name', PARAM_CLEANHTML); + } + + $mform->addRule('name', null, 'required', null, 'client'); + $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); - $mform->addRule('name', null, 'required', null, 'client'); - $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client'); + // Add standard elements. + $this->standard_coursemodule_elements(); - // Add standard elements. - $this->standard_coursemodule_elements(); + // Add standard buttons. + $this->add_action_buttons(); - // Add standard buttons. - $this->add_action_buttons(); + // Show only general and restrictions form sections. + $mform->filter_shown_headers(['general', 'availabilityconditionsheader']); + } } } diff --git a/mod/subsection/tests/behat/subsection_visibility.feature b/mod/subsection/tests/behat/subsection_visibility.feature index 5d2c5b7ef2380..f885a123a9e6a 100644 --- a/mod/subsection/tests/behat/subsection_visibility.feature +++ b/mod/subsection/tests/behat/subsection_visibility.feature @@ -29,8 +29,9 @@ Feature: Subsection visibility should work as a module When I hide section "Section 1" And I should see "Hidden from students" in the "Section 1" "section" # We cannot use generators because they don't check the parent section visibility. - And I add a subsection activity to course "Course 1" section "1" and I fill the form with: - | Name | Subsection 1 | + And I add a subsection activity to course "Course 1" section "1" + And I set the field "Name" to "Subsection 1" + And I press "Save and return to course" And I add a assign activity to course "Course 1" section "1" and I fill the form with: | Assignment name | Hidden assignment name | | ID number | assign1 | diff --git a/my/courses.php b/my/courses.php index 60489dc053306..4d41218e00ca5 100644 --- a/my/courses.php +++ b/my/courses.php @@ -71,19 +71,21 @@ // Add course management if the user has the capabilities for it. $coursecat = core_course_category::user_top(); $coursemanagemenu = []; -if ($coursecat && ($category = core_course_category::get_nearest_editable_subcategory($coursecat, ['create']))) { - // The user has the capability to create course. - $coursemanagemenu['newcourseurl'] = new moodle_url('/course/edit.php', ['category' => $category->id]); -} -if ($coursecat && ($category = core_course_category::get_nearest_editable_subcategory($coursecat, ['manage']))) { - // The user has the capability to manage the course category. - $coursemanagemenu['manageurl'] = new moodle_url('/course/management.php', ['categoryid' => $category->id]); -} -if ($coursecat) { - $category = core_course_category::get_nearest_editable_subcategory($coursecat, ['moodle/course:request']); - if ($category && $category->can_request_course()) { - $coursemanagemenu['courserequesturl'] = new moodle_url('/course/request.php', ['categoryid' => $category->id]); - +// Only display the action menu if the user has courses (otherwise, the buttons will be displayed in the zero state). +if (count(enrol_get_all_users_courses($USER->id, true)) > 0) { + if ($coursecat && ($category = core_course_category::get_nearest_editable_subcategory($coursecat, ['create']))) { + // The user has the capability to create course. + $coursemanagemenu['newcourseurl'] = new moodle_url('/course/edit.php', ['category' => $category->id]); + } + if ($coursecat && ($category = core_course_category::get_nearest_editable_subcategory($coursecat, ['manage']))) { + // The user has the capability to manage the course category. + $coursemanagemenu['manageurl'] = new moodle_url('/course/management.php', ['categoryid' => $category->id]); + } + if ($coursecat) { + $category = core_course_category::get_nearest_editable_subcategory($coursecat, ['moodle/course:request']); + if ($category && $category->can_request_course()) { + $coursemanagemenu['courserequesturl'] = new moodle_url('/course/request.php', ['categoryid' => $category->id]); + } } } if (!empty($coursemanagemenu)) { diff --git a/my/templates/dropdown.mustache b/my/templates/dropdown.mustache index 0ef5225855499..a98a81a62dbaa 100644 --- a/my/templates/dropdown.mustache +++ b/my/templates/dropdown.mustache @@ -15,9 +15,9 @@ along with Moodle. If not, see . }} {{! - @template my/dropdown + @template core_my/dropdown - Simple dropdown for the my/courses page + Simple dropdown for the my/courses page. Example context (json): { @@ -27,19 +27,21 @@ } }}
- - - diff --git a/my/tests/behat/my_courses.feature b/my/tests/behat/my_courses.feature index ec20259462ad5..d71ba6d819f7d 100644 --- a/my/tests/behat/my_courses.feature +++ b/my/tests/behat/my_courses.feature @@ -1,5 +1,5 @@ -@core @core_my -Feature: Run tests over my courses. +@core @core_my @block_myoverview +Feature: Run tests over my courses page Background: Given the following "users" exist: @@ -17,25 +17,69 @@ Feature: Run tests over my courses. Scenario: Admin can add new courses or manage them from my courses Given I am on the "My courses" page logged in as "admin" - And I click on "Course management options" "link" - And I click on "New course" "link" - And I wait to be redirected + And "Create course" "button" should not exist in the "page-header" "region" + And "Manage courses" "button" should not exist in the "page-header" "region" + When I click on "Create course" "button" in the "page-content" "region" Then I should see "Add a new course" And I am on the "My courses" page - And I click on "Course management options" "link" - And I click on "Manage courses" "link" + And I click on "Manage course categories" "button" in the "page-content" "region" And I should see "Manage course categories and courses" + # Check that the expected buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | admin | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Create course" "button" should exist in the "page-header" "region" + And "Manage courses" "button" should exist in the "page-header" "region" + And "Create course" "button" should not exist in the "page-content" "region" + And "Manage courses" "button" should not exist in the "page-content" "region" + And "Manage course categories" "button" should not exist in the "page-content" "region" Scenario: User without creating a course and managing category permissions cannot see any link - Given I am on the "My courses" page logged in as "user1" - Then "Course management options" "link" should not exist + When I am on the "My courses" page logged in as "user1" + Then "Create course" "button" should not exist + And "Manage courses" "button" should not exist + And "Manage course categories" "button" should not exist + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | user1 | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Create course" "button" should not exist + And "Manage courses" "button" should not exist + And "Manage course categories" "button" should not exist Scenario: User without capability to browse courses cannot see any link Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/category:viewcourselist | Prevent | user | System | | - Given I am on the "My courses" page logged in as "user1" - Then "Course management options" "link" should not exist + When I am on the "My courses" page logged in as "user1" + Then "Create course" "button" should not exist + And "Manage courses" "button" should not exist + And "Manage course categories" "button" should not exist + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | user1 | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Create course" "button" should not exist + And "Manage courses" "button" should not exist + And "Manage course categories" "button" should not exist @javascript Scenario: User with creating a course permission can see the Create course link only @@ -43,28 +87,48 @@ Feature: Run tests over my courses. | capability | permission | role | contextlevel | reference | | moodle/course:create | Allow | role1 | Category | cata | When I am on the "My courses" page logged in as "user1" - Then "Course management options" "link" should exist - And I click on "Course management options" "link" - And I should see "New course" - And I should not see "Manage courses" - And I click on "New course" "link" - And I wait to be redirected + Then "Create course" "button" should exist in the "page-content" "region" + But "Manage course categories" "button" should not exist + And "Create course" "button" should not exist in the "page-header" "region" + And I click on "Create course" "button" And I should see "Add a new course" And "CatA" "autocomplete_selection" should exist + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | user1 | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Create course" "button" should exist in the "page-header" "region" + And "Manage courses" "button" should not exist + And "Create course" "button" should not exist in the "page-content" "region" - @javascript Scenario: User with managing a category permission can see the Manage course link only Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/category:manage | Allow | role1 | Category | cata | When I am on the "My courses" page logged in as "user1" - Then "Course management options" "link" should exist - And I click on "Course management options" "link" - And I should not see "New course" - And I should see "Manage courses" - And I click on "Manage courses" "link" - And I wait to be redirected + Then "Manage course categories" "button" should exist in the "page-content" "region" + And "Create course" "button" should not exist + And I click on "Manage course categories" "button" in the "page-content" "region" And I should see "Manage course categories and courses" + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | user1 | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Manage courses" "button" should exist in the "page-header" "region" + And "Create course" "button" should not exist + And "Manage courses" "button" should not exist in the "page-content" "region" @javascript Scenario: User with both creating a course and managing a category permission can see both links @@ -73,19 +137,30 @@ Feature: Run tests over my courses. | moodle/course:create | Allow | role1 | Category | cata | | moodle/category:manage | Allow | role1 | Category | cata | When I am on the "My courses" page logged in as "user1" - Then "Course management options" "link" should exist - And I click on "Course management options" "link" - And I should see "New course" - And I should see "Manage courses" - And I click on "New course" "link" - And I wait to be redirected + Then "Create course" "button" should exist in the "page-content" "region" + And "Manage course categories" "button" should exist in the "page-content" "region" + And "Create course" "button" should not exist in the "page-header" "region" + And "Manage courses" "button" should not exist in the "page-header" "region" + And I click on "Create course" "button" And I should see "Add a new course" And "CatA" "autocomplete_selection" should exist And I am on the "My courses" page - And I click on "Course management options" "link" - And I click on "Manage courses" "link" - And I wait to be redirected + And I click on "Manage course categories" "button" And I should see "Manage course categories and courses" + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | user1 | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Create course" "button" should exist in the "page-header" "region" + And "Manage courses" "button" should exist in the "page-header" "region" + And "Create course" "button" should not exist in the "page-content" "region" + And "Manage courses" "button" should not exist in the "page-content" "region" @javascript Scenario: Admin can see relevant blocks but not add or move them @@ -111,30 +186,73 @@ Feature: Run tests over my courses. And "Move Course overview block" "menuitem" should not exist in the "Course overview" "block" And "Actions menu" "icon" in the "Course overview" "block" should not be visible - @javascript Scenario: User with creating a course permission can't see the Request course link Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/course:request | Allow | user | System | | When I am on the "My courses" page logged in as "admin" - And I click on "Course management options" "link" - And I should see "New course" - Then I should not see "Request a course" + Then "Create course" "button" should exist in the "page-content" "region" + And "Request a course" "button" should not exist + And "Create course" "button" should not exist in the "page-header" "region" + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | admin | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Create course" "button" should exist in the "page-header" "region" + And "Request a course" "button" should not exist + And "Create course" "button" should not exist in the "page-content" "region" - @javascript Scenario: User without creating a course but with course request permission could see the Request course link Given the following "permission overrides" exist: | capability | permission | role | contextlevel | reference | | moodle/course:request | Allow | user | System | | When I am on the "My courses" page logged in as "user1" - And I click on "Course management options" "link" - And I should not see "New course" - Then I should see "Request a course" + Then "Request a course" "button" should exist in the "page-content" "region" + And "Create course" "button" should not exist in the "page-content" "region" + And "Create course" "button" should not exist in the "page-header" "region" + And "Request a course" "button" should not exist in the "page-header" "region" + # Check the request a course button is not displayed when this feature is disabled. And the following config values are set as admin: | enablecourserequests | 0 | And I am on the "My courses" page logged in as "user1" - And "Course management options" "link" should not exist + And "Request a course" "button" should not exist + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | user1 | + | course | C1 | + | role | student | + And the following config values are set as admin: + | enablecourserequests | 1 | + And I am on the "My courses" page + And "Request a course" "button" should exist in the "page-header" "region" + And "Create course" "button" should not exist + And "Request a course" "button" should not exist in the "page-content" "region" Scenario: User without creating nor course request permission shouldn't see any Request course link Given I am on the "My courses" page logged in as "user1" - Then "Course management options" "link" should not exist + Then "Request a course" "button" should not exist in the "page-content" "region" + And "Create course" "button" should not exist in the "page-content" "region" + And "Manage courses" "button" should not exist in the "page-content" "region" + # Check that the same buttons are displayed in the header when the user is enrolled in a course. + But the following "course" exists: + | fullname | Course 1 | + | shortname | C1 | + | format | topics | + And the following "course enrolment" exists: + | user | user1 | + | course | C1 | + | role | student | + And I am on the "My courses" page + And "Create course" "button" should not exist + And "Request a course" "button" should not exist + And "Manage courses" "button" should not exist diff --git a/question/format/xml/tests/behat/import_export.feature b/question/format/xml/tests/behat/import_export.feature index c09746929aebd..5a836e4c95436 100644 --- a/question/format/xml/tests/behat/import_export.feature +++ b/question/format/xml/tests/behat/import_export.feature @@ -34,7 +34,7 @@ Feature: Test importing questions from Moodle XML format. And I set the field "id_format_xml" to "1" And I set the field "Export category" to "TrueFalse" And I press "Export questions to file" - Then following "click here" should download between "57100" and "58150" bytes + Then following "click here" should download between "17042" and "18874" bytes @javascript @_file_upload Scenario: import some multiple choice questions from Moodle XML format diff --git a/question/type/ddimageortext/tests/behat/export.feature b/question/type/ddimageortext/tests/behat/export.feature index 06d83e284ea30..eaa227cb7743c 100644 --- a/question/type/ddimageortext/tests/behat/export.feature +++ b/question/type/ddimageortext/tests/behat/export.feature @@ -26,7 +26,7 @@ Feature: Test exporting drag and drop onto image questions When I am on the "Course 1" "core_question > course question export" page logged in as teacher And I set the field "id_format_xml" to "1" And I press "Export questions to file" - Then following "click here" should download between "18600" and "19150" bytes + Then following "click here" should download between "18500" and "24864" bytes # If the download step is the last in the scenario then we can sometimes run # into the situation where the download page causes a http redirect but behat # has already conducted its reset (generating an error). By putting a logout diff --git a/report/usersessions/locallib.php b/report/usersessions/locallib.php index 02f6b5ae313ce..7f29ff65e5df3 100644 --- a/report/usersessions/locallib.php +++ b/report/usersessions/locallib.php @@ -77,15 +77,15 @@ function report_usersessions_format_ip($ip) { * @param int $id * @return void */ -function report_usersessions_kill_session($id) { - global $DB, $USER; +function report_usersessions_kill_session(int $id): void { + global $USER; - $session = $DB->get_record('sessions', array('id' => $id, 'userid' => $USER->id), 'id, sid'); + $sessions = \core\session\manager::get_sessions_by_userid($USER->id); + $filteredsessions = array_filter($sessions, fn ($session) => $session->id === $id); - if (!$session or $session->sid === session_id()) { - // Do not delete the current session! - return; + foreach ($filteredsessions as $session) { + if ($session->sid !== session_id()) { + \core\session\manager::destroy($session->sid); + } } - - \core\session\manager::kill_session($session->sid); } diff --git a/report/usersessions/user.php b/report/usersessions/user.php index e295d9012690c..aa3657f673d25 100644 --- a/report/usersessions/user.php +++ b/report/usersessions/user.php @@ -64,7 +64,7 @@ // Delete all sessions except current. if ($deleteall && confirm_sesskey()) { - \core\session\manager::kill_user_sessions($USER->id, session_id()); + \core\session\manager::destroy_user_sessions($USER->id, session_id()); redirect( url: $PAGE->url, message: get_string('logoutothersessionssuccess', 'report_usersessions'), @@ -82,15 +82,13 @@ echo $OUTPUT->heading(get_string('mysessions', 'report_usersessions')); $data = array(); -$sql = "SELECT id, timecreated, timemodified, firstip, lastip, sid - FROM {sessions} - WHERE userid = :userid - ORDER BY timemodified DESC"; -$params = array('userid' => $USER->id, 'sid' => session_id()); - -$sessions = $DB->get_records_sql($sql, $params); +$sessions = \core\session\manager::get_sessions_by_userid($USER->id); +// Order records by timemodified DESC. +usort($sessions, function($a, $b){ + return $b->timemodified <=> $a->timemodified; +}); foreach ($sessions as $session) { - if ($session->sid === $params['sid']) { + if ($session->sid === session_id()) { $lastaccess = get_string('thissession', 'report_usersessions'); $deletelink = ''; diff --git a/reportbuilder/classes/local/report/base.php b/reportbuilder/classes/local/report/base.php index c54145b149d06..4b9c9bf4aa286 100644 --- a/reportbuilder/classes/local/report/base.php +++ b/reportbuilder/classes/local/report/base.php @@ -855,8 +855,9 @@ public function set_default_per_page(int $defaultperpage): void { /** * Set the default lang string for the notice used when no results are found. * + * Note this should be called from within the report class instance itself (ideally it would be protected) + * * @param lang_string|null $notice string, or null to tell the report to omit the notice entirely. - * @return void */ public function set_default_no_results_notice(?lang_string $notice): void { $this->noresultsnotice = $notice; diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 50b53f82fd2f0..e3ec2aa4f91e7 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -2939,6 +2939,13 @@ body.dragging { } } +#page-my-index .my-action-buttons { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +} + .dropdown-toggle::after { @extend .fa-solid; content: fa-content($fa-var-chevron-down); diff --git a/theme/boost/scss/moodle/course.scss b/theme/boost/scss/moodle/course.scss index 54d8661bdc90f..61a38503ab713 100644 --- a/theme/boost/scss/moodle/course.scss +++ b/theme/boost/scss/moodle/course.scss @@ -1666,8 +1666,11 @@ $divider-hover-color: $primary !default; .bulk-hidden { display: none !important; // stylelint-disable-line declaration-no-important } - .section:not(:first-child) { - margin-top: map-get($spacers, 4); + .section { + margin-left: map-get($spacers, 3); + &:not(:first-child) { + margin-top: map-get($spacers, 4); + } } .activity { margin-top: map-get($spacers, 2); diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 99f6d7ea541f4..4ca9d8a606a7f 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -25917,6 +25917,13 @@ body.dragging .dragging { padding-right: 0 !important; /* stylelint-disable-line declaration-no-important */ } +#page-my-index .my-action-buttons { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +} + .dropdown-toggle::after { content: "\f078"; margin-right: 0; @@ -29865,6 +29872,9 @@ span.editinstructions .alert-link { .bulkenabled .bulk-hidden { display: none !important; } +.bulkenabled .section { + margin-left: 1rem; +} .bulkenabled .section:not(:first-child) { margin-top: 1.5rem; } diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index 5c50e70b1f59d..8091f1f81f0f1 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -25917,6 +25917,13 @@ body.dragging .dragging { padding-right: 0 !important; /* stylelint-disable-line declaration-no-important */ } +#page-my-index .my-action-buttons { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +} + .dropdown-toggle::after { content: "\f078"; margin-right: 0; @@ -29865,6 +29872,9 @@ span.editinstructions .alert-link { .bulkenabled .bulk-hidden { display: none !important; } +.bulkenabled .section { + margin-left: 1rem; +} .bulkenabled .section:not(:first-child) { margin-top: 1.5rem; } diff --git a/user/classes/privacy/provider.php b/user/classes/privacy/provider.php index d3e0feb43ca28..705392115bbb3 100644 --- a/user/classes/privacy/provider.php +++ b/user/classes/privacy/provider.php @@ -294,7 +294,10 @@ protected static function delete_user_data(int $userid, \context $context) { // Delete user course requests. $DB->delete_records('course_request', ['requester' => $userid]); // Delete sessions. - $DB->delete_records('sessions', ['userid' => $userid]); + $sessions = \core\session\manager::get_sessions_by_userid($userid); + foreach ($sessions as $session) { + \core\session\manager::destroy($session->sid); + } // Do I delete user preferences? Seems like the right place to do it. $DB->delete_records('user_preferences', ['userid' => $userid]); @@ -528,7 +531,7 @@ protected static function export_password_history(int $userid, \context $context protected static function export_user_session_data(int $userid, \context $context) { global $DB, $SESSION; - $records = $DB->get_records('sessions', ['userid' => $userid]); + $records = \core\session\manager::get_sessions_by_userid($userid); if (!empty($records)) { $sessiondata = (object) array_map(function($record) { return [ diff --git a/user/editadvanced.php b/user/editadvanced.php index 3d8795ebfef05..b8065ae7fa6c0 100644 --- a/user/editadvanced.php +++ b/user/editadvanced.php @@ -233,7 +233,7 @@ if (!empty($CFG->passwordchangelogout)) { // We can use SID of other user safely here because they are unique, // the problem here is we do not want to logout admin here when changing own password. - \core\session\manager::kill_user_sessions($usernew->id, session_id()); + \core\session\manager::destroy_user_sessions($usernew->id, session_id()); } if (!empty($usernew->signoutofotherservices)) { webservice::delete_user_ws_tokens($usernew->id); @@ -243,7 +243,7 @@ // Force logout if user just suspended. if (isset($usernew->suspended) and $usernew->suspended and !$user->suspended) { - \core\session\manager::kill_user_sessions($user->id); + \core\session\manager::destroy_user_sessions($user->id); } } diff --git a/user/externallib.php b/user/externallib.php index c0add4cb15d1d..6713b6324ee34 100644 --- a/user/externallib.php +++ b/user/externallib.php @@ -659,7 +659,7 @@ public static function update_users($users) { useredit_update_user_preference($userpref); } if (isset($user['suspended']) and $user['suspended']) { - \core\session\manager::kill_user_sessions($user['id']); + \core\session\manager::destroy_user_sessions($user['id']); } $transaction->allow_commit(); diff --git a/user/tests/privacy/provider_test.php b/user/tests/privacy/provider_test.php index 21b5d2564ab4a..a7be316486b25 100644 --- a/user/tests/privacy/provider_test.php +++ b/user/tests/privacy/provider_test.php @@ -13,33 +13,30 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Privacy tests for core_user. - * - * @package core_user - * @category test - * @copyright 2018 Adrian Greeve - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -namespace core_user\privacy; - -defined('MOODLE_INTERNAL') || die(); -global $CFG; -use \core_privacy\tests\provider_testcase; -use \core_user\privacy\provider; -use \core_privacy\local\request\approved_userlist; -use \core_privacy\local\request\transform; +namespace core_user\privacy; -require_once($CFG->dirroot . "/user/lib.php"); +use core\tests\session\mock_handler; +use core_privacy\tests\provider_testcase; +use core_privacy\local\request\approved_userlist; +use core_privacy\local\request\transform; +use core_user\privacy\provider; /** * Unit tests for core_user. * + * @package core_user * @copyright 2018 Adrian Greeve * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_user\privacy\provider */ -class provider_test extends provider_testcase { +final class provider_test extends provider_testcase { + public static function setUpBeforeClass(): void { + global $CFG; + parent::setUpBeforeClass(); + + require_once($CFG->dirroot . "/user/lib.php"); + } /** * Check that context information is returned correctly. @@ -475,7 +472,8 @@ protected function create_data_for_user($user, $course) { 'firstip' => '0.0.0.0', 'lastip' => '0.0.0.0' ]; - $DB->insert_record('sessions', $usersessions); + $mockhandler = new mock_handler(); + $mockhandler->add_test_session($usersessions); } /** diff --git a/version.php b/version.php index 9c33ae2a4bed8..a961a3c74e733 100644 --- a/version.php +++ b/version.php @@ -29,9 +29,9 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2024082900.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2024090300.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '4.5dev+ (Build: 20240829)'; // Human-friendly version name +$release = '4.5dev+ (Build: 20240903)'; // Human-friendly version name $branch = '405'; // This version's branch. $maturity = MATURITY_ALPHA; // This version's maturity level. diff --git a/webservice/externallib.php b/webservice/externallib.php index cd76c808b2257..b9389ff58b88b 100644 --- a/webservice/externallib.php +++ b/webservice/externallib.php @@ -213,7 +213,7 @@ public static function get_site_info($serviceshortnames = array()) { $siteinfo['limitconcurrentlogins'] = (int) $CFG->limitconcurrentlogins; if (!empty($CFG->limitconcurrentlogins)) { // For performance, only when enabled. - $siteinfo['usersessionscount'] = $DB->count_records('sessions', ['userid' => $USER->id]); + $siteinfo['usersessionscount'] = count(\core\session\manager::get_sessions_by_userid($USER->id)); } $siteinfo['policyagreed'] = $USER->policyagreed; diff --git a/webservice/tests/externallib_test.php b/webservice/tests/externallib_test.php index 9dbce930e0be6..3b41439d389ec 100644 --- a/webservice/tests/externallib_test.php +++ b/webservice/tests/externallib_test.php @@ -18,6 +18,7 @@ use core_external\external_api; use externallib_advanced_testcase; +use core\tests\session\mock_handler; defined('MOODLE_INTERNAL') || die(); @@ -33,8 +34,8 @@ * @copyright 2012 Paul Charsley * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class externallib_test extends externallib_advanced_testcase { - +final class externallib_test extends externallib_advanced_testcase { + #[\Override] public function setUp(): void { // Calling parent is good, always parent::setUp(); @@ -190,7 +191,9 @@ public function test_get_site_info(): void { $record->firstip = $record->lastip = '10.0.0.1'; $record->sid = md5('hokus1'); $record->timecreated = time(); - $DB->insert_record('sessions', $record); + + $mockhandler = new mock_handler(); + $mockhandler->add_test_session($record); $siteinfo = \core_webservice_external::get_site_info(); $siteinfo = external_api::clean_returnvalue(\core_webservice_external::get_site_info_returns(), $siteinfo);