Merge branch 'MDL-69005' of https://github.com/paulholden/moodle
[moodle.git] / h5p / classes / file_storage.php
blob675645c40a010c06bfb7fdfb75a667514f1c5578
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 H5peditorFile;
28 use stored_file;
30 /**
31 * Class to handle storage and export of H5P Content.
33 * @package core_h5p
34 * @copyright 2019 Victor Deniz <victor@moodle.com>
35 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 class file_storage implements \H5PFileStorage {
39 /** The component for H5P. */
40 public const COMPONENT = 'core_h5p';
41 /** The library file area. */
42 public const LIBRARY_FILEAREA = 'libraries';
43 /** The content file area */
44 public const CONTENT_FILEAREA = 'content';
45 /** The cached assest file area. */
46 public const CACHED_ASSETS_FILEAREA = 'cachedassets';
47 /** The export file area */
48 public const EXPORT_FILEAREA = 'export';
49 /** The icon filename */
50 public const ICON_FILENAME = 'icon.svg';
51 /** The editor file area */
52 public const EDITOR_FILEAREA = 'editor';
54 /**
55 * @var \context $context Currently we use the system context everywhere.
56 * Don't feel forced to keep it this way in the future.
58 protected $context;
60 /** @var \file_storage $fs File storage. */
61 protected $fs;
63 /**
64 * Initial setup for file_storage.
66 public function __construct() {
67 // Currently everything uses the system context.
68 $this->context = \context_system::instance();
69 $this->fs = get_file_storage();
72 /**
73 * Stores a H5P library in the Moodle filesystem.
75 * @param array $library Library properties.
77 public function saveLibrary($library) {
78 $options = [
79 'contextid' => $this->context->id,
80 'component' => self::COMPONENT,
81 'filearea' => self::LIBRARY_FILEAREA,
82 'filepath' => '/' . \H5PCore::libraryToString($library, true) . '/',
83 'itemid' => $library['libraryId']
86 // Easiest approach: delete the existing library version and copy the new one.
87 $this->delete_library($library);
88 $this->copy_directory($library['uploadDirectory'], $options);
91 /**
92 * Store the content folder.
94 * @param string $source Path on file system to content directory.
95 * @param array $content Content properties
97 public function saveContent($source, $content) {
98 $options = [
99 'contextid' => $this->context->id,
100 'component' => self::COMPONENT,
101 'filearea' => self::CONTENT_FILEAREA,
102 'itemid' => $content['id'],
103 'filepath' => '/',
106 $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
107 // Copy content directory into Moodle filesystem.
108 $this->copy_directory($source, $options);
112 * Remove content folder.
114 * @param array $content Content properties
116 public function deleteContent($content) {
118 $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
122 * Creates a stored copy of the content folder.
124 * @param string $id Identifier of content to clone.
125 * @param int $newid The cloned content's identifier
127 public function cloneContent($id, $newid) {
128 // Not implemented in Moodle.
132 * Get path to a new unique tmp folder.
133 * Please note this needs to not be a directory.
135 * @return string Path
137 public function getTmpPath(): string {
138 return make_request_directory() . '/' . uniqid('h5p-');
142 * Fetch content folder and save in target directory.
144 * @param int $id Content identifier
145 * @param string $target Where the content folder will be saved
147 public function exportContent($id, $target) {
148 $this->export_file_tree($target, $this->context->id, self::CONTENT_FILEAREA, '/', $id);
152 * Fetch library folder and save in target directory.
154 * @param array $library Library properties
155 * @param string $target Where the library folder will be saved
157 public function exportLibrary($library, $target) {
158 $folder = \H5PCore::libraryToString($library, true);
159 $this->export_file_tree($target . '/' . $folder, $this->context->id, self::LIBRARY_FILEAREA,
160 '/' . $folder . '/', $library['libraryId']);
164 * Save export in file system
166 * @param string $source Path on file system to temporary export file.
167 * @param string $filename Name of export file.
169 public function saveExport($source, $filename) {
170 global $USER;
172 // Remove old export.
173 $this->deleteExport($filename);
175 $filerecord = [
176 'contextid' => $this->context->id,
177 'component' => self::COMPONENT,
178 'filearea' => self::EXPORT_FILEAREA,
179 'itemid' => 0,
180 'filepath' => '/',
181 'filename' => $filename,
182 'userid' => $USER->id
184 $this->fs->create_file_from_pathname($filerecord, $source);
188 * Removes given export file
190 * @param string $filename filename of the export to delete.
192 public function deleteExport($filename) {
193 $file = $this->get_export_file($filename);
194 if ($file) {
195 $file->delete();
200 * Check if the given export file exists
202 * @param string $filename The export file to check.
203 * @return boolean True if the export file exists.
205 public function hasExport($filename) {
206 return !!$this->get_export_file($filename);
210 * Will concatenate all JavaScrips and Stylesheets into two files in order
211 * to improve page performance.
213 * @param array $files A set of all the assets required for content to display
214 * @param string $key Hashed key for cached asset
216 public function cacheAssets(&$files, $key) {
218 foreach ($files as $type => $assets) {
219 if (empty($assets)) {
220 continue;
223 // Create new file for cached assets.
224 $ext = ($type === 'scripts' ? 'js' : 'css');
225 $filename = $key . '.' . $ext;
226 $fileinfo = [
227 'contextid' => $this->context->id,
228 'component' => self::COMPONENT,
229 'filearea' => self::CACHED_ASSETS_FILEAREA,
230 'itemid' => 0,
231 'filepath' => '/',
232 'filename' => $filename
235 // Store concatenated content.
236 $this->fs->create_file_from_string($fileinfo, $this->concatenate_files($assets, $type, $this->context));
237 $files[$type] = [
238 (object) [
239 'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . $filename,
240 'version' => ''
247 * Will check if there are cache assets available for content.
249 * @param string $key Hashed key for cached asset
250 * @return array
252 public function getCachedAssets($key) {
253 $files = [];
255 $js = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.js");
256 if ($js && $js->get_filesize() > 0) {
257 $files['scripts'] = [
258 (object) [
259 'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.js",
260 'version' => ''
265 $css = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.css");
266 if ($css && $css->get_filesize() > 0) {
267 $files['styles'] = [
268 (object) [
269 'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.css",
270 'version' => ''
275 return empty($files) ? null : $files;
279 * Remove the aggregated cache files.
281 * @param array $keys The hash keys of removed files
283 public function deleteCachedAssets($keys) {
285 if (empty($keys)) {
286 return;
289 foreach ($keys as $hash) {
290 foreach (['js', 'css'] as $type) {
291 $cachedasset = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/',
292 "{$hash}.{$type}");
293 if ($cachedasset) {
294 $cachedasset->delete();
301 * Read file content of given file and then return it.
303 * @param string $filepath
304 * @return string contents
306 public function getContent($filepath) {
307 list(
308 'filearea' => $filearea,
309 'filepath' => $filepath,
310 'filename' => $filename,
311 'itemid' => $itemid
312 ) = $this->get_file_elements_from_filepath($filepath);
314 if (!$itemid) {
315 throw new \file_serving_exception('Could not retrieve the requested file, check your file permissions.');
318 // Locate file.
319 $file = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
321 // Return content.
322 return $file->get_content();
326 * Save files uploaded through the editor.
328 * @param H5peditorFile $file
329 * @param int $contentid
331 * @return int The id of the saved file.
333 public function saveFile($file, $contentid) {
334 $record = array(
335 'contextid' => $this->context->id,
336 'component' => self::COMPONENT,
337 'filearea' => $contentid === 0 ? self::EDITOR_FILEAREA : self::CONTENT_FILEAREA,
338 'itemid' => $contentid,
339 'filepath' => '/' . $file->getType() . 's/',
340 'filename' => $file->getName()
343 $storedfile = $this->fs->create_file_from_pathname($record, $_FILES['file']['tmp_name']);
345 return $storedfile->get_id();
349 * Copy a file from another content or editor tmp dir.
350 * Used when copy pasting content in H5P.
352 * @param string $file path + name
353 * @param string|int $fromid Content ID or 'editor' string
354 * @param \stdClass $tocontent Target Content
356 * @return void
358 public function cloneContentFile($file, $fromid, $tocontent): void {
359 // Determine source filearea and itemid.
360 if ($fromid === self::EDITOR_FILEAREA) {
361 $sourcefilearea = self::EDITOR_FILEAREA;
362 $sourceitemid = 0;
363 } else {
364 $sourcefilearea = self::CONTENT_FILEAREA;
365 $sourceitemid = (int)$fromid;
368 $filepath = '/' . dirname($file) . '/';
369 $filename = basename($file);
371 // Check to see if source exists.
372 $sourcefile = $this->get_file($sourcefilearea, $sourceitemid, $file);
373 if ($sourcefile === null) {
374 return; // Nothing to copy from.
377 // Check to make sure that file doesn't exist already in target.
378 $targetfile = $this->get_file(self::CONTENT_FILEAREA, $tocontent->id, $file);
379 if ( $targetfile !== null) {
380 return; // File exists, no need to copy.
383 // Create new file record.
384 $record = [
385 'contextid' => $this->context->id,
386 'component' => self::COMPONENT,
387 'filearea' => self::CONTENT_FILEAREA,
388 'itemid' => $tocontent->id,
389 'filepath' => $filepath,
390 'filename' => $filename,
393 $this->fs->create_file_from_storedfile($record, $sourcefile);
397 * Copy content from one directory to another.
398 * Defaults to cloning content from the current temporary upload folder to the editor path.
400 * @param string $source path to source directory
401 * @param string $contentid Id of content
404 public function moveContentDirectory($source, $contentid = null) {
405 $contentidint = (int)$contentid;
407 if ($source === null) {
408 return;
411 // Get H5P and content json.
412 $contentsource = $source . '/content';
414 // Move all temporary content files to editor.
415 $it = new \RecursiveIteratorIterator(
416 new \RecursiveDirectoryIterator($contentsource,\RecursiveDirectoryIterator::SKIP_DOTS),
417 \RecursiveIteratorIterator::SELF_FIRST
420 $it->rewind();
421 while ($it->valid()) {
422 $item = $it->current();
423 $pathname = $it->getPathname();
424 if (!$item->isDir() && !($item->getFilename() === 'content.json')) {
425 $this->move_file($pathname, $contentidint);
427 $it->next();
432 * Get the file URL or given library and then return it.
434 * @param int $itemid
435 * @param string $machinename
436 * @param int $majorversion
437 * @param int $minorversion
438 * @return string url or false if the file doesn't exist
440 public function get_icon_url(int $itemid, string $machinename, int $majorversion, int $minorversion) {
441 $filepath = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
442 if ($file = $this->fs->get_file(
443 $this->context->id,
444 self::COMPONENT,
445 self::LIBRARY_FILEAREA,
446 $itemid,
447 $filepath,
448 self::ICON_FILENAME)
450 $iconurl = \moodle_url::make_pluginfile_url(
451 $this->context->id,
452 self::COMPONENT,
453 self::LIBRARY_FILEAREA,
454 $itemid,
455 $filepath,
456 $file->get_filename());
458 // Return image URL.
459 return $iconurl->out();
462 return false;
466 * Checks to see if an H5P content has the given file.
468 * @param string $file File path and name.
469 * @param int $content Content id.
471 * @return int|null File ID or NULL if not found
473 public function getContentFile($file, $content): ?int {
474 if (is_object($content)) {
475 $content = $content->id;
477 $contentfile = $this->get_file(self::CONTENT_FILEAREA, $content, $file);
479 return ($contentfile === null ? null : $contentfile->get_id());
483 * Remove content files that are no longer used.
485 * Used when saving content.
487 * @param string $file File path and name.
488 * @param int $contentid Content id.
490 * @return void
492 public function removeContentFile($file, $contentid): void {
493 // Although the interface defines $contentid as int, object given in \H5peditor::processParameters.
494 if (is_object($contentid)) {
495 $contentid = $contentid->id;
497 $existingfile = $this->get_file(self::CONTENT_FILEAREA, $contentid, $file);
498 if ($existingfile !== null) {
499 $existingfile->delete();
504 * Check if server setup has write permission to
505 * the required folders
507 * @return bool True if server has the proper write access
509 public function hasWriteAccess() {
510 // Moodle has access to the files table which is where all of the folders are stored.
511 return true;
515 * Check if the library has a presave.js in the root folder
517 * @param string $libraryname
518 * @param string $developmentpath
519 * @return bool
521 public function hasPresave($libraryname, $developmentpath = null) {
522 return false;
526 * Check if upgrades script exist for library.
528 * @param string $machinename
529 * @param int $majorversion
530 * @param int $minorversion
531 * @return string Relative path
533 public function getUpgradeScript($machinename, $majorversion, $minorversion) {
534 $path = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
535 $file = 'upgrade.js';
536 $itemid = $this->get_itemid_for_file(self::LIBRARY_FILEAREA, $path, $file);
537 if ($this->fs->get_file($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $itemid, $path, $file)) {
538 return '/' . self::LIBRARY_FILEAREA . $path. $file;
539 } else {
540 return null;
545 * Store the given stream into the given file.
547 * @param string $path
548 * @param string $file
549 * @param resource $stream
550 * @return bool|int
552 public function saveFileFromZip($path, $file, $stream) {
553 $fullpath = $path . '/' . $file;
554 check_dir_exists(pathinfo($fullpath, PATHINFO_DIRNAME));
555 return file_put_contents($fullpath, $stream);
559 * Deletes a library from the file system.
561 * @param array $library Library details
563 public function delete_library(array $library): void {
565 // A library ID of false would result in all library files being deleted, which we don't want. Return instead.
566 if ($library['libraryId'] === false) {
567 return;
570 $this->delete_directory($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
574 * Remove an H5P directory from the filesystem.
576 * @param int $contextid context ID
577 * @param string $component component
578 * @param string $filearea file area or all areas in context if not specified
579 * @param int $itemid item ID or all files if not specified
581 private function delete_directory(int $contextid, string $component, string $filearea, int $itemid): void {
583 $this->fs->delete_area_files($contextid, $component, $filearea, $itemid);
587 * Copy an H5P directory from the temporary directory into the file system.
589 * @param string $source Temporary location for files.
590 * @param array $options File system information.
592 private function copy_directory(string $source, array $options): void {
593 $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
594 \RecursiveIteratorIterator::SELF_FIRST);
596 $root = $options['filepath'];
598 $it->rewind();
599 while ($it->valid()) {
600 $item = $it->current();
601 $subpath = $it->getSubPath();
602 if (!$item->isDir()) {
603 $options['filename'] = $it->getFilename();
604 if (!$subpath == '') {
605 $options['filepath'] = $root . $subpath . '/';
606 } else {
607 $options['filepath'] = $root;
610 $this->fs->create_file_from_pathname($options, $item->getPathName());
612 $it->next();
617 * Copies files from storage to temporary folder.
619 * @param string $target Path to temporary folder
620 * @param int $contextid context where the files are found
621 * @param string $filearea file area
622 * @param string $filepath file path
623 * @param int $itemid Optional item ID
625 private function export_file_tree(string $target, int $contextid, string $filearea, string $filepath, int $itemid = 0): void {
626 // Make sure target folder exists.
627 check_dir_exists($target);
629 // Read source files.
630 $files = $this->fs->get_directory_files($contextid, self::COMPONENT, $filearea, $itemid, $filepath, true);
632 foreach ($files as $file) {
633 $path = $target . str_replace($filepath, DIRECTORY_SEPARATOR, $file->get_filepath());
634 if ($file->is_directory()) {
635 check_dir_exists(rtrim($path));
636 } else {
637 $file->copy_content_to($path . $file->get_filename());
643 * Adds all files of a type into one file.
645 * @param array $assets A list of files.
646 * @param string $type The type of files in assets. Either 'scripts' or 'styles'
647 * @param \context $context Context
648 * @return string All of the file content in one string.
650 private function concatenate_files(array $assets, string $type, \context $context): string {
651 $content = '';
652 foreach ($assets as $asset) {
653 // Find location of asset.
654 list(
655 'filearea' => $filearea,
656 'filepath' => $filepath,
657 'filename' => $filename,
658 'itemid' => $itemid
659 ) = $this->get_file_elements_from_filepath($asset->path);
661 if ($itemid === false) {
662 continue;
665 // Locate file.
666 $file = $this->fs->get_file($context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
668 // Get file content and concatenate.
669 if ($type === 'scripts') {
670 $content .= $file->get_content() . ";\n";
671 } else {
672 // Rewrite relative URLs used inside stylesheets.
673 $content .= preg_replace_callback(
674 '/url\([\'"]?([^"\')]+)[\'"]?\)/i',
675 function ($matches) use ($filearea, $filepath, $itemid) {
676 if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) {
677 return $matches[0]; // Not relative, skip.
679 // Find "../" in matches[1].
680 // If it exists, we have to remove "../".
681 // And switch the last folder in the filepath for the first folder in $matches[1].
682 // For instance:
683 // $filepath: /H5P.Question-1.4/styles/
684 // $matches[1]: ../images/plus-one.svg
685 // We want to avoid this: H5P.Question-1.4/styles/ITEMID/../images/minus-one.svg
686 // We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg.
687 if (preg_match('/\.\.\//', $matches[1], $pathmatches)) {
688 $path = preg_split('/\//', $filepath, -1, PREG_SPLIT_NO_EMPTY);
689 $pathfilename = preg_split('/\//', $matches[1], -1, PREG_SPLIT_NO_EMPTY);
690 // Remove the first element: ../.
691 array_shift($pathfilename);
692 // Replace pathfilename into the filepath.
693 $path[count($path) - 1] = $pathfilename[0];
694 $filepath = '/' . implode('/', $path) . '/';
695 // Remove the element used to replace.
696 array_shift($pathfilename);
697 $matches[1] = implode('/', $pathfilename);
699 return 'url("../' . $filearea . $filepath . $itemid . '/' . $matches[1] . '")';
701 $file->get_content()) . "\n";
704 return $content;
708 * Get files ready for export.
710 * @param string $filename File name to retrieve.
711 * @return bool|\stored_file Stored file instance if exists, false if not
713 public function get_export_file(string $filename) {
714 return $this->fs->get_file($this->context->id, self::COMPONENT, self::EXPORT_FILEAREA, 0, '/', $filename);
718 * Converts a relative system file path into Moodle File API elements.
720 * @param string $filepath The system filepath to get information from.
721 * @return array File information.
723 private function get_file_elements_from_filepath(string $filepath): array {
724 $sections = explode('/', $filepath);
725 // Get the filename.
726 $filename = array_pop($sections);
727 // Discard first element.
728 if (empty($sections[0])) {
729 array_shift($sections);
731 // Get the filearea.
732 $filearea = array_shift($sections);
733 $itemid = array_shift($sections);
734 // Get the filepath.
735 $filepath = implode('/', $sections);
736 $filepath = '/' . $filepath . '/';
738 return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename, 'itemid' => $itemid];
742 * Returns the item id given the other necessary variables.
744 * @param string $filearea The file area.
745 * @param string $filepath The file path.
746 * @param string $filename The file name.
747 * @return mixed the specified value false if not found.
749 private function get_itemid_for_file(string $filearea, string $filepath, string $filename) {
750 global $DB;
751 return $DB->get_field('files', 'itemid', ['component' => self::COMPONENT, 'filearea' => $filearea, 'filepath' => $filepath,
752 'filename' => $filename]);
756 * Helper to make it easy to load content files.
758 * @param string $filearea File area where the file is saved.
759 * @param int $itemid Content instance or content id.
760 * @param string $file File path and name.
762 * @return stored_file|null
764 private function get_file(string $filearea, int $itemid, string $file): ?stored_file {
765 if ($filearea === 'editor') {
766 $itemid = 0;
769 $filepath = '/'. dirname($file). '/';
770 $filename = basename($file);
772 // Load file.
773 $existingfile = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
774 if (!$existingfile) {
775 return null;
778 return $existingfile;
782 * Move a single file
784 * @param string $sourcefile Path to source file
785 * @param int $contentid Content id or 0 if the file is in the editor file area
787 * @return void
789 private function move_file(string $sourcefile, int $contentid): void {
790 $pathparts = pathinfo($sourcefile);
791 $filename = $pathparts['basename'];
792 $filepath = $pathparts['dirname'];
793 $foldername = basename($filepath);
795 // Create file record for content.
796 $record = array(
797 'contextid' => $this->context->id,
798 'component' => self::COMPONENT,
799 'filearea' => $contentid > 0 ? self::CONTENT_FILEAREA : self::EDITOR_FILEAREA,
800 'itemid' => $contentid > 0 ? $contentid : 0,
801 'filepath' => '/' . $foldername . '/',
802 'filename' => $filename
805 $file = $this->fs->get_file(
806 $record['contextid'], $record['component'],
807 $record['filearea'], $record['itemid'], $record['filepath'],
808 $record['filename']
811 if ($file) {
812 // Delete it to make sure that it is replaced with correct content.
813 $file->delete();
816 $this->fs->create_file_from_pathname($record, $sourcefile);