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]

   1  <?php
   2  
   3  namespace Moodle;
   4  
   5  /**
   6   * File info?
   7   */
   8  
   9  /**
  10   * The default file storage class for H5P. Will carry out the requested file
  11   * operations using PHP's standard file operation functions.
  12   *
  13   * Some implementations of H5P that doesn't use the standard file system will
  14   * want to create their own implementation of the H5PFileStorage interface.
  15   *
  16   * @package    H5P
  17   * @copyright  2016 Joubel AS
  18   * @license    MIT
  19   */
  20  class H5PDefaultStorage implements H5PFileStorage {
  21    private $path, $alteditorpath;
  22  
  23    /**
  24     * The great Constructor!
  25     *
  26     * @param string $path
  27     *  The base location of H5P files
  28     * @param string $alteditorpath
  29     *  Optional. Use a different editor path
  30     */
  31    function __construct($path, $alteditorpath = NULL) {
  32      // Set H5P storage path
  33      $this->path = $path;
  34      $this->alteditorpath = $alteditorpath;
  35    }
  36  
  37    /**
  38     * Store the library folder.
  39     *
  40     * @param array $library
  41     *  Library properties
  42     */
  43    public function saveLibrary($library) {
  44      $dest = $this->path . '/libraries/' . H5PCore::libraryToFolderName($library);
  45  
  46      // Make sure destination dir doesn't exist
  47      H5PCore::deleteFileTree($dest);
  48  
  49      // Move library folder
  50      self::copyFileTree($library['uploadDirectory'], $dest);
  51    }
  52  
  53    public function deleteLibrary($library) {
  54      // TODO
  55    }
  56  
  57    /**
  58     * Store the content folder.
  59     *
  60     * @param string $source
  61     *  Path on file system to content directory.
  62     * @param array $content
  63     *  Content properties
  64     */
  65    public function saveContent($source, $content) {
  66      $dest = "{$this->path}/content/{$content['id']}";
  67  
  68      // Remove any old content
  69      H5PCore::deleteFileTree($dest);
  70  
  71      self::copyFileTree($source, $dest);
  72    }
  73  
  74    /**
  75     * Remove content folder.
  76     *
  77     * @param array $content
  78     *  Content properties
  79     */
  80    public function deleteContent($content) {
  81      H5PCore::deleteFileTree("{$this->path}/content/{$content['id']}");
  82    }
  83  
  84    /**
  85     * Creates a stored copy of the content folder.
  86     *
  87     * @param string $id
  88     *  Identifier of content to clone.
  89     * @param int $newId
  90     *  The cloned content's identifier
  91     */
  92    public function cloneContent($id, $newId) {
  93      $path = $this->path . '/content/';
  94      if (file_exists($path . $id)) {
  95        self::copyFileTree($path . $id, $path . $newId);
  96      }
  97    }
  98  
  99    /**
 100     * Get path to a new unique tmp folder.
 101     *
 102     * @return string
 103     *  Path
 104     */
 105    public function getTmpPath() {
 106      $temp = "{$this->path}/temp";
 107      self::dirReady($temp);
 108      return "{$temp}/" . uniqid('h5p-');
 109    }
 110  
 111    /**
 112     * Fetch content folder and save in target directory.
 113     *
 114     * @param int $id
 115     *  Content identifier
 116     * @param string $target
 117     *  Where the content folder will be saved
 118     */
 119    public function exportContent($id, $target) {
 120      $source = "{$this->path}/content/{$id}";
 121      if (file_exists($source)) {
 122        // Copy content folder if it exists
 123        self::copyFileTree($source, $target);
 124      }
 125      else {
 126        // No contnet folder, create emty dir for content.json
 127        self::dirReady($target);
 128      }
 129    }
 130  
 131    /**
 132     * Fetch library folder and save in target directory.
 133     *
 134     * @param array $library
 135     *  Library properties
 136     * @param string $target
 137     *  Where the library folder will be saved
 138     * @param string $developmentPath
 139     *  Folder that library resides in
 140     */
 141    public function exportLibrary($library, $target, $developmentPath=NULL) {
 142      $folder = H5PCore::libraryToFolderName($library);
 143  
 144      $srcPath = ($developmentPath === NULL ? "/libraries/{$folder}" : $developmentPath);
 145      self::copyFileTree("{$this->path}{$srcPath}", "{$target}/{$folder}");
 146    }
 147  
 148    /**
 149     * Save export in file system
 150     *
 151     * @param string $source
 152     *  Path on file system to temporary export file.
 153     * @param string $filename
 154     *  Name of export file.
 155     * @throws Exception Unable to save the file
 156     */
 157    public function saveExport($source, $filename) {
 158      $this->deleteExport($filename);
 159  
 160      if (!self::dirReady("{$this->path}/exports")) {
 161        throw new Exception("Unable to create directory for H5P export file.");
 162      }
 163  
 164      if (!copy($source, "{$this->path}/exports/{$filename}")) {
 165        throw new Exception("Unable to save H5P export file.");
 166      }
 167    }
 168  
 169    /**
 170     * Removes given export file
 171     *
 172     * @param string $filename
 173     */
 174    public function deleteExport($filename) {
 175      $target = "{$this->path}/exports/{$filename}";
 176      if (file_exists($target)) {
 177        unlink($target);
 178      }
 179    }
 180  
 181    /**
 182     * Check if the given export file exists
 183     *
 184     * @param string $filename
 185     * @return boolean
 186     */
 187    public function hasExport($filename) {
 188      $target = "{$this->path}/exports/{$filename}";
 189      return file_exists($target);
 190    }
 191  
 192    /**
 193     * Will concatenate all JavaScrips and Stylesheets into two files in order
 194     * to improve page performance.
 195     *
 196     * @param array $files
 197     *  A set of all the assets required for content to display
 198     * @param string $key
 199     *  Hashed key for cached asset
 200     */
 201    public function cacheAssets(&$files, $key) {
 202      foreach ($files as $type => $assets) {
 203        if (empty($assets)) {
 204          continue; // Skip no assets
 205        }
 206  
 207        $content = '';
 208        foreach ($assets as $asset) {
 209          // Get content from asset file
 210          $assetContent = file_get_contents($this->path . $asset->path);
 211          $cssRelPath = preg_replace('/[^\/]+$/', '', $asset->path);
 212  
 213          // Get file content and concatenate
 214          if ($type === 'scripts') {
 215            $content .= $assetContent . ";\n";
 216          }
 217          else {
 218            // Rewrite relative URLs used inside stylesheets
 219            $content .= preg_replace_callback(
 220                '/url\([\'"]?([^"\')]+)[\'"]?\)/i',
 221                function ($matches) use ($cssRelPath) {
 222                    if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) {
 223                      return $matches[0]; // Not relative, skip
 224                    }
 225                    return 'url("../' . $cssRelPath . $matches[1] . '")';
 226                },
 227                $assetContent) . "\n";
 228          }
 229        }
 230  
 231        self::dirReady("{$this->path}/cachedassets");
 232        $ext = ($type === 'scripts' ? 'js' : 'css');
 233        $outputfile = "/cachedassets/{$key}.{$ext}";
 234        file_put_contents($this->path . $outputfile, $content);
 235        $files[$type] = array((object) array(
 236          'path' => $outputfile,
 237          'version' => ''
 238        ));
 239      }
 240    }
 241  
 242    /**
 243     * Will check if there are cache assets available for content.
 244     *
 245     * @param string $key
 246     *  Hashed key for cached asset
 247     * @return array
 248     */
 249    public function getCachedAssets($key) {
 250      $files = array();
 251  
 252      $js = "/cachedassets/{$key}.js";
 253      if (file_exists($this->path . $js)) {
 254        $files['scripts'] = array((object) array(
 255          'path' => $js,
 256          'version' => ''
 257        ));
 258      }
 259  
 260      $css = "/cachedassets/{$key}.css";
 261      if (file_exists($this->path . $css)) {
 262        $files['styles'] = array((object) array(
 263          'path' => $css,
 264          'version' => ''
 265        ));
 266      }
 267  
 268      return empty($files) ? NULL : $files;
 269    }
 270  
 271    /**
 272     * Remove the aggregated cache files.
 273     *
 274     * @param array $keys
 275     *   The hash keys of removed files
 276     */
 277    public function deleteCachedAssets($keys) {
 278      foreach ($keys as $hash) {
 279        foreach (array('js', 'css') as $ext) {
 280          $path = "{$this->path}/cachedassets/{$hash}.{$ext}";
 281          if (file_exists($path)) {
 282            unlink($path);
 283          }
 284        }
 285      }
 286    }
 287  
 288    /**
 289     * Read file content of given file and then return it.
 290     *
 291     * @param string $file_path
 292     * @return string
 293     */
 294    public function getContent($file_path) {
 295      return file_get_contents($file_path);
 296    }
 297  
 298    /**
 299     * Save files uploaded through the editor.
 300     * The files must be marked as temporary until the content form is saved.
 301     *
 302     * @param H5peditorFile $file
 303     * @param int $contentid
 304     */
 305    public function saveFile($file, $contentId) {
 306      // Prepare directory
 307      if (empty($contentId)) {
 308        // Should be in editor tmp folder
 309        $path = $this->getEditorPath();
 310      }
 311      else {
 312        // Should be in content folder
 313        $path = $this->path . '/content/' . $contentId;
 314      }
 315      $path .= '/' . $file->getType() . 's';
 316      self::dirReady($path);
 317  
 318      // Add filename to path
 319      $path .= '/' . $file->getName();
 320  
 321      copy($_FILES['file']['tmp_name'], $path);
 322  
 323      return $file;
 324    }
 325  
 326    /**
 327     * Copy a file from another content or editor tmp dir.
 328     * Used when copy pasting content in H5P Editor.
 329     *
 330     * @param string $file path + name
 331     * @param string|int $fromid Content ID or 'editor' string
 332     * @param int $toid Target Content ID
 333     */
 334    public function cloneContentFile($file, $fromId, $toId) {
 335      // Determine source path
 336      if ($fromId === 'editor') {
 337        $sourcepath = $this->getEditorPath();
 338      }
 339      else {
 340        $sourcepath = "{$this->path}/content/{$fromId}";
 341      }
 342      $sourcepath .= '/' . $file;
 343  
 344      // Determine target path
 345      $filename = basename($file);
 346      $filedir = str_replace($filename, '', $file);
 347      $targetpath = "{$this->path}/content/{$toId}/{$filedir}";
 348  
 349      // Make sure it's ready
 350      self::dirReady($targetpath);
 351  
 352      $targetpath .= $filename;
 353  
 354      // Check to see if source exist and if target doesn't
 355      if (!file_exists($sourcepath) || file_exists($targetpath)) {
 356        return; // Nothing to copy from or target already exists
 357      }
 358  
 359      copy($sourcepath, $targetpath);
 360    }
 361  
 362    /**
 363     * Copy a content from one directory to another. Defaults to cloning
 364     * content from the current temporary upload folder to the editor path.
 365     *
 366     * @param string $source path to source directory
 367     * @param string $contentId Id of contentarray
 368     */
 369    public function moveContentDirectory($source, $contentId = NULL) {
 370      if ($source === NULL) {
 371        return NULL;
 372      }
 373  
 374      // TODO: Remove $contentId and never copy temporary files into content folder. JI-366
 375      if ($contentId === NULL || $contentId == 0) {
 376        $target = $this->getEditorPath();
 377      }
 378      else {
 379        // Use content folder
 380        $target = "{$this->path}/content/{$contentId}";
 381      }
 382  
 383      $contentSource = $source . '/' . 'content';
 384      $contentFiles = array_diff(scandir($contentSource), array('.','..', 'content.json'));
 385      foreach ($contentFiles as $file) {
 386        if (is_dir("{$contentSource}/{$file}")) {
 387          self::copyFileTree("{$contentSource}/{$file}", "{$target}/{$file}");
 388        }
 389        else {
 390          copy("{$contentSource}/{$file}", "{$target}/{$file}");
 391        }
 392      }
 393  
 394      // TODO: Return list of all files so that they can be marked as temporary. JI-366
 395    }
 396  
 397    /**
 398     * Checks to see if content has the given file.
 399     * Used when saving content.
 400     *
 401     * @param string $file path + name
 402     * @param int $contentId
 403     * @return string File ID or NULL if not found
 404     */
 405    public function getContentFile($file, $contentId) {
 406      $path = "{$this->path}/content/{$contentId}/{$file}";
 407      return file_exists($path) ? $path : NULL;
 408    }
 409  
 410    /**
 411     * Checks to see if content has the given file.
 412     * Used when saving content.
 413     *
 414     * @param string $file path + name
 415     * @param int $contentid
 416     * @return string|int File ID or NULL if not found
 417     */
 418    public function removeContentFile($file, $contentId) {
 419      $path = "{$this->path}/content/{$contentId}/{$file}";
 420      if (file_exists($path)) {
 421        unlink($path);
 422  
 423        // Clean up any empty parent directories to avoid cluttering the file system
 424        $parts = explode('/', $path);
 425        while (array_pop($parts) !== NULL) {
 426          $dir = implode('/', $parts);
 427          if (is_dir($dir) && count(scandir($dir)) === 2) { // empty contains '.' and '..'
 428            rmdir($dir); // Remove empty parent
 429          }
 430          else {
 431            return; // Not empty
 432          }
 433        }
 434      }
 435    }
 436  
 437    /**
 438     * Check if server setup has write permission to
 439     * the required folders
 440     *
 441     * @return bool True if site can write to the H5P files folder
 442     */
 443    public function hasWriteAccess() {
 444      return self::dirReady($this->path);
 445    }
 446  
 447    /**
 448     * Check if the file presave.js exists in the root of the library
 449     *
 450     * @param string $libraryFolder
 451     * @param string $developmentPath
 452     * @return bool
 453     */
 454    public function hasPresave($libraryFolder, $developmentPath = null) {
 455        $path = is_null($developmentPath) ? 'libraries' . '/' . $libraryFolder : $developmentPath;
 456        $filePath = realpath($this->path . '/' . $path . '/' . 'presave.js');
 457      return file_exists($filePath);
 458    }
 459  
 460    /**
 461     * Check if upgrades script exist for library.
 462     *
 463     * @param string $machineName
 464     * @param int $majorVersion
 465     * @param int $minorVersion
 466     * @return string Relative path
 467     */
 468    public function getUpgradeScript($machineName, $majorVersion, $minorVersion) {
 469      $upgrades = "/libraries/{$machineName}-{$majorVersion}.{$minorVersion}/upgrades.js";
 470      if (file_exists($this->path . $upgrades)) {
 471        return $upgrades;
 472      }
 473      else {
 474        return NULL;
 475      }
 476    }
 477  
 478    /**
 479     * Store the given stream into the given file.
 480     *
 481     * @param string $path
 482     * @param string $file
 483     * @param resource $stream
 484     * @return bool
 485     */
 486    public function saveFileFromZip($path, $file, $stream) {
 487      $filePath = $path . '/' . $file;
 488  
 489      // Make sure the directory exists first
 490      $matches = array();
 491      preg_match('/(.+)\/[^\/]*$/', $filePath, $matches);
 492      self::dirReady($matches[1]);
 493  
 494      // Store in local storage folder
 495      return file_put_contents($filePath, $stream);
 496    }
 497  
 498    /**
 499     * Recursive function for copying directories.
 500     *
 501     * @param string $source
 502     *  From path
 503     * @param string $destination
 504     *  To path
 505     * @return boolean
 506     *  Indicates if the directory existed.
 507     *
 508     * @throws Exception Unable to copy the file
 509     */
 510    private static function copyFileTree($source, $destination) {
 511      if (!self::dirReady($destination)) {
 512        throw new \Exception('unabletocopy');
 513      }
 514  
 515      $ignoredFiles = self::getIgnoredFiles("{$source}/.h5pignore");
 516  
 517      $dir = opendir($source);
 518      if ($dir === FALSE) {
 519        trigger_error('Unable to open directory ' . $source, E_USER_WARNING);
 520        throw new \Exception('unabletocopy');
 521      }
 522  
 523      while (false !== ($file = readdir($dir))) {
 524        if (($file != '.') && ($file != '..') && $file != '.git' && $file != '.gitignore' && !in_array($file, $ignoredFiles)) {
 525          if (is_dir("{$source}/{$file}")) {
 526            self::copyFileTree("{$source}/{$file}", "{$destination}/{$file}");
 527          }
 528          else {
 529            copy("{$source}/{$file}", "{$destination}/{$file}");
 530          }
 531        }
 532      }
 533      closedir($dir);
 534    }
 535  
 536    /**
 537     * Retrieve array of file names from file.
 538     *
 539     * @param string $file
 540     * @return array Array with files that should be ignored
 541     */
 542    private static function getIgnoredFiles($file) {
 543      if (file_exists($file) === FALSE) {
 544        return array();
 545      }
 546  
 547      $contents = file_get_contents($file);
 548      if ($contents === FALSE) {
 549        return array();
 550      }
 551  
 552      return preg_split('/\s+/', $contents);
 553    }
 554  
 555    /**
 556     * Recursive function that makes sure the specified directory exists and
 557     * is writable.
 558     *
 559     * @param string $path
 560     * @return bool
 561     */
 562    private static function dirReady($path) {
 563      if (!file_exists($path)) {
 564        $parent = preg_replace("/\/[^\/]+\/?$/", '', $path);
 565        if (!self::dirReady($parent)) {
 566          return FALSE;
 567        }
 568  
 569        mkdir($path, 0777, true);
 570      }
 571  
 572      if (!is_dir($path)) {
 573        trigger_error('Path is not a directory ' . $path, E_USER_WARNING);
 574        return FALSE;
 575      }
 576  
 577      if (!is_writable($path)) {
 578        trigger_error('Unable to write to ' . $path . ' – check directory permissions –', E_USER_WARNING);
 579        return FALSE;
 580      }
 581  
 582      return TRUE;
 583    }
 584  
 585    /**
 586     * Easy helper function for retrieving the editor path
 587     *
 588     * @return string Path to editor files
 589     */
 590    private function getEditorPath() {
 591      return ($this->alteditorpath !== NULL ? $this->alteditorpath : "{$this->path}/editor");
 592    }
 593  }