Merge branch 'MDL-63902-master' of git://github.com/andrewnicols/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 8 Nov 2018 05:39:05 +0000 (13:39 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 8 Nov 2018 05:39:05 +0000 (13:39 +0800)
50 files changed:
admin/tool/mobile/classes/api.php
admin/tool/mobile/tests/externallib_test.php
auth/classes/output/login.php
auth/ldap/auth.php
auth/shibboleth/index.php
auth/shibboleth/index_form.html
auth/shibboleth/login.php
auth/upgrade.txt
blocks/login/block_login.php
blocks/starredcourses/db/services.php
config-dist.php
lib/classes/session/manager.php
lib/db/services.php
lib/lessphp/Visitor/toCSS.php
lib/lessphp/moodle_readme.txt
lib/moodlelib.php
lib/templates/loginform.mustache
lib/testing/generator/data_generator.php
lib/tests/authlib_test.php
login/change_password_form.php
login/index.php
login/token.php
message/classes/api.php
message/classes/helper.php
message/externallib.php
message/output/popup/amd/build/notification_popover_controller.min.js
message/output/popup/amd/src/notification_popover_controller.js
message/output/popup/tests/behat/message_popover_preferences.feature
message/output/popup/tests/behat/message_popover_unread.feature
message/output/popup/tests/behat/notification_popover_preferences.feature
message/output/popup/tests/behat/notification_popover_unread.feature
message/tests/api_test.php
message/tests/behat/delete_all_messages.feature
message/tests/behat/delete_messages.feature
message/tests/behat/manage_contacts.feature
message/tests/behat/reply_message.feature
message/tests/behat/search_messages.feature
message/tests/behat/search_users.feature
message/tests/behat/update_messaging_preferences.feature
message/tests/behat/view_messages.feature
message/tests/externallib_test.php
mod/assign/renderer.php
question/type/ddmarker/styles.css
search/engine/solr/classes/engine.php
theme/boost/scss/moodle/blocks.scss
theme/boost/style/moodle.css
theme/boost/templates/core/loginform.mustache
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/style/moodle.css
webservice/externallib.php

