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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body