Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

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