Files
moodle/h5p/classes/file_storage.php
Sara Arjona 91175a6773 MDL-77049 core_h5p: Use the styles from Raw SCSS theme settings
MDL-69087 added an option to let themes personalize the H5P styles.
This patch implements the h5p_alter_styles() method in boost, to
let admins personalize the H5P styles using the 'Raw initial SCSS'
and 'Raw SCSS' theme settings.
That way, users won't need to create their own themes to define
some styles for the H5P player.
2023-03-22 12:10:57 +01:00

890 lines
30 KiB
PHP

<?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 editor file area.
* @deprecated since Moodle 3.10 MDL-68909. Please do not use this constant any more.
* @todo MDL-69530 This will be deleted in Moodle 4.2.
*/
public const EDITOR_FILEAREA = 'editor';
/**
* @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);
}
}