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
 * Class \core_h5p\file_storage.
19
 *
20
 * @package    core_h5p
21
 * @copyright  2019 Victor Deniz <victor@moodle.com>, base on code by Joubel AS
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
namespace core_h5p;
26
 
27
use stored_file;
28
use Moodle\H5PCore;
29
use Moodle\H5peditorFile;
30
use Moodle\H5PFileStorage;
31
 
32
// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod
33
 
34
/**
35
 * Class to handle storage and export of H5P Content.
36
 *
37
 * @package    core_h5p
38
 * @copyright  2019 Victor Deniz <victor@moodle.com>
39
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40
 */
41
class file_storage implements H5PFileStorage {
42
 
43
    /** The component for H5P. */
44
    public const COMPONENT   = 'core_h5p';
45
    /** The library file area. */
46
    public const LIBRARY_FILEAREA = 'libraries';
47
    /** The content file area */
48
    public const CONTENT_FILEAREA = 'content';
49
    /** The cached assest file area. */
50
    public const CACHED_ASSETS_FILEAREA = 'cachedassets';
51
    /** The export file area */
52
    public const EXPORT_FILEAREA = 'export';
53
    /** The export css file area */
54
    public const CSS_FILEAREA = 'css';
55
    /** The icon filename */
56
    public const ICON_FILENAME = 'icon.svg';
57
    /** The custom CSS filename */
58
    private const CUSTOM_CSS_FILENAME = 'custom_h5p.css';
59
 
60
    /**
61
     * @var \context $context Currently we use the system context everywhere.
62
     * Don't feel forced to keep it this way in the future.
63
     */
64
    protected $context;
65
 
66
    /** @var \file_storage $fs File storage. */
67
    protected $fs;
68
 
69
    /**
70
     * Initial setup for file_storage.
71
     */
72
    public function __construct() {
73
        // Currently everything uses the system context.
74
        $this->context = \context_system::instance();
75
        $this->fs = get_file_storage();
76
    }
77
 
78
    /**
79
     * Stores a H5P library in the Moodle filesystem.
80
     *
81
     * @param array $library Library properties.
82
     */
83
    public function saveLibrary($library) {
84
        $options = [
85
            'contextid' => $this->context->id,
86
            'component' => self::COMPONENT,
87
            'filearea' => self::LIBRARY_FILEAREA,
88
            'filepath' => '/' . H5PCore::libraryToFolderName($library) . '/',
89
            'itemid' => $library['libraryId'],
90
        ];
91
 
92
        // Easiest approach: delete the existing library version and copy the new one.
93
        $this->delete_library($library);
94
        $this->copy_directory($library['uploadDirectory'], $options);
95
    }
96
 
97
    /**
98
     * Delete library folder.
99
     *
100
     * @param array $library
101
     */
102
    public function deleteLibrary($library) {
103
        // Although this class had a method (delete_library()) for removing libraries before this was added to the interface,
104
        // it's not safe to call it from here because looking at the place where it's called, it's not clear what are their
105
        // expectation. This method will be implemented once more information will be added to the H5P technical doc.
106
    }
107
 
108
 
109
    /**
110
     * Store the content folder.
111
     *
112
     * @param string $source Path on file system to content directory.
113
     * @param array $content Content properties
114
     */
115
    public function saveContent($source, $content) {
116
        $options = [
117
                'contextid' => $this->context->id,
118
                'component' => self::COMPONENT,
119
                'filearea' => self::CONTENT_FILEAREA,
120
                'itemid' => $content['id'],
121
                'filepath' => '/',
122
        ];
123
 
124
        $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
125
        // Copy content directory into Moodle filesystem.
126
        $this->copy_directory($source, $options);
127
    }
128
 
129
    /**
130
     * Remove content folder.
131
     *
132
     * @param array $content Content properties
133
     */
134
    public function deleteContent($content) {
135
 
136
        $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
137
    }
138
 
139
    /**
140
     * Creates a stored copy of the content folder.
141
     *
142
     * @param string $id Identifier of content to clone.
143
     * @param int $newid The cloned content's identifier
144
     */
145
    public function cloneContent($id, $newid) {
146
        // Not implemented in Moodle.
147
    }
148
 
149
    /**
150
     * Get path to a new unique tmp folder.
151
     * Please note this needs to not be a directory.
152
     *
153
     * @return string Path
154
     */
155
    public function getTmpPath(): string {
156
        return make_request_directory() . '/' . uniqid('h5p-');
157
    }
158
 
159
    /**
160
     * Fetch content folder and save in target directory.
161
     *
162
     * @param int $id Content identifier
163
     * @param string $target Where the content folder will be saved
164
     */
165
    public function exportContent($id, $target) {
166
        $this->export_file_tree($target, $this->context->id, self::CONTENT_FILEAREA, '/', $id);
167
    }
168
 
169
    /**
170
     * Fetch library folder and save in target directory.
171
     *
172
     * @param array $library Library properties
173
     * @param string $target Where the library folder will be saved
174
     */
175
    public function exportLibrary($library, $target) {
176
        $folder = H5PCore::libraryToFolderName($library);
177
        $this->export_file_tree($target . '/' . $folder, $this->context->id, self::LIBRARY_FILEAREA,
178
                '/' . $folder . '/', $library['libraryId']);
179
    }
180
 
181
    /**
182
     * Save export in file system
183
     *
184
     * @param string $source Path on file system to temporary export file.
185
     * @param string $filename Name of export file.
186
     */
187
    public function saveExport($source, $filename) {
188
        global $USER;
189
 
190
        // Remove old export.
191
        $this->deleteExport($filename);
192
 
193
        $filerecord = [
194
            'contextid' => $this->context->id,
195
            'component' => self::COMPONENT,
196
            'filearea' => self::EXPORT_FILEAREA,
197
            'itemid' => 0,
198
            'filepath' => '/',
199
            'filename' => $filename,
200
            'userid' => $USER->id
201
        ];
202
        $this->fs->create_file_from_pathname($filerecord, $source);
203
    }
204
 
205
    /**
206
     * Removes given export file
207
     *
208
     * @param string $filename filename of the export to delete.
209
     */
210
    public function deleteExport($filename) {
211
        $file = $this->get_export_file($filename);
212
        if ($file) {
213
            $file->delete();
214
        }
215
    }
216
 
217
    /**
218
     * Check if the given export file exists
219
     *
220
     * @param string $filename The export file to check.
221
     * @return boolean True if the export file exists.
222
     */
223
    public function hasExport($filename) {
224
        return !!$this->get_export_file($filename);
225
    }
226
 
227
    /**
228
     * Will concatenate all JavaScrips and Stylesheets into two files in order
229
     * to improve page performance.
230
     *
231
     * @param array $files A set of all the assets required for content to display
232
     * @param string $key Hashed key for cached asset
233
     */
234
    public function cacheAssets(&$files, $key) {
235
 
236
        foreach ($files as $type => $assets) {
237
            if (empty($assets)) {
238
                continue;
239
            }
240
 
241
            // Create new file for cached assets.
242
            $ext = ($type === 'scripts' ? 'js' : 'css');
243
            $filename = $key . '.' . $ext;
244
            $fileinfo = [
245
                'contextid' => $this->context->id,
246
                'component' => self::COMPONENT,
247
                'filearea' => self::CACHED_ASSETS_FILEAREA,
248
                'itemid' => 0,
249
                'filepath' => '/',
250
                'filename' => $filename
251
            ];
252
 
253
            // Store concatenated content.
254
            $this->fs->create_file_from_string($fileinfo, $this->concatenate_files($assets, $type, $this->context));
255
            $files[$type] = [
256
                (object) [
257
                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . $filename,
258
                    'version' => ''
259
                ]
260
            ];
261
        }
262
    }
263
 
264
    /**
265
     * Will check if there are cache assets available for content.
266
     *
267
     * @param string $key Hashed key for cached asset
268
     * @return array
269
     */
270
    public function getCachedAssets($key) {
271
        $files = [];
272
 
273
        $js = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.js");
274
        if ($js && $js->get_filesize() > 0) {
275
            $files['scripts'] = [
276
                (object) [
277
                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.js",
278
                    'version' => ''
279
                ]
280
            ];
281
        }
282
 
283
        $css = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.css");
284
        if ($css && $css->get_filesize() > 0) {
285
            $files['styles'] = [
286
                (object) [
287
                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.css",
288
                    'version' => ''
289
                ]
290
            ];
291
        }
292
 
293
        return empty($files) ? null : $files;
294
    }
295
 
296
    /**
297
     * Remove the aggregated cache files.
298
     *
299
     * @param array $keys The hash keys of removed files
300
     */
301
    public function deleteCachedAssets($keys) {
302
 
303
        if (empty($keys)) {
304
            return;
305
        }
306
 
307
        foreach ($keys as $hash) {
308
            foreach (['js', 'css'] as $type) {
309
                $cachedasset = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/',
310
                        "{$hash}.{$type}");
311
                if ($cachedasset) {
312
                    $cachedasset->delete();
313
                }
314
            }
315
        }
316
    }
