Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

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