| Total Complexity | 126 |
| Total Lines | 654 |
| Duplicated Lines | 0 % |
| Changes | 0 | ||
Complex classes like CourseArchiver often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use CourseArchiver, and based on these observations, apply Extract Interface, too.
| 1 | <?php |
||
| 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); |
||
|
|
|||
| 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); |
||
| 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); |
||
| 182 | if (file_exists($course->path . $document->path)) { |
||
| 183 | @copy($course->path . $document->path, $doc_dir); |
||
| 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); |
||
| 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); |
||
| 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); |
||
| 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); |
||
| 264 | |||
| 265 | // Remove the temp-dir. |
||
| 266 | @rmdirr($backup_dir); |
||
| 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))) { |
||
| 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 |
||
| 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 |
||
| 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 |
||
| 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 |
||
| 685 | } |
||
| 686 | } |
||
| 687 |
If you suppress an error, we recommend checking for the error condition explicitly: