Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

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

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