index 5fcd7f7..71cf2bb 100644 (file)
@@ -336,6 +336,7 @@ class api {
         $mainmenu = new lang_string('mainmenu', 'tool_mobile');
         $course = new lang_string('course');
         $modules = new lang_string('managemodules');
+        $blocks = new lang_string('blocks');
         $user = new lang_string('user');
         $files = new lang_string('files');
         $remoteaddons = new lang_string('remoteaddons', 'tool_mobile');
@@ -359,6 +360,25 @@ class api {
 
         }
 
+        // Display blocks.
+        $availableblocks = core_plugin_manager::instance()->get_plugins_of_type('block');
+        $courseblocks = array();
+        $appsupportedblocks = array(
+            'activity_modules' => 'CoreBlockDelegate_AddonBlockActivityModules',
+            'site_main_menu' => 'CoreBlockDelegate_AddonBlockSiteMainMenu',
+            'myoverview' => 'CoreBlockDelegate_AddonBlockMyOverview',
+            'timeline' => 'CoreBlockDelegate_AddonBlockTimeline',
+            'recentlyaccessedcourses' => 'CoreBlockDelegate_AddonBlockRecentlyAccessedCourses',
+            'starredcourses' => 'CoreBlockDelegate_AddonBlockStarredCourses',
+            'recentlyaccesseditems' => 'CoreBlockDelegate_AddonBlockRecentlyAccessedItems',
+        );
+
+        foreach ($availableblocks as $block) {
+            if (isset($appsupportedblocks[$block->name])) {
+                $courseblocks[$appsupportedblocks[$block->name]] = $block->displayname;
+            }
+        }
+
         $features = array(
             'NoDelegate_CoreOffline' => new lang_string('offlineuse', 'tool_mobile'),
             '$mmLoginEmailSignup' => new lang_string('startsignup'),
@@ -401,6 +421,7 @@ class api {
                 'files_upload' => new lang_string('upload'),
             ),
             "$modules" => $coursemodules,
+            "$blocks" => $courseblocks,
         );
 
         if (!empty($remoteaddonslist)) {
index d5a8a34..00db97e 100644 (file)
@@ -109,6 +109,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         set_config('agedigitalconsentverification', 1);
         set_config('autolang', 1);
         set_config('lang', 'a_b');  // Set invalid lang.
+        set_config('disabledfeatures', 'myoverview', 'tool_mobile');
 
         list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id);
         $expected['registerauth'] = 'email';
@@ -120,6 +121,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         $expected['supportemail'] = $CFG->supportemail;
         $expected['autolang'] = '1';
         $expected['lang'] = ''; // Expect empty because it was set to an invalid lang.
+        $expected['tool_mobile_disabledfeatures'] = 'myoverview';
 
         if ($logourl = $OUTPUT->get_logo_url()) {
             $expected['logourl'] = $logourl->out(false);
index 584abb8..7034e97 100644 (file)
@@ -70,6 +70,8 @@ class login implements renderable, templatable {
     public $signupurl;
     /** @var string The user name to pre-fill the form with. */
     public $username;
+    /** @var string The csrf token to limit login to requests that come from the login form. */
+    public $logintoken;
 
     /**
      * Constructor.
@@ -109,6 +111,7 @@ class login implements renderable, templatable {
 
         // Identity providers.
         $this->identityproviders = \auth_plugin_base::get_identity_providers($authsequence);
+        $this->logintoken = \core\session\manager::get_login_token();
     }
 
     /**
@@ -141,6 +144,7 @@ class login implements renderable, templatable {
         $data->rememberusername = $this->rememberusername;
         $data->signupurl = $this->signupurl->out(false);
         $data->username = $this->username;
+        $data->logintoken = $this->logintoken;
 
         return $data;
     }
index 4c0ca9e..cd9e347 100644 (file)
@@ -1725,7 +1725,8 @@ class auth_plugin_ldap extends auth_plugin_base {
 
         // Here we want to trigger the whole authentication machinery
         // to make sure no step is bypassed...
-        $user = authenticate_user_login($username, $key);
+        $reason = null;
+        $user = authenticate_user_login($username, $key, false, $reason, false);
         if ($user) {
             complete_user_login($user);
 
index 07b5ce7..b177f7c 100644 (file)
         $frm->password = generate_password(8);
 
     /// Check if the user has actually submitted login data to us
+        $reason = null;
 
         if ($shibbolethauth->user_login($frm->username, $frm->password)
-                && $user = authenticate_user_login($frm->username, $frm->password)) {
+                && $user = authenticate_user_login($frm->username, $frm->password, false, $reason, false)) {
             complete_user_login($user);
 
             if (user_not_fully_set_up($USER, true)) {
index 144c1fd..9f1e23e 100644 (file)
@@ -54,6 +54,7 @@ if ($show_instructions) {
         </div>
         <form action="../../login/index.php" method="post" id="guestlogin">
           <div class="guestform">
+            <input type="hidden" name="logintoken" value="<?php echo s(\core\session\manager::get_login_token()); ?>" />
             <input type="hidden" name="username" value="guest" />
             <input type="hidden" name="password" value="guest" />
             <input type="submit" value="<?php print_string("loginguest") ?>" />
index 2ed931a..6877fcb 100644 (file)
     $PAGE->set_heading($site->fullname);
 
     echo $OUTPUT->header();
-    include("index_form.html");
+
+    if (isloggedin() and !isguestuser()) {
+        // Prevent logging when already logged in, we do not want them to relogin by accident because sesskey would be changed.
+        echo $OUTPUT->box_start();
+        $params = array('sesskey' => sesskey(), 'loginpage' => 1);
+        $logout = new single_button(new moodle_url('/login/logout.php', $params), get_string('logout'), 'post');
+        $continue = new single_button(new moodle_url('/'), get_string('cancel'), 'get');
+        echo $OUTPUT->confirm(get_string('alreadyloggedin', 'error', fullname($USER)), $logout, $continue);
+        echo $OUTPUT->box_end();
+    } else {
+        include("index_form.html");
+    }
+
     echo $OUTPUT->footer();
 
 
index 138cbbc..4e0502b 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in /auth/* - plugins,
 information provided here is intended especially for developers.
 
+=== 3.6 ===
+
+* Login forms generated from Moodle must include a login token to protect automated logins. See \core\session\manager::get_login_token().
+
 === 3.5 ===
 
 * The auth_db and auth_ldap plugins' implementations of update_user_record() have been removed and both now
index e17aea1..67d0d03 100644 (file)
@@ -86,6 +86,7 @@ class block_login extends block_base {
             $this->content->text .= '<div class="form-group">';
             $this->content->text .= '<input type="submit" class="btn btn-primary btn-block" value="'.get_string('login').'" />';
             $this->content->text .= '</div>';
+            $this->content->text .= '<input type="hidden" name="logintoken" value="'.s(\core\session\manager::get_login_token()).'" />';
 
             $this->content->text .= "</form>\n";
 
index fc3ebb9..cd5edb4 100644 (file)
@@ -33,6 +33,7 @@ $functions = array(
         'description' => 'Get users starred courses.',
         'type'        => 'read',
         'ajax'        => true,
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
 );
 
index d48ecab..18cbe31 100644 (file)
@@ -594,6 +594,11 @@ $CFG->admin = 'admin';
 //
 //      $CFG->keepmessagingallusersenabled = true;
 //
+// Disable login token validation for login pages. Login token validation is enabled
+// by default unless $CFG->alternateloginurl is set.
+//
+//      $CFG->disablelogintoken = true;
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index 6568e4b..a84424c 100644 (file)
@@ -46,6 +46,9 @@ class manager {
     /** @var bool $sessionactive Is the session active? */
     protected static $sessionactive = null;
 
+    /** @var string $logintokenkey Key used to get and store request protection for login form. */
+    protected static $logintokenkey = 'core_auth_login';
+
     /**
      * Start user session.
      *
@@ -923,4 +926,102 @@ class manager {
         )));
     }
 
+    /**
+     * Generate a new login token and store it in the session.
+     *
+     * @return array The current login state.
+     */
+    private static function create_login_token() {
+        global $SESSION;
+
+        $state = [
+            'token' => random_string(32),
+            'created' => time() // Server time - not user time.
+        ];
+
+        if (!isset($SESSION->logintoken)) {
+            $SESSION->logintoken = [];
+        }
+
+        // Overwrite any previous values.
+        $SESSION->logintoken[self::$logintokenkey] = $state;
+
+        return $state;
+    }
+
+    /**
+     * Get the current login token or generate a new one.
+     *
+     * All login forms generated from Moodle must include a login token
+     * named "logintoken" with the value being the result of this function.
+     * Logins will be rejected if they do not include this token as well as
+     * the username and password fields.
+     *
+     * @return string The current login token.
+     */
+    public static function get_login_token() {
+        global $CFG, $SESSION;
+
+        $state = false;
+
+        if (!isset($SESSION->logintoken)) {
+            $SESSION->logintoken = [];
+        }
+
+        if (array_key_exists(self::$logintokenkey, $SESSION->logintoken)) {
+            $state = $SESSION->logintoken[self::$logintokenkey];
+        }
+        if (empty($state)) {
+            $state = self::create_login_token();
+        }
+
+        // Check token lifespan.
+        if ($state['created'] < (time() - $CFG->sessiontimeout)) {
+            $state = self::create_login_token();
+        }
+
+        // Return the current session login token.
+        if (array_key_exists('token', $state)) {
+            return $state['token'];
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Check the submitted value against the stored login token.
+     *
+     * @param mixed $token The value submitted in the login form that we are validating.
+     *                     If false is passed for the token, this function will always return true.
+     * @return boolean If the submitted token is valid.
+     */
+    public static function validate_login_token($token = false) {
+        global $CFG;
+
+        if (!empty($CFG->alternateloginurl) || !empty($CFG->disablelogintoken)) {
+            // An external login page cannot generate the login token we need to protect CSRF on
+            // login requests.
+            // Other custom login workflows may skip this check by setting disablelogintoken in config.
+            return true;
+        }
+        if ($token === false) {
+            // authenticate_user_login is a core function was extended to validate tokens.
+            // For existing uses other than the login form it does not
+            // validate that a token was generated.
+            // Some uses that do not validate the token are login/token.php,
+            // or an auth plugin like auth/ldap/auth.php.
+            return true;
+        }
+
+        $currenttoken = self::get_login_token();
+
+        // We need to clean the login token so the old one is not valid again.
+        self::create_login_token();
+
+        if ($currenttoken !== $token) {
+            // Fail the login.
+            return false;
+        }
+        return true;
+    }
 }
index 53cc5eb..43f6fc2 100644 (file)
@@ -542,7 +542,8 @@ $functions = array(
         'classpath' => 'course/externallib.php',
         'description' => 'List of enrolled courses for the given timeline classification (past, inprogress, or future).',
         'type' => 'read',
-        'ajax' => true
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_course_get_recent_courses' => array(
         'classname' => 'core_course_external',
@@ -550,7 +551,17 @@ $functions = array(
         'classpath' => 'course/externallib.php',
         'description' => 'List of courses a user has accessed most recently.',
         'type' => 'read',
-        'ajax' => true
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_course_set_favourite_courses' => array(
+        'classname' => 'core_course_external',
+        'methodname' => 'set_favourite_courses',
+        'classpath' => 'course/externallib.php',
+        'description' => 'Add a list of courses to the list of favourite courses.',
+        'type' => 'read',
+        'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_enrol_get_course_enrolment_methods' => array(
         'classname' => 'core_enrol_external',
@@ -2319,14 +2330,6 @@ $functions = array(
         'type'        => 'read',
         'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
-    'core_course_set_favourite_courses' => array(
-        'classname' => 'core_course_external',
-        'methodname' => 'set_favourite_courses',
-        'classpath' => 'course/externallib.php',
-        'description' => 'Add a list of courses to the list of favourite courses.',
-        'type' => 'read',
-        'ajax' => true
-    )
 );
 
 $services = array(
index 18d328f..8aaca96 100644 (file)
@@ -110,7 +110,7 @@ class Less_Visitor_toCSS extends Less_VisitorReplacing{
 
 
                // Compile rules and rulesets
-               $nodeRuleCnt = count($rulesetNode->rules);
+               $nodeRuleCnt = $rulesetNode->rules?count($rulesetNode->rules):0;
                for( $i = 0; $i < $nodeRuleCnt; ){
                        $rule = $rulesetNode->rules[$i];
 
index fcfaf4b..2b9274d 100644 (file)
@@ -8,9 +8,13 @@ All the files from the folder lib/Less are copied in
 this directory. Only exception made for the directory
 '.easymin' which is not included.
 
-Verify that https://github.com/oyejorge/less.php/pull/367 has been applied to the imported version or apply it locally.
-
 Also copy the license file from the project root.
 
 Licensed under the Apache license 2.0.
 
+Modifications:
+* MDL-63422 - Verify that https://github.com/oyejorge/less.php/pull/367 has been applied to the
+    imported version or apply it locally. PHP 7.3 compatibility.
+* MDL-62294 - Cherry-picked upstream commit to fix PHP 7.2 compatibility when counting ruleset rules.
+    https://github.com/oyejorge/less.php/commit/669acc51817a8da162b5f1b7137e79f0e4acc636
+    TODO: Remove this note when this library gets upgraded to the latest release that already includes this fix.
index 6c760dd..faa4580 100644 (file)
@@ -2548,6 +2548,9 @@ function dayofweek($day, $month, $year) {
 /**
  * Returns full login url.
  *
+ * Any form submissions for authentication to this URL must include username,
+ * password as well as a logintoken generated by \core\session\manager::get_login_token().
+ *
  * @return string login url
  */
 function get_login_url() {
@@ -4286,9 +4289,10 @@ function guest_user() {
  * @param string $password  User's password
  * @param bool $ignorelockout useful when guessing is prevented by other mechanism such as captcha or SSO
  * @param int $failurereason login failure reason, can be used in renderers (it may disclose if account exists)
+ * @param mixed logintoken If this is set to a string it is validated against the login token for the session.
  * @return stdClass|false A {@link $USER} object or false if error
  */
-function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null) {
+function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null, $logintoken=false) {
     global $CFG, $DB;
     require_once("$CFG->libdir/authlib.php");
 
@@ -4310,6 +4314,18 @@ function authenticate_user_login($username, $password, $ignorelockout=false, &$f
         }
     }
 
+    // Make sure this request came from the login form.
+    if (!\core\session\manager::validate_login_token($logintoken)) {
+        $failurereason = AUTH_LOGIN_FAILED;
+
+        // Trigger login failed event.
+        $event = \core\event\user_login_failed::create(array('userid' => $user->id,
+                'other' => array('username' => $username, 'reason' => $failurereason)));
+        $event->trigger();
+        error_log('[client '.getremoteaddr()."]  $CFG->wwwroot  Invalid Login Token:  $username  ".$_SERVER['HTTP_USER_AGENT']);
+        return false;
+    }
+
     $authsenabled = get_enabled_auth_plugins();
 
     if ($user) {
index 616e860..7aa51a6 100644 (file)
@@ -35,7 +35,8 @@
         "rememberusername": true,
         "signupurl": "http://localhost/stable_master/login/signup.php",
         "cookieshelpiconformatted": "",
-        "username": ""
+        "username": "",
+        "logintoken": "randomstring"
     }
 }}
 {{#hasinstructions}}
@@ -95,6 +96,7 @@
                 <div class="clearer"><!-- --></div>
                 <input id="anchor" type="hidden" name="anchor" value="" />
                 <script>document.getElementById('anchor').value = location.hash;</script>
+                <input type="hidden" name="logintoken" value="{{logintoken}}">
                 <input type="submit" id="loginbtn" value={{#quote}}{{#str}} login {{/str}}{{/quote}} />
                 <div class="forgetpass">
                     <a href="{{forgotpasswordurl}}">{{#str}} forgotten {{/str}}</a>
                 <div class="desc">{{#str}} someallowguest {{/str}}</div>
                 <form action="{{loginurl}}" method="post" id="guestlogin">
                     <div class="guestform">
+                        <input type="hidden" name="logintoken" value="{{logintoken}}">
                         <input type="hidden" name="username" value="guest" />
                         <input type="hidden" name="password" value="guest" />
                         <input type="submit" value={{#quote}}{{#str}} loginguest {{/str}}{{/quote}} />
index 23b5042..961d669 100644 (file)
@@ -542,6 +542,18 @@ EOD;
 
         $id = groups_create_group((object)$record);
 
+        // Allow tests to set group pictures.
+        if (!empty($record['picturepath'])) {
+            require_once($CFG->dirroot . '/lib/gdlib.php');
+            $grouppicture = process_new_icon(\context_course::instance($record['courseid']), 'group', 'icon', $id,
+                $record['picturepath']);
+
+            $DB->set_field('groups', 'picture', $grouppicture, ['id' => $id]);
+
+            // Invalidate the group data as we've updated the group record.
+            cache_helper::invalidate_by_definition('core', 'groupdata', array(), [$record['courseid']]);
+        }
+
         return $DB->get_record('groups', array('id'=>$id));
     }
 
index 53ad307..e59e383 100644 (file)
@@ -200,6 +200,59 @@ class core_authlib_testcase extends advanced_testcase {
         $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED);
         $this->assertEventContextNotUsed($event);
 
+        // Capture failed login token.
+        unset($CFG->alternateloginurl);
+        unset($CFG->disablelogintoken);
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
+        $events = $sink->get_events();
+        $sink->close();
+        $event = array_pop($events);
+
+        $this->assertFalse($result);
+        $this->assertEquals(AUTH_LOGIN_FAILED, $reason);
+        // Test Event.
+        $this->assertInstanceOf('\core\event\user_login_failed', $event);
+        $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username1');
+        $this->assertEventLegacyLogData($expectedlogdata, $event);
+        $eventdata = $event->get_data();
+        $this->assertSame($eventdata['other']['username'], 'username1');
+        $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED);
+        $this->assertEventContextNotUsed($event);
+
+        // Login should work with invalid token if CFG login token settings override it.
+        $CFG->alternateloginurl = 'http://localhost/';
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertEmpty($events);
+        $this->assertInstanceOf('stdClass', $result);
+        $this->assertEquals(AUTH_LOGIN_OK, $reason);
+
+        unset($CFG->alternateloginurl);
+        $CFG->disablelogintoken = true;
+
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken');
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertEmpty($events);
+        $this->assertInstanceOf('stdClass', $result);
+        $this->assertEquals(AUTH_LOGIN_OK, $reason);
+
+        unset($CFG->disablelogintoken);
+        // Normal login with valid token.
+        $reason = null;
+        $token = \core\session\manager::get_login_token();
+        $sink = $this->redirectEvents();
+        $result = authenticate_user_login('username1', 'password1', false, $reason, $token);
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertEmpty($events);
+        $this->assertInstanceOf('stdClass', $result);
+        $this->assertEquals(AUTH_LOGIN_OK, $reason);
+
         $reason = null;
         // Capture failed login event.
         $sink = $this->redirectEvents();
index 66654f3..5064690 100644 (file)
@@ -85,9 +85,10 @@ class login_change_password_form extends moodleform {
     function validation($data, $files) {
         global $USER;
         $errors = parent::validation($data, $files);
+        $reason = null;
 
         // ignore submitted username
-        if (!$user = authenticate_user_login($USER->username, $data['password'], true)) {
+        if (!$user = authenticate_user_login($USER->username, $data['password'], true, $reason, false)) {
             $errors['password'] = get_string('invalidlogin');
             return $errors;
         }
index 923cb43..d62d7e1 100644 (file)
@@ -31,6 +31,8 @@ redirect_if_major_upgrade_required();
 
 $testsession = optional_param('testsession', 0, PARAM_INT); // test session works properly
 $anchor      = optional_param('anchor', '', PARAM_RAW);      // Used to restore hash anchor to wantsurl.
+$logintoken  = optional_param('logintoken', '', PARAM_RAW);       // Used to validate the request.
+
 $resendconfirmemail = optional_param('resendconfirmemail', false, PARAM_BOOL);
 
 $context = context_system::instance();
@@ -138,7 +140,7 @@ if ($frm and isset($frm->username)) {                             // Login WITH
         $frm = false;
     } else {
         if (empty($errormsg)) {
-            $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode);
+            $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode, $logintoken);
         }
     }
 
index a67b4b1..bf6fec5 100644 (file)
@@ -47,7 +47,8 @@ if (is_restored_user($username)) {
 
 $systemcontext = context_system::instance();
 
-$user = authenticate_user_login($username, $password);
+$reason = null;
+$user = authenticate_user_login($username, $password, false, $reason, false);
 if (!empty($user)) {
 
     // Cannot authenticate unless maintenance access is granted.
index b732e03..7556365 100644 (file)
@@ -276,14 +276,17 @@ class api {
     }
 
     /**
-     * Gets the subnames for any conversations linked to components.
+     * Gets extra fields, like image url and subname for any conversations linked to components.
      *
      * The subname is like a subtitle for the conversation, to compliment it's name.
+     * The imageurl is the location of the image for the conversation, as might be seen on a listing of conversations for a user.
      *
      * @param array $conversations a list of conversations records.
      * @return array the array of subnames, index by conversation id.
+     * @throws \coding_exception
+     * @throws \dml_exception
      */
-    protected static function get_linked_conversation_subnames(array $conversations) {
+    protected static function get_linked_conversation_extra_fields(array $conversations) : array {
         global $DB;
 
         $linkedconversations = [];
@@ -299,11 +302,11 @@ class api {
 
         // TODO: MDL-63814: Working out the subname for linked conversations should be done in a generic way.
         // Get the itemid, but only for course group linked conversation for now.
-        $convsubnames = [];
+        $extrafields = [];
         if (!empty($linkeditems = $linkedconversations['core_group']['groups'])) { // Format: [conversationid => itemid].
             // Get the name of the course to which the group belongs.
             list ($groupidsql, $groupidparams) = $DB->get_in_or_equal(array_values($linkeditems), SQL_PARAMS_NAMED, 'groupid');
-            $sql = "SELECT g.id, c.shortname
+            $sql = "SELECT g.*, c.shortname as courseshortname
                       FROM {groups} g
                       JOIN {course} c
                         ON g.courseid = c.id
@@ -311,11 +314,16 @@ class api {
             $courseinfo = $DB->get_records_sql($sql, $groupidparams);
             foreach ($linkeditems as $convid => $groupid) {
                 if (array_key_exists($groupid, $courseinfo)) {
-                    $convsubnames[$convid] = format_string($courseinfo[$groupid]->shortname);
+                    $group = $courseinfo[$groupid];
+                    // Subname.
+                    $extrafields[$convid]['subname'] = format_string($courseinfo[$groupid]->courseshortname);
+
+                    // Imageurl.
+                    $extrafields[$convid]['imageurl'] = get_group_picture_url($group, $group->courseid, true)->out(false);
                 }
             }
         }
-        return $convsubnames;
+        return $extrafields;
     }
 
 
@@ -386,7 +394,8 @@ class api {
         $typesql = !is_null($type) ? " AND mc.type = :convtype " : "";
 
         $sql = "SELECT m.id as messageid, mc.id as id, mc.name as conversationname, mc.type as conversationtype, m.useridfrom,
-                       m.smallmessage, m.timecreated, mc.component, mc.itemtype, mc.itemid
+                       m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagehtml, m.timecreated, mc.component,
+                       mc.itemtype, mc.itemid
                   FROM {message_conversations} mc
             INNER JOIN {message_conversation_members} mcm
                     ON (mcm.conversationid = mc.id AND mcm.userid = :userid3)
@@ -431,10 +440,12 @@ class api {
             return [];
         }
 
-        // COMPONENT-LINKED CONVERSATION SUBNAME.
-        // This subname will vary, depending on the component which created the linked conversation.
+        // COMPONENT-LINKED CONVERSATION FIELDS.
+        // Conversations linked to components may have extra information, such as:
+        // - subname: Essentially a subtitle for the conversation. So you'd have "name: subname".
+        // - imageurl: A URL to the image for the linked conversation.
         // For now, this is ONLY course groups.
-        $convsubnames = self::get_linked_conversation_subnames($conversations);
+        $convextrafields = self::get_linked_conversation_extra_fields($conversations);
 
         // MEMBERS.
         // Ideally, we want to get 1 member for each conversation, but this depends on the type and whether there is a recent
@@ -555,7 +566,8 @@ class api {
             $conv = new \stdClass();
             $conv->id = $conversation->id;
             $conv->name = $conversation->conversationname;
-            $conv->subname = $convsubnames[$conv->id] ?? null;
+            $conv->subname = $convextrafields[$conv->id]['subname'] ?? null;
+            $conv->imageurl = $convextrafields[$conv->id]['imageurl'] ?? null;
             $conv->type = $conversation->conversationtype;
             $conv->membercount = $membercounts[$conv->id]->membercount;
             $conv->isfavourite = in_array($conv->id, $favouriteconversationids);
@@ -568,7 +580,7 @@ class api {
             if ($conversation->smallmessage) {
                 $msg = new \stdClass();
                 $msg->id = $conversation->messageid;
-                $msg->text = clean_param($conversation->smallmessage, PARAM_NOTAGS);
+                $msg->text = message_format_message_text($conversation);
                 $msg->useridfrom = $conversation->useridfrom;
                 $msg->timecreated = $conversation->timecreated;
                 $conv->messages[] = $msg;
index 67dcf87..1927b64 100644 (file)
@@ -552,7 +552,7 @@ class helper {
             $data->profileimageurl = $conv->members[$otheruser->id]->profileimageurl;
             $data->profileimageurlsmall = $conv->members[$otheruser->id]->profileimageurlsmall;
             $data->ismessaging = isset($conv->messages[0]->text) ? true : false;
-            $data->lastmessage = $conv->messages[0]->text ?? null;
+            $data->lastmessage = $conv->messages[0]->text ? clean_param($conv->messages[0]->text, PARAM_NOTAGS) : null;
             $data->messageid = $conv->messages[0]->id ?? null;
             $data->isonline = $conv->members[$otheruser->id]->isonline ?? null;
             $data->isblocked = $conv->members[$otheruser->id]->isblocked ?? null;
index c0c55f0..fcafb71 100644 (file)
@@ -955,6 +955,7 @@ class core_message_external extends external_api {
                 'id' => new external_value(PARAM_INT, 'The conversation id'),
                 'name' => new external_value(PARAM_NOTAGS, 'The conversation name, if set', VALUE_DEFAULT, null),
                 'subname' => new external_value(PARAM_NOTAGS, 'A subtitle for the conversation name, if set', VALUE_DEFAULT, null),
+                'imageurl' => new external_value(PARAM_URL, 'A link to the conversation picture, if set', VALUE_DEFAULT, null),
                 'type' => new external_value(PARAM_INT, 'The type of the conversation (1=individual,2=group)'),
                 'membercount' => new external_value(PARAM_INT, 'Total number of conversation members'),
                 'isfavourite' => new external_value(PARAM_BOOL, 'If the user marked conversation this conversation as a favourite'),
index 2222a85..9dcc43c 100644 (file)
Binary files a/message/output/popup/amd/build/notification_popover_controller.min.js and b/message/output/popup/amd/build/notification_popover_controller.min.js differ
index 944f8a9..8a6b04f 100644 (file)
@@ -227,7 +227,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str', 'core/url',
                 notificationid: notification.id
             };
             if (notification.contexturl) {
-                notificationurlparams.redirecturl = notification.contexturl;
+                notificationurlparams.redirecturl = encodeURIComponent(notification.contexturl);
             }
             notification.contexturl = URL.relativeUrl('message/output/popup/mark_notification_read.php', notificationurlparams);
 
index dc8d49c..d9b050f 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup
+@core_message @message_popup
 Feature: Message popover preferences
   In order to modify my message preferences
   As a user
index b27e22e..ebd93b2 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup @javascript
+@core_message @message_popup @javascript
 Feature: Message popover unread messages
   In order to be kept informed
   As a user
index d4743da..1ec46af 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup
+@core_message @message_popup
 Feature: Notification popover preferences
   In order to modify my notification preferences
   As a user
index e055d04..bf1d8fd 100644 (file)
@@ -1,4 +1,4 @@
-@message @message_popup @javascript
+@core_message @message_popup @javascript
 Feature: Notification popover unread notifications
   In order to be kept informed
   As a user
index 89567e2..4d44b61 100644 (file)
@@ -715,6 +715,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
      * Test verifying get_conversations when no limits, offsets, type filters or favourite restrictions are used.
      */
     public function test_get_conversations_no_restrictions() {
+        global $DB;
         // No conversations should exist yet.
         $user1 = self::getDataGenerator()->create_user();
         $this->assertEquals([], \core_message\api::get_conversations($user1->id));
@@ -741,7 +742,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertCount(1, $conversations[0]->members);
         $this->assertEquals(4, $conversations[0]->membercount);
         $this->assertCount(1, $conversations[0]->messages);
-        $this->assertEquals("Message 14", $conversations[0]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[0]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[0]->messages[0]->text);
         $this->assertEquals($user1->id, $conversations[0]->messages[0]->useridfrom);
 
         $this->assertEquals($gc2->id, $conversations[1]->id);
@@ -750,7 +753,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertCount(1, $conversations[1]->members);
         $this->assertEquals(3, $conversations[1]->membercount);
         $this->assertCount(1, $conversations[1]->messages);
-        $this->assertEquals("Message 11", $conversations[1]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[1]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[1]->messages[0]->text);
         $this->assertEquals($user4->id, $conversations[1]->messages[0]->useridfrom);
 
         $this->assertEquals($ic2->id, $conversations[2]->id);
@@ -760,7 +765,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertEquals($user3->id, $conversations[2]->members[$user3->id]->id);
         $this->assertEquals(2, $conversations[2]->membercount);
         $this->assertCount(1, $conversations[2]->messages);
-        $this->assertEquals("Message 4", $conversations[2]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[2]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[2]->messages[0]->text);
         $this->assertEquals($user1->id, $conversations[2]->messages[0]->useridfrom);
 
         $this->assertEquals($ic1->id, $conversations[3]->id);
@@ -769,7 +776,9 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->assertCount(1, $conversations[3]->members);
         $this->assertEquals(2, $conversations[3]->membercount);
         $this->assertCount(1, $conversations[3]->messages);
-        $this->assertEquals("Message 2", $conversations[3]->messages[0]->text);
+        $message = $DB->get_record('messages', ['id' => $conversations[3]->messages[0]->id]);
+        $expectedmessagetext = message_format_message_text($message);
+        $this->assertEquals($expectedmessagetext, $conversations[3]->messages[0]->text);
         $this->assertEquals($user2->id, $conversations[3]->messages[0]->useridfrom);
 
         // Of the groups without messages, we expect to see the most recently created first.
@@ -792,6 +801,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
             $this->assertObjectHasAttribute('id', $conv);
             $this->assertObjectHasAttribute('name', $conv);
             $this->assertObjectHasAttribute('subname', $conv);
+            $this->assertObjectHasAttribute('imageurl', $conv);
             $this->assertObjectHasAttribute('type', $conv);
             $this->assertObjectHasAttribute('isfavourite', $conv);
             $this->assertObjectHasAttribute('membercount', $conv);
@@ -818,6 +828,34 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         }
     }
 
+    /**
+     * Test verifying that html format messages are supported, and that message_format_message_text() is being called appropriately.
+     */
+    public function test_get_conversations_message_format() {
+        global $DB;
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Create conversation.
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]
+        );
+
+        // Send some messages back and forth.
+        $time = 1;
+        testhelper::send_fake_message_to_conversation($user2, $conversation->id, 'Sup mang?', $time + 1);
+        $mid = testhelper::send_fake_message_to_conversation($user1, $conversation->id, '<a href="#">A link</a>', $time + 2);
+
+        // Verify the format of the html message.
+        $message = $DB->get_record('messages', ['id' => $mid]);
+        $expectedmessagetext = message_format_message_text($message);
+        $conversations = \core_message\api::get_conversations($user1->id);
+        $messages = $conversations[0]->messages;
+        $this->assertEquals($expectedmessagetext, $messages[0]->text);
+    }
+
     /**
      * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
      */
@@ -1016,6 +1054,8 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
      * Test verifying that group linked conversations are returned and contain a subname matching the course name.
      */
     public function test_get_conversations_group_linked() {
+        global $CFG;
+
         // Create some users.
         $user1 = self::getDataGenerator()->create_user();
         $user2 = self::getDataGenerator()->create_user();
@@ -1028,14 +1068,21 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
         $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
         $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
         $this->getDataGenerator()->enrol_user($user3->id, $course1->id);
-        $group1 = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+        $group1 = $this->getDataGenerator()->create_group([
+            'courseid' => $course1->id,
+            'enablemessaging' => 1,
+            'picturepath' => $CFG->dirroot . '/lib/tests/fixtures/gd-logo.png'
+        ]);
 
         // Add users to group1.
         $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
         $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user2->id));
 
         $conversations = \core_message\api::get_conversations($user1->id);
+        $this->assertEquals(2, $conversations[0]->membercount);
         $this->assertEquals($course1->shortname, $conversations[0]->subname);
+        $groupimageurl = get_group_picture_url($group1, $group1->courseid, true);
+        $this->assertEquals($groupimageurl, $conversations[0]->imageurl);
     }
 
    /**
@@ -1102,14 +1149,14 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user3',
-                            'subject'           => 'S5',
+                            'subject'           => '<p>S5</p>',
                             'unreadcount'       => 0,
                         ),
                         // User1 has also conversed with user2. The most recent message is S2.
                         array(
                             'messageposition'   => 1,
                             'with'              => 'user2',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 1,
                         ),
                     ),
@@ -1118,7 +1165,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 2,
                         ),
                     ),
@@ -1127,7 +1174,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S5',
+                            'subject'           => '<p>S5</p>',
                             'unreadcount'       => 0,
                         ),
                     ),
@@ -1174,7 +1221,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user2',
-                            'subject'           => 'S4',
+                            'subject'           => '<p>S4</p>',
                             'unreadcount'       => 0,
                         ),
                     ),
@@ -1183,7 +1230,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S4',
+                            'subject'           => '<p>S4</p>',
                             'unreadcount'       => 2,
                         ),
                     ),
@@ -1233,7 +1280,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user2',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 0,
                         ),
                     ),
@@ -1241,7 +1288,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S2',
+                            'subject'           => '<p>S2</p>',
                             'unreadcount'       => 2
                         ),
                     ),
@@ -1318,7 +1365,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user2',
-                            'subject'           => 'S8',
+                            'subject'           => '<p>S8</p>',
                             'unreadcount'       => 1,
                         ),
                     ),
@@ -1326,7 +1373,7 @@ class core_message_api_testcase extends core_message_messagelib_testcase {
                         array(
                             'messageposition'   => 0,
                             'with'              => 'user1',
-                            'subject'           => 'S8',
+                            'subject'           => '<p>S8</p>',
                             'unreadcount'       => 3,
                         ),
                     ),
index c6f3546..a1ba032 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Delete all messages
   In order to communicate with fellow users
   As a user
index a97087d..07559d1 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Delete messages
   In order to communicate with fellow users
   As a user
index 135e154..b3d142c 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Manage contacts
   In order to communicate with fellow users
   As a user
index a237ac3..ce7d956 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Reply message
   In order to communicate with fellow users
   As a user
index 5c7d3e9..841be25 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Search messages
   In order to communicate with fellow users
   As a user
index e8c0114..b808ccc 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Search users
   In order to communicate with fellow users
   As a user
index c03d2a3..bfd4c2c 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: Messaging preferences
   In order to be notified of messages
   As a user
index f979bd9..5c0733c 100644 (file)
@@ -1,4 +1,4 @@
-@core @message @javascript
+@core @core_message @javascript
 Feature: View messages
   In order to communicate with fellow users
   As a user
index 1d2667d..d6fd8ca 100644 (file)
@@ -3022,14 +3022,11 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->assertContains($user1->id, $membersid);
         $this->assertContains($user2->id, $membersid);
         $this->assertContains($user3->id, $membersid);
-        $this->assertNotContains($user4->id, $membersid);
-        $this->assertNotContains($user5->id, $membersid);
+
         $membersfullnames = [$members[0]['fullname'], $members[1]['fullname'], $members[2]['fullname']];
         $this->assertContains(fullname($user1), $membersfullnames);
         $this->assertContains(fullname($user2), $membersfullnames);
         $this->assertContains(fullname($user3), $membersfullnames);
-        $this->assertNotContains(fullname($user4), $membersfullnames);
-        $this->assertNotContains(fullname($user5), $membersfullnames);
 
         // Confirm the messages data is correct.
         $messages = $result['messages'];
@@ -3154,7 +3151,6 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         $this->assertContains($user1->id, $membersid);
         $this->assertContains($user2->id, $membersid);
         $this->assertContains($user3->id, $membersid);
-        $this->assertNotContains($user4->id, $membersid);
 
         // Confirm the message data is correct.
         $messages = $result['messages'];
@@ -4443,6 +4439,7 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
             $this->assertArrayHasKey('id', $conv);
             $this->assertArrayHasKey('name', $conv);
             $this->assertArrayHasKey('subname', $conv);
+            $this->assertArrayHasKey('imageurl', $conv);
             $this->assertArrayHasKey('type', $conv);
             $this->assertArrayHasKey('membercount', $conv);
             $this->assertArrayHasKey('isfavourite', $conv);
@@ -4469,6 +4466,41 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         }
     }
 
+    /**
+     * Test verifying that html format messages are supported, and that message_format_message_text() is being called appropriately.
+     */
+    public function test_get_conversations_message_format() {
+        $this->resetAfterTest();
+
+        global $DB;
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+
+        // Create conversation.
+        $conversation = \core_message\api::create_conversation(
+            \core_message\api::MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
+            [$user1->id, $user2->id]
+        );
+
+        // Send some messages back and forth.
+        $time = 1;
+        testhelper::send_fake_message_to_conversation($user2, $conversation->id, 'Sup mang?', $time + 1);
+        $mid = testhelper::send_fake_message_to_conversation($user1, $conversation->id, '<a href="#">A link</a>', $time + 2);
+        $message = $DB->get_record('messages', ['id' => $mid]);
+
+        // The user in scope.
+        $this->setUser($user1);
+
+        // Verify the format of the html message.
+        $expectedmessagetext = message_format_message_text($message);
+        $result = core_message_external::get_conversations($user1->id);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+        $messages = $conversations[0]['messages'];
+        $this->assertEquals($expectedmessagetext, $messages[0]['text']);
+    }
+
     /**
      * Tests retrieving conversations with a limit and offset to ensure pagination works correctly.
      */
@@ -4679,6 +4711,45 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
         }
     }
 
+    /**
+     * Test verifying that group linked conversations are returned and contain a subname matching the course name.
+     */
+    public function test_get_conversations_group_linked() {
+        $this->resetAfterTest();
+        global $CFG;
+
+        // Create some users.
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $course1 = $this->getDataGenerator()->create_course();
+
+        // Create a group with a linked conversation.
+        $this->setAdminUser();
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user3->id, $course1->id);
+        $group1 = $this->getDataGenerator()->create_group([
+            'courseid' => $course1->id,
+            'enablemessaging' => 1,
+            'picturepath' => $CFG->dirroot . '/lib/tests/fixtures/gd-logo.png'
+        ]);
+
+        // Add users to group1.
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user1->id));
+        $this->getDataGenerator()->create_group_member(array('groupid' => $group1->id, 'userid' => $user2->id));
+
+        $result = core_message_external::get_conversations($user1->id, 0, 20, null, false);
+        $result = external_api::clean_returnvalue(core_message_external::get_conversations_returns(), $result);
+        $conversations = $result['conversations'];
+
+        $this->assertEquals(2, $conversations[0]['membercount']);
+        $this->assertEquals($course1->shortname, $conversations[0]['subname']);
+        $groupimageurl = get_group_picture_url($group1, $group1->courseid, true);
+        $this->assertEquals($groupimageurl, $conversations[0]['imageurl']);
+    }
+
     /**
      * Test returning members in a conversation with no contact requests.
      */
index 6a1b81a..858f951 100644 (file)
@@ -1025,7 +1025,9 @@ class mod_assign_renderer extends plugin_renderer_base {
             $grade = null;
             foreach ($history->grades as $onegrade) {
                 if ($onegrade->attemptnumber == $submission->attemptnumber) {
-                    $grade = $onegrade;
+                    if ($onegrade->grade != ASSIGN_GRADE_NOT_SET) {
+                        $grade = $onegrade;
+                    }
                     break;
                 }
             }
index ccaafe5..b826e1d 100644 (file)
@@ -15,7 +15,9 @@
 }
 
 .que.ddmarker .dragitems .draghome {
+    display: inline-block;
     margin: 10px;
+    vertical-align: top;
 }
 
 .que.ddmarker .dragitems {
index 21199d4..74b2e08 100644 (file)
@@ -1257,7 +1257,13 @@ class engine extends \core_search\engine {
 
         if ($CFG->proxyhost && !is_proxybypass('http://' . $this->config->server_hostname . '/')) {
             $options['proxy_host'] = $CFG->proxyhost;
-            $options['proxy_port'] = $CFG->proxyport;
+            if (!empty($CFG->proxyport)) {
+                $options['proxy_port'] = $CFG->proxyport;
+            }
+            if (!empty($CFG->proxyuser) && !empty($CFG->proxypassword)) {
+                $options['proxy_login'] = $CFG->proxyuser;
+                $options['proxy_password'] = $CFG->proxypassword;
+            }
         }
 
         if (!class_exists('\SolrClient')) {
index dcbdfe4..b603dd2 100644 (file)
@@ -131,8 +131,10 @@ $card-gutter : $card-deck-margin * 2;
 
 .dashboard-card-deck .dashboard-card {
     margin-bottom: $card-gutter;
-    flex-basis: 100%;
+    flex-basis: auto;
+    width: 100%;
     flex-grow: 0;
+    flex-shrink: 0;
     .dashboard-card-img {
         height: 7rem;
         background-position: center;
@@ -146,22 +148,22 @@ $card-gutter : $card-deck-margin * 2;
 .dashboard-card-deck {
     @include media-breakpoint-up(sm) {
         .dashboard-card {
-            flex-basis: calc(50% - #{$card-gutter});
+            width: calc(50% - #{$card-gutter});
         }
     }
     @include media-breakpoint-up(md) {
         .dashboard-card {
-            flex-basis: calc(33.33% - #{$card-gutter});
+            width: calc(33.33% - #{$card-gutter});
         }
     }
     @include media-breakpoint-up(lg) {
         .dashboard-card {
-            flex-basis: calc(25% - #{$card-gutter});
+            width: calc(25% - #{$card-gutter});
         }
     }
     @include media-breakpoint-up(xl) {
         .dashboard-card {
-            flex-basis: calc(20% - #{$card-gutter});
+            width: calc(20% - #{$card-gutter});
         }
     }
 }
@@ -170,12 +172,12 @@ $card-gutter : $card-deck-margin * 2;
     .dashboard-card-deck {
         @include media-breakpoint-up(lg) {
             .dashboard-card {
-                flex-basis: calc(33.33% - #{$card-gutter});
+                width: calc(33.33% - #{$card-gutter});
             }
         }
         @include media-breakpoint-up(xl) {
             .dashboard-card {
-                flex-basis: calc(25% - #{$card-gutter});
+                width: calc(25% - #{$card-gutter});
             }
         }
     }
@@ -185,12 +187,12 @@ body.drawer-open-left #region-main.has-blocks {
     .dashboard-card-deck {
         @include media-breakpoint-up(lg) {
             .dashboard-card {
-                flex-basis: calc(33.33% - #{$card-gutter});
+                width: calc(33.33% - #{$card-gutter});
             }
         }
         @media (min-width: 1400px) {
             .dashboard-card {
-                flex-basis: calc(25% - #{$card-gutter});
+                width: calc(25% - #{$card-gutter});
             }
         }
     }
@@ -201,7 +203,7 @@ body.drawer-open-left #region-main.has-blocks {
         .dashboard-card-deck {
             margin: 0;
             .dashboard-card {
-                flex-basis: 100% !important;
+                width: 100% !important;
                 margin-left: 0;
                 margin-right: 0;
             }
index 4670378..68934ab 100644 (file)
@@ -11259,8 +11259,10 @@ div.editor_atto_toolbar button .icon {
 
 .dashboard-card-deck .dashboard-card {
   margin-bottom: 0.5rem;
-  flex-basis: 100%;
-  flex-grow: 0; }
+  flex-basis: auto;
+  width: 100%;
+  flex-grow: 0;
+  flex-shrink: 0; }
   .dashboard-card-deck .dashboard-card .dashboard-card-img {
     height: 7rem;
     background-position: center;
@@ -11270,41 +11272,41 @@ div.editor_atto_toolbar button .icon {
 
 @media (min-width: 576px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(50% - 0.5rem); } }
+    width: calc(50% - 0.5rem); } }
 
 @media (min-width: 768px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.33% - 0.5rem); } }
+    width: calc(33.33% - 0.5rem); } }
 
 @media (min-width: 992px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(25% - 0.5rem); } }
+    width: calc(25% - 0.5rem); } }
 
 @media (min-width: 1200px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(20% - 0.5rem); } }
+    width: calc(20% - 0.5rem); } }
 
 @media (min-width: 992px) {
   #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.33% - 0.5rem); } }
+    width: calc(33.33% - 0.5rem); } }
 
 @media (min-width: 1200px) {
   #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(25% - 0.5rem); } }
+    width: calc(25% - 0.5rem); } }
 
 @media (min-width: 992px) {
   body.drawer-open-left #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.33% - 0.5rem); } }
+    width: calc(33.33% - 0.5rem); } }
 
 @media (min-width: 1400px) {
   body.drawer-open-left #region-main.has-blocks .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(25% - 0.5rem); } }
+    width: calc(25% - 0.5rem); } }
 
 @media (min-width: 1200px) {
   #block-region-side-pre .dashboard-card-deck {
     margin: 0; }
     #block-region-side-pre .dashboard-card-deck .dashboard-card {
-      flex-basis: 100% !important;
+      width: 100% !important;
       margin-left: 0;
       margin-right: 0; } }
 
index 4f5168f..5450089 100644 (file)
@@ -39,6 +39,7 @@
     * errorformatted - Formatted error,
     * logourl - Flag, logo url,
     * sitename - Name of site.
+    * logintoken - Random token to protect login request.
 
     Example context (json):
     {
@@ -87,7 +88,8 @@
         "cookieshelpiconformatted": "",
         "errorformatted": "",
         "logourl": false,
-        "sitename": "Beer & Chips"
+        "sitename": "Beer & Chips",
+        "logintoken": "randomstring"
     }
 }}
 
                     <form class="mt-3" action="{{loginurl}}" method="post" id="login">
                         <input id="anchor" type="hidden" name="anchor" value="">
                         <script>document.getElementById('anchor').value = location.hash;</script>
+                        <input type="hidden" name="logintoken" value="{{logintoken}}">
                         <div class="form-group">
                             <label for="username" class="sr-only">
                                 {{^canloginbyemail}}
                         <div class="mt-2">
                             <p>{{#str}}someallowguest{{/str}}</p>
                             <form action="{{loginurl}}" method="post" id="guestlogin">
+                                <input type="hidden" name="logintoken" value="{{logintoken}}">
                                 <input type="hidden" name="username" value="guest" />
                                 <input type="hidden" name="password" value="guest" />
                                 <button class="btn btn-secondary btn-block" type="submit">{{#str}}loginguest{{/str}}</button>
index 7edeaa7..7492e5e 100644 (file)
                 img.actionmenu {
                     width: auto;
                 }
+                .toggle-display[role="menuitem"] img.icon {
+                    width: 22px;
+                    vertical-align: middle;
+                }
             }
         }
     }
         flex-grow: 0;
         flex-shrink: 0;
         min-width: 0;
-        flex-basis: 100%;
+        width: 100%;
+        flex-basis: auto;
     }
     @media (min-width: 576px) {
         .dashboard-card {
             flex-direction: column;
             margin-right: 0.25rem;
             margin-left: 0.25rem;
-            flex-basis: ~"calc(50% - 0.6rem)";
+            width: ~"calc(50% - 0.5rem)";
         }
     }
 
     @media (min-width: 1200px) {
         .dashboard-card {
-            flex-basis: ~"calc(33.333% - 0.5rem)";
+            width: ~"calc(33.333% - 0.5rem)";
         }
     }
 }
         margin: 0;
         height: unset;
         .dashboard-card {
-            flex-basis: 100%;
+            width: 100%;
             margin-left: 0;
             margin-right: 0;
         }
index 230c0b0..52266d1 100644 (file)
@@ -16084,6 +16084,10 @@ body {
 .editing .block .header .commands img.actionmenu {
   width: auto;
 }
+.editing .block .header .commands .toggle-display[role="menuitem"] img.icon {
+  width: 22px;
+  vertical-align: middle;
+}
 .jsenabled .block.hidden .content {
   display: none;
 }
@@ -16673,7 +16677,8 @@ body {
   flex-grow: 0;
   flex-shrink: 0;
   min-width: 0;
-  flex-basis: 100%;
+  width: 100%;
+  flex-basis: auto;
 }
 @media (min-width: 576px) {
   .dashboard-card-deck .dashboard-card {
@@ -16681,12 +16686,12 @@ body {
     flex-direction: column;
     margin-right: 0.25rem;
     margin-left: 0.25rem;
-    flex-basis: calc(50% - 0.6rem);
+    width: calc(50% - 0.5rem);
   }
 }
 @media (min-width: 1200px) {
   .dashboard-card-deck .dashboard-card {
-    flex-basis: calc(33.333% - 0.5rem);
+    width: calc(33.333% - 0.5rem);
   }
 }
 @media (min-width: 768px) {
@@ -16697,7 +16702,7 @@ body {
   }
   #block-region-side-post .dashboard-card-deck .dashboard-card,
   #block-region-side-pre .dashboard-card-deck .dashboard-card {
-    flex-basis: 100%;
+    width: 100%;
     margin-left: 0;
     margin-right: 0;
   }
index b0b71c3..94e3858 100644 (file)
@@ -167,7 +167,7 @@ class core_webservice_external extends external_api {
 
         // Retrieve some advanced features. Only enable/disable ones (bool).
         $advancedfeatures = array("usecomments", "usetags", "enablenotes", "messaging", "enableblogs",
-                                    "enablecompletion", "enablebadges");
+                                    "enablecompletion", "enablebadges", "messagingallusers");
         foreach ($advancedfeatures as $feature) {
             if (isset($CFG->{$feature})) {
                 $siteinfo['advancedfeatures'][] = array(