Passed
Pull Request — master (#6935)
by
unknown
08:44
created

CourseArchiver::readCourse()   F

Complexity

Conditions 28
Paths 5940

Size

Total Lines 155
Code Lines 94

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 28
eloc 94
c 0
b 0
f 0
nc 5940
nop 2
dl 0
loc 155
rs 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CourseBundle\Component\CourseCopy;
8
9
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Asset;
10
use Chamilo\CourseBundle\Component\CourseCopy\Resources\Document;
11
use DateTime;
12
use PclZip;
13
use RecursiveDirectoryIterator;
14
use RecursiveIteratorIterator;
15
use RuntimeException;
16
use Symfony\Component\Filesystem\Filesystem;
17
use Throwable;
18
use UnserializeApi;
19
use ZipArchive;
20
21
use const PATHINFO_EXTENSION;
22
23
/**
24
 * Some functions to write a course-object to a zip-file and to read a course-
25
 * object from such a zip-file.
26
 *
27
 * Hardened to support PHP 8+ typed properties and legacy backups.
28
 *
29
 * @author Bart Mollet
30
 */
31
class CourseArchiver
32
{
33
    /** @var bool Global debug flag (true by default) */
34
    private static bool $debug = true;
35
36
    /** Debug logger (safe JSON, truncated) */
37
    private static function dlog(string $stage, mixed $payload = null): void
38
    {
39
        if (!self::$debug) { return; }
40
        $prefix = 'COURSE_ARCHIVER';
41
        if ($payload === null) {
42
            error_log("$prefix: $stage");
43
            return;
44
        }
45
        try {
46
            $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
47
            if ($json !== null && strlen($json) > 8000) {
48
                $json = substr($json, 0, 8000) . '…(truncated)';
49
            }
50
        } catch (Throwable $e) {
51
            $json = '[payload_json_error: ' . $e->getMessage() . ']';
52
        }
53
        error_log("$prefix: $stage -> " . $json);
54
    }
55
56
    /** Allow toggling debug at runtime. */
57
    public static function setDebug(?bool $flag): void
58
    {
59
        if ($flag === null) { return; }
60
        self::$debug = (bool) $flag;
61
    }
62
63
    /** Expose aliases/typed-props helpers to other components. */
64
    public static function preprocessSerializedPayloadForTypedProps(string $serialized): string
65
    {
66
        return self::coerceNumericStringsInSerialized($serialized);
67
    }
68
    public static function ensureLegacyAliases(): void
69
    {
70
        self::registerLegacyAliases();
71
    }
72
73
    /** @return string */
74
    public static function getBackupDir()
75
    {
76
        return api_get_path(SYS_ARCHIVE_PATH) . 'course_backups/';
77
    }
78
79
    /** @return string */
80
    public static function createBackupDir()
81
    {
82
        $perms = api_get_permissions_for_new_directories();
83
        $dir = self::getBackupDir();
84
        $fs = new Filesystem();
85
        $fs->mkdir($dir, $perms);
86
        self::dlog('createBackupDir', ['dir' => $dir, 'perms' => $perms]);
87
88
        return $dir;
89
    }
90
91
    /** Delete old temp-dirs. */
92
    public static function cleanBackupDir(): void
93
    {
94
        $dir = self::getBackupDir();
95
        self::dlog('cleanBackupDir.begin', ['dir' => $dir]);
96
97
        if (is_dir($dir)) {
98
            if ($handle = @opendir($dir)) {
99
                while (false !== ($file = readdir($handle))) {
100
                    if ($file !== '.' && $file !== '..'
101
                        && str_starts_with($file, 'CourseArchiver_')
102
                        && is_dir($dir . '/' . $file)
103
                    ) {
104
                        @rmdirr($dir . '/' . $file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rmdirr(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

104
                        /** @scrutinizer ignore-unhandled */ @rmdirr($dir . '/' . $file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
105
                        self::dlog('cleanBackupDir.removed', ['path' => $dir . '/' . $file]);
106
                    }
107
                }
108
                closedir($handle);
109
            }
110
        }
111
112
        self::dlog('cleanBackupDir.end');
113
    }
114
115
    /**
116
     * Write a course and all its resources to a zip-file.
117
     *
118
     * @param mixed $course
119
     * @return string A pointer to the zip-file
120
     */
121
    public static function createBackup($course)
122
    {
123
        self::cleanBackupDir();
124
        self::createBackupDir();
125
126
        $perm_dirs = api_get_permissions_for_new_directories();
127
        $backupDirectory = self::getBackupDir();
128
129
        // Create a temp directory
130
        $backup_dir = $backupDirectory . 'CourseArchiver_' . api_get_unique_id() . '/';
131
132
        // All course-information will be stored in course_info.dat
133
        $course_info_file = $backup_dir . 'course_info.dat';
134
135
        $user = api_get_user_info();
136
        $date = new DateTime(api_get_local_time());
137
        $zipFileName = $user['user_id'] . '_' . $course->code . '_' . $date->format('Ymd-His') . '.zip';
138
        $zipFilePath = $backupDirectory . $zipFileName;
139
140
        self::dlog('createBackup.begin', [
141
            'zip' => $zipFileName,
142
            'backup_dir' => $backup_dir,
143
            'course_code' => $course->code ?? null,
144
        ]);
145
146
        $php_errormsg = '';
147
        $res = @mkdir($backup_dir, $perm_dirs);
148
        if ($res === false) {
149
            error_log(__FILE__ . ' line ' . __LINE__ . ': ' . ($php_errormsg ?: 'mkdir failed') . ' - Archive directory not writable; will prevent backups.', 0);
150
        }
151
152
        // Write the course-object to the file
153
        $fp = @fopen($course_info_file, 'w');
154
        if ($fp === false) {
155
            error_log(__FILE__ . ' line ' . __LINE__ . ': ' . ($php_errormsg ?: 'fopen failed for course_info.dat'), 0);
156
        }
157
158
        $serialized = @serialize($course);
159
        $b64 = base64_encode($serialized);
160
        $okWrite = $fp !== false ? @fwrite($fp, $b64) : false;
161
        if ($okWrite === false) {
162
            error_log(__FILE__ . ' line ' . __LINE__ . ': ' . ($php_errormsg ?: 'fwrite failed for course_info.dat'), 0);
163
        }
164
        if ($fp !== false) {
165
            @fclose($fp);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

165
            /** @scrutinizer ignore-unhandled */ @fclose($fp);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
166
        }
167
168
        self::dlog('createBackup.course_info', [
169
            'size_bytes' => @filesize($course_info_file),
170
            'md5' => @md5_file($course_info_file),
171
        ]);
172
173
        // Copy all documents to the temp-dir
174
        if (isset($course->resources[RESOURCE_DOCUMENT]) && is_array($course->resources[RESOURCE_DOCUMENT])) {
175
            $webEditorCss = api_get_path(WEB_CSS_PATH) . 'editor.css';
176
177
            /** @var Document $document */
178
            foreach ($course->resources[RESOURCE_DOCUMENT] as $document) {
179
                if ('document' === $document->file_type) {
180
                    $doc_dir = $backup_dir . $document->path;
181
                    @mkdir(dirname($doc_dir), $perm_dirs, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

181
                    /** @scrutinizer ignore-unhandled */ @mkdir(dirname($doc_dir), $perm_dirs, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
182
                    if (file_exists($course->path . $document->path)) {
183
                        @copy($course->path . $document->path, $doc_dir);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for copy(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

183
                        /** @scrutinizer ignore-unhandled */ @copy($course->path . $document->path, $doc_dir);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
184
                        // Check if is html or htm
185
                        $extension = pathinfo(basename($document->path), PATHINFO_EXTENSION);
186
                        switch ($extension) {
187
                            case 'html':
188
                            case 'htm':
189
                                $contents = @file_get_contents($doc_dir);
190
                                if ($contents !== false) {
191
                                    $contents = str_replace(
192
                                        $webEditorCss,
193
                                        '{{css_editor}}',
194
                                        $contents
195
                                    );
196
                                    @file_put_contents($doc_dir, $contents);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

196
                                    /** @scrutinizer ignore-unhandled */ @file_put_contents($doc_dir, $contents);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
197
                                }
198
                                break;
199
                        }
200
                    }
201
                } else {
202
                    @mkdir($backup_dir . $document->path, $perm_dirs, true);
203
                }
204
            }
205
        }
206
207
        // Copy all scorm documents to the temp-dir
208
        if (isset($course->resources[RESOURCE_SCORM]) && is_array($course->resources[RESOURCE_SCORM])) {
209
            foreach ($course->resources[RESOURCE_SCORM] as $document) {
210
                @copyDirTo($course->path . $document->path, $backup_dir . $document->path, false);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for copyDirTo(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

210
                /** @scrutinizer ignore-unhandled */ @copyDirTo($course->path . $document->path, $backup_dir . $document->path, false);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
211
            }
212
        }
213
214
        // Copy calendar attachments.
215
        if (isset($course->resources[RESOURCE_EVENT]) && is_array($course->resources[RESOURCE_EVENT])) {
216
            $doc_dir = dirname($backup_dir . '/upload/calendar/');
217
            @mkdir($doc_dir, $perm_dirs, true);
218
            @copyDirTo($course->path . 'upload/calendar/', $doc_dir, false);
219
        }
220
221
        // Copy Learning path author image.
222
        if (isset($course->resources[RESOURCE_LEARNPATH]) && is_array($course->resources[RESOURCE_LEARNPATH])) {
223
            $doc_dir = dirname($backup_dir . '/upload/learning_path/');
224
            @mkdir($doc_dir, $perm_dirs, true);
225
            @copyDirTo($course->path . 'upload/learning_path/', $doc_dir, false);
226
        }
227
228
        // Copy announcements attachments.
229
        if (isset($course->resources[RESOURCE_ANNOUNCEMENT]) && is_array($course->resources[RESOURCE_ANNOUNCEMENT])) {
230
            $doc_dir = dirname($backup_dir . '/upload/announcements/');
231
            @mkdir($doc_dir, $perm_dirs, true);
232
            @copyDirTo($course->path . 'upload/announcements/', $doc_dir, false);
233
        }
234
235
        // Copy work folders (only folders)
236
        if (isset($course->resources[RESOURCE_WORK]) && is_array($course->resources[RESOURCE_WORK])) {
237
            $doc_dir = $backup_dir . 'work';
238
            @mkdir($doc_dir, $perm_dirs, true);
239
            @copyDirWithoutFilesTo($course->path . 'work/', $doc_dir);
0 ignored issues
show
Bug introduced by
The function copyDirWithoutFilesTo was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

239
            @/** @scrutinizer ignore-call */ copyDirWithoutFilesTo($course->path . 'work/', $doc_dir);
Loading history...
240
        }
241
242
        if (isset($course->resources[RESOURCE_ASSET]) && is_array($course->resources[RESOURCE_ASSET])) {
243
            /** @var Asset $asset */
244
            foreach ($course->resources[RESOURCE_ASSET] as $asset) {
245
                $doc_dir = $backup_dir . $asset->path;
246
                @mkdir(dirname($doc_dir), $perm_dirs, true);
247
                $assetPath = $course->path . $asset->path;
248
249
                if (!file_exists($assetPath)) {
250
                    continue;
251
                }
252
253
                if (is_dir($assetPath)) {
254
                    @copyDirTo($assetPath, $doc_dir, false);
255
                } else {
256
                    @copy($assetPath, $doc_dir);
257
                }
258
            }
259
        }
260
261
        // Zip the course-contents
262
        $zip = new PclZip($zipFilePath);
263
        $zip->create($backup_dir, PCLZIP_OPT_REMOVE_PATH, $backup_dir);
0 ignored issues
show
Bug introduced by
The constant Chamilo\CourseBundle\Com...\PCLZIP_OPT_REMOVE_PATH was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
264
265
        // Remove the temp-dir.
266
        @rmdirr($backup_dir);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rmdirr(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

266
        /** @scrutinizer ignore-unhandled */ @rmdirr($backup_dir);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
267
268
        self::dlog('createBackup.end', ['zip' => $zipFileName, 'path' => $zipFilePath]);
269
270
        return $zipFileName;
271
    }
272
273
    /**
274
     * @param int $user_id
275
     * @return array
276
     */
277
    public static function getAvailableBackups($user_id = null)
278
    {
279
        $backup_files = [];
280
        $dirname = self::getBackupDir();
281
282
        if (!file_exists($dirname)) {
283
            $dirname = self::createBackupDir();
284
        }
285
286
        if ($dir = opendir($dirname)) {
287
            while (false !== ($file = readdir($dir))) {
288
                $file_parts = explode('_', $file);
289
                if (3 == count($file_parts)) {
290
                    $owner_id = $file_parts[0];
291
                    $course_code = $file_parts[1];
292
                    $file_parts = explode('.', $file_parts[2]);
293
                    $date = $file_parts[0];
294
                    $ext = $file_parts[1] ?? null;
295
                    if ('zip' == $ext && ((null != $user_id && $owner_id == $user_id) || (null == $user_id))) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $user_id of type integer|null against null; this is ambiguous if the integer can be zero. Consider using a strict comparison !== instead.
Loading history...
Bug Best Practice introduced by
It seems like you are loosely comparing $user_id of type integer|null against null; this is ambiguous if the integer can be zero. Consider using a strict comparison === instead.
Loading history...
296
                        $date =
297
                            substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' .
298
                            substr($date, 6, 2) . ' ' . substr($date, 9, 2) . ':' .
299
                            substr($date, 11, 2) . ':' . substr($date, 13, 2);
300
                        $backup_files[] = [
301
                            'file' => $file,
302
                            'date' => $date,
303
                            'course_code' => $course_code,
304
                        ];
305
                    }
306
                }
307
            }
308
            closedir($dir);
309
        }
310
311
        return $backup_files;
312
    }
313
314
    /**
315
     * @param array|string $file path or $_FILES['tmp_name']
316
     * @return bool|string
317
     */
318
    public static function importUploadedFile($file)
319
    {
320
        $new_filename = uniqid('import_file', true) . '.zip';
321
        $new_dir = self::getBackupDir();
322
        if (!is_dir($new_dir)) {
323
            $fs = new Filesystem();
324
            $fs->mkdir($new_dir);
325
        }
326
        if (is_dir($new_dir) && is_writable($new_dir)) {
327
            // move_uploaded_file() may fail in CLI/tests; try rename() as fallback
328
            $src = is_array($file) ? ($file['tmp_name'] ?? '') : (string) $file;
329
            $dst = $new_dir . $new_filename;
330
331
            $moved = @move_uploaded_file($src, $dst);
332
            if (!$moved) {
333
                $moved = @rename($src, $dst);
334
            }
335
            if ($moved) {
336
                self::dlog('importUploadedFile.ok', ['dst' => $dst, 'size' => @filesize($dst)]);
337
                return $new_filename;
338
            }
339
340
            self::dlog('importUploadedFile.fail', ['src' => $src, 'dst' => $dst]);
341
            return false;
342
        }
343
344
        self::dlog('importUploadedFile.dir_not_writable', ['dir' => $new_dir]);
345
        return false;
346
    }
347
348
    /**
349
     * Read a legacy course backup (.zip) and return a Course object.
350
     * - Extracts the zip into a temp dir.
351
     * - Finds and decodes course_info.dat:
352
     *     prefers base64(serialize), then raw serialize as fallback.
353
     * - Registers legacy aliases/stubs BEFORE unserialize (critical).
354
     * - Coerces typed-prop numeric strings BEFORE unserialize.
355
     * - Tries relaxed unserialize (no class instantiation) on failure and converts incomplete classes to stdClass.
356
     * - Normalizes common numeric identifiers AFTER unserialize (safe).
357
     *
358
     * @throws RuntimeException
359
     */
360
    public static function readCourse(string $filename, bool $delete = false): false|Course
361
    {
362
        self::cleanBackupDir();
363
        self::createBackupDir();
364
365
        $backupDir = rtrim(self::getBackupDir(), '/') . '/';
366
        $zipPath = $backupDir . $filename;
367
368
        if (!is_file($zipPath)) {
369
            throw new RuntimeException('Backup file not found: ' . $filename);
370
        }
371
372
        self::dlog('readCourse.begin', ['filename' => $filename, 'zipPath' => $zipPath]);
373
374
        // 1) Extract zip into a temp directory
375
        $tmp = $backupDir . 'CourseArchiver_' . uniqid('', true) . '/';
376
        (new Filesystem())->mkdir($tmp);
377
378
        $zip = new ZipArchive();
379
        if (true !== $zip->open($zipPath)) {
380
            throw new RuntimeException('Cannot open zip: ' . $filename);
381
        }
382
        if (!$zip->extractTo($tmp)) {
383
            $zip->close();
384
            throw new RuntimeException('Cannot extract zip: ' . $filename);
385
        }
386
        $zip->close();
387
388
        // 2) Read course_info.dat (search nested if necessary)
389
        $courseInfoDat = $tmp . 'course_info.dat';
390
        if (!is_file($courseInfoDat)) {
391
            $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp));
392
            foreach ($rii as $f) {
393
                if ($f->isFile() && $f->getFilename() === 'course_info.dat') {
394
                    $courseInfoDat = $f->getPathname();
395
                    break;
396
                }
397
            }
398
            if (!is_file($courseInfoDat)) {
399
                throw new RuntimeException('course_info.dat not found in backup');
400
            }
401
        }
402
403
        $raw = (string) @file_get_contents($courseInfoDat);
404
        if ($raw === '') {
405
            throw new RuntimeException('course_info.dat is empty');
406
        }
407
408
        self::dlog('readCourse.course_info', [
409
            'path' => $courseInfoDat,
410
            'size' => strlen($raw),
411
            'md5'  => md5($raw),
412
            'magic_ascii' => preg_replace('/[^\x20-\x7E]/', '.', substr($raw, 0, 16)),
413
            'magic_hex'   => bin2hex(substr($raw, 0, 8)),
414
        ]);
415
416
        // 3) Decode: prefer base64(serialize), else raw serialize
417
        $payload = base64_decode($raw, true);
418
        $encoding = 'base64(php-serialize)';
419
420
        if ($payload === false) {
421
            // Some very old backups stored raw serialize without base64
422
            $payload = $raw;
423
            $encoding = 'php-serialize';
424
        }
425
426
        // 4) Coerce numeric-string identifiers to int in serialized payload
427
        $payload = self::coerceNumericStringsInSerialized($payload);
428
429
        // 5) Register legacy aliases BEFORE unserialize
430
        self::registerLegacyAliases();
431
432
        // 6) Unserialize with robust fallbacks
433
        $course = null;
434
        $unserOk = false;
435
        $unserErr = null;
436
        $usedRelaxed = false;
437
438
        set_error_handler(static function () { /* suppress E_NOTICE/E_WARNING from unserialize */ });
439
        try {
440
            if (class_exists('UnserializeApi')) {
441
                /** @var Course $c */
442
                $c = UnserializeApi::unserialize('course', $payload);
443
                $course = $c;
444
                $unserOk = is_object($course);
445
            } else {
446
                /** @var Course|false $c */
447
                $c = @unserialize($payload, ['allowed_classes' => true]); // may throw TypeError with typed props
448
                if (is_object($c) || ($c === false && trim($payload) === 'b:0;')) {
449
                    $course = $c;
450
                    $unserOk = is_object($course);
451
                } else {
452
                    $unserOk = false;
453
                }
454
455
                // Relaxed fallback: do not instantiate classes; convert incomplete classes to stdClass
456
                if (!$unserOk) {
457
                    /** @var mixed $c2 */
458
                    $c2 = @unserialize($payload, ['allowed_classes' => false]);
459
                    if ($c2 !== false || trim($payload) === 'b:0;') {
460
                        $c2 = self::deincomplete($c2);
461
                        if (is_object($c2)) {
462
                            $course = $c2;
463
                            $unserOk = true;
464
                            $usedRelaxed = true;
465
                        }
466
                    }
467
                }
468
            }
469
        } catch (Throwable $e) {
470
            $unserErr = $e->getMessage();
471
472
            // Hard fallback inside catch as well
473
            try {
474
                $c2 = @unserialize($payload, ['allowed_classes' => false]);
475
                if ($c2 !== false || trim($payload) === 'b:0;') {
476
                    $c2 = self::deincomplete($c2);
477
                    if (is_object($c2)) {
478
                        $course = $c2;
479
                        $unserOk = true;
480
                        $usedRelaxed = true;
481
                        $unserErr = null;
482
                    }
483
                }
484
            } catch (Throwable $e2) {
485
                $unserErr = $unserErr . ' | relaxed: ' . $e2->getMessage();
486
            }
487
        } finally {
488
            restore_error_handler();
489
        }
490
491
        self::dlog('readCourse.unserialize', [
492
            'ok' => $unserOk,
493
            'encoding' => $encoding,
494
            'relaxed' => $usedRelaxed,
495
            'error' => $unserErr,
496
        ]);
497
498
        if (!$unserOk || !is_object($course)) {
499
            throw new RuntimeException('Could not unserialize legacy course');
500
        }
501
502
        // 7) Normalize numeric identifiers post-unserialize (safe)
503
        self::normalizeIds($course);
504
505
        // 8) Optionally delete uploaded file (compat with v1)
506
        if ($delete && is_file($zipPath)) {
507
            @unlink($zipPath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

507
            /** @scrutinizer ignore-unhandled */ @unlink($zipPath);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
508
            self::dlog('readCourse.deleted_zip', ['path' => $zipPath]);
509
        }
510
511
        self::dlog('readCourse.end', ['ok' => true]);
512
513
        // Keep temp dir; some restore flows need extracted files
514
        return $course;
515
    }
516
517
    /**
518
     * Convert selected numeric-string fields to integers inside the serialized payload
519
     * to avoid "Cannot assign string to property ... of type int" on typed properties.
520
     *
521
     * It handles public, protected ("\0*\0key") and private ("\0Class\0key") property names.
522
     * We only coerce known identifier keys to keep it safe.
523
     */
524
    private static function coerceNumericStringsInSerialized(string $ser): string
525
    {
526
        // If it looks like JSON, do nothing (defensive)
527
        $t = ltrim($ser);
528
        if ($t !== '' && ($t[0] === '{' || $t[0] === '[')) {
529
            self::dlog('coerceNumericStrings.skip_json');
530
            return $ser;
531
        }
532
533
        // Common identifier keys (conservative list)
534
        $keys = [
535
            'id','iid','c_id','parent_id','thematic_id','attendance_id',
536
            'room_id','display_order','session_id','category_id',
537
            // forum/link/doc/quiz/survey typical relations:
538
            'forum_id','thread_id','post_id','survey_id','question_id',
539
            'document_id','doc_id','link_id','quiz_id','work_id',
540
        ];
541
542
        $alternatives = [];
543
        foreach ($keys as $k) {
544
            $alternatives[] = preg_quote($k, '/');                      // public
545
            $alternatives[] = "\x00\\*\x00" . preg_quote($k, '/');      // protected
546
            $alternatives[] = "\x00[^\x00]+\x00" . preg_quote($k, '/'); // private (any class)
547
        }
548
        $nameAlt = '(?:' . implode('|', $alternatives) . ')';
549
550
        // Pattern: property name token, then value token -> coerce s:"123" to i:123
551
        $pattern = '/(s:\d+:"' . $nameAlt . '";)s:\d+:"(\d+)";/s';
552
553
        $fixed = preg_replace_callback(
554
            $pattern,
555
            static fn($m) => $m[1] . 'i:' . $m[2] . ';',
556
            $ser
557
        );
558
559
        if ($fixed !== null && $fixed !== $ser) {
560
            self::dlog('coerceNumericStrings.changed', ['delta_len' => strlen($fixed) - strlen($ser)]);
561
            return $fixed;
562
        }
563
564
        self::dlog('coerceNumericStrings.no_change');
565
        return $ser;
566
    }
567
568
    /**
569
     * Recursively cast common identifier fields to int after unserialize (safe).
570
     * - Never writes into private/protected properties (names starting with "\0").
571
     * - Most coercion should happen *before* unserialize; this is a safety net.
572
     */
573
    private static function normalizeIds(mixed &$node): void
574
    {
575
        $castKeys = [
576
            'id','iid','c_id','parent_id','thematic_id','attendance_id',
577
            'room_id','display_order','session_id','category_id',
578
            'forum_id','thread_id','post_id','survey_id','question_id',
579
            'document_id','doc_id','link_id','quiz_id','work_id',
580
        ];
581
582
        if (is_array($node)) {
583
            foreach ($node as &$v) {
584
                self::normalizeIds($v);
585
            }
586
            return;
587
        }
588
589
        if (is_object($node)) {
590
            foreach (get_object_vars($node) as $k => $v) {
591
                // Skip private/protected property names (start with NUL)
592
                if (is_string($k) && $k !== '' && $k[0] === "\0") {
593
                    self::normalizeIds($v);
594
                    continue;
595
                }
596
597
                if (in_array($k, $castKeys, true) && (is_string($v) || is_numeric($v))) {
598
                    try {
599
                        $node->{$k} = (int) $v;
600
                    } catch (Throwable) {
0 ignored issues
show
Unused Code introduced by
catch (\Throwable) is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
601
                        // Read-only or typed mismatch: ignore
602
                    }
603
                    continue;
604
                }
605
606
                self::normalizeIds($node->{$k});
607
            }
608
        }
609
    }
610
611
    /**
612
     * Replace any __PHP_Incomplete_Class instances with stdClass (deep).
613
     * Also traverses arrays and objects.
614
     */
615
    private static function deincomplete(mixed $v): mixed
616
    {
617
        // Handle leaf
618
        if ($v instanceof \__PHP_Incomplete_Class) {
619
            $o = new \stdClass();
620
            foreach (get_object_vars($v) as $k => $vv) {
621
                $o->{$k} = self::deincomplete($vv);
622
            }
623
            return $o;
624
        }
625
        // Recurse arrays
626
        if (is_array($v)) {
627
            foreach ($v as $k => $vv) {
628
                $v[$k] = self::deincomplete($vv);
629
            }
630
            return $v;
631
        }
632
        // Recurse stdClass or any object
633
        if (is_object($v)) {
634
            foreach (get_object_vars($v) as $k => $vv) {
635
                $v->{$k} = self::deincomplete($vv);
636
            }
637
            return $v;
638
        }
639
        return $v;
640
    }
641
642
    /**
643
     * Keep the old alias map so unserialize works exactly like v1.
644
     */
645
    private static function registerLegacyAliases(): void
646
    {
647
        $aliases = [
648
            'Chamilo\CourseBundle\Component\CourseCopy\Course' => 'Course',
649
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Announcement' => 'Announcement',
650
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Attendance' => 'Attendance',
651
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\CalendarEvent' => 'CalendarEvent',
652
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseCopyLearnpath' => 'CourseCopyLearnpath',
653
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseCopyTestCategory' => 'CourseCopyTestCategory',
654
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseDescription' => 'CourseDescription',
655
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\CourseSession' => 'CourseSession',
656
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Document' => 'Document',
657
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Forum' => 'Forum',
658
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\ForumCategory' => 'ForumCategory',
659
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\ForumPost' => 'ForumPost',
660
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\ForumTopic' => 'ForumTopic',
661
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Glossary' => 'Glossary',
662
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\GradeBookBackup' => 'GradeBookBackup',
663
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Link' => 'Link',
664
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\LinkCategory' => 'LinkCategory',
665
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Quiz' => 'Quiz',
666
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\QuizQuestion' => 'QuizQuestion',
667
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\QuizQuestionOption' => 'QuizQuestionOption',
668
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\ScormDocument' => 'ScormDocument',
669
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Survey' => 'Survey',
670
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\SurveyInvitation' => 'SurveyInvitation',
671
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\SurveyQuestion' => 'SurveyQuestion',
672
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Thematic' => 'Thematic',
673
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\ToolIntro' => 'ToolIntro',
674
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Wiki' => 'Wiki',
675
            'Chamilo\CourseBundle\Component\CourseCopy\Resources\Work' => 'Work',
676
        ];
677
678
        foreach ($aliases as $fqcn => $alias) {
679
            if (!class_exists($alias)) {
680
                class_alias($fqcn, $alias);
681
            }
682
        }
683
684
        self::dlog('registerLegacyAliases.done', ['count' => count($aliases)]);
685
    }
686
}
687