MDL-66609 core_h5p: Implement the file_storage interface.
[moodle.git] / h5p / classes / file_storage.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * 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  */
25 namespace core_h5p;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Class to handle storage and export of H5P Content.
31  *
32  * @package    core_h5p
33  * @copyright  2019 Victor Deniz <victor@moodle.com>
34  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class file_storage implements \H5PFileStorage {
38     /** The component for H5P. */
39     public const COMPONENT   = 'core_h5p';
40     /** The library file area. */
41     public const LIBRARY_FILEAREA = 'libraries';
42     /** The content file area */
43     public const CONTENT_FILEAREA = 'content';
44     /** The cached assest file area. */
45     public const CACHED_ASSETS_FILEAREA = 'cachedassets';
46     /** The export file area */
47     public const EXPORT_FILEAREA = 'export';
49     /**
50      * @var \context $context Currently we use the system context everywhere.
51      * Don't feel forced to keep it this way in the future.
52      */
53     protected $context;
55     /** @var \file_storage $fs File storage. */
56     protected $fs;
58     /**
59      * Initial setup for file_storage.
60      */
61     public function __construct() {
62         // Currently everything uses the system context.
63         $this->context = \context_system::instance();
64         $this->fs = get_file_storage();
65     }
67     /**
68      * Stores a H5P library in the Moodle filesystem.
69      *
70      * @param array $library Library properties.
71      */
72     public function saveLibrary($library) {
73         $options = [
74             'contextid' => $this->context->id,
75             'component' => self::COMPONENT,
76             'filearea' => self::LIBRARY_FILEAREA,
77             'filepath' => '/' . \H5PCore::libraryToString($library, true) . '/',
78             'itemid' => $library['libraryId']
79         ];
81         // Easiest approach: delete the existing library version and copy the new one.
82         $this->delete_library($library);
83         $this->copy_directory($library['uploadDirectory'], $options);
84     }
86     /**
87      * Store the content folder.
88      *
89      * @param string $source Path on file system to content directory.
90      * @param array $content Content properties
91      */
92     public function saveContent($source, $content) {
93         $options = [
94                 'contextid' => $this->context->id,
95                 'component' => self::COMPONENT,
96                 'filearea' => self::CONTENT_FILEAREA,
97                 'itemid' => $content['id'],
98                 'filepath' => '/',
99         ];
101         $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
102         // Copy content directory into Moodle filesystem.
103         $this->copy_directory($source, $options);
104     }
106     /**
107      * Remove content folder.
108      *
109      * @param array $content Content properties
110      */
111     public function deleteContent($content) {
113         $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
114     }
116     /**
117      * Creates a stored copy of the content folder.
118      *
119      * @param string $id Identifier of content to clone.
120      * @param int $newid The cloned content's identifier
121      */
122     public function cloneContent($id, $newid) {
123         // Not implemented in Moodle.
124     }
126     /**
127      * Get path to a new unique tmp folder.
128      * Please note this needs to not be a directory.
129      *
130      * @return string Path
131      */
132     public function getTmpPath(): string {
133         return make_request_directory() . '/' . uniqid('h5p-');
134     }
136     /**
137      * Fetch content folder and save in target directory.
138      *
139      * @param int $id Content identifier
140      * @param string $target Where the content folder will be saved
141      */
142     public function exportContent($id, $target) {
143         $this->export_file_tree($target, $this->context->id, self::CONTENT_FILEAREA, '/', $id);
144     }
146     /**
147      * Fetch library folder and save in target directory.
148      *
149      * @param array $library Library properties
150      * @param string $target Where the library folder will be saved
151      */
152     public function exportLibrary($library, $target) {
153         $folder = \H5PCore::libraryToString($library, true);
154         $this->export_file_tree($target . '/' . $folder, $this->context->id, self::LIBRARY_FILEAREA,
155                 '/' . $folder . '/', $library['libraryId']);
156     }
158     /**
159      * Save export in file system
160      *
161      * @param string $source Path on file system to temporary export file.
162      * @param string $filename Name of export file.
163      */
164     public function saveExport($source, $filename) {
165         $filerecord = [
166             'contextid' => $this->context->id,
167             'component' => self::COMPONENT,
168             'filearea' => self::EXPORT_FILEAREA,
169             'itemid' => 0,
170             'filepath' => '/',
171             'filename' => $filename
172         ];
173         $this->fs->create_file_from_pathname($filerecord, $source);
174     }
176     /**
177      * Removes given export file
178      *
179      * @param string $filename filename of the export to delete.
180      */
181     public function deleteExport($filename) {
182         $file = $this->get_export_file($filename);
183         if ($file) {
184             $file->delete();
185         }
186     }
188     /**
189      * Check if the given export file exists
190      *
191      * @param string $filename The export file to check.
192      * @return boolean True if the export file exists.
193      */
194     public function hasExport($filename) {
195         return !!$this->get_export_file($filename);
196     }
198     /**
199      * Will concatenate all JavaScrips and Stylesheets into two files in order
200      * to improve page performance.
201      *
202      * @param array $files A set of all the assets required for content to display
203      * @param string $key Hashed key for cached asset
204      */
205     public function cacheAssets(&$files, $key) {
207         foreach ($files as $type => $assets) {
208             if (empty($assets)) {
209                 continue;
210             }
212             // Create new file for cached assets.
213             $ext = ($type === 'scripts' ? 'js' : 'css');
214             $filename = $key . '.' . $ext;
215             $fileinfo = [
216                 'contextid' => $this->context->id,
217                 'component' => self::COMPONENT,
218                 'filearea' => self::CACHED_ASSETS_FILEAREA,
219                 'itemid' => 0,
220                 'filepath' => '/',
221                 'filename' => $filename
222             ];
224             // Store concatenated content.
225             $this->fs->create_file_from_string($fileinfo, $this->concatenate_files($assets, $type, $this->context));
226             $files[$type] = [
227                 (object) [
228                     'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . $filename,
229                     'version' => ''
230                 ]
231             ];
232         }
233     }
235     /**
236      * Will check if there are cache assets available for content.
237      *
238      * @param string $key Hashed key for cached asset
239      * @return array
240      */
241     public function getCachedAssets($key) {
242         $files = [];
244         $js = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.js");
245         if ($js && $js->get_filesize() > 0) {
246             $files['scripts'] = [
247                 (object) [
248                     'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.js",
249                     'version' => ''
250                 ]
251             ];
252         }
254         $css = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.css");
255         if ($css && $css->get_filesize() > 0) {
256             $files['styles'] = [
257                 (object) [
258                     'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.css",
259                     'version' => ''
260                 ]
261             ];
262         }
264         return empty($files) ? null : $files;
265     }
267     /**
268      * Remove the aggregated cache files.
269      *
270      * @param array $keys The hash keys of removed files
271      */
272     public function deleteCachedAssets($keys) {
274         if (empty($keys)) {
275             return;
276         }
278         foreach ($keys as $hash) {
279             foreach (['js', 'css'] as $type) {
280                 $cachedasset = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/',
281                         "{$hash}.{$type}");
282                 if ($cachedasset) {
283                     $cachedasset->delete();
284                 }
285             }
286         }
287     }
289     /**
290      * Read file content of given file and then return it.
291      *
292      * @param string $filepath
293      * @return string contents
294      */
295     public function getContent($filepath) {
296         list(
297             'filearea' => $filearea,
298             'filepath' => $filepath,
299             'filename' => $filename,
300             'itemid' => $itemid
301         ) = $this->get_file_elements_from_filepath($filepath);
303         if (!$itemid) {
304             throw new \file_serving_exception('Could not retrieve the requested file, check your file permissions.');
305         }
307         // Locate file.
308         $file = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
310         // Return content.
311         return $file->get_content();
312     }
314     /**
315      * Save files uploaded through the editor.
316      * The files must be marked as temporary until the content form is saved.
317      *
318      * @param \H5peditorFile $file
319      * @param int $contentid
320      */
321     public function saveFile($file, $contentid) {
322         // This is to be implemented when the h5p editor is introduced / created.
323     }
325     /**
326      * Copy a file from another content or editor tmp dir.
327      * Used when copy pasting content in H5P.
328      *
329      * @param string $file path + name
330      * @param string|int $fromid Content ID or 'editor' string
331      * @param int $toid Target Content ID
332      */
333     public function cloneContentFile($file, $fromid, $toid) {
334         // This is to be implemented when the h5p editor is introduced / created.
335     }
337     /**
338      * Copy content from one directory to another. Defaults to cloning
339      * content from the current temporary upload folder to the editor path.
340      *
341      * @param string $source path to source directory
342      * @param string $contentid Id of content
343      *
344      * @return object Object containing h5p json and content json data
345      */
346     public function moveContentDirectory($source, $contentid = null) {
347         // This is to be implemented when the h5p editor is introduced / created.
348     }
350     /**
351      * Checks to see if content has the given file.
352      * Used when saving content.
353      *
354      * @param string $file path + name
355      * @param int $contentid
356      * @return string|int File ID or NULL if not found
357      */
358     public function getContentFile($file, $contentid) {
359         // This is to be implemented when the h5p editor is introduced / created.
360     }
362     /**
363      * Remove content files that are no longer used.
364      * Used when saving content.
365      *
366      * @param string $file path + name
367      * @param int $contentid
368      */
369     public function removeContentFile($file, $contentid) {
370         // This is to be implemented when the h5p editor is introduced / created.
371     }
373     /**
374      * Check if server setup has write permission to
375      * the required folders
376      *
377      * @return bool True if server has the proper write access
378      */
379     public function hasWriteAccess() {
380         // Moodle has access to the files table which is where all of the folders are stored.
381         return true;
382     }
384     /**
385      * Check if the library has a presave.js in the root folder
386      *
387      * @param string $libraryname
388      * @param string $developmentpath
389      * @return bool
390      */
391     public function hasPresave($libraryname, $developmentpath = null) {
392         return false;
393     }
395     /**
396      * Check if upgrades script exist for library.
397      *
398      * @param string $machinename
399      * @param int $majorversion
400      * @param int $minorversion
401      * @return string Relative path
402      */
403     public function getUpgradeScript($machinename, $majorversion, $minorversion) {
404         $path = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
405         $file = 'upgrade.js';
406         $itemid = $this->get_itemid_for_file(self::LIBRARY_FILEAREA, $path, $file);
407         if ($this->fs->get_file($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $itemid, $path, $file)) {
408             return '/' . self::LIBRARY_FILEAREA . $path. $file;
409         } else {
410             return null;
411         }
412     }
414     /**
415      * Store the given stream into the given file.
416      *
417      * @param string $path
418      * @param string $file
419      * @param resource $stream
420      * @return bool|int
421      */
422     public function saveFileFromZip($path, $file, $stream) {
423         $fullpath = $path . '/' . $file;
424         check_dir_exists(pathinfo($fullpath, PATHINFO_DIRNAME));
425         return file_put_contents($fullpath, $stream);
426     }
428     /**
429      * Deletes a library from the file system.
430      *
431      * @param  array $library Library details
432      */
433     public function delete_library(array $library): void {
435         // A library ID of false would result in all library files being deleted, which we don't want. Return instead.
436         if ($library['libraryId'] === false) {
437             return;
438         }
440         $this->delete_directory($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
441     }
443     /**
444      * Remove an H5P directory from the filesystem.
445      *
446      * @param int $contextid context ID
447      * @param string $component component
448      * @param string $filearea file area or all areas in context if not specified
449      * @param int $itemid item ID or all files if not specified
450      */
451     private function delete_directory(int $contextid, string $component, string $filearea, int $itemid): void {
453         $this->fs->delete_area_files($contextid, $component, $filearea, $itemid);
454     }
456     /**
457      * Copy an H5P directory from the temporary directory into the file system.
458      *
459      * @param  string $source  Temporary location for files.
460      * @param  array  $options File system information.
461      */
462     private function copy_directory(string $source, array $options): void {
463         $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
464                 \RecursiveIteratorIterator::SELF_FIRST);
466         $root = $options['filepath'];
468         $it->rewind();
469         while ($it->valid()) {
470             $item = $it->current();
471             $subpath = $it->getSubPath();
472             if (!$item->isDir()) {
473                 $options['filename'] = $it->getFilename();
474                 if (!$subpath == '') {
475                     $options['filepath'] = $root . $subpath . '/';
476                 } else {
477                     $options['filepath'] = $root;
478                 }
480                 $this->fs->create_file_from_pathname($options, $item->getPathName());
481             }
482             $it->next();
483         }
484     }
486     /**
487      * Copies files from storage to temporary folder.
488      *
489      * @param string $target Path to temporary folder
490      * @param int $contextid context where the files are found
491      * @param string $filearea file area
492      * @param string $filepath file path
493      * @param int $itemid Optional item ID
494      */
495     private function export_file_tree(string $target, int $contextid, string $filearea, string $filepath, int $itemid = 0): void {
496         // Make sure target folder exists.
497         check_dir_exists($target);
499         // Read source files.
500         $files = $this->fs->get_directory_files($contextid, self::COMPONENT, $filearea, $itemid, $filepath, true);
502         foreach ($files as $file) {
503             $path = $target . str_replace($filepath, DIRECTORY_SEPARATOR, $file->get_filepath());
504             if ($file->is_directory()) {
505                 check_dir_exists(rtrim($path));
506             } else {
507                 $file->copy_content_to($path . $file->get_filename());
508             }
509         }
510     }
512     /**
513      * Adds all files of a type into one file.
514      *
515      * @param  array    $assets  A list of files.
516      * @param  string   $type    The type of files in assets. Either 'scripts' or 'styles'
517      * @param  \context $context Context
518      * @return string All of the file content in one string.
519      */
520     private function concatenate_files(array $assets, string $type, \context $context): string {
521         $content = '';
522         foreach ($assets as $asset) {
523             // Find location of asset.
524             list(
525                 'filearea' => $filearea,
526                 'filepath' => $filepath,
527                 'filename' => $filename,
528                 'itemid' => $itemid
529             ) = $this->get_file_elements_from_filepath($asset->path);
531             if ($itemid === false) {
532                 continue;
533             }
535             // Locate file.
536             $file = $this->fs->get_file($context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
538             // Get file content and concatenate.
539             if ($type === 'scripts') {
540                 $content .= $file->get_content() . ";\n";
541             } else {
542                 // Rewrite relative URLs used inside stylesheets.
543                 $content .= preg_replace_callback(
544                     '/url\([\'"]?([^"\')]+)[\'"]?\)/i',
545                     function ($matches) use ($filearea, $filepath, $itemid) {
546                         if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) {
547                             return $matches[0]; // Not relative, skip.
548                         }
549                         // Find "../" in matches[1].
550                         // If it exists, we have to remove "../".
551                         // And switch the last folder in the filepath for the first folder in $matches[1].
552                         // For instance:
553                         // $filepath: /H5P.Question-1.4/styles/
554                         // $matches[1]: ../images/plus-one.svg
555                         // We want to avoid this: H5P.Question-1.4/styles/ITEMID/../images/minus-one.svg
556                         // We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg.
557                         if (preg_match('/\.\.\//', $matches[1], $pathmatches)) {
558                             $path = preg_split('/\//', $filepath, -1, PREG_SPLIT_NO_EMPTY);
559                             $pathfilename = preg_split('/\//', $matches[1], -1, PREG_SPLIT_NO_EMPTY);
560                             // Remove the first element: ../.
561                             array_shift($pathfilename);
562                             // Replace pathfilename into the filepath.
563                             $path[count($path) - 1] = $pathfilename[0];
564                             $filepath = '/' . implode('/', $path) . '/';
565                             // Remove the element used to replace.
566                             array_shift($pathfilename);
567                             $matches[1] = implode('/', $pathfilename);
568                         }
569                         return 'url("../' . $filearea . $filepath . $itemid . '/' . $matches[1] . '")';
570                     },
571                     $file->get_content()) . "\n";
572             }
573         }
574         return $content;
575     }
577     /**
578      * Get files ready for export.
579      *
580      * @param  string $filename File name to retrieve.
581      * @return bool|\stored_file Stored file instance if exists, false if not
582      */
583     private function get_export_file(string $filename) {
584         return $this->fs->get_file($this->context->id, self::COMPONENT, self::EXPORT_FILEAREA, 0, '/', $filename);
585     }
587     /**
588      * Converts a relative system file path into Moodle File API elements.
589      *
590      * @param  string $filepath The system filepath to get information from.
591      * @return array File information.
592      */
593     private function get_file_elements_from_filepath(string $filepath): array {
594         $sections = explode('/', $filepath);
595         // Get the filename.
596         $filename = array_pop($sections);
597         // Discard first element.
598         if (empty($sections[0])) {
599             array_shift($sections);
600         }
601         // Get the filearea.
602         $filearea = array_shift($sections);
603         // Get the filepath.
604         $filepath = implode('/', $sections);
605         $filepath = '/' . $filepath . '/';
607         return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename];
608     }
610     /**
611      * Returns the item id given the other necessary variables.
612      *
613      * @param  string $filearea The file area.
614      * @param  string $filepath The file path.
615      * @param  string $filename The file name.
616      * @return mixed the specified value false if not found.
617      */
618     private function get_itemid_for_file(string $filearea, string $filepath, string $filename) {
619         global $DB;
620         return $DB->get_field('files', 'itemid', ['component' => self::COMPONENT, 'filearea' => $filearea, 'filepath' => $filepath,
621                 'filename' => $filename]);
622     }