MDL-66609 core_h5p: Implement the file_storage interface.
authorAdrian Greeve <abgreeve@gmail.com>
Sun, 1 Sep 2019 12:50:53 +0000 (13:50 +0100)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 29 Oct 2019 02:22:43 +0000 (10:22 +0800)
h5p/classes/file_storage.php [new file with mode: 0644]
h5p/tests/file_storage_test.php [new file with mode: 0644]

diff --git a/h5p/classes/file_storage.php b/h5p/classes/file_storage.php
new file mode 100644 (file)
index 0000000..b26c520
--- /dev/null
@@ -0,0 +1,623 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class \core_h5p\file_storage.
+ *
+ * @package    core_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>, base on code by Joubel AS
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class to handle storage and export of H5P Content.
+ *
+ * @package    core_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class file_storage implements \H5PFileStorage {
+
+    /** The component for H5P. */
+    public const COMPONENT   = 'core_h5p';
+    /** The library file area. */
+    public const LIBRARY_FILEAREA = 'libraries';
+    /** The content file area */
+    public const CONTENT_FILEAREA = 'content';
+    /** The cached assest file area. */
+    public const CACHED_ASSETS_FILEAREA = 'cachedassets';
+    /** The export file area */
+    public const EXPORT_FILEAREA = 'export';
+
+    /**
+     * @var \context $context Currently we use the system context everywhere.
+     * Don't feel forced to keep it this way in the future.
+     */
+    protected $context;
+
+    /** @var \file_storage $fs File storage. */
+    protected $fs;
+
+    /**
+     * Initial setup for file_storage.
+     */
+    public function __construct() {
+        // Currently everything uses the system context.
+        $this->context = \context_system::instance();
+        $this->fs = get_file_storage();
+    }
+
+    /**
+     * Stores a H5P library in the Moodle filesystem.
+     *
+     * @param array $library Library properties.
+     */
+    public function saveLibrary($library) {
+        $options = [
+            'contextid' => $this->context->id,
+            'component' => self::COMPONENT,
+            'filearea' => self::LIBRARY_FILEAREA,
+            'filepath' => '/' . \H5PCore::libraryToString($library, true) . '/',
+            'itemid' => $library['libraryId']
+        ];
+
+        // Easiest approach: delete the existing library version and copy the new one.
+        $this->delete_library($library);
+        $this->copy_directory($library['uploadDirectory'], $options);
+    }
+
+    /**
+     * Store the content folder.
+     *
+     * @param string $source Path on file system to content directory.
+     * @param array $content Content properties
+     */
+    public function saveContent($source, $content) {
+        $options = [
+                'contextid' => $this->context->id,
+                'component' => self::COMPONENT,
+                'filearea' => self::CONTENT_FILEAREA,
+                'itemid' => $content['id'],
+                'filepath' => '/',
+        ];
+
+        $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
+        // Copy content directory into Moodle filesystem.
+        $this->copy_directory($source, $options);
+    }
+
+    /**
+     * Remove content folder.
+     *
+     * @param array $content Content properties
+     */
+    public function deleteContent($content) {
+
+        $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
+    }
+
+    /**
+     * Creates a stored copy of the content folder.
+     *
+     * @param string $id Identifier of content to clone.
+     * @param int $newid The cloned content's identifier
+     */
+    public function cloneContent($id, $newid) {
+        // Not implemented in Moodle.
+    }
+
+    /**
+     * Get path to a new unique tmp folder.
+     * Please note this needs to not be a directory.
+     *
+     * @return string Path
+     */
+    public function getTmpPath(): string {
+        return make_request_directory() . '/' . uniqid('h5p-');
+    }
+
+    /**
+     * Fetch content folder and save in target directory.
+     *
+     * @param int $id Content identifier
+     * @param string $target Where the content folder will be saved
+     */
+    public function exportContent($id, $target) {
+        $this->export_file_tree($target, $this->context->id, self::CONTENT_FILEAREA, '/', $id);
+    }
+
+    /**
+     * Fetch library folder and save in target directory.
+     *
+     * @param array $library Library properties
+     * @param string $target Where the library folder will be saved
+     */
+    public function exportLibrary($library, $target) {
+        $folder = \H5PCore::libraryToString($library, true);
+        $this->export_file_tree($target . '/' . $folder, $this->context->id, self::LIBRARY_FILEAREA,
+                '/' . $folder . '/', $library['libraryId']);
+    }
+
+    /**
+     * Save export in file system
+     *
+     * @param string $source Path on file system to temporary export file.
+     * @param string $filename Name of export file.
+     */
+    public function saveExport($source, $filename) {
+        $filerecord = [
+            'contextid' => $this->context->id,
+            'component' => self::COMPONENT,
+            'filearea' => self::EXPORT_FILEAREA,
+            'itemid' => 0,
+            'filepath' => '/',
+            'filename' => $filename
+        ];
+        $this->fs->create_file_from_pathname($filerecord, $source);
+    }
+
+    /**
+     * Removes given export file
+     *
+     * @param string $filename filename of the export to delete.
+     */
+    public function deleteExport($filename) {
+        $file = $this->get_export_file($filename);
+        if ($file) {
+            $file->delete();
+        }
+    }
+
+    /**
+     * Check if the given export file exists
+     *
+     * @param string $filename The export file to check.
+     * @return boolean True if the export file exists.
+     */
+    public function hasExport($filename) {
+        return !!$this->get_export_file($filename);
+    }
+
+    /**
+     * Will concatenate all JavaScrips and Stylesheets into two files in order
+     * to improve page performance.
+     *
+     * @param array $files A set of all the assets required for content to display
+     * @param string $key Hashed key for cached asset
+     */
+    public function cacheAssets(&$files, $key) {
+
+        foreach ($files as $type => $assets) {
+            if (empty($assets)) {
+                continue;
+            }
+
+            // Create new file for cached assets.
+            $ext = ($type === 'scripts' ? 'js' : 'css');
+            $filename = $key . '.' . $ext;
+            $fileinfo = [
+                'contextid' => $this->context->id,
+                'component' => self::COMPONENT,
+                'filearea' => self::CACHED_ASSETS_FILEAREA,
+                'itemid' => 0,
+                'filepath' => '/',
+                'filename' => $filename
+            ];
+
+            // Store concatenated content.
+            $this->fs->create_file_from_string($fileinfo, $this->concatenate_files($assets, $type, $this->context));
+            $files[$type] = [
+                (object) [
+                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . $filename,
+                    'version' => ''
+                ]
+            ];
+        }
+    }
+
+    /**
+     * Will check if there are cache assets available for content.
+     *
+     * @param string $key Hashed key for cached asset
+     * @return array
+     */
+    public function getCachedAssets($key) {
+        $files = [];
+
+        $js = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.js");
+        if ($js && $js->get_filesize() > 0) {
+            $files['scripts'] = [
+                (object) [
+                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.js",
+                    'version' => ''
+                ]
+            ];
+        }
+
+        $css = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.css");
+        if ($css && $css->get_filesize() > 0) {
+            $files['styles'] = [
+                (object) [
+                    'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.css",
+                    'version' => ''
+                ]
+            ];
+        }
+
+        return empty($files) ? null : $files;
+    }
+
+    /**
+     * Remove the aggregated cache files.
+     *
+     * @param array $keys The hash keys of removed files
+     */
+    public function deleteCachedAssets($keys) {
+
+        if (empty($keys)) {
+            return;
+        }
+
+        foreach ($keys as $hash) {
+            foreach (['js', 'css'] as $type) {
+                $cachedasset = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/',
+                        "{$hash}.{$type}");
+                if ($cachedasset) {
+                    $cachedasset->delete();
+                }
+            }
+        }
+    }
+
+    /**
+     * Read file content of given file and then return it.
+     *
+     * @param string $filepath
+     * @return string contents
+     */
+    public function getContent($filepath) {
+        list(
+            'filearea' => $filearea,
+            'filepath' => $filepath,
+            'filename' => $filename,
+            'itemid' => $itemid
+        ) = $this->get_file_elements_from_filepath($filepath);
+
+        if (!$itemid) {
+            throw new \file_serving_exception('Could not retrieve the requested file, check your file permissions.');
+        }
+
+        // Locate file.
+        $file = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
+
+        // Return content.
+        return $file->get_content();
+    }
+
+    /**
+     * Save files uploaded through the editor.
+     * The files must be marked as temporary until the content form is saved.
+     *
+     * @param \H5peditorFile $file
+     * @param int $contentid
+     */
+    public function saveFile($file, $contentid) {
+        // This is to be implemented when the h5p editor is introduced / created.
+    }
+
+    /**
+     * Copy a file from another content or editor tmp dir.
+     * Used when copy pasting content in H5P.
+     *
+     * @param string $file path + name
+     * @param string|int $fromid Content ID or 'editor' string
+     * @param int $toid Target Content ID
+     */
+    public function cloneContentFile($file, $fromid, $toid) {
+        // This is to be implemented when the h5p editor is introduced / created.
+    }
+
+    /**
+     * Copy content from one directory to another. Defaults to cloning
+     * content from the current temporary upload folder to the editor path.
+     *
+     * @param string $source path to source directory
+     * @param string $contentid Id of content
+     *
+     * @return object Object containing h5p json and content json data
+     */
+    public function moveContentDirectory($source, $contentid = null) {
+        // This is to be implemented when the h5p editor is introduced / created.
+    }
+
+    /**
+     * Checks to see if content has the given file.
+     * Used when saving content.
+     *
+     * @param string $file path + name
+     * @param int $contentid
+     * @return string|int File ID or NULL if not found
+     */
+    public function getContentFile($file, $contentid) {
+        // This is to be implemented when the h5p editor is introduced / created.
+    }
+
+    /**
+     * Remove content files that are no longer used.
+     * Used when saving content.
+     *
+     * @param string $file path + name
+     * @param int $contentid
+     */
+    public function removeContentFile($file, $contentid) {
+        // This is to be implemented when the h5p editor is introduced / created.
+    }
+
+    /**
+     * Check if server setup has write permission to
+     * the required folders
+     *
+     * @return bool True if server has the proper write access
+     */
+    public function hasWriteAccess() {
+        // Moodle has access to the files table which is where all of the folders are stored.
+        return true;
+    }
+
+    /**
+     * Check if the library has a presave.js in the root folder
+     *
+     * @param string $libraryname
+     * @param string $developmentpath
+     * @return bool
+     */
+    public function hasPresave($libraryname, $developmentpath = null) {
+        return false;
+    }
+
+    /**
+     * Check if upgrades script exist for library.
+     *
+     * @param string $machinename
+     * @param int $majorversion
+     * @param int $minorversion
+     * @return string Relative path
+     */
+    public function getUpgradeScript($machinename, $majorversion, $minorversion) {
+        $path = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
+        $file = 'upgrade.js';
+        $itemid = $this->get_itemid_for_file(self::LIBRARY_FILEAREA, $path, $file);
+        if ($this->fs->get_file($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $itemid, $path, $file)) {
+            return '/' . self::LIBRARY_FILEAREA . $path. $file;
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Store the given stream into the given file.
+     *
+     * @param string $path
+     * @param string $file
+     * @param resource $stream
+     * @return bool|int
+     */
+    public function saveFileFromZip($path, $file, $stream) {
+        $fullpath = $path . '/' . $file;
+        check_dir_exists(pathinfo($fullpath, PATHINFO_DIRNAME));
+        return file_put_contents($fullpath, $stream);
+    }
+
+    /**
+     * Deletes a library from the file system.
+     *
+     * @param  array $library Library details
+     */
+    public function delete_library(array $library): void {
+
+        // A library ID of false would result in all library files being deleted, which we don't want. Return instead.
+        if ($library['libraryId'] === false) {
+            return;
+        }
+
+        $this->delete_directory($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
+    }
+
+    /**
+     * Remove an H5P directory from the filesystem.
+     *
+     * @param int $contextid context ID
+     * @param string $component component
+     * @param string $filearea file area or all areas in context if not specified
+     * @param int $itemid item ID or all files if not specified
+     */
+    private function delete_directory(int $contextid, string $component, string $filearea, int $itemid): void {
+
+        $this->fs->delete_area_files($contextid, $component, $filearea, $itemid);
+    }
+
+    /**
+     * Copy an H5P directory from the temporary directory into the file system.
+     *
+     * @param  string $source  Temporary location for files.
+     * @param  array  $options File system information.
+     */
+    private function copy_directory(string $source, array $options): void {
+        $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
+                \RecursiveIteratorIterator::SELF_FIRST);
+
+        $root = $options['filepath'];
+
+        $it->rewind();
+        while ($it->valid()) {
+            $item = $it->current();
+            $subpath = $it->getSubPath();
+            if (!$item->isDir()) {
+                $options['filename'] = $it->getFilename();
+                if (!$subpath == '') {
+                    $options['filepath'] = $root . $subpath . '/';
+                } else {
+                    $options['filepath'] = $root;
+                }
+
+                $this->fs->create_file_from_pathname($options, $item->getPathName());
+            }
+            $it->next();
+        }
+    }
+
+    /**
+     * Copies files from storage to temporary folder.
+     *
+     * @param string $target Path to temporary folder
+     * @param int $contextid context where the files are found
+     * @param string $filearea file area
+     * @param string $filepath file path
+     * @param int $itemid Optional item ID
+     */
+    private function export_file_tree(string $target, int $contextid, string $filearea, string $filepath, int $itemid = 0): void {
+        // Make sure target folder exists.
+        check_dir_exists($target);
+
+        // Read source files.
+        $files = $this->fs->get_directory_files($contextid, self::COMPONENT, $filearea, $itemid, $filepath, true);
+
+        foreach ($files as $file) {
+            $path = $target . str_replace($filepath, DIRECTORY_SEPARATOR, $file->get_filepath());
+            if ($file->is_directory()) {
+                check_dir_exists(rtrim($path));
+            } else {
+                $file->copy_content_to($path . $file->get_filename());
+            }
+        }
+    }
+
+    /**
+     * Adds all files of a type into one file.
+     *
+     * @param  array    $assets  A list of files.
+     * @param  string   $type    The type of files in assets. Either 'scripts' or 'styles'
+     * @param  \context $context Context
+     * @return string All of the file content in one string.
+     */
+    private function concatenate_files(array $assets, string $type, \context $context): string {
+        $content = '';
+        foreach ($assets as $asset) {
+            // Find location of asset.
+            list(
+                'filearea' => $filearea,
+                'filepath' => $filepath,
+                'filename' => $filename,
+                'itemid' => $itemid
+            ) = $this->get_file_elements_from_filepath($asset->path);
+
+            if ($itemid === false) {
+                continue;
+            }
+
+            // Locate file.
+            $file = $this->fs->get_file($context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
+
+            // Get file content and concatenate.
+            if ($type === 'scripts') {
+                $content .= $file->get_content() . ";\n";
+            } else {
+                // Rewrite relative URLs used inside stylesheets.
+                $content .= preg_replace_callback(
+                    '/url\([\'"]?([^"\')]+)[\'"]?\)/i',
+                    function ($matches) use ($filearea, $filepath, $itemid) {
+                        if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) {
+                            return $matches[0]; // Not relative, skip.
+                        }
+                        // Find "../" in matches[1].
+                        // If it exists, we have to remove "../".
+                        // And switch the last folder in the filepath for the first folder in $matches[1].
+                        // For instance:
+                        // $filepath: /H5P.Question-1.4/styles/
+                        // $matches[1]: ../images/plus-one.svg
+                        // We want to avoid this: H5P.Question-1.4/styles/ITEMID/../images/minus-one.svg
+                        // We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg.
+                        if (preg_match('/\.\.\//', $matches[1], $pathmatches)) {
+                            $path = preg_split('/\//', $filepath, -1, PREG_SPLIT_NO_EMPTY);
+                            $pathfilename = preg_split('/\//', $matches[1], -1, PREG_SPLIT_NO_EMPTY);
+                            // Remove the first element: ../.
+                            array_shift($pathfilename);
+                            // Replace pathfilename into the filepath.
+                            $path[count($path) - 1] = $pathfilename[0];
+                            $filepath = '/' . implode('/', $path) . '/';
+                            // Remove the element used to replace.
+                            array_shift($pathfilename);
+                            $matches[1] = implode('/', $pathfilename);
+                        }
+                        return 'url("../' . $filearea . $filepath . $itemid . '/' . $matches[1] . '")';
+                    },
+                    $file->get_content()) . "\n";
+            }
+        }
+        return $content;
+    }
+
+    /**
+     * Get files ready for export.
+     *
+     * @param  string $filename File name to retrieve.
+     * @return bool|\stored_file Stored file instance if exists, false if not
+     */
+    private function get_export_file(string $filename) {
+        return $this->fs->get_file($this->context->id, self::COMPONENT, self::EXPORT_FILEAREA, 0, '/', $filename);
+    }
+
+    /**
+     * Converts a relative system file path into Moodle File API elements.
+     *
+     * @param  string $filepath The system filepath to get information from.
+     * @return array File information.
+     */
+    private function get_file_elements_from_filepath(string $filepath): array {
+        $sections = explode('/', $filepath);
+        // Get the filename.
+        $filename = array_pop($sections);
+        // Discard first element.
+        if (empty($sections[0])) {
+            array_shift($sections);
+        }
+        // Get the filearea.
+        $filearea = array_shift($sections);
+        // Get the filepath.
+        $filepath = implode('/', $sections);
+        $filepath = '/' . $filepath . '/';
+
+        return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename];
+    }
+
+    /**
+     * Returns the item id given the other necessary variables.
+     *
+     * @param  string $filearea The file area.
+     * @param  string $filepath The file path.
+     * @param  string $filename The file name.
+     * @return mixed the specified value false if not found.
+     */
+    private function get_itemid_for_file(string $filearea, string $filepath, string $filename) {
+        global $DB;
+        return $DB->get_field('files', 'itemid', ['component' => self::COMPONENT, 'filearea' => $filearea, 'filepath' => $filepath,
+                'filename' => $filename]);
+    }
+}
\ No newline at end of file
diff --git a/h5p/tests/file_storage_test.php b/h5p/tests/file_storage_test.php
new file mode 100644 (file)
index 0000000..6a87e27
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Testing the H5P H5PFileStorage interface implementation.
+ *
+ * @package    core_h5p
+ * @category   test
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+use \core_h5p\file_storage;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Test class covering the H5PFileStorage interface implementation.
+ *
+ * @package    core_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class file_storage_testcase extends advanced_testcase {
+    public function test_saveLibrary() {
+        global $CFG;
+
+        $this->resetAfterTest(true);
+
+        $path = $CFG->tempdir . uniqid('/hvp-');
+        $path .= '.h5p';
+    }
+}
\ No newline at end of file