AutorÃa | Ultima modificación | Ver Log |
<?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;use stored_file;use Moodle\H5PCore;use Moodle\H5peditorFile;use Moodle\H5PFileStorage;// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod/*** 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';/** The export css file area */public const CSS_FILEAREA = 'css';/** The icon filename */public const ICON_FILENAME = 'icon.svg';/** The custom CSS filename */private const CUSTOM_CSS_FILENAME = 'custom_h5p.css';/*** @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::libraryToFolderName($library) . '/','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);}/*** Delete library folder.** @param array $library*/public function deleteLibrary($library) {// Although this class had a method (delete_library()) for removing libraries before this was added to the interface,// 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// expectation. This method will be implemented once more information will be added to the H5P technical doc.}/*** 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::libraryToFolderName($library);$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) {global $USER;// Remove old export.$this->deleteExport($filename);$filerecord = ['contextid' => $this->context->id,'component' => self::COMPONENT,'filearea' => self::EXPORT_FILEAREA,'itemid' => 0,'filepath' => '/','filename' => $filename,'userid' => $USER->id];$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.** @param H5peditorFile $file* @param int $contentid** @return int The id of the saved file.*/public function saveFile($file, $contentid) {global $USER;$context = $this->context->id;$component = self::COMPONENT;$filearea = self::CONTENT_FILEAREA;if ($contentid === 0) {$usercontext = \context_user::instance($USER->id);$context = $usercontext->id;$component = 'user';$filearea = 'draft';}$record = array('contextid' => $context,'component' => $component,'filearea' => $filearea,'itemid' => $contentid,'filepath' => '/' . $file->getType() . 's/','filename' => $file->getName());$storedfile = $this->fs->create_file_from_pathname($record, $_FILES['file']['tmp_name']);return $storedfile->get_id();}/*** 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 \stdClass $tocontent Target Content** @return void*/public function cloneContentFile($file, $fromid, $tocontent): void {// Determine source filearea and itemid.if ($fromid === 'editor') {$sourcefilearea = 'draft';$sourceitemid = 0;} else {$sourcefilearea = self::CONTENT_FILEAREA;$sourceitemid = (int)$fromid;}$filepath = '/' . dirname($file) . '/';$filename = basename($file);// Check to see if source exists.$sourcefile = $this->get_file($sourcefilearea, $sourceitemid, $file);if ($sourcefile === null) {return; // Nothing to copy from.}// Check to make sure that file doesn't exist already in target.$targetfile = $this->get_file(self::CONTENT_FILEAREA, $tocontent->id, $file);if ( $targetfile !== null) {return; // File exists, no need to copy.}// Create new file record.$record = ['contextid' => $this->context->id,'component' => self::COMPONENT,'filearea' => self::CONTENT_FILEAREA,'itemid' => $tocontent->id,'filepath' => $filepath,'filename' => $filename,];$this->fs->create_file_from_storedfile($record, $sourcefile);}/*** 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**/public function moveContentDirectory($source, $contentid = null) {$contentidint = (int)$contentid;if ($source === null) {return;}// Get H5P and content json.$contentsource = $source . '/content';// Move all temporary content files to editor.$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($contentsource, \RecursiveDirectoryIterator::SKIP_DOTS),\RecursiveIteratorIterator::SELF_FIRST);$it->rewind();while ($it->valid()) {$item = $it->current();$pathname = $it->getPathname();if (!$item->isDir() && !($item->getFilename() === 'content.json')) {$this->move_file($pathname, $contentidint);}$it->next();}}/*** Get the file URL or given library and then return it.** @param int $itemid* @param string $machinename* @param int $majorversion* @param int $minorversion* @return string url or false if the file doesn't exist*/public function get_icon_url(int $itemid, string $machinename, int $majorversion, int $minorversion) {$filepath = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';if ($file = $this->fs->get_file($this->context->id,self::COMPONENT,self::LIBRARY_FILEAREA,$itemid,$filepath,self::ICON_FILENAME)) {$iconurl = \moodle_url::make_pluginfile_url($this->context->id,self::COMPONENT,self::LIBRARY_FILEAREA,$itemid,$filepath,$file->get_filename());// Return image URL.return $iconurl->out();}return false;}/*** Checks to see if an H5P content has the given file.** @param string $file File path and name.* @param int $content Content id.** @return int|null File ID or NULL if not found*/public function getContentFile($file, $content): ?int {if (is_object($content)) {$content = $content->id;}$contentfile = $this->get_file(self::CONTENT_FILEAREA, $content, $file);return ($contentfile === null ? null : $contentfile->get_id());}/*** Remove content files that are no longer used.** Used when saving content.** @param string $file File path and name.* @param int $contentid Content id.** @return void*/public function removeContentFile($file, $contentid): void {// Although the interface defines $contentid as int, object given in H5peditor::processParameters.if (is_object($contentid)) {$contentid = $contentid->id;}$existingfile = $this->get_file(self::CONTENT_FILEAREA, $contentid, $file);if ($existingfile !== null) {$existingfile->delete();}}/*** 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 {global $DB;// A library ID of false would result in all library files being deleted, which we don't want. Return instead.if (empty($library['libraryId'])) {return;}$areafiles = $this->fs->get_area_files($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);$this->delete_directory($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);$librarycache = \cache::make('core', 'h5p_library_files');foreach ($areafiles as $file) {if (!$DB->record_exists('files', array('contenthash' => $file->get_contenthash(),'component' => self::COMPONENT,'filearea' => self::LIBRARY_FILEAREA))) {$librarycache->delete($file->get_contenthash());}}}/*** 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 {$librarycache = \cache::make('core', 'h5p_library_files');$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;}$file = $this->fs->create_file_from_pathname($options, $item->getPathName());if ($options['filearea'] == self::LIBRARY_FILEAREA) {if (!$librarycache->has($file->get_contenthash())) {$librarycache->set($file->get_contenthash(), file_get_contents($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);$librarycache = \cache::make('core', 'h5p_library_files');foreach ($files as $file) {$path = $target . str_replace($filepath, DIRECTORY_SEPARATOR, $file->get_filepath());if ($file->is_directory()) {check_dir_exists(rtrim($path));} else {if ($filearea == self::LIBRARY_FILEAREA) {$cachedfile = $librarycache->get($file->get_contenthash());if (empty($cachedfile)) {$file->copy_content_to($path . $file->get_filename());$librarycache->set($file->get_contenthash(), file_get_contents($path . $file->get_filename()));} else {file_put_contents($path . $file->get_filename(), $cachedfile);}} 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*/public 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);$itemid = array_shift($sections);// Get the filepath.$filepath = implode('/', $sections);$filepath = '/' . $filepath . '/';return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename, 'itemid' => $itemid];}/*** 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]);}/*** Helper to make it easy to load content files.** @param string $filearea File area where the file is saved.* @param int $itemid Content instance or content id.* @param string $file File path and name.** @return stored_file|null*/private function get_file(string $filearea, int $itemid, string $file): ?stored_file {global $USER;$component = self::COMPONENT;$context = $this->context->id;if ($filearea === 'draft') {$itemid = 0;$component = 'user';$usercontext = \context_user::instance($USER->id);$context = $usercontext->id;}$filepath = '/'. dirname($file). '/';$filename = basename($file);// Load file.$existingfile = $this->fs->get_file($context, $component, $filearea, $itemid, $filepath, $filename);if (!$existingfile) {return null;}return $existingfile;}/*** Move a single file** @param string $sourcefile Path to source file* @param int $contentid Content id or 0 if the file is in the editor file area** @return void*/private function move_file(string $sourcefile, int $contentid): void {$pathparts = pathinfo($sourcefile);$filename = $pathparts['basename'];$filepath = $pathparts['dirname'];$foldername = basename($filepath);// Create file record for content.$record = array('contextid' => $this->context->id,'component' => $contentid > 0 ? self::COMPONENT : 'user','filearea' => $contentid > 0 ? self::CONTENT_FILEAREA : 'draft','itemid' => $contentid > 0 ? $contentid : 0,'filepath' => '/' . $foldername . '/','filename' => $filename);$file = $this->fs->get_file($record['contextid'], $record['component'],$record['filearea'], $record['itemid'], $record['filepath'],$record['filename']);if ($file) {// Delete it to make sure that it is replaced with correct content.$file->delete();}$this->fs->create_file_from_pathname($record, $sourcefile);}/*** Generate H5P custom styles if any.*/public static function generate_custom_styles(): void {$record = self::get_custom_styles_file_record();$cssfile = self::get_custom_styles_file($record);if ($cssfile) {// The CSS file needs to be updated, so delete and recreate it// if there is CSS in the 'h5pcustomcss' setting.$cssfile->delete();}$css = get_config('core_h5p', 'h5pcustomcss');if (!empty($css)) {$fs = get_file_storage();$fs->create_file_from_string($record, $css);}}/*** Get H5P custom styles if any.** @throws \moodle_exception If the CSS setting is empty but there is a file to serve* or there is no file but the CSS setting is not empty.* @return array|null If there is CSS then an array with the keys 'cssurl'* and 'cssversion' is returned otherwise null. 'cssurl' is a link to the* generated 'custom_h5p.css' file and 'cssversion' the md5 hash of its contents.*/public static function get_custom_styles(): ?array {$record = self::get_custom_styles_file_record();$css = get_config('core_h5p', 'h5pcustomcss');if (self::get_custom_styles_file($record)) {if (empty($css)) {// The custom CSS file exists and yet the setting 'h5pcustomcss' is empty.// This prevents an invalid content hash.throw new \moodle_exception('The H5P \'h5pcustomcss\' setting is empty and yet the custom CSS file \''.$record['filename'].'\' exists.','core_h5p');}// File exists, so generate the url and version hash.$cssurl = \moodle_url::make_pluginfile_url($record['contextid'],$record['component'],$record['filearea'],null,$record['filepath'],$record['filename']);return ['cssurl' => $cssurl, 'cssversion' => md5($css)];} else if (!empty($css)) {// The custom CSS file does not exist and yet should do.throw new \moodle_exception('The H5P custom CSS file \''.$record['filename'].'\' does not exist and yet there is CSS in the \'h5pcustomcss\' setting.','core_h5p');}return null;}/*** Get H5P custom styles file record.** @return array File record for the CSS custom styles.*/private static function get_custom_styles_file_record(): array {return ['contextid' => \context_system::instance()->id,'component' => self::COMPONENT,'filearea' => self::CSS_FILEAREA,'itemid' => 0,'filepath' => '/','filename' => self::CUSTOM_CSS_FILENAME,];}/*** Get H5P custom styles file.** @param array $record The H5P custom styles file record.** @return stored_file|bool stored_file instance if exists, false if not.*/private static function get_custom_styles_file($record): stored_file|bool {$fs = get_file_storage();return $fs->get_file($record['contextid'],$record['component'],$record['filearea'],$record['itemid'],$record['filepath'],$record['filename']);}}