Differences Between: [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * Zip writer wrapper. 19 * 20 * @package core 21 * @copyright 2020 Simey Lameze <simey@moodle.com> 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 namespace core\content\export; 25 26 use context; 27 use context_system; 28 use moodle_url; 29 use stdClass; 30 use stored_file; 31 32 /** 33 * Zip writer wrapper. 34 * 35 * @copyright 2020 Simey Lameze <simey@moodle.com> 36 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 37 */ 38 class zipwriter { 39 40 /** @var int Maximum folder length name for a context */ 41 const MAX_CONTEXT_NAME_LENGTH = 32; 42 43 /** @var \ZipStream\ZipStream */ 44 protected $archive; 45 46 /** @var int Max file size of an individual file in the archive */ 47 protected $maxfilesize = 1 * 1024 * 1024 * 10; 48 49 /** @var resource File resource for the file handle for a file-based zip stream */ 50 protected $zipfilehandle = null; 51 52 /** @var string File path for a file-based zip stream */ 53 protected $zipfilepath = null; 54 55 /** @var context The context to use as a base for export */ 56 protected $rootcontext = null; 57 58 /** @var array The files in the zip */ 59 protected $filesinzip = []; 60 61 /** @var bool Whether page requirements needed for HTML pages have been added */ 62 protected $pagerequirementsadded = false; 63 64 /** @var stdClass The course relating to the root context */ 65 protected $course; 66 67 /** @var context The context of the course for the root contect */ 68 protected $coursecontext; 69 70 /** 71 * zipwriter constructor. 72 * 73 * @param \ZipStream\ZipStream $archive 74 * @param stdClass|null $options 75 */ 76 public function __construct(\ZipStream\ZipStream $archive, stdClass $options = null) { 77 $this->archive = $archive; 78 if ($options) { 79 $this->parse_options($options); 80 } 81 82 $this->rootcontext = context_system::instance(); 83 } 84 85 /** 86 * Set a root context for use during the export. 87 * 88 * This is primarily used for creating paths within the archive relative to the root context. 89 * 90 * @param context $rootcontext 91 */ 92 public function set_root_context(context $rootcontext): void { 93 $this->rootcontext = $rootcontext; 94 } 95 96 /** 97 * Get the course object for the root context. 98 * 99 * @return stdClass 100 */ 101 protected function get_course(): stdClass { 102 if ($this->course && ($this->coursecontext !== $this->rootcontext->get_course_context())) { 103 $this->coursecontext = null; 104 $this->course = null; 105 } 106 if (empty($this->course)) { 107 $this->coursecontext = $this->rootcontext->get_course_context(); 108 $this->course = get_course($this->coursecontext->instanceid); 109 } 110 111 return $this->course; 112 } 113 114 /** 115 * Parse options. 116 * 117 * @param stdClass $options 118 */ 119 protected function parse_options(stdClass $options): void { 120 if (property_exists($options, 'maxfilesize')) { 121 $this->maxfilesize = $options->maxfilesize; 122 } 123 } 124 125 /** 126 * Finish writing the zip footer. 127 */ 128 public function finish(): void { 129 $this->archive->finish(); 130 131 if ($this->zipfilehandle) { 132 fclose($this->zipfilehandle); 133 } 134 } 135 136 /** 137 * Get the stream writer. 138 * 139 * @param string $filename 140 * @param stdClass|null $exportoptions 141 * @return static 142 */ 143 public static function get_stream_writer(string $filename, stdClass $exportoptions = null) { 144 $options = new \ZipStream\Option\Archive(); 145 $options->setSendHttpHeaders(true); 146 $archive = new \ZipStream\ZipStream($filename, $options); 147 148 $zipwriter = new static($archive, $exportoptions); 149 150 \core\session\manager::write_close(); 151 return $zipwriter; 152 } 153 154 /** 155 * Get the file writer. 156 * 157 * @param string $filename 158 * @param stdClass|null $exportoptions 159 * @return static 160 */ 161 public static function get_file_writer(string $filename, stdClass $exportoptions = null) { 162 $options = new \ZipStream\Option\Archive(); 163 164 $dir = make_request_directory(); 165 $filepath = $dir . "/$filename"; 166 $fh = fopen($filepath, 'w'); 167 168 $options->setOutputStream($fh); 169 $options->setSendHttpHeaders(false); 170 $archive = new \ZipStream\ZipStream($filename, $options); 171 172 $zipwriter = new static($archive, $exportoptions); 173 174 $zipwriter->zipfilehandle = $fh; 175 $zipwriter->zipfilepath = $filepath; 176 177 \core\session\manager::write_close(); 178 return $zipwriter; 179 } 180 181 /** 182 * Get the file path for a file-based zip writer. 183 * 184 * If this is not a file-based writer then no value is returned. 185 * 186 * @return null|string 187 */ 188 public function get_file_path(): ?string { 189 return $this->zipfilepath; 190 } 191 192 /** 193 * Add a file from the File Storage API. 194 * 195 * @param context $context 196 * @param string $filepathinzip 197 * @param stored_file $file The file to add 198 */ 199 public function add_file_from_stored_file( 200 context $context, 201 string $filepathinzip, 202 stored_file $file 203 ): void { 204 $fullfilepathinzip = $this->get_context_path($context, $filepathinzip); 205 206 if ($file->get_filesize() <= $this->maxfilesize) { 207 $filehandle = $file->get_content_file_handle(); 208 $this->archive->addFileFromStream($fullfilepathinzip, $filehandle); 209 fclose($filehandle); 210 211 $this->filesinzip[] = $fullfilepathinzip; 212 } 213 } 214 215 /** 216 * Add a file from string content. 217 * 218 * @param context $context 219 * @param string $filepathinzip 220 * @param string $content 221 */ 222 public function add_file_from_string( 223 context $context, 224 string $filepathinzip, 225 string $content 226 ): void { 227 $fullfilepathinzip = $this->get_context_path($context, $filepathinzip); 228 229 $this->archive->addFile($fullfilepathinzip, $content); 230 231 $this->filesinzip[] = $fullfilepathinzip; 232 } 233 234 /** 235 * Create a file based on a Mustache Template and associated data. 236 * 237 * @param context $context 238 * @param string $filepathinzip 239 * @param string $template 240 * @param stdClass $templatedata 241 */ 242 public function add_file_from_template( 243 context $context, 244 string $filepathinzip, 245 string $template, 246 stdClass $templatedata 247 ): void { 248 global $CFG, $PAGE, $SITE, $USER; 249 250 $exportedcourse = $this->get_course(); 251 $courselink = (new moodle_url('/course/view.php', ['id' => $exportedcourse->id]))->out(false); 252 $coursename = format_string($exportedcourse->fullname, true, ['context' => $this->coursecontext]); 253 254 $this->add_template_requirements(); 255 256 $templatedata->global = (object) [ 257 'righttoleft' => right_to_left(), 258 'language' => str_replace('_', '-', current_language()), 259 'sitename' => format_string($SITE->fullname, true, ['context' => context_system::instance()]), 260 'siteurl' => $CFG->wwwroot, 261 'pathtotop' => $this->get_relative_context_path($context, $this->rootcontext, '/'), 262 'contentexportfooter' => get_string('contentexport_footersummary', 'core', (object) [ 263 'courselink' => $courselink, 264 'coursename' => $coursename, 265 'userfullname' => fullname($USER), 266 'date' => userdate(time()), 267 ]), 268 'contentexportsummary' => get_string('contentexport_coursesummary', 'core', (object) [ 269 'courselink' => $courselink, 270 'coursename' => $coursename, 271 'date' => userdate(time()), 272 ]), 273 'coursename' => $coursename, 274 'courseshortname' => $exportedcourse->shortname, 275 'courselink' => $courselink, 276 'exportdate' => userdate(time()), 277 'maxfilesize' => display_size($this->maxfilesize), 278 ]; 279 280 $renderer = $PAGE->get_renderer('core'); 281 $this->add_file_from_string($context, $filepathinzip, $renderer->render_from_template($template, $templatedata)); 282 } 283 284 /** 285 * Ensure that all requirements for a templated page are present. 286 * 287 * This includes CSS, and any other similar content. 288 */ 289 protected function add_template_requirements(): void { 290 if ($this->pagerequirementsadded) { 291 return; 292 } 293 294 // CSS required. 295 $this->add_content_from_dirroot('/theme/boost/style/moodle.css', 'shared/moodle.css'); 296 297 $this->pagerequirementsadded = true; 298 } 299 300 /** 301 * Add content from the dirroot into the specified path in the zip file. 302 * 303 * @param string $dirrootpath 304 * @param string $pathinzip 305 */ 306 protected function add_content_from_dirroot(string $dirrootpath, string $pathinzip): void { 307 global $CFG; 308 309 $this->archive->addFileFromPath( 310 $this->get_context_path($this->rootcontext, $pathinzip), 311 "{$CFG->dirroot}/{$dirrootpath}" 312 ); 313 } 314 315 /** 316 * Check whether the file was actually added to the archive. 317 * 318 * @param context $context 319 * @param string $filepathinzip 320 * @return bool 321 */ 322 public function is_file_in_archive(context $context, string $filepathinzip): bool { 323 $fullfilepathinzip = $this->get_context_path($context, $filepathinzip); 324 325 return in_array($fullfilepathinzip, $this->filesinzip); 326 } 327 328 /** 329 * Get the full path to the context within the zip. 330 * 331 * @param context $context 332 * @param string $filepathinzip 333 * @return string 334 */ 335 public function get_context_path(context $context, string $filepathinzip): string { 336 if (!$context->is_child_of($this->rootcontext, true)) { 337 throw new \coding_exception("Unexpected path requested"); 338 } 339 340 // Fetch the path from the course down. 341 $parentcontexts = array_filter( 342 $context->get_parent_contexts(true), 343 function(context $curcontext): bool { 344 return $curcontext->is_child_of($this->rootcontext, true); 345 } 346 ); 347 348 foreach (array_reverse($parentcontexts) as $curcontext) { 349 $path[] = $this->get_context_folder_name($curcontext); 350 } 351 352 $path[] = $filepathinzip; 353 354 $finalpath = implode(DIRECTORY_SEPARATOR, $path); 355 356 // Remove relative paths (./). 357 $finalpath = str_replace('./', '/', $finalpath); 358 359 // De-duplicate slashes. 360 $finalpath = str_replace('//', '/', $finalpath); 361 362 // Remove leading /. 363 ltrim($finalpath, '/'); 364 365 return $this->sanitise_filename($finalpath); 366 } 367 368 /** 369 * Get a relative path to the specified context path. 370 * 371 * @param context $rootcontext 372 * @param context $targetcontext 373 * @param string $filepathinzip 374 * @return string 375 */ 376 public function get_relative_context_path(context $rootcontext, context $targetcontext, string $filepathinzip): string { 377 $path = []; 378 if ($targetcontext === $rootcontext) { 379 $lookupcontexts = []; 380 } else if ($targetcontext->is_child_of($rootcontext, true)) { 381 // Fetch the path from the course down. 382 $lookupcontexts = array_filter( 383 $targetcontext->get_parent_contexts(true), 384 function(context $curcontext): bool { 385 return $curcontext->is_child_of($this->rootcontext, false); 386 } 387 ); 388 389 foreach ($lookupcontexts as $curcontext) { 390 array_unshift($path, $this->get_context_folder_name($curcontext)); 391 } 392 } else if ($targetcontext->is_parent_of($rootcontext, true)) { 393 $lookupcontexts = $targetcontext->get_parent_contexts(true); 394 $path[] = '..'; 395 } 396 397 $path[] = $filepathinzip; 398 $relativepath = implode(DIRECTORY_SEPARATOR, $path); 399 400 // De-duplicate slashes and remove leading /. 401 $relativepath = ltrim(preg_replace('#/+#', '/', $relativepath), '/'); 402 403 if (substr($relativepath, 0, 1) !== '.') { 404 $relativepath = "./{$relativepath}"; 405 } 406 407 return $this->sanitise_filename($relativepath); 408 } 409 410 /** 411 * Sanitise the file path, removing any unsuitable characters. 412 * 413 * @param string $filepath 414 * @return string 415 */ 416 protected function sanitise_filename(string $filepath): string { 417 // The filename must be sanitised in the same as the parent ZipStream library. 418 return \ZipStream\File::filterFilename($filepath); 419 } 420 421 /** 422 * Get the name of the folder for the specified context. 423 * 424 * @param context $context 425 * @return string 426 */ 427 protected function get_context_folder_name(context $context): string { 428 // Replace spaces with underscores, or they will be removed completely when cleaning. 429 $contextname = str_replace(' ', '_', $context->get_context_name()); 430 431 // Clean the context name of all but basic characters, as some systems don't support unicode within zip structure. 432 $shortenedname = shorten_text( 433 clean_param($contextname, PARAM_SAFEDIR), 434 self::MAX_CONTEXT_NAME_LENGTH, 435 true 436 ); 437 438 return "{$shortenedname}_.{$context->id}"; 439 } 440 441 /** 442 * Rewrite any pluginfile URLs in the content. 443 * 444 * @param context $context 445 * @param string $content 446 * @param string $component 447 * @param string $filearea 448 * @param null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs 449 * @return string 450 */ 451 protected function rewrite_other_pluginfile_urls( 452 context $context, 453 string $content, 454 string $component, 455 string $filearea, 456 ?int $pluginfileitemid 457 ): string { 458 // The pluginfile URLs should have been rewritten when the files were exported, but if any file was too large it 459 // may not have been included. 460 // In that situation use a tokenpluginfile URL. 461 462 if (strpos($content, '@@PLUGINFILE@@/') !== false) { 463 // Some files could not be rewritten. 464 // Use a tokenurl pluginfile for those. 465 $content = file_rewrite_pluginfile_urls( 466 $content, 467 'pluginfile.php', 468 $context->id, 469 $component, 470 $filearea, 471 $pluginfileitemid, 472 [ 473 'includetoken' => true, 474 ] 475 ); 476 } 477 478 return $content; 479 } 480 481 /** 482 * Export files releating to this text area. 483 * 484 * @param context $context 485 * @param string $subdir The sub directory to export any files to 486 * @param string $content 487 * @param string $component 488 * @param string $filearea 489 * @param int $fileitemid The itemid as used in the Files API 490 * @param null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs 491 * @return exported_item 492 */ 493 public function add_pluginfiles_for_content( 494 context $context, 495 string $subdir, 496 string $content, 497 string $component, 498 string $filearea, 499 int $fileitemid, 500 ?int $pluginfileitemid 501 ): exported_item { 502 // Export all of the files for this text area. 503 $fs = get_file_storage(); 504 $files = $fs->get_area_files($context->id, $component, $filearea, $fileitemid); 505 506 $result = new exported_item(); 507 foreach ($files as $file) { 508 if ($file->is_directory()) { 509 continue; 510 } 511 512 $filepathinzip = self::get_filepath_for_file($file, $subdir, false); 513 $this->add_file_from_stored_file( 514 $context, 515 $filepathinzip, 516 $file 517 ); 518 519 if ($this->is_file_in_archive($context, $filepathinzip)) { 520 // Attempt to rewrite any @@PLUGINFILE@@ URLs for this file in the content. 521 $searchpath = "@@PLUGINFILE@@" . $file->get_filepath() . rawurlencode($file->get_filename()); 522 if (strpos($content, $searchpath) !== false) { 523 $content = str_replace($searchpath, self::get_filepath_for_file($file, $subdir, true), $content); 524 $result->add_file($filepathinzip, true); 525 } else { 526 $result->add_file($filepathinzip, false); 527 } 528 } 529 530 } 531 532 $content = $this->rewrite_other_pluginfile_urls($context, $content, $component, $filearea, $pluginfileitemid); 533 $result->set_content($content); 534 535 return $result; 536 } 537 538 /** 539 * Get the filepath for the specified stored_file. 540 * 541 * @param stored_file $file 542 * @param string $parentdir Any parent directory to place this file in 543 * @param bool $escape 544 * @return string 545 */ 546 protected static function get_filepath_for_file(stored_file $file, string $parentdir, bool $escape): string { 547 $path = []; 548 549 $filepath = sprintf( 550 '%s/%s/%s/%s', 551 $parentdir, 552 $file->get_filearea(), 553 $file->get_filepath(), 554 $file->get_filename() 555 ); 556 557 if ($escape) { 558 foreach (explode('/', $filepath) as $dirname) { 559 $path[] = rawurlencode($dirname); 560 } 561 $filepath = implode('/', $path); 562 } 563 564 return ltrim(preg_replace('#/+#', '/', $filepath), '/'); 565 } 566 567 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body