Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   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  }