317
 
318
    /**
319
     * Read file content of given file and then return it.
320
     *
321
     * @param string $filepath
322
     * @return string contents
323
     */
324
    public function getContent($filepath) {
325
        list(
326
            'filearea' => $filearea,
327
            'filepath' => $filepath,
328
            'filename' => $filename,
329
            'itemid' => $itemid
330
        ) = $this->get_file_elements_from_filepath($filepath);
331
 
332
        if (!$itemid) {
333
            throw new \file_serving_exception('Could not retrieve the requested file, check your file permissions.');
334
        }
335
 
336
        // Locate file.
337
        $file = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
338
 
339
        // Return content.
340
        return $file->get_content();
341
    }
342
 
343
    /**
344
     * Save files uploaded through the editor.
345
     *
346
     * @param H5peditorFile $file
347
     * @param int $contentid
348
     *
349
     * @return int The id of the saved file.
350
     */
351
    public function saveFile($file, $contentid) {
352
        global $USER;
353
 
354
        $context = $this->context->id;
355
        $component = self::COMPONENT;
356
        $filearea = self::CONTENT_FILEAREA;
357
        if ($contentid === 0) {
358
            $usercontext = \context_user::instance($USER->id);
359
            $context = $usercontext->id;
360
            $component = 'user';
361
            $filearea = 'draft';
362
        }
363
 
364
        $record = array(
365
            'contextid' => $context,
366
            'component' => $component,
367
            'filearea' => $filearea,
368
            'itemid' => $contentid,
369
            'filepath' => '/' . $file->getType() . 's/',
370
            'filename' => $file->getName()
371
        );
372
 
373
        $storedfile = $this->fs->create_file_from_pathname($record, $_FILES['file']['tmp_name']);
374
 
375
        return $storedfile->get_id();
376
    }
