MDL-69549 core: Add context export API
[moodle.git] / lib / classes / content / export / zipwriter.php
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/>.
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;
26 use context;
27 use context_system;
28 use moodle_url;
29 use stdClass;
30 use stored_file;
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 {
40     /** @var int Maximum folder length name for a context */
41     const MAX_CONTEXT_NAME_LENGTH = 32;
43     /** @var \ZipStream\ZipStream */
44     protected $archive;
46     /** @var int Max file size of an individual file in the archive */
47     protected $maxfilesize = 1 * 1024 * 1024 * 10;
49     /** @var resource File resource for the file handle for a file-based zip stream */
50     protected $zipfilehandle = null;
52     /** @var string File path for a file-based zip stream */
53     protected $zipfilepath = null;
55     /** @var context The context to use as a base for export */
56     protected $rootcontext = null;
58     /** @var array The files in the zip */
59     protected $filesinzip = [];
61     /** @var bool Whether page requirements needed for HTML pages have been added */
62     protected $pagerequirementsadded = false;
64     /** @var stdClass The course relating to the root context */
65     protected $course;
67     /** @var context The context of the course for the root contect */
68     protected $coursecontext;
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         }
82         $this->rootcontext = context_system::instance();
83     }
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     }
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         }
111         return $this->course;
112     }
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     }
125     /**
126      * Finish writing the zip footer.
127      */
128     public function finish(): void {
129         $this->archive->finish();
131         if ($this->zipfilehandle) {
132             fclose($this->zipfilehandle);
133         }
134     }
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);
148         $zipwriter = new static($archive, $exportoptions);
150         \core\session\manager::write_close();
151         return $zipwriter;
152     }
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();
164         $dir = make_request_directory();
165         $filepath = $dir . "/$filename";
166         $fh = fopen($filepath, 'w');
168         $options->setOutputStream($fh);
169         $options->setSendHttpHeaders(false);
170         $archive = new \ZipStream\ZipStream($filename, $options);
172         $zipwriter = new static($archive, $exportoptions);
174         $zipwriter->zipfilehandle = $fh;
175         $zipwriter->zipfilepath = $filepath;
177         \core\session\manager::write_close();
178         return $zipwriter;
179     }
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     }
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);
206         if ($file->get_filesize() <= $this->maxfilesize) {
207             $filehandle = $file->get_content_file_handle();
208             $this->archive->addFileFromStream($fullfilepathinzip, $filehandle);
209             fclose($filehandle);
211             $this->filesinzip[] = $fullfilepathinzip;
212         }
213     }
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);
229         $this->archive->addFile($fullfilepathinzip, $content);
231         $this->filesinzip[] = $fullfilepathinzip;
232     }
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;
250         $exportedcourse = $this->get_course();
251         $courselink = (new moodle_url('/course/view.php', ['id' => $exportedcourse->id]))->out(false);
253         $this->add_template_requirements();
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         ];
279         $renderer = $PAGE->get_renderer('core');
280         $this->add_file_from_string($context, $filepathinzip, $renderer->render_from_template($template, $templatedata));
281     }
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         }
293         // CSS required.
294         $this->add_content_from_dirroot('/theme/boost/style/moodle.css', 'shared/moodle.css');
296         $this->pagerequirementsadded = true;
297     }
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;
308         $this->archive->addFileFromPath(
309             $this->get_context_path($this->rootcontext, $pathinzip),
310             "{$CFG->dirroot}/{$dirrootpath}"
311         );
312     }
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);
324         return in_array($fullfilepathinzip, $this->filesinzip);
325     }
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         }
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         );
347         foreach (array_reverse($parentcontexts) as $curcontext) {
348             $path[] = $this->get_context_folder_name($curcontext);
349         }
351         $path[] = $filepathinzip;
353         $finalpath = implode(DIRECTORY_SEPARATOR, $path);
355         // Remove relative paths (./).
356         $finalpath = str_replace('./', '/', $finalpath);
358         // De-duplicate slashes.
359         $finalpath = str_replace('//', '/', $finalpath);
361         // Remove leading /.
362         ltrim($finalpath, '/');
364         return $this->sanitise_filename($finalpath);
365     }
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             );
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         }
396         $path[] = $filepathinzip;
397         $relativepath = implode(DIRECTORY_SEPARATOR, $path);
399         // De-duplicate slashes and remove leading /.
400         $relativepath = ltrim(preg_replace('#/+#', '/', $relativepath), '/');
402         if (substr($relativepath, 0, 1) !== '.') {
403             $relativepath = "./{$relativepath}";
404         }
406         return $this->sanitise_filename($relativepath);
407     }
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     }
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         );
434         return "{$shortenedname}_.{$context->id}";
435     }
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.
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         }
474         return $content;
475     }
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);
502         $result = new exported_item();
503         foreach ($files as $file) {
504             if ($file->is_directory()) {
505                 continue;
506             }
508             $filepathinzip = self::get_filepath_for_file($file, $subdir, false);
509             $this->add_file_from_stored_file(
510                 $context,
511                 $filepathinzip,
512                 $file
513             );
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             }
526         }
528         $content = $this->rewrite_other_pluginfile_urls($context, $content, $component, $filearea, $pluginfileitemid);
529         $result->set_content($content);
531         return $result;
532     }
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 = [];
545         $filepath = sprintf(
546             '%s/%s/%s/%s',
547             $parentdir,
548             $file->get_filearea(),
549             $file->get_filepath(),
550             $file->get_filename()
551         );
553         if ($escape) {
554             foreach (explode('/', $filepath) as $dirname) {
555                 $path[] = rawurlencode($dirname);
556             }
557             $filepath = implode('/', $path);
558         }
560         return ltrim(preg_replace('#/+#', '/', $filepath), '/');
561     }