Search moodle.org's
Developer Documentation

See Release Notes

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

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