377
 
378
    /**
379
     * Copy a file from another content or editor tmp dir.
380
     * Used when copy pasting content in H5P.
381
     *
382
     * @param string $file path + name
383
     * @param string|int $fromid Content ID or 'editor' string
384
     * @param \stdClass $tocontent Target Content
385
     *
386
     * @return void
387
     */
388
    public function cloneContentFile($file, $fromid, $tocontent): void {
389
        // Determine source filearea and itemid.
390
        if ($fromid === 'editor') {
391
            $sourcefilearea = 'draft';
392
            $sourceitemid = 0;
393
        } else {
394
            $sourcefilearea = self::CONTENT_FILEAREA;
395
            $sourceitemid = (int)$fromid;
396
        }
397
 
398
        $filepath = '/' . dirname($file) . '/';
399
        $filename = basename($file);
400
 
401
        // Check to see if source exists.
402
        $sourcefile = $this->get_file($sourcefilearea, $sourceitemid, $file);
403
        if ($sourcefile === null) {
404
            return; // Nothing to copy from.
405
        }
406
 
407
        // Check to make sure that file doesn't exist already in target.
408
        $targetfile = $this->get_file(self::CONTENT_FILEAREA, $tocontent->id, $file);
409
        if ( $targetfile !== null) {
410
            return; // File exists, no need to copy.
411
        }
412
 
413
        // Create new file record.
414
        $record = [
415
            'contextid' => $this->context->id,
416
            'component' => self::COMPONENT,
417
            'filearea' => self::CONTENT_FILEAREA,
418
            'itemid' => $tocontent->id,
419
            'filepath' => $filepath,
420
            'filename' => $filename,
421
        ];
422
 
423
        $this->fs->create_file_from_storedfile($record, $sourcefile);
424
    }
