Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 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          if ($this->accessmanager->should_validate_basic_header() && !$this->accessmanager->validate_basic_header()) {
 302              access_prevented::create_strict($this->accessmanager, $this->get_reason_text('not_seb'))->trigger();
 303              return $this->get_require_seb_error_message();
 304          }
 305  
 306          if ($this->accessmanager->should_validate_config_key() && !$this->accessmanager->validate_config_key()) {
 307              if ($this->should_redirect_to_seb_config_link()) {
 308                  $this->redirect_to_seb_config_link();
 309              }
 310  
 311              access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_config_key'))->trigger();
 312              return $this->get_invalid_key_error_message();
 313          }
 314  
 315          if ($this->accessmanager->should_validate_browser_exam_key() && !$this->accessmanager->validate_browser_exam_keys()) {
 316              access_prevented::create_strict($this->accessmanager, $this->get_reason_text('invalid_browser_key'))->trigger();
 317              return $this->get_invalid_key_error_message();
 318          }
 319  
 320          return false;
 321      }
 322  
 323      /**
 324       * Returns a list of finished attempts for the current user.
 325       *
 326       * @return array
 327       */
 328      private function get_user_finished_attempts() : array {
 329          global $USER;
 330  
 331          return quiz_get_user_attempts(
 332              $this->quizobj->get_quizid(),
 333              $USER->id,
 334              quiz_attempt::FINISHED,
 335              false
 336          );
 337      }
 338  
 339      /**
 340       * Prevent block displaying as configured.
 341       */
 342      private function prevent_display_blocks() {
 343          global $PAGE;
 344  
 345          if ($PAGE->has_set_url() && $PAGE->url == $this->quizobj->view_url()) {
 346              $attempts = $this->get_user_finished_attempts();
 347  
 348              // Don't display blocks before starting an attempt.
 349              if (empty($attempts) && !get_config('quizaccess_seb', 'displayblocksbeforestart')) {
 350                  $PAGE->blocks->show_only_fake_blocks();
 351              }
 352  
 353              // Don't display blocks after finishing an attempt.
 354              if (!empty($attempts) && !get_config('quizaccess_seb', 'displayblockswhenfinished')) {
 355                  $PAGE->blocks->show_only_fake_blocks();
 356              }
 357          }
 358      }
 359  
 360      /**
 361       * Returns reason for access prevention as a text.
 362       *
 363       * @param string $identifier Reason string identifier.
 364       * @return string
 365       */
 366      private function get_reason_text(string $identifier) : string {
 367          if (in_array($identifier, ['not_seb', 'invalid_config_key', 'invalid_browser_key'])) {
 368              return get_string($identifier, 'quizaccess_seb');
 369          }
 370  
 371          return get_string('unknown_reason', 'quizaccess_seb');
 372      }
 373  
 374      /**
 375       * Return error message when a SEB key is not valid.
 376       *
 377       * @return string
 378       */
 379      private function get_invalid_key_error_message() : string {
 380          // Return error message with download link and links to get the seb config.
 381          return get_string('invalidkeys', 'quizaccess_seb')
 382              . $this->display_buttons($this->get_action_buttons());
 383      }
 384  
 385      /**
 386       * Return error message when a SEB browser is not used.
 387       *
 388       * @return string
 389       */
 390      private function get_require_seb_error_message() : string {
 391          $message = get_string('clientrequiresseb', 'quizaccess_seb');
 392  
 393          if ($this->should_display_download_seb_link()) {
 394              $message .= $this->display_buttons($this->get_download_seb_button());
 395          }
 396  
 397          // Return error message with download link.
 398          return $message;
 399      }
 400  
 401      /**
 402       * Helper function to display an Exit Safe Exam Browser button if configured to do so and attempts are > 0.
 403       *
 404       * @return string empty or a button which has the configured seb quit link.
 405       */
 406      private function get_quit_button() : string {
 407          $quitbutton = '';
 408  
 409          if (empty($this->get_user_finished_attempts())) {
 410              return $quitbutton;
 411          }
 412  
 413          // Only display if the link has been configured and attempts are greater than 0.
 414          if (!empty($this->quiz->seb_linkquitseb)) {
 415              $quitbutton = html_writer::link(
 416                  $this->quiz->seb_linkquitseb,
 417                  get_string('exitsebbutton', 'quizaccess_seb'),
 418                  ['class' => 'btn btn-secondary']
 419              );
 420          }
 421  
 422          return $quitbutton;
 423      }
 424  
 425      /**
 426       * Information, such as might be shown on the quiz view page, relating to this restriction.
 427       * There is no obligation to return anything. If it is not appropriate to tell students
 428       * about this rule, then just return ''.
 429       *
 430       * @return mixed a message, or array of messages, explaining the restriction
 431       *         (may be '' if no message is appropriate).
 432       */
 433      public function description() : array {
 434          $messages = [get_string('sebrequired', 'quizaccess_seb')];
 435  
 436          // Display download SEB config link for those who can bypass using SEB.
 437          if ($this->accessmanager->can_bypass_seb() && $this->accessmanager->should_validate_config_key()) {
 438              $messages[] = $this->display_buttons($this->get_download_config_button());
 439          }
 440  
 441          // Those with higher level access will be able to see the button if they've made an attempt.
 442          if (!$this->prevent_access()) {
 443              $messages[] = $this->display_buttons($this->get_quit_button());
 444          }
 445  
 446          return $messages;
 447      }
 448  
 449      /**
 450       * Sets up the attempt (review or summary) page with any special extra
 451       * properties required by this rule.
 452       *
 453       * @param moodle_page $page the page object to initialise.
 454       */
 455      public function setup_attempt_page($page) {
 456          $page->set_title($this->quizobj->get_course()->shortname . ': ' . $page->title);
 457          $page->set_popup_notification_allowed(false); // Prevent message notifications.
 458          $page->set_heading($page->title);
 459          $page->set_pagelayout('secure');
 460      }
 461  
 462      /**
 463       * Prepare buttons HTML code for being displayed on the screen.
 464       *
 465       * @param string $buttonshtml Html string of the buttons.
 466       * @param string $class Optional CSS class (or classes as space-separated list)
 467       * @param array $attributes Optional other attributes as array
 468       *
 469       * @return string HTML code of the provided buttons.
 470       */
 471      private function display_buttons(string $buttonshtml, $class = '', array $attributes = null) : string {
 472          $html = '';
 473  
 474          if (!empty($buttonshtml)) {
 475              $html = html_writer::div($buttonshtml, $class, $attributes);
 476          }
 477  
 478          return $html;
 479      }
 480  
 481      /**
 482       * Get buttons to prompt user to download SEB or config file or launch SEB.
 483       *
 484       * @return string Html block of all action buttons.
 485       */
 486      private function get_action_buttons() : string {
 487          $buttons = '';
 488  
 489          if ($this->should_display_download_seb_link()) {
 490              $buttons .= $this->get_download_seb_button();
 491          }
 492  
 493          // Get config for displaying links.
 494          $linkconfig = explode(',', get_config('quizaccess_seb', 'showseblinks'));
 495  
 496          // Display links to download config/launch SEB only if required.
 497          if ($this->accessmanager->should_validate_config_key()) {
 498              if (in_array('seb', $linkconfig)) {
 499                  $buttons .= $this->get_launch_seb_button();
 500              }
 501  
 502              if (in_array('http', $linkconfig)) {
 503                  $buttons .= $this->get_download_config_button();
 504              }
 505          }
 506  
 507          return $buttons;
 508      }
 509  
 510      /**
 511       * Get a button to download SEB.
 512       *
 513       * @return string A link to download SafeExam Browser.
 514       */
 515      private function get_download_seb_button() : string {
 516          global $OUTPUT;
 517  
 518          $button = '';
 519  
 520          if (!empty($this->get_seb_download_url())) {
 521              $button = $OUTPUT->single_button($this->get_seb_download_url(), get_string('sebdownloadbutton', 'quizaccess_seb'));
 522          }
 523  
 524          return $button;
 525      }
 526  
 527      /**
 528       * Get a button to launch Safe Exam Browser.
 529       *
 530       * @return string A link to launch Safe Exam Browser.
 531       */
 532      private function get_launch_seb_button() : string {
 533          // Rendering as a href and not as button in a form to circumvent browser warnings for sending to URL with unknown protocol.
 534          $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, true, is_https());
 535  
 536          $buttonlink = html_writer::start_tag('div', array('class' => 'singlebutton'));
 537          $buttonlink .= html_writer::link($seblink, get_string('seblinkbutton', 'quizaccess_seb'),
 538              ['class' => 'btn btn-secondary', 'title' => get_string('seblinkbutton', 'quizaccess_seb')]);
 539          $buttonlink .= html_writer::end_tag('div');
 540  
 541          return $buttonlink;
 542      }
 543  
 544      /**
 545       * Get a button to download Safe Exam Browser config.
 546       *
 547       * @return string A link to launch Safe Exam Browser.
 548       */
 549      private function get_download_config_button() : string {
 550          // Rendering as a href and not as button in a form to circumvent browser warnings for sending to URL with unknown protocol.
 551          $httplink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, false, is_https());
 552  
 553          $buttonlink = html_writer::start_tag('div', array('class' => 'singlebutton'));
 554          $buttonlink .= html_writer::link($httplink, get_string('httplinkbutton', 'quizaccess_seb'),
 555              ['class' => 'btn btn-secondary', 'title' => get_string('httplinkbutton', 'quizaccess_seb')]);
 556          $buttonlink .= html_writer::end_tag('div');
 557  
 558          return $buttonlink;
 559      }
 560  
 561      /**
 562       * Returns SEB download URL.
 563       *
 564       * @return string
 565       */
 566      private function get_seb_download_url() : string {
 567          return get_config('quizaccess_seb', 'downloadlink');
 568      }
 569  
 570      /**
 571       * Check if we should display a link to download Safe Exam Browser.
 572       *
 573       * @return bool
 574       */
 575      private function should_display_download_seb_link() : bool {
 576          return !empty($this->quiz->seb_showsebdownloadlink);
 577      }
 578  
 579      /**
 580       * Redirect to SEB config link. This will force Safe Exam Browser to be reconfigured.
 581       */
 582      private function redirect_to_seb_config_link() {
 583          global $PAGE;
 584  
 585          $seblink = \quizaccess_seb\link_generator::get_link($this->quiz->cmid, true, is_https());
 586          $PAGE->requires->js_amd_inline("document.location.replace('" . $seblink . "')");
 587      }
 588  
 589      /**
 590       * Check if we need to redirect to SEB config link.
 591       * @return bool
 592       */
 593      private function should_redirect_to_seb_config_link() : bool {
 594          return $this->accessmanager->is_using_seb() && get_config('quizaccess_seb', 'autoreconfigureseb');
 595      }
 596  
 597  }