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 * Entity model representing quiz settings for the seb plugin. 19 * 20 * @package quizaccess_seb 21 * @author Andrew Madden <andrewmadden@catalyst-au.net> 22 * @copyright 2019 Catalyst IT 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 namespace quizaccess_seb; 27 28 use CFPropertyList\CFArray; 29 use CFPropertyList\CFBoolean; 30 use CFPropertyList\CFDictionary; 31 use CFPropertyList\CFNumber; 32 use CFPropertyList\CFString; 33 use core\persistent; 34 use lang_string; 35 use moodle_exception; 36 use moodle_url; 37 38 defined('MOODLE_INTERNAL') || die(); 39 40 /** 41 * Entity model representing quiz settings for the seb plugin. 42 * 43 * @copyright 2020 Catalyst IT 44 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 45 */ 46 class seb_quiz_settings extends persistent { 47 48 /** Table name for the persistent. */ 49 const TABLE = 'quizaccess_seb_quizsettings'; 50 51 /** @var property_list $plist The SEB config represented as a Property List object. */ 52 private $plist; 53 54 /** @var string $config The SEB config represented as a string. */ 55 private $config; 56 57 /** @var string $configkey The SEB config key represented as a string. */ 58 private $configkey; 59 60 61 /** 62 * Return the definition of the properties of this model. 63 * 64 * @return array 65 */ 66 protected static function define_properties() : array { 67 return [ 68 'quizid' => [ 69 'type' => PARAM_INT, 70 ], 71 'cmid' => [ 72 'type' => PARAM_INT, 73 ], 74 'templateid' => [ 75 'type' => PARAM_INT, 76 'default' => 0, 77 ], 78 'requiresafeexambrowser' => [ 79 'type' => PARAM_INT, 80 'default' => 0, 81 ], 82 'showsebtaskbar' => [ 83 'type' => PARAM_INT, 84 'default' => 1, 85 'null' => NULL_ALLOWED, 86 ], 87 'showwificontrol' => [ 88 'type' => PARAM_INT, 89 'default' => 0, 90 'null' => NULL_ALLOWED, 91 ], 92 'showreloadbutton' => [ 93 'type' => PARAM_INT, 94 'default' => 1, 95 'null' => NULL_ALLOWED, 96 ], 97 'showtime' => [ 98 'type' => PARAM_INT, 99 'default' => 1, 100 'null' => NULL_ALLOWED, 101 ], 102 'showkeyboardlayout' => [ 103 'type' => PARAM_INT, 104 'default' => 1, 105 'null' => NULL_ALLOWED, 106 ], 107 'allowuserquitseb' => [ 108 'type' => PARAM_INT, 109 'default' => 1, 110 'null' => NULL_ALLOWED, 111 ], 112 'quitpassword' => [ 113 'type' => PARAM_TEXT, 114 'default' => '', 115 'null' => NULL_ALLOWED, 116 ], 117 'linkquitseb' => [ 118 'type' => PARAM_URL, 119 'default' => '', 120 'null' => NULL_ALLOWED, 121 ], 122 'userconfirmquit' => [ 123 'type' => PARAM_INT, 124 'default' => 1, 125 'null' => NULL_ALLOWED, 126 ], 127 'enableaudiocontrol' => [ 128 'type' => PARAM_INT, 129 'default' => 0, 130 'null' => NULL_ALLOWED, 131 ], 132 'muteonstartup' => [ 133 'type' => PARAM_INT, 134 'default' => 0, 135 'null' => NULL_ALLOWED, 136 ], 137 'allowspellchecking' => [ 138 'type' => PARAM_INT, 139 'default' => 0, 140 'null' => NULL_ALLOWED, 141 ], 142 'allowreloadinexam' => [ 143 'type' => PARAM_INT, 144 'default' => 1, 145 'null' => NULL_ALLOWED, 146 ], 147 'activateurlfiltering' => [ 148 'type' => PARAM_INT, 149 'default' => 0, 150 'null' => NULL_ALLOWED, 151 ], 152 'filterembeddedcontent' => [ 153 'type' => PARAM_INT, 154 'default' => 0, 155 'null' => NULL_ALLOWED, 156 ], 157 'expressionsallowed' => [ 158 'type' => PARAM_TEXT, 159 'default' => '', 160 'null' => NULL_ALLOWED, 161 ], 162 'regexallowed' => [ 163 'type' => PARAM_TEXT, 164 'default' => '', 165 'null' => NULL_ALLOWED, 166 ], 167 'expressionsblocked' => [ 168 'type' => PARAM_TEXT, 169 'default' => '', 170 'null' => NULL_ALLOWED, 171 ], 172 'regexblocked' => [ 173 'type' => PARAM_TEXT, 174 'default' => '', 175 'null' => NULL_ALLOWED, 176 ], 177 'showsebdownloadlink' => [ 178 'type' => PARAM_INT, 179 'default' => 1, 180 'null' => NULL_ALLOWED, 181 ], 182 'allowedbrowserexamkeys' => [ 183 'type' => PARAM_TEXT, 184 'default' => '', 185 'null' => NULL_ALLOWED, 186 ], 187 ]; 188 } 189 190 /** 191 * Return an instance by quiz id. 192 * 193 * This method gets data from cache before doing any DB calls. 194 * 195 * @param int $quizid Quiz id. 196 * @return false|\quizaccess_seb\seb_quiz_settings 197 */ 198 public static function get_by_quiz_id(int $quizid) { 199 if ($data = self::get_quiz_settings_cache()->get($quizid)) { 200 return new static(0, $data); 201 } 202 203 return self::get_record(['quizid' => $quizid]); 204 } 205 206 /** 207 * Return cached SEB config represented as a string by quiz ID. 208 * 209 * @param int $quizid Quiz id. 210 * @return string|null 211 */ 212 public static function get_config_by_quiz_id(int $quizid) : ?string { 213 $config = self::get_config_cache()->get($quizid); 214 215 if ($config !== false) { 216 return $config; 217 } 218 219 $config = null; 220 if ($settings = self::get_by_quiz_id($quizid)) { 221 $config = $settings->get_config(); 222 self::get_config_cache()->set($quizid, $config); 223 } 224 225 return $config; 226 } 227 228 /** 229 * Return cached SEB config key by quiz ID. 230 * 231 * @param int $quizid Quiz id. 232 * @return string|null 233 */ 234 public static function get_config_key_by_quiz_id(int $quizid) : ?string { 235 $configkey = self::get_config_key_cache()->get($quizid); 236 237 if ($configkey !== false) { 238 return $configkey; 239 } 240 241 $configkey = null; 242 if ($settings = self::get_by_quiz_id($quizid)) { 243 $configkey = $settings->get_config_key(); 244 self::get_config_key_cache()->set($quizid, $configkey); 245 } 246 247 return $configkey; 248 } 249 250 /** 251 * Return SEB config key cache instance. 252 * 253 * @return \cache_application 254 */ 255 private static function get_config_key_cache() : \cache_application { 256 return \cache::make('quizaccess_seb', 'configkey'); 257 } 258 259 /** 260 * Return SEB config cache instance. 261 * 262 * @return \cache_application 263 */ 264 private static function get_config_cache() : \cache_application { 265 return \cache::make('quizaccess_seb', 'config'); 266 } 267 268 /** 269 * Return quiz settings cache object, 270 * 271 * @return \cache_application 272 */ 273 private static function get_quiz_settings_cache() : \cache_application { 274 return \cache::make('quizaccess_seb', 'quizsettings'); 275 } 276 277 /** 278 * Adds the new record to the cache. 279 */ 280 protected function after_create() { 281 $this->after_save(); 282 } 283 284 /** 285 * Updates the cache record. 286 * 287 * @param bool $result 288 */ 289 protected function after_update($result) { 290 $this->after_save(); 291 } 292 293 /** 294 * Helper method to execute common stuff after create and update. 295 */ 296 private function after_save() { 297 self::get_quiz_settings_cache()->set($this->get('quizid'), $this->to_record()); 298 self::get_config_cache()->set($this->get('quizid'), $this->config); 299 self::get_config_key_cache()->set($this->get('quizid'), $this->configkey); 300 } 301 302 /** 303 * Removes unnecessary stuff from db. 304 */ 305 protected function before_delete() { 306 $key = $this->get('quizid'); 307 self::get_quiz_settings_cache()->delete($key); 308 self::get_config_cache()->delete($key); 309 self::get_config_key_cache()->delete($key); 310 } 311 312 /** 313 * Validate the browser exam keys string. 314 * 315 * @param string $keys Newline separated browser exam keys. 316 * @return true|lang_string If there is an error, an error string is returned. 317 */ 318 protected function validate_allowedbrowserexamkeys($keys) { 319 $keys = $this->split_keys($keys); 320 foreach ($keys as $i => $key) { 321 if (!preg_match('~^[a-f0-9]{64}$~', $key)) { 322 return new lang_string('allowedbrowserkeyssyntax', 'quizaccess_seb'); 323 } 324 } 325 if (count($keys) != count(array_unique($keys))) { 326 return new lang_string('allowedbrowserkeysdistinct', 'quizaccess_seb'); 327 } 328 return true; 329 } 330 331 /** 332 * Get the browser exam keys as a pre-split array instead of just as a string. 333 * 334 * @return array 335 */ 336 protected function get_allowedbrowserexamkeys() : array { 337 $keysstring = $this->raw_get('allowedbrowserexamkeys'); 338 $keysstring = empty($keysstring) ? '' : $keysstring; 339 return $this->split_keys($keysstring); 340 } 341 342 /** 343 * Hook to execute before an update. 344 * 345 * Please note that at this stage the data has already been validated and therefore 346 * any new data being set will not be validated before it is sent to the database. 347 */ 348 protected function before_update() { 349 $this->before_save(); 350 } 351 352 /** 353 * Hook to execute before a create. 354 * 355 * Please note that at this stage the data has already been validated and therefore 356 * any new data being set will not be validated before it is sent to the database. 357 */ 358 protected function before_create() { 359 $this->before_save(); 360 } 361 362 /** 363 * As there is no hook for before both create and update, this function is called by both hooks. 364 */ 365 private function before_save() { 366 // Set template to 0 if using anything different to template. 367 if ($this->get('requiresafeexambrowser') != settings_provider::USE_SEB_TEMPLATE) { 368 $this->set('templateid', 0); 369 } 370 371 // Process configs to make sure that all data is set correctly. 372 $this->process_configs(); 373 } 374 375 /** 376 * Before validate hook. 377 */ 378 protected function before_validate() { 379 // Template can't be null. 380 if (is_null($this->raw_get('templateid'))) { 381 $this->set('templateid', 0); 382 } 383 } 384 385 /** 386 * Create or update the config string based on the current quiz settings. 387 */ 388 private function process_configs() { 389 switch ($this->get('requiresafeexambrowser')) { 390 case settings_provider::USE_SEB_NO: 391 $this->process_seb_config_no(); 392 break; 393 394 case settings_provider::USE_SEB_CONFIG_MANUALLY: 395 $this->process_seb_config_manually(); 396 break; 397 398 case settings_provider::USE_SEB_TEMPLATE: 399 $this->process_seb_template(); 400 break; 401 402 case settings_provider::USE_SEB_UPLOAD_CONFIG: 403 $this->process_seb_upload_config(); 404 break; 405 406 default: // Also settings_provider::USE_SEB_CLIENT_CONFIG. 407 $this->process_seb_client_config(); 408 } 409 410 // Generate config key based on given SEB config. 411 if (!empty($this->config)) { 412 $this->configkey = config_key::generate($this->config)->get_hash(); 413 } else { 414 $this->configkey = null; 415 } 416 } 417 418 /** 419 * Return SEB config key. 420 * 421 * @return string|null 422 */ 423 public function get_config_key() : ?string { 424 $this->process_configs(); 425 426 return $this->configkey; 427 } 428 429 /** 430 * Return string representation of the config. 431 * 432 * @return string|null 433 */ 434 public function get_config() : ?string { 435 $this->process_configs(); 436 437 return $this->config; 438 } 439 440 /** 441 * Case for USE_SEB_NO. 442 */ 443 private function process_seb_config_no() { 444 $this->config = null; 445 } 446 447 /** 448 * Case for USE_SEB_CONFIG_MANUALLY. This creates a plist and applies all settings from the posted form, along with 449 * some defaults. 450 */ 451 private function process_seb_config_manually() { 452 // If at any point a configuration file has been uploaded and parsed, clear the settings. 453 $this->plist = new property_list(); 454 455 $this->process_bool_settings(); 456 $this->process_quit_password_settings(); 457 $this->process_quit_url_from_settings(); 458 $this->process_url_filters(); 459 $this->process_required_enforced_settings(); 460 461 // One of the requirements for USE_SEB_CONFIG_MANUALLY is setting examSessionClearCookiesOnStart to false. 462 $this->plist->set_or_update_value('examSessionClearCookiesOnStart', new CFBoolean(false)); 463 $this->plist->set_or_update_value('allowPreferencesWindow', new CFBoolean(false)); 464 $this->config = $this->plist->to_xml(); 465 } 466 467 /** 468 * Case for USE_SEB_TEMPLATE. This creates a plist from the template uploaded, then applies the quit password 469 * setting and some defaults. 470 */ 471 private function process_seb_template() { 472 $template = template::get_record(['id' => $this->get('templateid')]); 473 $this->plist = new property_list($template->get('content')); 474 475 $this->process_bool_setting('allowuserquitseb'); 476 $this->process_quit_password_settings(); 477 $this->process_quit_url_from_template_or_config(); 478 $this->process_required_enforced_settings(); 479 480 $this->config = $this->plist->to_xml(); 481 } 482 483 /** 484 * Case for USE_SEB_UPLOAD_CONFIG. This creates a plist from an uploaded configuration file, then applies the quiz 485 * password settings and some defaults. 486 */ 487 private function process_seb_upload_config() { 488 $file = settings_provider::get_module_context_sebconfig_file($this->get('cmid')); 489 490 // If there was no file, create an empty plist so the rest of this wont explode. 491 if (empty($file)) { 492 throw new moodle_exception('noconfigfilefound', 'quizaccess_seb', '', $this->get('cmid')); 493 } else { 494 $this->plist = new property_list($file->get_content()); 495 } 496 497 $this->process_quit_url_from_template_or_config(); 498 $this->process_required_enforced_settings(); 499 500 $this->config = $this->plist->to_xml(); 501 } 502 503 /** 504 * Case for USE_SEB_CLIENT_CONFIG. This creates an empty plist to remove the config stored. 505 */ 506 private function process_seb_client_config() { 507 $this->config = null; 508 } 509 510 /** 511 * Sets or updates some sensible default settings, these are the items 'startURL' and 'sendBrowserExamKey'. 512 */ 513 private function process_required_enforced_settings() { 514 global $CFG; 515 516 $quizurl = new moodle_url($CFG->wwwroot . "/mod/quiz/view.php", ['id' => $this->get('cmid')]); 517 $this->plist->set_or_update_value('startURL', new CFString($quizurl->out(true))); 518 $this->plist->set_or_update_value('sendBrowserExamKey', new CFBoolean(true)); 519 520 // Use the modern WebView and JS API if the SEB version supports it. 521 // Documentation: https://safeexambrowser.org/developer/seb-config-key.html . 522 // "Set the key browserWindowWebView to the policy "Prefer Modern" (value 3)". 523 $this->plist->set_or_update_value('browserWindowWebView', new CFNumber(3)); 524 } 525 526 /** 527 * Use the boolean map to add Moodle boolean setting to config PList. 528 */ 529 private function process_bool_settings() { 530 $settings = $this->to_record(); 531 $map = $this->get_bool_seb_setting_map(); 532 foreach ($settings as $setting => $value) { 533 if (isset($map[$setting])) { 534 $this->process_bool_setting($setting); 535 } 536 } 537 } 538 539 /** 540 * Process provided single bool setting. 541 * 542 * @param string $name Setting name matching one from self::get_bool_seb_setting_map. 543 */ 544 private function process_bool_setting(string $name) { 545 $map = $this->get_bool_seb_setting_map(); 546 547 if (!isset($map[$name])) { 548 throw new \coding_exception('Provided setting name can not be found in known bool settings'); 549 } 550 551 $enabled = $this->raw_get($name) == 1 ? true : false; 552 $this->plist->set_or_update_value($map[$name], new CFBoolean($enabled)); 553 } 554 555 /** 556 * Turn hashed quit password and quit link into PList strings and add to config PList. 557 */ 558 private function process_quit_password_settings() { 559 $settings = $this->to_record(); 560 if (!empty($settings->quitpassword) && is_string($settings->quitpassword)) { 561 // Hash quit password. 562 $hashedpassword = hash('SHA256', $settings->quitpassword); 563 $this->plist->add_element_to_root('hashedQuitPassword', new CFString($hashedpassword)); 564 } else if (!is_null($this->plist->get_element_value('hashedQuitPassword'))) { 565 $this->plist->delete_element('hashedQuitPassword'); 566 } 567 } 568 569 /** 570 * Sets the quitURL if found in the seb_quiz_settings. 571 */ 572 private function process_quit_url_from_settings() { 573 $settings = $this->to_record(); 574 if (!empty($settings->linkquitseb) && is_string($settings->linkquitseb)) { 575 $this->plist->set_or_update_value('quitURL', new CFString($settings->linkquitseb)); 576 } 577 } 578 579 /** 580 * Sets the quiz_setting's linkquitseb if a quitURL value was found in a template or uploaded config. 581 */ 582 private function process_quit_url_from_template_or_config() { 583 // Does the plist (template or config file) have an existing quitURL? 584 $quiturl = $this->plist->get_element_value('quitURL'); 585 if (!empty($quiturl)) { 586 $this->set('linkquitseb', $quiturl); 587 } 588 } 589 590 /** 591 * Turn return separated strings for URL filters into a PList array and add to config PList. 592 */ 593 private function process_url_filters() { 594 $settings = $this->to_record(); 595 // Create rules to each expression provided and add to config. 596 $urlfilterrules = []; 597 // Get all rules separated by newlines and remove empty rules. 598 $expallowed = array_filter(explode(PHP_EOL, $settings->expressionsallowed)); 599 $expblocked = array_filter(explode(PHP_EOL, $settings->expressionsblocked)); 600 $regallowed = array_filter(explode(PHP_EOL, $settings->regexallowed)); 601 $regblocked = array_filter(explode(PHP_EOL, $settings->regexblocked)); 602 foreach ($expallowed as $rulestring) { 603 $urlfilterrules[] = $this->create_filter_rule($rulestring, true, false); 604 } 605 foreach ($expblocked as $rulestring) { 606 $urlfilterrules[] = $this->create_filter_rule($rulestring, false, false); 607 } 608 foreach ($regallowed as $rulestring) { 609 $urlfilterrules[] = $this->create_filter_rule($rulestring, true, true); 610 } 611 foreach ($regblocked as $rulestring) { 612 $urlfilterrules[] = $this->create_filter_rule($rulestring, false, true); 613 } 614 $this->plist->add_element_to_root('URLFilterRules', new CFArray($urlfilterrules)); 615 } 616 617 /** 618 * Create a CFDictionary represeting a URL filter rule. 619 * 620 * @param string $rulestring The expression to filter with. 621 * @param bool $allowed Allowed or blocked. 622 * @param bool $isregex Regex or simple. 623 * @return CFDictionary A PList dictionary. 624 */ 625 private function create_filter_rule(string $rulestring, bool $allowed, bool $isregex) : CFDictionary { 626 $action = $allowed ? 1 : 0; 627 return new CFDictionary([ 628 'action' => new CFNumber($action), 629 'active' => new CFBoolean(true), 630 'expression' => new CFString(trim($rulestring)), 631 'regex' => new CFBoolean($isregex), 632 ]); 633 } 634 635 /** 636 * Map the settings that are booleans to the Safe Exam Browser config keys. 637 * 638 * @return array Moodle setting as key, SEB setting as value. 639 */ 640 private function get_bool_seb_setting_map() : array { 641 return [ 642 'activateurlfiltering' => 'URLFilterEnable', 643 'allowspellchecking' => 'allowSpellCheck', 644 'allowreloadinexam' => 'browserWindowAllowReload', 645 'allowuserquitseb' => 'allowQuit', 646 'enableaudiocontrol' => 'audioControlEnabled', 647 'filterembeddedcontent' => 'URLFilterEnableContentFilter', 648 'muteonstartup' => 'audioMute', 649 'showkeyboardlayout' => 'showInputLanguage', 650 'showreloadbutton' => 'showReloadButton', 651 'showsebtaskbar' => 'showTaskBar', 652 'showtime' => 'showTime', 653 'showwificontrol' => 'allowWlan', 654 'userconfirmquit' => 'quitURLConfirm', 655 ]; 656 } 657 658 /** 659 * This helper method takes list of browser exam keys in a string and splits it into an array of separate keys. 660 * 661 * @param string|null $keys the allowed keys. 662 * @return array of string, the separate keys. 663 */ 664 private function split_keys($keys) : array { 665 $keys = preg_split('~[ \t\n\r,;]+~', $keys ?? '', -1, PREG_SPLIT_NO_EMPTY); 666 foreach ($keys as $i => $key) { 667 $keys[$i] = strtolower($key); 668 } 669 return $keys; 670 } 671 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body