425
 
426
    /**
427
     * Copy content from one directory to another.
428
     * Defaults to cloning content from the current temporary upload folder to the editor path.
429
     *
430
     * @param string $source path to source directory
431
     * @param string $contentid Id of content
432
     *
433
     */
434
    public function moveContentDirectory($source, $contentid = null) {
435
        $contentidint = (int)$contentid;
436
 
437
        if ($source === null) {
438
            return;
439
        }
440
 
441
        // Get H5P and content json.
442
        $contentsource = $source . '/content';
443
 
444
        // Move all temporary content files to editor.
445
        $it = new \RecursiveIteratorIterator(
446
            new \RecursiveDirectoryIterator($contentsource, \RecursiveDirectoryIterator::SKIP_DOTS),
447
            \RecursiveIteratorIterator::SELF_FIRST
448
        );
449
 
450
        $it->rewind();
451
        while ($it->valid()) {
452
            $item = $it->current();
453
            $pathname = $it->getPathname();
454
            if (!$item->isDir() && !($item->getFilename() === 'content.json')) {
455
                $this->move_file($pathname, $contentidint);
456
            }
457
            $it->next();
458
        }
459
    }
460
 
461
    /**
462
     * Get the file URL or given library and then return it.
463
     *
464
     * @param int $itemid
465
     * @param string $machinename
466
     * @param int $majorversion
467
     * @param int $minorversion
468
     * @return string url or false if the file doesn't exist
469
     */
470
    public function get_icon_url(int $itemid, string $machinename, int $majorversion, int $minorversion) {
471
        $filepath = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
472
        if ($file = $this->fs->get_file(
473
            $this->context->id,
474
            self::COMPONENT,
475
            self::LIBRARY_FILEAREA,
476
            $itemid,
477
            $filepath,
478
            self::ICON_FILENAME)
479
        ) {
480
            $iconurl  = \moodle_url::make_pluginfile_url(
481
                $this->context->id,
482
                self::COMPONENT,
483
                self::LIBRARY_FILEAREA,
484
                $itemid,
485
                $filepath,
486
                $file->get_filename());
487
 
488
            // Return image URL.
489
            return $iconurl->out();
490
        }
491
 
492
        return false;
493
    }
494
 
495
    /**
496
     * Checks to see if an H5P content has the given file.
497
     *
498
     * @param string $file File path and name.
499
     * @param int $content Content id.
500
     *
501
     * @return int|null File ID or NULL if not found
502
     */
503
    public function getContentFile($file, $content): ?int {
504
        if (is_object($content)) {
505
            $content = $content->id;
506
        }
507
        $contentfile = $this->get_file(self::CONTENT_FILEAREA, $content, $file);
508
 
509
        return ($contentfile === null ? null : $contentfile->get_id());
510
    }
511
 
512
    /**
513
     * Remove content files that are no longer used.
514
     *
515
     * Used when saving content.
516
     *
517
     * @param string $file File path and name.
518
     * @param int $contentid Content id.
519
     *
520
     * @return void
521
     */
522
    public function removeContentFile($file, $contentid): void {
523
        // Although the interface defines $contentid as int, object given in H5peditor::processParameters.
524
        if (is_object($contentid)) {
525
            $contentid = $contentid->id;
526
        }
527
        $existingfile = $this->get_file(self::CONTENT_FILEAREA, $contentid, $file);
528
        if ($existingfile !== null) {
529
            $existingfile->delete();
530
        }
531
    }
532
 
533
    /**
534
     * Check if server setup has write permission to
535
     * the required folders
536
     *
537
     * @return bool True if server has the proper write access
538
     */
539
    public function hasWriteAccess() {
540
        // Moodle has access to the files table which is where all of the folders are stored.
541
        return true;
542
    }
