Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
}