Skip to content

Commit 3752ede

Browse files
authored
Merge pull request #7472 from christianbeeznest/dolot-23189
Learnpath: Fix course backup import and LP restore flow - refs BT#23189
2 parents d96de9e + 19e0674 commit 3752ede

File tree

2 files changed

+217
-40
lines changed

2 files changed

+217
-40
lines changed

main/lp/lp_upload.php

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,84 @@
1212
*/
1313
require_once __DIR__.'/../inc/global.inc.php';
1414
api_protect_course_script();
15+
16+
/**
17+
* Imports a Chamilo CourseBackup zip that contains LP data into the current course.
18+
* Returns true only if something was actually restored (basic DB verification).
19+
*/
20+
function chamilo_lp_import_from_zip(string $zipPath, int $sessionId): bool
21+
{
22+
$cid = (int) api_get_course_int_id();
23+
24+
// Basic input validation
25+
if (!is_file($zipPath) || !is_readable($zipPath)) {
26+
return false;
27+
}
28+
29+
// Minimal snapshot to detect real changes
30+
$snapshot = static function () use ($cid): array {
31+
$lp = Database::fetch_array(Database::query(
32+
"SELECT COUNT(*) AS cnt, COALESCE(MAX(iid), 0) AS max_iid
33+
FROM c_lp WHERE c_id = ".$cid
34+
));
35+
$lpItem = Database::fetch_array(Database::query(
36+
"SELECT COUNT(*) AS cnt, COALESCE(MAX(iid), 0) AS max_iid
37+
FROM c_lp_item WHERE c_id = ".$cid
38+
));
39+
40+
return [
41+
'lp_cnt' => (int) ($lp['cnt'] ?? 0),
42+
'lp_max' => (int) ($lp['max_iid'] ?? 0),
43+
'lpi_cnt' => (int) ($lpItem['cnt'] ?? 0),
44+
'lpi_max' => (int) ($lpItem['max_iid'] ?? 0),
45+
];
46+
};
47+
48+
$before = $snapshot();
49+
50+
// Store inside course_backups/
51+
$backupFile = CourseArchiver::importUploadedFile($zipPath);
52+
if ($backupFile === false || $backupFile === '') {
53+
return false;
54+
}
55+
56+
// Guard: ensure file is really there before readCourse()
57+
$backupAbs = CourseArchiver::getBackupDir().$backupFile;
58+
if (!is_file($backupAbs) || !is_readable($backupAbs)) {
59+
return false;
60+
}
61+
62+
// true => delete backup zip after extracting
63+
$course = CourseArchiver::readCourse($backupFile, true);
64+
if (!is_object($course)) {
65+
return false;
66+
}
67+
68+
$restorer = new CourseRestorer($course);
69+
$restorer->set_file_option(FILE_OVERWRITE);
70+
71+
// Restore only what LP import needs (avoid side effects from full course restore)
72+
$allowedTools = ['documents', 'learnpath_category', 'learnpaths', 'scorm_documents', 'assets'];
73+
if (isset($restorer->tools_to_restore) && is_array($restorer->tools_to_restore)) {
74+
$restorer->tools_to_restore = array_values(array_intersect($restorer->tools_to_restore, $allowedTools));
75+
}
76+
77+
// Destination MUST be course code (api_get_course_id)
78+
$destination = (string) api_get_course_id();
79+
if ($destination === '') {
80+
return false;
81+
}
82+
83+
$res = $restorer->restore($destination, (int) $sessionId);
84+
if ($res === false) {
85+
return false;
86+
}
87+
88+
$after = $snapshot();
89+
90+
return $after !== $before;
91+
}
92+
1593
$course_dir = api_get_course_path().'/scorm';
1694
$course_sys_dir = api_get_path(SYS_COURSE_PATH).$course_dir;
1795
if (empty($_POST['current_dir'])) {
@@ -68,14 +146,17 @@
68146

69147
switch ($type) {
70148
case 'chamilo':
71-
$filename = CourseArchiver::importUploadedFile($_FILES['user_file']['tmp_name']);
72-
if ($filename) {
73-
$course = CourseArchiver::readCourse($filename, false);
74-
$courseRestorer = new CourseRestorer($course);
75-
// FILE_SKIP, FILE_RENAME or FILE_OVERWRITE
76-
$courseRestorer->set_file_option(FILE_OVERWRITE);
77-
$courseRestorer->restore('', api_get_session_id());
78-
Display::addFlash(Display::return_message(get_lang('UplUploadSucceeded')));
149+
if (empty($_configuration['allow_lp_chamilo_export'])) {
150+
Display::addFlash(Display::return_message(get_lang('ScormUnknownPackageFormat'), 'warning'));
151+
break;
152+
}
153+
154+
$ok = chamilo_lp_import_from_zip($_FILES['user_file']['tmp_name'], api_get_session_id());
155+
156+
if ($ok) {
157+
Display::addFlash(Display::return_message(get_lang('UplUploadSucceeded'), 'confirmation'));
158+
} else {
159+
Display::addFlash(Display::return_message(get_lang('UploadError'), 'error'));
79160
}
80161
break;
81162
case 'scorm':
@@ -168,6 +249,23 @@
168249
$type = learnpath::getPackageType($s, basename($s));
169250

170251
switch ($type) {
252+
case 'chamilo':
253+
if (empty($_configuration['allow_lp_chamilo_export'])) {
254+
$is_error = true;
255+
Display::addFlash(Display::return_message(get_lang('UnknownPackageFormat'), 'error'));
256+
break;
257+
}
258+
259+
$ok = chamilo_lp_import_from_zip($s, api_get_session_id());
260+
@unlink($s);
261+
262+
if ($ok) {
263+
Display::addFlash(Display::return_message(get_lang('UplUploadSucceeded'), 'confirmation'));
264+
} else {
265+
$is_error = true;
266+
Display::addFlash(Display::return_message(get_lang('UploadError'), 'error'));
267+
}
268+
break;
171269
case 'scorm':
172270
$oScorm = new scorm();
173271
$manifest = $oScorm->import_local_package($s, $current_dir);

src/Chamilo/CourseBundle/Component/CourseCopy/CourseArchiver.php

Lines changed: 111 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -258,25 +258,59 @@ public static function getAvailableBackups($user_id = null)
258258
}
259259

260260
/**
261-
* @param array $file
261+
* @param array|string $file Either $_FILES['...'] array or a tmp path string
262262
*
263-
* @return bool|string
263+
* @return bool|string Returns the stored zip filename (not full path) or false on failure
264264
*/
265265
public static function importUploadedFile($file)
266266
{
267-
$new_filename = uniqid('import_file', true).'.zip';
268-
$new_dir = self::getBackupDir();
269-
if (!is_dir($new_dir)) {
270-
$fs = new Filesystem();
271-
$fs->mkdir($new_dir);
267+
$newFilename = uniqid('import_file', true).'.zip';
268+
$newDir = self::getBackupDir();
269+
270+
// Ensure backup directory exists
271+
if (!is_dir($newDir)) {
272+
@mkdir($newDir, api_get_permissions_for_new_directories(), true);
273+
}
274+
275+
if (!is_dir($newDir) || !is_writable($newDir)) {
276+
return false;
277+
}
278+
279+
// Normalize input
280+
$tmpPath = '';
281+
$uploadErr = 0;
282+
283+
if (is_array($file)) {
284+
$uploadErr = (int) ($file['error'] ?? 0);
285+
$tmpPath = (string) ($file['tmp_name'] ?? '');
286+
} else {
287+
$tmpPath = (string) $file;
272288
}
273-
if (is_dir($new_dir) && is_writable($new_dir)) {
274-
move_uploaded_file($file, $new_dir.$new_filename);
275289

276-
return $new_filename;
290+
if ($uploadErr !== 0 || $tmpPath === '') {
291+
return false;
277292
}
278293

279-
return false;
294+
$destPath = $newDir.$newFilename;
295+
296+
// Prefer move_uploaded_file for real HTTP uploads, fallback to copy for non-upload contexts
297+
$ok = false;
298+
if (function_exists('is_uploaded_file') && is_uploaded_file($tmpPath)) {
299+
$ok = @move_uploaded_file($tmpPath, $destPath);
300+
} elseif (is_file($tmpPath) && is_readable($tmpPath)) {
301+
$ok = @copy($tmpPath, $destPath);
302+
} else {
303+
return false;
304+
}
305+
306+
clearstatcache(true, $destPath);
307+
308+
if (!$ok || !is_file($destPath) || (int) filesize($destPath) <= 0) {
309+
@unlink($destPath);
310+
return false;
311+
}
312+
313+
return $newFilename;
280314
}
281315

282316
/**
@@ -297,36 +331,73 @@ public static function readCourse($filename, $delete = false)
297331
$unzip_dir = self::getBackupDir().$tmp_dir_name;
298332
$filePath = self::getBackupDir().$filename;
299333

300-
@mkdir($unzip_dir, api_get_permissions_for_new_directories(), true);
301-
@copy(
302-
$filePath,
303-
$unzip_dir.'/backup.zip'
304-
);
334+
$perms = api_get_permissions_for_new_directories();
335+
336+
if (!is_dir($unzip_dir) && !@mkdir($unzip_dir, $perms, true)) {
337+
error_log('[COURSE_ARCHIVER] readCourse: failed to create unzip_dir="'.$unzip_dir.'"');
338+
return new Course();
339+
}
340+
341+
if (!is_file($filePath)) {
342+
error_log('[COURSE_ARCHIVER] readCourse: backup zip not found filePath="'.$filePath.'"');
343+
return new Course();
344+
}
305345

306-
// unzip the archive
346+
if (!@copy($filePath, $unzip_dir.'/backup.zip')) {
347+
error_log('[COURSE_ARCHIVER] readCourse: failed to copy zip filePath="'.$filePath.'" to "'.$unzip_dir.'/backup.zip"');
348+
return new Course();
349+
}
350+
351+
// Unzip the archive
307352
$zip = new \PclZip($unzip_dir.'/backup.zip');
308-
@chdir($unzip_dir);
353+
354+
if (!@chdir($unzip_dir)) {
355+
error_log('[COURSE_ARCHIVER] readCourse: chdir failed unzip_dir="'.$unzip_dir.'"');
356+
return new Course();
357+
}
309358

310359
// For course backups we must preserve original filenames so that
311360
// paths in course_info.dat still match the files in backup_path.
312-
$zip->extract(
313-
PCLZIP_OPT_TEMP_FILE_ON
314-
);
361+
$extractResult = $zip->extract(PCLZIP_OPT_TEMP_FILE_ON);
362+
363+
if ($extractResult === 0) {
364+
error_log('[COURSE_ARCHIVER] readCourse: extract failed error="'.$zip->errorInfo(true).'" unzip_dir="'.$unzip_dir.'"');
365+
return new Course();
366+
}
315367

316-
// remove the archive-file
368+
// Remove the archive-file
317369
if ($delete) {
318370
@unlink($filePath);
319371
}
320372

321-
// read the course
373+
// Read the course
322374
if (!is_file('course_info.dat')) {
375+
error_log('[COURSE_ARCHIVER] readCourse: missing course_info.dat cwd="'.getcwd().'" unzip_dir="'.$unzip_dir.'"');
376+
return new Course();
377+
}
378+
379+
$size = (int) @filesize('course_info.dat');
380+
if ($size <= 0) {
381+
error_log('[COURSE_ARCHIVER] readCourse: empty course_info.dat size='.$size.' cwd="'.getcwd().'"');
323382
return new Course();
324383
}
325384

326-
$fp = @fopen('course_info.dat', "r");
327-
$contents = @fread($fp, filesize('course_info.dat'));
385+
$fp = @fopen('course_info.dat', 'r');
386+
if (false === $fp) {
387+
error_log('[COURSE_ARCHIVER] readCourse: failed to open course_info.dat cwd="'.getcwd().'"');
388+
return new Course();
389+
}
390+
391+
$contents = @fread($fp, $size);
328392
@fclose($fp);
329393

394+
$readLen = is_string($contents) ? strlen($contents) : -1;
395+
if (!is_string($contents) || $readLen <= 0) {
396+
error_log('[COURSE_ARCHIVER] readCourse: failed to read course_info.dat');
397+
return new Course();
398+
}
399+
400+
// Backward compatibility aliases used by serialized payloads
330401
class_alias('Chamilo\CourseBundle\Component\CourseCopy\Course', 'Course');
331402
class_alias('Chamilo\CourseBundle\Component\CourseCopy\Resources\Announcement', 'Announcement');
332403
class_alias('Chamilo\CourseBundle\Component\CourseCopy\Resources\Attendance', 'Attendance');
@@ -357,17 +428,25 @@ class_alias('Chamilo\CourseBundle\Component\CourseCopy\Resources\Wiki', 'Wiki');
357428
class_alias('Chamilo\CourseBundle\Component\CourseCopy\Resources\Work', 'Work');
358429
class_alias('Chamilo\CourseBundle\Component\CourseCopy\Resources\XapiTool', 'XapiTool');
359430

360-
/** @var Course $course */
361-
$course = \UnserializeApi::unserialize('course', base64_decode($contents));
431+
$decoded = base64_decode($contents, true);
432+
if (false === $decoded) {
433+
error_log('[COURSE_ARCHIVER] readCourse: base64_decode strict failed, retry non-strict');
434+
$decoded = base64_decode($contents);
435+
}
436+
437+
if (!is_string($decoded) || $decoded === '') {
438+
error_log('[COURSE_ARCHIVER] readCourse: base64_decode produced empty payload');
439+
return new Course();
440+
}
362441

363-
if (!in_array(
364-
get_class($course),
365-
['Course', 'Chamilo\CourseBundle\Component\CourseCopy\Course']
366-
)
367-
) {
442+
/** @var mixed $course */
443+
$course = \UnserializeApi::unserialize('course', $decoded);
444+
if (!is_object($course) || !in_array(get_class($course), ['Course', 'Chamilo\CourseBundle\Component\CourseCopy\Course'], true)) {
445+
error_log('[COURSE_ARCHIVER] readCourse: invalid class after unserialize, returning empty Course');
368446
return new Course();
369447
}
370448

449+
// Ensure backup_path is always set when unserialize is successful
371450
$course->backup_path = $unzip_dir;
372451

373452
return $course;

0 commit comments

Comments
 (0)