543
 
544
    /**
545
     * Check if the library has a presave.js in the root folder
546
     *
547
     * @param string $libraryname
548
     * @param string $developmentpath
549
     * @return bool
550
     */
551
    public function hasPresave($libraryname, $developmentpath = null) {
552
        return false;
553
    }
554
 
555
    /**
556
     * Check if upgrades script exist for library.
557
     *
558
     * @param string $machinename
559
     * @param int $majorversion
560
     * @param int $minorversion
561
     * @return string Relative path
562
     */
563
    public function getUpgradeScript($machinename, $majorversion, $minorversion) {
564
        $path = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
565
        $file = 'upgrade.js';
566
        $itemid = $this->get_itemid_for_file(self::LIBRARY_FILEAREA, $path, $file);
567
        if ($this->fs->get_file($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $itemid, $path, $file)) {
568
            return '/' . self::LIBRARY_FILEAREA . $path. $file;
569
        } else {
570
            return null;
571
        }
572
    }
573
 
574
    /**
575
     * Store the given stream into the given file.
576
     *
577
     * @param string $path
578
     * @param string $file
579
     * @param resource $stream
580
     * @return bool|int
581
     */
582
    public function saveFileFromZip($path, $file, $stream) {
583
        $fullpath = $path . '/' . $file;
584
        check_dir_exists(pathinfo($fullpath, PATHINFO_DIRNAME));
585
        return file_put_contents($fullpath, $stream);
586
    }
587
 
588
    /**
589
     * Deletes a library from the file system.
590
     *
591
     * @param  array $library Library details
592
     */
