Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
        $archive = new \ZipStream\ZipStream(
145
            outputName: $filename,
146
        );
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
        $dir = make_request_directory();
163
        $filepath = $dir . "/$filename";
164
        $fh = fopen($filepath, 'w');
165
 
166
        $archive = new \ZipStream\ZipStream(
167
            outputName: $filename,
168
            outputStream: $fh,
169
            sendHttpHeaders: false,
170
        );
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' => get_html_lang_attribute_value(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, 0),
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
        return $finalpath;
363
    }
364
 
365
    /**
366
     * Get a relative path to the specified context path.
367
     *
368
     * @param   context $rootcontext
369
     * @param   context $targetcontext
370
     * @param   string $filepathinzip
371
     * @return  string
372
     */
373
    public function get_relative_context_path(context $rootcontext, context $targetcontext, string $filepathinzip): string {
374
        $path = [];
375
        if ($targetcontext === $rootcontext) {
376
            $lookupcontexts = [];
377
        } else if ($targetcontext->is_child_of($rootcontext, true)) {
378
            // Fetch the path from the course down.
379
            $lookupcontexts = array_filter(
380
                $targetcontext->get_parent_contexts(true),
381
                function(context $curcontext): bool {
382
                    return $curcontext->is_child_of($this->rootcontext, false);
383
                }
384
            );
385
 
386
            foreach ($lookupcontexts as $curcontext) {
387
                array_unshift($path, $this->get_context_folder_name($curcontext));
388
            }
389
        } else if ($targetcontext->is_parent_of($rootcontext, true)) {
390
            $lookupcontexts = $targetcontext->get_parent_contexts(true);
391
            $path[] = '..';
392
        }
393
 
394
        $path[] = $filepathinzip;
395
        $relativepath = implode(DIRECTORY_SEPARATOR, $path);
396
 
397
        // De-duplicate slashes and remove leading /.
398
        $relativepath = ltrim(preg_replace('#/+#', '/', $relativepath), '/');
399
 
400
        if (substr($relativepath, 0, 1) !== '.') {
401
            $relativepath = "./{$relativepath}";
402
        }
403
 
404
        return $relativepath;
405
    }
406
 
407
    /**
408
     * Get the name of the folder for the specified context.
409
     *
410
     * @param   context $context
411
     * @return  string
412
     */
413
    protected function get_context_folder_name(context $context): string {
414
        // Replace spaces with underscores, or they will be removed completely when cleaning.
415
        $contextname = str_replace(' ', '_', $context->get_context_name());
416
 
417
        // Clean the context name of all but basic characters, as some systems don't support unicode within zip structure.
418
        $shortenedname = shorten_text(
419
            clean_param($contextname, PARAM_SAFEDIR),
420
            self::MAX_CONTEXT_NAME_LENGTH,
421
            true
422
        );
423
 
424
        return "{$shortenedname}_.{$context->id}";
425
    }
426
 
427
    /**
428
     * Rewrite any pluginfile URLs in the content.
429
     *
430
     * @param   context $context
431
     * @param   string $content
432
     * @param   string $component
433
     * @param   string $filearea
434
     * @param   null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs
435
     * @return  string
436
     */
437
    protected function rewrite_other_pluginfile_urls(
438
        context $context,
439
        string $content,
440
        string $component,
441
        string $filearea,
442
        ?int $pluginfileitemid
443
    ): string {
444
        // The pluginfile URLs should have been rewritten when the files were exported, but if any file was too large it
445
        // may not have been included.
446
        // In that situation use a tokenpluginfile URL.
447
 
448
        if (strpos($content, '@@PLUGINFILE@@/') !== false) {
449
            // Some files could not be rewritten.
450
            // Use a tokenurl pluginfile for those.
451
            $content = file_rewrite_pluginfile_urls(
452
                $content,
453
                'pluginfile.php',
454
                $context->id,
455
                $component,
456
                $filearea,
457
                $pluginfileitemid,
458
                [
459
                    'includetoken' => true,
460
                ]
461
            );
462
        }
463
 
464
        return $content;
465
    }
466
 
467
    /**
468
     * Export files releating to this text area.
469
     *
470
     * @param   context $context
471
     * @param   string $subdir The sub directory to export any files to
472
     * @param   string $content
473
     * @param   string $component
474
     * @param   string $filearea
475
     * @param   int $fileitemid The itemid as used in the Files API
476
     * @param   null|int $pluginfileitemid The itemid to use in the pluginfile URL when composing any required URLs
477
     * @return  exported_item
478
     */
479
    public function add_pluginfiles_for_content(
480
        context $context,
481
        string $subdir,
482
        string $content,
483
        string $component,
484
        string $filearea,
485
        int $fileitemid,
486
        ?int $pluginfileitemid
487
    ): exported_item {
488
        // Export all of the files for this text area.
489
        $fs = get_file_storage();
490
        $files = $fs->get_area_files($context->id, $component, $filearea, $fileitemid);
491
 
492
        $result = new exported_item();
493
        foreach ($files as $file) {
494
            if ($file->is_directory()) {
495
                continue;
496
            }
497
 
498
            $filepathinzip = self::get_filepath_for_file($file, $subdir, false);
499
            $this->add_file_from_stored_file(
500
                $context,
501
                $filepathinzip,
502
                $file
503
            );
504
 
505
            if ($this->is_file_in_archive($context, $filepathinzip)) {
506
                // Attempt to rewrite any @@PLUGINFILE@@ URLs for this file in the content.
507
                $searchpath = "@@PLUGINFILE@@" . $file->get_filepath() . rawurlencode($file->get_filename());
508
                if (strpos($content, $searchpath) !== false) {
509
                    $content = str_replace($searchpath, self::get_filepath_for_file($file, $subdir, true), $content);
510
                    $result->add_file($filepathinzip, true);
511
                } else {
512
                    $result->add_file($filepathinzip, false);
513
                }
514
            }
515
 
516
        }
517
 
518
        $content = $this->rewrite_other_pluginfile_urls($context, $content, $component, $filearea, $pluginfileitemid);
519
        $result->set_content($content);
520
 
521
        return $result;
522
    }
523
 
524
    /**
525
     * Get the filepath for the specified stored_file.
526
     *
527
     * @param   stored_file $file
528
     * @param   string $parentdir Any parent directory to place this file in
529
     * @param   bool $escape
530
     * @return  string
531
     */
532
    protected static function get_filepath_for_file(stored_file $file, string $parentdir, bool $escape): string {
533
        $path = [];
534
 
535
        $filepath = sprintf(
536
            '%s/%s/%s/%s',
537
            $parentdir,
538
            $file->get_filearea(),
539
            $file->get_filepath(),
540
            $file->get_filename()
541
        );
542
 
543
        if ($escape) {
544
            foreach (explode('/', $filepath) as $dirname) {
545
                $path[] = rawurlencode($dirname);
546
            }
547
            $filepath = implode('/', $path);
548
        }
549
 
550
        return ltrim(preg_replace('#/+#', '/', $filepath), '/');
551
    }
552
 
553
}