1<?php
2
3namespace 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 */
19class 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}
588