593
    public function delete_library(array $library): void {
594
        global $DB;
595
 
596
        // A library ID of false would result in all library files being deleted, which we don't want. Return instead.
597
        if (empty($library['libraryId'])) {
598
            return;
599
        }
600
 
601
        $areafiles = $this->fs->get_area_files($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
602
        $this->delete_directory($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
603
        $librarycache = \cache::make('core', 'h5p_library_files');
604
        foreach ($areafiles as $file) {
605
            if (!$DB->record_exists('files', array('contenthash' => $file->get_contenthash(),
606
                                                   'component' => self::COMPONENT,
607
                                                   'filearea' => self::LIBRARY_FILEAREA))) {
608
                $librarycache->delete($file->get_contenthash());
609
            }
610
        }
611
    }
612
 
613
    /**
614
     * Remove an H5P directory from the filesystem.
615
     *
616
     * @param int $contextid context ID
617
     * @param string $component component
618
     * @param string $filearea file area or all areas in context if not specified
619
     * @param int $itemid item ID or all files if not specified
620
     */
621
    private function delete_directory(int $contextid, string $component, string $filearea, int $itemid): void {
622
 
623
        $this->fs->delete_area_files($contextid, $component, $filearea, $itemid);
624
    }
625
 
626
    /**
627
     * Copy an H5P directory from the temporary directory into the file system.
628
     *
629
     * @param  string $source  Temporary location for files.
630
     * @param  array  $options File system information.
631
     */
632
    private function copy_directory(string $source, array $options): void {
633
        $librarycache = \cache::make('core', 'h5p_library_files');
634
        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
635
                \RecursiveIteratorIterator::SELF_FIRST);
636
 
637
        $root = $options['filepath'];
638
 
639
        $it->rewind();
640
        while ($it->valid()) {
641
            $item = $it->current();
642
            $subpath = $it->getSubPath();
643
            if (!$item->isDir()) {
644
                $options['filename'] = $it->getFilename();
645
                if (!$subpath == '') {
646
                    $options['filepath'] = $root . $subpath . '/';
647
                } else {
648
                    $options['filepath'] = $root;
649
                }
650
 
651
                $file = $this->fs->create_file_from_pathname($options, $item->getPathName());
652
 
653
                if ($options['filearea'] == self::LIBRARY_FILEAREA) {
654
                    if (!$librarycache->has($file->get_contenthash())) {
655
                        $librarycache->set($file->get_contenthash(), file_get_contents($item->getPathName()));
656
                    }
657
                }
658
            }
659
            $it->next();
660
        }
661
    }
662
 
663
    /**
664
     * Copies files from storage to temporary folder.
665
     *
666
     * @param string $target Path to temporary folder
667
     * @param int $contextid context where the files are found
668
     * @param string $filearea file area
669
     * @param string $filepath file path
670
     * @param int $itemid Optional item ID
671
     */
672
    private function export_file_tree(string $target, int $contextid, string $filearea, string $filepath, int $itemid = 0): void {
673
        // Make sure target folder exists.
674
        check_dir_exists($target);
675
 
676
        // Read source files.
677
        $files = $this->fs->get_directory_files($contextid, self::COMPONENT, $filearea, $itemid, $filepath, true);
678
 
679
        $librarycache = \cache::make('core', 'h5p_library_files');
680
 
681
        foreach ($files as $file) {
682
            $path = $target . str_replace($filepath, DIRECTORY_SEPARATOR, $file->get_filepath());
683
            if ($file->is_directory()) {
684
                check_dir_exists(rtrim($path));
685
            } else {
686
                if ($filearea == self::LIBRARY_FILEAREA) {
687
                    $cachedfile = $librarycache->get($file->get_contenthash());
688
                    if (empty($cachedfile)) {
689
                        $file->copy_content_to($path . $file->get_filename());
690
                        $librarycache->set($file->get_contenthash(), file_get_contents($path . $file->get_filename()));
691
                    } else {
692
                        file_put_contents($path . $file->get_filename(), $cachedfile);
693
                    }
694
                } else {
695
                    $file->copy_content_to($path . $file->get_filename());
696
                }
697
            }
698
        }
699
    }
700
 
701
    /**
702
     * Adds all files of a type into one file.
703
     *
704
     * @param  array    $assets  A list of files.
705
     * @param  string   $type    The type of files in assets. Either 'scripts' or 'styles'
706
     * @param  \context $context Context
707
     * @return string All of the file content in one string.
708
     */
709
    private function concatenate_files(array $assets, string $type, \context $context): string {
710
        $content = '';
711
        foreach ($assets as $asset) {
712
            // Find location of asset.
713
            list(
714
                'filearea' => $filearea,
715
                'filepath' => $filepath,
716
                'filename' => $filename,
717
                'itemid' => $itemid
718
            ) = $this->get_file_elements_from_filepath($asset->path);
719
 
720
            if ($itemid === false) {
721
                continue;
722
            }
723
 
724
            // Locate file.
725
            $file = $this->fs->get_file($context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
726
 
727
            // Get file content and concatenate.
728
            if ($type === 'scripts') {
729
                $content .= $file->get_content() . ";\n";
730
            } else {
731
                // Rewrite relative URLs used inside stylesheets.
732
                $content .= preg_replace_callback(
733
                    '/url\([\'"]?([^"\')]+)[\'"]?\)/i',
734
                    function ($matches) use ($filearea, $filepath, $itemid) {
735
                        if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) {
736
                            return $matches[0]; // Not relative, skip.
737
                        }
738
                        // Find "../" in matches[1].
739
                        // If it exists, we have to remove "../".
740
                        // And switch the last folder in the filepath for the first folder in $matches[1].
741
                        // For instance:
742
                        // $filepath: /H5P.Question-1.4/styles/
743
                        // $matches[1]: ../images/plus-one.svg
744
                        // We want to avoid this: H5P.Question-1.4/styles/ITEMID/../images/minus-one.svg
745
                        // We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg.
746
                        if (preg_match('/\.\.\//', $matches[1], $pathmatches)) {
747
                            $path = preg_split('/\//', $filepath, -1, PREG_SPLIT_NO_EMPTY);
748
                            $pathfilename = preg_split('/\//', $matches[1], -1, PREG_SPLIT_NO_EMPTY);
749
                            // Remove the first element: ../.
750
                            array_shift($pathfilename);
751
                            // Replace pathfilename into the filepath.
752
                            $path[count($path) - 1] = $pathfilename[0];
753
                            $filepath = '/' . implode('/', $path) . '/';
754
                            // Remove the element used to replace.
755
                            array_shift($pathfilename);
756
                            $matches[1] = implode('/', $pathfilename);
757
                        }
758
                        return 'url("../' . $filearea . $filepath . $itemid . '/' . $matches[1] . '")';
759
                    },
760
                    $file->get_content()) . "\n";
761
            }
762
        }
763
        return $content;
764
    }
765
 
766
    /**
767
     * Get files ready for export.
768
     *
769
     * @param  string $filename File name to retrieve.
770
     * @return bool|\stored_file Stored file instance if exists, false if not
771
     */
772
    public function get_export_file(string $filename) {
773
        return $this->fs->get_file($this->context->id, self::COMPONENT, self::EXPORT_FILEAREA, 0, '/', $filename);
774
    }
775
 
776
    /**
777
     * Converts a relative system file path into Moodle File API elements.
778
     *
779
     * @param  string $filepath The system filepath to get information from.
780
     * @return array File information.
781
     */
782
    private function get_file_elements_from_filepath(string $filepath): array {
783
        $sections = explode('/', $filepath);
784
        // Get the filename.
785
        $filename = array_pop($sections);
786
        // Discard first element.
787
        if (empty($sections[0])) {
788
            array_shift($sections);
789
        }
790
        // Get the filearea.
791
        $filearea = array_shift($sections);
792
        $itemid = array_shift($sections);
793
        // Get the filepath.
794
        $filepath = implode('/', $sections);
795
        $filepath = '/' . $filepath . '/';
796
 
797
        return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename, 'itemid' => $itemid];
798
    }
