Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Implementation of the quizaccess_seb plugin.
  19   *
  20   * @package    quizaccess_seb
  21   * @author     Andrew Madden <andrewmadden@catalyst-au.net>
  22   * @author     Dmitrii Metelkin <dmitriim@catalyst-au.net>
  23   * @copyright  2019 Catalyst IT
  24   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25   */
  26  
  27  use quizaccess_seb\access_manager;
  28  use quizaccess_seb\quiz_settings;
  29  use quizaccess_seb\settings_provider;
  30  use \quizaccess_seb\event\access_prevented;
  31  
  32  defined('MOODLE_INTERNAL') || die();
  33  
  34  global $CFG;
  35  require_once($CFG->dirroot . '/mod/quiz/accessrule/accessrulebase.php');
  36  
  37  /**
  38   * Implementation of the quizaccess_seb plugin.
  39   *
  40   * @copyright  2020 Catalyst IT
  41   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  42   */
  43  class quizaccess_seb extends quiz_access_rule_base {
  44  
  45      /** @var access_manager $accessmanager Instance to manage the access to the quiz for this plugin. */
  46      private $accessmanager;
  47  
  48      /**
  49       * Create an instance of this rule for a particular quiz.
  50       *
  51       * @param quiz $quizobj information about the quiz in question.
  52       * @param int $timenow the time that should be considered as 'now'.
  53       * @param access_manager $accessmanager the quiz accessmanager.
  54       */
  55      public function __construct(quiz $quizobj, int $timenow, access_manager $accessmanager) {
  56          parent::__construct($quizobj, $timenow);
  57          $this->accessmanager = $accessmanager;
  58      }
  59  
  60      /**
  61       * Return an appropriately configured instance of this rule, if it is applicable
  62       * to the given quiz, otherwise return null.
  63       *
  64       * @param quiz $quizobj information about the quiz in question.
  65       * @param int $timenow the time that should be considered as 'now'.
  66       * @param bool $canignoretimelimits whether the current user is exempt from
  67       *      time limits by the mod/quiz:ignoretimelimits capability.
  68       * @return quiz_access_rule_base|null the rule, if applicable, else null.
  69       */
  70      public static function make (quiz $quizobj, $timenow, $canignoretimelimits) {
  71          $accessmanager = new access_manager($quizobj);
  72          // If Safe Exam Browser is not required, this access rule is not applicable.
  73          if (!$accessmanager->seb_required()) {
  74              return null;
  75          }
  76  
  77          return new self($quizobj, $timenow, $accessmanager);
  78      }
  79  
  80      /**
  81       * Add any fields that this rule requires to the quiz settings form. This
  82       * method is called from {@link mod_quiz_mod_form::definition()}, while the
  83       * security section is being built.
  84       *
  85       * @param mod_quiz_mod_form $quizform the quiz settings form that is being built.
  86       * @param MoodleQuickForm $mform the wrapped MoodleQuickForm.
  87       */
  88      public static function add_settings_form_fields(mod_quiz_mod_form $quizform, MoodleQuickForm $mform) {
  89          settings_provider::add_seb_settings_fields($quizform, $mform);
  90      }
  91  
  92      /**
  93       * Validate the data from any form fields added using {@link add_settings_form_fields()}.
  94       *
  95       * @param array $errors the errors found so far.
  96       * @param array $data the submitted form data.
  97       * @param array $files information about any uploaded files.
  98       * @param mod_quiz_mod_form $quizform the quiz form object.
  99       * @return array $errors the updated $errors array.
 100       */
 101      public static function validate_settings_form_fields(array $errors,
 102                                                           array $data, $files, mod_quiz_mod_form $quizform) : array {
 103  
 104          $quizid = $data['instance'];
 105          $cmid = $data['coursemodule'];
 106          $context = $quizform->get_context();
 107  
 108          if (!settings_provider::can_configure_seb($context)) {
 109              return $errors;
 110          }
 111  
 112          if (settings_provider::is_seb_settings_locked($quizid)) {
 113              return $errors;
 114          }
 115  
 116          if (settings_provider::is_conflicting_permissions($context)) {
 117              return $errors;
 118          }
 119  
 120          $settings = settings_provider::filter_plugin_settings((object) $data);
 121  
 122          // Validate basic settings using persistent class.
 123          $quizsettings = (new quiz_settings())->from_record($settings);
 124          // Set non-form fields.
 125          $quizsettings->set('quizid', $quizid);
 126          $quizsettings->set('cmid', $cmid);
 127          $quizsettings->validate();
 128  
 129          // Add any errors to list.
 130          foreach ($quizsettings->get_errors() as $name => $error) {
 131              $name = settings_provider::add_prefix($name); // Re-add prefix to match form element.
 132              $errors[$name] = $error->out();
 133          }
 134  
 135          // Edge case for filemanager_sebconfig.
 136          if ($quizsettings->get('requiresafeexambrowser') == settings_provider::USE_SEB_UPLOAD_CONFIG) {
 137              $errorvalidatefile = settings_provider::validate_draftarea_configfile($data['filemanager_sebconfigfile']);
 138              if (!empty($errorvalidatefile)) {
 139                  $errors['filemanager_sebconfigfile'] = $errorvalidatefile;
 140              }
 141          }
 142  
 143          // Edge case to force user to select a template.
 144          if ($quizsettings->get('requiresafeexambrowser') == settings_provider::USE_SEB_TEMPLATE) {
 145              if (empty($data['seb_templateid'])) {
 146                  $errors['seb_templateid'] = get_string('invalidtemplate', 'quizaccess_seb');
 147              }
 148          }
 149  
 150          if ($quizsettings->get('requiresafeexambrowser') != settings_provider::USE_SEB_NO) {
 151              // Global settings may be active which require a quiz password to be set if using SEB.
 152              if (!empty(get_config('quizaccess_seb', 'quizpasswordrequired')) && empty($data['quizpassword'])) {
 153                  $errors['quizpassword'] = get_string('passwordnotset', 'quizaccess_seb');
 154              }
 155          }
 156  
 157          return $errors;
 158      }
 159  
 160      /**
 161       * Save any submitted settings when the quiz settings form is submitted. This
 162       * is called from {@link quiz_after_add_or_update()} in lib.php.
 163       *
 164       * @param object $quiz the data from the quiz form, including $quiz->id
 165       *      which is the id of the quiz being saved.
 166       */
 167      public static function save_settings($quiz) {
 168          $context = context_module::instance($quiz->coursemodule);
 169  
 170          if (!settings_provider::can_configure_seb($context)) {
 171              return;
 172          }
 173  
 174          if (settings_provider::is_seb_settings_locked($quiz->id)) {
 175              return;
 176          }
 177  
 178          if (settings_provider::is_conflicting_permissions($context)) {
 179              return;
 180          }
 181  
 182          $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course, false, MUST_EXIST);
 183  
 184          $settings = settings_provider::filter_plugin_settings($quiz);
 185          $settings->quizid = $quiz->id;
 186          $settings->cmid = $cm->id;
 187  
 188          // Get existing settings or create new settings if none exist.
 189          $quizsettings = quiz_settings::get_by_quiz_id($quiz->id);
 190          if (empty($quizsettings)) {
 191              $quizsettings = new quiz_settings(0, $settings);
 192          } else {
 193              $settings->id = $quizsettings->get('id');
 194              $quizsettings->from_record($settings);
 195          }
 196  
 197          // Process uploaded files if required.
 198          if ($quizsettings->get('requiresafeexambrowser') == settings_provider::USE_SEB_UPLOAD_CONFIG) {
 199              $draftitemid = file_get_submitted_draft_itemid('filemanager_sebconfigfile');
 200              settings_provider::save_filemanager_sebconfigfile_draftarea($draftitemid, $cm->id);
 201          } else {
 202              settings_provider::delete_uploaded_config_file($cm->id);
 203          }
 204  
 205          // Save or delete settings.
 206          if ($quizsettings->get('requiresafeexambrowser') != settings_provider::USE_SEB_NO) {
 207              $quizsettings->save();
 208          } else if ($quizsettings->get('id')) {
 209              $quizsettings->delete();
 210          }
 211      }
 212  
 213      /**
 214       * Delete any rule-specific settings when the quiz is deleted. This is called
 215       * from {@link quiz_delete_instance()} in lib.php.
 216       *
 217       * @param object $quiz the data from the database, including $quiz->id
 218       *      which is the id of the quiz being deleted.
 219       */
 220      public static function delete_settings($quiz) {
 221          $quizsettings = quiz_settings::get_by_quiz_id($quiz->id);
 222          // Check that there are existing settings.
 223          if ($quizsettings !== false) {
 224              $quizsettings->delete();
 225          }
 226      }
 227  
 228      /**
 229       * Return the bits of SQL needed to load all the settings from all the access
 230       * plugins in one DB query. The easiest way to understand what you need to do
 231       * here is probalby to read the code of {@link quiz_access_manager::load_settings()}.
 232       *
 233       * If you have some settings that cannot be loaded in this way, then you can
 234       * use the {@link get_extra_settings()} method instead, but that has
 235       * performance implications.
 236       *
 237       * @param int $quizid the id of the quiz we are loading settings for. This
 238       *     can also be accessed as quiz.id in the SQL. (quiz is a table alisas for {quiz}.)
 239       * @return array with three elements:
 240       *     1. fields: any fields to add to the select list. These should be alised
 241       *        if neccessary so that the field name starts the name of the plugin.
 242       *     2. joins: any joins (should probably be LEFT JOINS) with other tables that
 243       *        are needed.
 244       *     3. params: array of placeholder values that are needed by the SQL. You must
 245       *        used named placeholders, and the placeholder names should start with the
 246       *        plugin name, to avoid collisions.
 247       */
 248      public static function get_settings_sql($quizid) : array {
 249          return [
 250                  'seb.requiresafeexambrowser AS seb_requiresafeexambrowser, '
 251                  . 'seb.showsebtaskbar AS seb_showsebtaskbar, '
 252                  . 'seb.showwificontrol AS seb_showwificontrol, '
 253                  . 'seb.showreloadbutton AS seb_showreloadbutton, '
 254                  . 'seb.showtime AS seb_showtime, '
 255                  . 'seb.showkeyboardlayout AS seb_showkeyboardlayout, '
 256                  . 'seb.allowuserquitseb AS seb_allowuserquitseb, '
 257                  . 'seb.quitpassword AS seb_quitpassword, '
 258                  . 'seb.linkquitseb AS seb_linkquitseb, '
 259                  . 'seb.userconfirmquit AS seb_userconfirmquit, '
 260                  . 'seb.enableaudiocontrol AS seb_enableaudiocontrol, '
 261                  . 'seb.muteonstartup AS seb_muteonstartup, '
 262                  . 'seb.allowspellchecking AS seb_allowspellchecking, '
 263                  . 'seb.allowreloadinexam AS seb_allowreloadinexam, '
 264                  . 'seb.activateurlfiltering AS seb_activateurlfiltering, '
 265                  . 'seb.filterembeddedcontent AS seb_filterembeddedcontent, '
 266                  . 'seb.expressionsallowed AS seb_expressionsallowed, '
 267                  . 'seb.regexallowed AS seb_regexallowed, '
 268                  . 'seb.expressionsblocked AS seb_expressionsblocked, '
 269                  . 'seb.regexblocked AS seb_regexblocked, '
 270                  . 'seb.allowedbrowserexamkeys AS seb_allowedbrowserexamkeys, '
 271                  . 'seb.showsebdownloadlink AS seb_showsebdownloadlink, '
 272                  . 'sebtemplate.id AS seb_templateid '
 273                  , 'LEFT JOIN {quizaccess_seb_quizsettings} seb ON seb.quizid = quiz.id '
 274                  . 'LEFT JOIN {quizaccess_seb_template} sebtemplate ON seb.templateid = sebtemplate.id '
 275                  , []
 276          ];
 277      }
 278  
 279      /**
 280       * Whether the user should be blocked from starting a new attempt or continuing
 281       * an attempt now.
 282       *
 283       * @return string false if access should be allowed, a message explaining the
 284       *      reason if access should be prevented.
 285       */
 286      public function prevent_access() {
 287          global $PAGE;
 288  
 289          if (!$this->accessmanager->seb_required()) {
 290              return false;
 291          }
 292  
 293          if ($this->accessmanager->can_bypass_seb()) {
 294              return false;
 295          }
 296  
 297          // If the rule is active, enforce a secure view whilst taking the quiz.
 298          $PAGE->set_pagelayout('secure');
 299          $this->prevent_display_blocks();
 300  
 301          // Access has previously been validated for this session and quiz.
 302          if ($this->accessmanager->validate_session_access()) {
 303              return false;
 304          }
 305  
 306          if (!$this->accessmanager->validate_basic_header()) {
 307              access_prevented::create_strict($this->accessmanager, $this->get_reason_text('not_seb'))->trigger();
 308              return $this->get_require_seb_error_message();
 309          }
 310  
 311          if (!$this->accessmanager->validate_config_key()) {
 312              if ($this->accessmanager->should_redirect_to_seb_config_link()) {
 313                  $this->accessmanager->redirect_to_seb_config_link();
 314              }
 315  
 316              access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_config_key'))->trigger();
 317              return $this->get_invalid_key_error_message();
 318          }
 319  
 320          if (!$this->accessmanager->validate_browser_exam_key()) {
 321              access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_browser_key'))->trigger();
 322              return $this->get_invalid_key_error_message();
 323          }
 324  
 325          // Set the state of the access for this Moodle session.
 326          $this->accessmanager->set_session_access(true);
 327  
 328          return false;
 329      }
 330  
 331      /**
 332       * Returns a list of finished attempts for the current user.
 333       *
 334       * @return array
 335       */
 336      private function get_user_finished_attempts() : array {
 337          global $USER;
 338  
 339          return quiz_get_user_attempts(
 340              $this->quizobj->get_quizid(),
 341              $USER->id,
 342              quiz_attempt::FINISHED,
 343              false
 344          );
 345      }
 346  
 347      /**
 348       * Prevent block displaying as configured.
 349       */
 350      private function prevent_display_blocks() {
 351          global $PAGE;
 352  
 353          if ($PAGE->has_set_url() && $PAGE->url == $this->quizobj->view_url()) {
 354              $attempts = $this->get_user_finished_attempts();
 355  
 356              // Don't display blocks before starting an attempt.
 357              if (empty($attempts) && !get_config('quizaccess_seb', 'displayblocksbeforestart')) {
 358                  $PAGE->blocks->show_only_fake_blocks();
 359              }
 360  
 361              // Don't display blocks after finishing an attempt.
 362              if (!empty($attempts) && !get_config('quizaccess_seb', 'displayblockswhenfinished')) {
 363                  $PAGE->blocks->show_only_fake_blocks();
 364              }
 365          }
 366      }
 367  
 368      /**
 369       * Returns reason for access prevention as a text.
 370       *
 371       * @param string $identifier Reason string identifier.
 372       * @return string
 373       */
 374      private function get_reason_text(string $identifier) : string {
 375          if (in_array($identifier, ['not_seb', 'invalid_config_key', 'invalid_browser_key'])) {
 376              return get_string($identifier, 'quizaccess_seb');
 377          }
 378  
 379          return get_string('unknown_reason', 'quizaccess_seb');
 380      }
 381  
 382      /**
 383       * Return error message when a SEB key is not valid.
 384       *
 385       * @return string
 386       */
 387      private function get_invalid_key_error_message() : string {
 388          // Return error message with download link and links to get the seb config.
 389          return get_string('invalidkeys', 'quizaccess_seb')
 390              . $this->display_buttons($this->get_action_buttons());
 391      }
 392  
 393      /**
 394       * Return error message when a SEB browser is not used.
 395       *
 396       * @return string
 397       */
 398      private function get_require_seb_error_message() : string {
 399          $message = get_string('clientrequiresseb', 'quizaccess_seb');
 400  
 401          if ($this->should_display_download_seb_link()) {
 402              $message .= $this->display_buttons($this->get_download_seb_button());
 403          }
 404  
 405          // Return error message with download link.
 406          return $message;
 407      }
 408  
 409      /**
 410       * Helper function to display an Exit Safe Exam Browser button if configured to do so and attempts are > 0.
 411       *
 412       * @return string empty or a button which has the configured seb quit link.
 413       */
 414      private function get_quit_button() : string {
 415          $quitbutton = '';
 416  
 417          if (empty($this->get_user_finished_attempts())) {
 418              return $quitbutton;
 419          }
 420  
 421          // Only display if the link has been configured and attempts are greater than 0.
 422          if (!empty($this->quiz->seb_linkquitseb)) {
 423              $quitbutton = html_writer::link(
 424                  $this->quiz->seb_linkquitseb,
 425                  get_string('exitsebbutton', 'quizaccess_seb'),
 426                  ['class' => 'btn btn-secondary']
 427              );
 428          }
 429  
 430          return $quitbutton;
 431      }
 432  
 433      /**
 434       * Information, such as might be shown on the quiz view page, relating to this restriction.
 435       * There is no obligation to return anything. If it is not appropriate to tell students
 436       * about this rule, then just return ''.
 437       *
 438       * @return mixed a message, or array of messages, explaining the restriction
 439       *         (may be '' if no message is appropriate).
 440       */
 441      public function description() : array {
 442          global $PAGE;
 443  
 444          $messages = [get_string('sebrequired', 'quizaccess_seb')];
 445  
 446          // Display download SEB config link for those who can bypass using SEB.
 447          if ($this->accessmanager->can_bypass_seb() && $this->accessmanager->should_validate_config_key()) {
 448              $messages[] = $this->display_buttons($this->get_download_config_button());
 449          }
 450  
 451          // Those with higher level access will be able to see the button if they've made an attempt.
 452          if (!$this->prevent_access()) {
 453              $messages[] = $this->display_buttons($this->get_quit_button());
 454          } else {
 455              $PAGE->requires->js_call_amd('quizaccess_seb/validate_quiz_access', 'init',
 456                  [$this->quiz->cmid, (bool)get_config('quizaccess_seb', 'autoreconfigureseb')]);
 457          }
 458  
 459          return $messages;
 460      }
 461  
 462      /**
 463       * Sets up the attempt (review or summary) page with any special extra
 464       * properties required by this rule.
 465       *
 466       * @param moodle_page $page the page object to initialise.
 467       */
 468      public function setup_attempt_page($page) {
 469          $page->set_title($this->quizobj->get_course()->shortname . ': ' . $page->title);
 470          $page->set_popup_notification_allowed(false); // Prevent message notifications.
 471          $page->set_heading($page->title);
 472          $page->set_pagelayout('secure');
 473      }
 474  
 475      /**
 476       * This is called when the current attempt at the quiz is finished.
 477       */
 478      public function current_attempt_finished() {
 479          $this->accessmanager->clear_session_access();
 480      }
 481  
 482      /**
 483       * Prepare buttons HTML code for being displayed on the screen.
 484       *
 485       * @param string $buttonshtml Html string of the buttons.
 486       * @param string $class Optional CSS class (or classes as space-separated list)
 487       * @param array $attributes Optional other attributes as array
 488       *
 489       * @return string HTML code of the provided buttons.
 490       */
 491      private function display_buttons(string $buttonshtml, $class = '', array $attributes = null) : string {
 492          $html = '';
 493  
 494          if (!empty($buttonshtml)) {
 495              $html = html_writer::div($buttonshtml, $class, $attributes);
 496          }
 497  
 498          return $html;
 499      }
 500  
 501      /**
 502       * Get buttons to prompt user to download SEB or config file or launch SEB.
 503       *
 504       * @return string Html block of all action buttons.
 505       */
 506      private function get_action_buttons() : string {
 507          $buttons = '';
 508  
 509          if ($this->should_display_download_seb_link()) {
 510              $buttons .= $this->get_download_seb_button();
 511          }
 512  
 513          // Get config for displaying links.
 514          $linkconfig = explode(',', get_config('quizaccess_seb', 'showseblinks'));
 515  
 516          // Display links to download config/launch SEB only if required.
 517          if ($this->accessmanager->should_validate_config_key()) {
 518              if (in_array('seb', $linkconfig)) {
 519                  $buttons .= $this->get_launch_seb_button();
 520              }
 521  
 522              if (in_array('http', $linkconfig)) {
 523                  $buttons .= $this->get_download_config_button();
 524              }
 525          }
 526  
 527          return $buttons;
 528      }
 529  
 530      /**
 531       * Get a button to download SEB.
 532       *
 533       * @return string A link to download SafeExam Browser.
 534       */
 535      private function get_download_seb_button() : string {
 536          global $OUTPUT;
 537  
 538          $button = '';
 539  
 540          if (!empty($this->get_seb_download_url())) {
 541              $button = $OUTPUT->single_button($this->get_seb_download_url(), get_string('sebdownloadbutton', 'quizaccess_seb'));
 542          }
 543  
 544          return $button;
 545      }
 546  
 547      /**
 548       * Get a button to launch Safe Exam Browser.
 549       *
 550       * @return string A link to launch Safe Exam Browser.
 551       */
 552      private function get_launch_seb_button() : string {
 553          // Rendering as a href and not as button in a form to circumvent browser warnings for sending to URL with unknown protocol.
 554          $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, true, is_https());
 555  
 556          $buttonlink = html_writer::start_tag('div', array('class' => 'singlebutton'));
 557          $buttonlink .= html_writer::link($seblink, get_string('seblinkbutton', 'quizaccess_seb'),
 558              ['class' => 'btn btn-secondary', 'title' => get_string('seblinkbutton', 'quizaccess_seb')]);
 559          $buttonlink .= html_writer::end_tag('div');
 560  
 561          return $buttonlink;
 562      }
 563  
 564      /**
 565       * Get a button to download Safe Exam Browser config.
 566       *
 567       * @return string A link to launch Safe Exam Browser.
 568       */
 569      private function get_download_config_button() : string {
 570          // Rendering as a href and not as button in a form to circumvent browser warnings for sending to URL with unknown protocol.
 571          $httplink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, false, is_https());
 572  
 573          $buttonlink = html_writer::start_tag('div', array('class' => 'singlebutton'));
 574          $buttonlink .= html_writer::link($httplink, get_string('httplinkbutton', 'quizaccess_seb'),
 575              ['class' => 'btn btn-secondary', 'title' => get_string('httplinkbutton', 'quizaccess_seb')]);
 576          $buttonlink .= html_writer::end_tag('div');
 577  
 578          return $buttonlink;
 579      }
 580  
 581      /**
 582       * Returns SEB download URL.
 583       *
 584       * @return string
 585       */
 586      private function get_seb_download_url() : string {
 587          return get_config('quizaccess_seb', 'downloadlink');
 588      }
 589  
 590      /**
 591       * Check if we should display a link to download Safe Exam Browser.
 592       *
 593       * @return bool
 594       */
 595      private function should_display_download_seb_link() : bool {
 596          return !empty($this->quiz->seb_showsebdownloadlink);
 597      }
 598  }