Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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

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