799
 
800
    /**
801
     * Returns the item id given the other necessary variables.
802
     *
803
     * @param  string $filearea The file area.
804
     * @param  string $filepath The file path.
805
     * @param  string $filename The file name.
806
     * @return mixed the specified value false if not found.
807
     */
808
    private function get_itemid_for_file(string $filearea, string $filepath, string $filename) {
809
        global $DB;
810
        return $DB->get_field('files', 'itemid', ['component' => self::COMPONENT, 'filearea' => $filearea, 'filepath' => $filepath,
811
                'filename' => $filename]);
812
    }
813
 
814
    /**
815
     * Helper to make it easy to load content files.
816
     *
817
     * @param string $filearea File area where the file is saved.
818
     * @param int $itemid Content instance or content id.
819
     * @param string $file File path and name.
820
     *
821
     * @return stored_file|null
822
     */
823
    private function get_file(string $filearea, int $itemid, string $file): ?stored_file {
824
        global $USER;
825
 
826
        $component = self::COMPONENT;
827
        $context = $this->context->id;
828
        if ($filearea === 'draft') {
829
            $itemid = 0;
830
            $component = 'user';
831
            $usercontext = \context_user::instance($USER->id);
832
            $context = $usercontext->id;
833
        }
834
 
835
        $filepath = '/'. dirname($file). '/';
836
        $filename = basename($file);
837
 
838
        // Load file.
839
        $existingfile = $this->fs->get_file($context, $component, $filearea, $itemid, $filepath, $filename);
840
        if (!$existingfile) {
841
            return null;
842
        }
843
 
844
        return $existingfile;
845
    }
846
 
847
    /**
848
     * Move a single file
849
     *
850
     * @param string $sourcefile Path to source file
851
     * @param int $contentid Content id or 0 if the file is in the editor file area
852
     *
853
     * @return void
854
     */
855
    private function move_file(string $sourcefile, int $contentid): void {
856
        $pathparts = pathinfo($sourcefile);
857
        $filename  = $pathparts['basename'];
858
        $filepath  = $pathparts['dirname'];
859
        $foldername = basename($filepath);
860
 
861
        // Create file record for content.
862
        $record = array(
863
            'contextid' => $this->context->id,
864
            'component' => $contentid > 0 ? self::COMPONENT : 'user',
865
            'filearea' => $contentid > 0 ? self::CONTENT_FILEAREA : 'draft',
866
            'itemid' => $contentid > 0 ? $contentid : 0,
867
            'filepath' => '/' . $foldername . '/',
868
            'filename' => $filename
869
        );
870
 
871
        $file = $this->fs->get_file(
872
            $record['contextid'], $record['component'],
873
            $record['filearea'], $record['itemid'], $record['filepath'],
874
            $record['filename']
875
        );
876
 
877
        if ($file) {
878
            // Delete it to make sure that it is replaced with correct content.
879
            $file->delete();
880
        }
881
 
882
        $this->fs->create_file_from_pathname($record, $sourcefile);
883
    }
