MDL-69549 core: Add context export API
[moodle.git] / lib / classes / content / export / zipwriter.php
CommitLineData
17d4bc49
AN
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 */
24namespace core\content\export;
25
26use context;
27use context_system;
28use moodle_url;
29use stdClass;
30use 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 */
38class 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
253 $this->add_template_requirements();
254
255 $templatedata->global = (object) [
256 'righttoleft' => right_to_left(),
257 'language' => str_replace('_', '-', current_language()),
258 'sitename' => $SITE->fullname,
259 'siteurl' => $CFG->wwwroot,
260 'pathtotop' => $this->get_relative_context_path($context, $this->rootcontext, '/'),
261 'contentexportfooter' => get_string('contentexport_footersummary', 'core', (object) [
262 'courselink' => $courselink,
263 'coursename' => $exportedcourse->fullname,
264 'userfullname' => fullname($USER),
265 'date' => userdate(time()),
266 ]),
267 'contentexportsummary' => get_string('contentexport_coursesummary', 'core', (object) [
268 'courselink' => $courselink,
269 'coursename' => $exportedcourse->fullname,
270 'date' => userdate(time()),
271 ]),
272 'coursename' => $exportedcourse->fullname,
273 'courseshortname' => $exportedcourse->shortname,
274 'courselink' => $courselink,
275 'exportdate' => userdate(time()),
276 'maxfilesize' => display_size($this->maxfilesize),
277 ];
278
279 $renderer = $PAGE->get_renderer('core');
280 $this->add_file_from_string($context, $filepathinzip, $renderer->render_from_template($template, $templatedata));
281 }
282
283 /**
284 * Ensure that all requirements for a templated page are present.
285 *
286 * This includes CSS, and any other similar content.
287 */
288 protected function add_template_requirements(): void {
289 if ($this->pagerequirementsadded) {
290 return;
291 }
292
293 // CSS required.
294 $this->add_content_from_dirroot('/theme/boost/style/moodle.css', 'shared/moodle.css');
295
296 $this->pagerequirementsadded = true;
297 }
298
299 /**
300 * Add content from the dirroot into the specified path in the zip file.
301 *
302 * @param string $dirrootpath
303 * @param string $pathinzip
304 */
305 protected function add_content_from_dirroot(string $dirrootpath, string $pathinzip): void {
306 global $CFG;
307
308 $this->archive->addFileFromPath(
309 $this->get_context_path($this->rootcontext, $pathinzip),
310 "{$CFG->dirroot}/{$dirrootpath}"
311 );
312 }
313
314 /**
315 * Check whether the file was actually added to the archive.
316 *
317 * @param context $context
318 * @param string $filepathinzip
319 * @return bool
320 */
321 public function is_file_in_archive(context $context, string $filepathinzip): bool {
322 $fullfilepathinzip = $this->get_context_path($context, $filepathinzip);
323
324 return in_array($fullfilepathinzip, $this->filesinzip);
325 }
326
327 /**
328 * Get the full path to the context within the zip.
329 *
330 * @param context $context
331 * @param string $filepathinzip
332 * @return string
333 */
334 public function get_context_path(context $context, string $filepathinzip): string {
335 if (!$context->is_child_of($this->rootcontext, true)) {
336 throw new \coding_exception("Unexpected path requested");
337 }
338
339 // Fetch the path from the course down.
340 $parentcontexts = array_filter(
341 $context->get_parent_contexts(true),
342 function(context $curcontext): bool {
343 return $curcontext->is_child_of($this->rootcontext, true);
344 }
345 );
346
347 foreach (array_reverse($parentcontexts) as $curcontext) {
348 $path[] = $this->get_context_folder_name($curcontext);
349 }
350
351 $path[] = $filepathinzip;
352
353 $finalpath = implode(DIRECTORY_SEPARATOR, $path);
354
355 // Remove relative paths (./).
356 $finalpath = str_replace('./', '/', $finalpath);
357
358 // De-duplicate slashes.
359 $finalpath = str_replace('//', '/', $finalpath);
360
361 // Remove leading /.
362 ltrim($finalpath, '/');
363
364 return $this->sanitise_filename($finalpath);
365 }
366
367 /**
368 * Get a relative path to the specified context path.
369 *
370 * @param context $rootcontext
371 * @param context $targetcontext
372 * @param string $filepathinzip
373 * @return string
374 */
375 public function get_relative_context_path(context $rootcontext, context $targetcontext, string $filepathinzip): string {
376 $path = [];
377 if ($targetcontext === $rootcontext) {
378 $lookupcontexts = [];
379 } else if ($targetcontext->is_child_of($rootcontext, true)) {
380 // Fetch the path from the course down.
381 $lookupcontexts = array_filter(
382 $targetcontext->get_parent_contexts(true),
383 function(context $curcontext): bool {
384 return $curcontext->is_child_of($this->rootcontext, false);
385 }
386 );
387
388 foreach ($lookupcontexts as $curcontext) {
389 array_unshift($path, $this->get_context_folder_name($curcontext));
390 }
391 } else if ($targetcontext->is_parent_of($rootcontext, true)) {
392 $lookupcontexts = $targetcontext->get_parent_contexts(true);
393 $path[] = '..';
394 }
395
396 $path[] = $filepathinzip;
397 $relativepath = implode(DIRECTORY_SEPARATOR, $path);
398
399 // De-duplicate slashes and remove leading /.
400 $relativepath = ltrim(preg_replace('#/+#', '/', $relativepath), '/');
401
402 if (substr($relativepath, 0, 1) !== '.') {
403 $relativepath = "./{$relativepath}";
404 }
405
406 return $this->sanitise_filename($relativepath);
407 }
408
409 /**
410 * Sanitise the file path, removing any unsuitable characters.
411 *
412 * @param string $filepath
413 * @return string
414 */
415 protected function sanitise_filename(string $filepath): string {
416 // The filename must be sanitised in the same as the parent ZipStream library.
417 return \ZipStream\File::filterFilename($filepath);
418 }
419
420 /**
421 * Get the name of the folder for the specified context.
422 *
423 * @param context $context
424 * @return string
425 */
426 protected function get_context_folder_name(context $context): string {
427 $shortenedname = shorten_text(
428 clean_param($context->get_context_name(), PARAM_FILE),
429 self::MAX_CONTEXT_NAME_LENGTH,
430 true,
431 json_decode('"' . '\u2026' . '"')
432 );
433
434 return "{$shortenedname}_.{$context->id}";
435 }
436
437 /**
438 * Rewrite any pluginfile URLs in the content.
439 *
440 * @param context $context
441 * @param string $content
442 * @param string $component
443 * @param string $filearea
444 * @param null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs
445 * @return string
446 */
447 protected function rewrite_other_pluginfile_urls(
448 context $context,
449 string $content,
450 string $component,
451 string $filearea,
452 ?int $pluginfileitemid
453 ): string {
454 // The pluginfile URLs should have been rewritten when the files were exported, but if any file was too large it
455 // may not have been included.
456 // In that situation use a tokenpluginfile URL.
457
458 if (strpos($content, '@@PLUGINFILE@@/') !== false) {
459 // Some files could not be rewritten.
460 // Use a tokenurl pluginfile for those.
461 $content = file_rewrite_pluginfile_urls(
462 $content,
463 'pluginfile.php',
464 $context->id,
465 $component,
466 $filearea,
467 $pluginfileitemid,
468 [
469 'includetoken' => true,
470 ]
471 );
472 }
473
474 return $content;
475 }
476
477 /**
478 * Export files releating to this text area.
479 *
480 * @param context $context
481 * @param string $subdir The sub directory to export any files to
482 * @param string $content
483 * @param string $component
484 * @param string $filearea
485 * @param int $fileitemid The itemid as used in the Files API
486 * @param null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs
487 * @return exported_item
488 */
489 public function add_pluginfiles_for_content(
490 context $context,
491 string $subdir,
492 string $content,
493 string $component,
494 string $filearea,
495 int $fileitemid,
496 ?int $pluginfileitemid
497 ): exported_item {
498 // Export all of the files for this text area.
499 $fs = get_file_storage();
500 $files = $fs->get_area_files($context->id, $component, $filearea, $fileitemid);
501
502 $result = new exported_item();
503 foreach ($files as $file) {
504 if ($file->is_directory()) {
505 continue;
506 }
507
508 $filepathinzip = self::get_filepath_for_file($file, $subdir, false);
509 $this->add_file_from_stored_file(
510 $context,
511 $filepathinzip,
512 $file
513 );
514
515 if ($this->is_file_in_archive($context, $filepathinzip)) {
516 // Attempt to rewrite any @@PLUGINFILE@@ URLs for this file in the content.
517 $searchpath = "@@PLUGINFILE@@" . $file->get_filepath() . rawurlencode($file->get_filename());
518 if (strpos($content, $searchpath) !== false) {
519 $content = str_replace($searchpath, self::get_filepath_for_file($file, $subdir, true), $content);
520 $result->add_file($filepathinzip, true);
521 } else {
522 $result->add_file($filepathinzip, false);
523 }
524 }
525
526 }
527
528 $content = $this->rewrite_other_pluginfile_urls($context, $content, $component, $filearea, $pluginfileitemid);
529 $result->set_content($content);
530
531 return $result;
532 }
533
534 /**
535 * Get the filepath for the specified stored_file.
536 *
537 * @param stored_file $file
538 * @param string $parentdir Any parent directory to place this file in
539 * @param bool $escape
540 * @return string
541 */
542 protected static function get_filepath_for_file(stored_file $file, string $parentdir, bool $escape): string {
543 $path = [];
544
545 $filepath = sprintf(
546 '%s/%s/%s/%s',
547 $parentdir,
548 $file->get_filearea(),
549 $file->get_filepath(),
550 $file->get_filename()
551 );
552
553 if ($escape) {
554 foreach (explode('/', $filepath) as $dirname) {
555 $path[] = rawurlencode($dirname);
556 }
557 $filepath = implode('/', $path);
558 }
559
560 return ltrim(preg_replace('#/+#', '/', $filepath), '/');
561 }
562
563}