Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 and 403]

   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/>.
  16  
  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   */
  24  
  25  namespace core_h5p;
  26  
  27  use stored_file;
  28  use Moodle\H5PCore;
  29  use Moodle\H5peditorFile;
  30  use Moodle\H5PFileStorage;
  31  
  32  // phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod
  33  
  34  /**
  35   * Class to handle storage and export of H5P Content.
  36   *
  37   * @package    core_h5p
  38   * @copyright  2019 Victor Deniz <victor@moodle.com>
  39   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class file_storage implements H5PFileStorage {
  42  
  43      /** The component for H5P. */
  44      public const COMPONENT   = 'core_h5p';
  45      /** The library file area. */
  46      public const LIBRARY_FILEAREA = 'libraries';
  47      /** The content file area */
  48      public const CONTENT_FILEAREA = 'content';
  49      /** The cached assest file area. */
  50      public const CACHED_ASSETS_FILEAREA = 'cachedassets';
  51      /** The export file area */
  52      public const EXPORT_FILEAREA = 'export';
  53      /** The export css file area */
  54      public const CSS_FILEAREA = 'css';
  55      /** The icon filename */
  56      public const ICON_FILENAME = 'icon.svg';
  57  
  58      /**
  59       * @var \context $context Currently we use the system context everywhere.
  60       * Don't feel forced to keep it this way in the future.
  61       */
  62      protected $context;
  63  
  64      /** @var \file_storage $fs File storage. */
  65      protected $fs;
  66  
  67      /**
  68       * Initial setup for file_storage.
  69       */
  70      public function __construct() {
  71          // Currently everything uses the system context.
  72          $this->context = \context_system::instance();
  73          $this->fs = get_file_storage();
  74      }
  75  
  76      /**
  77       * Stores a H5P library in the Moodle filesystem.
  78       *
  79       * @param array $library Library properties.
  80       */
  81      public function saveLibrary($library) {
  82          $options = [
  83              'contextid' => $this->context->id,
  84              'component' => self::COMPONENT,
  85              'filearea' => self::LIBRARY_FILEAREA,
  86              'filepath' => '/' . H5PCore::libraryToFolderName($library) . '/',
  87              'itemid' => $library['libraryId'],
  88          ];
  89  
  90          // Easiest approach: delete the existing library version and copy the new one.
  91          $this->delete_library($library);
  92          $this->copy_directory($library['uploadDirectory'], $options);
  93      }
  94  
  95      /**
  96       * Delete library folder.
  97       *
  98       * @param array $library
  99       */
 100      public function deleteLibrary($library) {
 101          // Although this class had a method (delete_library()) for removing libraries before this was added to the interface,
 102          // 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
 103          // expectation. This method will be implemented once more information will be added to the H5P technical doc.
 104      }
 105  
 106  
 107      /**
 108       * Store the content folder.
 109       *
 110       * @param string $source Path on file system to content directory.
 111       * @param array $content Content properties
 112       */
 113      public function saveContent($source, $content) {
 114          $options = [
 115                  'contextid' => $this->context->id,
 116                  'component' => self::COMPONENT,
 117                  'filearea' => self::CONTENT_FILEAREA,
 118                  'itemid' => $content['id'],
 119                  'filepath' => '/',
 120          ];
 121  
 122          $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
 123          // Copy content directory into Moodle filesystem.
 124          $this->copy_directory($source, $options);
 125      }
 126  
 127      /**
 128       * Remove content folder.
 129       *
 130       * @param array $content Content properties
 131       */
 132      public function deleteContent($content) {
 133  
 134          $this->delete_directory($this->context->id, self::COMPONENT, self::CONTENT_FILEAREA, $content['id']);
 135      }
 136  
 137      /**
 138       * Creates a stored copy of the content folder.
 139       *
 140       * @param string $id Identifier of content to clone.
 141       * @param int $newid The cloned content's identifier
 142       */
 143      public function cloneContent($id, $newid) {
 144          // Not implemented in Moodle.
 145      }
 146  
 147      /**
 148       * Get path to a new unique tmp folder.
 149       * Please note this needs to not be a directory.
 150       *
 151       * @return string Path
 152       */
 153      public function getTmpPath(): string {
 154          return make_request_directory() . '/' . uniqid('h5p-');
 155      }
 156  
 157      /**
 158       * Fetch content folder and save in target directory.
 159       *
 160       * @param int $id Content identifier
 161       * @param string $target Where the content folder will be saved
 162       */
 163      public function exportContent($id, $target) {
 164          $this->export_file_tree($target, $this->context->id, self::CONTENT_FILEAREA, '/', $id);
 165      }
 166  
 167      /**
 168       * Fetch library folder and save in target directory.
 169       *
 170       * @param array $library Library properties
 171       * @param string $target Where the library folder will be saved
 172       */
 173      public function exportLibrary($library, $target) {
 174          $folder = H5PCore::libraryToFolderName($library);
 175          $this->export_file_tree($target . '/' . $folder, $this->context->id, self::LIBRARY_FILEAREA,
 176                  '/' . $folder . '/', $library['libraryId']);
 177      }
 178  
 179      /**
 180       * Save export in file system
 181       *
 182       * @param string $source Path on file system to temporary export file.
 183       * @param string $filename Name of export file.
 184       */
 185      public function saveExport($source, $filename) {
 186          global $USER;
 187  
 188          // Remove old export.
 189          $this->deleteExport($filename);
 190  
 191          $filerecord = [
 192              'contextid' => $this->context->id,
 193              'component' => self::COMPONENT,
 194              'filearea' => self::EXPORT_FILEAREA,
 195              'itemid' => 0,
 196              'filepath' => '/',
 197              'filename' => $filename,
 198              'userid' => $USER->id
 199          ];
 200          $this->fs->create_file_from_pathname($filerecord, $source);
 201      }
 202  
 203      /**
 204       * Removes given export file
 205       *
 206       * @param string $filename filename of the export to delete.
 207       */
 208      public function deleteExport($filename) {
 209          $file = $this->get_export_file($filename);
 210          if ($file) {
 211              $file->delete();
 212          }
 213      }
 214  
 215      /**
 216       * Check if the given export file exists
 217       *
 218       * @param string $filename The export file to check.
 219       * @return boolean True if the export file exists.
 220       */
 221      public function hasExport($filename) {
 222          return !!$this->get_export_file($filename);
 223      }
 224  
 225      /**
 226       * Will concatenate all JavaScrips and Stylesheets into two files in order
 227       * to improve page performance.
 228       *
 229       * @param array $files A set of all the assets required for content to display
 230       * @param string $key Hashed key for cached asset
 231       */
 232      public function cacheAssets(&$files, $key) {
 233  
 234          foreach ($files as $type => $assets) {
 235              if (empty($assets)) {
 236                  continue;
 237              }
 238  
 239              // Create new file for cached assets.
 240              $ext = ($type === 'scripts' ? 'js' : 'css');
 241              $filename = $key . '.' . $ext;
 242              $fileinfo = [
 243                  'contextid' => $this->context->id,
 244                  'component' => self::COMPONENT,
 245                  'filearea' => self::CACHED_ASSETS_FILEAREA,
 246                  'itemid' => 0,
 247                  'filepath' => '/',
 248                  'filename' => $filename
 249              ];
 250  
 251              // Store concatenated content.
 252              $this->fs->create_file_from_string($fileinfo, $this->concatenate_files($assets, $type, $this->context));
 253              $files[$type] = [
 254                  (object) [
 255                      'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . $filename,
 256                      'version' => ''
 257                  ]
 258              ];
 259          }
 260      }
 261  
 262      /**
 263       * Will check if there are cache assets available for content.
 264       *
 265       * @param string $key Hashed key for cached asset
 266       * @return array
 267       */
 268      public function getCachedAssets($key) {
 269          $files = [];
 270  
 271          $js = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.js");
 272          if ($js && $js->get_filesize() > 0) {
 273              $files['scripts'] = [
 274                  (object) [
 275                      'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.js",
 276                      'version' => ''
 277                  ]
 278              ];
 279          }
 280  
 281          $css = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/', "{$key}.css");
 282          if ($css && $css->get_filesize() > 0) {
 283              $files['styles'] = [
 284                  (object) [
 285                      'path' => '/' . self::CACHED_ASSETS_FILEAREA . '/' . "{$key}.css",
 286                      'version' => ''
 287                  ]
 288              ];
 289          }
 290  
 291          return empty($files) ? null : $files;
 292      }
 293  
 294      /**
 295       * Remove the aggregated cache files.
 296       *
 297       * @param array $keys The hash keys of removed files
 298       */
 299      public function deleteCachedAssets($keys) {
 300  
 301          if (empty($keys)) {
 302              return;
 303          }
 304  
 305          foreach ($keys as $hash) {
 306              foreach (['js', 'css'] as $type) {
 307                  $cachedasset = $this->fs->get_file($this->context->id, self::COMPONENT, self::CACHED_ASSETS_FILEAREA, 0, '/',
 308                          "{$hash}.{$type}");
 309                  if ($cachedasset) {
 310                      $cachedasset->delete();
 311                  }
 312              }
 313          }
 314      }
 315  
 316      /**
 317       * Read file content of given file and then return it.
 318       *
 319       * @param string $filepath
 320       * @return string contents
 321       */
 322      public function getContent($filepath) {
 323          list(
 324              'filearea' => $filearea,
 325              'filepath' => $filepath,
 326              'filename' => $filename,
 327              'itemid' => $itemid
 328          ) = $this->get_file_elements_from_filepath($filepath);
 329  
 330          if (!$itemid) {
 331              throw new \file_serving_exception('Could not retrieve the requested file, check your file permissions.');
 332          }
 333  
 334          // Locate file.
 335          $file = $this->fs->get_file($this->context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
 336  
 337          // Return content.
 338          return $file->get_content();
 339      }
 340  
 341      /**
 342       * Save files uploaded through the editor.
 343       *
 344       * @param H5peditorFile $file
 345       * @param int $contentid
 346       *
 347       * @return int The id of the saved file.
 348       */
 349      public function saveFile($file, $contentid) {
 350          global $USER;
 351  
 352          $context = $this->context->id;
 353          $component = self::COMPONENT;
 354          $filearea = self::CONTENT_FILEAREA;
 355          if ($contentid === 0) {
 356              $usercontext = \context_user::instance($USER->id);
 357              $context = $usercontext->id;
 358              $component = 'user';
 359              $filearea = 'draft';
 360          }
 361  
 362          $record = array(
 363              'contextid' => $context,
 364              'component' => $component,
 365              'filearea' => $filearea,
 366              'itemid' => $contentid,
 367              'filepath' => '/' . $file->getType() . 's/',
 368              'filename' => $file->getName()
 369          );
 370  
 371          $storedfile = $this->fs->create_file_from_pathname($record, $_FILES['file']['tmp_name']);
 372  
 373          return $storedfile->get_id();
 374      }
 375  
 376      /**
 377       * Copy a file from another content or editor tmp dir.
 378       * Used when copy pasting content in H5P.
 379       *
 380       * @param string $file path + name
 381       * @param string|int $fromid Content ID or 'editor' string
 382       * @param \stdClass $tocontent Target Content
 383       *
 384       * @return void
 385       */
 386      public function cloneContentFile($file, $fromid, $tocontent): void {
 387          // Determine source filearea and itemid.
 388          if ($fromid === 'editor') {
 389              $sourcefilearea = 'draft';
 390              $sourceitemid = 0;
 391          } else {
 392              $sourcefilearea = self::CONTENT_FILEAREA;
 393              $sourceitemid = (int)$fromid;
 394          }
 395  
 396          $filepath = '/' . dirname($file) . '/';
 397          $filename = basename($file);
 398  
 399          // Check to see if source exists.
 400          $sourcefile = $this->get_file($sourcefilearea, $sourceitemid, $file);
 401          if ($sourcefile === null) {
 402              return; // Nothing to copy from.
 403          }
 404  
 405          // Check to make sure that file doesn't exist already in target.
 406          $targetfile = $this->get_file(self::CONTENT_FILEAREA, $tocontent->id, $file);
 407          if ( $targetfile !== null) {
 408              return; // File exists, no need to copy.
 409          }
 410  
 411          // Create new file record.
 412          $record = [
 413              'contextid' => $this->context->id,
 414              'component' => self::COMPONENT,
 415              'filearea' => self::CONTENT_FILEAREA,
 416              'itemid' => $tocontent->id,
 417              'filepath' => $filepath,
 418              'filename' => $filename,
 419          ];
 420  
 421          $this->fs->create_file_from_storedfile($record, $sourcefile);
 422      }
 423  
 424      /**
 425       * Copy content from one directory to another.
 426       * Defaults to cloning content from the current temporary upload folder to the editor path.
 427       *
 428       * @param string $source path to source directory
 429       * @param string $contentid Id of content
 430       *
 431       */
 432      public function moveContentDirectory($source, $contentid = null) {
 433          $contentidint = (int)$contentid;
 434  
 435          if ($source === null) {
 436              return;
 437          }
 438  
 439          // Get H5P and content json.
 440          $contentsource = $source . '/content';
 441  
 442          // Move all temporary content files to editor.
 443          $it = new \RecursiveIteratorIterator(
 444              new \RecursiveDirectoryIterator($contentsource, \RecursiveDirectoryIterator::SKIP_DOTS),
 445              \RecursiveIteratorIterator::SELF_FIRST
 446          );
 447  
 448          $it->rewind();
 449          while ($it->valid()) {
 450              $item = $it->current();
 451              $pathname = $it->getPathname();
 452              if (!$item->isDir() && !($item->getFilename() === 'content.json')) {
 453                  $this->move_file($pathname, $contentidint);
 454              }
 455              $it->next();
 456          }
 457      }
 458  
 459      /**
 460       * Get the file URL or given library and then return it.
 461       *
 462       * @param int $itemid
 463       * @param string $machinename
 464       * @param int $majorversion
 465       * @param int $minorversion
 466       * @return string url or false if the file doesn't exist
 467       */
 468      public function get_icon_url(int $itemid, string $machinename, int $majorversion, int $minorversion) {
 469          $filepath = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
 470          if ($file = $this->fs->get_file(
 471              $this->context->id,
 472              self::COMPONENT,
 473              self::LIBRARY_FILEAREA,
 474              $itemid,
 475              $filepath,
 476              self::ICON_FILENAME)
 477          ) {
 478              $iconurl  = \moodle_url::make_pluginfile_url(
 479                  $this->context->id,
 480                  self::COMPONENT,
 481                  self::LIBRARY_FILEAREA,
 482                  $itemid,
 483                  $filepath,
 484                  $file->get_filename());
 485  
 486              // Return image URL.
 487              return $iconurl->out();
 488          }
 489  
 490          return false;
 491      }
 492  
 493      /**
 494       * Checks to see if an H5P content has the given file.
 495       *
 496       * @param string $file File path and name.
 497       * @param int $content Content id.
 498       *
 499       * @return int|null File ID or NULL if not found
 500       */
 501      public function getContentFile($file, $content): ?int {
 502          if (is_object($content)) {
 503              $content = $content->id;
 504          }
 505          $contentfile = $this->get_file(self::CONTENT_FILEAREA, $content, $file);
 506  
 507          return ($contentfile === null ? null : $contentfile->get_id());
 508      }
 509  
 510      /**
 511       * Remove content files that are no longer used.
 512       *
 513       * Used when saving content.
 514       *
 515       * @param string $file File path and name.
 516       * @param int $contentid Content id.
 517       *
 518       * @return void
 519       */
 520      public function removeContentFile($file, $contentid): void {
 521          // Although the interface defines $contentid as int, object given in H5peditor::processParameters.
 522          if (is_object($contentid)) {
 523              $contentid = $contentid->id;
 524          }
 525          $existingfile = $this->get_file(self::CONTENT_FILEAREA, $contentid, $file);
 526          if ($existingfile !== null) {
 527              $existingfile->delete();
 528          }
 529      }
 530  
 531      /**
 532       * Check if server setup has write permission to
 533       * the required folders
 534       *
 535       * @return bool True if server has the proper write access
 536       */
 537      public function hasWriteAccess() {
 538          // Moodle has access to the files table which is where all of the folders are stored.
 539          return true;
 540      }
 541  
 542      /**
 543       * Check if the library has a presave.js in the root folder
 544       *
 545       * @param string $libraryname
 546       * @param string $developmentpath
 547       * @return bool
 548       */
 549      public function hasPresave($libraryname, $developmentpath = null) {
 550          return false;
 551      }
 552  
 553      /**
 554       * Check if upgrades script exist for library.
 555       *
 556       * @param string $machinename
 557       * @param int $majorversion
 558       * @param int $minorversion
 559       * @return string Relative path
 560       */
 561      public function getUpgradeScript($machinename, $majorversion, $minorversion) {
 562          $path = '/' . "{$machinename}-{$majorversion}.{$minorversion}" . '/';
 563          $file = 'upgrade.js';
 564          $itemid = $this->get_itemid_for_file(self::LIBRARY_FILEAREA, $path, $file);
 565          if ($this->fs->get_file($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $itemid, $path, $file)) {
 566              return '/' . self::LIBRARY_FILEAREA . $path. $file;
 567          } else {
 568              return null;
 569          }
 570      }
 571  
 572      /**
 573       * Store the given stream into the given file.
 574       *
 575       * @param string $path
 576       * @param string $file
 577       * @param resource $stream
 578       * @return bool|int
 579       */
 580      public function saveFileFromZip($path, $file, $stream) {
 581          $fullpath = $path . '/' . $file;
 582          check_dir_exists(pathinfo($fullpath, PATHINFO_DIRNAME));
 583          return file_put_contents($fullpath, $stream);
 584      }
 585  
 586      /**
 587       * Deletes a library from the file system.
 588       *
 589       * @param  array $library Library details
 590       */
 591      public function delete_library(array $library): void {
 592          global $DB;
 593  
 594          // A library ID of false would result in all library files being deleted, which we don't want. Return instead.
 595          if (empty($library['libraryId'])) {
 596              return;
 597          }
 598  
 599          $areafiles = $this->fs->get_area_files($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
 600          $this->delete_directory($this->context->id, self::COMPONENT, self::LIBRARY_FILEAREA, $library['libraryId']);
 601          $librarycache = \cache::make('core', 'h5p_library_files');
 602          foreach ($areafiles as $file) {
 603              if (!$DB->record_exists('files', array('contenthash' => $file->get_contenthash(),
 604                                                     'component' => self::COMPONENT,
 605                                                     'filearea' => self::LIBRARY_FILEAREA))) {
 606                  $librarycache->delete($file->get_contenthash());
 607              }
 608          }
 609      }
 610  
 611      /**
 612       * Remove an H5P directory from the filesystem.
 613       *
 614       * @param int $contextid context ID
 615       * @param string $component component
 616       * @param string $filearea file area or all areas in context if not specified
 617       * @param int $itemid item ID or all files if not specified
 618       */
 619      private function delete_directory(int $contextid, string $component, string $filearea, int $itemid): void {
 620  
 621          $this->fs->delete_area_files($contextid, $component, $filearea, $itemid);
 622      }
 623  
 624      /**
 625       * Copy an H5P directory from the temporary directory into the file system.
 626       *
 627       * @param  string $source  Temporary location for files.
 628       * @param  array  $options File system information.
 629       */
 630      private function copy_directory(string $source, array $options): void {
 631          $librarycache = \cache::make('core', 'h5p_library_files');
 632          $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
 633                  \RecursiveIteratorIterator::SELF_FIRST);
 634  
 635          $root = $options['filepath'];
 636  
 637          $it->rewind();
 638          while ($it->valid()) {
 639              $item = $it->current();
 640              $subpath = $it->getSubPath();
 641              if (!$item->isDir()) {
 642                  $options['filename'] = $it->getFilename();
 643                  if (!$subpath == '') {
 644                      $options['filepath'] = $root . $subpath . '/';
 645                  } else {
 646                      $options['filepath'] = $root;
 647                  }
 648  
 649                  $file = $this->fs->create_file_from_pathname($options, $item->getPathName());
 650  
 651                  if ($options['filearea'] == self::LIBRARY_FILEAREA) {
 652                      if (!$librarycache->has($file->get_contenthash())) {
 653                          $librarycache->set($file->get_contenthash(), file_get_contents($item->getPathName()));
 654                      }
 655                  }
 656              }
 657              $it->next();
 658          }
 659      }
 660  
 661      /**
 662       * Copies files from storage to temporary folder.
 663       *
 664       * @param string $target Path to temporary folder
 665       * @param int $contextid context where the files are found
 666       * @param string $filearea file area
 667       * @param string $filepath file path
 668       * @param int $itemid Optional item ID
 669       */
 670      private function export_file_tree(string $target, int $contextid, string $filearea, string $filepath, int $itemid = 0): void {
 671          // Make sure target folder exists.
 672          check_dir_exists($target);
 673  
 674          // Read source files.
 675          $files = $this->fs->get_directory_files($contextid, self::COMPONENT, $filearea, $itemid, $filepath, true);
 676  
 677          $librarycache = \cache::make('core', 'h5p_library_files');
 678  
 679          foreach ($files as $file) {
 680              $path = $target . str_replace($filepath, DIRECTORY_SEPARATOR, $file->get_filepath());
 681              if ($file->is_directory()) {
 682                  check_dir_exists(rtrim($path));
 683              } else {
 684                  if ($filearea == self::LIBRARY_FILEAREA) {
 685                      $cachedfile = $librarycache->get($file->get_contenthash());
 686                      if (empty($cachedfile)) {
 687                          $file->copy_content_to($path . $file->get_filename());
 688                          $librarycache->set($file->get_contenthash(), file_get_contents($path . $file->get_filename()));
 689                      } else {
 690                          file_put_contents($path . $file->get_filename(), $cachedfile);
 691                      }
 692                  } else {
 693                      $file->copy_content_to($path . $file->get_filename());
 694                  }
 695              }
 696          }
 697      }
 698  
 699      /**
 700       * Adds all files of a type into one file.
 701       *
 702       * @param  array    $assets  A list of files.
 703       * @param  string   $type    The type of files in assets. Either 'scripts' or 'styles'
 704       * @param  \context $context Context
 705       * @return string All of the file content in one string.
 706       */
 707      private function concatenate_files(array $assets, string $type, \context $context): string {
 708          $content = '';
 709          foreach ($assets as $asset) {
 710              // Find location of asset.
 711              list(
 712                  'filearea' => $filearea,
 713                  'filepath' => $filepath,
 714                  'filename' => $filename,
 715                  'itemid' => $itemid
 716              ) = $this->get_file_elements_from_filepath($asset->path);
 717  
 718              if ($itemid === false) {
 719                  continue;
 720              }
 721  
 722              // Locate file.
 723              $file = $this->fs->get_file($context->id, self::COMPONENT, $filearea, $itemid, $filepath, $filename);
 724  
 725              // Get file content and concatenate.
 726              if ($type === 'scripts') {
 727                  $content .= $file->get_content() . ";\n";
 728              } else {
 729                  // Rewrite relative URLs used inside stylesheets.
 730                  $content .= preg_replace_callback(
 731                      '/url\([\'"]?([^"\')]+)[\'"]?\)/i',
 732                      function ($matches) use ($filearea, $filepath, $itemid) {
 733                          if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) {
 734                              return $matches[0]; // Not relative, skip.
 735                          }
 736                          // Find "../" in matches[1].
 737                          // If it exists, we have to remove "../".
 738                          // And switch the last folder in the filepath for the first folder in $matches[1].
 739                          // For instance:
 740                          // $filepath: /H5P.Question-1.4/styles/
 741                          // $matches[1]: ../images/plus-one.svg
 742                          // We want to avoid this: H5P.Question-1.4/styles/ITEMID/../images/minus-one.svg
 743                          // We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg.
 744                          if (preg_match('/\.\.\//', $matches[1], $pathmatches)) {
 745                              $path = preg_split('/\//', $filepath, -1, PREG_SPLIT_NO_EMPTY);
 746                              $pathfilename = preg_split('/\//', $matches[1], -1, PREG_SPLIT_NO_EMPTY);
 747                              // Remove the first element: ../.
 748                              array_shift($pathfilename);
 749                              // Replace pathfilename into the filepath.
 750                              $path[count($path) - 1] = $pathfilename[0];
 751                              $filepath = '/' . implode('/', $path) . '/';
 752                              // Remove the element used to replace.
 753                              array_shift($pathfilename);
 754                              $matches[1] = implode('/', $pathfilename);
 755                          }
 756                          return 'url("../' . $filearea . $filepath . $itemid . '/' . $matches[1] . '")';
 757                      },
 758                      $file->get_content()) . "\n";
 759              }
 760          }
 761          return $content;
 762      }
 763  
 764      /**
 765       * Get files ready for export.
 766       *
 767       * @param  string $filename File name to retrieve.
 768       * @return bool|\stored_file Stored file instance if exists, false if not
 769       */
 770      public function get_export_file(string $filename) {
 771          return $this->fs->get_file($this->context->id, self::COMPONENT, self::EXPORT_FILEAREA, 0, '/', $filename);
 772      }
 773  
 774      /**
 775       * Converts a relative system file path into Moodle File API elements.
 776       *
 777       * @param  string $filepath The system filepath to get information from.
 778       * @return array File information.
 779       */
 780      private function get_file_elements_from_filepath(string $filepath): array {
 781          $sections = explode('/', $filepath);
 782          // Get the filename.
 783          $filename = array_pop($sections);
 784          // Discard first element.
 785          if (empty($sections[0])) {
 786              array_shift($sections);
 787          }
 788          // Get the filearea.
 789          $filearea = array_shift($sections);
 790          $itemid = array_shift($sections);
 791          // Get the filepath.
 792          $filepath = implode('/', $sections);
 793          $filepath = '/' . $filepath . '/';
 794  
 795          return ['filearea' => $filearea, 'filepath' => $filepath, 'filename' => $filename, 'itemid' => $itemid];
 796      }
 797  
 798      /**
 799       * Returns the item id given the other necessary variables.
 800       *
 801       * @param  string $filearea The file area.
 802       * @param  string $filepath The file path.
 803       * @param  string $filename The file name.
 804       * @return mixed the specified value false if not found.
 805       */
 806      private function get_itemid_for_file(string $filearea, string $filepath, string $filename) {
 807          global $DB;
 808          return $DB->get_field('files', 'itemid', ['component' => self::COMPONENT, 'filearea' => $filearea, 'filepath' => $filepath,
 809                  'filename' => $filename]);
 810      }
 811  
 812      /**
 813       * Helper to make it easy to load content files.
 814       *
 815       * @param string $filearea File area where the file is saved.
 816       * @param int $itemid Content instance or content id.
 817       * @param string $file File path and name.
 818       *
 819       * @return stored_file|null
 820       */
 821      private function get_file(string $filearea, int $itemid, string $file): ?stored_file {
 822          global $USER;
 823  
 824          $component = self::COMPONENT;
 825          $context = $this->context->id;
 826          if ($filearea === 'draft') {
 827              $itemid = 0;
 828              $component = 'user';
 829              $usercontext = \context_user::instance($USER->id);
 830              $context = $usercontext->id;
 831          }
 832  
 833          $filepath = '/'. dirname($file). '/';
 834          $filename = basename($file);
 835  
 836          // Load file.
 837          $existingfile = $this->fs->get_file($context, $component, $filearea, $itemid, $filepath, $filename);
 838          if (!$existingfile) {
 839              return null;
 840          }
 841  
 842          return $existingfile;
 843      }
 844  
 845      /**
 846       * Move a single file
 847       *
 848       * @param string $sourcefile Path to source file
 849       * @param int $contentid Content id or 0 if the file is in the editor file area
 850       *
 851       * @return void
 852       */
 853      private function move_file(string $sourcefile, int $contentid): void {
 854          $pathparts = pathinfo($sourcefile);
 855          $filename  = $pathparts['basename'];
 856          $filepath  = $pathparts['dirname'];
 857          $foldername = basename($filepath);
 858  
 859          // Create file record for content.
 860          $record = array(
 861              'contextid' => $this->context->id,
 862              'component' => $contentid > 0 ? self::COMPONENT : 'user',
 863              'filearea' => $contentid > 0 ? self::CONTENT_FILEAREA : 'draft',
 864              'itemid' => $contentid > 0 ? $contentid : 0,
 865              'filepath' => '/' . $foldername . '/',
 866              'filename' => $filename
 867          );
 868  
 869          $file = $this->fs->get_file(
 870              $record['contextid'], $record['component'],
 871              $record['filearea'], $record['itemid'], $record['filepath'],
 872              $record['filename']
 873          );
 874  
 875          if ($file) {
 876              // Delete it to make sure that it is replaced with correct content.
 877              $file->delete();
 878          }
 879  
 880          $this->fs->create_file_from_pathname($record, $sourcefile);
 881      }
 882  }