884
 
885
    /**
886
     * Generate H5P custom styles if any.
887
     */
888
    public static function generate_custom_styles(): void {
889
        $record = self::get_custom_styles_file_record();
890
        $cssfile = self::get_custom_styles_file($record);
891
        if ($cssfile) {
892
            // The CSS file needs to be updated, so delete and recreate it
893
            // if there is CSS in the 'h5pcustomcss' setting.
894
            $cssfile->delete();
895
        }
896
 
897
        $css = get_config('core_h5p', 'h5pcustomcss');
898
        if (!empty($css)) {
899
            $fs = get_file_storage();
900
            $fs->create_file_from_string($record, $css);
901
        }
902
    }
903
 
904
    /**
905
     * Get H5P custom styles if any.
906
     *
907
     * @throws \moodle_exception If the CSS setting is empty but there is a file to serve
908
     * or there is no file but the CSS setting is not empty.
909
     * @return array|null If there is CSS then an array with the keys 'cssurl'
910
     * and 'cssversion' is returned otherwise null.  'cssurl' is a link to the
911
     * generated 'custom_h5p.css' file and 'cssversion' the md5 hash of its contents.
912
     */
913
    public static function get_custom_styles(): ?array {
914
        $record = self::get_custom_styles_file_record();
915
 
916
        $css = get_config('core_h5p', 'h5pcustomcss');
917
        if (self::get_custom_styles_file($record)) {
918
            if (empty($css)) {
919
                // The custom CSS file exists and yet the setting 'h5pcustomcss' is empty.
920
                // This prevents an invalid content hash.
921
                throw new \moodle_exception(
922
                    'The H5P \'h5pcustomcss\' setting is empty and yet the custom CSS file \''.
923
                    $record['filename'].
924
                    '\' exists.',
925
                    'core_h5p'
926
                );
927
            }
928
            // File exists, so generate the url and version hash.
929
            $cssurl = \moodle_url::make_pluginfile_url(
930
                $record['contextid'],
931
                $record['component'],
932
                $record['filearea'],
933
                null,
934
                $record['filepath'],
935
                $record['filename']
936
            );
937
            return ['cssurl' => $cssurl, 'cssversion' => md5($css)];
938
        } else if (!empty($css)) {
939
            // The custom CSS file does not exist and yet should do.
940
            throw new \moodle_exception(
941
                'The H5P custom CSS file \''.
942
                $record['filename'].
943
                '\' does not exist and yet there is CSS in the \'h5pcustomcss\' setting.',
944
                'core_h5p'
945
            );
946
        }
947
        return null;
948
    }
949
 
950
    /**
951
     * Get H5P custom styles file record.
952
     *
953
     * @return array File record for the CSS custom styles.
954
     */
955
    private static function get_custom_styles_file_record(): array {
956
        return [
957
            'contextid' => \context_system::instance()->id,
958
            'component' => self::COMPONENT,
959
            'filearea' => self::CSS_FILEAREA,
960
            'itemid' => 0,
961
            'filepath' => '/',
962
            'filename' => self::CUSTOM_CSS_FILENAME,
963
        ];
964
    }
965
 
966
    /**
967
     * Get H5P custom styles file.
968
     *
969
     * @param array $record The H5P custom styles file record.
970
     *
971
     * @return stored_file|bool stored_file instance if exists, false if not.
972
     */
973
    private static function get_custom_styles_file($record): stored_file|bool {
974
        $fs = get_file_storage();
975
        return $fs->get_file(
976
            $record['contextid'],
977
            $record['component'],
978
            $record['filearea'],
979
            $record['itemid'],
980
            $record['filepath'],
981
            $record['filename']
982
        );
983
    }
984
}