MDL-66609 core_h5p: Add capability to deploy H5P content
[moodle.git] / h5p / classes / player.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * H5P player class.
19  *
20  * @package    core_h5p
21  * @copyright  2019 Sara Arjona <sara@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_h5p;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * H5P player class, for displaying any local H5P content.
31  *
32  * @package    core_h5p
33  * @copyright  2019 Sara Arjona <sara@moodle.com>
34  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class player {
38     /**
39      * @var string The local H5P URL containing the .h5p file to display.
40      */
41     private $url;
43     /**
44      * @var core The H5PCore object.
45      */
46     private $core;
48     /**
49      * @var int H5P DB id.
50      */
51     private $h5pid;
53     /**
54      * @var array JavaScript requirements for this H5P.
55      */
56     private $jsrequires = [];
58     /**
59      * @var array CSS requirements for this H5P.
60      */
61     private $cssrequires = [];
63     /**
64      * @var array H5P content to display.
65      */
66     private $content;
68     /**
69      * @var string Type of embed object, div or iframe.
70      */
71     private $embedtype;
73     /**
74      * @var context The context object where the .h5p belongs.
75      */
76     private $context;
78     /**
79      * @var context The \core_h5p\factory object.
80      */
81     private $factory;
83     /**
84      * Inits the H5P player for rendering the content.
85      *
86      * @param string $url Local URL of the H5P file to display.
87      * @param stdClass $config Configuration for H5P buttons.
88      */
89     public function __construct(string $url, \stdClass $config) {
90         if (empty($url)) {
91             throw new \moodle_exception('h5pinvalidurl', 'core_h5p');
92         }
93         $this->url = new \moodle_url($url);
95         $this->factory = new \core_h5p\factory();
97         // Create \core_h5p\core instance.
98         $this->core = $this->factory->get_core();
100         // Get the H5P identifier linked to this URL.
101         if ($this->h5pid = $this->get_h5p_id($url, $config)) {
102             // Load the content of the H5P content associated to this $url.
103             $this->content = $this->core->loadContent($this->h5pid);
105             // Get the embedtype to use for displaying the H5P content.
106             $this->embedtype = core::determineEmbedType($this->content['embedType'], $this->content['library']['embedTypes']);
107         }
108     }
110     /**
111      * Get the error messages stored in our H5P framework.
112      *
113      * @return stdClass with framework error messages.
114      */
115     public function get_messages() : \stdClass {
116         $messages = new \stdClass();
117         $messages->error = $this->core->h5pF->getMessages('error');
119         if (empty($messages->error)) {
120             $messages->error = false;
121         }
122         return $messages;
123     }
125     /**
126      * Create the H5PIntegration variable that will be included in the page. This variable is used as the
127      * main H5P config variable.
128      */
129     public function add_assets_to_page() {
130         global $PAGE;
132         $cid = $this->get_cid();
133         $systemcontext = \context_system::instance();
135         $disable = array_key_exists('disable', $this->content) ? $this->content['disable'] : core::DISABLE_NONE;
136         $displayoptions = $this->core->getDisplayOptionsForView($disable, $this->h5pid);
138         $contenturl = \moodle_url::make_pluginfile_url($systemcontext->id, \core_h5p\file_storage::COMPONENT,
139             \core_h5p\file_storage::CONTENT_FILEAREA, $this->h5pid, null, null);
141         $contentsettings = [
142             'library'         => core::libraryToString($this->content['library']),
143             'fullScreen'      => $this->content['library']['fullscreen'],
144             'exportUrl'       => $this->get_export_settings($displayoptions[ core::DISPLAY_OPTION_DOWNLOAD ]),
145             'embedCode'       => $this->get_embed_code($this->url->out(),
146                 $displayoptions[ core::DISPLAY_OPTION_EMBED ]),
147             'resizeCode'      => $this->get_resize_code(),
148             'title'           => $this->content['slug'],
149             'displayOptions'  => $displayoptions,
150             'url'             => self::get_embed_url($this->url->out())->out(),
151             'contentUrl'      => $contenturl->out(),
152             'metadata'        => $this->content['metadata'],
153             'contentUserData' => [0 => ['state' => '{}']]
154         ];
155         // Get the core H5P assets, needed by the H5P classes to render the H5P content.
156         $settings = $this->get_assets();
157         $settings['contents'][$cid] = array_merge($settings['contents'][$cid], $contentsettings);
159         foreach ($this->jsrequires as $script) {
160             $PAGE->requires->js($script, true);
161         }
163         foreach ($this->cssrequires as $css) {
164             $PAGE->requires->css($css);
165         }
167         // Print JavaScript settings to page.
168         $PAGE->requires->data_for_js('H5PIntegration', $settings, true);
169     }
171     /**
172      * Outputs H5P wrapper HTML.
173      *
174      * @return string The HTML code to display this H5P content.
175      */
176     public function output() : string {
177         global $OUTPUT;
179         $template = new \stdClass();
180         $template->h5pid = $this->h5pid;
181         if ($this->embedtype === 'div') {
182             return $OUTPUT->render_from_template('core_h5p/h5pdiv', $template);
183         } else {
184             return $OUTPUT->render_from_template('core_h5p/h5piframe', $template);
185         }
186     }
188     /**
189      * Get the title of the H5P content to display.
190      *
191      * @return string the title
192      */
193     public function get_title() : string {
194         return $this->content['title'];
195     }
197     /**
198      * Get the context where the .h5p file belongs.
199      *
200      * @return context The context.
201      */
202     public function get_context() : \context {
203         return $this->context;
204     }
206     /**
207      * Get the H5P DB instance id for a H5P pluginfile URL. The H5P file will be saved if it doesn't exist previously or
208      * if its content has changed. Besides, the displayoptions in the $config will be also updated when they have changed and
209      * the user has the right permissions.
210      *
211      * @param string $url H5P pluginfile URL.
212      * @param stdClass $config Configuration for H5P buttons.
213      *
214      * @return int|false H5P DB identifier.
215      */
216     private function get_h5p_id(string $url, \stdClass $config) {
217         global $DB;
219         $fs = get_file_storage();
221         // Deconstruct the URL and get the pathname associated.
222         $pathnamehash = $this->get_pluginfile_hash($url);
223         if (!$pathnamehash) {
224             $this->core->h5pF->setErrorMessage(get_string('h5pfilenotfound', 'core_h5p'));
225             return false;
226         }
228         // Get the file.
229         $file = $fs->get_file_by_hash($pathnamehash);
230         if (!$file) {
231             $this->core->h5pF->setErrorMessage(get_string('h5pfilenotfound', 'core_h5p'));
232             return false;
233         }
235         $h5p = $DB->get_record('h5p', ['pathnamehash' => $pathnamehash]);
236         $contenthash = $file->get_contenthash();
237         if ($h5p && $h5p->contenthash != $contenthash) {
238             // The content exists and it is different from the one deployed previously. The existing one should be removed before
239             // deploying the new version.
240             $this->delete_h5p($h5p);
241             $h5p = false;
242         }
244         if ($h5p) {
245             // The H5P content has been deployed previously.
246             $displayoptions = $this->get_display_options($config);
247             // Check if the user can set the displayoptions.
248             if ($displayoptions != $h5p->displayoptions && has_capability('moodle/h5p:setdisplayoptions', $this->context)) {
249                 // If the displayoptions has changed and the user has permission to modify it, update this information in the DB.
250                 $this->core->h5pF->updateContentFields($h5p->id, ['displayoptions' => $displayoptions]);
251             }
252             return $h5p->id;
253         } else {
254             // The H5P content hasn't been deployed previously.
256             // Check if the user uploading the H5P content is "trustable". If the file hasn't been uploaded by a user with this
257             // capability, the content won't be deployed and an error message will be displayed.
258             if (!has_capability('moodle/h5p:deploy', $this->context, $file->get_userid())) {
259                 $this->core->h5pF->setErrorMessage(get_string('nopermissiontodeploy', 'core_h5p'));
260                 return false;
261             }
263             // Validate and store the H5P content before displaying it.
264             return $this->save_h5p($file, $config);
265         }
266     }
268     /**
269      * Get the pathnamehash from an H5P internal URL.
270      *
271      * @param  string $url H5P pluginfile URL poiting to an H5P file.
272      *
273      * @return string|false pathnamehash for the file in the internal URL.
274      */
275     private function get_pluginfile_hash(string $url) {
276         global $USER;
278         // Decode the URL before start processing it.
279         $url = new \moodle_url(urldecode($url));
281         // Remove params from the URL (such as the 'forcedownload=1'), to avoid errors.
282         $url->remove_params(array_keys($url->params()));
283         $path = $url->out_as_local_url();
285         $parts = explode('/', $path);
286         $filename = array_pop($parts);
287         // First is an empty row and then the pluginfile.php part. Both can be ignored.
288         array_shift($parts);
289         array_shift($parts);
291         // Get the contextid, component and filearea.
292         $contextid = array_shift($parts);
293         $component = array_shift($parts);
294         $filearea = array_shift($parts);
296         // Ignore draft files, because they are considered temporary files, so shouldn't be displayed.
297         if ($filearea == 'draft') {
298             return false;
299         }
301         // Get the context.
302         try {
303             list($this->context, $course, $cm) = get_context_info_array($contextid);
304         } catch (\moodle_exception $e) {
305             throw new \moodle_exception('invalidcontextid', 'core_h5p');
306         }
308         // For CONTEXT_USER, such as the private files, raise an exception if the owner of the file is not the current user.
309         if ($this->context->contextlevel == CONTEXT_USER && $USER->id !== $this->context->instanceid) {
310             throw new \moodle_exception('h5pprivatefile', 'core_h5p');
311         }
313         // For CONTEXT_MODULE, check if the user is enrolled in the course and has permissions view this .h5p file.
314         if ($this->context->contextlevel == CONTEXT_MODULE) {
315             // Require login to the course first (without login to the module).
316             require_course_login($course, true, null, false, true);
318             // Now check if module is available OR it is restricted but the intro is shown on the course page.
319             $cminfo = \cm_info::create($cm);
320             if (!$cminfo->uservisible) {
321                 if (!$cm->showdescription || !$cminfo->is_visible_on_course_page()) {
322                     // Module intro is not visible on the course page and module is not available, show access error.
323                     require_course_login($course, true, $cminfo, false, true);
324                 }
325             }
326         }
328         // Some components, such as mod_page or mod_resource, add the revision to the URL to prevent caching problems.
329         // So the URL contains this revision number as itemid but a 0 is always stored in the files table.
330         // In order to get the proper hash, a callback should be done (looking for those exceptions).
331         $pathdata = component_callback($component, 'get_path_from_pluginfile', [$filearea, $parts], null);
332         if (null === $pathdata) {
333             // Look for the components and fileareas which have empty itemid defined in xxx_pluginfile.
334             $hasnullitemid = false;
335             $hasnullitemid = $hasnullitemid || ($component === 'user' && ($filearea === 'private' || $filearea === 'profile'));
336             $hasnullitemid = $hasnullitemid || ($component === 'mod' && $filearea === 'intro');
337             $hasnullitemid = $hasnullitemid || ($component === 'course' &&
338                     ($filearea === 'summary' || $filearea === 'overviewfiles'));
339             $hasnullitemid = $hasnullitemid || ($component === 'coursecat' && $filearea === 'description');
340             $hasnullitemid = $hasnullitemid || ($component === 'backup' &&
341                     ($filearea === 'course' || $filearea === 'activity' || $filearea === 'automated'));
342             if ($hasnullitemid) {
343                 $itemid = 0;
344             } else {
345                 $itemid = array_shift($parts);
346             }
348             if (empty($parts)) {
349                 $filepath = '/';
350             } else {
351                 $filepath = '/' . implode('/', $parts) . '/';
352             }
353         } else {
354             // The itemid and filepath have been returned by the component callback.
355             [
356                 'itemid' => $itemid,
357                 'filepath' => $filepath,
358             ] = $pathdata;
359         }
361         $fs = get_file_storage();
362         return $fs->get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, $filename);
363     }
365     /**
366      * Store an H5P file
367      *
368      * @param stored_file $file Moodle file instance
369      * @param stdClass $config Button options config.
370      *
371      * @return int|false The H5P identifier or false if it's not a valid H5P package.
372      */
373     private function save_h5p($file, \stdClass $config) : int {
374         // This may take a long time.
375         \core_php_time_limit::raise();
377         $path = $this->core->fs->getTmpPath();
378         $this->core->h5pF->getUploadedH5pFolderPath($path);
379         // Add manually the extension to the file to avoid the validation fails.
380         $path .= '.h5p';
381         $this->core->h5pF->getUploadedH5pPath($path);
383         // Copy the .h5p file to the temporary folder.
384         $file->copy_content_to($path);
386         // Check if the h5p file is valid before saving it.
387         $h5pvalidator = $this->factory->get_validator();
388         if ($h5pvalidator->isValidPackage(false, false)) {
389             $h5pstorage = $this->factory->get_storage();
391             $options = ['disable' => $this->get_display_options($config)];
392             $content = [
393                 'pathnamehash' => $file->get_pathnamehash(),
394                 'contenthash' => $file->get_contenthash(),
395             ];
397             $h5pstorage->savePackage($content, null, false, $options);
398             return $h5pstorage->contentId;
399         }
401         return false;
402     }
404     /**
405      * Get the representation of display options as int.
406      * @param stdClass $config Button options config.
407      *
408      * @return int The representation of display options as int.
409      */
410     private function get_display_options(\stdClass $config) : int {
411         $export = isset($config->export) ? $config->export : 0;
412         $embed = isset($config->embed) ? $config->embed : 0;
413         $copyright = isset($config->copyright) ? $config->copyright : 0;
414         $frame = ($export || $embed || $copyright);
415         if (!$frame) {
416             $frame = isset($config->frame) ? $config->frame : 0;
417         }
419         $disableoptions = [
420             core::DISPLAY_OPTION_FRAME     => $frame,
421             core::DISPLAY_OPTION_DOWNLOAD  => $export,
422             core::DISPLAY_OPTION_EMBED     => $embed,
423             core::DISPLAY_OPTION_COPYRIGHT => $copyright,
424         ];
426         return $this->core->getStorableDisplayOptions($disableoptions, 0);
427     }
429     /**
430      * Delete an H5P package.
431      *
432      * @param stdClass $content The H5P package to delete.
433      */
434     private function delete_h5p(\stdClass $content) {
435         $h5pstorage = $this->factory->get_storage();
436         // Add an empty slug to the content if it's not defined, because the H5P library requires this field exists.
437         // It's not used when deleting a package, so the real slug value is not required at this point.
438         $content->slug = $content->slug ?? '';
439         $h5pstorage->deletePackage( (array) $content);
440     }
442     /**
443      * Export path for settings
444      *
445      * @param bool $downloadenabled Whether the option to export the H5P content is enabled.
446      *
447      * @return string The URL of the exported file.
448      */
449     private function get_export_settings(bool $downloadenabled) : string {
451         if ( ! $downloadenabled) {
452             return '';
453         }
455         $systemcontext = \context_system::instance();
456         $slug = $this->content['slug'] ? $this->content['slug'] . '-' : '';
457         $url  = \moodle_url::make_pluginfile_url(
458             $systemcontext->id,
459             \core_h5p\file_storage::COMPONENT,
460             \core_h5p\file_storage::EXPORT_FILEAREA,
461             '',
462             '',
463             "{$slug}{$this->content['id']}.h5p"
464         );
466         return $url->out();
467     }
469     /**
470      * Get a query string with the theme revision number to include at the end
471      * of URLs. This is used to force the browser to reload the asset when the
472      * theme caches are cleared.
473      *
474      * @return string
475      */
476     private function get_cache_buster() : string {
477         global $CFG;
478         return '?ver=' . $CFG->themerev;
479     }
481     /**
482      * Get the identifier for the H5P content, to be used in the arrays as index.
483      *
484      * @return string The identifier.
485      */
486     private function get_cid() : string {
487         return 'cid-' . $this->h5pid;
488     }
490     /**
491      * Get the core H5P assets, including all core H5P JavaScript and CSS.
492      *
493      * @return Array core H5P assets.
494      */
495     private function get_assets() : array {
496         global $CFG;
498         // Get core settings.
499         $settings = $this->get_core_settings();
500         $settings['core'] = [
501           'styles' => [],
502           'scripts' => []
503         ];
504         $settings['loadedJs'] = [];
505         $settings['loadedCss'] = [];
507         // Make sure files are reloaded for each plugin update.
508         $cachebuster = $this->get_cache_buster();
510         // Use relative URL to support both http and https.
511         $liburl = $CFG->wwwroot . '/lib/h5p/';
512         $relpath = '/' . preg_replace('/^[^:]+:\/\/[^\/]+\//', '', $liburl);
514         // Add core stylesheets.
515         foreach (core::$styles as $style) {
516             $settings['core']['styles'][] = $relpath . $style . $cachebuster;
517             $this->cssrequires[] = new \moodle_url($liburl . $style . $cachebuster);
518         }
519         // Add core JavaScript.
520         foreach (core::get_scripts() as $script) {
521             $settings['core']['scripts'][] = $script->out(false);
522             $this->jsrequires[] = $script;
523         }
525         $cid = $this->get_cid();
526         // The filterParameters function should be called before getting the dependencyfiles because it rebuild content
527         // dependency cache and export file.
528         $settings['contents'][$cid]['jsonContent'] = $this->core->filterParameters($this->content);
530         $files = $this->get_dependency_files();
531         if ($this->embedtype === 'div') {
532             $systemcontext = \context_system::instance();
533             $h5ppath = "/pluginfile.php/{$systemcontext->id}/core_h5p";
535             // Schedule JavaScripts for loading through Moodle.
536             foreach ($files['scripts'] as $script) {
537                 $url = $script->path . $script->version;
539                 // Add URL prefix if not external.
540                 $isexternal = strpos($script->path, '://');
541                 if ($isexternal === false) {
542                     $url = $h5ppath . $url;
543                 }
544                 $settings['loadedJs'][] = $url;
545                 $this->jsrequires[] = new \moodle_url($isexternal ? $url : $CFG->wwwroot . $url);
546             }
548             // Schedule stylesheets for loading through Moodle.
549             foreach ($files['styles'] as $style) {
550                 $url = $style->path . $style->version;
552                 // Add URL prefix if not external.
553                 $isexternal = strpos($style->path, '://');
554                 if ($isexternal === false) {
555                     $url = $h5ppath . $url;
556                 }
557                 $settings['loadedCss'][] = $url;
558                 $this->cssrequires[] = new \moodle_url($isexternal ? $url : $CFG->wwwroot . $url);
559             }
561         } else {
562             // JavaScripts and stylesheets will be loaded through h5p.js.
563             $settings['contents'][$cid]['scripts'] = $this->core->getAssetsUrls($files['scripts']);
564             $settings['contents'][$cid]['styles']  = $this->core->getAssetsUrls($files['styles']);
565         }
566         return $settings;
567     }
569     /**
570      * Get the settings needed by the H5P library.
571      *
572      * @return array The settings.
573      */
574     private function get_core_settings() : array {
575         global $CFG;
577         $basepath = $CFG->wwwroot . '/';
578         $systemcontext = \context_system::instance();
580         // Generate AJAX paths.
581         $ajaxpaths = [];
582         $ajaxpaths['xAPIResult'] = '';
583         $ajaxpaths['contentUserData'] = '';
585         $settings = array(
586             'baseUrl' => $basepath,
587             'url' => "{$basepath}pluginfile.php/{$systemcontext->instanceid}/core_h5p",
588             'urlLibraries' => "{$basepath}pluginfile.php/{$systemcontext->id}/core_h5p/libraries",
589             'postUserStatistics' => false,
590             'ajax' => $ajaxpaths,
591             'saveFreq' => false,
592             'siteUrl' => $CFG->wwwroot,
593             'l10n' => array('H5P' => $this->core->getLocalization()),
594             'user' => [],
595             'hubIsEnabled' => false,
596             'reportingIsEnabled' => false,
597             'crossorigin' => null,
598             'libraryConfig' => $this->core->h5pF->getLibraryConfig(),
599             'pluginCacheBuster' => $this->get_cache_buster(),
600             'libraryUrl' => $basepath . 'lib/h5p/js',
601             'moodleLibraryPaths' => $this->core->get_dependency_roots($this->h5pid),
602         );
604         return $settings;
605     }
607     /**
608      * Finds library dependencies of view
609      *
610      * @return array Files that the view has dependencies to
611      */
612     private function get_dependency_files() : array {
613         $preloadeddeps = $this->core->loadContentDependencies($this->h5pid, 'preloaded');
614         $files = $this->core->getDependenciesFiles($preloadeddeps);
616         return $files;
617     }
619     /**
620      * Resizing script for settings
621      *
622      * @return string The HTML code with the resize script.
623      */
624     private function get_resize_code() : string {
625         global $OUTPUT;
627         $template = new \stdClass();
628         $template->resizeurl = new \moodle_url('/lib/h5p/js/h5p-resizer.js');
630         return $OUTPUT->render_from_template('core_h5p/h5presize', $template);
631     }
633     /**
634      * Embed code for settings
635      *
636      * @param string $url The URL of the .h5p file.
637      * @param bool $embedenabled Whether the option to embed the H5P content is enabled.
638      *
639      * @return string The HTML code to reuse this H5P content in a different place.
640      */
641     private function get_embed_code(string $url, bool $embedenabled) : string {
642         global $OUTPUT;
644         if ( ! $embedenabled) {
645             return '';
646         }
648         $template = new \stdClass();
649         $template->embedurl = self::get_embed_url($url)->out();
651         return $OUTPUT->render_from_template('core_h5p/h5pembed', $template);
652     }
654     /**
655      * Get the encoded URL for embeding this H5P content.
656      * @param  string $url The URL of the .h5p file.
657      *
658      * @return \moodle_url The embed URL.
659      */
660     public static function get_embed_url(string $url) : \moodle_url {
661         return new \moodle_url('/h5p/embed.php', ['url' => $url]);
662     }