93ecddc3e2cdc25ea47fa573b08795c470641711
[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 hasn't been deployed previously. It has to be validated and stored before displaying it.
246             return $this->save_h5p($file, $config);
247         } else {
248             // The H5P content has been deployed previously.
249             $displayoptions = $this->get_display_options($config);
250             // Check if the user can set the displayoptions.
251             if ($displayoptions != $h5p->displayoptions && has_capability('moodle/h5p:setdisplayoptions', $this->context)) {
252                 // If the displayoptions has changed and the user has permission to modify it, update this information in the DB.
253                 $this->core->h5pF->updateContentFields($h5p->id, ['displayoptions' => $displayoptions]);
254             }
255             return $h5p->id;
256         }
257     }
259     /**
260      * Get the pathnamehash from an H5P internal URL.
261      *
262      * @param  string $url H5P pluginfile URL poiting to an H5P file.
263      *
264      * @return string|false pathnamehash for the file in the internal URL.
265      */
266     private function get_pluginfile_hash(string $url) {
267         global $USER;
269         // Decode the URL before start processing it.
270         $url = new \moodle_url(urldecode($url));
272         // Remove params from the URL (such as the 'forcedownload=1'), to avoid errors.
273         $url->remove_params(array_keys($url->params()));
274         $path = $url->out_as_local_url();
276         $parts = explode('/', $path);
277         $filename = array_pop($parts);
278         // First is an empty row and then the pluginfile.php part. Both can be ignored.
279         array_shift($parts);
280         array_shift($parts);
282         // Get the contextid, component and filearea.
283         $contextid = array_shift($parts);
284         $component = array_shift($parts);
285         $filearea = array_shift($parts);
287         // Ignore draft files, because they are considered temporary files, so shouldn't be displayed.
288         if ($filearea == 'draft') {
289             return false;
290         }
292         // Get the context.
293         try {
294             list($this->context, $course, $cm) = get_context_info_array($contextid);
295         } catch (\moodle_exception $e) {
296             throw new \moodle_exception('invalidcontextid', 'core_h5p');
297         }
299         // For CONTEXT_USER, such as the private files, raise an exception if the owner of the file is not the current user.
300         if ($this->context->contextlevel == CONTEXT_USER && $USER->id !== $this->context->instanceid) {
301             throw new \moodle_exception('h5pprivatefile', 'core_h5p');
302         }
304         // For CONTEXT_MODULE, check if the user is enrolled in the course and has permissions view this .h5p file.
305         if ($this->context->contextlevel == CONTEXT_MODULE) {
306             // Require login to the course first (without login to the module).
307             require_course_login($course, true, null, false, true);
309             // Now check if module is available OR it is restricted but the intro is shown on the course page.
310             $cminfo = \cm_info::create($cm);
311             if (!$cminfo->uservisible) {
312                 if (!$cm->showdescription || !$cminfo->is_visible_on_course_page()) {
313                     // Module intro is not visible on the course page and module is not available, show access error.
314                     require_course_login($course, true, $cminfo, false, true);
315                 }
316             }
317         }
319         // Some components, such as mod_page or mod_resource, add the revision to the URL to prevent caching problems.
320         // So the URL contains this revision number as itemid but a 0 is always stored in the files table.
321         // In order to get the proper hash, a callback should be done (looking for those exceptions).
322         $pathdata = component_callback($component, 'get_path_from_pluginfile', [$filearea, $parts], null);
323         if (null === $pathdata) {
324             // Look for the components and fileareas which have empty itemid defined in xxx_pluginfile.
325             $hasnullitemid = false;
326             $hasnullitemid = $hasnullitemid || ($component === 'user' && ($filearea === 'private' || $filearea === 'profile'));
327             $hasnullitemid = $hasnullitemid || ($component === 'mod' && $filearea === 'intro');
328             $hasnullitemid = $hasnullitemid || ($component === 'course' &&
329                     ($filearea === 'summary' || $filearea === 'overviewfiles'));
330             $hasnullitemid = $hasnullitemid || ($component === 'coursecat' && $filearea === 'description');
331             $hasnullitemid = $hasnullitemid || ($component === 'backup' &&
332                     ($filearea === 'course' || $filearea === 'activity' || $filearea === 'automated'));
333             if ($hasnullitemid) {
334                 $itemid = 0;
335             } else {
336                 $itemid = array_shift($parts);
337             }
339             if (empty($parts)) {
340                 $filepath = '/';
341             } else {
342                 $filepath = '/' . implode('/', $parts) . '/';
343             }
344         } else {
345             // The itemid and filepath have been returned by the component callback.
346             [
347                 'itemid' => $itemid,
348                 'filepath' => $filepath,
349             ] = $pathdata;
350         }
352         $fs = get_file_storage();
353         return $fs->get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, $filename);
354     }
356     /**
357      * Store an H5P file
358      *
359      * @param stored_file $file Moodle file instance
360      * @param stdClass $config Button options config.
361      *
362      * @return int|false The H5P identifier or false if it's not a valid H5P package.
363      */
364     private function save_h5p($file, \stdClass $config) : int {
365         // This may take a long time.
366         \core_php_time_limit::raise();
368         $path = $this->core->fs->getTmpPath();
369         $this->core->h5pF->getUploadedH5pFolderPath($path);
370         // Add manually the extension to the file to avoid the validation fails.
371         $path .= '.h5p';
372         $this->core->h5pF->getUploadedH5pPath($path);
374         // Copy the .h5p file to the temporary folder.
375         $file->copy_content_to($path);
377         // Check if the h5p file is valid before saving it.
378         $h5pvalidator = $this->factory->get_validator();
379         if ($h5pvalidator->isValidPackage(false, false)) {
380             $h5pstorage = $this->factory->get_storage();
382             $options = ['disable' => $this->get_display_options($config)];
383             $content = [
384                 'pathnamehash' => $file->get_pathnamehash(),
385                 'contenthash' => $file->get_contenthash(),
386             ];
388             $h5pstorage->savePackage($content, null, false, $options);
389             return $h5pstorage->contentId;
390         }
392         return false;
393     }
395     /**
396      * Get the representation of display options as int.
397      * @param stdClass $config Button options config.
398      *
399      * @return int The representation of display options as int.
400      */
401     private function get_display_options(\stdClass $config) : int {
402         $export = isset($config->export) ? $config->export : 0;
403         $embed = isset($config->embed) ? $config->embed : 0;
404         $copyright = isset($config->copyright) ? $config->copyright : 0;
405         $frame = ($export || $embed || $copyright);
406         if (!$frame) {
407             $frame = isset($config->frame) ? $config->frame : 0;
408         }
410         $disableoptions = [
411             core::DISPLAY_OPTION_FRAME     => $frame,
412             core::DISPLAY_OPTION_DOWNLOAD  => $export,
413             core::DISPLAY_OPTION_EMBED     => $embed,
414             core::DISPLAY_OPTION_COPYRIGHT => $copyright,
415         ];
417         return $this->core->getStorableDisplayOptions($disableoptions, 0);
418     }
420     /**
421      * Delete an H5P package.
422      *
423      * @param stdClass $content The H5P package to delete.
424      */
425     private function delete_h5p(\stdClass $content) {
426         $h5pstorage = $this->factory->get_storage();
427         // Add an empty slug to the content if it's not defined, because the H5P library requires this field exists.
428         // It's not used when deleting a package, so the real slug value is not required at this point.
429         $content->slug = $content->slug ?? '';
430         $h5pstorage->deletePackage( (array) $content);
431     }
433     /**
434      * Export path for settings
435      *
436      * @param bool $downloadenabled Whether the option to export the H5P content is enabled.
437      *
438      * @return string The URL of the exported file.
439      */
440     private function get_export_settings(bool $downloadenabled) : string {
442         if ( ! $downloadenabled) {
443             return '';
444         }
446         $systemcontext = \context_system::instance();
447         $slug = $this->content['slug'] ? $this->content['slug'] . '-' : '';
448         $url  = \moodle_url::make_pluginfile_url(
449             $systemcontext->id,
450             \core_h5p\file_storage::COMPONENT,
451             \core_h5p\file_storage::EXPORT_FILEAREA,
452             '',
453             '',
454             "{$slug}{$this->content['id']}.h5p"
455         );
457         return $url->out();
458     }
460     /**
461      * Get a query string with the theme revision number to include at the end
462      * of URLs. This is used to force the browser to reload the asset when the
463      * theme caches are cleared.
464      *
465      * @return string
466      */
467     private function get_cache_buster() : string {
468         global $CFG;
469         return '?ver=' . $CFG->themerev;
470     }
472     /**
473      * Get the identifier for the H5P content, to be used in the arrays as index.
474      *
475      * @return string The identifier.
476      */
477     private function get_cid() : string {
478         return 'cid-' . $this->h5pid;
479     }
481     /**
482      * Get the core H5P assets, including all core H5P JavaScript and CSS.
483      *
484      * @return Array core H5P assets.
485      */
486     private function get_assets() : array {
487         global $CFG;
489         // Get core settings.
490         $settings = $this->get_core_settings();
491         $settings['core'] = [
492           'styles' => [],
493           'scripts' => []
494         ];
495         $settings['loadedJs'] = [];
496         $settings['loadedCss'] = [];
498         // Make sure files are reloaded for each plugin update.
499         $cachebuster = $this->get_cache_buster();
501         // Use relative URL to support both http and https.
502         $liburl = $CFG->wwwroot . '/lib/h5p/';
503         $relpath = '/' . preg_replace('/^[^:]+:\/\/[^\/]+\//', '', $liburl);
505         // Add core stylesheets.
506         foreach (core::$styles as $style) {
507             $settings['core']['styles'][] = $relpath . $style . $cachebuster;
508             $this->cssrequires[] = new \moodle_url($liburl . $style . $cachebuster);
509         }
510         // Add core JavaScript.
511         foreach (core::get_scripts() as $script) {
512             $settings['core']['scripts'][] = $script->out(false);
513             $this->jsrequires[] = $script;
514         }
516         $cid = $this->get_cid();
517         // The filterParameters function should be called before getting the dependencyfiles because it rebuild content
518         // dependency cache and export file.
519         $settings['contents'][$cid]['jsonContent'] = $this->core->filterParameters($this->content);
521         $files = $this->get_dependency_files();
522         if ($this->embedtype === 'div') {
523             $systemcontext = \context_system::instance();
524             $h5ppath = "/pluginfile.php/{$systemcontext->id}/core_h5p";
526             // Schedule JavaScripts for loading through Moodle.
527             foreach ($files['scripts'] as $script) {
528                 $url = $script->path . $script->version;
530                 // Add URL prefix if not external.
531                 $isexternal = strpos($script->path, '://');
532                 if ($isexternal === false) {
533                     $url = $h5ppath . $url;
534                 }
535                 $settings['loadedJs'][] = $url;
536                 $this->jsrequires[] = new \moodle_url($isexternal ? $url : $CFG->wwwroot . $url);
537             }
539             // Schedule stylesheets for loading through Moodle.
540             foreach ($files['styles'] as $style) {
541                 $url = $style->path . $style->version;
543                 // Add URL prefix if not external.
544                 $isexternal = strpos($style->path, '://');
545                 if ($isexternal === false) {
546                     $url = $h5ppath . $url;
547                 }
548                 $settings['loadedCss'][] = $url;
549                 $this->cssrequires[] = new \moodle_url($isexternal ? $url : $CFG->wwwroot . $url);
550             }
552         } else {
553             // JavaScripts and stylesheets will be loaded through h5p.js.
554             $settings['contents'][$cid]['scripts'] = $this->core->getAssetsUrls($files['scripts']);
555             $settings['contents'][$cid]['styles']  = $this->core->getAssetsUrls($files['styles']);
556         }
557         return $settings;
558     }
560     /**
561      * Get the settings needed by the H5P library.
562      *
563      * @return array The settings.
564      */
565     private function get_core_settings() : array {
566         global $CFG;
568         $basepath = $CFG->wwwroot . '/';
569         $systemcontext = \context_system::instance();
571         // Generate AJAX paths.
572         $ajaxpaths = [];
573         $ajaxpaths['xAPIResult'] = '';
574         $ajaxpaths['contentUserData'] = '';
576         $settings = array(
577             'baseUrl' => $basepath,
578             'url' => "{$basepath}pluginfile.php/{$systemcontext->instanceid}/core_h5p",
579             'urlLibraries' => "{$basepath}pluginfile.php/{$systemcontext->id}/core_h5p/libraries",
580             'postUserStatistics' => false,
581             'ajax' => $ajaxpaths,
582             'saveFreq' => false,
583             'siteUrl' => $CFG->wwwroot,
584             'l10n' => array('H5P' => $this->core->getLocalization()),
585             'user' => [],
586             'hubIsEnabled' => false,
587             'reportingIsEnabled' => false,
588             'crossorigin' => null,
589             'libraryConfig' => $this->core->h5pF->getLibraryConfig(),
590             'pluginCacheBuster' => $this->get_cache_buster(),
591             'libraryUrl' => $basepath . 'lib/h5p/js',
592             'moodleLibraryPaths' => $this->core->get_dependency_roots($this->h5pid),
593         );
595         return $settings;
596     }
598     /**
599      * Finds library dependencies of view
600      *
601      * @return array Files that the view has dependencies to
602      */
603     private function get_dependency_files() : array {
604         $preloadeddeps = $this->core->loadContentDependencies($this->h5pid, 'preloaded');
605         $files = $this->core->getDependenciesFiles($preloadeddeps);
607         return $files;
608     }
610     /**
611      * Resizing script for settings
612      *
613      * @return string The HTML code with the resize script.
614      */
615     private function get_resize_code() : string {
616         global $OUTPUT;
618         $template = new \stdClass();
619         $template->resizeurl = new \moodle_url('/lib/h5p/js/h5p-resizer.js');
621         return $OUTPUT->render_from_template('core_h5p/h5presize', $template);
622     }
624     /**
625      * Embed code for settings
626      *
627      * @param string $url The URL of the .h5p file.
628      * @param bool $embedenabled Whether the option to embed the H5P content is enabled.
629      *
630      * @return string The HTML code to reuse this H5P content in a different place.
631      */
632     private function get_embed_code(string $url, bool $embedenabled) : string {
633         global $OUTPUT;
635         if ( ! $embedenabled) {
636             return '';
637         }
639         $template = new \stdClass();
640         $template->embedurl = self::get_embed_url($url)->out();
642         return $OUTPUT->render_from_template('core_h5p/h5pembed', $template);
643     }
645     /**
646      * Get the encoded URL for embeding this H5P content.
647      * @param  string $url The URL of the .h5p file.
648      *
649      * @return \moodle_url The embed URL.
650      */
651     public static function get_embed_url(string $url) : \moodle_url {
652         return new \moodle_url('/h5p/embed.php', ['url' => $url]);
653     }