Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
 
3
// This file is part of Moodle - http://moodle.org/
4
//
5
// Moodle is free software: you can redistribute it and/or modify
6
// it under the terms of the GNU General Public License as published by
7
// the Free Software Foundation, either version 3 of the License, or
8
// (at your option) any later version.
9
//
10
// Moodle is distributed in the hope that it will be useful,
11
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
// GNU General Public License for more details.
14
//
15
// You should have received a copy of the GNU General Public License
16
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17
 
18
/**
19
 * @package    moodlecore
20
 * @subpackage backup-dbops
21
 * @copyright  2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
/**
26
 * Base abstract class for all the helper classes providing DB operations
27
 *
28
 * TODO: Finish phpdocs
29
 */
30
abstract class restore_dbops {
31
    /**
32
     * Keep cache of backup records.
33
     * @var array
34
     * @todo MDL-25290 static should be replaced with MUC code.
35
     */
36
    private static $backupidscache = array();
37
    /**
38
     * Keep track of backup ids which are cached.
39
     * @var array
40
     * @todo MDL-25290 static should be replaced with MUC code.
41
     */
42
    private static $backupidsexist = array();
43
    /**
44
     * Count is expensive, so manually keeping track of
45
     * backupidscache, to avoid memory issues.
46
     * @var int
47
     * @todo MDL-25290 static should be replaced with MUC code.
48
     */
49
    private static $backupidscachesize = 2048;
50
    /**
51
     * Count is expensive, so manually keeping track of
52
     * backupidsexist, to avoid memory issues.
53
     * @var int
54
     * @todo MDL-25290 static should be replaced with MUC code.
55
     */
56
    private static $backupidsexistsize = 10240;
57
    /**
58
     * Slice backupids cache to add more data.
59
     * @var int
60
     * @todo MDL-25290 static should be replaced with MUC code.
61
     */
62
    private static $backupidsslice = 512;
63
 
64
    /**
65
     * Return one array containing all the tasks that have been included
66
     * in the restore process. Note that these tasks aren't built (they
67
     * haven't steps nor ids data available)
68
     */
69
    public static function get_included_tasks($restoreid) {
70
        $rc = restore_controller_dbops::load_controller($restoreid);
71
        $tasks = $rc->get_plan()->get_tasks();
72
        $includedtasks = array();
73
        foreach ($tasks as $key => $task) {
74
            // Calculate if the task is being included
75
            $included = false;
76
            // blocks, based in blocks setting and parent activity/course
77
            if ($task instanceof restore_block_task) {
78
                if (!$task->get_setting_value('blocks')) { // Blocks not included, continue
79
                    continue;
80
                }
81
                $parent = basename(dirname(dirname($task->get_taskbasepath())));
82
                if ($parent == 'course') { // Parent is course, always included if present
83
                    $included = true;
84
 
85
                } else { // Look for activity_included setting
86
                    $included = $task->get_setting_value($parent . '_included');
87
                }
88
 
89
            // ativities, based on included setting
90
            } else if ($task instanceof restore_activity_task) {
91
                $included = $task->get_setting_value('included');
92
 
93
            // sections, based on included setting
94
            } else if ($task instanceof restore_section_task) {
95
                $included = $task->get_setting_value('included');
96
 
97
            // course always included if present
98
            } else if ($task instanceof restore_course_task) {
99
                $included = true;
100
            }
101
 
102
            // If included, add it
103
            if ($included) {
104
                $includedtasks[] = clone($task); // A clone is enough. In fact we only need the basepath.
105
            }
106
        }
107
        $rc->destroy(); // Always need to destroy.
108
 
109
        return $includedtasks;
110
    }
111
 
112
    /**
113
     * Load one inforef.xml file to backup_ids table for future reference
114
     *
115
     * @param string $restoreid Restore id
116
     * @param string $inforeffile File path
117
     * @param \core\progress\base $progress Progress tracker
118
     */
119
    public static function load_inforef_to_tempids($restoreid, $inforeffile,
1441 ariadna 120
            ?\core\progress\base $progress = null) {
1 efrain 121
 
122
        if (!file_exists($inforeffile)) { // Shouldn't happen ever, but...
123
            throw new backup_helper_exception('missing_inforef_xml_file', $inforeffile);
124
        }
125
 
126
        // Set up progress tracking (indeterminate).
127
        if (!$progress) {
128
            $progress = new \core\progress\none();
129
        }
130
        $progress->start_progress('Loading inforef.xml file');
131
 
132
        // Let's parse, custom processor will do its work, sending info to DB
133
        $xmlparser = new progressive_parser();
134
        $xmlparser->set_file($inforeffile);
135
        $xmlprocessor = new restore_inforef_parser_processor($restoreid);
136
        $xmlparser->set_processor($xmlprocessor);
137
        $xmlparser->set_progress($progress);
138
        $xmlparser->process();
139
 
140
        // Finish progress
141
        $progress->end_progress();
142
    }
143
 
144
    /**
145
     * Load the needed role.xml file to backup_ids table for future reference
146
     */
147
    public static function load_roles_to_tempids($restoreid, $rolesfile) {
148
 
149
        if (!file_exists($rolesfile)) { // Shouldn't happen ever, but...
150
            throw new backup_helper_exception('missing_roles_xml_file', $rolesfile);
151
        }
152
        // Let's parse, custom processor will do its work, sending info to DB
153
        $xmlparser = new progressive_parser();
154
        $xmlparser->set_file($rolesfile);
155
        $xmlprocessor = new restore_roles_parser_processor($restoreid);
156
        $xmlparser->set_processor($xmlprocessor);
157
        $xmlparser->process();
158
    }
159
 
160
    /**
161
     * Precheck the loaded roles, return empty array if everything is ok, and
162
     * array with 'errors', 'warnings' elements (suitable to be used by restore_prechecks)
163
     * with any problem found. At the same time, store all the mapping into backup_ids_temp
164
     * and also put the information into $rolemappings (controller->info), so it can be reworked later by
165
     * post-precheck stages while at the same time accept modified info in the same object coming from UI
166
     */
167
    public static function precheck_included_roles($restoreid, $courseid, $userid, $samesite, $rolemappings) {
168
        global $DB;
169
 
170
        $problems = array(); // To store warnings/errors
171
 
172
        // Get loaded roles from backup_ids
173
        $rs = $DB->get_recordset('backup_ids_temp', array('backupid' => $restoreid, 'itemname' => 'role'), '', 'itemid, info');
174
        foreach ($rs as $recrole) {
175
            // If the rolemappings->modified flag is set, that means that we are coming from
176
            // manually modified mappings (by UI), so accept those mappings an put them to backup_ids
177
            if ($rolemappings->modified) {
178
                $target = $rolemappings->mappings[$recrole->itemid]->targetroleid;
179
                self::set_backup_ids_record($restoreid, 'role', $recrole->itemid, $target);
180
 
181
            // Else, we haven't any info coming from UI, let's calculate the mappings, matching
182
            // in multiple ways and checking permissions. Note mapping to 0 means "skip"
183
            } else {
184
                $role = (object)backup_controller_dbops::decode_backup_temp_info($recrole->info);
185
                $match = self::get_best_assignable_role($role, $courseid, $userid, $samesite);
186
                // Send match to backup_ids
187
                self::set_backup_ids_record($restoreid, 'role', $recrole->itemid, $match);
188
                // Build the rolemappings element for controller
189
                unset($role->id);
190
                unset($role->nameincourse);
191
                $role->targetroleid = $match;
192
                $rolemappings->mappings[$recrole->itemid] = $role;
193
                // Prepare warning if no match found
194
                if (!$match) {
195
                    $problems['warnings'][] = get_string('cannotfindassignablerole', 'backup', $role->name);
196
                }
197
            }
198
        }
199
        $rs->close();
200
        return $problems;
201
    }
202
 
203
    /**
204
     * Return cached backup id's
205
     *
206
     * @param int $restoreid id of backup
207
     * @param string $itemname name of the item
208
     * @param int $itemid id of item
209
     * @return stdClass|false record from 'backup_ids_temp' table
210
     * @todo MDL-25290 replace static backupids* with MUC code
211
     */
212
    protected static function get_backup_ids_cached($restoreid, $itemname, $itemid) {
213
        global $DB;
214
 
215
        $key = "$itemid $itemname $restoreid";
216
 
217
        // If record exists in cache then return.
218
        if (isset(self::$backupidsexist[$key]) && isset(self::$backupidscache[$key])) {
219
            // Return a copy of cached data, to avoid any alterations in cached data.
220
            return clone self::$backupidscache[$key];
221
        }
222
 
223
        // Clean cache, if it's full.
224
        if (self::$backupidscachesize <= 0) {
225
            // Remove some records, to keep memory in limit.
226
            self::$backupidscache = array_slice(self::$backupidscache, self::$backupidsslice, null, true);
227
            self::$backupidscachesize = self::$backupidscachesize + self::$backupidsslice;
228
        }
229
        if (self::$backupidsexistsize <= 0) {
230
            self::$backupidsexist = array_slice(self::$backupidsexist, self::$backupidsslice, null, true);
231
            self::$backupidsexistsize = self::$backupidsexistsize + self::$backupidsslice;
232
        }
233
 
234
        // Retrive record from database.
235
        $record = array(
236
            'backupid' => $restoreid,
237
            'itemname' => $itemname,
238
            'itemid'   => $itemid
239
        );
240
        if ($dbrec = $DB->get_record('backup_ids_temp', $record)) {
241
            self::$backupidsexist[$key] = $dbrec->id;
242
            self::$backupidscache[$key] = $dbrec;
243
            self::$backupidscachesize--;
244
            self::$backupidsexistsize--;
245
            return $dbrec;
246
        } else {
247
            return false;
248
        }
249
    }
250
 
251
    /**
252
     * Cache backup ids'
253
     *
254
     * @param int $restoreid id of backup
255
     * @param string $itemname name of the item
256
     * @param int $itemid id of item
257
     * @param array $extrarecord extra record which needs to be updated
258
     * @return void
259
     * @todo MDL-25290 replace static BACKUP_IDS_* with MUC code
260
     */
261
    protected static function set_backup_ids_cached($restoreid, $itemname, $itemid, $extrarecord) {
262
        global $DB;
263
 
264
        $key = "$itemid $itemname $restoreid";
265
 
266
        $record = array(
267
            'backupid' => $restoreid,
268
            'itemname' => $itemname,
269
            'itemid'   => $itemid,
270
        );
271
 
272
        // If record is not cached then add one.
273
        if (!isset(self::$backupidsexist[$key])) {
274
            // If we have this record in db, then just update this.
275
            if ($existingrecord = $DB->get_record('backup_ids_temp', $record)) {
276
                self::$backupidsexist[$key] = $existingrecord->id;
277
                self::$backupidsexistsize--;
278
                self::update_backup_cached_record($record, $extrarecord, $key, $existingrecord);
279
            } else {
280
                // Add new record to cache and db.
281
                $recorddefault = array (
282
                    'newitemid' => 0,
283
                    'parentitemid' => null,
284
                    'info' => null);
285
                $record = array_merge($record, $recorddefault, $extrarecord);
286
                $record['id'] = $DB->insert_record('backup_ids_temp', $record);
287
                self::$backupidsexist[$key] = $record['id'];
288
                self::$backupidsexistsize--;
289
                if (self::$backupidscachesize > 0) {
290
                    // Cache new records if we haven't got many yet.
291
                    self::$backupidscache[$key] = (object) $record;
292
                    self::$backupidscachesize--;
293
                }
294
            }
295
        } else {
296
            self::update_backup_cached_record($record, $extrarecord, $key);
297
        }
298
    }
299
 
300
    /**
301
     * Updates existing backup record
302
     *
303
     * @param array $record record which needs to be updated
304
     * @param array $extrarecord extra record which needs to be updated
305
     * @param string $key unique key which is used to identify cached record
306
     * @param stdClass $existingrecord (optional) existing record
307
     */
308
    protected static function update_backup_cached_record($record, $extrarecord, $key, $existingrecord = null) {
309
        global $DB;
310
        // Update only if extrarecord is not empty.
311
        if (!empty($extrarecord)) {
312
            $extrarecord['id'] = self::$backupidsexist[$key];
313
            $DB->update_record('backup_ids_temp', $extrarecord);
314
            // Update existing cache or add new record to cache.
315
            if (isset(self::$backupidscache[$key])) {
316
                $record = array_merge((array)self::$backupidscache[$key], $extrarecord);
317
                self::$backupidscache[$key] = (object) $record;
318
            } else if (self::$backupidscachesize > 0) {
319
                if ($existingrecord) {
320
                    self::$backupidscache[$key] = $existingrecord;
321
                } else {
322
                    // Retrive record from database and cache updated records.
323
                    self::$backupidscache[$key] = $DB->get_record('backup_ids_temp', $record);
324
                }
325
                $record = array_merge((array)self::$backupidscache[$key], $extrarecord);
326
                self::$backupidscache[$key] = (object) $record;
327
                self::$backupidscachesize--;
328
            }
329
        }
330
    }
331
 
332
    /**
333
     * Reset the ids caches completely
334
     *
335
     * Any destructive operation (partial delete, truncate, drop or recreate) performed
336
     * with the backup_ids table must cause the backup_ids caches to be
337
     * invalidated by calling this method. See MDL-33630.
338
     *
339
     * Note that right now, the only operation of that type is the recreation
340
     * (drop & restore) of the table that may happen once the prechecks have ended. All
341
     * the rest of operations are always routed via {@link set_backup_ids_record()}, 1 by 1,
342
     * keeping the caches on sync.
343
     *
344
     * @todo MDL-25290 static should be replaced with MUC code.
345
     */
346
    public static function reset_backup_ids_cached() {
347
        // Reset the ids cache.
348
        $cachetoadd = count(self::$backupidscache);
349
        self::$backupidscache = array();
350
        self::$backupidscachesize = self::$backupidscachesize + $cachetoadd;
351
        // Reset the exists cache.
352
        $existstoadd = count(self::$backupidsexist);
353
        self::$backupidsexist = array();
354
        self::$backupidsexistsize = self::$backupidsexistsize + $existstoadd;
355
    }
356
 
357
    /**
358
     * Given one role, as loaded from XML, perform the best possible matching against the assignable
359
     * roles, using different fallback alternatives (shortname, archetype, editingteacher => teacher, defaultcourseroleid)
360
     * returning the id of the best matching role or 0 if no match is found
361
     */
362
    protected static function get_best_assignable_role($role, $courseid, $userid, $samesite) {
363
        global $CFG, $DB;
364
 
365
        // Gather various information about roles
366
        $coursectx = context_course::instance($courseid);
367
        $assignablerolesshortname = get_assignable_roles($coursectx, ROLENAME_SHORT, false, $userid);
368
 
369
        // Note: under 1.9 we had one function restore_samerole() that performed one complete
370
        // matching of roles (all caps) and if match was found the mapping was availabe bypassing
371
        // any assignable_roles() security. IMO that was wrong and we must not allow such
372
        // mappings anymore. So we have left that matching strategy out in 2.0
373
 
374
        // Empty assignable roles, mean no match possible
375
        if (empty($assignablerolesshortname)) {
376
            return 0;
377
        }
378
 
379
        // Match by shortname
380
        if ($match = array_search($role->shortname, $assignablerolesshortname)) {
381
            return $match;
382
        }
383
 
384
        // Match by archetype
385
        list($in_sql, $in_params) = $DB->get_in_or_equal(array_keys($assignablerolesshortname));
386
        $params = array_merge(array($role->archetype), $in_params);
387
        if ($rec = $DB->get_record_select('role', "archetype = ? AND id $in_sql", $params, 'id', IGNORE_MULTIPLE)) {
388
            return $rec->id;
389
        }
390
 
391
        // Match editingteacher to teacher (happens a lot, from 1.9)
392
        if ($role->shortname == 'editingteacher' && in_array('teacher', $assignablerolesshortname)) {
393
            return array_search('teacher', $assignablerolesshortname);
394
        }
395
 
396
        // No match, return 0
397
        return 0;
398
    }
399
 
400
 
401
    /**
402
     * Process the loaded roles, looking for their best mapping or skipping
403
     * Any error will cause exception. Note this is one wrapper over
404
     * precheck_included_roles, that contains all the logic, but returns
405
     * errors/warnings instead and is executed as part of the restore prechecks
406
     */
407
     public static function process_included_roles($restoreid, $courseid, $userid, $samesite, $rolemappings) {
408
        global $DB;
409
 
410
        // Just let precheck_included_roles() to do all the hard work
411
        $problems = self::precheck_included_roles($restoreid, $courseid, $userid, $samesite, $rolemappings);
412
 
413
        // With problems of type error, throw exception, shouldn't happen if prechecks executed
414
        if (array_key_exists('errors', $problems)) {
415
            throw new restore_dbops_exception('restore_problems_processing_roles', null, implode(', ', $problems['errors']));
416
        }
417
    }
418
 
419
    /**
420
     * Load the needed users.xml file to backup_ids table for future reference
421
     *
422
     * @param string $restoreid Restore id
423
     * @param string $usersfile File path
424
     * @param \core\progress\base $progress Progress tracker
425
     */
426
    public static function load_users_to_tempids($restoreid, $usersfile,
1441 ariadna 427
            ?\core\progress\base $progress = null) {
1 efrain 428
 
429
        if (!file_exists($usersfile)) { // Shouldn't happen ever, but...
430
            throw new backup_helper_exception('missing_users_xml_file', $usersfile);
431
        }
432
 
433
        // Set up progress tracking (indeterminate).
434
        if (!$progress) {
435
            $progress = new \core\progress\none();
436
        }
437
        $progress->start_progress('Loading users into temporary table');
438
 
439
        // Let's parse, custom processor will do its work, sending info to DB
440
        $xmlparser = new progressive_parser();
441
        $xmlparser->set_file($usersfile);
442
        $xmlprocessor = new restore_users_parser_processor($restoreid);
443
        $xmlparser->set_processor($xmlprocessor);
444
        $xmlparser->set_progress($progress);
445
        $xmlparser->process();
446
 
447
        // Finish progress.
448
        $progress->end_progress();
449
    }
450
 
451
    /**
452
     * Load the needed questions.xml file to backup_ids table for future reference
453
     */
454
    public static function load_categories_and_questions_to_tempids($restoreid, $questionsfile) {
455
 
456
        if (!file_exists($questionsfile)) { // Shouldn't happen ever, but...
457
            throw new backup_helper_exception('missing_questions_xml_file', $questionsfile);
458
        }
459
        // Let's parse, custom processor will do its work, sending info to DB
460
        $xmlparser = new progressive_parser();
461
        $xmlparser->set_file($questionsfile);
462
        $xmlprocessor = new restore_questions_parser_processor($restoreid);
463
        $xmlparser->set_processor($xmlprocessor);
464
        $xmlparser->process();
465
    }
466
 
467
    /**
468
     * Check all the included categories and questions, deciding the action to perform
469
     * for each one (mapping / creation) and returning one array of problems in case
470
     * something is wrong.
471
     *
472
     * There are some basic rules that the method below will always try to enforce:
473
     *
474
     * Rule1: Targets will be, always, calculated for *whole* question banks (a.k.a. contexid source),
475
     *     so, given 2 question categories belonging to the same bank, their target bank will be
476
     *     always the same. If not, we can be incurring into "fragmentation", leading to random/cloze
477
     *     problems (qtypes having "child" questions).
478
     *
479
     * Rule2: The 'moodle/question:managecategory' and 'moodle/question:add' capabilities will be
480
     *     checked before creating any category/question respectively and, if the cap is not allowed
481
     *     into upper contexts (system, coursecat)) but in lower ones (course), the *whole* question bank
482
     *     will be created there.
483
     *
484
     * Rule3: Coursecat question banks not existing in the target site will be created as course
485
     *     (lower ctx) question banks, never as "guessed" coursecat question banks base on depth or so.
486
     *
487
     * Rule4: System question banks will be created at system context if user has perms to do so. Else they
488
     *     will created as course (lower ctx) question banks (similary to rule3). In other words, course ctx
489
     *     if always a fallback for system and coursecat question banks.
490
     *
491
     * Also, there are some notes to clarify the scope of this method:
492
     *
493
     * Note1: This method won't create any question category nor question at all. It simply will calculate
494
     *     which actions (create/map) must be performed for each element and where, validating that all those
495
     *     actions are doable by the user executing the restore operation. Any problem found will be
496
     *     returned in the problems array, causing the restore process to stop with error.
497
     *
498
     * Note2: To decide if one question bank (all its question categories and questions) is going to be remapped,
499
     *     then all the categories and questions must exist in the same target bank. If able to do so, missing
500
     *     qcats and qs will be created (rule2). But if, at the end, something is missing, the whole question bank
501
     *     will be recreated at course ctx (rule1), no matter if that duplicates some categories/questions.
502
     *
503
     * Note3: We'll be using the newitemid column in the temp_ids table to store the action to be performed
504
     *     with each question category and question. newitemid = 0 means the qcat/q needs to be created and
505
     *     any other value means the qcat/q is mapped. Also, for qcats, parentitemid will contain the target
506
     *     context where the categories have to be created (but for module contexts where we'll keep the old
507
     *     one until the activity is created)
508
     *
509
     * Note4: All these "actions" will be "executed" later by {@link restore_create_categories_and_questions}
510
     */
511
    public static function precheck_categories_and_questions($restoreid, $courseid, $userid, $samesite) {
512
 
513
        $problems = array();
514
 
515
        // TODO: Check all qs, looking their qtypes are restorable
516
 
517
        // Precheck all qcats and qs looking for target contexts / warnings / errors
518
        list($syserr, $syswarn) = self::prechek_precheck_qbanks_by_level($restoreid, $courseid, $userid, $samesite, CONTEXT_SYSTEM);
519
        list($caterr, $catwarn) = self::prechek_precheck_qbanks_by_level($restoreid, $courseid, $userid, $samesite, CONTEXT_COURSECAT);
520
        list($couerr, $couwarn) = self::prechek_precheck_qbanks_by_level($restoreid, $courseid, $userid, $samesite, CONTEXT_COURSE);
521
        list($moderr, $modwarn) = self::prechek_precheck_qbanks_by_level($restoreid, $courseid, $userid, $samesite, CONTEXT_MODULE);
522
 
523
        // Acummulate and handle errors and warnings
524
        $errors   = array_merge($syserr, $caterr, $couerr, $moderr);
525
        $warnings = array_merge($syswarn, $catwarn, $couwarn, $modwarn);
526
        if (!empty($errors)) {
527
            $problems['errors'] = $errors;
528
        }
529
        if (!empty($warnings)) {
530
            $problems['warnings'] = $warnings;
531
        }
532
        return $problems;
533
    }
534
 
535
    /**
536
     * This function will process all the question banks present in restore
537
     * at some contextlevel (from CONTEXT_SYSTEM to CONTEXT_MODULE), finding
538
     * the target contexts where each bank will be restored and returning
539
     * warnings/errors as needed.
540
     *
1441 ariadna 541
     * Question categories at CONTEXT_SYSTEM, CONTEXT_COURSE, and CONTEXT_COURSECAT
542
     * are now deprecated, but we still have to account for them in backup files
543
     * made with pre-deprecated code. As such, any categories in backup files that used
544
     * to target these contexts will now be attached to a 'fallback' qbank
545
     * instance on the course being restored.
1 efrain 546
     *
547
     * At the end, if no errors were found, all the categories in backup_temp_ids
548
     * will be pointing (parentitemid) to the target context where they must be
549
     * created later in the restore process.
550
     *
551
     * Note: at the time these prechecks are executed, activities haven't been
552
     * created yet so, for CONTEXT_MODULE banks, we keep the old contextid
553
     * in the parentitemid field. Once the activity (and its context) has been
554
     * created, we'll update that context in the required qcats
555
     *
556
     * Caller {@link precheck_categories_and_questions} will, simply, execute
557
     * this function for all the contextlevels, acting as a simple controller
558
     * of warnings and errors.
559
     *
560
     * The function returns 2 arrays, one containing errors and another containing
561
     * warnings. Both empty if no errors/warnings are found.
562
     *
563
     * @param int $restoreid The restore ID
564
     * @param int $courseid The ID of the course
565
     * @param int $userid The id of the user doing the restore
566
     * @param bool $samesite True if restore is to same site
567
     * @param int $contextlevel (CONTEXT_SYSTEM, etc.)
568
     * @return array A separate list of all error and warnings detected
569
     */
570
    public static function prechek_precheck_qbanks_by_level($restoreid, $courseid, $userid, $samesite, $contextlevel) {
1441 ariadna 571
        global $DB, $CFG;
1 efrain 572
 
573
        // To return any errors and warnings found
1441 ariadna 574
        $errors = [];
575
        $warnings = [];
1 efrain 576
 
577
        /** @var restore_controller $rc */
578
        $rc = restore_controller_dbops::load_controller($restoreid);
579
        $plan = $rc->get_plan();
580
        $after35 = $plan->backup_release_compare('3.5', '>=') && $plan->backup_version_compare(20180205, '>');
581
        $rc->destroy(); // Always need to destroy.
582
 
1441 ariadna 583
        /*
584
          For any contextlevel, follow this process logic:
1 efrain 585
 
1441 ariadna 586
          0) Iterate over each context (qbank)
587
          1) Iterate over each qcat in the context, matching by stamp for the found target context
588
              2a) No match, check if user can create qcat and q
589
                  3a) User can, mark the qcat and all dependent qs to be created in that target context
590
                  3b) User cannot. Move ALL the qcats to a default qbank instance, warn. End qcat loop
591
              2b) Match, mark qcat to be mapped and iterate over each q, matching by stamp and version
592
                  4a) No match, check if user can add q
593
                      5a) User can, mark the q to be created
594
                      5b) User cannot. Move ALL the qcats to a default qbank instance, warn. End qcat loop
595
                  4b) Random question, must always create new.
596
                  4c) Match, mark q to be mapped
597
          6) Check if backup is from Moodle >= 3.5 and error if more than one top-level category in the context.
598
         */
599
 
1 efrain 600
        // Get all the contexts (question banks) in restore for the given contextlevel
601
        $contexts = self::restore_get_question_banks($restoreid, $contextlevel);
602
 
603
        // 0) Iterate over each context (qbank)
604
        foreach ($contexts as $contextid => $contextlevel) {
605
            // Init some perms
606
            $canmanagecategory = false;
1441 ariadna 607
            $canadd = false;
1 efrain 608
            // Top-level category counter.
609
            $topcats = 0;
610
            // get categories in context (bank)
611
            $categories = self::restore_get_question_categories($restoreid, $contextid, $contextlevel);
612
 
613
            // cache permissions if $targetcontext is found
614
            if ($targetcontext = self::restore_find_best_target_context($categories, $courseid, $contextlevel)) {
615
                $canmanagecategory = has_capability('moodle/question:managecategory', $targetcontext, $userid);
1441 ariadna 616
                $canadd = has_capability('moodle/question:add', $targetcontext, $userid);
1 efrain 617
            }
618
            // 1) Iterate over each qcat in the context, matching by stamp for the found target context
619
            foreach ($categories as $category) {
620
                if ($category->parent == 0) {
621
                    $topcats++;
622
                }
623
 
624
                $matchcat = false;
625
                if ($targetcontext) {
1441 ariadna 626
                    $matchcat = $DB->get_record('question_categories', [
627
                            'contextid' => $targetcontext->id,
628
                            'stamp' => $category->stamp,
629
                    ]);
1 efrain 630
                }
631
                // 2a) No match, check if user can create qcat and q
632
                if (!$matchcat) {
633
                    // 3a) User can, mark the qcat and all dependent qs to be created in that target context
634
                    if ($canmanagecategory && $canadd) {
635
                        // Set parentitemid to targetcontext, BUT for CONTEXT_MODULE categories, where
636
                        // we keep the source contextid unmodified (for easier matching later when the
637
                        // activities are created)
638
                        $parentitemid = $targetcontext->id;
639
                        if ($contextlevel == CONTEXT_MODULE) {
640
                            $parentitemid = null; // null means "not modify" a.k.a. leave original contextid
641
                        }
642
                        self::set_backup_ids_record($restoreid, 'question_category', $category->id, 0, $parentitemid);
643
                        // Nothing else to mark, newitemid = 0 means create
644
                    } else {
1441 ariadna 645
                        // 3b) User cannot. Move ALL the qcats to the fallback i.e. a default qbank instance, warn. End qcat loop.
646
                        $course = get_course($courseid);
647
                        $course->fullname = get_string('courserestore', 'question');
648
                        $module =
649
                            core_question\local\bank\question_bank_helper::get_default_open_instance_system_type($course, true);
650
                        $fallbackcontext = $module->context;
651
                        foreach ($categories as $movedcat) {
652
                            $movedcat->contextlevel = $contextlevel;
653
                            self::set_backup_ids_record($restoreid,
654
                                'question_category',
655
                                $movedcat->id,
656
                                0,
657
                                $fallbackcontext->id,
658
                                $movedcat
659
                            );
660
                            // Warn about the performed fallback.
661
                            $warnings[] = get_string('qcategory2coursefallback', 'backup', $movedcat);
1 efrain 662
                        }
1441 ariadna 663
                        break; // Out from qcat loop (both 3a and 3b), we have decided about ALL categories in context (bank).
1 efrain 664
                    }
665
 
1441 ariadna 666
                    // 2b) Match, mark qcat to be mapped and iterate over each q, matching by stamp and version
1 efrain 667
                } else {
668
                    self::set_backup_ids_record($restoreid, 'question_category', $category->id, $matchcat->id, $targetcontext->id);
669
                    $questions = self::restore_get_questions($restoreid, $category->id);
1441 ariadna 670
                    $transformer = self::get_backup_xml_transformer($courseid);
1 efrain 671
 
672
                    // Collect all the questions for this category into memory so we only talk to the DB once.
1441 ariadna 673
                    $recordset = $DB->get_recordset_sql(
674
                        "SELECT q.*
675
                           FROM {question} q
676
                           JOIN {question_versions} qv ON qv.questionid = q.id
677
                           JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
678
                           JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
679
                          WHERE qc.id = ?",
680
                        [$matchcat->id],
681
                    );
1 efrain 682
 
1441 ariadna 683
                    // Compute a hash of question and answer fields to differentiate between identical stamp-version questions.
684
                    $questioncache = [];
685
                    foreach ($recordset as $question) {
686
                        $question->export_process = true; // Include all question options required for export.
687
                        get_question_options($question);
688
                        unset($question->export_process);
689
                        // Remove some additional properties from get_question_options() that isn't included in backups
690
                        // before we produce the identity hash.
691
                        unset($question->categoryobject);
692
                        unset($question->questioncategoryid);
693
                        $cachekey = restore_questions_parser_processor::generate_question_identity_hash($question, $transformer);
694
                        $questioncache[$cachekey] = $question->id;
695
                    }
696
                    $recordset->close();
697
 
1 efrain 698
                    foreach ($questions as $question) {
1441 ariadna 699
                        if (isset($questioncache[$question->questionhash])) {
700
                            $matchqid = $questioncache[$question->questionhash];
1 efrain 701
                        } else {
702
                            $matchqid = false;
703
                        }
1441 ariadna 704
                        // 4a) No match, check if user can add q
1 efrain 705
                        if (!$matchqid) {
1441 ariadna 706
                            // 5a) User can, mark the q to be created
1 efrain 707
                            if ($canadd) {
708
                                // Nothing to mark, newitemid means create
709
                            } else {
1441 ariadna 710
                                // 5b) User cannot.
711
                                // Move ALL the qcats to the fallback i.e. a default qbank instance, warn. End qcat loop.
712
                                $course = get_course($courseid);
713
                                $course->fullname = get_string('courserestore', 'question');
714
                                $module = core_question\local\bank\question_bank_helper::get_default_open_instance_system_type(
715
                                    $course,
716
                                    true
717
                                );
718
                                $fallbackcontext = $module->context;
719
                                foreach ($categories as $movedcat) {
720
                                    $movedcat->contextlevel = $contextlevel;
721
                                    self::set_backup_ids_record($restoreid,
722
                                        'question_category',
723
                                        $movedcat->id,
724
                                        0,
725
                                        $fallbackcontext->id,
726
                                        $movedcat
727
                                    );
728
                                    // Warn about the performed fallback.
729
                                    $warnings[] = get_string('question2coursefallback', 'backup', $movedcat);
1 efrain 730
                                }
1441 ariadna 731
                                // Out from qcat loop (both 5a and 5b), we have decided about ALL categories in context (bank).
732
                                break 2;
1 efrain 733
                            }
734
 
1441 ariadna 735
                            // 4b) Random questions must always be newly created.
1 efrain 736
                        } else if ($question->qtype == 'random') {
737
                            // Nothing to mark, newitemid means create
738
 
1441 ariadna 739
                            // 4c) Match, mark q to be mapped.
1 efrain 740
                        } else {
741
                            self::set_backup_ids_record($restoreid, 'question', $question->id, $matchqid);
742
                        }
743
                    }
744
                }
745
            }
746
 
1441 ariadna 747
            // 6) Check if backup is made on Moodle >= 3.5 and there are more than one top-level category in the context.
1 efrain 748
            if ($after35 && $topcats > 1) {
749
                $errors[] = get_string('restoremultipletopcats', 'question', $contextid);
750
            }
751
 
752
        }
753
 
1441 ariadna 754
        return [$errors, $warnings];
1 efrain 755
    }
756
 
757
    /**
758
     * Return one array of contextid => contextlevel pairs
759
     * of question banks to be checked for one given restore operation
760
     * ordered from CONTEXT_SYSTEM downto CONTEXT_MODULE
761
     * If contextlevel is specified, then only banks corresponding to
762
     * that level are returned
763
     */
764
    public static function restore_get_question_banks($restoreid, $contextlevel = null) {
765
        global $DB;
766
 
767
        $results = array();
768
        $qcats = $DB->get_recordset_sql("SELECT itemid, parentitemid AS contextid, info
769
                                         FROM {backup_ids_temp}
770
                                       WHERE backupid = ?
771
                                         AND itemname = 'question_category'", array($restoreid));
772
        foreach ($qcats as $qcat) {
773
            // If this qcat context haven't been acummulated yet, do that
774
            if (!isset($results[$qcat->contextid])) {
775
                $info = backup_controller_dbops::decode_backup_temp_info($qcat->info);
776
                // Filter by contextlevel if necessary
777
                if (is_null($contextlevel) || $contextlevel == $info->contextlevel) {
778
                    $results[$qcat->contextid] = $info->contextlevel;
779
                }
780
            }
781
        }
782
        $qcats->close();
783
        // Sort by value (contextlevel from CONTEXT_SYSTEM downto CONTEXT_MODULE)
784
        asort($results);
785
        return $results;
786
    }
787
 
788
    /**
789
     * Return one array of question_category records for
790
     * a given restore operation and one restore context (question bank)
791
     *
792
     * @param string $restoreid Unique identifier of the restore operation being performed.
793
     * @param int $contextid Context id we want question categories to be returned.
794
     * @param int $contextlevel Context level we want to restrict the returned categories.
795
     * @return array Question categories for the given context id and level.
796
     */
797
    public static function restore_get_question_categories($restoreid, $contextid, $contextlevel) {
798
        global $DB;
799
 
800
        $results = array();
801
        $qcats = $DB->get_recordset_sql("SELECT itemid, info
802
                                         FROM {backup_ids_temp}
803
                                        WHERE backupid = ?
804
                                          AND itemname = 'question_category'
805
                                          AND parentitemid = ?", array($restoreid, $contextid));
806
        foreach ($qcats as $qcat) {
807
            $result = backup_controller_dbops::decode_backup_temp_info($qcat->info);
808
            // Filter out found categories that belong to another context level.
809
            // (this can happen when a higher level category becomes remapped to
810
            // a context id that, by coincidence, matches a context id of another
811
            // category at lower level). See MDL-72950 for more info.
812
            if ($result->contextlevel == $contextlevel) {
813
                $results[$qcat->itemid] = $result;
814
            }
815
        }
816
        $qcats->close();
817
 
818
        return $results;
819
    }
820
 
821
    /**
1441 ariadna 822
     * Calculates the best existing context to restore one collection of qcats.
823
     * Uses the backup category stamp to match the target category stamp
824
     * and categories must all belong to the same context (question bank).
825
     *
826
     * @param array $categories categories to find target context for
827
     * @param int $courseid course to restore to
828
     * @param int $contextlevel contextlevel to search for the target context
829
     * @return bool|\core\context target context or false if no target context found
1 efrain 830
     */
831
    public static function restore_find_best_target_context($categories, $courseid, $contextlevel) {
832
        global $DB;
833
 
834
        $targetcontext = false;
835
 
1441 ariadna 836
        // If context module we need to find any existing module instances with categories matching the category stamps
837
        // from the backup. If multiple matches are found, that means that there is some annoying
838
        // qbank "fragmentation" in the categories, so we'll fall back
839
        // to creating a qbank instance at course level and putting the categories there.
840
        if ($contextlevel == CONTEXT_MODULE) {
841
            $stamps = [];
842
            foreach ($categories as $category) {
843
                $stamps[] = $category->stamp;
844
            }
845
            $modinfo = get_fast_modinfo($courseid);
1 efrain 846
 
1441 ariadna 847
            // Get contextids of modules from the course that support publishing questions.
848
            $supportedcontextids = [];
849
            foreach ($modinfo->get_cms() as $cm) {
850
                if (plugin_supports('mod', $cm->modname, FEATURE_PUBLISHES_QUESTIONS, false)) {
851
                    $supportedcontextids[] = $cm->context->id;
852
                }
853
            }
1 efrain 854
 
1441 ariadna 855
            if (!empty($stamps) && !empty($supportedcontextids)) {
856
                [$stampsql, $stampparams] = $DB->get_in_or_equal($stamps);
857
                [$contextsql, $contextparams] = $DB->get_in_or_equal($supportedcontextids);
858
                $sql = "SELECT DISTINCT contextid
859
                          FROM {question_categories}
860
                         WHERE stamp {$stampsql}
861
                           AND contextid {$contextsql}";
862
                $params = array_merge($stampparams, $contextparams);
863
                $matchingcontexts = $DB->get_records_sql($sql, $params);
864
                // Only if ONE and ONLY ONE context is found, use it as valid target.
865
                if (count($matchingcontexts) === 1) {
866
                    $targetcontext = context::instance_by_id(reset($matchingcontexts)->contextid);
867
                }
868
            }
869
            // We don't have a target so set as course context until the module is created and then assign to the module context.
870
            $targetcontext = $targetcontext ?: context_course::instance($courseid);
871
        }
1 efrain 872
 
873
        return $targetcontext;
874
    }
875
 
876
    /**
877
     * Return one array of question records for
878
     * a given restore operation and one question category
879
     */
880
    public static function restore_get_questions($restoreid, $qcatid) {
881
        global $DB;
882
 
883
        $results = array();
884
        $qs = $DB->get_recordset_sql("SELECT itemid, info
885
                                      FROM {backup_ids_temp}
886
                                     WHERE backupid = ?
887
                                       AND itemname = 'question'
888
                                       AND parentitemid = ?", array($restoreid, $qcatid));
889
        foreach ($qs as $q) {
890
            $results[$q->itemid] = backup_controller_dbops::decode_backup_temp_info($q->info);
891
        }
892
        $qs->close();
893
        return $results;
894
    }
895
 
896
    /**
897
     * Given one component/filearea/context and
898
     * optionally one source itemname to match itemids
899
     * put the corresponding files in the pool
900
     *
901
     * If you specify a progress reporter, it will get called once per file with
902
     * indeterminate progress.
903
     *
904
     * @param string $basepath the full path to the root of unzipped backup file
905
     * @param string $restoreid the restore job's identification
906
     * @param string $component
907
     * @param string $filearea
908
     * @param int $oldcontextid
909
     * @param int $dfltuserid default $file->user if the old one can't be mapped
910
     * @param string|null $itemname
911
     * @param int|null $olditemid
912
     * @param int|null $forcenewcontextid explicit value for the new contextid (skip mapping)
913
     * @param bool $skipparentitemidctxmatch
914
     * @param \core\progress\base $progress Optional progress reporter
915
     * @return array of result object
916
     */
917
    public static function send_files_to_pool($basepath, $restoreid, $component, $filearea,
918
            $oldcontextid, $dfltuserid, $itemname = null, $olditemid = null,
919
            $forcenewcontextid = null, $skipparentitemidctxmatch = false,
1441 ariadna 920
            ?\core\progress\base $progress = null) {
1 efrain 921
        global $DB, $CFG;
922
 
923
        $backupinfo = backup_general_helper::get_backup_information(basename($basepath));
924
        $includesfiles = $backupinfo->include_files;
925
 
926
        $results = array();
927
 
928
        if ($forcenewcontextid) {
929
            // Some components can have "forced" new contexts (example: questions can end belonging to non-standard context mappings,
930
            // with questions originally at system/coursecat context in source being restored to course context in target). So we need
931
            // to be able to force the new contextid
932
            $newcontextid = $forcenewcontextid;
933
        } else {
934
            // Get new context, must exist or this will fail
935
            $newcontextrecord = self::get_backup_ids_record($restoreid, 'context', $oldcontextid);
936
            if (!$newcontextrecord || !$newcontextrecord->newitemid) {
937
                throw new restore_dbops_exception('unknown_context_mapping', $oldcontextid);
938
            }
939
            $newcontextid = $newcontextrecord->newitemid;
940
        }
941
 
942
        // Sometimes it's possible to have not the oldcontextids stored into backup_ids_temp->parentitemid
943
        // columns (because we have used them to store other information). This happens usually with
944
        // all the question related backup_ids_temp records. In that case, it's safe to ignore that
945
        // matching as far as we are always restoring for well known oldcontexts and olditemids
946
        $parentitemctxmatchsql = ' AND i.parentitemid = f.contextid ';
947
        if ($skipparentitemidctxmatch) {
948
            $parentitemctxmatchsql = '';
949
        }
950
 
951
        // Important: remember how files have been loaded to backup_files_temp
952
        //   - info: contains the whole original object (times, names...)
953
        //   (all them being original ids as loaded from xml)
954
 
955
        // itemname = null, we are going to match only by context, no need to use itemid (all them are 0)
956
        if ($itemname == null) {
957
            $sql = "SELECT id AS bftid, contextid, component, filearea, itemid, itemid AS newitemid, info
958
                      FROM {backup_files_temp}
959
                     WHERE backupid = ?
960
                       AND contextid = ?
961
                       AND component = ?
962
                       AND filearea  = ?";
963
            $params = array($restoreid, $oldcontextid, $component, $filearea);
964
 
965
        // itemname not null, going to join with backup_ids to perform the old-new mapping of itemids
966
        } else {
967
            $sql = "SELECT f.id AS bftid, f.contextid, f.component, f.filearea, f.itemid, i.newitemid, f.info
968
                      FROM {backup_files_temp} f
969
                      JOIN {backup_ids_temp} i ON i.backupid = f.backupid
970
                                              $parentitemctxmatchsql
971
                                              AND i.itemid = f.itemid
972
                     WHERE f.backupid = ?
973
                       AND f.contextid = ?
974
                       AND f.component = ?
975
                       AND f.filearea = ?
976
                       AND i.itemname = ?";
977
            $params = array($restoreid, $oldcontextid, $component, $filearea, $itemname);
978
            if ($olditemid !== null) { // Just process ONE olditemid intead of the whole itemname
979
                $sql .= ' AND i.itemid = ?';
980
                $params[] = $olditemid;
981
            }
982
        }
983
 
984
        $fs = get_file_storage();         // Get moodle file storage
985
        $basepath = $basepath . '/files/';// Get backup file pool base
986
        // Report progress before query.
987
        if ($progress) {
988
            $progress->progress();
989
        }
990
        $rs = $DB->get_recordset_sql($sql, $params);
991
        foreach ($rs as $rec) {
992
            // Report progress each time around loop.
993
            if ($progress) {
994
                $progress->progress();
995
            }
996
 
997
            $file = (object)backup_controller_dbops::decode_backup_temp_info($rec->info);
998
 
999
            // ignore root dirs (they are created automatically)
1000
            if ($file->filepath == '/' && $file->filename == '.') {
1001
                continue;
1002
            }
1003
 
1004
            // set the best possible user
1005
            $mappeduser = self::get_backup_ids_record($restoreid, 'user', $file->userid);
1006
            $mappeduserid = !empty($mappeduser) ? $mappeduser->newitemid : $dfltuserid;
1007
 
1008
            // dir found (and not root one), let's create it
1009
            if ($file->filename == '.') {
1010
                $fs->create_directory($newcontextid, $component, $filearea, $rec->newitemid, $file->filepath, $mappeduserid);
1011
                continue;
1012
            }
1013
 
1014
            // Updated the times of the new record.
1015
            // The file record should reflect when the file entered the system,
1016
            // and when this record was created.
1017
            $time = time();
1018
 
1019
            // The file record to restore.
1020
            $file_record = array(
1021
                'contextid'    => $newcontextid,
1022
                'component'    => $component,
1023
                'filearea'     => $filearea,
1024
                'itemid'       => $rec->newitemid,
1025
                'filepath'     => $file->filepath,
1026
                'filename'     => $file->filename,
1027
                'timecreated'  => $time,
1028
                'timemodified' => $time,
1029
                'userid'       => $mappeduserid,
1030
                'source'       => $file->source,
1031
                'author'       => $file->author,
1032
                'license'      => $file->license,
1033
                'sortorder'    => $file->sortorder
1034
            );
1035
 
1036
            if (empty($file->repositoryid)) {
1037
                // If contenthash is empty then gracefully skip adding file.
1038
                if (empty($file->contenthash)) {
1039
                    $result = new stdClass();
1040
                    $result->code = 'file_missing_in_backup';
1041
                    $result->message = sprintf('missing file (%s) contenthash in backup for component %s', $file->filename, $component);
1042
                    $result->level = backup::LOG_WARNING;
1043
                    $results[] = $result;
1044
                    continue;
1045
                }
1046
                // this is a regular file, it must be present in the backup pool
1047
                $backuppath = $basepath . backup_file_manager::get_backup_content_file_location($file->contenthash);
1048
 
1049
                // Some file types do not include the files as they should already be
1050
                // present. We still need to create entries into the files table.
1051
                if ($includesfiles) {
1052
                    // The file is not found in the backup.
1053
                    if (!file_exists($backuppath)) {
1054
                        $results[] = self::get_missing_file_result($file);
1055
                        continue;
1056
                    }
1057
 
1058
                    // create the file in the filepool if it does not exist yet
1059
                    if (!$fs->file_exists($newcontextid, $component, $filearea, $rec->newitemid, $file->filepath, $file->filename)) {
1060
 
1061
                        // If no license found, use default.
1062
                        if ($file->license == null){
1063
                            $file->license = $CFG->sitedefaultlicense;
1064
                        }
1065
 
1066
                        $fs->create_file_from_pathname($file_record, $backuppath);
1067
                    }
1068
                } else {
1069
                    // This backup does not include the files - they should be available in moodle filestorage already.
1070
 
1071
                    // Create the file in the filepool if it does not exist yet.
1072
                    if (!$fs->file_exists($newcontextid, $component, $filearea, $rec->newitemid, $file->filepath, $file->filename)) {
1073
 
1074
                        // Even if a file has been deleted since the backup was made, the file metadata may remain in the
1075
                        // files table, and the file will not yet have been moved to the trashdir. e.g. a draft file version.
1076
                        // Try to recover from file table first.
1077
                        if ($foundfiles = $DB->get_records('files', array('contenthash' => $file->contenthash), '', '*', 0, 1)) {
1078
                            // Only grab one of the foundfiles - the file content should be the same for all entries.
1079
                            $foundfile = reset($foundfiles);
1080
                            $fs->create_file_from_storedfile($file_record, $foundfile->id);
1081
                        } else {
1082
                            $filesystem = $fs->get_file_system();
1083
                            $restorefile = $file;
1084
                            $restorefile->contextid = $newcontextid;
1085
                            $restorefile->itemid = $rec->newitemid;
1086
                            $storedfile = new stored_file($fs, $restorefile);
1087
 
1088
                            // Ok, let's try recover this file.
1089
                            // 1. We check if the file can be fetched locally without attempting to fetch
1090
                            //    from the trash.
1091
                            // 2. We check if we can get the remote filepath for the specified stored file.
1092
                            // 3. We check if the file can be fetched from the trash.
1093
                            // 4. All failed, say we couldn't find it.
1094
                            if ($filesystem->is_file_readable_locally_by_storedfile($storedfile)) {
1095
                                $localpath = $filesystem->get_local_path_from_storedfile($storedfile);
1096
                                $fs->create_file_from_pathname($file, $localpath);
1097
                            } else if ($filesystem->is_file_readable_remotely_by_storedfile($storedfile)) {
1098
                                $remotepath = $filesystem->get_remote_path_from_storedfile($storedfile);
1099
                                $fs->create_file_from_pathname($file, $remotepath);
1100
                            } else if ($filesystem->is_file_readable_locally_by_storedfile($storedfile, true)) {
1101
                                $localpath = $filesystem->get_local_path_from_storedfile($storedfile, true);
1102
                                $fs->create_file_from_pathname($file, $localpath);
1103
                            } else {
1104
                                // A matching file was not found.
1105
                                $results[] = self::get_missing_file_result($file);
1106
                                continue;
1107
                            }
1108
                        }
1109
                    }
1110
                }
1111
 
1112
                // store the the new contextid and the new itemid in case we need to remap
1113
                // references to this file later
1114
                $DB->update_record('backup_files_temp', array(
1115
                    'id' => $rec->bftid,
1116
                    'newcontextid' => $newcontextid,
1117
                    'newitemid' => $rec->newitemid), true);
1118
 
1119
            } else {
1120
                // this is an alias - we can't create it yet so we stash it in a temp
1121
                // table and will let the final task to deal with it
1122
                if (!$fs->file_exists($newcontextid, $component, $filearea, $rec->newitemid, $file->filepath, $file->filename)) {
1123
                    $info = new stdClass();
1124
                    // oldfile holds the raw information stored in MBZ (including reference-related info)
1125
                    $info->oldfile = $file;
1126
                    // newfile holds the info for the new file_record with the context, user and itemid mapped
1127
                    $info->newfile = (object) $file_record;
1128
 
1129
                    restore_dbops::set_backup_ids_record($restoreid, 'file_aliases_queue', $file->id, 0, null, $info);
1130
                }
1131
            }
1132
        }
1133
        $rs->close();
1134
        return $results;
1135
    }
1136
 
1137
    /**
1138
     * Returns suitable entry to include in log when there is a missing file.
1139
     *
1140
     * @param stdClass $file File definition
1141
     * @return stdClass Log entry
1142
     */
1143
    protected static function get_missing_file_result($file) {
1144
        $result = new stdClass();
1145
        $result->code = 'file_missing_in_backup';
1146
        $result->message = 'Missing file in backup: ' . $file->filepath  . $file->filename .
1147
                ' (old context ' . $file->contextid . ', component ' . $file->component .
1148
                ', filearea ' . $file->filearea . ', old itemid ' . $file->itemid . ')';
1149
        $result->level = backup::LOG_WARNING;
1150
        return $result;
1151
    }
1152
 
1153
    /**
1154
     * Given one restoreid, create in DB all the users present
1155
     * in backup_ids having newitemid = 0, as far as
1156
     * precheck_included_users() have left them there
1157
     * ready to be created. Also, annotate their newids
1158
     * once created for later reference.
1159
     *
1160
     * This function will start and end a new progress section in the progress
1161
     * object.
1162
     *
1163
     * @param string $basepath Base path of unzipped backup
1164
     * @param string $restoreid Restore ID
1165
     * @param int $userid Default userid for files
1166
     * @param \core\progress\base $progress Object used for progress tracking
1167
     * @param int $courseid Course ID
1168
     */
1169
    public static function create_included_users($basepath, $restoreid, $userid,
1170
            \core\progress\base $progress, int $courseid = 0) {
1171
        global $CFG, $DB;
1172
        require_once($CFG->dirroot.'/user/profile/lib.php');
1173
        $progress->start_progress('Creating included users');
1174
 
1175
        $authcache = array(); // Cache to get some bits from authentication plugins
1176
        $languages = get_string_manager()->get_list_of_translations(); // Get languages for quick search later
1177
        $themes    = get_list_of_themes(); // Get themes for quick search later
1178
 
1179
        // Iterate over all the included users with newitemid = 0, have to create them
1180
        $rs = $DB->get_recordset('backup_ids_temp', array('backupid' => $restoreid, 'itemname' => 'user', 'newitemid' => 0), '', 'itemid, parentitemid, info');
1181
        foreach ($rs as $recuser) {
1182
            $progress->progress();
1183
            $user = (object)backup_controller_dbops::decode_backup_temp_info($recuser->info);
1184
 
1185
            // if user lang doesn't exist here, use site default
1186
            if (!array_key_exists($user->lang, $languages)) {
1187
                $user->lang = get_newuser_language();
1188
            }
1189
 
1190
            // if user theme isn't available on target site or they are disabled, reset theme
1191
            if (!empty($user->theme)) {
1192
                if (empty($CFG->allowuserthemes) || !in_array($user->theme, $themes)) {
1193
                    $user->theme = '';
1194
                }
1195
            }
1196
 
1197
            // if user to be created has mnet auth and its mnethostid is $CFG->mnet_localhost_id
1198
            // that's 100% impossible as own server cannot be accesed over mnet. Change auth to email/manual
1199
            if ($user->auth == 'mnet' && $user->mnethostid == $CFG->mnet_localhost_id) {
1200
                // Respect registerauth
1201
                if ($CFG->registerauth == 'email') {
1202
                    $user->auth = 'email';
1203
                } else {
1204
                    $user->auth = 'manual';
1205
                }
1206
            }
1207
            unset($user->mnethosturl); // Not needed anymore
1208
 
1209
            // Disable pictures based on global setting
1210
            if (!empty($CFG->disableuserimages)) {
1211
                $user->picture = 0;
1212
            }
1213
 
1214
            // We need to analyse the AUTH field to recode it:
1215
            //   - if the auth isn't enabled in target site, $CFG->registerauth will decide
1216
            //   - finally, if the auth resulting isn't enabled, default to 'manual'
1217
            if (!is_enabled_auth($user->auth)) {
1218
                if ($CFG->registerauth == 'email') {
1219
                    $user->auth = 'email';
1220
                } else {
1221
                    $user->auth = 'manual';
1222
                }
1223
            }
1224
            if (!is_enabled_auth($user->auth)) { // Final auth check verify, default to manual if not enabled
1225
                $user->auth = 'manual';
1226
            }
1227
 
1228
            // Now that we know the auth method, for users to be created without pass
1229
            // if password handling is internal and reset password is available
1230
            // we set the password to "restored" (plain text), so the login process
1231
            // will know how to handle that situation in order to allow the user to
1232
            // recover the password. MDL-20846
1233
            if (empty($user->password)) { // Only if restore comes without password
1234
                if (!array_key_exists($user->auth, $authcache)) { // Not in cache
1235
                    $userauth = new stdClass();
1236
                    $authplugin = get_auth_plugin($user->auth);
1237
                    $userauth->preventpassindb = $authplugin->prevent_local_passwords();
1238
                    $userauth->isinternal      = $authplugin->is_internal();
1239
                    $userauth->canresetpwd     = $authplugin->can_reset_password();
1240
                    $authcache[$user->auth] = $userauth;
1241
                } else {
1242
                    $userauth = $authcache[$user->auth]; // Get from cache
1243
                }
1244
 
1245
                // Most external plugins do not store passwords locally
1246
                if (!empty($userauth->preventpassindb)) {
1247
                    $user->password = AUTH_PASSWORD_NOT_CACHED;
1248
 
1249
                // If Moodle is responsible for storing/validating pwd and reset functionality is available, mark
1250
                } else if ($userauth->isinternal and $userauth->canresetpwd) {
1251
                    $user->password = 'restored';
1252
                }
1253
            } else if (self::password_should_be_discarded($user->password)) {
1254
                // Password is not empty and it is MD5 hashed. Generate a new random password for the user.
1255
                // We don't want MD5 hashes in the database and users won't be able to log in with the associated password anyway.
1256
                $user->password = hash_internal_user_password(base64_encode(random_bytes(24)));
1257
            }
1258
 
1259
            // Creating new user, we must reset the policyagreed always
1260
            $user->policyagreed = 0;
1261
 
1262
            // Set time created if empty
1263
            if (empty($user->timecreated)) {
1264
                $user->timecreated = time();
1265
            }
1266
 
1267
            // Done, let's create the user and annotate its id
1268
            $newuserid = $DB->insert_record('user', $user);
1269
            self::set_backup_ids_record($restoreid, 'user', $recuser->itemid, $newuserid);
1270
            // Let's create the user context and annotate it (we need it for sure at least for files)
1271
            // but for deleted users that don't have a context anymore (MDL-30192). We are done for them
1272
            // and nothing else (custom fields, prefs, tags, files...) will be created.
1273
            if (empty($user->deleted)) {
1274
                $newuserctxid = $user->deleted ? 0 : context_user::instance($newuserid)->id;
1275
                self::set_backup_ids_record($restoreid, 'context', $recuser->parentitemid, $newuserctxid);
1276
 
1277
                // Process custom fields
1278
                if (isset($user->custom_fields)) { // if present in backup
1279
                    foreach($user->custom_fields['custom_field'] as $udata) {
1280
                        $udata = (object)$udata;
1281
                        // If the profile field has data and the profile shortname-datatype is defined in server
1282
                        if ($udata->field_data) {
1283
                            $field = profile_get_custom_field_data_by_shortname($udata->field_name);
1284
                            if ($field && $field->datatype === $udata->field_type) {
1285
                                // Insert the user_custom_profile_field.
1286
                                $rec = new stdClass();
1287
                                $rec->userid  = $newuserid;
1288
                                $rec->fieldid = $field->id;
1289
                                $rec->data    = $udata->field_data;
1290
                                $DB->insert_record('user_info_data', $rec);
1291
                            }
1292
                        }
1293
                    }
1294
                }
1295
 
1296
                // Trigger event that user was created.
1297
                \core\event\user_created::create_from_user_id_on_restore($newuserid, $restoreid, $courseid)->trigger();
1298
 
1299
                // Process tags
1300
                if (core_tag_tag::is_enabled('core', 'user') && isset($user->tags)) { // If enabled in server and present in backup.
1301
                    $tags = array();
1302
                    foreach($user->tags['tag'] as $usertag) {
1303
                        $usertag = (object)$usertag;
1304
                        $tags[] = $usertag->rawname;
1305
                    }
1306
                    core_tag_tag::set_item_tags('core', 'user', $newuserid,
1307
                            context_user::instance($newuserid), $tags);
1308
                }
1309
 
1310
                // Process preferences
1311
                if (isset($user->preferences)) { // if present in backup
1312
                    foreach($user->preferences['preference'] as $preference) {
1313
                        $preference = (object)$preference;
1314
                        // Prepare the record and insert it
1315
                        $preference->userid = $newuserid;
1316
 
1317
                        // Translate _loggedin / _loggedoff message user preferences to _enabled. (MDL-67853)
1318
                        // This code cannot be removed.
1319
                        if (preg_match('/message_provider_.*/', $preference->name)) {
1320
                            $nameparts = explode('_', $preference->name);
1321
                            $name = array_pop($nameparts);
1322
 
1323
                            if ($name == 'loggedin' || $name == 'loggedoff') {
1324
                                $preference->name = implode('_', $nameparts).'_enabled';
1325
 
1326
                                $existingpreference = $DB->get_record('user_preferences',
1327
                                    ['name' => $preference->name , 'userid' => $newuserid]);
1328
                                // Merge both values.
1329
                                if ($existingpreference) {
1330
                                    $values = [];
1331
 
1332
                                    if (!empty($existingpreference->value) && $existingpreference->value != 'none') {
1333
                                        $values = explode(',', $existingpreference->value);
1334
                                    }
1335
 
1336
                                    if (!empty($preference->value) && $preference->value != 'none') {
1337
                                        $values = array_merge(explode(',', $preference->value), $values);
1338
                                        $values = array_unique($values);
1339
                                    }
1340
 
1341
                                    $existingpreference->value = empty($values) ? 'none' : implode(',', $values);
1342
 
1343
                                    $DB->update_record('user_preferences', $existingpreference);
1344
                                    continue;
1345
                                }
1346
                            }
1347
                        }
1348
                        // End translating loggedin / loggedoff message user preferences.
1349
 
1350
                        $DB->insert_record('user_preferences', $preference);
1351
                    }
1352
                }
1353
                // Special handling for htmleditor which was converted to a preference.
1354
                if (isset($user->htmleditor)) {
1355
                    if ($user->htmleditor == 0) {
1356
                        $preference = new stdClass();
1357
                        $preference->userid = $newuserid;
1358
                        $preference->name = 'htmleditor';
1359
                        $preference->value = 'textarea';
1360
                        $DB->insert_record('user_preferences', $preference);
1361
                    }
1362
                }
1363
 
1364
                // Create user files in pool (profile, icon, private) by context
1365
                restore_dbops::send_files_to_pool($basepath, $restoreid, 'user', 'icon',
1366
                        $recuser->parentitemid, $userid, null, null, null, false, $progress);
1367
                restore_dbops::send_files_to_pool($basepath, $restoreid, 'user', 'profile',
1368
                        $recuser->parentitemid, $userid, null, null, null, false, $progress);
1369
            }
1370
        }
1371
        $rs->close();
1372
        $progress->end_progress();
1373
    }
1374
 
1375
    /**
1376
    * Given one user object (from backup file), perform all the neccesary
1377
    * checks is order to decide how that user will be handled on restore.
1378
    *
1379
    * Note the function requires $user->mnethostid to be already calculated
1380
    * so it's caller responsibility to set it
1381
    *
1382
    * This function is used both by @restore_precheck_users() and
1383
    * @restore_create_users() to get consistent results in both places
1384
    *
1385
    * It returns:
1386
    *   - one user object (from DB), if match has been found and user will be remapped
1387
    *   - boolean true if the user needs to be created
1388
    *   - boolean false if some conflict happened and the user cannot be handled
1389
    *
1390
    * Each test is responsible for returning its results and interrupt
1391
    * execution. At the end, boolean true (user needs to be created) will be
1392
    * returned if no test has interrupted that.
1393
    *
1394
    * Here it's the logic applied, keep it updated:
1395
    *
1396
    *  If restoring users from same site backup:
1397
    *      1A - Normal check: If match by id and username and mnethost  => ok, return target user
1398
    *      1B - If restoring an 'anonymous' user (created via the 'Anonymize user information' option) try to find a
1399
    *           match by username only => ok, return target user MDL-31484
1400
    *      1C - Handle users deleted in DB and "alive" in backup file:
1401
    *           If match by id and mnethost and user is deleted in DB and
1402
    *           (match by username LIKE 'backup_email.%' or by non empty email = md5(username)) => ok, return target user
1403
    *      1D - Handle users deleted in backup file and "alive" in DB:
1404
    *           If match by id and mnethost and user is deleted in backup file
1405
    *           and match by email = email_without_time(backup_email) => ok, return target user
1406
    *      1E - Conflict: If match by username and mnethost and doesn't match by id => conflict, return false
1407
    *      1F - None of the above, return true => User needs to be created
1408
    *
1409
    *  if restoring from another site backup (cannot match by id here, replace it by email/firstaccess combination):
1410
    *      2A - Normal check:
1411
    *           2A1 - If match by username and mnethost and (email or non-zero firstaccess) => ok, return target user
1412
    *           2A2 - Exceptional handling (MDL-21912): Match "admin" username. Then, if import_general_duplicate_admin_allowed is
1413
    *                 enabled, attempt to map the admin user to the user 'admin_[oldsiteid]' if it exists. If not,
1414
    *                 the user 'admin_[oldsiteid]' will be created in precheck_included users
1415
    *      2B - Handle users deleted in DB and "alive" in backup file:
1416
    *           2B1 - If match by mnethost and user is deleted in DB and not empty email = md5(username) and
1417
    *                 (username LIKE 'backup_email.%' or non-zero firstaccess) => ok, return target user
1418
    *           2B2 - If match by mnethost and user is deleted in DB and
1419
    *                 username LIKE 'backup_email.%' and non-zero firstaccess) => ok, return target user
1420
    *                 (to cover situations were md5(username) wasn't implemented on delete we requiere both)
1421
    *      2C - Handle users deleted in backup file and "alive" in DB:
1422
    *           If match mnethost and user is deleted in backup file
1423
    *           and by email = email_without_time(backup_email) and non-zero firstaccess=> ok, return target user
1424
    *      2D - Conflict: If match by username and mnethost and not by (email or non-zero firstaccess) => conflict, return false
1425
    *      2E - None of the above, return true => User needs to be created
1426
    *
1427
    * Note: for DB deleted users email is stored in username field, hence we
1428
    *       are looking there for emails. See delete_user()
1429
    * Note: for DB deleted users md5(username) is stored *sometimes* in the email field,
1430
    *       hence we are looking there for usernames if not empty. See delete_user()
1431
    */
1432
    protected static function precheck_user($user, $samesite, $siteid = null) {
1433
        global $CFG, $DB;
1434
 
1435
        // Handle checks from same site backups
1436
        if ($samesite && empty($CFG->forcedifferentsitecheckingusersonrestore)) {
1437
 
1438
            // 1A - If match by id and username and mnethost => ok, return target user
1439
            if ($rec = $DB->get_record('user', array('id'=>$user->id, 'username'=>$user->username, 'mnethostid'=>$user->mnethostid))) {
1440
                return $rec; // Matching user found, return it
1441
            }
1442
 
1443
            // 1B - If restoring an 'anonymous' user (created via the 'Anonymize user information' option) try to find a
1444
            // match by username only => ok, return target user MDL-31484
1445
            // This avoids username / id mis-match problems when restoring subsequent anonymized backups.
1446
            if (backup_anonymizer_helper::is_anonymous_user($user)) {
1447
                if ($rec = $DB->get_record('user', array('username' => $user->username))) {
1448
                    return $rec; // Matching anonymous user found - return it
1449
                }
1450
            }
1451
 
1452
            // 1C - Handle users deleted in DB and "alive" in backup file
1453
            // Note: for DB deleted users email is stored in username field, hence we
1454
            //       are looking there for emails. See delete_user()
1455
            // Note: for DB deleted users md5(username) is stored *sometimes* in the email field,
1456
            //       hence we are looking there for usernames if not empty. See delete_user()
1457
            // If match by id and mnethost and user is deleted in DB and
1458
            // match by username LIKE 'substring(backup_email).%' where the substr length matches the retained data in the
1459
            // username field (100 - (timestamp + 1) characters), or by non empty email = md5(username) => ok, return target user.
1460
            $usernamelookup = core_text::substr($user->email, 0, 89) . '.%';
1461
            if ($rec = $DB->get_record_sql("SELECT *
1462
                                              FROM {user} u
1463
                                             WHERE id = ?
1464
                                               AND mnethostid = ?
1465
                                               AND deleted = 1
1466
                                               AND (
1467
                                                       UPPER(username) LIKE UPPER(?)
1468
                                                    OR (
1469
                                                           ".$DB->sql_isnotempty('user', 'email', false, false)."
1470
                                                       AND email = ?
1471
                                                       )
1472
                                                   )",
1473
                                           array($user->id, $user->mnethostid, $usernamelookup, md5($user->username)))) {
1474
                return $rec; // Matching user, deleted in DB found, return it
1475
            }
1476
 
1477
            // 1D - Handle users deleted in backup file and "alive" in DB
1478
            // If match by id and mnethost and user is deleted in backup file
1479
            // and match by substring(email) = email_without_time(backup_email) where the substr length matches the retained data
1480
            // in the username field (100 - (timestamp + 1) characters) => ok, return target user.
1481
            if ($user->deleted) {
1482
                // Note: for DB deleted users email is stored in username field, hence we
1483
                //       are looking there for emails. See delete_user()
1484
                // Trim time() from email
1485
                $trimemail = preg_replace('/(.*?)\.[0-9]+.?$/', '\\1', $user->username);
1486
                if ($rec = $DB->get_record_sql("SELECT *
1487
                                                  FROM {user} u
1488
                                                 WHERE id = ?
1489
                                                   AND mnethostid = ?
1490
                                                   AND " . $DB->sql_substr('UPPER(email)', 1, 89) . " = UPPER(?)",
1491
                                               array($user->id, $user->mnethostid, $trimemail))) {
1492
                    return $rec; // Matching user, deleted in backup file found, return it
1493
                }
1494
            }
1495
 
1496
            // 1E - If match by username and mnethost and doesn't match by id => conflict, return false
1497
            if ($rec = $DB->get_record('user', array('username'=>$user->username, 'mnethostid'=>$user->mnethostid))) {
1498
                if ($user->id != $rec->id) {
1499
                    return false; // Conflict, username already exists and belongs to another id
1500
                }
1501
            }
1502
 
1503
        // Handle checks from different site backups
1504
        } else {
1505
 
1506
            // 2A1 - If match by username and mnethost and
1507
            //     (email or non-zero firstaccess) => ok, return target user
1508
            if ($rec = $DB->get_record_sql("SELECT *
1509
                                              FROM {user} u
1510
                                             WHERE username = ?
1511
                                               AND mnethostid = ?
1512
                                               AND (
1513
                                                       UPPER(email) = UPPER(?)
1514
                                                    OR (
1515
                                                           firstaccess != 0
1516
                                                       AND firstaccess = ?
1517
                                                       )
1518
                                                   )",
1519
                                           array($user->username, $user->mnethostid, $user->email, $user->firstaccess))) {
1520
                return $rec; // Matching user found, return it
1521
            }
1522
 
1523
            // 2A2 - If we're allowing conflicting admins, attempt to map user to admin_[oldsiteid].
1524
            if (get_config('backup', 'import_general_duplicate_admin_allowed') && $user->username === 'admin' && $siteid
1525
                    && $user->mnethostid == $CFG->mnet_localhost_id) {
1526
                if ($rec = $DB->get_record('user', array('username' => 'admin_' . $siteid))) {
1527
                    return $rec;
1528
                }
1529
            }
1530
 
1531
            // 2B - Handle users deleted in DB and "alive" in backup file
1532
            // Note: for DB deleted users email is stored in username field, hence we
1533
            //       are looking there for emails. See delete_user()
1534
            // Note: for DB deleted users md5(username) is stored *sometimes* in the email field,
1535
            //       hence we are looking there for usernames if not empty. See delete_user()
1536
            // 2B1 - If match by mnethost and user is deleted in DB and not empty email = md5(username) and
1537
            //       (by username LIKE 'substring(backup_email).%' or non-zero firstaccess) => ok, return target user.
1538
            $usernamelookup = core_text::substr($user->email, 0, 89) . '.%';
1539
            if ($rec = $DB->get_record_sql("SELECT *
1540
                                              FROM {user} u
1541
                                             WHERE mnethostid = ?
1542
                                               AND deleted = 1
1543
                                               AND ".$DB->sql_isnotempty('user', 'email', false, false)."
1544
                                               AND email = ?
1545
                                               AND (
1546
                                                       UPPER(username) LIKE UPPER(?)
1547
                                                    OR (
1548
                                                           firstaccess != 0
1549
                                                       AND firstaccess = ?
1550
                                                       )
1551
                                                   )",
1552
                                           array($user->mnethostid, md5($user->username), $usernamelookup, $user->firstaccess))) {
1553
                return $rec; // Matching user found, return it
1554
            }
1555
 
1556
            // 2B2 - If match by mnethost and user is deleted in DB and
1557
            //       username LIKE 'substring(backup_email).%' and non-zero firstaccess) => ok, return target user
1558
            //       (this covers situations where md5(username) wasn't being stored so we require both
1559
            //        the email & non-zero firstaccess to match)
1560
            $usernamelookup = core_text::substr($user->email, 0, 89) . '.%';
1561
            if ($rec = $DB->get_record_sql("SELECT *
1562
                                              FROM {user} u
1563
                                             WHERE mnethostid = ?
1564
                                               AND deleted = 1
1565
                                               AND UPPER(username) LIKE UPPER(?)
1566
                                               AND firstaccess != 0
1567
                                               AND firstaccess = ?",
1568
                                           array($user->mnethostid, $usernamelookup, $user->firstaccess))) {
1569
                return $rec; // Matching user found, return it
1570
            }
1571
 
1572
            // 2C - Handle users deleted in backup file and "alive" in DB
1573
            // If match mnethost and user is deleted in backup file
1574
            // and match by substring(email) = email_without_time(backup_email) and non-zero firstaccess=> ok, return target user.
1575
            if ($user->deleted) {
1576
                // Note: for DB deleted users email is stored in username field, hence we
1577
                //       are looking there for emails. See delete_user()
1578
                // Trim time() from email
1579
                $trimemail = preg_replace('/(.*?)\.[0-9]+.?$/', '\\1', $user->username);
1580
                if ($rec = $DB->get_record_sql("SELECT *
1581
                                                  FROM {user} u
1582
                                                 WHERE mnethostid = ?
1583
                                                   AND " . $DB->sql_substr('UPPER(email)', 1, 89) . " = UPPER(?)
1584
                                                   AND firstaccess != 0
1585
                                                   AND firstaccess = ?",
1586
                                               array($user->mnethostid, $trimemail, $user->firstaccess))) {
1587
                    return $rec; // Matching user, deleted in backup file found, return it
1588
                }
1589
            }
1590
 
1591
            // 2D - If match by username and mnethost and not by (email or non-zero firstaccess) => conflict, return false
1592
            if ($rec = $DB->get_record_sql("SELECT *
1593
                                              FROM {user} u
1594
                                             WHERE username = ?
1595
                                               AND mnethostid = ?
1596
                                           AND NOT (
1597
                                                       UPPER(email) = UPPER(?)
1598
                                                    OR (
1599
                                                           firstaccess != 0
1600
                                                       AND firstaccess = ?
1601
                                                       )
1602
                                                   )",
1603
                                           array($user->username, $user->mnethostid, $user->email, $user->firstaccess))) {
1604
                return false; // Conflict, username/mnethostid already exist and belong to another user (by email/firstaccess)
1605
            }
1606
        }
1607
 
1608
        // Arrived here, return true as the user will need to be created and no
1609
        // conflicts have been found in the logic above. This covers:
1610
        // 1E - else => user needs to be created, return true
1611
        // 2E - else => user needs to be created, return true
1612
        return true;
1613
    }
1614
 
1615
    /**
1616
     * Check all the included users, deciding the action to perform
1617
     * for each one (mapping / creation) and returning one array
1618
     * of problems in case something is wrong (lack of permissions,
1619
     * conficts)
1620
     *
1621
     * @param string $restoreid Restore id
1622
     * @param int $courseid Course id
1623
     * @param int $userid User id
1624
     * @param bool $samesite True if restore is to same site
1625
     * @param \core\progress\base $progress Progress reporter
1626
     */
1627
    public static function precheck_included_users($restoreid, $courseid, $userid, $samesite,
1628
            \core\progress\base $progress) {
1629
        global $CFG, $DB;
1630
 
1631
        // To return any problem found
1632
        $problems = array();
1633
 
1634
        // We are going to map mnethostid, so load all the available ones
1635
        $mnethosts = $DB->get_records('mnet_host', array(), 'wwwroot', 'wwwroot, id');
1636
 
1637
        // Calculate the context we are going to use for capability checking
1638
        $context = context_course::instance($courseid);
1639
 
1640
        // TODO: Some day we must kill this dependency and change the process
1641
        // to pass info around without loading a controller copy.
1642
        // When conflicting users are detected we may need original site info.
1643
        $rc = restore_controller_dbops::load_controller($restoreid);
1644
        $restoreinfo = $rc->get_info();
1645
        $rc->destroy(); // Always need to destroy.
1646
 
1647
        // Calculate if we have perms to create users, by checking:
1648
        // to 'moodle/restore:createuser' and 'moodle/restore:userinfo'
1649
        // and also observe $CFG->disableusercreationonrestore
1650
        $cancreateuser = false;
1651
        if (has_capability('moodle/restore:createuser', $context, $userid) and
1652
            has_capability('moodle/restore:userinfo', $context, $userid) and
1653
            empty($CFG->disableusercreationonrestore)) { // Can create users
1654
 
1655
            $cancreateuser = true;
1656
        }
1657
 
1658
        // Prepare for reporting progress.
1659
        $conditions = array('backupid' => $restoreid, 'itemname' => 'user');
1660
        $max = $DB->count_records('backup_ids_temp', $conditions);
1661
        $done = 0;
1662
        $progress->start_progress('Checking users', $max);
1663
 
1664
        // Iterate over all the included users
1665
        $rs = $DB->get_recordset('backup_ids_temp', $conditions, '', 'itemid, info');
1666
        foreach ($rs as $recuser) {
1667
            $user = (object)backup_controller_dbops::decode_backup_temp_info($recuser->info);
1668
 
1669
            // Find the correct mnethostid for user before performing any further check
1670
            if (empty($user->mnethosturl) || $user->mnethosturl === $CFG->wwwroot) {
1671
                $user->mnethostid = $CFG->mnet_localhost_id;
1672
            } else {
1673
                // fast url-to-id lookups
1674
                if (isset($mnethosts[$user->mnethosturl])) {
1675
                    $user->mnethostid = $mnethosts[$user->mnethosturl]->id;
1676
                } else {
1677
                    $user->mnethostid = $CFG->mnet_localhost_id;
1678
                }
1679
            }
1680
 
1681
            // Now, precheck that user and, based on returned results, annotate action/problem
1682
            $usercheck = self::precheck_user($user, $samesite, $restoreinfo->original_site_identifier_hash);
1683
 
1684
            if (is_object($usercheck)) { // No problem, we have found one user in DB to be mapped to
1685
                // Annotate it, for later process. Set newitemid to mapping user->id
1686
                self::set_backup_ids_record($restoreid, 'user', $recuser->itemid, $usercheck->id);
1687
 
1688
            } else if ($usercheck === false) { // Found conflict, report it as problem
1689
                if (!get_config('backup', 'import_general_duplicate_admin_allowed')) {
1690
                    $problems[] = get_string('restoreuserconflict', '', $user->username);
1691
                } else if ($user->username == 'admin') {
1692
                    if (!$cancreateuser) {
1693
                        $problems[] = get_string('restorecannotcreateuser', '', $user->username);
1694
                    }
1695
                    if ($user->mnethostid != $CFG->mnet_localhost_id) {
1696
                        $problems[] = get_string('restoremnethostidmismatch', '', $user->username);
1697
                    }
1698
                    if (!$problems) {
1699
                        // Duplicate admin allowed, append original site idenfitier to username.
1700
                        $user->username .= '_' . $restoreinfo->original_site_identifier_hash;
1701
                        self::set_backup_ids_record($restoreid, 'user', $recuser->itemid, 0, null, (array)$user);
1702
                    }
1703
                }
1704
 
1705
            } else if ($usercheck === true) { // User needs to be created, check if we are able
1706
                if ($cancreateuser) { // Can create user, set newitemid to 0 so will be created later
1707
                    self::set_backup_ids_record($restoreid, 'user', $recuser->itemid, 0, null, (array)$user);
1708
 
1709
                } else { // Cannot create user, report it as problem
1710
                    $problems[] = get_string('restorecannotcreateuser', '', $user->username);
1711
                }
1712
 
1713
            } else { // Shouldn't arrive here ever, something is for sure wrong. Exception
1714
                throw new restore_dbops_exception('restore_error_processing_user', $user->username);
1715
            }
1716
            $done++;
1717
            $progress->progress($done);
1718
        }
1719
        $rs->close();
1720
        $progress->end_progress();
1721
        return $problems;
1722
    }
1723
 
1724
    /**
1725
     * Process the needed users in order to decide
1726
     * which action to perform with them (create/map)
1727
     *
1728
     * Just wrap over precheck_included_users(), returning
1729
     * exception if any problem is found
1730
     *
1731
     * @param string $restoreid Restore id
1732
     * @param int $courseid Course id
1733
     * @param int $userid User id
1734
     * @param bool $samesite True if restore is to same site
1735
     * @param \core\progress\base $progress Optional progress tracker
1736
     */
1737
    public static function process_included_users($restoreid, $courseid, $userid, $samesite,
1441 ariadna 1738
            ?\core\progress\base $progress = null) {
1 efrain 1739
        global $DB;
1740
 
1741
        // Just let precheck_included_users() to do all the hard work
1742
        $problems = self::precheck_included_users($restoreid, $courseid, $userid, $samesite, $progress);
1743
 
1744
        // With problems, throw exception, shouldn't happen if prechecks were originally
1745
        // executed, so be radical here.
1746
        if (!empty($problems)) {
1747
            throw new restore_dbops_exception('restore_problems_processing_users', null, implode(', ', $problems));
1748
        }
1749
    }
1750
 
1751
    /**
1752
     * Process the needed question categories and questions
1753
     * to check all them, deciding about the action to perform
1754
     * (create/map) and target.
1755
     *
1756
     * Just wrap over precheck_categories_and_questions(), returning
1757
     * exception if any problem is found
1758
     */
1759
    public static function process_categories_and_questions($restoreid, $courseid, $userid, $samesite) {
1760
        global $DB;
1761
 
1762
        // Just let precheck_included_users() to do all the hard work
1763
        $problems = self::precheck_categories_and_questions($restoreid, $courseid, $userid, $samesite);
1764
 
1765
        // With problems of type error, throw exception, shouldn't happen if prechecks were originally
1766
        // executed, so be radical here.
1767
        if (array_key_exists('errors', $problems)) {
1768
            throw new restore_dbops_exception('restore_problems_processing_questions', null, implode(', ', $problems['errors']));
1769
        }
1770
    }
1771
 
1772
    public static function set_backup_files_record($restoreid, $filerec) {
1773
        global $DB;
1774
 
1775
        // Store external files info in `info` field
1776
        $filerec->info     = backup_controller_dbops::encode_backup_temp_info($filerec); // Encode the whole record into info.
1777
        $filerec->backupid = $restoreid;
1778
        $DB->insert_record('backup_files_temp', $filerec);
1779
    }
1780
 
1781
    public static function set_backup_ids_record($restoreid, $itemname, $itemid, $newitemid = 0, $parentitemid = null, $info = null) {
1782
        // Build conditionally the extra record info
1783
        $extrarecord = array();
1784
        if ($newitemid != 0) {
1785
            $extrarecord['newitemid'] = $newitemid;
1786
        }
1787
        if ($parentitemid != null) {
1788
            $extrarecord['parentitemid'] = $parentitemid;
1789
        }
1790
        if ($info != null) {
1791
            $extrarecord['info'] = backup_controller_dbops::encode_backup_temp_info($info);
1792
        }
1793
 
1794
        self::set_backup_ids_cached($restoreid, $itemname, $itemid, $extrarecord);
1795
    }
1796
 
1797
    public static function get_backup_ids_record($restoreid, $itemname, $itemid) {
1798
        $dbrec = self::get_backup_ids_cached($restoreid, $itemname, $itemid);
1799
 
1800
        // We must test if info is a string, as the cache stores info in object form.
1801
        if ($dbrec && isset($dbrec->info) && is_string($dbrec->info)) {
1802
            $dbrec->info = backup_controller_dbops::decode_backup_temp_info($dbrec->info);
1803
        }
1804
 
1805
        return $dbrec;
1806
    }
1807
 
1808
    /**
1809
     * Given on courseid, fullname and shortname, calculate the correct fullname/shortname to avoid dupes
1810
     */
1811
    public static function calculate_course_names($courseid, $fullname, $shortname) {
1812
        global $CFG, $DB;
1813
 
1814
        $counter = 0;
1815
 
1816
        // Iterate while fullname or shortname exist.
1817
        do {
1818
            if ($counter) {
1819
                $suffixfull  = ' ' . get_string('copyasnoun') . ' ' . $counter;
1820
                $suffixshort = '_' . $counter;
1821
            } else {
1822
                $suffixfull  = '';
1823
                $suffixshort = '';
1824
            }
1825
 
1826
            // Ensure we don't overflow maximum length of name fields, in multi-byte safe manner.
1441 ariadna 1827
            $currentfullname = core_text::substr($fullname, 0,
1828
                    \core_course\constants::FULLNAME_MAXIMUM_LENGTH - strlen($suffixfull)) . $suffixfull;
1829
            $currentshortname = core_text::substr($shortname, 0,
1830
                    \core_course\constants::SHORTNAME_MAXIMUM_LENGTH - strlen($suffixshort)) . $suffixshort;
1 efrain 1831
 
1832
            $coursefull  = $DB->get_record_select('course', 'fullname = ? AND id != ?',
1833
                    array($currentfullname, $courseid), '*', IGNORE_MULTIPLE);
1834
            $courseshort = $DB->get_record_select('course', 'shortname = ? AND id != ?', array($currentshortname, $courseid));
1835
            $counter++;
1836
        } while ($coursefull || $courseshort);
1837
 
1838
        // Return results
1839
        return array($currentfullname, $currentshortname);
1840
    }
1841
 
1842
    /**
1843
     * For the target course context, put as many custom role names as possible
1844
     */
1845
    public static function set_course_role_names($restoreid, $courseid) {
1846
        global $DB;
1847
 
1848
        // Get the course context
1849
        $coursectx = context_course::instance($courseid);
1850
        // Get all the mapped roles we have
1851
        $rs = $DB->get_recordset('backup_ids_temp', array('backupid' => $restoreid, 'itemname' => 'role'), '', 'itemid, info, newitemid');
1852
        foreach ($rs as $recrole) {
1853
            $info = backup_controller_dbops::decode_backup_temp_info($recrole->info);
1854
            // If it's one mapped role and we have one name for it
1855
            if (!empty($recrole->newitemid) && !empty($info['nameincourse'])) {
1856
                // If role name doesn't exist, add it
1857
                $rolename = new stdclass();
1858
                $rolename->roleid = $recrole->newitemid;
1859
                $rolename->contextid = $coursectx->id;
1860
                if (!$DB->record_exists('role_names', (array)$rolename)) {
1861
                    $rolename->name = $info['nameincourse'];
1862
                    $DB->insert_record('role_names', $rolename);
1863
                }
1864
            }
1865
        }
1866
        $rs->close();
1867
    }
1868
 
1869
    /**
1870
     * Creates a skeleton record within the database using the passed parameters
1871
     * and returns the new course id.
1872
     *
1873
     * @global moodle_database $DB
1874
     * @param string $fullname
1875
     * @param string $shortname
1876
     * @param int $categoryid
1877
     * @return int The new course id
1878
     */
1879
    public static function create_new_course($fullname, $shortname, $categoryid) {
1880
        global $DB;
1881
        $category = $DB->get_record('course_categories', array('id'=>$categoryid), '*', MUST_EXIST);
1882
 
1883
        $course = new stdClass;
1884
        $course->fullname = $fullname;
1885
        $course->shortname = $shortname;
1886
        $course->category = $category->id;
1887
        $course->sortorder = 0;
1888
        $course->timecreated  = time();
1889
        $course->timemodified = $course->timecreated;
1890
        // forcing skeleton courses to be hidden instead of going by $category->visible , until MDL-27790 is resolved.
1891
        $course->visible = 0;
1892
 
1893
        $courseid = $DB->insert_record('course', $course);
1894
 
1895
        $category->coursecount++;
1896
        $DB->update_record('course_categories', $category);
1897
 
1898
        return $courseid;
1899
    }
1900
 
1901
    /**
1902
     * Deletes all of the content associated with the given course (courseid)
1903
     * @param int $courseid
1904
     * @param array $options
1905
     * @return bool True for success
1906
     */
1441 ariadna 1907
    public static function delete_course_content($courseid, ?array $options = null) {
1 efrain 1908
        return remove_course_contents($courseid, false, $options);
1909
    }
1910
 
1911
    /**
1912
     * Checks if password stored in backup is a MD5 hash.
1913
     * Returns true if it is, false otherwise.
1914
     *
1915
     * @param string $password The password to check.
1916
     * @return bool
1917
     */
1918
    private static function password_should_be_discarded(#[\SensitiveParameter] string $password): bool {
1919
        return (bool) preg_match('/^[0-9a-f]{32}$/', $password);
1920
    }
1441 ariadna 1921
 
1922
    /**
1923
     * Load required classes and return a backup XML transformer for the specified course.
1924
     *
1925
     * These classes may not have been loaded if we're only doing a restore in the current process,
1926
     * so make sure we have them here.
1927
     *
1928
     * @param int $courseid
1929
     * @return backup_xml_transformer
1930
     */
1931
    protected static function get_backup_xml_transformer(int $courseid): backup_xml_transformer {
1932
        global $CFG;
1933
        require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
1934
        require_once($CFG->dirroot . '/backup/moodle2/backup_plan_builder.class.php');
1935
        return new backup_xml_transformer($courseid);
1936
    }
1 efrain 1937
}
1938
 
1939
/*
1940
 * Exception class used by all the @dbops stuff
1941
 */
1942
class restore_dbops_exception extends backup_exception {
1943
 
1944
    public function __construct($errorcode, $a=NULL, $debuginfo=null) {
1945
        parent::__construct($errorcode, 'error', '', $a, null, $debuginfo);
1946
    }
1947
}