Merge branch 'MDL-81073' of https://github.com/paulholden/moodle
[moodle.git] / h5p / classes / file_storage.php
blob25d681764c4ca25ae0d74edca8d45ca4af9cd25e
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.
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 * Class \core_h5p\file_storage.
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
25 namespace core_h5p;
27 use stored_file;
28 use Moodle\H5PCore;
29 use Moodle\H5peditorFile;
30 use Moodle\H5PFileStorage;
32 // phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod
34 /**
35 * Class to handle storage and export of H5P Content.
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
41 class file_storage implements H5PFileStorage {
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';
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.
64 protected $context;
66 /** @var \file_storage $fs File storage. */
67 protected $fs;
69 /**
70 * Initial setup for file_storage.
72 public function __construct() {
73 // Currently everything uses the system context.
74 $this->context = \context_system::instance();
75 $this->fs = get_file_storage();
78 /**
79 * Stores a H5P library in the Moodle filesystem.
81 * @param array $library Library properties.
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'],
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);
97 /**
98 * Delete library folder.
100 * @param array $library
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.
110 * Store the content folder.
112 * @param string $source Path on file system to content directory.
113 * @param array $content Content properties
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' => '/',
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);
130 * Remove content folder.
132 * @param array $content Content properties
134 public function deleteContent($content) {
136 $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
140 * Creates a stored copy of the content folder.
142 * @param string $id Identifier of content to clone.
143 * @param int $newid The cloned content's identifier
145 public function cloneContent($id, $newid) {
146 // Not implemented in Moodle.
150 * Get path to a new unique tmp folder.
151 * Please note this needs to not be a directory.
153 * @return string Path
155 public function getTmpPath(): string {
156 return make_request_directory() . '/' . uniqid('h5p-');
160 * Fetch content folder and save in target directory.
162 * @param int $id Content identifier
163 * @param string $target Where the content folder will be saved
165 public function exportContent($id, $target) {
166 $this->export_file_tree($target, $this->context->id, self::CONTENT_FILEAREA, '/', $id);
170 * Fetch library folder and save in target directory.
172 * @param array $library Library properties
173 * @param string $target Where the library folder will be saved
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']);
182 * Save export in file system
184 * @param string $source Path on file system to temporary export file.
185 * @param string $filename Name of export file.
187 public function saveExport($source, $filename) {
188 global $USER;
190 // Remove old export.
191 $this->deleteExport($filename);
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
202 $this->fs->create_file_from_pathname($filerecord, $source);
206 * Removes given export file
208 * @param string $filename filename of the export to delete.
210 public function deleteExport($filename) {
211 $file = $this->get_export_file($filename);
212 if ($file) {
213 $file->delete();
218 * Check if the given export file exists
220 * @param string $filename The export file to check.
221 * @return boolean True if the export file exists.
223 public function hasExport($filename) {
224 return !!$this->get_export_file($filename);
228 * Will concatenate all JavaScrips and Stylesheets into two files in order
229 * to improve page performance.
231 * @param array $files A set of all the assets required for content to display
232 * @param string $key Hashed key for cached asset
234 public function cacheAssets(&$files, $key) {
236 foreach ($files as $type => $assets) {
237 if (empty($assets)) {
238 continue;
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
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' => ''
265 * Will check if there are cache assets available for content.
267 * @param string $key Hashed key for cached asset
268 * @return array
270 public function getCachedAssets($key) {
271 $files = [];
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' => ''
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' => ''
293 return empty($files) ? null : $files;
297 * Remove the aggregated cache files.
299 * @param array $keys The hash keys of removed files
301 public function deleteCachedAssets($keys) {
303 if (empty($keys)) {
304 return;
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();
319 * Read file content of given file and then return it.
321 * @param string $filepath
322 * @return string contents
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);
332 if (!$itemid) {
333 throw new \file_serving_exception('Could not retrieve the requested file, check your file permissions.');
336 // Locate file.
337 $file = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
339 // Return content.
340 return $file->get_content();
344 * Save files uploaded through the editor.
346 * @param H5peditorFile $file
347 * @param int $contentid
349 * @return int The id of the saved file.
351 public function saveFile($file, $contentid) {
352 global $USER;
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';
364 $record = array(
365 'contextid' => $context,
366 'component' => $component,
367 'filearea' => $filearea,
368 'itemid' => $contentid,
369 'filepath' => '/' . $file->getType() . 's/',
370 'filename' => $file->getName()
373 $storedfile = $this->fs->create_file_from_pathname($record, $_FILES['file']['tmp_name']);
375 return $storedfile->get_id();
379 * Copy a file from another content or editor tmp dir.
380 * Used when copy pasting content in H5P.
382 * @param string $file path + name
383 * @param string|int $fromid Content ID or 'editor' string
384 * @param \stdClass $tocontent Target Content
386 * @return void
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;
398 $filepath = '/' . dirname($file) . '/';
399 $filename = basename($file);
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.
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.
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,
423 $this->fs->create_file_from_storedfile($record, $sourcefile);
427 * Copy content from one directory to another.
428 * Defaults to cloning content from the current temporary upload folder to the editor path.
430 * @param string $source path to source directory
431 * @param string $contentid Id of content
434 public function moveContentDirectory($source, $contentid = null) {
435 $contentidint = (int)$contentid;
437 if ($source === null) {
438 return;
441 // Get H5P and content json.
442 $contentsource = $source . '/content';
444 // Move all temporary content files to editor.
445 $it = new \RecursiveIteratorIterator(
446 new \RecursiveDirectoryIterator($contentsource, \RecursiveDirectoryIterator::SKIP_DOTS),
447 \RecursiveIteratorIterator::SELF_FIRST
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);
457 $it->next();
462 * Get the file URL or given library and then return it.
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
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)
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());
488 // Return image URL.
489 return $iconurl->out();
492 return false;
496 * Checks to see if an H5P content has the given file.
498 * @param string $file File path and name.
499 * @param int $content Content id.
501 * @return int|null File ID or NULL if not found
503 public function getContentFile($file, $content): ?int {
504 if (is_object($content)) {
505 $content = $content->id;
507 $contentfile = $this->get_file(self::CONTENT_FILEAREA, $content, $file);
509 return ($contentfile === null ? null : $contentfile->get_id());
513 * Remove content files that are no longer used.
515 * Used when saving content.
517 * @param string $file File path and name.
518 * @param int $contentid Content id.
520 * @return void
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;
527 $existingfile = $this->get_file(self::CONTENT_FILEAREA, $contentid, $file);
528 if ($existingfile !== null) {
529 $existingfile->delete();
534 * Check if server setup has write permission to
535 * the required folders
537 * @return bool True if server has the proper write access
539 public function hasWriteAccess() {
540 // Moodle has access to the files table which is where all of the folders are stored.
541 return true;
545 * Check if the library has a presave.js in the root folder
547 * @param string $libraryname
548 * @param string $developmentpath
549 * @return bool
551 public function hasPresave($libraryname, $developmentpath = null) {
552 return false;
556 * Check if upgrades script exist for library.
558 * @param string $machinename
559 * @param int $majorversion
560 * @param int $minorversion
561 * @return string Relative path
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;
575 * Store the given stream into the given file.
577 * @param string $path
578 * @param string $file
579 * @param resource $stream
580 * @return bool|int
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);
589 * Deletes a library from the file system.
591 * @param array $library Library details
593 public function delete_library(array $library): void {
594 global $DB;
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;
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());
614 * Remove an H5P directory from the filesystem.
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
621 private function delete_directory(int $contextid, string $component, string $filearea, int $itemid): void {
623 $this->fs->delete_area_files($contextid, $component, $filearea, $itemid);
627 * Copy an H5P directory from the temporary directory into the file system.
629 * @param string $source Temporary location for files.
630 * @param array $options File system information.
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);
637 $root = $options['filepath'];
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;
651 $file = $this->fs->create_file_from_pathname($options, $item->getPathName());
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()));
659 $it->next();
664 * Copies files from storage to temporary folder.
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
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);
676 // Read source files.
677 $files = $this->fs->get_directory_files($contextid, self::COMPONENT, $filearea, $itemid, $filepath, true);
679 $librarycache = \cache::make('core', 'h5p_library_files');
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);
694 } else {
695 $file->copy_content_to($path . $file->get_filename());
702 * Adds all files of a type into one file.
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.
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);
720 if ($itemid === false) {
721 continue;
724 // Locate file.
725 $file = $this->fs->get_file($context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
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.
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);
758 return 'url("../' . $filearea . $filepath . $itemid . '/' . $matches[1] . '")';
760 $file->get_content()) . "\n";
763 return $content;
767 * Get files ready for export.
769 * @param string $filename File name to retrieve.
770 * @return bool|\stored_file Stored file instance if exists, false if not
772 public function get_export_file(string $filename) {
773 return $this->fs->get_file($this->context->id, self::COMPONENT, self::EXPORT_FILEAREA, 0, '/', $filename);
777 * Converts a relative system file path into Moodle File API elements.
779 * @param string $filepath The system filepath to get information from.
780 * @return array File information.
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);
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 . '/';
797 return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename, 'itemid' => $itemid];
801 * Returns the item id given the other necessary variables.
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.
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]);
815 * Helper to make it easy to load content files.
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.
821 * @return stored_file|null
823 private function get_file(string $filearea, int $itemid, string $file): ?stored_file {
824 global $USER;
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;
835 $filepath = '/'. dirname($file). '/';
836 $filename = basename($file);
838 // Load file.
839 $existingfile = $this->fs->get_file($context, $component, $filearea, $itemid, $filepath, $filename);
840 if (!$existingfile) {
841 return null;
844 return $existingfile;
848 * Move a single file
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
853 * @return void
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);
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
871 $file = $this->fs->get_file(
872 $record['contextid'], $record['component'],
873 $record['filearea'], $record['itemid'], $record['filepath'],
874 $record['filename']
877 if ($file) {
878 // Delete it to make sure that it is replaced with correct content.
879 $file->delete();
882 $this->fs->create_file_from_pathname($record, $sourcefile);
886 * Generate H5P custom styles if any.
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();
897 $css = get_config('core_h5p', 'h5pcustomcss');
898 if (!empty($css)) {
899 $fs = get_file_storage();
900 $fs->create_file_from_string($record, $css);
905 * Get H5P custom styles if any.
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.
913 public static function get_custom_styles(): ?array {
914 $record = self::get_custom_styles_file_record();
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'
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']
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'
947 return null;
951 * Get H5P custom styles file record.
953 * @return array File record for the CSS custom styles.
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,
967 * Get H5P custom styles file.
969 * @param array $record The H5P custom styles file record.
971 * @return stored_file|bool stored_file instance if exists, false if not.
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']