See Release Notes
Long Term Support Release
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body