From f1ac735abf2d2f0f8f08ca7b4a19151c2a697af4 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 16 Dec 2020 14:28:59 +0100 Subject: [PATCH] MOBILE-3666 h5p: Implement services and classes --- src/core/classes/sqlitedb.ts | 2 +- src/core/features/features.module.ts | 2 + .../features/h5p/classes/content-validator.ts | 1392 +++++++++++++++++ src/core/features/h5p/classes/core.ts | 1005 ++++++++++++ src/core/features/h5p/classes/file-storage.ts | 475 ++++++ src/core/features/h5p/classes/framework.ts | 917 +++++++++++ src/core/features/h5p/classes/helper.ts | 255 +++ src/core/features/h5p/classes/metadata.ts | 41 + src/core/features/h5p/classes/player.ts | 420 +++++ src/core/features/h5p/classes/storage.ts | 233 +++ src/core/features/h5p/classes/validator.ts | 328 ++++ src/core/features/h5p/h5p.module.ts | 51 + .../features/h5p/services/database/h5p.ts | 308 ++++ src/core/features/h5p/services/h5p.ts | 248 +++ src/core/services/file.ts | 43 +- src/core/services/ws.ts | 8 +- 16 files changed, 5712 insertions(+), 16 deletions(-) create mode 100644 src/core/features/h5p/classes/content-validator.ts create mode 100644 src/core/features/h5p/classes/core.ts create mode 100644 src/core/features/h5p/classes/file-storage.ts create mode 100644 src/core/features/h5p/classes/framework.ts create mode 100644 src/core/features/h5p/classes/helper.ts create mode 100644 src/core/features/h5p/classes/metadata.ts create mode 100644 src/core/features/h5p/classes/player.ts create mode 100644 src/core/features/h5p/classes/storage.ts create mode 100644 src/core/features/h5p/classes/validator.ts create mode 100644 src/core/features/h5p/h5p.module.ts create mode 100644 src/core/features/h5p/services/database/h5p.ts create mode 100644 src/core/features/h5p/services/h5p.ts diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 038b37967..5ee68de65 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -1087,7 +1087,7 @@ export class SQLiteDB { } export type SQLiteDBRecordValues = { - [key in string ]: SQLiteDBRecordValue | undefined; + [key in string ]: SQLiteDBRecordValue | undefined | null; }; export type SQLiteDBQueryParams = { diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index b3d5c06e5..57ed4343e 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -18,6 +18,7 @@ import { CoreCourseModule } from './course/course.module'; import { CoreCoursesModule } from './courses/courses.module'; import { CoreEmulatorModule } from './emulator/emulator.module'; import { CoreFileUploaderModule } from './fileuploader/fileuploader.module'; +import { CoreH5PModule } from './h5p/h5p.module'; import { CoreLoginModule } from './login/login.module'; import { CoreMainMenuModule } from './mainmenu/mainmenu.module'; import { CoreSettingsModule } from './settings/settings.module'; @@ -41,6 +42,7 @@ import { CoreXAPIModule } from './xapi/xapi.module'; CoreUserModule, CorePushNotificationsModule, CoreXAPIModule, + CoreH5PModule, ], }) export class CoreFeaturesModule {} diff --git a/src/core/features/h5p/classes/content-validator.ts b/src/core/features/h5p/classes/content-validator.ts new file mode 100644 index 000000000..a65c8f4f3 --- /dev/null +++ b/src/core/features/h5p/classes/content-validator.ts @@ -0,0 +1,1392 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { Translate } from '@singletons'; +import { CoreH5PCore, CoreH5PLibraryData, CoreH5PLibraryAddonData, CoreH5PContentDepsTreeDependency } from './core'; + +const ALLOWED_STYLEABLE_TAGS = ['span', 'p', 'div', 'h1', 'h2', 'h3', 'td']; + +/** + * Equivalent to H5P's H5PContentValidator, but without some of the validations. + * It's also used to build the dependency list. + */ +export class CoreH5PContentValidator { + + protected typeMap = { + text: 'validateText', + number: 'validateNumber', // eslint-disable-line id-blacklist + boolean: 'validateBoolean', // eslint-disable-line id-blacklist + list: 'validateList', + group: 'validateGroup', + file: 'validateFile', + image: 'validateImage', + video: 'validateVideo', + audio: 'validateAudio', + select: 'validateSelect', + library: 'validateLibrary', + }; + + protected nextWeight = 1; + protected libraries: {[libString: string]: CoreH5PLibraryData} = {}; + protected dependencies: {[key: string]: CoreH5PContentDepsTreeDependency} = {}; + protected relativePathRegExp = /^((\.\.\/){1,2})(.*content\/)?(\d+|editor)\/(.+)$/; + protected allowedHtml: {[tag: string]: string} = {}; + protected allowedStyles?: RegExp[]; + protected metadataSemantics?: CoreH5PSemantics[]; + protected copyrightSemantics?: CoreH5PSemantics; + + constructor(protected siteId: string) { } + + /** + * Add Addon library. + * + * @param library The addon library to add. + * @return Promise resolved when done. + */ + async addon(library: CoreH5PLibraryAddonData): Promise { + const depKey = 'preloaded-' + library.machineName; + + this.dependencies[depKey] = { + library: library, + type: 'preloaded', + }; + + this.nextWeight = await CoreH5P.instance.h5pCore.findLibraryDependencies(this.dependencies, library, this.nextWeight); + + this.dependencies[depKey].weight = this.nextWeight++; + } + + /** + * Get the flat dependency tree. + * + * @return Dependencies. + */ + getDependencies(): {[key: string]: CoreH5PContentDepsTreeDependency} { + return this.dependencies; + } + + /** + * Validate metadata + * + * @param metadata Metadata. + * @return Promise resolved with metadata validated & filtered. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validateMetadata(metadata: any): Promise { + const semantics = this.getMetadataSemantics(); + const group = CoreUtils.instance.clone(metadata || {}); + + // Stop complaining about "invalid selected option in select" for old content without license chosen. + if (typeof group.license == 'undefined') { + group.license = 'U'; + } + + return this.validateGroup(group, { type: 'group', fields: semantics }, false); + } + + /** + * Validate given text value against text semantics. + * + * @param text Text to validate. + * @param semantics Semantics. + * @return Validated text. + */ + validateText(text: string, semantics: CoreH5PSemantics): string { + if (typeof text != 'string') { + text = ''; + } + + if (semantics.tags) { + // Not testing for empty array allows us to use the 4 defaults without specifying them in semantics. + let tags = ['div', 'span', 'p', 'br'].concat(semantics.tags); + + // Add related tags for table etc. + if (tags.indexOf('table') != -1) { + tags = tags.concat(['tr', 'td', 'th', 'colgroup', 'thead', 'tbody', 'tfoot']); + } + if (tags.indexOf('b') != -1) { + tags.push('strong'); + } + if (tags.indexOf('i') != -1) { + tags.push('em'); + } + if (tags.indexOf('ul') != -1 || tags.indexOf('ol') != -1) { + tags.push('li'); + } + if (tags.indexOf('del') != -1 || tags.indexOf('strike') != -1) { + tags.push('s'); + } + + tags = CoreUtils.instance.uniqueArray(tags); + + // Determine allowed style tags + const stylePatterns: RegExp[] = []; + // All styles must be start to end patterns (^...$) + if (semantics.font) { + if (semantics.font.size) { + stylePatterns.push(/^font-size: *[0-9.]+(em|px|%) *;?$/i); + } + if (semantics.font.family) { + stylePatterns.push(/^font-family: *[-a-z0-9," ]+;?$/i); + } + if (semantics.font.color) { + stylePatterns.push(/^color: *(#[a-f0-9]{3}[a-f0-9]{3}?|rgba?\([0-9, ]+\)) *;?$/i); + } + if (semantics.font.background) { + stylePatterns.push(/^background-color: *(#[a-f0-9]{3}[a-f0-9]{3}?|rgba?\([0-9, ]+\)) *;?$/i); + } + if (semantics.font.spacing) { + stylePatterns.push(/^letter-spacing: *[0-9.]+(em|px|%) *;?$/i); + } + if (semantics.font.height) { + stylePatterns.push(/^line-height: *[0-9.]+(em|px|%|) *;?$/i); + } + } + + // Alignment is allowed for all wysiwyg texts + stylePatterns.push(/^text-align: *(center|left|right);?$/i); + + // Strip invalid HTML tags. + text = this.filterXss(text, tags, stylePatterns); + } else { + // Filter text to plain text. + text = CoreTextUtils.instance.escapeHTML(text, false); + } + + // Check if string is within allowed length. + if (typeof semantics.maxLength != 'undefined') { + text = text.substr(0, semantics.maxLength); + } + + return text; + } + + /** + * Validates content files + * + * @param contentPath The path containing content files to validate. + * @param isLibrary Whether it's a library. + * @return True if all files are valid. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + validateContentFiles(contentPath: string, isLibrary: boolean = false): boolean { + // Nothing to do, already checked by Moodle. + return true; + } + + /** + * Validate given value against number semantics. + * + * @param num Number to validate. + * @param semantics Semantics. + * @return Validated number. + */ + validateNumber(value: unknown, semantics: CoreH5PSemantics): number { + // Validate that num is indeed a number. + let num = Number(value); + if (isNaN(num)) { + num = 0; + } + // Check if number is within valid bounds. Move within bounds if not. + if (typeof semantics.min != 'undefined' && num < semantics.min) { + num = semantics.min; + } + if (typeof semantics.max != 'undefined' && num > semantics.max) { + num = semantics.max; + } + // Check if number is within allowed bounds even if step value is set. + if (typeof semantics.step != 'undefined') { + const testNumber = num - (typeof semantics.min != 'undefined' ? semantics.min : 0); + const rest = testNumber % semantics.step; + if (rest !== 0) { + num -= rest; + } + } + // Check if number has proper number of decimals. + if (typeof semantics.decimals != 'undefined') { + num = Number(num.toFixed(semantics.decimals)); + } + + return num; + } + + /** + * Validate given value against boolean semantics. + * + * @param bool Boolean to check. + * @return Validated bool. + */ + validateBoolean(bool: unknown): boolean { + return !!bool; + } + + /** + * Validate select values. + * + * @param select Values to validate. + * @param semantics Semantics. + * @return Validated select. + */ + validateSelect(select: string | string[], semantics: CoreH5PSemantics): string | string[] { + const optional = semantics.optional; + const options: Record = {}; + let strict = false; + + if (semantics.options && semantics.options.length) { + // We have a strict set of options to choose from. + strict = true; + + semantics.options.forEach((option: OptionSemantics) => { + // Support optgroup - just flatten options into one. + if (option.type == 'optgroup') { + option.options?.forEach((subOption) => { + options[subOption.value || ''] = true; + }); + } else if (option.value) { + options[option.value] = true; + } + }); + } + + if (semantics.multiple && Array.isArray(select)) { + // Multi-choice generates array of values. Test each one against valid options, if we are strict. + for (const key in select) { + const value = select[key]; + + if (strict && !optional && !options[value]) { + delete select[key]; + } else { + select[key] = CoreTextUtils.instance.escapeHTML(value, false); + } + } + } else { + // Single mode. If we get an array in here, we chop off the first element and use that instead. + if (Array.isArray(select)) { + select = select[0]; + } + + if (strict && !optional && !options[select]) { + select = ( semantics.options![0]).value || ''; + } + select = CoreTextUtils.instance.escapeHTML(select, false); + } + + return select; + } + + /** + * Validate given list value against list semantics. + * Will recurse into validating each item in the list according to the type. + * + * @param list List to validate. + * @param semantics Semantics. + * @return Validated list. + */ + async validateList(list: Record | unknown[], semantics: CoreH5PSemantics): Promise { + const field = semantics.field!; + const validateFunction = this[this.typeMap[field.type || '']].bind(this); + const isArray = Array.isArray(list); + let keys = Object.keys(list); + + // Check that list is not longer than allowed length. + if (typeof semantics.max != 'undefined') { + keys = keys.slice(0, semantics.max); + } + + // Validate each element in list. + for (const i in keys) { + const key = keys[i]; + const keyNumber = parseInt(key, 10); + + if (isNaN(keyNumber)) { + // It's an object and the key isn't an integer. Delete it. + delete list[key]; + } else { + const val = await validateFunction(list[keyNumber], field); + + if (val === null) { + if (isArray) { + ( list).splice(keyNumber, 1); + } else { + delete list[key]; + } + } else { + list[keyNumber] = val; + } + } + } + + if (!isArray) { + list = CoreUtils.instance.objectToArray(> list); + } + + if (!list.length) { + return null; + } + + return list; + } + + /** + * Validate a file like object, such as video, image, audio and file. + * + * @param file File to validate. + * @param semantics Semantics. + * @param typeValidKeys List of valid keys. + * @return Promise resolved with the validated file. + */ + protected async validateFilelike(file: FileLike, semantics: CoreH5PSemantics, typeValidKeys: string[] = []): Promise { + // Do not allow to use files from other content folders. + const matches = file.path.match(this.relativePathRegExp); + if (matches && matches.length) { + file.path = matches[5]; + } + + // Remove temporary files suffix. + if (file.path.substr(-4, 4) === '#tmp') { + file.path = file.path.substr(0, file.path.length - 4); + } + + // Make sure path and mime does not have any special chars + file.path = CoreTextUtils.instance.escapeHTML(file.path, false); + if (file.mime) { + file.mime = CoreTextUtils.instance.escapeHTML(file.mime, false); + } + + // Remove attributes that should not exist, they may contain JSON escape code. + let validKeys = ['path', 'mime', 'copyright'].concat(typeValidKeys); + if (semantics.extraAttributes) { + validKeys = validKeys.concat(semantics.extraAttributes); + } + validKeys = CoreUtils.instance.uniqueArray(validKeys); + + this.filterParams(file, validKeys); + + if (typeof file.width == 'string') { + file.width = parseInt(file.width, 10); + } + + if (typeof file.height == 'string') { + file.height = parseInt(file.height, 10); + } + + if (file.codecs) { + file.codecs = CoreTextUtils.instance.escapeHTML(file.codecs, false); + } + + if (typeof file.bitrate == 'string') { + file.bitrate = parseInt(file.bitrate, 10); + } + + if (typeof file.quality != 'undefined') { + if (file.quality === null || typeof file.quality.level == 'undefined' || typeof file.quality.label == 'undefined') { + delete file.quality; + } else { + this.filterParams(file.quality, ['level', 'label']); + file.quality.level = Number(file.quality.level); + file.quality.label = CoreTextUtils.instance.escapeHTML(file.quality.label, false); + } + } + + if (typeof file.copyright != 'undefined') { + await this.validateGroup(file.copyright, this.getCopyrightSemantics()); + } + + return file; + } + + /** + * Validate given file data. + * + * @param file File. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateFile(file: FileLike, semantics: CoreH5PSemantics): Promise { + return this.validateFilelike(file, semantics); + } + + /** + * Validate given image data. + * + * @param image Image. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateImage(image: FileLike, semantics: CoreH5PSemantics): Promise { + return this.validateFilelike(image, semantics, ['width', 'height', 'originalImage']); + } + + /** + * Validate given video data. + * + * @param video Video. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + async validateVideo(video: Record, semantics: CoreH5PSemantics): Promise> { + for (const key in video) { + await this.validateFilelike(video[key], semantics, ['width', 'height', 'codecs', 'quality', 'bitrate']); + } + + return video; + } + + /** + * Validate given audio data. + * + * @param audio Audio. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + async validateAudio(audio: Record, semantics: CoreH5PSemantics): Promise> { + for (const key in audio) { + await this.validateFilelike(audio[key], semantics); + } + + return audio; + } + + /** + * Validate given group value against group semantics. + * Will recurse into validating each group member. + * + * @param group Group. + * @param semantics Semantics. + * @param flatten Whether to flatten. + * @return Promise resolved when done. + */ + async validateGroup(group: unknown, semantics: CoreH5PSemantics, flatten: boolean = true): Promise { + if (!semantics.fields) { + return group; + } + + // Groups with just one field are compressed in the editor to only output the child content. + const isSubContent = semantics.isSubContent === true; + + if (semantics.fields.length == 1 && flatten && !isSubContent) { + const field = semantics.fields[0]; + const validateFunction = this[this.typeMap[field.type || '']].bind(this); + + return validateFunction(group, field); + } else { + const groupObject = > group; + + for (const key in groupObject) { + // If subContentId is set, keep value + if (isSubContent && key == 'subContentId') { + continue; + } + + // Find semantics for name=key. + let found = false; + let validateFunction: undefined | ((...args: unknown[]) => unknown); + let field: CoreH5PSemantics | undefined; + + for (let i = 0; i < semantics.fields.length; i++) { + field = semantics.fields[i]; + + if (field.name == key) { + if (semantics.optional) { + field.optional = true; + } + validateFunction = this[this.typeMap[field.type || '']].bind(this); + found = true; + break; + } + } + + if (found && validateFunction) { + const val = await validateFunction(groupObject[key], field); + + groupObject[key] = val; + if (val === null) { + delete groupObject[key]; + } + } else { + // Something exists in content that does not have a corresponding semantics field. Remove it. + delete groupObject.key; + } + } + + return groupObject; + } + } + + /** + * Validate given library value against library semantics. + * Check if provided library is within allowed options. + * Will recurse into validating the library's semantics too. + * + * @param value Value. + * @param semantics Semantics. + * @return Promise resolved when done. + */ + async validateLibrary(value: LibraryType, semantics: CoreH5PSemantics): Promise { + if (!value.library) { + return; + } + + if (!this.libraries[value.library]) { + // Load the library and store it in the index of libraries. + const libSpec = CoreH5PCore.libraryFromString(value.library); + + this.libraries[value.library] = await CoreH5P.instance.h5pCore.loadLibrary( + libSpec?.machineName || '', + libSpec?.majorVersion || 0, + libSpec?.minorVersion || 0, + this.siteId, + ); + } + + const library = this.libraries[value.library]; + + // Validate parameters. + value.params = await this.validateGroup(value.params, { type: 'group', fields: library.semantics }, false); + + // Validate subcontent's metadata + if (value.metadata) { + value.metadata = await this.validateMetadata(value.metadata); + } + + let validKeys = ['library', 'params', 'subContentId', 'metadata']; + if (semantics.extraAttributes) { + validKeys = CoreUtils.instance.uniqueArray(validKeys.concat(semantics.extraAttributes)); + } + + this.filterParams(value, validKeys); + + if (value.subContentId && + !value.subContentId.match(/^\{?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\}?$/)) { + delete value.subContentId; + } + + // Find all dependencies for this library. + const depKey = 'preloaded-' + library.machineName; + if (!this.dependencies[depKey]) { + this.dependencies[depKey] = { + library: library, + type: 'preloaded', + }; + + this.nextWeight = await CoreH5P.instance.h5pCore.findLibraryDependencies(this.dependencies, library, this.nextWeight); + + this.dependencies[depKey].weight = this.nextWeight++; + + return value; + } else { + return value; + } + } + + /** + * Check params for a whitelist of allowed properties. + * + * @param params Object to filter. + * @param whitelist List of keys to keep. + */ + filterParams(params: Record, whitelist: string[]): void { + for (const key in params) { + if (whitelist.indexOf(key) == -1) { + delete params[key]; + } + } + } + + /** + * Filters HTML to prevent cross-site-scripting (XSS) vulnerabilities. + * Based on kses by Ulf Harnhammar, see http://sourceforge.net/projects/kses. + * + * @param text The string with raw HTML in it. + * @param allowedTags An array of allowed tags. + * @param allowedStyles Allowed styles. + * @return An XSS safe version of the string. + */ + protected filterXss(text: string, allowedTags?: string[], allowedStyles?: RegExp[]): string { + if (!text || typeof text != 'string') { + return text; + } + + allowedTags = allowedTags || ['a', 'em', 'strong', 'cite', 'blockquote', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd']; + + this.allowedStyles = allowedStyles; + + // Store the text format. + this.filterXssSplit(allowedTags, true); + + // Remove Netscape 4 JS entities. + text = text.replace(/&\s*\{[^}]*(\}\s*;?|$)/g, ''); + + // Defuse all HTML entities. + text = text.replace(/&/g, '&'); + + // Change back only well-formed entities in our whitelist: + // Decimal numeric entities. + text = text.replace(/&#([0-9]+;)/g, '&#$1'); + // Hexadecimal numeric entities. + text = text.replace(/&#[Xx]0*((?:[0-9A-Fa-f]{2})+;)/g, '&#x$1'); + // Named entities. + text = text.replace(/&([A-Za-z][A-Za-z0-9]*;)/g, '&$1'); + + const matches = text.match(/(<(?=[^a-zA-Z!/])||<[^>]*(>|$)|>)/g); + if (matches && matches.length) { + matches.forEach((match) => { + text = text.replace(match, this.filterXssSplit([match])); + }); + } + + return text; + } + + /** + * Processes an HTML tag. + * + * @param tags An array with various meaning depending on the value of store. + * If store is TRUE then the array contains the allowed tags. + * If store is FALSE then the array has one element, the HTML tag to process. + * @param store Whether to store m. + * @return string If the element isn't allowed, an empty string. Otherwise, the cleaned up version of the HTML element. + */ + protected filterXssSplit(tags: string[], store: boolean = false): string { + if (store) { + this.allowedHtml = CoreUtils.instance.arrayToObject(tags); + + return ''; + } + + const tag = tags[0]; + + if (tag.substr(0, 1) != '<') { + // We matched a lone ">" character. + return '>'; + } else if (tag.length == 1) { + // We matched a lone "<" character. + return '<'; + } + + const matches = tag.match(/^<\s*(\/\s*)?([a-zA-Z0-9-]+)([^>]*)>?|()$/); + if (!matches) { + // Seriously malformed. + return ''; + } + + const slash = matches[1] ? matches[1].trim() : ''; + const attrList = matches[3] || ''; + const comment = matches[4] || ''; + let elem = matches[2] || ''; + + if (comment) { + elem = '!--'; + } + + if (!this.allowedHtml[elem.toLowerCase()]) { + // Disallowed HTML element. + return ''; + } + + if (comment) { + return comment; + } + + if (slash != '') { + return ''; + } + + // Is there a closing XHTML slash at the end of the attributes? + const newAttrList = attrList.replace(/(\s?)\/\s*$/g, '$1'); + const xhtmlSlash = attrList != newAttrList ? ' /' : ''; + + // Clean up attributes. + let attr2 = this.filterXssAttributes( + newAttrList, + ALLOWED_STYLEABLE_TAGS.indexOf(elem) != -1 ? this.allowedStyles : undefined, + ).join(' '); + attr2 = attr2.replace(/[<>]/g, ''); + attr2 = attr2.length ? ' ' + attr2 : ''; + + return '<' + elem + attr2 + xhtmlSlash + '>'; + } + + /** + * Processes a string of HTML attributes. + * + * @param attr HTML attributes. + * @param allowedStyles Allowed styles. + * @return Cleaned up version of the HTML attributes. + */ + protected filterXssAttributes(attr: string, allowedStyles?: RegExp[]): string[] { + const attrArray: string[] = []; + let mode = 0; + let attrName = ''; + let skip = false; + + while (attr.length != 0) { + // Was the last operation successful? + let working = 0; + let matches: RegExpMatchArray | null = null; + let thisVal: string | undefined; + + switch (mode) { + case 0: + // Attribute name, href for instance. + matches = attr.match(/^([-a-zA-Z]+)/); + if (matches && matches.length > 1) { + attrName = matches[1].toLowerCase(); + skip = (attrName == 'style' || attrName.substr(0, 2) == 'on'); + working = mode = 1; + attr = attr.replace(/^[-a-zA-Z]+/, ''); + } + break; + + case 1: + // Equals sign or valueless ("selected"). + if (attr.match(/^\s*=\s*/)) { + working = 1; + mode = 2; + attr = attr.replace(/^\s*=\s*/, ''); + break; + } + + if (attr.match(/^\s+/)) { + working = 1; + mode = 0; + if (!skip) { + attrArray.push(attrName); + } + attr = attr.replace(/^\s+/, ''); + } + break; + + case 2: + // Attribute value, a URL after href= for instance. + matches = attr.match(/^"([^"]*)"(\s+|$)/); + if (matches && matches.length > 1) { + if (allowedStyles && attrName === 'style') { + // Allow certain styles. + for (let i = 0; i < allowedStyles.length; i++) { + const pattern = allowedStyles[i]; + if (matches[1].match(pattern)) { + // All patterns are start to end patterns, and CKEditor adds one span per style. + attrArray.push('style="' + matches[1] + '"'); + break; + } + } + break; + } + + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArray.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^"[^"]*"(\s+|$)/, ''); + break; + } + + matches = attr.match(/^'([^']*)'(\s+|$)/); + if (matches && matches.length > 1) { + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArray.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^'[^']*'(\s+|$)/, ''); + break; + } + + matches = attr.match(/^([^\s"']+)(\s+|$)/); + if (matches && matches.length > 1) { + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArray.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^([^\s"']+)(\s+|$)/, ''); + } + break; + + default: + } + + if (working == 0) { + // Not well formed; remove and try again. + attr = attr.replace(/^("[^"]*("|$)|'[^']*('|$)||\S)*\s*/, ''); + mode = 0; + } + } + + // The attribute list ends with a valueless attribute like "selected". + if (mode == 1 && !skip) { + attrArray.push(attrName); + } + + return attrArray; + } + + /** + * Processes an HTML attribute value and strips dangerous protocols from URLs. + * + * @param str The string with the attribute value. + * @param decode Whether to decode entities in the str. + * @return Cleaned up and HTML-escaped version of str. + */ + filterXssBadProtocol(str: string, decode: boolean = true): string { + // Get the plain text representation of the attribute value (i.e. its meaning). + if (decode) { + str = CoreTextUtils.instance.decodeHTMLEntities(str); + } + + return CoreTextUtils.instance.escapeHTML(this.stripDangerousProtocols(str), false); + } + + /** + * Strips dangerous protocols (e.g. 'javascript:') from a URI. + * + * @param uri A plain-text URI that might contain dangerous protocols. + * @return A plain-text URI stripped of dangerous protocols. + */ + protected stripDangerousProtocols(uri: string): string { + + const allowedProtocols = { + ftp: true, + http: true, + https: true, + mailto: true, + }; + let before: string | undefined; + + // Iteratively remove any invalid protocol found. + do { + before = uri; + const colonPos = uri.indexOf(':'); + + if (colonPos > 0) { + // We found a colon, possibly a protocol. Verify. + const protocol = uri.substr(0, colonPos); + // If a colon is preceded by a slash, question mark or hash, it cannot possibly be part of the URL scheme. + // This must be a relative URL, which inherits the (safe) protocol of the base document. + if (protocol.match(/[/?#]/)) { + break; + } + // Check if this is a disallowed protocol. + if (!allowedProtocols[protocol.toLowerCase()]) { + uri = uri.substr(colonPos + 1); + } + } + } while (before != uri); + + return uri; + } + + /** + * Get metadata semantics. + * + * @return Semantics. + */ + getMetadataSemantics(): CoreH5PSemantics[] { + + if (this.metadataSemantics) { + return this.metadataSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.metadataSemantics = [ + { + name: 'title', + type: 'text', + label: Translate.instance.instant('core.h5p.title'), + placeholder: 'La Gioconda', + }, + { + name: 'license', + type: 'select', + label: Translate.instance.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: Translate.instance.instant('core.h5p.undisclosed'), + }, + { + type: 'optgroup', + label: Translate.instance.instant('core.h5p.creativecommons'), + options: [ + { + value: 'CC BY', + label: Translate.instance.instant('core.h5p.ccattribution'), + versions: ccVersions, + }, + { + value: 'CC BY-SA', + label: Translate.instance.instant('core.h5p.ccattributionsa'), + versions: ccVersions, + }, + { + value: 'CC BY-ND', + label: Translate.instance.instant('core.h5p.ccattributionnd'), + versions: ccVersions, + }, + { + value: 'CC BY-NC', + label: Translate.instance.instant('core.h5p.ccattributionnc'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-SA', + label: Translate.instance.instant('core.h5p.ccattributionncsa'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-ND', + label: Translate.instance.instant('core.h5p.ccattributionncnd'), + versions: ccVersions, + }, + { + value: 'CC0 1.0', + label: Translate.instance.instant('core.h5p.ccpdd'), + }, + { + value: 'CC PDM', + label: Translate.instance.instant('core.h5p.pdm'), + }, + ], + }, + { + value: 'GNU GPL', + label: Translate.instance.instant('core.h5p.gpl'), + }, + { + value: 'PD', + label: Translate.instance.instant('core.h5p.pd'), + }, + { + value: 'ODC PDDL', + label: Translate.instance.instant('core.h5p.pddl'), + }, + { + value: 'C', + label: Translate.instance.instant('core.h5p.copyrightstring'), + }, + ], + }, + { + name: 'licenseVersion', + type: 'select', + label: Translate.instance.instant('core.h5p.licenseversion'), + options: ccVersions, + optional: true, + }, + { + name: 'yearFrom', + type: 'number', + label: Translate.instance.instant('core.h5p.yearsfrom'), + placeholder: '1991', + min: -9999, + max: 9999, + optional: true, + }, + { + name: 'yearTo', + type: 'number', + label: Translate.instance.instant('core.h5p.yearsto'), + placeholder: '1992', + min: -9999, + max: 9999, + optional: true, + }, + { + name: 'source', + type: 'text', + label: Translate.instance.instant('core.h5p.source'), + placeholder: 'https://', + optional: true, + }, + { + name: 'authors', + type: 'list', + field: { + name: 'author', + type: 'group', + fields: [ + { + label: Translate.instance.instant('core.h5p.authorname'), + name: 'name', + optional: true, + type: 'text', + }, + { + name: 'role', + type: 'select', + label: Translate.instance.instant('core.h5p.authorrole'), + default: 'Author', + options: [ + { + value: 'Author', + label: Translate.instance.instant('core.h5p.author'), + }, + { + value: 'Editor', + label: Translate.instance.instant('core.h5p.editor'), + }, + { + value: 'Licensee', + label: Translate.instance.instant('core.h5p.licensee'), + }, + { + value: 'Originator', + label: Translate.instance.instant('core.h5p.originator'), + }, + ], + }, + ], + }, + }, + { + name: 'licenseExtras', + type: 'text', + widget: 'textarea', + label: Translate.instance.instant('core.h5p.licenseextras'), + optional: true, + description: Translate.instance.instant('core.h5p.additionallicenseinfo'), + }, + { + name: 'changes', + type: 'list', + field: { + name: 'change', + type: 'group', + label: Translate.instance.instant('core.h5p.changelog'), + fields: [ + { + name: 'date', + type: 'text', + label: Translate.instance.instant('core.h5p.date'), + optional: true, + }, + { + name: 'author', + type: 'text', + label: Translate.instance.instant('core.h5p.changedby'), + optional: true, + }, + { + name: 'log', + type: 'text', + widget: 'textarea', + label: Translate.instance.instant('core.h5p.changedescription'), + placeholder: Translate.instance.instant('core.h5p.changeplaceholder'), + optional: true, + }, + ], + }, + }, + { + name: 'authorComments', + type: 'text', + widget: 'textarea', + label: Translate.instance.instant('core.h5p.authorcomments'), + description: Translate.instance.instant('core.h5p.authorcommentsdescription'), + optional: true, + }, + { + name: 'contentType', + type: 'text', + widget: 'none', + }, + { + name: 'defaultLanguage', + type: 'text', + widget: 'none', + }, + ]; + + return this.metadataSemantics!; + } + + /** + * Get copyright semantics. + * + * @return Semantics. + */ + getCopyrightSemantics(): CoreH5PSemantics { + + if (this.copyrightSemantics) { + return this.copyrightSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.copyrightSemantics = { + name: 'copyright', + type: 'group', + label: Translate.instance.instant('core.h5p.copyrightinfo'), + fields: [ + { + name: 'title', + type: 'text', + label: Translate.instance.instant('core.h5p.title'), + placeholder: 'La Gioconda', + optional: true, + }, + { + name: 'author', + type: 'text', + label: Translate.instance.instant('core.h5p.author'), + placeholder: 'Leonardo da Vinci', + optional: true, + }, + { + name: 'year', + type: 'text', + label: Translate.instance.instant('core.h5p.years'), + placeholder: '1503 - 1517', + optional: true, + }, + { + name: 'source', + type: 'text', + label: Translate.instance.instant('core.h5p.source'), + placeholder: 'http://en.wikipedia.org/wiki/Mona_Lisa', + optional: true, + regexp: { + pattern: '^http[s]?://.+', + modifiers: 'i', + }, + }, + { + name: 'license', + type: 'select', + label: Translate.instance.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: Translate.instance.instant('core.h5p.undisclosed'), + }, + { + value: 'CC BY', + label: Translate.instance.instant('core.h5p.ccattribution'), + versions: ccVersions, + }, + { + value: 'CC BY-SA', + label: Translate.instance.instant('core.h5p.ccattributionsa'), + versions: ccVersions, + }, + { + value: 'CC BY-ND', + label: Translate.instance.instant('core.h5p.ccattributionnd'), + versions: ccVersions, + }, + { + value: 'CC BY-NC', + label: Translate.instance.instant('core.h5p.ccattributionnc'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-SA', + label: Translate.instance.instant('core.h5p.ccattributionncsa'), + versions: ccVersions, + }, + { + value: 'CC BY-NC-ND', + label: Translate.instance.instant('core.h5p.ccattributionncnd'), + versions: ccVersions, + }, + { + value: 'GNU GPL', + label: Translate.instance.instant('core.h5p.licenseGPL'), + versions: [ + { + value: 'v3', + label: Translate.instance.instant('core.h5p.licenseV3'), + }, + { + value: 'v2', + label: Translate.instance.instant('core.h5p.licenseV2'), + }, + { + value: 'v1', + label: Translate.instance.instant('core.h5p.licenseV1'), + }, + ], + }, + { + value: 'PD', + label: Translate.instance.instant('core.h5p.pd'), + versions: [ + { + value: '-', + label: '-', + }, + { + value: 'CC0 1.0', + label: Translate.instance.instant('core.h5p.licenseCC010U'), + }, + { + value: 'CC PDM', + label: Translate.instance.instant('core.h5p.pdm'), + }, + ], + }, + { + value: 'C', + label: Translate.instance.instant('core.h5p.copyrightstring'), + }, + ], + }, + { + name: 'version', + type: 'select', + label: Translate.instance.instant('core.h5p.licenseversion'), + options: [], + }, + ], + }; + + return this.copyrightSemantics!; + } + + /** + * Get CC versions for semantics. + * + * @return CC versions. + */ + protected getCCVersions(): VersionSemantics[] { + return [ + { + value: '4.0', + label: Translate.instance.instant('core.h5p.licenseCC40'), + }, + { + value: '3.0', + label: Translate.instance.instant('core.h5p.licenseCC30'), + }, + { + value: '2.5', + label: Translate.instance.instant('core.h5p.licenseCC25'), + }, + { + value: '2.0', + label: Translate.instance.instant('core.h5p.licenseCC20'), + }, + { + value: '1.0', + label: Translate.instance.instant('core.h5p.licenseCC10'), + }, + ]; + } + +} + +/** + * Semantics of each field type. More info in https://h5p.org/semantics + */ +export type CoreH5PSemantics = { + type?: string; + name?: string; + label?: string; + description?: string; + optional?: boolean; + default?: string; + importance?: 'low' | 'medium' | 'high'; + common?: boolean; + widget?: string; + widgets?: { + name: string; + label: string; + }[]; + field?: CoreH5PSemantics; + fields?: CoreH5PSemantics[]; + maxLength?: number; + regexp?: { + pattern: string; + modifiers: string; + }; + enterMode?: 'p' | 'div'; + tags?: string[]; + font?: { + size?: unknown; + family?: unknown; + color?: unknown; + background?: unknown; + spacing?: unknown; + height?: unknown; + }; + min?: number; + max?: number; + step?: number; + decimals?: number; + entity?: string; + isSubContent?: boolean; + expanded?: boolean; + options?: (string | OptionSemantics)[]; + important?: { + description: string; + example: string; + }; + multiple?: boolean; + extraAttributes?: string[]; + placeholder?: string; +}; + +type OptionSemantics = { + value?: string; + label?: string; + type?: string; + options?: OptionSemantics[]; + versions?: VersionSemantics[]; +}; + +type VersionSemantics = { + value: string; + label: string; +}; + +/** + * File like object, such as video, image, audio and file. + */ +type FileLike = { + path: string; + mime?: string; + width?: number | string; + height?: number | string; + codecs?: string; + bitrate?: number | string; + quality?: { + level?: string | number; + label?: string; + }; + copyright?: unknown; +}; + +/** + * Library type. + */ +type LibraryType = { + library: string; + params: unknown; + metadata?: unknown; + subContentId?: string; +}; diff --git a/src/core/features/h5p/classes/core.ts b/src/core/features/h5p/classes/core.ts new file mode 100644 index 000000000..c0d65a380 --- /dev/null +++ b/src/core/features/h5p/classes/core.ts @@ -0,0 +1,1005 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Md5 } from 'ts-md5/dist/md5'; + +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { CoreH5PFileStorage } from './file-storage'; +import { CoreH5PFramework } from './framework'; +import { CoreH5PContentValidator, CoreH5PSemantics } from './content-validator'; +import { Translate } from '@singletons'; +import { CoreH5PContentBeingSaved } from './storage'; +import { CoreH5PLibraryAddTo } from './validator'; + +/** + * Equivalent to H5P's H5PCore class. + */ +export class CoreH5PCore { + + static readonly STYLES = [ + 'styles/h5p.css', + 'styles/h5p-confirmation-dialog.css', + 'styles/h5p-core-button.css', + ]; + + static readonly SCRIPTS = [ + 'js/jquery.js', + 'js/h5p.js', + 'js/h5p-event-dispatcher.js', + 'js/h5p-x-api-event.js', + 'js/h5p-x-api.js', + 'js/h5p-content-type.js', + 'js/h5p-confirmation-dialog.js', + 'js/h5p-action-bar.js', + 'js/request-queue.js', + ]; + + static readonly ADMIN_SCRIPTS = [ + 'js/jquery.js', + 'js/h5p-utils.js', + ]; + + // Disable flags + static readonly DISABLE_NONE = 0; + static readonly DISABLE_FRAME = 1; + static readonly DISABLE_DOWNLOAD = 2; + static readonly DISABLE_EMBED = 4; + static readonly DISABLE_COPYRIGHT = 8; + static readonly DISABLE_ABOUT = 16; + + static readonly DISPLAY_OPTION_FRAME = 'frame'; + static readonly DISPLAY_OPTION_DOWNLOAD = 'export'; + static readonly DISPLAY_OPTION_EMBED = 'embed'; + static readonly DISPLAY_OPTION_COPYRIGHT = 'copyright'; + static readonly DISPLAY_OPTION_ABOUT = 'icon'; + static readonly DISPLAY_OPTION_COPY = 'copy'; + + // Map to slugify characters. + static readonly SLUGIFY_MAP = { + // eslint-disable-next-line @typescript-eslint/naming-convention + æ: 'ae', ø: 'oe', ö: 'o', ó: 'o', ô: 'o', Ò: 'oe', Õ: 'o', Ý: 'o', ý: 'y', ÿ: 'y', ā: 'y', ă: 'a', ą: 'a', œ: 'a', å: 'a', + ä: 'a', á: 'a', à: 'a', â: 'a', ã: 'a', ç: 'c', ć: 'c', ĉ: 'c', ċ: 'c', č: 'c', é: 'e', è: 'e', ê: 'e', ë: 'e', í: 'i', + ì: 'i', î: 'i', ï: 'i', ú: 'u', ñ: 'n', ü: 'u', ù: 'u', û: 'u', ß: 'es', ď: 'd', đ: 'd', ē: 'e', ĕ: 'e', ė: 'e', ę: 'e', + ě: 'e', ĝ: 'g', ğ: 'g', ġ: 'g', ģ: 'g', ĥ: 'h', ħ: 'h', ĩ: 'i', ī: 'i', ĭ: 'i', į: 'i', ı: 'i', ij: 'ij', ĵ: 'j', ķ: 'k', + ĺ: 'l', ļ: 'l', ľ: 'l', ŀ: 'l', ł: 'l', ń: 'n', ņ: 'n', ň: 'n', ʼn: 'n', ō: 'o', ŏ: 'o', ő: 'o', ŕ: 'r', ŗ: 'r', ř: 'r', + ś: 's', ŝ: 's', ş: 's', š: 's', ţ: 't', ť: 't', ŧ: 't', ũ: 'u', ū: 'u', ŭ: 'u', ů: 'u', ű: 'u', ų: 'u', ŵ: 'w', ŷ: 'y', + ź: 'z', ż: 'z', ž: 'z', ſ: 's', ƒ: 'f', ơ: 'o', ư: 'u', ǎ: 'a', ǐ: 'i', ǒ: 'o', ǔ: 'u', ǖ: 'u', ǘ: 'u', ǚ: 'u', ǜ: 'u', + ǻ: 'a', ǽ: 'ae', ǿ: 'oe', + }; + + aggregateAssets = true; + h5pFS: CoreH5PFileStorage; + + constructor(public h5pFramework: CoreH5PFramework) { + this.h5pFS = new CoreH5PFileStorage(); + } + + /** + * Determine the correct embed type to use. + * + * @param Embed type of the content. + * @param Embed type of the main library. + * @return Either 'div' or 'iframe'. + */ + static determineEmbedType(contentEmbedType: string, libraryEmbedTypes: string): string { + // Detect content embed type. + let embedType = contentEmbedType.toLowerCase().indexOf('div') != -1 ? 'div' : 'iframe'; + + if (libraryEmbedTypes) { + // Check that embed type is available for library + const embedTypes = libraryEmbedTypes.toLowerCase(); + + if (embedTypes.indexOf(embedType) == -1) { + // Not available, pick default. + embedType = embedTypes.indexOf('div') != -1 ? 'div' : 'iframe'; + } + } + + return embedType; + } + + /** + * Get the hash of a list of dependencies. + * + * @param dependencies Dependencies. + * @return Hash. + */ + static getDependenciesHash(dependencies: {[machineName: string]: CoreH5PContentDependencyData}): string { + // Build hash of dependencies. + const toHash: string[] = []; + + // Use unique identifier for each library version. + for (const name in dependencies) { + const dep = dependencies[name]; + toHash.push(dep.machineName + '-' + dep.majorVersion + '.' + dep.minorVersion + '.' + dep.patchVersion); + } + + // Sort in case the same dependencies comes in a different order. + toHash.sort((a, b) => a.localeCompare(b)); + + // Calculate hash. + return Md5.hashAsciiStr(toHash.join('')); + } + + /** + * Get core JavaScript files. + * + * @return array The array containg urls of the core JavaScript files: + */ + static getScripts(): string[] { + const libUrl = CoreH5P.instance.h5pCore.h5pFS.getCoreH5PPath(); + const urls: string[] = []; + + CoreH5PCore.SCRIPTS.forEach((script) => { + urls.push(libUrl + script); + }); + + urls.push(CoreTextUtils.instance.concatenatePaths(libUrl, 'moodle/js/h5p_overrides.js')); + + return urls; + } + + /** + * Parses library data from a string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryString On the form {machineName} {majorVersion}.{minorVersion} + * @return Object with keys machineName, majorVersion and minorVersion. Null if string is not parsable. + */ + static libraryFromString(libraryString: string): CoreH5PLibraryBasicData | null { + + const matches = libraryString.match(/^([\w0-9\-.]{1,255})[- ]([0-9]{1,5})\.([0-9]{1,5})$/i); + + if (matches && matches.length >= 4) { + return { + machineName: matches[1], + majorVersion: Number(matches[2]), + minorVersion: Number(matches[3]), + }; + } + + return null; + } + + /** + * Writes library data as string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryData Library data. + * @param folderName Use hyphen instead of space in returned string. + * @return String on the form {machineName} {majorVersion}.{minorVersion}. + */ + static libraryToString(libraryData: CoreH5PLibraryBasicData | CoreH5PContentMainLibraryData, folderName?: boolean): string { + return ('machineName' in libraryData ? libraryData.machineName : libraryData.name) + (folderName ? '-' : ' ') + + libraryData.majorVersion + '.' + libraryData.minorVersion; + } + + /** + * Convert strings of text into simple kebab case slugs. Based on H5PCore::slugify. + * + * @param input The string to slugify. + * @return Slugified text. + */ + static slugify(input: string): string { + input = input || ''; + + input = input.toLowerCase(); + + // Replace common chars. + let newInput = ''; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + newInput += CoreH5PCore.SLUGIFY_MAP[char] || char; + } + + // Replace everything else. + newInput = newInput.replace(/[^a-z0-9]/g, '-'); + + // Prevent double hyphen + newInput = newInput.replace(/-{2,}/g, '-'); + + // Prevent hyphen in beginning or end. + newInput = newInput.replace(/(^-+|-+$)/g, ''); + + // Prevent too long slug. + if (newInput.length > 91) { + newInput = newInput.substr(0, 92); + } + + // Prevent empty slug + if (newInput === '') { + newInput = 'interactive'; + } + + return newInput; + } + + /** + * Filter content run parameters and rebuild content dependency cache. + * + * @param content Content data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filtered params, resolved with null if error. + */ + async filterParameters(content: CoreH5PContentData, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (content.filtered) { + return content.filtered; + } + + if (typeof content.library == 'undefined' || typeof content.params == 'undefined') { + return null; + } + + const params = { + library: CoreH5PCore.libraryToString(content.library), + params: CoreTextUtils.instance.parseJSON(content.params, false), + }; + + if (!params.params) { + return null; + } + + try { + const validator = new CoreH5PContentValidator(siteId); + + // Validate the main library and its dependencies. + await validator.validateLibrary(params, { options: [params.library] }); + + // Handle addons. + const addons = await this.h5pFramework.loadAddons(siteId); + + // Validate addons. + for (const i in addons) { + const addon = addons[i]; + + if (addon.addTo?.content?.types?.length) { + for (let i = 0; i < addon.addTo.content.types.length; i++) { + const type = addon.addTo.content.types[i]; + + if (type && type.text && type.text.regex && this.textAddonMatches(params.params, type.text.regex)) { + await validator.addon(addon); + + // An addon shall only be added once. + break; + } + } + } + } + + // Update content dependencies. + content.dependencies = validator.getDependencies(); + + const paramsStr = JSON.stringify(params.params); + + // Sometimes the parameters are filtered before content has been created + if (content.id) { + // Update library usage. + try { + await this.h5pFramework.deleteLibraryUsage(content.id, siteId); + } catch (error) { + // Ignore errors. + } + + await this.h5pFramework.saveLibraryUsage(content.id, content.dependencies, siteId); + + if (!content.slug) { + content.slug = this.generateContentSlug(content); + } + + // Cache. + await this.h5pFramework.updateContentFields(content.id, { + filtered: paramsStr, + }, siteId); + } + + return paramsStr; + } catch (error) { + return null; + } + } + + /** + * Recursive. Goes through the dependency tree for the given library and + * adds all the dependencies to the given array in a flat format. + * + * @param dependencies Object where to save the dependencies. + * @param library The library to find all dependencies for. + * @param nextWeight An integer determining the order of the libraries when they are loaded. + * @param editor Used internally to force all preloaded sub dependencies of an editor dependency to be editor dependencies. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the next weight. + */ + async findLibraryDependencies( + dependencies: {[key: string]: CoreH5PContentDepsTreeDependency}, + library: CoreH5PLibraryData | CoreH5PLibraryAddonData, + nextWeight: number = 1, + editor: boolean = false, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const types = ['dynamic', 'preloaded', 'editor']; + + for (const i in types) { + let type = types[i]; + const property = type + 'Dependencies'; + + if (!library[property]) { + continue; // Skip, no such dependencies. + } + + if (type === 'preloaded' && editor) { + // All preloaded dependencies of an editor library is set to editor. + type = 'editor'; + } + + for (const j in library[property]) { + const dependency: CoreH5PLibraryBasicData = library[property][j]; + + const dependencyKey = type + '-' + dependency.machineName; + if (dependencies[dependencyKey]) { + continue; // Skip, already have this. + } + + // Get the dependency library data and its subdependencies. + const dependencyLibrary = await this.loadLibrary( + dependency.machineName, + dependency.majorVersion, + dependency.minorVersion, + siteId, + ); + + dependencies[dependencyKey] = { + library: dependencyLibrary, + type: type, + }; + + // Get all its subdependencies. + const weight = await this.findLibraryDependencies( + dependencies, + dependencyLibrary, + nextWeight, + type === 'editor', + siteId, + ); + + nextWeight = weight; + dependencies[dependencyKey].weight = nextWeight++; + } + } + + return nextWeight; + } + + /** + * Validate and fix display options, updating them if needed. + * + * @param displayOptions The display options to validate. + * @param id Package ID. + */ + fixDisplayOptions(displayOptions: CoreH5PDisplayOptions, id: number): CoreH5PDisplayOptions { + displayOptions = displayOptions || {}; + + // Never allow downloading in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = false; + + // Never show the embed option in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] = false; + + if (!this.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_FRAME, true)) { + displayOptions[CoreH5PCore.DISPLAY_OPTION_FRAME] = false; + } else if (this.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_COPYRIGHT, true) == false) { + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = false; + } + + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPY] = this.h5pFramework.hasPermission(CoreH5PPermission.COPY_H5P, id); + + return displayOptions; + } + + /** + * Parses library data from a string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryString On the form {machineName} {majorVersion}.{minorVersion} + * @return Object with keys machineName, majorVersion and minorVersion. Null if string is not parsable. + */ + generateContentSlug(content: CoreH5PContentData): string { + + let slug = CoreH5PCore.slugify(content.title); + let available: boolean | null = null; + + while (!available) { + if (available === false) { + // If not available, add number suffix. + const matches = slug.match(/(.+-)([0-9]+)$/); + if (matches) { + slug = matches[1] + (Number(matches[2]) + 1); + } else { + slug += '-2'; + } + } + + available = this.h5pFramework.isContentSlugAvailable(slug); + } + + return slug; + } + + /** + * Combines path with version. + * + * @param assets List of assets to get their URLs. + * @param assetsFolderPath The path of the folder where the assets are. + * @return List of urls. + */ + getAssetsUrls(assets: CoreH5PDependencyAsset[], assetsFolderPath: string = ''): string[] { + const urls: string[] = []; + + assets.forEach((asset) => { + let url = asset.path; + + // Add URL prefix if not external. + if (asset.path.indexOf('://') == -1 && assetsFolderPath) { + url = CoreTextUtils.instance.concatenatePaths(assetsFolderPath, url); + } + + // Add version if set. + if (asset.version) { + url += asset.version; + } + + urls.push(url); + }); + + return urls; + } + + /** + * Return file paths for all dependencies files. + * + * @param dependencies The dependencies to get the files. + * @param folderName Name of the folder of the content. + * @param prefix Make paths relative to another dir. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + async getDependenciesFiles( + dependencies: {[machineName: string]: CoreH5PContentDependencyData}, + folderName: string, + prefix: string = '', + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Build files list for assets. + const files: CoreH5PDependenciesFiles = { + scripts: [], + styles: [], + }; + + // Avoid caching empty files. + if (!Object.keys(dependencies).length) { + return files; + } + + let cachedAssetsHash: string; + + if (this.aggregateAssets) { + // Get aggregated files for assets. + cachedAssetsHash = CoreH5PCore.getDependenciesHash(dependencies); + + const cachedAssets = await this.h5pFS.getCachedAssets(cachedAssetsHash); + + if (cachedAssets) { + // Cached assets found, return them. + return Object.assign(files, cachedAssets); + } + } + + // No cached assets, use content dependencies. + for (const key in dependencies) { + const dependency = dependencies[key]; + + if (!dependency.path) { + dependency.path = this.h5pFS.getDependencyPath(dependency); + dependency.preloadedJs = ( dependency.preloadedJs).split(','); + dependency.preloadedCss = ( dependency.preloadedCss).split(','); + } + + dependency.version = '?ver=' + dependency.majorVersion + '.' + dependency.minorVersion + '.' + dependency.patchVersion; + + this.getDependencyAssets(dependency, 'preloadedJs', files.scripts, prefix); + this.getDependencyAssets(dependency, 'preloadedCss', files.styles, prefix); + } + + if (this.aggregateAssets) { + // Aggregate and store assets. + await this.h5pFS.cacheAssets(files, cachedAssetsHash!, folderName, siteId); + + // Keep track of which libraries have been cached in case they are updated. + await this.h5pFramework.saveCachedAssets(cachedAssetsHash!, dependencies, folderName, siteId); + } + + return files; + } + + /** + * Get the paths to the content dependencies. + * + * @param id The H5P content ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with an object containing the path of each content dependency. + */ + async getDependencyRoots(id: number, siteId?: string): Promise<{[libString: string]: string}> { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const roots = {}; + + const dependencies = await this.h5pFramework.loadContentDependencies(id, undefined, siteId); + + for (const machineName in dependencies) { + const dependency = dependencies[machineName]; + const folderName = CoreH5PCore.libraryToString(dependency, true); + + roots[folderName] = this.h5pFS.getLibraryFolderPath(dependency, siteId, folderName); + } + + return roots; + } + + /** + * Get all dependency assets of the given type. + * + * @param dependency The dependency. + * @param type Type of assets to get. + * @param assets Array where to store the assets. + * @param prefix Make paths relative to another dir. + */ + protected getDependencyAssets( + dependency: CoreH5PContentDependencyData, + type: string, + assets: CoreH5PDependencyAsset[], + prefix: string = '', + ): void { + + // Check if dependency has any files of this type + if (!dependency[type] || dependency[type][0] === '') { + return; + } + + // Check if we should skip CSS. + if (type === 'preloadedCss' && CoreUtils.instance.isTrueOrOne(dependency.dropCss)) { + return; + } + + for (const key in dependency[type]) { + const file = dependency[type][key]; + + assets.push({ + path: prefix + '/' + dependency.path + '/' + (typeof file != 'string' ? file.path : file).trim(), + version: dependency.version || '', + }); + } + } + + /** + * Convert display options to an object. + * + * @param disable Display options as a number. + * @return Display options as object. + */ + getDisplayOptionsAsObject(disable: number): CoreH5PDisplayOptions { + const displayOptions: CoreH5PDisplayOptions = {}; + + // eslint-disable-next-line no-bitwise + displayOptions[CoreH5PCore.DISPLAY_OPTION_FRAME] = !(disable & CoreH5PCore.DISABLE_FRAME); + displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = false; // Never allow downloading in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] = false; // Never show the embed option in the app. + // eslint-disable-next-line no-bitwise + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = !(disable & CoreH5PCore.DISABLE_COPYRIGHT); + displayOptions[CoreH5PCore.DISPLAY_OPTION_ABOUT] = !!this.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_ABOUT, true); + + return displayOptions; + } + + /** + * Determine display option visibility when viewing H5P + * + * @param disable The display options as a number. + * @param id Package ID. + * @return Display options as object. + */ + getDisplayOptionsForView(disable: number, id: number): CoreH5PDisplayOptions { + return this.fixDisplayOptions(this.getDisplayOptionsAsObject(disable), id); + } + + /** + * Provide localization for the Core JS. + * + * @return Object with the translations. + */ + getLocalization(): {[name: string]: string} { + return { + fullscreen: Translate.instance.instant('core.h5p.fullscreen'), + disableFullscreen: Translate.instance.instant('core.h5p.disablefullscreen'), + download: Translate.instance.instant('core.h5p.download'), + copyrights: Translate.instance.instant('core.h5p.copyright'), + embed: Translate.instance.instant('core.h5p.embed'), + size: Translate.instance.instant('core.h5p.size'), + showAdvanced: Translate.instance.instant('core.h5p.showadvanced'), + hideAdvanced: Translate.instance.instant('core.h5p.hideadvanced'), + advancedHelp: Translate.instance.instant('core.h5p.resizescript'), + copyrightInformation: Translate.instance.instant('core.h5p.copyright'), + close: Translate.instance.instant('core.h5p.close'), + title: Translate.instance.instant('core.h5p.title'), + author: Translate.instance.instant('core.h5p.author'), + year: Translate.instance.instant('core.h5p.year'), + source: Translate.instance.instant('core.h5p.source'), + license: Translate.instance.instant('core.h5p.license'), + thumbnail: Translate.instance.instant('core.h5p.thumbnail'), + noCopyrights: Translate.instance.instant('core.h5p.nocopyright'), + reuse: Translate.instance.instant('core.h5p.reuse'), + reuseContent: Translate.instance.instant('core.h5p.reuseContent'), + reuseDescription: Translate.instance.instant('core.h5p.reuseDescription'), + downloadDescription: Translate.instance.instant('core.h5p.downloadtitle'), + copyrightsDescription: Translate.instance.instant('core.h5p.copyrighttitle'), + embedDescription: Translate.instance.instant('core.h5p.embedtitle'), + h5pDescription: Translate.instance.instant('core.h5p.h5ptitle'), + contentChanged: Translate.instance.instant('core.h5p.contentchanged'), + startingOver: Translate.instance.instant('core.h5p.startingover'), + by: Translate.instance.instant('core.h5p.by'), + showMore: Translate.instance.instant('core.h5p.showmore'), + showLess: Translate.instance.instant('core.h5p.showless'), + subLevel: Translate.instance.instant('core.h5p.sublevel'), + confirmDialogHeader: Translate.instance.instant('core.h5p.confirmdialogheader'), + confirmDialogBody: Translate.instance.instant('core.h5p.confirmdialogbody'), + cancelLabel: Translate.instance.instant('core.h5p.cancellabel'), + confirmLabel: Translate.instance.instant('core.h5p.confirmlabel'), + licenseU: Translate.instance.instant('core.h5p.undisclosed'), + licenseCCBY: Translate.instance.instant('core.h5p.ccattribution'), + licenseCCBYSA: Translate.instance.instant('core.h5p.ccattributionsa'), + licenseCCBYND: Translate.instance.instant('core.h5p.ccattributionnd'), + licenseCCBYNC: Translate.instance.instant('core.h5p.ccattributionnc'), + licenseCCBYNCSA: Translate.instance.instant('core.h5p.ccattributionncsa'), + licenseCCBYNCND: Translate.instance.instant('core.h5p.ccattributionncnd'), + licenseCC40: Translate.instance.instant('core.h5p.licenseCC40'), + licenseCC30: Translate.instance.instant('core.h5p.licenseCC30'), + licenseCC25: Translate.instance.instant('core.h5p.licenseCC25'), + licenseCC20: Translate.instance.instant('core.h5p.licenseCC20'), + licenseCC10: Translate.instance.instant('core.h5p.licenseCC10'), + licenseGPL: Translate.instance.instant('core.h5p.licenseGPL'), + licenseV3: Translate.instance.instant('core.h5p.licenseV3'), + licenseV2: Translate.instance.instant('core.h5p.licenseV2'), + licenseV1: Translate.instance.instant('core.h5p.licenseV1'), + licensePD: Translate.instance.instant('core.h5p.pd'), + licenseCC010: Translate.instance.instant('core.h5p.licenseCC010'), + licensePDM: Translate.instance.instant('core.h5p.pdm'), + licenseC: Translate.instance.instant('core.h5p.copyrightstring'), + contentType: Translate.instance.instant('core.h5p.contenttype'), + licenseExtras: Translate.instance.instant('core.h5p.licenseextras'), + changes: Translate.instance.instant('core.h5p.changelog'), + contentCopied: Translate.instance.instant('core.h5p.contentCopied'), + connectionLost: Translate.instance.instant('core.h5p.connectionLost'), + connectionReestablished: Translate.instance.instant('core.h5p.connectionReestablished'), + resubmitScores: Translate.instance.instant('core.h5p.resubmitScores'), + offlineDialogHeader: Translate.instance.instant('core.h5p.offlineDialogHeader'), + offlineDialogBody: Translate.instance.instant('core.h5p.offlineDialogBody'), + offlineDialogRetryMessage: Translate.instance.instant('core.h5p.offlineDialogRetryMessage'), + offlineDialogRetryButtonLabel: Translate.instance.instant('core.h5p.offlineDialogRetryButtonLabel'), + offlineSuccessfulSubmit: Translate.instance.instant('core.h5p.offlineSuccessfulSubmit'), + }; + } + + /** + * Load content data from DB. + * + * @param id Content ID. + * @param fileUrl H5P file URL. Required if id is not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async loadContent(id?: number, fileUrl?: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const content = await this.h5pFramework.loadContent(id, fileUrl, siteId); + + // Validate metadata. + const validator = new CoreH5PContentValidator(siteId); + + content.metadata = await validator.validateMetadata(content.metadata); + + return { + id: content.id, + params: content.params, + embedType: content.embedType, + disable: content.disable, + folderName: content.folderName, + title: content.title, + slug: content.slug, + filtered: content.filtered, + libraryMajorVersion: content.libraryMajorVersion, + libraryMinorVersion: content.libraryMinorVersion, + metadata: content.metadata, + library: { + id: content.libraryId, + name: content.libraryName, + majorVersion: content.libraryMajorVersion, + minorVersion: content.libraryMinorVersion, + embedTypes: content.libraryEmbedTypes, + fullscreen: content.libraryFullscreen, + }, + }; + } + + /** + * Load dependencies for the given content of the given type. + * + * @param id Content ID. + * @param type The dependency type. + * @return Content dependencies, indexed by machine name. + */ + loadContentDependencies( + id: number, + type?: string, + siteId?: string, + ): Promise<{[machineName: string]: CoreH5PContentDependencyData}> { + return this.h5pFramework.loadContentDependencies(id, type, siteId); + } + + /** + * Loads a library and its dependencies. + * + * @param machineName The library's machine name. + * @param majorVersion The library's major version. + * @param minorVersion The library's minor version. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data. + */ + loadLibrary(machineName: string, majorVersion: number, minorVersion: number, siteId?: string): Promise { + return this.h5pFramework.loadLibrary(machineName, majorVersion, minorVersion, siteId); + } + + /** + * Check if the current user has permission to update and install new libraries. + * + * @return Whether has permissions. + */ + mayUpdateLibraries(): boolean { + // In the app the installation only affects current user, so the user always has permissions. + return true; + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with content ID. + */ + async saveContent(content: CoreH5PContentBeingSaved, folderName: string, fileUrl: string, siteId?: string): Promise { + content.id = await this.h5pFramework.updateContent(content, folderName, fileUrl, siteId); + + // Some user data for content has to be reset when the content changes. + await this.h5pFramework.resetContentUserData(content.id, siteId); + + return content.id; + } + + /** + * Helper function used to figure out embed and download behaviour. + * + * @param optionName The option name. + * @param permission The permission. + * @param id The package ID. + * @param value Default value. + * @return The value to use. + */ + setDisplayOptionOverrides(optionName: string, permission: number, id: number, value: boolean): boolean { + const behaviour = this.h5pFramework.getOption(optionName, CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW); + + // If never show globally, force hide + if (behaviour == CoreH5PDisplayOptionBehaviour.NEVER_SHOW) { + value = false; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW) { + // If always show or permissions say so, force show + value = true; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_PERMISSIONS) { + value = this.h5pFramework.hasPermission(permission, id); + } + + return value; + } + + /** + * Determine if params contain any match. + * + * @param params Parameters. + * @param pattern Regular expression to identify pattern. + * @return True if params matches pattern. + */ + protected textAddonMatches(params: unknown, pattern: string): boolean { + + if (typeof params == 'string') { + if (params.match(pattern)) { + return true; + } + } else if (typeof params == 'object') { + for (const key in params) { + const value = params[key]; + + if (this.textAddonMatches(value, pattern)) { + return true; + } + } + } + + return false; + } + +} + +/** + * Display options behaviour constants. + */ +export enum CoreH5PDisplayOptionBehaviour { + NEVER_SHOW = 0, + CONTROLLED_BY_AUTHOR_DEFAULT_ON = 1, + CONTROLLED_BY_AUTHOR_DEFAULT_OFF = 2, + ALWAYS_SHOW = 3, + CONTROLLED_BY_PERMISSIONS = 4, +} + +/** + * Permission constants. + */ +export enum CoreH5PPermission { + DOWNLOAD_H5P = 0, + EMBED_H5P = 1, + CREATE_RESTRICTED = 2, + UPDATE_LIBRARIES = 3, + INSTALL_RECOMMENDED = 4, + COPY_H5P = 8, +} + +/** + * Display options as object. + */ +export type CoreH5PDisplayOptions = { + frame?: boolean; + export?: boolean; + embed?: boolean; + copyright?: boolean; + icon?: boolean; + copy?: boolean; +}; + +/** + * Dependency asset. + */ +export type CoreH5PDependencyAsset = { + path: string; // Path to the asset. + version: string; // Dependency version. +}; + +/** + * Dependencies files. + */ +export type CoreH5PDependenciesFiles = { + scripts: CoreH5PDependencyAsset[]; // JS scripts. + styles: CoreH5PDependencyAsset[]; // CSS files. +}; + +/** + * Content data, including main library data. + */ +export type CoreH5PContentData = { + id: number; // The id of the content. + params: string; // The content in json format. + embedType: string; // Embed type to use. + disable: number | null; // H5P Button display options. + folderName: string; // Name of the folder that contains the contents. + title: string; // Main library's title. + slug: string; // Lib title and ID slugified. + filtered: string | null; // Filtered version of json_content. + libraryMajorVersion: number; // Main library's major version. + libraryMinorVersion: number; // Main library's minor version. + metadata: unknown; // Content metadata. + library: CoreH5PContentMainLibraryData; // Main library data. + dependencies?: {[key: string]: CoreH5PContentDepsTreeDependency}; // Dependencies. Calculated in filterParameters. +}; + +/** + * Data about main library of a content. + */ +export type CoreH5PContentMainLibraryData = { + id: number; // The id of the library. + name: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + embedTypes: string; // List of supported embed types. + fullscreen: number; // Display fullscreen button. +}; + +/** + * Content dependency data. + */ +export type CoreH5PContentDependencyData = CoreH5PLibraryBasicDataWithPatch & { + libraryId: number; // The id of the library if it is an existing library. + preloadedJs?: string | string[]; // Comma separated string with js file paths. If already parsed, list of paths. + preloadedCss?: string | string[]; // Comma separated string with css file paths. If already parsed, list of paths. + dropCss?: string; // CSV of machine names. + dependencyType: string; // The dependency type. + path?: string; // Path to the dependency. Calculated in getDependenciesFiles. + version?: string; // Version of the dependency. Calculated in getDependenciesFiles. +}; + +/** + * Data for each content dependency in the dependency tree. + */ +export type CoreH5PContentDepsTreeDependency = { + library: CoreH5PLibraryData | CoreH5PLibraryAddonData; // Library data. + type: string; // Dependency type. + weight?: number; // An integer determining the order of the libraries when they are loaded. +}; + +/** + * Library data. + */ +export type CoreH5PLibraryData = CoreH5PLibraryBasicDataWithPatch & { + libraryId: number; // The id of the library. + title: string; // The human readable name of this library. + runnable: number; // Can this library be started by the module? I.e. not a dependency. + fullscreen: number; // Display fullscreen button. + embedTypes: string; // List of supported embed types. + preloadedJs?: string; // Comma separated list of scripts to load. + preloadedCss?: string; // Comma separated list of stylesheets to load. + dropLibraryCss?: string; // List of libraries that should not have CSS included if this library is used. Comma separated list. + semantics?: CoreH5PSemantics[]; // The semantics definition. If it's a string, it's in json format. + preloadedDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + dynamicDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + editorDependencies: CoreH5PLibraryBasicData[]; // Dependencies. +}; + +/** + * Library basic data. + */ +export type CoreH5PLibraryBasicData = { + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. +}; + +/** + * Library basic data including patch version. + */ +export type CoreH5PLibraryBasicDataWithPatch = CoreH5PLibraryBasicData & { + patchVersion: number; // Patch version. +}; + +/** + * "Addon" data (library). + */ +export type CoreH5PLibraryAddonData = CoreH5PLibraryBasicDataWithPatch & { + libraryId: number; // The id of the library. + preloadedJs?: string; // Comma separated list of scripts to load. + preloadedCss?: string; // Comma separated list of stylesheets to load. + addTo?: CoreH5PLibraryAddTo | null; // Plugin configuration data. +}; diff --git a/src/core/features/h5p/classes/file-storage.ts b/src/core/features/h5p/classes/file-storage.ts new file mode 100644 index 000000000..453d8cd52 --- /dev/null +++ b/src/core/features/h5p/classes/file-storage.ts @@ -0,0 +1,475 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { + CoreH5PCore, + CoreH5PDependencyAsset, + CoreH5PContentDependencyData, + CoreH5PDependenciesFiles, + CoreH5PLibraryBasicData, + CoreH5PContentMainLibraryData, +} from './core'; +import { CONTENTS_LIBRARIES_TABLE_NAME, CONTENT_TABLE_NAME, CoreH5PLibraryCachedAssetsDBRecord } from '../services/database/h5p'; +import { CoreH5PLibraryBeingSaved } from './storage'; + +/** + * Equivalent to Moodle's implementation of H5PFileStorage. + */ +export class CoreH5PFileStorage { + + static readonly CACHED_ASSETS_FOLDER_NAME = 'cachedassets'; + + /** + * Will concatenate all JavaScrips and Stylesheets into two files in order to improve page performance. + * + * @param files A set of all the assets required for content to display. + * @param key Hashed key for cached asset. + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Promise resolved when done. + */ + async cacheAssets(files: CoreH5PDependenciesFiles, key: string, folderName: string, siteId: string): Promise { + + const cachedAssetsPath = this.getCachedAssetsFolderPath(folderName, siteId); + + // Treat each type in the assets. + await Promise.all(Object.keys(files).map(async (type) => { + + const assets: CoreH5PDependencyAsset[] = files[type]; + + if (!assets || !assets.length) { + return; + } + + // Create new file for cached assets. + const fileName = key + '.' + (type == 'scripts' ? 'js' : 'css'); + const path = CoreTextUtils.instance.concatenatePaths(cachedAssetsPath, fileName); + + // Store concatenated content. + const content = await this.concatenateFiles(assets, type); + + await CoreFile.instance.writeFile(path, content); + + // Now update the files data. + files[type] = [ + { + path: CoreTextUtils.instance.concatenatePaths(CoreH5PFileStorage.CACHED_ASSETS_FOLDER_NAME, fileName), + version: '', + }, + ]; + })); + } + + /** + * Adds all files of a type into one file. + * + * @param assets A list of files. + * @param type The type of files in assets. Either 'scripts' or 'styles' + * @return Promise resolved with all of the files content in one string. + */ + protected async concatenateFiles(assets: CoreH5PDependencyAsset[], type: string): Promise { + const basePath = CoreFile.instance.convertFileSrc(CoreFile.instance.getBasePathInstant()); + let content = ''; + + for (const i in assets) { + const asset = assets[i]; + + let fileContent = await CoreFile.instance.readFile(asset.path); + + if (type == 'scripts') { + // No need to treat scripts, just append the content. + content += fileContent + ';\n'; + + continue; + } + + // Rewrite relative URLs used inside stylesheets. + const matches = fileContent.match(/url\(['"]?([^"')]+)['"]?\)/ig); + const assetPath = asset.path.replace(/(^\/|\/$)/g, ''); // Path without start/end slashes. + const treated = {}; + + if (matches && matches.length) { + matches.forEach((match) => { + let url = match.replace(/(url\(['"]?|['"]?\)$)/ig, ''); + + if (treated[url] || url.match(/^(data:|([a-z0-9]+:)?\/)/i)) { + return; // Not relative or already treated, skip. + } + + const pathSplit = assetPath.split('/'); + treated[url] = url; + + /* Find "../" in the URL. If it exists, we have to remove "../" and switch the last folder in the + filepath for the first folder in the url. */ + if (url.match(/^\.\.\//)) { + // Split and remove empty values. + const urlSplit = url.split('/').filter((i) => i); + + // Remove the file name from the asset path. + pathSplit.pop(); + + // Remove the first element from the file URL: ../ . + urlSplit.shift(); + + // Put the url's first folder into the asset path. + pathSplit[pathSplit.length - 1] = urlSplit[0]; + urlSplit.shift(); + + // Create the new URL and replace it in the file contents. + url = pathSplit.join('/') + '/' + urlSplit.join('/'); + + } else { + pathSplit[pathSplit.length - 1] = url; // Put the whole path to the end of the asset path. + url = pathSplit.join('/'); + } + + fileContent = fileContent.replace( + new RegExp(CoreTextUtils.instance.escapeForRegex(match), 'g'), + 'url("' + CoreTextUtils.instance.concatenatePaths(basePath, url) + '")', + ); + }); + } + + content += fileContent + '\n'; + } + + return content; + } + + /** + * Delete cached assets from file system. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteCachedAssets(removedEntries: CoreH5PLibraryCachedAssetsDBRecord[], siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const promises: Promise[] = []; + + removedEntries.forEach((entry) => { + const cachedAssetsFolder = this.getCachedAssetsFolderPath(entry.foldername, site.getId()); + + ['js', 'css'].forEach((type) => { + const path = CoreTextUtils.instance.concatenatePaths(cachedAssetsFolder, entry.hash + '.' + type); + + promises.push(CoreFile.instance.removeFile(path)); + }); + }); + + // Ignore errors, maybe there's no cached asset of some type. + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(promises)); + } + + /** + * Deletes a content folder from the file system. + * + * @param folderName Folder name of the content. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentFolder(folderName: string, siteId: string): Promise { + await CoreFile.instance.removeDir(this.getContentFolderPath(folderName, siteId)); + } + + /** + * Delete content indexes from filesystem. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentIndex(folderName: string, siteId: string): Promise { + await CoreFile.instance.removeFile(this.getContentIndexPath(folderName, siteId)); + } + + /** + * Delete content indexes from filesystem. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentIndexesForLibrary(libraryId: number, siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const db = site.getDb(); + + // Get the folder names of all the packages that use this library. + const query = 'SELECT DISTINCT hc.foldername ' + + 'FROM ' + CONTENTS_LIBRARIES_TABLE_NAME + ' hcl ' + + 'JOIN ' + CONTENT_TABLE_NAME + ' hc ON hcl.h5pid = hc.id ' + + 'WHERE hcl.libraryid = ?'; + const queryArgs = [libraryId]; + + const result = await db.execute(query, queryArgs); + + await Array.from(result.rows).map(async (entry: {foldername: string}) => { + try { + // Delete the index.html. + await this.deleteContentIndex(entry.foldername, site.getId()); + } catch (error) { + // Ignore errors. + } + }); + } + + /** + * Deletes a library from the file system. + * + * @param libraryData The library data. + * @param siteId Site ID. + * @param folderName Folder name. If not provided, it will be calculated. + * @return Promise resolved when done. + */ + async deleteLibraryFolder( + libraryData: CoreH5PLibraryBasicData | CoreH5PContentMainLibraryData, + siteId: string, + folderName?: string, + ): Promise { + await CoreFile.instance.removeDir(this.getLibraryFolderPath(libraryData, siteId, folderName)); + } + + /** + * Will check if there are cache assets available for content. + * + * @param key Hashed key for cached asset + * @return Promise resolved with the files. + */ + async getCachedAssets(key: string): Promise<{scripts?: CoreH5PDependencyAsset[]; styles?: CoreH5PDependencyAsset[]} | null> { + + // Get JS and CSS cached assets if they exist. + const results = await Promise.all([ + this.getCachedAsset(key, '.js'), + this.getCachedAsset(key, '.css'), + ]); + + const files = { + scripts: results[0], + styles: results[1], + }; + + return files.scripts || files.styles ? files : null; + } + + /** + * Check if a cached asset file exists and, if so, return its data. + * + * @param key Key of the cached asset. + * @param extension Extension of the file to get. + * @return Promise resolved with the list of assets (only one), undefined if not found. + */ + protected async getCachedAsset(key: string, extension: string): Promise { + + try { + const path = CoreTextUtils.instance.concatenatePaths(CoreH5PFileStorage.CACHED_ASSETS_FOLDER_NAME, key + extension); + + const size = await CoreFile.instance.getFileSize(path); + + if (size > 0) { + return [ + { + path: path, + version: '', + }, + ]; + } + } catch (error) { + // Not found, nothing to do. + } + } + + /** + * Get relative path to a content cached assets. + * + * @param folderName Name of the folder of the content the assets belong to. + * @param siteId Site ID. + * @return Path. + */ + getCachedAssetsFolderPath(folderName: string, siteId: string): string { + return CoreTextUtils.instance.concatenatePaths( + this.getContentFolderPath(folderName, siteId), + CoreH5PFileStorage.CACHED_ASSETS_FOLDER_NAME, + ); + } + + /** + * Get a content folder name given the package URL. + * + * @param fileUrl Package URL. + * @param siteId Site ID. + * @return Promise resolved with the folder name. + */ + async getContentFolderNameByUrl(fileUrl: string, siteId: string): Promise { + const path = await CoreFilepool.instance.getFilePathByUrl(siteId, fileUrl); + + const fileAndDir = CoreFile.instance.getFileAndDirectoryFromPath(path); + + return CoreMimetypeUtils.instance.removeExtension(fileAndDir.name); + } + + /** + * Get a package content path. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentFolderPath(folderName: string, siteId: string): string { + return CoreTextUtils.instance.concatenatePaths( + this.getExternalH5PFolderPath(siteId), + 'packages/' + folderName + '/content', + ); + } + + /** + * Get the content index file. + * + * @param fileUrl URL of the H5P package. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the file URL if exists, rejected otherwise. + */ + async getContentIndexFileUrl(fileUrl: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const folderName = await this.getContentFolderNameByUrl(fileUrl, siteId); + + const file = await CoreFile.instance.getFile(this.getContentIndexPath(folderName, siteId)); + + return file.toURL(); + } + + /** + * Get the path to a content index. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentIndexPath(folderName: string, siteId: string): string { + return CoreTextUtils.instance.concatenatePaths(this.getContentFolderPath(folderName, siteId), 'index.html'); + } + + /** + * Get the path to the folder that contains the H5P core libraries. + * + * @return Folder path. + */ + getCoreH5PPath(): string { + return CoreTextUtils.instance.concatenatePaths(CoreFile.instance.getWWWPath(), '/h5p/'); + } + + /** + * Get the path to the dependency. + * + * @param dependency Dependency library. + * @return The path to the dependency library + */ + getDependencyPath(dependency: CoreH5PContentDependencyData): string { + return 'libraries/' + dependency.machineName + '-' + dependency.majorVersion + '.' + dependency.minorVersion; + } + + /** + * Get path to the folder containing H5P files extracted from packages. + * + * @param siteId The site ID. + * @return Folder path. + */ + getExternalH5PFolderPath(siteId: string): string { + return CoreTextUtils.instance.concatenatePaths(CoreFile.instance.getSiteFolder(siteId), 'h5p'); + } + + /** + * Get libraries folder path. + * + * @param siteId The site ID. + * @return Folder path. + */ + getLibrariesFolderPath(siteId: string): string { + return CoreTextUtils.instance.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'libraries'); + } + + /** + * Get a library's folder path. + * + * @param libraryData The library data. + * @param siteId The site ID. + * @param folderName Folder name. If not provided, it will be calculated. + * @return Folder path. + */ + getLibraryFolderPath( + libraryData: CoreH5PLibraryBasicData | CoreH5PContentMainLibraryData, + siteId: string, + folderName?: string, + ): string { + if (!folderName) { + folderName = CoreH5PCore.libraryToString(libraryData, true); + } + + return CoreTextUtils.instance.concatenatePaths(this.getLibrariesFolderPath(siteId), folderName); + } + + /** + * Save the content in filesystem. + * + * @param contentPath Path to the current content folder (tmp). + * @param folderName Name to put to the content folder. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + async saveContent(contentPath: string, folderName: string, siteId: string): Promise { + const folderPath = this.getContentFolderPath(folderName, siteId); + + // Delete existing content for this package. + await CoreUtils.instance.ignoreErrors(CoreFile.instance.removeDir(folderPath)); + + // Copy the new one. + await CoreFile.instance.moveDir(contentPath, folderPath); + } + + /** + * Save a library in filesystem. + * + * @param libraryData Library data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibrary(libraryData: CoreH5PLibraryBeingSaved, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const folderPath = this.getLibraryFolderPath(libraryData, siteId); + + // Delete existing library version. + try { + await CoreFile.instance.removeDir(folderPath); + } catch (error) { + // Ignore errors, maybe it doesn't exist. + } + + if (libraryData.uploadDirectory) { + // Copy the new one. + await CoreFile.instance.moveDir(libraryData.uploadDirectory, folderPath, true); + } + } + +} diff --git a/src/core/features/h5p/classes/framework.ts b/src/core/features/h5p/classes/framework.ts new file mode 100644 index 000000000..47cdea7ae --- /dev/null +++ b/src/core/features/h5p/classes/framework.ts @@ -0,0 +1,917 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { + CoreH5PCore, + CoreH5PDisplayOptionBehaviour, + CoreH5PContentDependencyData, + CoreH5PLibraryData, + CoreH5PLibraryAddonData, + CoreH5PContentDepsTreeDependency, + CoreH5PLibraryBasicData, + CoreH5PLibraryBasicDataWithPatch, +} from './core'; +import { + CONTENT_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, + CoreH5PLibraryCachedAssetsDBRecord, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + CoreH5PContentDBRecord, + CoreH5PLibraryDBRecord, + CoreH5PLibraryDependencyDBRecord, + CoreH5PContentsLibraryDBRecord, +} from '../services/database/h5p'; +import { CoreError } from '@classes/errors/error'; +import { CoreH5PSemantics } from './content-validator'; +import { CoreH5PContentBeingSaved, CoreH5PLibraryBeingSaved } from './storage'; +import { CoreH5PLibraryAddTo } from './validator'; + +/** + * Equivalent to Moodle's implementation of H5PFrameworkInterface. + */ +export class CoreH5PFramework { + + /** + * Will clear filtered params for all the content that uses the specified libraries. + * This means that the content dependencies will have to be rebuilt and the parameters re-filtered. + * + * @param libraryIds Array of library ids. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async clearFilteredParameters(libraryIds: number[], siteId?: string): Promise { + if (!libraryIds || !libraryIds.length) { + return; + } + + const db = await CoreSites.instance.getSiteDb(siteId); + + const whereAndParams = db.getInOrEqual(libraryIds); + whereAndParams[0] = 'mainlibraryid ' + whereAndParams[0]; + + await db.updateRecordsWhere(CONTENT_TABLE_NAME, { filtered: null }, whereAndParams[0], whereAndParams[1]); + } + + /** + * Delete cached assets from DB. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the removed entries. + */ + async deleteCachedAssets(libraryId: number, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + // Get all the hashes that use this library. + const entries = await db.getRecords( + LIBRARIES_CACHEDASSETS_TABLE_NAME, + { libraryid: libraryId }, + ); + + const hashes = entries.map((entry) => entry.hash); + + if (hashes.length) { + // Delete the entries from DB. + await db.deleteRecordsList(LIBRARIES_CACHEDASSETS_TABLE_NAME, 'hash', hashes); + } + + return entries; + } + + /** + * Delete content data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentData(id: number, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all([ + // Delete the content data. + db.deleteRecords(CONTENT_TABLE_NAME, { id }), + + // Remove content library dependencies. + this.deleteLibraryUsage(id, siteId), + ]); + } + + /** + * Delete library data from DB. + * + * @param id Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteLibrary(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(LIBRARIES_TABLE_NAME, { id }); + } + + /** + * Delete all dependencies belonging to given library. + * + * @param libraryId Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteLibraryDependencies(libraryId: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(LIBRARY_DEPENDENCIES_TABLE_NAME, { libraryid: libraryId }); + } + + /** + * Delete what libraries a content item is using. + * + * @param id Package ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteLibraryUsage(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.deleteRecords(CONTENTS_LIBRARIES_TABLE_NAME, { h5pid: id }); + } + + /** + * Get all conent data from DB. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of content data. + */ + async getAllContentData(siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getAllRecords(CONTENT_TABLE_NAME); + } + + /** + * Get conent data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async getContentData(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getRecord(CONTENT_TABLE_NAME, { id }); + } + + /** + * Get conent data from DB. + * + * @param fileUrl H5P file URL. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async getContentDataByUrl(fileUrl: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const db = site.getDb(); + + // Try to use the folder name, it should be more reliable than the URL. + const folderName = await CoreH5P.instance.h5pCore.h5pFS.getContentFolderNameByUrl(fileUrl, site.getId()); + + try { + return await db.getRecord(CONTENT_TABLE_NAME, { foldername: folderName }); + } catch (error) { + // Cannot get folder name, the h5p file was probably deleted. Just use the URL. + return db.getRecord(CONTENT_TABLE_NAME, { fileurl: fileUrl }); + } + } + + /** + * Get the latest library version. + * + * @param machineName The library's machine name. + * @return Promise resolved with the latest library version data. + */ + async getLatestLibraryVersion(machineName: string, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + try { + const records = await db.getRecords( + LIBRARIES_TABLE_NAME, + { machinename: machineName }, + 'majorversion DESC, minorversion DESC, patchversion DESC', + '*', + 0, + 1, + ); + + if (records && records[0]) { + return this.parseLibDBData(records[0]); + } + } catch (error) { + // Library not found. + } + + throw new CoreError(`Missing required library: ${machineName}`); + } + + /** + * Get a library data stored in DB. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected async getLibrary( + machineName: string, + majorVersion?: string | number, + minorVersion?: string | number, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const libraries = await db.getRecords(LIBRARIES_TABLE_NAME, { + machinename: machineName, + majorversion: majorVersion, + minorversion: minorVersion, + }); + + if (!libraries.length) { + throw new CoreError('Libary not found.'); + } + + return this.parseLibDBData(libraries[0]); + } + + /** + * Get a library data stored in DB. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + getLibraryByData(libraryData: CoreH5PLibraryBasicData, siteId?: string): Promise { + return this.getLibrary(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get a library data stored in DB by ID. + * + * @param id Library ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + async getLibraryById(id: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + const library = await db.getRecord(LIBRARIES_TABLE_NAME, { id }); + + return this.parseLibDBData(library); + } + + /** + * Get a library ID. If not found, return null. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + async getLibraryId( + machineName: string, + majorVersion?: string | number, + minorVersion?: string | number, + siteId?: string, + ): Promise { + try { + const library = await this.getLibrary(machineName, majorVersion, minorVersion, siteId); + + return library.id || undefined; + } catch (error) { + return undefined; + } + } + + /** + * Get a library ID. If not found, return null. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + getLibraryIdByData(libraryData: CoreH5PLibraryBasicData, siteId?: string): Promise { + return this.getLibraryId(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get the default behaviour for the display option defined. + * + * @param name Identifier for the setting. + * @param defaultValue Optional default value if settings is not set. + * @return Return the value for this display option. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getOption(name: string, defaultValue: unknown): unknown { + // For now, all them are disabled by default, so only will be rendered when defined in the display options. + return CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_AUTHOR_DEFAULT_OFF; + } + + /** + * Check whether the user has permission to execute an action. + * + * @param permission Permission to check. + * @param id H5P package id. + * @return Whether the user has permission to execute an action. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + hasPermission(permission: number, id: number): boolean { + // H5P capabilities have not been introduced. + return true; + } + + /** + * Determines if content slug is used. + * + * @param slug The content slug. + * @return Whether the content slug is used + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isContentSlugAvailable(slug: string): boolean { + // By default the slug should be available as it's currently generated as a unique value for each h5p content. + return true; + } + + /** + * Check whether a library is a patched version of the one installed. + * + * @param library Library to check. + * @param dbData Installed library. If not supplied it will be calculated. + * @return Promise resolved with boolean: whether it's a patched library. + */ + async isPatchedLibrary(library: CoreH5PLibraryBasicDataWithPatch, dbData?: CoreH5PLibraryParsedDBRecord): Promise { + if (!dbData) { + dbData = await this.getLibraryByData(library); + } + + return library.patchVersion > dbData.patchversion; + } + + /** + * Convert list of library parameter values to csv. + * + * @param libraryData Library data as found in library.json files. + * @param key Key that should be found in libraryData. + * @param searchParam The library parameter (Default: 'path'). + * @return Library parameter values separated by ', ' + */ + libraryParameterValuesToCsv(libraryData: CoreH5PLibraryBeingSaved, key: string, searchParam: string = 'path'): string { + if (typeof libraryData[key] != 'undefined') { + const parameterValues: string[] = []; + + libraryData[key].forEach((file) => { + for (const index in file) { + if (index === searchParam) { + parameterValues.push(file[index]); + } + } + }); + + return parameterValues.join(','); + } + + return ''; + } + + /** + * Load addon libraries. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the addon libraries. + */ + async loadAddons(siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const query = 'SELECT l1.id AS libraryId, l1.machinename AS machineName, ' + + 'l1.majorversion AS majorVersion, l1.minorversion AS minorVersion, ' + + 'l1.patchversion AS patchVersion, l1.addto AS addTo, ' + + 'l1.preloadedjs AS preloadedJs, l1.preloadedcss AS preloadedCss ' + + 'FROM ' + LIBRARIES_TABLE_NAME + ' l1 ' + + 'JOIN ' + LIBRARIES_TABLE_NAME + ' l2 ON l1.machinename = l2.machinename AND (' + + 'l1.majorversion < l2.majorversion OR (l1.majorversion = l2.majorversion AND ' + + 'l1.minorversion < l2.minorversion)) ' + + 'WHERE l1.addto IS NOT NULL AND l2.machinename IS NULL'; + + const result = await db.execute(query); + + const addons: CoreH5PLibraryAddonData[] = []; + + for (let i = 0; i < result.rows.length; i++) { + addons.push(this.parseLibAddonData(result.rows.item(i))); + } + + return addons; + } + + /** + * Load content data from DB. + * + * @param id Content ID. + * @param fileUrl H5P file URL. Required if id is not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async loadContent(id?: number, fileUrl?: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + let contentData: CoreH5PContentDBRecord; + + if (id) { + contentData = await this.getContentData(id, siteId); + } else if (fileUrl) { + contentData = await this.getContentDataByUrl(fileUrl, siteId); + } else { + throw new CoreError('No id or fileUrl supplied to loadContent.'); + } + + // Load the main library data. + const libData = await this.getLibraryById(contentData.mainlibraryid, siteId); + + // Map the values to the names used by the H5P core (it's the same Moodle web does). + const content = { + id: contentData.id, + params: contentData.jsoncontent, + embedType: 'iframe', // Always use iframe. + disable: null, + folderName: contentData.foldername, + title: libData.title, + slug: CoreH5PCore.slugify(libData.title) + '-' + contentData.id, + filtered: contentData.filtered, + libraryId: libData.id, + libraryName: libData.machinename, + libraryMajorVersion: libData.majorversion, + libraryMinorVersion: libData.minorversion, + libraryEmbedTypes: libData.embedtypes, + libraryFullscreen: libData.fullscreen, + metadata: null, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params = CoreTextUtils.instance.parseJSON(contentData.jsoncontent); + if (!params.metadata) { + params.metadata = {}; + } + content.metadata = params.metadata; + content.params = JSON.stringify(typeof params.params != 'undefined' && params.params != null ? params.params : params); + + return content; + } + + /** + * Load dependencies for the given content of the given type. + * + * @param id Content ID. + * @param type The dependency type. + * @return Content dependencies, indexed by machine name. + */ + async loadContentDependencies( + id: number, + type?: string, + siteId?: string, + ): Promise<{[machineName: string]: CoreH5PContentDependencyData}> { + + const db = await CoreSites.instance.getSiteDb(siteId); + + let query = 'SELECT hl.id AS libraryId, hl.machinename AS machineName, ' + + 'hl.majorversion AS majorVersion, hl.minorversion AS minorVersion, ' + + 'hl.patchversion AS patchVersion, hl.preloadedcss AS preloadedCss, ' + + 'hl.preloadedjs AS preloadedJs, hcl.dropcss AS dropCss, ' + + 'hcl.dependencytype as dependencyType ' + + 'FROM ' + CONTENTS_LIBRARIES_TABLE_NAME + ' hcl ' + + 'JOIN ' + LIBRARIES_TABLE_NAME + ' hl ON hcl.libraryid = hl.id ' + + 'WHERE hcl.h5pid = ?'; + + const queryArgs: (string | number)[] = []; + queryArgs.push(id); + + if (type) { + query += ' AND hcl.dependencytype = ?'; + queryArgs.push(type); + } + + query += ' ORDER BY hcl.weight'; + + const result = await db.execute(query, queryArgs); + + const dependencies: {[machineName: string]: CoreH5PContentDependencyData} = {}; + + for (let i = 0; i < result.rows.length; i++) { + const dependency = result.rows.item(i); + + dependencies[dependency.machineName] = dependency; + } + + return dependencies; + } + + /** + * Loads a library and its dependencies. + * + * @param machineName The library's machine name. + * @param majorVersion The library's major version. + * @param minorVersion The library's minor version. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data. + */ + async loadLibrary( + machineName: string, + majorVersion: number, + minorVersion: number, + siteId?: string, + ): Promise { + + // First get the library data from DB. + const library = await this.getLibrary(machineName, majorVersion, minorVersion, siteId); + + const libraryData: CoreH5PLibraryData = { + libraryId: library.id, + title: library.title, + machineName: library.machinename, + majorVersion: library.majorversion, + minorVersion: library.minorversion, + patchVersion: library.patchversion, + runnable: library.runnable, + fullscreen: library.fullscreen, + embedTypes: library.embedtypes, + preloadedJs: library.preloadedjs || undefined, + preloadedCss: library.preloadedcss || undefined, + dropLibraryCss: library.droplibrarycss || undefined, + semantics: library.semantics || undefined, + preloadedDependencies: [], + dynamicDependencies: [], + editorDependencies: [], + }; + + // Now get the dependencies. + const sql = 'SELECT hl.id, hl.machinename, hl.majorversion, hl.minorversion, hll.dependencytype ' + + 'FROM ' + LIBRARY_DEPENDENCIES_TABLE_NAME + ' hll ' + + 'JOIN ' + LIBRARIES_TABLE_NAME + ' hl ON hll.requiredlibraryid = hl.id ' + + 'WHERE hll.libraryid = ? ' + + 'ORDER BY hl.id ASC'; + + const sqlParams = [ + library.id, + ]; + + const db = await CoreSites.instance.getSiteDb(siteId); + + const result = await db.execute(sql, sqlParams); + + for (let i = 0; i < result.rows.length; i++) { + const dependency: LibraryDependency = result.rows.item(i); + const key = dependency.dependencytype + 'Dependencies'; + + libraryData[key].push({ + machineName: dependency.machinename, + majorVersion: dependency.majorversion, + minorVersion: dependency.minorversion, + }); + } + + return libraryData; + } + + /** + * Parse library addon data. + * + * @param library Library addon data. + * @return Parsed library. + */ + parseLibAddonData(library: LibraryAddonDBData): CoreH5PLibraryAddonData { + const parsedLib = library; + parsedLib.addTo = CoreTextUtils.instance.parseJSON(library.addTo, null); + + return parsedLib; + } + + /** + * Parse library DB data. + * + * @param library Library DB data. + * @return Parsed library. + */ + protected parseLibDBData(library: CoreH5PLibraryDBRecord): CoreH5PLibraryParsedDBRecord { + return Object.assign(library, { + semantics: library.semantics ? CoreTextUtils.instance.parseJSON(library.semantics, null) : null, + addto: library.addto ? CoreTextUtils.instance.parseJSON(library.addto, null) : null, + }); + } + + /** + * Resets marked user data for the given content. + * + * @param contentId Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async resetContentUserData(conentId: number, siteId?: string): Promise { + // Currently, we do not store user data for a content. + } + + /** + * Stores hash keys for cached assets, aggregated JavaScripts and stylesheets, and connects it to libraries so that we + * know which cache file to delete when a library is updated. + * + * @param key Hash key for the given libraries. + * @param libraries List of dependencies used to create the key. + * @param folderName The name of the folder that contains the H5P. + * @param siteId The site ID. + * @return Promise resolved when done. + */ + async saveCachedAssets( + hash: string, + dependencies: {[machineName: string]: CoreH5PContentDependencyData}, + folderName: string, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all(Object.keys(dependencies).map(async (key) => { + const data: Partial = { + hash: key, + libraryid: dependencies[key].libraryId, + foldername: folderName, + }; + + await db.insertRecord(LIBRARIES_CACHEDASSETS_TABLE_NAME, data); + })); + } + + /** + * Save library data in DB. + * + * @param libraryData Library data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibraryData(libraryData: CoreH5PLibraryBeingSaved, siteId?: string): Promise { + // Some special properties needs some checking and converting before they can be saved. + const preloadedJS = this.libraryParameterValuesToCsv(libraryData, 'preloadedJs', 'path'); + const preloadedCSS = this.libraryParameterValuesToCsv(libraryData, 'preloadedCss', 'path'); + const dropLibraryCSS = this.libraryParameterValuesToCsv(libraryData, 'dropLibraryCss', 'machineName'); + + if (typeof libraryData.semantics == 'undefined') { + libraryData.semantics = []; + } + if (typeof libraryData.fullscreen == 'undefined') { + libraryData.fullscreen = 0; + } + + let embedTypes = ''; + if (typeof libraryData.embedTypes != 'undefined') { + embedTypes = libraryData.embedTypes.join(', '); + } + + const site = await CoreSites.instance.getSite(siteId); + + const db = site.getDb(); + const data: Partial = { + title: libraryData.title, + machinename: libraryData.machineName, + majorversion: libraryData.majorVersion, + minorversion: libraryData.minorVersion, + patchversion: libraryData.patchVersion, + runnable: libraryData.runnable, + fullscreen: libraryData.fullscreen, + embedtypes: embedTypes, + preloadedjs: preloadedJS, + preloadedcss: preloadedCSS, + droplibrarycss: dropLibraryCSS, + semantics: typeof libraryData.semantics != 'undefined' ? JSON.stringify(libraryData.semantics) : null, + addto: typeof libraryData.addTo != 'undefined' ? JSON.stringify(libraryData.addTo) : null, + }; + + if (libraryData.libraryId) { + data.id = libraryData.libraryId; + } + + await db.insertRecord(LIBRARIES_TABLE_NAME, data); + + if (!data.id) { + // New library. Get its ID. + const entry = await db.getRecord(LIBRARIES_TABLE_NAME, data); + + libraryData.libraryId = entry.id; + } else { + // Updated libary. Remove old dependencies. + await this.deleteLibraryDependencies(data.id, site.getId()); + } + } + + /** + * Save what libraries a library is depending on. + * + * @param libraryId Library Id for the library we're saving dependencies for. + * @param dependencies List of dependencies as associative arrays containing machineName, majorVersion, minorVersion. + * @param dependencytype The type of dependency. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibraryDependencies( + libraryId: number, + dependencies: CoreH5PLibraryBasicData[], + dependencyType: string, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all(dependencies.map(async (dependency) => { + // Get the ID of the library. + const dependencyId = await this.getLibraryIdByData(dependency, siteId); + + // Create the relation. + const entry: Partial = { + libraryid: libraryId, + requiredlibraryid: dependencyId, + dependencytype: dependencyType, + }; + + await db.insertRecord(LIBRARY_DEPENDENCIES_TABLE_NAME, entry); + })); + } + + /** + * Saves what libraries the content uses. + * + * @param id Id identifying the package. + * @param librariesInUse List of libraries the content uses. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveLibraryUsage( + id: number, + librariesInUse: {[key: string]: CoreH5PContentDepsTreeDependency}, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + // Calculate the CSS to drop. + const dropLibraryCssList: Record = {}; + + for (const key in librariesInUse) { + const dependency = librariesInUse[key]; + + if ('dropLibraryCss' in dependency.library && dependency.library.dropLibraryCss) { + const split = dependency.library.dropLibraryCss.split(', '); + + split.forEach((css) => { + dropLibraryCssList[css] = css; + }); + } + } + + // Now save the uusage. + await Promise.all(Object.keys(librariesInUse).map((key) => { + const dependency = librariesInUse[key]; + const data: Partial = { + h5pid: id, + libraryid: dependency.library.libraryId, + dependencytype: dependency.type, + dropcss: dropLibraryCssList[dependency.library.machineName] ? 1 : 0, + weight: dependency.weight, + }; + + return db.insertRecord(CONTENTS_LIBRARIES_TABLE_NAME, data); + })); + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with content ID. + */ + async updateContent(content: CoreH5PContentBeingSaved, folderName: string, fileUrl: string, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + // If the libraryid declared in the package is empty, get the latest version. + if (content.library && typeof content.library.libraryId == 'undefined') { + const mainLibrary = await this.getLatestLibraryVersion(content.library.machineName, siteId); + + content.library.libraryId = mainLibrary.id; + } + + const data: Partial = { + id: undefined, + jsoncontent: content.params, + mainlibraryid: content.library?.libraryId, + timemodified: Date.now(), + filtered: null, + foldername: folderName, + fileurl: fileUrl, + timecreated: undefined, + }; + + if (typeof content.id != 'undefined') { + data.id = content.id; + } else { + data.timecreated = data.timemodified; + } + + await db.insertRecord(CONTENT_TABLE_NAME, data); + + if (!data.id) { + // New content. Get its ID. + const entry = await db.getRecord(CONTENT_TABLE_NAME, data); + + content.id = entry.id; + } + + return content.id!; + } + + /** + * This will update selected fields on the given content. + * + * @param id Content identifier. + * @param fields Object with the fields to update. + * @param siteId Site ID. If not defined, current site. + */ + async updateContentFields(id: number, fields: Partial, siteId?: string): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const data = Object.assign({}, fields); + + await db.updateRecords(CONTENT_TABLE_NAME, data, { id }); + } + +} + +/** + * Content data returned by loadContent. + */ +export type CoreH5PFrameworkContentData = { + id: number; // The id of the content. + params: string; // The content in json format. + embedType: string; // Embed type to use. + disable: number | null; // H5P Button display options. + folderName: string; // Name of the folder that contains the contents. + title: string; // Main library's title. + slug: string; // Lib title and ID slugified. + filtered: string | null; // Filtered version of json_content. + libraryId: number; // Main library's ID. + libraryName: string; // Main library's machine name. + libraryMajorVersion: number; // Main library's major version. + libraryMinorVersion: number; // Main library's minor version. + libraryEmbedTypes: string; // Main library's list of supported embed types. + libraryFullscreen: number; // Main library's display fullscreen button. + metadata: unknown; // Content metadata. +}; + +export type CoreH5PLibraryParsedDBRecord = Omit & { + semantics: CoreH5PSemantics[] | null; + addto: CoreH5PLibraryAddTo | null; +}; + +type LibraryDependency = { + id: number; + machinename: string; + majorversion: number; + minorversion: number; + dependencytype: string; +}; + +type LibraryAddonDBData = Omit & { + addTo: string; +}; + diff --git a/src/core/features/h5p/classes/helper.ts b/src/core/features/h5p/classes/helper.ts new file mode 100644 index 000000000..4a0822ce0 --- /dev/null +++ b/src/core/features/h5p/classes/helper.ts @@ -0,0 +1,255 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { FileEntry } from '@ionic-native/file'; + +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreUser } from '@features/user/services/user'; +import { CoreH5P } from '../services/h5p'; +import { CoreH5PCore, CoreH5PDisplayOptions } from './core'; +import { Translate } from '@singletons'; +import { CoreError } from '@classes/errors/error'; + +/** + * Equivalent to Moodle's H5P helper class. + */ +export class CoreH5PHelper { + + /** + * Convert the number representation of display options into an object. + * + * @param displayOptions Number representing display options. + * @return Object with display options. + */ + static decodeDisplayOptions(displayOptions: number): CoreH5PDisplayOptions { + const displayOptionsObject = CoreH5P.instance.h5pCore.getDisplayOptionsAsObject(displayOptions); + + const config: CoreH5PDisplayOptions = { + export: false, // Don't allow downloading in the app. + embed: false, // Don't display the embed button in the app. + copyright: CoreUtils.instance.notNullOrUndefined(displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT]) ? + displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] : false, + icon: CoreUtils.instance.notNullOrUndefined(displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_ABOUT]) ? + displayOptionsObject[CoreH5PCore.DISPLAY_OPTION_ABOUT] : false, + }; + + config.frame = config.copyright || config.export || config.embed; + + return config; + } + + /** + * Get the core H5P assets, including all core H5P JavaScript and CSS. + * + * @return Array core H5P assets. + */ + static async getCoreAssets( + siteId?: string, + ): Promise<{settings: CoreH5PCoreSettings; cssRequires: string[]; jsRequires: string[]}> { + + // Get core settings. + const settings = await CoreH5PHelper.getCoreSettings(siteId); + + settings.core = { + styles: [], + scripts: [], + }; + settings.loadedJs = []; + settings.loadedCss = []; + + const libUrl = CoreH5P.instance.h5pCore.h5pFS.getCoreH5PPath(); + const cssRequires: string[] = []; + const jsRequires: string[] = []; + + // Add core stylesheets. + CoreH5PCore.STYLES.forEach((style) => { + settings.core!.styles.push(libUrl + style); + cssRequires.push(libUrl + style); + }); + + // Add core JavaScript. + CoreH5PCore.getScripts().forEach((script) => { + settings.core!.scripts.push(script); + jsRequires.push(script); + }); + + return { settings, cssRequires, jsRequires }; + } + + /** + * Get the settings needed by the H5P library. + * + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the settings. + */ + static async getCoreSettings(siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const userId = site.getUserId(); + const user = await CoreUtils.instance.ignoreErrors(CoreUser.instance.getProfile(userId, undefined, false, siteId)); + + if (!user || !user.email) { + throw new CoreError(Translate.instance.instant('core.h5p.errorgetemail')); + } + + const basePath = CoreFile.instance.getBasePathInstant(); + const ajaxPaths = { + xAPIResult: '', + contentUserData: '', + }; + + return { + baseUrl: CoreFile.instance.getWWWPath(), + url: CoreFile.instance.convertFileSrc( + CoreTextUtils.instance.concatenatePaths( + basePath, + CoreH5P.instance.h5pCore.h5pFS.getExternalH5PFolderPath(site.getId()), + ), + ), + urlLibraries: CoreFile.instance.convertFileSrc( + CoreTextUtils.instance.concatenatePaths( + basePath, + CoreH5P.instance.h5pCore.h5pFS.getLibrariesFolderPath(site.getId()), + ), + ), + postUserStatistics: false, + ajax: ajaxPaths, + saveFreq: false, + siteUrl: site.getURL(), + l10n: { + H5P: CoreH5P.instance.h5pCore.getLocalization(), // eslint-disable-line @typescript-eslint/naming-convention + }, + user: { name: site.getInfo()!.fullname, mail: user.email }, + hubIsEnabled: false, + reportingIsEnabled: false, + crossorigin: null, + libraryConfig: null, + pluginCacheBuster: '', + libraryUrl: CoreTextUtils.instance.concatenatePaths(CoreH5P.instance.h5pCore.h5pFS.getCoreH5PPath(), 'js'), + }; + } + + /** + * Extract and store an H5P file. + * This function won't validate most things because it should've been done by the server already. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @param onProgress Function to call on progress. + * @return Promise resolved when done. + */ + static async saveH5P(fileUrl: string, file: FileEntry, siteId?: string, onProgress?: CoreH5PSaveOnProgress): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Notify that the unzip is starting. + onProgress && onProgress({ message: 'core.unzipping' }); + + const queueId = siteId + ':saveH5P:' + fileUrl; + + await CoreH5P.instance.queueRunner.run(queueId, () => CoreH5PHelper.performSave(fileUrl, file, siteId, onProgress)); + } + + /** + * Extract and store an H5P file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @param onProgress Function to call on progress. + * @return Promise resolved when done. + */ + protected static async performSave( + fileUrl: string, + file: FileEntry, + siteId?: string, + onProgress?: CoreH5PSaveOnProgress, + ): Promise { + + const folderName = CoreMimetypeUtils.instance.removeExtension(file.name); + const destFolder = CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName); + + // Unzip the file. + await CoreFile.instance.unzipFile(file.toURL(), destFolder, onProgress); + + try { + // Notify that the unzip is starting. + onProgress && onProgress({ message: 'core.storingfiles' }); + + // Read the contents of the unzipped dir, process them and store them. + const contents = await CoreFile.instance.getDirectoryContents(destFolder); + + const filesData = await CoreH5P.instance.h5pValidator.processH5PFiles(destFolder, contents); + + const content = await CoreH5P.instance.h5pStorage.savePackage(filesData, folderName, fileUrl, false, siteId); + + // Create the content player. + const contentData = await CoreH5P.instance.h5pCore.loadContent(content.id, undefined, siteId); + + const embedType = CoreH5PCore.determineEmbedType(contentData.embedType, contentData.library.embedTypes); + + await CoreH5P.instance.h5pPlayer.createContentIndex(content.id!, fileUrl, contentData, embedType, siteId); + } finally { + // Remove tmp folder. + try { + await CoreFile.instance.removeDir(destFolder); + } catch (error) { + // Ignore errors, it will be deleted eventually. + } + } + } + +} + +/** + * Core settings for H5P. + */ +export type CoreH5PCoreSettings = { + baseUrl: string; + url: string; + urlLibraries: string; + postUserStatistics: boolean; + ajax: { + xAPIResult: string; + contentUserData: string; + }; + saveFreq: boolean; + siteUrl: string; + l10n: { + H5P: {[name: string]: string}; // eslint-disable-line @typescript-eslint/naming-convention + }; + user: { + name: string; + mail: string; + }; + hubIsEnabled: boolean; + reportingIsEnabled: boolean; + crossorigin: null; + libraryConfig: null; + pluginCacheBuster: string; + libraryUrl: string; + core?: { + styles: string[]; + scripts: string[]; + }; + loadedJs?: string[]; + loadedCss?: string[]; +}; + +export type CoreH5PSaveOnProgress = (event?: ProgressEvent | { message: string }) => void; diff --git a/src/core/features/h5p/classes/metadata.ts b/src/core/features/h5p/classes/metadata.ts new file mode 100644 index 000000000..69c44054c --- /dev/null +++ b/src/core/features/h5p/classes/metadata.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreH5PLibraryMetadataSettings } from './validator'; + +/** + * Equivalent to H5P's H5PMetadata class. + */ +export class CoreH5PMetadata { + + /** + * The metadataSettings field in libraryJson uses 1 for true and 0 for false. + * Here we are converting these to booleans, and also doing JSON encoding. + * + * @param metadataSettings Settings. + * @return Stringified settings. + */ + static boolifyAndEncodeSettings(metadataSettings: CoreH5PLibraryMetadataSettings): string { + // Convert metadataSettings values to boolean. + if (typeof metadataSettings.disable != 'undefined') { + metadataSettings.disable = metadataSettings.disable === 1; + } + if (typeof metadataSettings.disableExtraTitleField != 'undefined') { + metadataSettings.disableExtraTitleField = metadataSettings.disableExtraTitleField === 1; + } + + return JSON.stringify(metadataSettings); + } + +} diff --git a/src/core/features/h5p/classes/player.ts b/src/core/features/h5p/classes/player.ts new file mode 100644 index 000000000..0a473aef9 --- /dev/null +++ b/src/core/features/h5p/classes/player.ts @@ -0,0 +1,420 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreXAPI } from '@features/xapi/services/xapi'; +import { CoreH5P } from '../services/h5p'; +import { CoreH5PCore, CoreH5PDisplayOptions, CoreH5PContentData, CoreH5PDependenciesFiles } from './core'; +import { CoreH5PCoreSettings, CoreH5PHelper } from './helper'; +import { CoreH5PStorage } from './storage'; + +/** + * Equivalent to Moodle's H5P player class. + */ +export class CoreH5PPlayer { + + constructor( + protected h5pCore: CoreH5PCore, + protected h5pStorage: CoreH5PStorage, + ) { } + + /** + * Calculate the URL to the site H5P player. + * + * @param siteUrl Site URL. + * @param fileUrl File URL. + * @param displayOptions Display options. + * @param component Component to send xAPI events to. + * @return URL. + */ + calculateOnlinePlayerUrl(siteUrl: string, fileUrl: string, displayOptions?: CoreH5PDisplayOptions, component?: string): string { + fileUrl = CoreH5P.instance.treatH5PUrl(fileUrl, siteUrl); + + const params = this.getUrlParamsFromDisplayOptions(displayOptions); + params.url = encodeURIComponent(fileUrl); + if (component) { + params.component = component; + } + + return CoreUrlUtils.instance.addParamsToUrl(CoreTextUtils.instance.concatenatePaths(siteUrl, '/h5p/embed.php'), params); + } + + /** + * Create the index.html to render an H5P package. + * Part of the code of this function is equivalent to Moodle's add_assets_to_page function. + * + * @param id Content ID. + * @param h5pUrl The URL of the H5P file. + * @param content Content data. + * @param embedType Embed type. The app will always use 'iframe'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the URL of the index file. + */ + async createContentIndex( + id: number, + h5pUrl: string, + content: CoreH5PContentData, + embedType: string, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const contentId = this.getContentId(id); + const basePath = CoreFile.instance.getBasePathInstant(); + const contentUrl = CoreFile.instance.convertFileSrc( + CoreTextUtils.instance.concatenatePaths( + basePath, + this.h5pCore.h5pFS.getContentFolderPath(content.folderName, site.getId()), + ), + ); + + // Create the settings needed for the content. + const contentSettings = { + library: CoreH5PCore.libraryToString(content.library), + fullScreen: content.library.fullscreen, + exportUrl: '', // We'll never display the download button, so we don't need the exportUrl. + embedCode: this.getEmbedCode(site.getURL(), h5pUrl, true), + resizeCode: this.getResizeCode(), + title: content.slug, + displayOptions: {}, + url: '', // It will be filled using dynamic params if needed. + contentUrl: contentUrl, + metadata: content.metadata, + contentUserData: [ + { + state: '{}', + }, + ], + }; + + // Get the core H5P assets, needed by the H5P classes to render the H5P content. + const result = await this.getAssets(id, content, embedType, site.getId()); + + result.settings.contents[contentId] = Object.assign(result.settings.contents[contentId], contentSettings); + + const indexPath = this.h5pCore.h5pFS.getContentIndexPath(content.folderName, site.getId()); + let html = '' + content.title + '' + + ''; + + // Include the required CSS. + result.cssRequires.forEach((cssUrl) => { + html += ''; + }); + + // Add the settings. + html += ''; + + // Add our own script to handle the params. + html += ''; + + html += ''; + + // Include the required JS at the beginning of the body, like Moodle web does. + // Load the embed.js to allow communication with the parent window. + html += ''; + + result.jsRequires.forEach((jsUrl) => { + html += ''; + }); + + html += '
' + + '' + + '
'; + + const fileEntry = await CoreFile.instance.writeFile(indexPath, html); + + return fileEntry.toURL(); + } + + /** + * Delete all content indexes of all sites from filesystem. + * + * @return Promise resolved when done. + */ + async deleteAllContentIndexes(): Promise { + const siteIds = await CoreSites.instance.getSitesIds(); + + await Promise.all(siteIds.map((siteId) => this.deleteAllContentIndexesForSite(siteId))); + } + + /** + * Delete all content indexes for a certain site from filesystem. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteAllContentIndexesForSite(siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!siteId) { + return; + } + + const records = await this.h5pCore.h5pFramework.getAllContentData(siteId); + + await Promise.all(records.map(async (record) => { + await CoreUtils.instance.ignoreErrors(this.h5pCore.h5pFS.deleteContentIndex(record.foldername, siteId!)); + })); + } + + /** + * Delete all package content data. + * + * @param fileUrl File URL. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteContentByUrl(fileUrl: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const data = await this.h5pCore.h5pFramework.getContentDataByUrl(fileUrl, siteId); + + await CoreUtils.instance.allPromises([ + this.h5pCore.h5pFramework.deleteContentData(data.id, siteId), + + this.h5pCore.h5pFS.deleteContentFolder(data.foldername, siteId), + ]); + } + + /** + * Get the assets of a package. + * + * @param id Content id. + * @param content Content data. + * @param embedType Embed type. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the assets. + */ + protected async getAssets( + id: number, + content: CoreH5PContentData, + embedType: string, + siteId?: string, + ): Promise<{settings: AssetsSettings; cssRequires: string[]; jsRequires: string[]}> { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get core assets. + const coreAssets = await CoreH5PHelper.getCoreAssets(siteId); + + const contentId = this.getContentId(id); + const settings = coreAssets.settings; + settings.contents = settings.contents || {}; + settings.contents[contentId] = settings.contents[contentId] || {}; + + settings.moodleLibraryPaths = await this.h5pCore.getDependencyRoots(id); + + /* The filterParameters function should be called before getting the dependency files because it rebuilds content + dependency cache. */ + settings.contents[contentId].jsonContent = await this.h5pCore.filterParameters(content, siteId); + + const files = await this.getDependencyFiles(id, content.folderName, siteId); + + // H5P checks the embedType in here, but we'll always use iframe so there's no need to do it. + // JavaScripts and stylesheets will be loaded through h5p.js. + settings.contents[contentId].scripts = this.h5pCore.getAssetsUrls(files.scripts); + settings.contents[contentId].styles = this.h5pCore.getAssetsUrls(files.styles); + + return { + settings: settings, + cssRequires: coreAssets.cssRequires, + jsRequires: coreAssets.jsRequires, + }; + } + + /** + * Get the identifier for the H5P content. This identifier is different than the ID stored in the DB. + * + * @param id Package ID. + * @return Content identifier. + */ + protected getContentId(id: number): string { + return 'cid-' + id; + } + + /** + * Get the content index file. + * + * @param fileUrl URL of the H5P package. + * @param displayOptions Display options. + * @param component Component to send xAPI events to. + * @param contextId Context ID where the H5P is. Required for tracking. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the file URL if exists, rejected otherwise. + */ + async getContentIndexFileUrl( + fileUrl: string, + displayOptions?: CoreH5PDisplayOptions, + component?: string, + contextId?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const path = await this.h5pCore.h5pFS.getContentIndexFileUrl(fileUrl, siteId); + + // Add display options and component to the URL. + const data = await this.h5pCore.h5pFramework.getContentDataByUrl(fileUrl, siteId); + + displayOptions = this.h5pCore.fixDisplayOptions(displayOptions || {}, data.id); + + const params: Record = { + displayOptions: JSON.stringify(displayOptions), + component: component || '', + }; + + if (contextId) { + params.trackingUrl = await CoreXAPI.instance.getUrl(contextId, 'activity', siteId); + } + + return CoreUrlUtils.instance.addParamsToUrl(path, params); + } + + /** + * Finds library dependencies files of a certain package. + * + * @param id Content id. + * @param folderName Name of the folder of the content. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + protected async getDependencyFiles(id: number, folderName: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const preloadedDeps = await CoreH5P.instance.h5pCore.loadContentDependencies(id, 'preloaded', siteId); + + return this.h5pCore.getDependenciesFiles( + preloadedDeps, + folderName, + this.h5pCore.h5pFS.getExternalH5PFolderPath(siteId), + siteId, + ); + } + + /** + * Get display options from a URL params. + * + * @param params URL params. + * @return Display options as object. + */ + getDisplayOptionsFromUrlParams(params?: {[name: string]: string}): CoreH5PDisplayOptions { + const displayOptions: CoreH5PDisplayOptions = {}; + + if (!params) { + return displayOptions; + } + + displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = false; // Never allow downloading in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] = false; // Never show the embed option in the app. + displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = + CoreUtils.instance.isTrueOrOne(params[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT]); + displayOptions[CoreH5PCore.DISPLAY_OPTION_FRAME] = displayOptions[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] || + displayOptions[CoreH5PCore.DISPLAY_OPTION_EMBED] || displayOptions[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT]; + displayOptions[CoreH5PCore.DISPLAY_OPTION_ABOUT] = + !!this.h5pCore.h5pFramework.getOption(CoreH5PCore.DISPLAY_OPTION_ABOUT, true); + + return displayOptions; + } + + /** + * Embed code for settings. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @param embedEnabled Whether the option to embed the H5P content is enabled. + * @return The HTML code to reuse this H5P content in a different place. + */ + protected getEmbedCode(siteUrl: string, h5pUrl: string, embedEnabled?: boolean): string { + if (!embedEnabled) { + return ''; + } + + return ''; + } + + /** + * Get the encoded URL for embeding an H5P content. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @return The embed URL. + */ + protected getEmbedUrl(siteUrl: string, h5pUrl: string): string { + return CoreTextUtils.instance.concatenatePaths(siteUrl, '/h5p/embed.php') + '?url=' + h5pUrl; + } + + /** + * Resizing script for settings. + * + * @return The HTML code with the resize script. + */ + protected getResizeCode(): string { + return ''; + } + + /** + * Get the URL to the resizer script. + * + * @return URL. + */ + getResizerScriptUrl(): string { + return CoreTextUtils.instance.concatenatePaths(this.h5pCore.h5pFS.getCoreH5PPath(), 'js/h5p-resizer.js'); + } + + /** + * Get online player URL params from display options. + * + * @param options Display options. + * @return Object with URL params. + */ + getUrlParamsFromDisplayOptions(options?: CoreH5PDisplayOptions): {[name: string]: string} { + const params: {[name: string]: string} = {}; + + if (!options) { + return params; + } + + params[CoreH5PCore.DISPLAY_OPTION_FRAME] = options[CoreH5PCore.DISPLAY_OPTION_FRAME] ? '1' : '0'; + params[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] = options[CoreH5PCore.DISPLAY_OPTION_DOWNLOAD] ? '1' : '0'; + params[CoreH5PCore.DISPLAY_OPTION_EMBED] = options[CoreH5PCore.DISPLAY_OPTION_EMBED] ? '1' : '0'; + params[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] = options[CoreH5PCore.DISPLAY_OPTION_COPYRIGHT] ? '1' : '0'; + + return params; + } + +} + +type AssetsSettings = CoreH5PCoreSettings & { + contents: { + [contentId: string]: { + jsonContent: string | null; + scripts: string[]; + styles: string[]; + }; + }; + moodleLibraryPaths: { + [libString: string]: string; + }; +}; diff --git a/src/core/features/h5p/classes/storage.ts b/src/core/features/h5p/classes/storage.ts new file mode 100644 index 000000000..91b32aa07 --- /dev/null +++ b/src/core/features/h5p/classes/storage.ts @@ -0,0 +1,233 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreH5PCore, CoreH5PLibraryBasicData } from './core'; +import { CoreH5PFramework } from './framework'; +import { CoreH5PMetadata } from './metadata'; +import { + CoreH5PLibrariesJsonData, + CoreH5PLibraryJsonData, + CoreH5PLibraryMetadataSettings, + CoreH5PMainJSONFilesData, +} from './validator'; + +/** + * Equivalent to H5P's H5PStorage class. + */ +export class CoreH5PStorage { + + constructor( + protected h5pCore: CoreH5PCore, + protected h5pFramework: CoreH5PFramework, + ) { } + + /** + * Save libraries. + * + * @param librariesJsonData Data about libraries. + * @param folderName Name of the folder of the H5P package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected async saveLibraries(librariesJsonData: CoreH5PLibrariesJsonData, folderName: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // First of all, try to create the dir where the libraries are stored. This way we don't have to do it for each lib. + await CoreFile.instance.createDir(this.h5pCore.h5pFS.getLibrariesFolderPath(siteId)); + + const libraryIds: number[] = []; + + // Go through libraries that came with this package. + await Promise.all(Object.keys(librariesJsonData).map(async (libString) => { + const libraryData: CoreH5PLibraryBeingSaved = librariesJsonData[libString]; + + // Find local library identifier. + const dbData = await CoreUtils.instance.ignoreErrors(this.h5pFramework.getLibraryByData(libraryData)); + + if (dbData) { + // Library already installed. + libraryData.libraryId = dbData.id; + + const isNewPatch = await this.h5pFramework.isPatchedLibrary(libraryData, dbData); + + if (!isNewPatch) { + // Same or older version, no need to save. + libraryData.saveDependencies = false; + + return; + } + } + + libraryData.saveDependencies = true; + + // Convert metadataSettings values to boolean and json_encode it before saving. + libraryData.metadataSettings = libraryData.metadataSettings ? + CoreH5PMetadata.boolifyAndEncodeSettings( libraryData.metadataSettings) : undefined; + + // Save the library data in DB. + await this.h5pFramework.saveLibraryData(libraryData, siteId); + + // Now save it in FS. + try { + await this.h5pCore.h5pFS.saveLibrary(libraryData, siteId); + } catch (error) { + if (libraryData.libraryId) { + // An error occurred, delete the DB data because the lib FS data has been deleted. + await this.h5pFramework.deleteLibrary(libraryData.libraryId, siteId); + } + + throw error; + } + + if (typeof libraryData.libraryId != 'undefined') { + const promises: Promise[] = []; + + // Remove all indexes of contents that use this library. + promises.push(this.h5pCore.h5pFS.deleteContentIndexesForLibrary(libraryData.libraryId, siteId)); + + if (this.h5pCore.aggregateAssets) { + // Remove cached assets that use this library. + const removedEntries = await this.h5pFramework.deleteCachedAssets(libraryData.libraryId, siteId); + + await this.h5pCore.h5pFS.deleteCachedAssets(removedEntries, siteId); + } + + await CoreUtils.instance.allPromises(promises); + } + })); + + // Go through the libraries again to save dependencies. + await Promise.all(Object.keys(librariesJsonData).map(async (libString) => { + const libraryData: CoreH5PLibraryBeingSaved = librariesJsonData[libString]; + + if (!libraryData.saveDependencies || !libraryData.libraryId) { + return; + } + + const libId = libraryData.libraryId; + + libraryIds.push(libId); + + // Remove any old dependencies. + await this.h5pFramework.deleteLibraryDependencies(libId, siteId); + + // Insert the different new ones. + const promises: Promise[] = []; + + if (typeof libraryData.preloadedDependencies != 'undefined') { + promises.push(this.h5pFramework.saveLibraryDependencies(libId, libraryData.preloadedDependencies, 'preloaded')); + } + if (typeof libraryData.dynamicDependencies != 'undefined') { + promises.push(this.h5pFramework.saveLibraryDependencies(libId, libraryData.dynamicDependencies, 'dynamic')); + } + if (typeof libraryData.editorDependencies != 'undefined') { + promises.push(this.h5pFramework.saveLibraryDependencies(libId, libraryData.editorDependencies, 'editor')); + } + + await Promise.all(promises); + })); + + // Make sure dependencies, parameter filtering and export files get regenerated for content who uses these libraries. + if (libraryIds.length) { + await this.h5pFramework.clearFilteredParameters(libraryIds, siteId); + } + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + async savePackage( + data: CoreH5PMainJSONFilesData, + folderName: string, + fileUrl: string, + skipContent?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.h5pCore.mayUpdateLibraries()) { + // Save the libraries that were processed. + await this.saveLibraries(data.librariesJsonData, folderName, siteId); + } + + const content: CoreH5PContentBeingSaved = {}; + + if (!skipContent) { + // Find main library version. + if (data.mainJsonData.preloadedDependencies) { + const mainLib = data.mainJsonData.preloadedDependencies.find((dependency) => + dependency.machineName === data.mainJsonData.mainLibrary); + + if (mainLib) { + const id = await this.h5pFramework.getLibraryIdByData(mainLib); + + content.library = Object.assign(mainLib, { libraryId: id }); + } + } + + content.params = JSON.stringify(data.contentJsonData); + + // Save the content data in DB. + await this.h5pCore.saveContent(content, folderName, fileUrl, siteId); + + // Save the content files in their right place in FS. + const destFolder = CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName); + const contentPath = CoreTextUtils.instance.concatenatePaths(destFolder, 'content'); + + try { + await this.h5pCore.h5pFS.saveContent(contentPath, folderName, siteId); + } catch (error) { + // An error occurred, delete the DB data because the content files have been deleted. + await this.h5pFramework.deleteContentData(content.id!, siteId); + + throw error; + } + } + + return content; + } + +} + +/** + * Library to save. + */ +export type CoreH5PLibraryBeingSaved = Omit & { + libraryId?: number; // Library ID in the DB. + saveDependencies?: boolean; // Whether to save dependencies. + metadataSettings?: CoreH5PLibraryMetadataSettings | string; // Encoded metadata settings. +}; + +/** + * Data about a content being saved. + */ +export type CoreH5PContentBeingSaved = { + id?: number; + params?: string; + library?: CoreH5PContentLibrary; +}; + +export type CoreH5PContentLibrary = CoreH5PLibraryBasicData & { + libraryId?: number; // Library ID in the DB. +}; diff --git a/src/core/features/h5p/classes/validator.ts b/src/core/features/h5p/classes/validator.ts new file mode 100644 index 000000000..dc6994ef0 --- /dev/null +++ b/src/core/features/h5p/classes/validator.ts @@ -0,0 +1,328 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreFile, CoreFileFormat } from '@services/file'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreH5PSemantics } from './content-validator'; +import { CoreH5PCore, CoreH5PLibraryBasicData } from './core'; + +/** + * Equivalent to H5P's H5PValidator class. + */ +export class CoreH5PValidator { + + /** + * Get library data. + * This function won't validate most things because it should've been done by the server already. + * + * @param libDir Directory where the library files are. + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with library data. + */ + protected async getLibraryData(libDir: DirectoryEntry, libPath: string): Promise { + + // Read the required files. + const results = await Promise.all([ + this.readLibraryJsonFile(libPath), + this.readLibrarySemanticsFile(libPath), + this.readLibraryLanguageFiles(libPath), + this.libraryHasIcon(libPath), + ]); + + const libraryData: CoreH5PLibraryJsonData = results[0]; + libraryData.semantics = results[1]; + libraryData.language = results[2]; + libraryData.hasIcon = results[3]; + + return libraryData; + } + + /** + * Get library data for all libraries in an H5P package. + * + * @param packagePath The path to the package folder. + * @param entries List of files and directories in the root of the package folder. + * @retun Promise resolved with the libraries data. + */ + protected async getPackageLibrariesData( + packagePath: string, + entries: (DirectoryEntry | FileEntry)[], + ): Promise { + + const libraries: CoreH5PLibrariesJsonData = {}; + + await Promise.all(entries.map(async (entry) => { + if (entry.name[0] == '.' || entry.name[0] == '_' || entry.name == 'content' || entry.isFile) { + // Skip files, the content folder and any folder starting with a . or _. + return; + } + + const libDirPath = CoreTextUtils.instance.concatenatePaths(packagePath, entry.name); + + const libraryData = await this.getLibraryData( entry, libDirPath); + + libraryData.uploadDirectory = libDirPath; + libraries[CoreH5PCore.libraryToString(libraryData)] = libraryData; + })); + + return libraries; + } + + /** + * Check if the library has an icon file. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with boolean: whether the library has an icon file. + */ + protected async libraryHasIcon(libPath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'icon.svg'); + + try { + // Check if the file exists. + await CoreFile.instance.getFile(path); + + return true; + } catch (error) { + return false; + } + } + + /** + * Process libraries from an H5P library, getting the required data to save them. + * This code is inspired on the isValidPackage function in Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param packagePath The path to the package folder. + * @param entries List of files and directories in the root of the package folder. + * @return Promise resolved when done. + */ + async processH5PFiles(packagePath: string, entries: (DirectoryEntry | FileEntry)[]): Promise { + + // Read the needed files. + const results = await Promise.all([ + this.readH5PJsonFile(packagePath), + this.readH5PContentJsonFile(packagePath), + this.getPackageLibrariesData(packagePath, entries), + ]); + + return { + librariesJsonData: results[2], + mainJsonData: results[0], + contentJsonData: results[1], + }; + + } + + /** + * Read content.json file and return its parsed contents. + * + * @param packagePath The path to the package folder. + * @return Promise resolved with the parsed file contents. + */ + protected readH5PContentJsonFile(packagePath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(packagePath, 'content/content.json'); + + return CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } + + /** + * Read h5p.json file and return its parsed contents. + * + * @param packagePath The path to the package folder. + * @return Promise resolved with the parsed file contents. + */ + protected readH5PJsonFile(packagePath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(packagePath, 'h5p.json'); + + return CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } + + /** + * Read library.json file and return its parsed contents. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with the parsed file contents. + */ + protected readLibraryJsonFile(libPath: string): Promise { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'library.json'); + + return CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } + + /** + * Read all language files and return their contents indexed by language code. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with the language data. + */ + protected async readLibraryLanguageFiles(libPath: string): Promise { + try { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'language'); + const langIndex: CoreH5PLibraryLangsJsonData = {}; + + // Read all the files in the language directory. + const entries = await CoreFile.instance.getDirectoryContents(path); + + await Promise.all(entries.map(async (entry) => { + const langFilePath = CoreTextUtils.instance.concatenatePaths(path, entry.name); + + try { + const langFileData = await CoreFile.instance.readFile( + langFilePath, + CoreFileFormat.FORMATJSON, + ); + + const parts = entry.name.split('.'); // The language code is in parts[0]. + langIndex[parts[0]] = langFileData; + } catch (error) { + // Ignore this language. + } + })); + + return langIndex; + + } catch (error) { + // Probably doesn't exist, ignore. + } + } + + /** + * Read semantics.json file and return its parsed contents. + * + * @param libPath Path to the directory where the library files are. + * @return Promise resolved with the parsed file contents. + */ + protected async readLibrarySemanticsFile(libPath: string): Promise { + try { + const path = CoreTextUtils.instance.concatenatePaths(libPath, 'semantics.json'); + + return await CoreFile.instance.readFile(path, CoreFileFormat.FORMATJSON); + } catch (error) { + // Probably doesn't exist, ignore. + } + } + +} + +/** + * Data of the main JSON H5P files. + */ +export type CoreH5PMainJSONFilesData = { + contentJsonData: unknown; // Contents of content.json file. + librariesJsonData: CoreH5PLibrariesJsonData; // JSON data about each library. + mainJsonData: CoreH5PMainJSONData; // Contents of h5p.json file. +}; + +/** + * Data stored in h5p.json file of a content. More info in https://h5p.org/documentation/developers/json-file-definitions + */ +export type CoreH5PMainJSONData = { + title: string; // Title of the content. + mainLibrary: string; // The main H5P library for this content. + language: string; // Language code. + preloadedDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + embedTypes?: ('div' | 'iframe')[]; // List of possible ways to embed the package in the page. + authors?: { // The name and role of the content authors + name: string; + role: string; + }[]; + source?: string; // The source (a URL) of the licensed material. + license?: string; // A code for the content license. + licenseVersion?: string; // The version of the license above as a string. + licenseExtras?: string; // Any additional information about the license. + yearFrom?: string; // If a license is valid for a certain period of time, this represents the start year (as a string). + yearTo?: string; // If a license is valid for a certain period of time, this represents the end year (as a string). + changes?: { // The changelog. + date: string; + author: string; + log: string; + }[]; + authorComments?: string; // Comments for the editor of the content. +}; + +/** + * All JSON data for libraries of a package. + */ +export type CoreH5PLibrariesJsonData = {[libString: string]: CoreH5PLibraryJsonData}; + +/** + * All JSON data for a library, including semantics and language. + */ +export type CoreH5PLibraryJsonData = CoreH5PLibraryMainJsonData & { + semantics?: CoreH5PSemantics[]; // Data in semantics.json. + language?: CoreH5PLibraryLangsJsonData; // Language JSON data. + hasIcon?: boolean; // Whether the library has an icon. + uploadDirectory?: string; // Path where the lib is stored. +}; + +/** + * Data stored in library.json file of a library. More info in https://h5p.org/library-definition + */ +export type CoreH5PLibraryMainJsonData = { + title: string; // The human readable name of this library. + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + patchVersion: number; // Patch version. + runnable: number; // Whether or not this library is runnable. + coreApi?: { // Required version of H5P Core API. + majorVersion: number; + minorVersion: number; + }; + author?: string; // The name of the library author. + license?: string; // A code for the content license. + description?: string; // Textual description of the library. + preloadedDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + dynamicDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + editorDependencies?: CoreH5PLibraryBasicData[]; // Dependencies. + preloadedJs?: { path: string }[]; // List of path to the javascript files required for the library. + preloadedCss?: { path: string }[]; // List of path to the CSS files to be loaded with the library. + embedTypes?: ('div' | 'iframe')[]; // List of possible ways to embed the package in the page. + fullscreen?: number; // Enables the integrated full-screen button. + metadataSettings?: CoreH5PLibraryMetadataSettings; // Metadata settings. + addTo?: CoreH5PLibraryAddTo; +}; + +/** + * Library metadata settings. + */ +export type CoreH5PLibraryMetadataSettings = { + disable?: boolean | number; + disableExtraTitleField?: boolean | number; +}; + +/** + * Library plugin configuration data. + */ +export type CoreH5PLibraryAddTo = { + content?: { + types?: { + text?: { + regex?: string; + }; + }[]; + }; +}; + +/** + * Data stored in all languages JSON file of a library. + */ +export type CoreH5PLibraryLangsJsonData = {[code: string]: CoreH5PLibraryLangJsonData}; + +/** + * Data stored in each language JSON file of a library. + */ +export type CoreH5PLibraryLangJsonData = { + semantics?: CoreH5PSemantics[]; +}; diff --git a/src/core/features/h5p/h5p.module.ts b/src/core/features/h5p/h5p.module.ts new file mode 100644 index 000000000..e6fdab6e4 --- /dev/null +++ b/src/core/features/h5p/h5p.module.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { + CONTENT_TABLE_NAME, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, +} from './services/database/h5p'; + +@NgModule({ + imports: [ + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ + CONTENT_TABLE_NAME, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, + ], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + // @todo + }, + }, + ], +}) +export class CoreH5PModule {} diff --git a/src/core/features/h5p/services/database/h5p.ts b/src/core/features/h5p/services/database/h5p.ts new file mode 100644 index 000000000..027e848a1 --- /dev/null +++ b/src/core/features/h5p/services/database/h5p.ts @@ -0,0 +1,308 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreH5PProvider service. + */ +// DB table names. +export const CONTENT_TABLE_NAME = 'h5p_content'; // H5P content. +export const LIBRARIES_TABLE_NAME = 'h5p_libraries'; // Installed libraries. +export const LIBRARY_DEPENDENCIES_TABLE_NAME = 'h5p_library_dependencies'; // Library dependencies. +export const CONTENTS_LIBRARIES_TABLE_NAME = 'h5p_contents_libraries'; // Which library is used in which content. +export const LIBRARIES_CACHEDASSETS_TABLE_NAME = 'h5p_libraries_cachedassets'; // H5P cached library assets. +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreH5PProvider', + version: 1, + canBeCleared: [ + CONTENT_TABLE_NAME, + LIBRARIES_TABLE_NAME, + LIBRARY_DEPENDENCIES_TABLE_NAME, + CONTENTS_LIBRARIES_TABLE_NAME, + LIBRARIES_CACHEDASSETS_TABLE_NAME, + ], + tables: [ + { + name: CONTENT_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'jsoncontent', + type: 'TEXT', + notNull: true, + }, + { + name: 'mainlibraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'foldername', + type: 'TEXT', + notNull: true, + }, + { + name: 'fileurl', + type: 'TEXT', + notNull: true, + }, + { + name: 'filtered', + type: 'TEXT', + }, + { + name: 'timecreated', + type: 'INTEGER', + notNull: true, + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: LIBRARIES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'machinename', + type: 'TEXT', + notNull: true, + }, + { + name: 'title', + type: 'TEXT', + notNull: true, + }, + { + name: 'majorversion', + type: 'INTEGER', + notNull: true, + }, + { + name: 'minorversion', + type: 'INTEGER', + notNull: true, + }, + { + name: 'patchversion', + type: 'INTEGER', + notNull: true, + }, + { + name: 'runnable', + type: 'INTEGER', + notNull: true, + }, + { + name: 'fullscreen', + type: 'INTEGER', + notNull: true, + }, + { + name: 'embedtypes', + type: 'TEXT', + notNull: true, + }, + { + name: 'preloadedjs', + type: 'TEXT', + }, + { + name: 'preloadedcss', + type: 'TEXT', + }, + { + name: 'droplibrarycss', + type: 'TEXT', + }, + { + name: 'semantics', + type: 'TEXT', + }, + { + name: 'addto', + type: 'TEXT', + }, + ], + }, + { + name: LIBRARY_DEPENDENCIES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'requiredlibraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'dependencytype', + type: 'TEXT', + notNull: true, + }, + ], + }, + { + name: CONTENTS_LIBRARIES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'h5pid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'dependencytype', + type: 'TEXT', + notNull: true, + }, + { + name: 'dropcss', + type: 'INTEGER', + notNull: true, + }, + { + name: 'weight', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: LIBRARIES_CACHEDASSETS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'hash', + type: 'TEXT', + notNull: true, + }, + { + name: 'foldername', + type: 'TEXT', + notNull: true, + }, + ], + }, + ], +}; + +/** + * Structure of content data stored in DB. + */ +export type CoreH5PContentDBRecord = { + id: number; // The id of the content. + jsoncontent: string; // The content in json format. + mainlibraryid: number; // The library we first instantiate for this node. + foldername: string; // Name of the folder that contains the contents. + fileurl: string; // The online URL of the H5P package. + filtered: string | null; // Filtered version of json_content. + timecreated: number; // Time created. + timemodified: number; // Time modified. +}; + +/** + * Structure of library data stored in DB. + */ +export type CoreH5PLibraryDBRecord = { + id: number; // The id of the library. + machinename: string; // The library machine name. + title: string; // The human readable name of this library. + majorversion: number; // Major version. + minorversion: number; // Minor version. + patchversion: number; // Patch version. + runnable: number; // Can this library be started by the module? I.e. not a dependency. + fullscreen: number; // Display fullscreen button. + embedtypes: string; // List of supported embed types. + preloadedjs?: string | null; // Comma separated list of scripts to load. + preloadedcss?: string | null; // Comma separated list of stylesheets to load. + droplibrarycss?: string | null; // Libraries that should not have CSS included if this lib is used. Comma separated list. + semantics?: string | null; // The semantics definition. + addto?: string | null; // Plugin configuration data. +}; + +/** + * Structure of library dependencies stored in DB. + */ +export type CoreH5PLibraryDependencyDBRecord = { + id: number; // Id. + libraryid: number; // The id of an H5P library. + requiredlibraryid: number; // The dependent library to load. + dependencytype: string; // Type: preloaded, dynamic, or editor. +}; + +/** + * Structure of library used by a content stored in DB. + */ +export type CoreH5PContentsLibraryDBRecord = { + id: number; + h5pid: number; + libraryid: number; + dependencytype: string; + dropcss: number; + weight: number; +}; + +/** + * Structure of library cached assets stored in DB. + */ +export type CoreH5PLibraryCachedAssetsDBRecord = { + id: number; // Id. + libraryid: number; // The id of an H5P library. + hash: string; // The hash to identify the cached asset. + foldername: string; // Name of the folder that contains the contents. +}; diff --git a/src/core/features/h5p/services/h5p.ts b/src/core/features/h5p/services/h5p.ts new file mode 100644 index 000000000..bb3106fc6 --- /dev/null +++ b/src/core/features/h5p/services/h5p.ts @@ -0,0 +1,248 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreWSExternalWarning, CoreWSExternalFile } from '@services/ws'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreQueueRunner } from '@classes/queue-runner'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; + +import { CoreH5PCore } from '../classes/core'; +import { CoreH5PFramework } from '../classes/framework'; +import { CoreH5PPlayer } from '../classes/player'; +import { CoreH5PStorage } from '../classes/storage'; +import { CoreH5PValidator } from '../classes/validator'; + +import { makeSingleton } from '@singletons'; +import { CoreError } from '@classes/errors/error'; + +/** + * Service to provide H5P functionalities. + */ +@Injectable({ providedIn: 'root' }) +export class CoreH5PProvider { + + h5pCore: CoreH5PCore; + h5pFramework: CoreH5PFramework; + h5pPlayer: CoreH5PPlayer; + h5pStorage: CoreH5PStorage; + h5pValidator: CoreH5PValidator; + queueRunner: CoreQueueRunner; + + protected readonly ROOT_CACHE_KEY = 'CoreH5P:'; + + constructor() { + this.queueRunner = new CoreQueueRunner(1); + + this.h5pValidator = new CoreH5PValidator(); + this.h5pFramework = new CoreH5PFramework(); + this.h5pCore = new CoreH5PCore(this.h5pFramework); + this.h5pStorage = new CoreH5PStorage(this.h5pCore, this.h5pFramework); + this.h5pPlayer = new CoreH5PPlayer(this.h5pCore, this.h5pStorage); + } + + /** + * Returns whether or not WS to get trusted H5P file is available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.8 + */ + async canGetTrustedH5PFile(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canGetTrustedH5PFileInSite(site); + } + + /** + * Returns whether or not WS to get trusted H5P file is available in a certain site. + * + * @param site Site. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.8 + */ + canGetTrustedH5PFileInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site?.wsAvailable('core_h5p_get_trusted_h5p_file')); + } + + /** + * Get a trusted H5P file. + * + * @param url The file URL. + * @param options Options. + * @param ignoreCache Whether to ignore cache. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file data. + */ + async getTrustedH5PFile( + url: string, + options?: CoreH5PGetTrustedFileOptions, + ignoreCache?: boolean, + siteId?: string, + ): Promise { + + options = options || {}; + + const site = await CoreSites.instance.getSite(siteId); + + const data: CoreH5pGetTrustedH5pFileWSParams = { + url: this.treatH5PUrl(url, site.getURL()), + frame: options.frame ? 1 : 0, + export: options.export ? 1 : 0, + embed: options.embed ? 1 : 0, + copyright: options.copyright ? 1 : 0, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getTrustedH5PFileCacheKey(url), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const result: CoreH5PGetTrustedH5PFileResult = await site.read('core_h5p_get_trusted_h5p_file', data, preSets); + + if (result.warnings && result.warnings.length) { + throw result.warnings[0]; + } + + if (result.files && result.files.length) { + return result.files[0]; + } + + throw new CoreError('File not found'); + } + + /** + * Get cache key for trusted H5P file WS calls. + * + * @param url The file URL. + * @return Cache key. + */ + protected getTrustedH5PFileCacheKey(url: string): string { + return this.getTrustedH5PFilePrefixCacheKey() + url; + } + + /** + * Get prefixed cache key for trusted H5P file WS calls. + * + * @return Cache key. + */ + protected getTrustedH5PFilePrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'trustedH5PFile:'; + } + + /** + * Invalidates all trusted H5P file WS calls. + * + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllGetTrustedH5PFile(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getTrustedH5PFilePrefixCacheKey()); + } + + /** + * Invalidates get trusted H5P file WS call. + * + * @param url The URL of the file. + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + async invalidateGetTrustedH5PFile(url: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getTrustedH5PFileCacheKey(url)); + } + + /** + * Check whether H5P offline is disabled. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether is disabled. + */ + async isOfflineDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isOfflineDisabledInSite(site); + } + + /** + * Check whether H5P offline is disabled. + * + * @param site Site instance. If not defined, current site. + * @return Whether is disabled. + */ + isOfflineDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site?.isFeatureDisabled('NoDelegate_H5POffline')); + } + + /** + * Treat an H5P url before sending it to WS. + * + * @param url H5P file URL. + * @param siteUrl Site URL. + * @return Treated url. + */ + treatH5PUrl(url: string, siteUrl: string): string { + if (url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, '/webservice/pluginfile.php')) === 0) { + url = url.replace('/webservice/pluginfile', '/pluginfile'); + } + + return CoreUrlUtils.instance.removeUrlParams(url); + } + +} + +export class CoreH5P extends makeSingleton(CoreH5PProvider) {} + +/** + * Params of core_h5p_get_trusted_h5p_file WS. + */ +export type CoreH5pGetTrustedH5pFileWSParams = { + url: string; // H5P file url. + frame?: number; // The frame allow to show the bar options below the content. + export?: number; // The export allow to download the package. + embed?: number; // The embed allow to copy the code to your site. + copyright?: number; // The copyright option. +}; + +/** + * Options for core_h5p_get_trusted_h5p_file. + */ +export type CoreH5PGetTrustedFileOptions = { + frame?: boolean; // Whether to show the bar options below the content. + export?: boolean; // Whether to allow to download the package. + embed?: boolean; // Whether to allow to copy the code to your site. + copyright?: boolean; // The copyright option. +}; + +/** + * Result of core_h5p_get_trusted_h5p_file. + */ +export type CoreH5PGetTrustedH5PFileResult = { + files: CoreWSExternalFile[]; // Files. + warnings: CoreWSExternalWarning[]; // List of warnings. +}; diff --git a/src/core/services/file.ts b/src/core/services/file.ts index 30f015ae9..5f48fa2e8 100644 --- a/src/core/services/file.ts +++ b/src/core/services/file.ts @@ -70,10 +70,25 @@ export const enum CoreFileFormat { export class CoreFileProvider { // Formats to read a file. + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATTEXT = CoreFileFormat.FORMATTEXT; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATDATAURL = CoreFileFormat.FORMATDATAURL; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATBINARYSTRING = CoreFileFormat.FORMATBINARYSTRING; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATARRAYBUFFER = CoreFileFormat.FORMATARRAYBUFFER; + /** + * @deprecated since 3.9.5, use CoreFileFormat directly. + */ static readonly FORMATJSON = CoreFileFormat.FORMATJSON; // Folders. @@ -460,19 +475,25 @@ export class CoreFileProvider { * @param format Format to read the file. * @return Promise to be resolved when the file is read. */ - readFile(path: string, format: CoreFileFormat = CoreFileProvider.FORMATTEXT): Promise { + readFile( + path: string, + format?: CoreFileFormat.FORMATTEXT | CoreFileFormat.FORMATDATAURL | CoreFileFormat.FORMATBINARYSTRING, + ): Promise; + readFile(path: string, format: CoreFileFormat.FORMATARRAYBUFFER): Promise; + readFile(path: string, format: CoreFileFormat.FORMATJSON): Promise; + readFile(path: string, format: CoreFileFormat = CoreFileFormat.FORMATTEXT): Promise { // Remove basePath if it's in the path. path = this.removeStartingSlash(path.replace(this.basePath, '')); this.logger.debug('Read file ' + path + ' with format ' + format); switch (format) { - case CoreFileProvider.FORMATDATAURL: + case CoreFileFormat.FORMATDATAURL: return File.instance.readAsDataURL(this.basePath, path); - case CoreFileProvider.FORMATBINARYSTRING: + case CoreFileFormat.FORMATBINARYSTRING: return File.instance.readAsBinaryString(this.basePath, path); - case CoreFileProvider.FORMATARRAYBUFFER: + case CoreFileFormat.FORMATARRAYBUFFER: return File.instance.readAsArrayBuffer(this.basePath, path); - case CoreFileProvider.FORMATJSON: + case CoreFileFormat.FORMATJSON: return File.instance.readAsText(this.basePath, path).then((text) => { const parsed = CoreTextUtils.instance.parseJSON(text, null); @@ -494,8 +515,8 @@ export class CoreFileProvider { * @param format Format to read the file. * @return Promise to be resolved when the file is read. */ - readFileData(fileData: IFile, format: CoreFileFormat = CoreFileProvider.FORMATTEXT): Promise { - format = format || CoreFileProvider.FORMATTEXT; + readFileData(fileData: IFile, format: CoreFileFormat = CoreFileFormat.FORMATTEXT): Promise { + format = format || CoreFileFormat.FORMATTEXT; this.logger.debug('Read file from file data with format ' + format); return new Promise((resolve, reject): void => { @@ -503,7 +524,7 @@ export class CoreFileProvider { reader.onloadend = (event): void => { if (event.target?.result !== undefined && event.target.result !== null) { - if (format == CoreFileProvider.FORMATJSON) { + if (format == CoreFileFormat.FORMATJSON) { // Convert to object. const parsed = CoreTextUtils.instance.parseJSON( event.target.result, null); @@ -535,13 +556,13 @@ export class CoreFileProvider { }, 3000); switch (format) { - case CoreFileProvider.FORMATDATAURL: + case CoreFileFormat.FORMATDATAURL: reader.readAsDataURL(fileData); break; - case CoreFileProvider.FORMATBINARYSTRING: + case CoreFileFormat.FORMATBINARYSTRING: reader.readAsBinaryString(fileData); break; - case CoreFileProvider.FORMATARRAYBUFFER: + case CoreFileFormat.FORMATARRAYBUFFER: reader.readAsArrayBuffer(fileData); break; default: diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index 366b5e6fe..cf82d0c22 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -23,7 +23,7 @@ import { timeout } from 'rxjs/operators'; import { CoreNativeToAngularHttpResponse } from '@classes/native-to-angular-http'; import { CoreApp } from '@services/app'; -import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreFile, CoreFileFormat } from '@services/file'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; @@ -855,9 +855,9 @@ export class CoreWSProvider { // Use the cordova plugin. if (url.indexOf('file://') === 0) { // We cannot load local files using the http native plugin. Use file provider instead. - const format = options.responseType == 'json' ? CoreFileProvider.FORMATJSON : CoreFileProvider.FORMATTEXT; - - const content = await CoreFile.instance.readFile(url, format); + const content = options.responseType == 'json' ? + await CoreFile.instance.readFile(url, CoreFileFormat.FORMATJSON) : + await CoreFile.instance.readFile(url, CoreFileFormat.FORMATTEXT); return new HttpResponse({ body: content,