From 56faa66adcff8d42976ec40b3cea4c417ac9c4ce Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 28 Nov 2019 12:26:20 +0100 Subject: [PATCH] MOBILE-2235 h5p: Implement and use content validator --- scripts/langindex.json | 88 ++ src/assets/lang/en.json | 22 + src/core/h5p/classes/content-validator.ts | 1324 +++++++++++++++++ .../h5p/components/h5p-player/h5p-player.ts | 6 +- src/core/h5p/lang/en.json | 24 +- src/core/h5p/providers/h5p.ts | 291 ++-- src/core/h5p/providers/utils.ts | 21 + src/providers/utils/utils.ts | 8 +- 8 files changed, 1642 insertions(+), 142 deletions(-) create mode 100644 src/core/h5p/classes/content-validator.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index f6be29269..70f4eb012 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1552,6 +1552,94 @@ "core.group": "moodle", "core.groupsseparate": "moodle", "core.groupsvisible": "moodle", + "core.h5p.additionallicenseinfo": "h5p", + "core.h5p.author": "h5p", + "core.h5p.authorcomments": "h5p", + "core.h5p.authorcommentsdescription": "h5p", + "core.h5p.authorname": "h5p", + "core.h5p.authorrole": "h5p", + "core.h5p.by": "h5p", + "core.h5p.cancellabel": "h5p", + "core.h5p.ccattribution": "h5p", + "core.h5p.ccattributionnc": "h5p", + "core.h5p.ccattributionncnd": "h5p", + "core.h5p.ccattributionncsa": "h5p", + "core.h5p.ccattributionnd": "h5p", + "core.h5p.ccattributionsa": "h5p", + "core.h5p.ccpdd": "h5p", + "core.h5p.changedby": "h5p", + "core.h5p.changedescription": "h5p", + "core.h5p.changelog": "h5p", + "core.h5p.changeplaceholder": "h5p", + "core.h5p.close": "h5p", + "core.h5p.confirmdialogbody": "h5p", + "core.h5p.confirmdialogheader": "h5p", + "core.h5p.confirmlabel": "h5p", + "core.h5p.connectionLost": "h5p", + "core.h5p.connectionReestablished": "h5p", + "core.h5p.contentCopied": "h5p", + "core.h5p.contentchanged": "h5p", + "core.h5p.contenttype": "h5p", + "core.h5p.copyright": "h5p", + "core.h5p.copyrightinfo": "h5p", + "core.h5p.copyrightstring": "h5p", + "core.h5p.copyrighttitle": "h5p", + "core.h5p.creativecommons": "h5p", + "core.h5p.date": "h5p", + "core.h5p.disablefullscreen": "h5p", + "core.h5p.download": "h5p", + "core.h5p.downloadtitle": "h5p", + "core.h5p.editor": "h5p", + "core.h5p.embed": "h5p", + "core.h5p.embedtitle": "h5p", + "core.h5p.fullscreen": "h5p", + "core.h5p.gpl": "h5p", + "core.h5p.h5ptitle": "h5p", + "core.h5p.hideadvanced": "h5p", + "core.h5p.license": "h5p", + "core.h5p.licenseCC010": "h5p", + "core.h5p.licenseCC010U": "h5p", + "core.h5p.licenseCC10": "h5p", + "core.h5p.licenseCC20": "h5p", + "core.h5p.licenseCC25": "h5p", + "core.h5p.licenseCC30": "h5p", + "core.h5p.licenseCC40": "h5p", + "core.h5p.licenseGPL": "h5p", + "core.h5p.licenseV1": "h5p", + "core.h5p.licenseV2": "h5p", + "core.h5p.licenseV3": "h5p", + "core.h5p.licensee": "h5p", + "core.h5p.licenseextras": "h5p", + "core.h5p.licenseversion": "h5p", + "core.h5p.nocopyright": "h5p", + "core.h5p.offlineDialogBody": "h5p", + "core.h5p.offlineDialogHeader": "h5p", + "core.h5p.offlineDialogRetryButtonLabel": "h5p", + "core.h5p.offlineDialogRetryMessage": "h5p", + "core.h5p.offlineSuccessfulSubmit": "h5p", + "core.h5p.originator": "h5p", + "core.h5p.pd": "h5p", + "core.h5p.pddl": "h5p", + "core.h5p.pdm": "h5p", + "core.h5p.resizescript": "h5p", + "core.h5p.resubmitScores": "h5p", + "core.h5p.reuse": "h5p", + "core.h5p.reuseContent": "h5p", + "core.h5p.reuseDescription": "h5p", + "core.h5p.showadvanced": "h5p", + "core.h5p.showless": "h5p", + "core.h5p.showmore": "h5p", + "core.h5p.size": "h5p", + "core.h5p.source": "h5p", + "core.h5p.startingover": "h5p", + "core.h5p.sublevel": "h5p", + "core.h5p.thumbnail": "h5p", + "core.h5p.title": "h5p", + "core.h5p.undisclosed": "h5p", + "core.h5p.year": "h5p", + "core.h5p.years": "h5p", + "core.h5p.yearsfrom": "h5p", + "core.h5p.yearsto": "h5p", "core.hasdatatosync": "local_moodlemobileapp", "core.help": "moodle", "core.hide": "moodle", diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 0b7468465..5a3093332 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1550,7 +1550,12 @@ "core.group": "Group", "core.groupsseparate": "Separate groups", "core.groupsvisible": "Visible groups", + "core.h5p.additionallicenseinfo": "Any additional information about the license", "core.h5p.author": "Author", + "core.h5p.authorcomments": "Author comments", + "core.h5p.authorcommentsdescription": "Comments for the editor of the content. (This text will not be published as a part of the copyright info.)", + "core.h5p.authorname": "Author's name", + "core.h5p.authorrole": "Author's role", "core.h5p.by": "by", "core.h5p.cancellabel": "Cancel", "core.h5p.ccattribution": "Attribution (CC BY)", @@ -1559,7 +1564,11 @@ "core.h5p.ccattributionncsa": "Attribution-NonCommercial-ShareAlike (CC BY-NC-SA)", "core.h5p.ccattributionnd": "Attribution-NoDerivs (CC BY-ND)", "core.h5p.ccattributionsa": "Attribution-ShareAlike (CC BY-SA)", + "core.h5p.ccpdd": "Public Domain Dedication (CC0)", + "core.h5p.changedby": "Changed by", + "core.h5p.changedescription": "Description of change", "core.h5p.changelog": "Changelog", + "core.h5p.changeplaceholder": "Photo cropped, text changed, etc.", "core.h5p.close": "Close", "core.h5p.confirmdialogbody": "Please confirm that you wish to proceed. This action is not reversible.", "core.h5p.confirmdialogheader": "Confirm action", @@ -1570,18 +1579,24 @@ "core.h5p.contentchanged": "This content has changed since you last used it.", "core.h5p.contenttype": "Content Type", "core.h5p.copyright": "Rights of use", + "core.h5p.copyrightinfo": "Copyright information", "core.h5p.copyrightstring": "Copyright", "core.h5p.copyrighttitle": "View copyright information for this content.", + "core.h5p.creativecommons": "Creative Commons", + "core.h5p.date": "Date", "core.h5p.disablefullscreen": "Disable fullscreen", "core.h5p.download": "Download", "core.h5p.downloadtitle": "Download this content as a H5P file.", + "core.h5p.editor": "Editor", "core.h5p.embed": "Embed", "core.h5p.embedtitle": "View the embed code for this content.", "core.h5p.fullscreen": "Fullscreen", + "core.h5p.gpl": "General Public License v3", "core.h5p.h5ptitle": "Visit H5P.org to check out more cool content.", "core.h5p.hideadvanced": "Hide advanced", "core.h5p.license": "License", "core.h5p.licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "core.h5p.licenseCC010U": "CC0 1.0 Universal", "core.h5p.licenseCC10": "1.0 Generic", "core.h5p.licenseCC20": "2.0 Generic", "core.h5p.licenseCC25": "2.5 Generic", @@ -1591,14 +1606,18 @@ "core.h5p.licenseV1": "Version 1", "core.h5p.licenseV2": "Version 2", "core.h5p.licenseV3": "Version 3", + "core.h5p.licensee": "Licensee", "core.h5p.licenseextras": "License Extras", + "core.h5p.licenseversion": "License version", "core.h5p.nocopyright": "No copyright information available for this content.", "core.h5p.offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", "core.h5p.offlineDialogHeader": "Your connection to the server was lost", "core.h5p.offlineDialogRetryButtonLabel": "Retry now", "core.h5p.offlineDialogRetryMessage": "Retrying in :num....", "core.h5p.offlineSuccessfulSubmit": "Successfully submitted results.", + "core.h5p.originator": "Originator", "core.h5p.pd": "Public Domain", + "core.h5p.pddl": "Public Domain Dedication and Licence", "core.h5p.pdm": "Public Domain Mark (PDM)", "core.h5p.play": "Play H5P", "core.h5p.resizescript": "Include this script on your website if you want dynamic sizing of the embedded content:", @@ -1617,6 +1636,9 @@ "core.h5p.title": "Title", "core.h5p.undisclosed": "Undisclosed", "core.h5p.year": "Year", + "core.h5p.years": "Year(s)", + "core.h5p.yearsfrom": "Years (from)", + "core.h5p.yearsto": "Years (to)", "core.hasdatatosync": "This {{$a}} has offline data to be synchronised.", "core.help": "Help", "core.hide": "Hide", diff --git a/src/core/h5p/classes/content-validator.ts b/src/core/h5p/classes/content-validator.ts new file mode 100644 index 000000000..18ae59dc3 --- /dev/null +++ b/src/core/h5p/classes/content-validator.ts @@ -0,0 +1,1324 @@ +// (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 { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreH5PProvider, CoreH5PLibraryData, CoreH5PLibraryAddonData, CoreH5PContentDepsTreeDependency } from '../providers/h5p'; +import { CoreH5PUtilsProvider } from '../providers/utils'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Equivalent to Moodle's H5PContentValidator, but without some of the validations. + * It's also used to build the dependency list. + */ +export class CoreH5PContentValidator { + protected static ALLOWED_STYLEABLE_TAGS = ['span', 'p', 'div', 'h1', 'h2', 'h3', 'td']; + + protected typeMap = { + text: 'validateText', + number: 'validateNumber', + boolean: 'validateBoolean', + 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]: any} = {}; + protected allowedStyles: RegExp[]; + protected metadataSemantics: any[]; + protected copyrightSemantics: any; + + constructor(protected h5pProvider: CoreH5PProvider, + protected h5pUtils: CoreH5PUtilsProvider, + protected textUtils: CoreTextUtilsProvider, + protected utils: CoreUtilsProvider, + protected translate: TranslateService, + protected siteId: string) { } + + /** + * Add Addon library. + * + * @param library The addon library to add. + * @return Promise resolved when done. + */ + addon(library: CoreH5PLibraryAddonData): Promise { + const depKey = 'preloaded-' + library.machineName; + + this.dependencies[depKey] = { + library: library, + type: 'preloaded' + }; + + return this.h5pProvider.findLibraryDependencies(this.dependencies, library, this.nextWeight).then((weight) => { + this.nextWeight = weight; + this.dependencies[depKey].weight = this.nextWeight++; + }); + } + + /** + * Get the flat dependency tree. + * + * @return array + */ + getDependencies(): {[key: string]: CoreH5PContentDepsTreeDependency} { + return this.dependencies; + } + + /** + * Validate metadata + * + * @param metadata Metadata. + * @return Promise resolved with metadata validated & filtered. + */ + validateMetadata(metadata: any): Promise { + const semantics = this.getMetadataSemantics(); + const group = this.utils.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: any): 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 = this.utils.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 = this.textUtils.escapeHTML(text); + } + + // 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. + */ + 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(num: any, semantics: any): number { + // Validate that num is indeed a number. + num = Number(num); + 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), + rest = testNumber % semantics.step; + if (rest !== 0) { + num -= rest; + } + } + // Check if number has proper number of decimals. + if (typeof semantics.decimals != 'undefined') { + num = num.toFixed(semantics.decimals); + } + + return num; + } + + /** + * Validate given value against boolean semantics. + * + * @param bool Boolean to check. + * @return Validated bool. + */ + validateBoolean(bool: boolean): boolean { + return !!bool; + } + + /** + * Validate select values. + * + * @param select Values to validate. + * @param semantics Semantics. + * @return Validated select. + */ + validateSelect(select: any, semantics: any): any { + const optional = semantics.optional, + options = {}; + 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) => { + // 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) { + // 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] = this.textUtils.escapeHTML(value); + } + } + } 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 = this.textUtils.escapeHTML(select); + } + + 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. + */ + validateList(list: any, semantics: any): Promise { + const field = semantics.field, + fn = this[this.typeMap[field.type]].bind(this); + let promise = Promise.resolve(), // Use a chain of promises so the order is kept. + 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. + keys.forEach((key) => { + if (isNaN(parseInt(key, 10))) { + // It's an object and the key isn't an integer. Delete it. + delete list[key]; + } else { + promise = promise.then(() => { + return Promise.resolve(fn(list[key], field)).then((val) => { + if (val === null) { + list.splice(key, 1); + } else { + list[key] = val; + } + }); + }); + } + }); + + return promise.then(() => { + + if (!Array.isArray(list)) { + list = this.utils.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 validateFilelike(file: any, semantics: any, 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 = this.textUtils.escapeHTML(file.path); + if (file.mime) { + file.mime = this.textUtils.escapeHTML(file.mime); + } + + // 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 = this.utils.uniqueArray(validKeys); + + this.filterParams(file, validKeys); + + if (typeof file.width != 'undefined') { + file.width = parseInt(file.width, 10); + } + + if (typeof file.height != 'undefined') { + file.height = parseInt(file.height, 10); + } + + if (file.codecs) { + file.codecs = this.textUtils.escapeHTML(file.codecs); + } + + if (typeof file.bitrate != 'undefined') { + 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 = parseInt(file.quality.level); + file.quality.label = this.textUtils.escapeHTML(file.quality.label); + } + } + + if (typeof file.copyright != 'undefined') { + return this.validateGroup(file.copyright, this.getCopyrightSemantics()).then(() => { + return file; + }); + } + + return Promise.resolve(file); + } + + /** + * Validate given file data. + * + * @param file File. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateFile(file: any, semantics: any): 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: any, semantics: any): 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. + */ + validateVideo(video: any, semantics: any): Promise { + let promise = Promise.resolve(); // Use a chain of promises so the order is kept. + + for (const key in video) { + promise = promise.then(() => { + return this.validateFilelike(video[key], semantics, ['width', 'height', 'codecs', 'quality', 'bitrate']); + }); + } + + return promise.then(() => { + return video; + }); + } + + /** + * Validate given audio data. + * + * @param audio Audio. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateAudio(audio: any, semantics: any): Promise { + let promise = Promise.resolve(); // Use a chain of promises so the order is kept. + + for (const key in audio) { + promise = promise.then(() => { + return this.validateFilelike(audio[key], semantics); + }); + } + + return promise.then(() => { + 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. + */ + validateGroup(group: any, semantics: any, flatten: boolean = true): Promise { + // Groups with just one field are compressed in the editor to only output he child content. + + const isSubContent = semantics.isSubContent === true; + + if (semantics.fields.length == 1 && flatten && !isSubContent) { + const field = semantics.fields[0], + fn = this[this.typeMap[field.type]].bind(this); + + return Promise.resolve(fn(group, field)); + } else { + let promise = Promise.resolve(); // Use a chain of promises so the order is kept. + + for (const key in group) { + // If subContentId is set, keep value + if (isSubContent && key == 'subContentId') { + continue; + } + + // Find semantics for name=$key + let found = false, + fn = null, + field = null; + + for (let i = 0; i < semantics.fields.length; i++) { + field = semantics.fields[i]; + + if (field.name == key) { + if (semantics.optional) { + field.optional = true; + } + fn = this[this.typeMap[field.type]].bind(this); + found = true; + break; + } + } + + if (found && fn) { + promise = promise.then(() => { + return Promise.resolve(fn(group[key], field)).then((val) => { + group[key] = val; + if (val === null) { + delete group[key]; + } + }); + }); + } else { + // Something exists in content that does not have a corresponding semantics field. Remove it. + delete group.key; + } + } + + return promise.then(() => { + return group; + }); + } + } + + /** + * 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. + */ + validateLibrary(value: any, semantics: any): Promise { + if (!value.library) { + return Promise.resolve(); + } + + let promise; + + if (!this.libraries[value.library]) { + const libSpec = this.h5pUtils.libraryFromString(value.library); + + promise = this.h5pProvider.loadLibrary(libSpec.machineName, libSpec.majorVersion, libSpec.minorVersion, this.siteId) + .then((library) => { + this.libraries[value.library] = library; + + return library; + }); + } else { + promise = Promise.resolve(this.libraries[value.library]); + } + + return promise.then((library) => { + // Validate parameters. + return this.validateGroup(value.params, {type: 'group', fields: library.semantics}, false).then((validated) => { + + value.params = validated; + + // Validate subcontent's metadata + if (value.metadata) { + return this.validateMetadata(value.metadata).then((res) => { + value.metadata = res; + }); + } + }).then(() => { + + let validKeys = ['library', 'params', 'subContentId', 'metadata']; + if (semantics.extraAttributes) { + validKeys = this.utils.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' + }; + + return this.h5pProvider.findLibraryDependencies(this.dependencies, library, this.nextWeight).then((weight) => { + this.nextWeight = weight; + 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: any, 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 str 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(str: string, allowedTags?: string[], allowedStyles?: RegExp[]): string { + if (!str || typeof str != 'string') { + return str; + } + + 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. + str = str.replace(/&\s*\{[^}]*(\}\s*;?|$)/g, ''); + + // Defuse all HTML entities. + str = str.replace(/&/g, '&'); + + // Change back only well-formed entities in our whitelist: + // Decimal numeric entities. + str = str.replace(/&#([0-9]+;)/g, '&#$1'); + // Hexadecimal numeric entities. + str = str.replace(/&#[Xx]0*((?:[0-9A-Fa-f]{2})+;)/g, '&#x$1'); + // Named entities. + str = str.replace(/&([A-Za-z][A-Za-z0-9]*;)/g, '&$1'); + + const matches = str.match(/(<(?=[^a-zA-Z!\/])||<[^>]*(>|$)|>)/g); + if (matches && matches.length) { + matches.forEach((match) => { + str = str.replace(match, this.filterXssSplit([match])); + }); + } + + return str; + } + + /** + * Processes an HTML tag. + * + * @param m 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(m: string[], store: boolean = false): string { + + if (store) { + this.allowedHtml = this.utils.arrayToObject(m); + + return ''; + } + + const str = m[0]; + + if (str.substr(0, 1) != '<') { + // We matched a lone ">" character. + return '>'; + } else if (str.length == 1) { + // We matched a lone "<" character. + return '<'; + } + + const matches = str.match(/^<\s*(\/\s*)?([a-zA-Z0-9\-]+)([^>]*)>?|()$/); + if (!matches) { + // Seriously malformed. + return ''; + } + + const slash = matches[1] ? matches[1].trim() : '', + attrList = matches[3] || '', + 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'), + xhtmlSlash = attrList != newAttrList ? ' /' : ''; + + // Clean up attributes. + let attr2 = this.filterXssAttributes(newAttrList, + (CoreH5PContentValidator.ALLOWED_STYLEABLE_TAGS.indexOf(elem) != -1 ? this.allowedStyles : null)).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 attrArr = []; + let mode = 0, + attrName = '', + skip = false; + + while (attr.length != 0) { + // Was the last operation successful? + let working = 0, + matches, + thisVal; + + 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) { + attrArr.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. + attrArr.push('style="' + matches[1] + '"'); + break; + } + } + break; + } + + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArr.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) { + attrArr.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) { + attrArr.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) { + attrArr.push(attrName); + } + + return attrArr; + } + + /** + * 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 $string. + * @return Cleaned up and HTML-escaped version of $string. + */ + filterXssBadProtocol(str: string, decode: boolean = true): string { + // Get the plain text representation of the attribute value (i.e. its meaning). + if (decode) { + str = this.textUtils.decodeHTMLEntities(str); + } + + return this.textUtils.escapeHTML(this.stripDangerousProtocols(str)); + } + + /** + * 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; + + // 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(): any[] { + + if (this.metadataSemantics) { + return this.metadataSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.metadataSemantics = [ + { + name: 'title', + type: 'text', + label: this.translate.instant('core.h5p.title'), + placeholder: 'La Gioconda' + }, + { + name: 'license', + type: 'select', + label: this.translate.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: this.translate.instant('core.h5p.undisclosed') + }, + { + type: 'optgroup', + label: this.translate.instant('core.h5p.creativecommons'), + options: [ + { + value: 'CC BY', + label: this.translate.instant('core.h5p.ccattribution'), + versions: ccVersions + }, + { + value: 'CC BY-SA', + label: this.translate.instant('core.h5p.ccattributionsa'), + versions: ccVersions + }, + { + value: 'CC BY-ND', + label: this.translate.instant('core.h5p.ccattributionnd'), + versions: ccVersions + }, + { + value: 'CC BY-NC', + label: this.translate.instant('core.h5p.ccattributionnc'), + versions: ccVersions + }, + { + value: 'CC BY-NC-SA', + label: this.translate.instant('core.h5p.ccattributionncsa'), + versions: ccVersions + }, + { + value: 'CC BY-NC-ND', + label: this.translate.instant('core.h5p.ccattributionncnd'), + versions: ccVersions + }, + { + value: 'CC0 1.0', + label: this.translate.instant('core.h5p.ccpdd') + }, + { + value: 'CC PDM', + label: this.translate.instant('core.h5p.pdm') + }, + ] + }, + { + value: 'GNU GPL', + label: this.translate.instant('core.h5p.gpl') + }, + { + value: 'PD', + label: this.translate.instant('core.h5p.pd') + }, + { + value: 'ODC PDDL', + label: this.translate.instant('core.h5p.pddl') + }, + { + value: 'C', + label: this.translate.instant('core.h5p.copyrightstring') + } + ] + }, + { + name: 'licenseVersion', + type: 'select', + label: this.translate.instant('core.h5p.licenseversion'), + options: ccVersions, + optional: true + }, + { + name: 'yearFrom', + type: 'number', + label: this.translate.instant('core.h5p.yearsfrom'), + placeholder: '1991', + min: '-9999', + max: '9999', + optional: true + }, + { + name: 'yearTo', + type: 'number', + label: this.translate.instant('core.h5p.yearsto'), + placeholder: '1992', + min: '-9999', + max: '9999', + optional: true + }, + { + name: 'source', + type: 'text', + label: this.translate.instant('core.h5p.source'), + placeholder: 'https://', + optional: true + }, + { + name: 'authors', + type: 'list', + field: { + name: 'author', + type: 'group', + fields: [ + { + label: this.translate.instant('core.h5p.authorname'), + name: 'name', + optional: true, + type: 'text' + }, + { + name: 'role', + type: 'select', + label: this.translate.instant('core.h5p.authorrole'), + default: 'Author', + options: [ + { + value: 'Author', + label: this.translate.instant('core.h5p.author') + }, + { + value: 'Editor', + label: this.translate.instant('core.h5p.editor') + }, + { + value: 'Licensee', + label: this.translate.instant('core.h5p.licensee') + }, + { + value: 'Originator', + label: this.translate.instant('core.h5p.originator') + } + ] + } + ] + } + }, + { + name: 'licenseExtras', + type: 'text', + widget: 'textarea', + label: this.translate.instant('core.h5p.licenseextras'), + optional: true, + description: this.translate.instant('core.h5p.additionallicenseinfo') + }, + { + name: 'changes', + type: 'list', + field: { + name: 'change', + type: 'group', + label: this.translate.instant('core.h5p.changelog'), + fields: [ + { + name: 'date', + type: 'text', + label: this.translate.instant('core.h5p.date'), + optional: true + }, + { + name: 'author', + type: 'text', + label: this.translate.instant('core.h5p.changedby'), + optional: true + }, + { + name: 'log', + type: 'text', + widget: 'textarea', + label: this.translate.instant('core.h5p.changedescription'), + placeholder: this.translate.instant('core.h5p.changeplaceholder'), + optional: true + } + ] + } + }, + { + name: 'authorComments', + type: 'text', + widget: 'textarea', + label: this.translate.instant('core.h5p.authorcomments'), + description: this.translate.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(): any { + + if (this.copyrightSemantics) { + return this.copyrightSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.copyrightSemantics = { + name: 'copyright', + type: 'group', + label: this.translate.instant('core.h5p.copyrightinfo'), + fields: [ + { + name: 'title', + type: 'text', + label: this.translate.instant('core.h5p.title'), + placeholder: 'La Gioconda', + optional: true + }, + { + name: 'author', + type: 'text', + label: this.translate.instant('core.h5p.author'), + placeholder: 'Leonardo da Vinci', + optional: true + }, + { + name: 'year', + type: 'text', + label: this.translate.instant('core.h5p.years'), + placeholder: '1503 - 1517', + optional: true + }, + { + name: 'source', + type: 'text', + label: this.translate.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: this.translate.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: this.translate.instant('core.h5p.undisclosed') + }, + { + value: 'CC BY', + label: this.translate.instant('core.h5p.ccattribution'), + versions: ccVersions + }, + { + value: 'CC BY-SA', + label: this.translate.instant('core.h5p.ccattributionsa'), + versions: ccVersions + }, + { + value: 'CC BY-ND', + label: this.translate.instant('core.h5p.ccattributionnd'), + versions: ccVersions + }, + { + value: 'CC BY-NC', + label: this.translate.instant('core.h5p.ccattributionnc'), + versions: ccVersions + }, + { + value: 'CC BY-NC-SA', + label: this.translate.instant('core.h5p.ccattributionncsa'), + versions: ccVersions + }, + { + value: 'CC BY-NC-ND', + label: this.translate.instant('core.h5p.ccattributionncnd'), + versions: ccVersions + }, + { + value: 'GNU GPL', + label: this.translate.instant('core.h5p.licenseGPL'), + versions: [ + { + value: 'v3', + label: this.translate.instant('core.h5p.licenseV3') + }, + { + value: 'v2', + label: this.translate.instant('core.h5p.licenseV2') + }, + { + value: 'v1', + label: this.translate.instant('core.h5p.licenseV1') + } + ] + }, + { + value: 'PD', + label: this.translate.instant('core.h5p.pd'), + versions: [ + { + value: '-', + label: '-' + }, + { + value: 'CC0 1.0', + label: this.translate.instant('core.h5p.licenseCC010U') + }, + { + value: 'CC PDM', + label: this.translate.instant('core.h5p.pdm') + } + ] + }, + { + value: 'C', + label: this.translate.instant('core.h5p.copyrightstring') + } + ] + }, + { + name: 'version', + type: 'select', + label: this.translate.instant('core.h5p.licenseversion'), + options: [] + } + ] + }; + + return this.copyrightSemantics; + } + + /** + * Get CC versions for semantics. + * + * @return CC versions. + */ + protected getCCVersions(): any[] { + return [ + { + value: '4.0', + label: this.translate.instant('core.h5p.licenseCC40') + }, + { + value: '3.0', + label: this.translate.instant('core.h5p.licenseCC30') + }, + { + value: '2.5', + label: this.translate.instant('core.h5p.licenseCC25') + }, + { + value: '2.0', + label: this.translate.instant('core.h5p.licenseCC20') + }, + { + value: '1.0', + label: this.translate.instant('core.h5p.licenseCC10') + } + ]; + } +} diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index 11a5901fc..f13b33073 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -129,8 +129,12 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { // Local package. this.playerSrc = url; } else { + // Never allow downloading in the app. This will only work if the user is allowed to change the params. + const src = this.src && this.src.replace(CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD + '=1', + CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD + '=0'); + // Get auto-login URL so the user is automatically authenticated. - return this.sitesProvider.getCurrentSite().getAutoLoginUrl(this.src, false).then((url) => { + return this.sitesProvider.getCurrentSite().getAutoLoginUrl(src, false).then((url) => { // Add the preventredirect param so the user can authenticate. this.playerSrc = this.urlUtils.addParamsToUrl(url, {preventredirect: false}); }); diff --git a/src/core/h5p/lang/en.json b/src/core/h5p/lang/en.json index 0cffba19a..a85304502 100644 --- a/src/core/h5p/lang/en.json +++ b/src/core/h5p/lang/en.json @@ -1,5 +1,10 @@ { + "additionallicenseinfo": "Any additional information about the license", "author": "Author", + "authorcomments": "Author comments", + "authorcommentsdescription": "Comments for the editor of the content. (This text will not be published as a part of the copyright info.)", + "authorname": "Author's name", + "authorrole": "Author's role", "by": "by", "cancellabel": "Cancel", "ccattribution": "Attribution (CC BY)", @@ -8,7 +13,11 @@ "ccattributionncsa": "Attribution-NonCommercial-ShareAlike (CC BY-NC-SA)", "ccattributionnd": "Attribution-NoDerivs (CC BY-ND)", "ccattributionsa": "Attribution-ShareAlike (CC BY-SA)", + "ccpdd": "Public Domain Dedication (CC0)", + "changedby": "Changed by", + "changedescription": "Description of change", "changelog": "Changelog", + "changeplaceholder": "Photo cropped, text changed, etc.", "close": "Close", "confirmdialogbody": "Please confirm that you wish to proceed. This action is not reversible.", "confirmdialogheader": "Confirm action", @@ -19,18 +28,24 @@ "contentchanged": "This content has changed since you last used it.", "contenttype": "Content Type", "copyright": "Rights of use", + "copyrightinfo": "Copyright information", "copyrightstring": "Copyright", "copyrighttitle": "View copyright information for this content.", + "creativecommons": "Creative Commons", + "date": "Date", "disablefullscreen": "Disable fullscreen", "download": "Download", "downloadtitle": "Download this content as a H5P file.", + "editor": "Editor", "embed": "Embed", "embedtitle": "View the embed code for this content.", "fullscreen": "Fullscreen", + "gpl": "General Public License v3", "h5ptitle": "Visit H5P.org to check out more cool content.", "hideadvanced": "Hide advanced", "license": "License", "licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "licenseCC010U": "CC0 1.0 Universal", "licenseCC10": "1.0 Generic", "licenseCC20": "2.0 Generic", "licenseCC25": "2.5 Generic", @@ -40,14 +55,18 @@ "licenseV1": "Version 1", "licenseV2": "Version 2", "licenseV3": "Version 3", + "licensee": "Licensee", "licenseextras": "License Extras", + "licenseversion": "License version", "nocopyright": "No copyright information available for this content.", "offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", "offlineDialogHeader": "Your connection to the server was lost", "offlineDialogRetryButtonLabel": "Retry now", "offlineDialogRetryMessage": "Retrying in :num....", "offlineSuccessfulSubmit": "Successfully submitted results.", + "originator": "Originator", "pd": "Public Domain", + "pddl": "Public Domain Dedication and Licence", "pdm": "Public Domain Mark (PDM)", "play": "Play H5P", "resizescript": "Include this script on your website if you want dynamic sizing of the embedded content:", @@ -65,5 +84,8 @@ "thumbnail": "Thumbnail", "title": "Title", "undisclosed": "Undisclosed", - "year": "Year" + "year": "Year", + "years": "Year(s)", + "yearsfrom": "Years (from)", + "yearsto": "Years (to)" } diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index e6c9e76e9..adb3f4fbd 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -25,6 +25,8 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PUtilsProvider } from './utils'; +import { CoreH5PContentValidator } from '../classes/content-validator'; +import { TranslateService } from '@ngx-translate/core'; import { FileEntry } from '@ionic-native/file'; /** @@ -304,7 +306,8 @@ export class CoreH5PProvider { private h5pUtils: CoreH5PUtilsProvider, private filepoolProvider: CoreFilepoolProvider, private utils: CoreUtilsProvider, - private urlUtils: CoreUrlUtilsProvider) { + private urlUtils: CoreUrlUtilsProvider, + private translate: TranslateService) { this.logger = logger.getInstance('CoreH5PProvider'); @@ -415,6 +418,7 @@ export class CoreH5PProvider { * @return Promise resolved with all of the files content in one string. */ protected concatenateFiles(assets: CoreH5PDependencyAsset[], type: string): Promise { + const basePath = this.fileProvider.getBasePathInstant(); let content = '', promise = Promise.resolve(); // Use a chain of promises so the order is kept. @@ -433,39 +437,42 @@ export class CoreH5PProvider { if (matches && matches.length) { matches.forEach((match) => { - let url = match.replace(/(url\([\'"]?|[\'"]?\)$)/i, ''); + 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. - For instance: - Path: /H5P.Question-1.4/styles/ - Url: ../images/plus-one.svg - We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg. */ + filepath for the first folder in the url. */ if (url.match(/^\.\.\//)) { - const pathSplit = assetPath.split('/'), - urlSplit = url.split('/').filter((i) => { + const urlSplit = url.split('/').filter((i) => { return i; // Remove empty values. }); - // Remove the first element: ../. - urlSplit.unshift(); + // 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('/'); + url = pathSplit.join('/') + '/' + urlSplit.join('/'); - fileContent = fileContent.replace(new RegExp(this.textUtils.escapeForRegex(match), 'g'), - 'url("' + url + '")'); + } 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(this.textUtils.escapeForRegex(match), 'g'), + 'url("' + this.textUtils.concatenatePaths(basePath, url) + '")'); }); } @@ -510,11 +517,11 @@ export class CoreH5PProvider { url: this.getEmbedUrl(site.getURL(), h5pUrl), contentUrl: contentUrl, metadata: content.metadata, - contentUserData: { - 0: { + contentUserData: [ + { state: '{}' } - } + ] }; // Get the core H5P assets, needed by the H5P classes to render the H5P content. @@ -859,8 +866,7 @@ export class CoreH5PProvider { return Promise.resolve(null); } - const dependencies = {}, // In web, dependencies are built by the validator. - params = { + const params = { library: this.libraryToString(content.library), params: this.textUtils.parseJSON(content.params, false) }; @@ -869,90 +875,65 @@ export class CoreH5PProvider { return null; } - // Get the main library data. - return this.loadLibrary(content.library.name, content.library.majorVersion, content.library.minorVersion, siteId) - .then((library) => { + const validator = new CoreH5PContentValidator(this, this.h5pUtils, this.textUtils, this.utils, this.translate, siteId); - library.semantics = this.textUtils.parseJSON(library.semantics, ''); + // Validate the main library and its dependencies. + return validator.validateLibrary(params, {options: [params.library]}).then(() => { - const depKey = 'preloaded-' + library.machineName; - let nextWeight; + // Handle addons. + return this.loadAddons(siteId); + }).then((addons) => { + // Validate addons. Use a chain of promises to calculate the weight properly. + let promise = Promise.resolve(); - if (!dependencies[depKey]) { - dependencies[depKey] = { - library: library, - type: 'preloaded' - }; - } + addons.forEach((addon) => { + const addTo = addon.addTo; - // Get the whole library dependency tree. - return this.findLibraryDependencies(dependencies, library, 1, false, siteId).then((weight) => { - nextWeight = weight; - dependencies[depKey].weight = nextWeight++; + if (addTo && addTo.content && addTo.content.types && addTo.content.types.length) { + for (let i = 0; i < addTo.content.types.length; i++) { + const type = addTo.content.types[i]; - // Handle addons. - return this.loadAddons(siteId); - }).then((addons) => { - // Get the dependencies of all the addons. Use a chain of promises to calculate the weight properly. - let promise = Promise.resolve(); + if (type && type.text && type.text.regex && + this.h5pUtils.textAddonMatches(params.params, type.text.regex)) { - addons.forEach((addon) => { - const addTo = this.textUtils.parseJSON(addon.addTo, null); + promise = promise.then(() => { + return validator.addon(addon); + }); - if (addTo && addTo.content && addTo.content.types && addTo.content.types.length) { - for (let i = 0; i < addTo.content.types.length; i++) { - const type = addTo.content.types[i]; - - if (type && type.text && type.text.regex && - this.h5pUtils.textAddonMatches(params.params, type.text.regex)) { - - const addonDepKey = 'preloaded-' + addon.machineName; - dependencies[addonDepKey] = { - library: addon, - type: 'preloaded' - }; - - promise = promise.then(() => { - return this.findLibraryDependencies(dependencies, addon, nextWeight).then((weight) => { - nextWeight = weight; - dependencies[addonDepKey].weight = nextWeight++; - }); - }); - - break; - } + // An addon shall only be added once. + break; } } - }); - - return promise; - }).then(() => { - // Update content dependencies. - content.dependencies = dependencies; - - const paramsStr = JSON.stringify(params.params); - - // Sometimes the parameters are filtered before content has been created - if (content.id) { - // Update library usage. - return this.deleteLibraryUsage(content.id, siteId).catch(() => { - // Ignore errors. - }).then(() => { - return this.saveLibraryUsage(content.id, content.dependencies, siteId); - }).then(() => { - if (!content.slug) { - content.slug = this.h5pUtils.slugify(content.title); - } - - // Cache. - return this.updateContentFields(content.id, {filtered: paramsStr}, siteId).then(() => { - return paramsStr; - }); - }); } - - return paramsStr; }); + + return promise; + }).then(() => { + // 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. + return this.deleteLibraryUsage(content.id, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return this.saveLibraryUsage(content.id, content.dependencies, siteId); + }).then(() => { + if (!content.slug) { + content.slug = this.h5pUtils.slugify(content.title); + } + + // Cache. + return this.updateContentFields(content.id, {filtered: paramsStr}, siteId).then(() => { + return paramsStr; + }); + }); + } + + return paramsStr; }).catch(() => { return null; }); @@ -990,12 +971,13 @@ export class CoreH5PProvider { } library[property].forEach((dependency: CoreH5PLibraryBasicData) => { - const dependencyKey = type + '-' + dependency.machineName; - if (dependencies[dependencyKey]) { - return; // Skip, already have this. - } promise = promise.then(() => { + const dependencyKey = type + '-' + dependency.machineName; + if (dependencies[dependencyKey]) { + return; // Skip, already have this. + } + // Get the dependency library data and its subdependencies. return this.loadLibrary(dependency.machineName, dependency.majorVersion, dependency.minorVersion, siteId) .then((dependencyLibrary) => { @@ -1424,7 +1406,7 @@ export class CoreH5PProvider { // Aggregate and store assets. return this.cacheAssets(files, cachedAssetsHash, folderName, siteId).then(() => { // Keep track of which libraries have been cached in case they are updated. - return this.saveCachedAssets(cachedAssetsHash, dependencies, siteId); + return this.saveCachedAssets(cachedAssetsHash, dependencies, folderName, siteId); }).then(() => { return files; }); @@ -1652,12 +1634,12 @@ export class CoreH5PProvider { } return db.getRecords(this.LIBRARIES_TABLE, conditions); - }).then((libraries) => { + }).then((libraries): any => { if (!libraries.length) { return Promise.reject(null); } - return libraries[0]; + return this.parseLibDBData(libraries[0]); }); } @@ -1681,7 +1663,9 @@ export class CoreH5PProvider { */ protected getLibraryById(id: number, siteId?: string): Promise { return this.sitesProvider.getSiteDb(siteId).then((db) => { - return db.getRecord(this.LIBRARIES_TABLE, {id: id}); + return db.getRecord(this.LIBRARIES_TABLE, {id: id}).then((library) => { + return this.parseLibDBData(library); + }); }); } @@ -1946,7 +1930,7 @@ export class CoreH5PProvider { const addons = []; for (let i = 0; i < result.rows.length; i++) { - addons.push(result.rows.item(i)); + addons.push(this.parseLibAddonData(result.rows.item(i))); } return addons; @@ -1963,6 +1947,8 @@ export class CoreH5PProvider { * @return Promise resolved with the content data. */ protected loadContentData(id?: number, fileUrl?: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + let promise: Promise; if (id) { @@ -1978,31 +1964,35 @@ export class CoreH5PProvider { // Load the main library data. return this.getLibraryById(contentData.mainlibraryid, siteId).then((libData) => { - // Map the values to the names used by the H5P core (it's the same Moodle web does). - return { - id: contentData.id, - params: contentData.jsoncontent, - // The embedtype will be always set to 'iframe' to prevent conflicts with JS and CSS. - embedType: 'iframe', - disable: null, - folderName: contentData.foldername, - title: libData.title, - slug: this.h5pUtils.slugify(libData.title) + '-' + contentData.id, - filtered: contentData.filtered, - libraryMajorVersion: libData.majorversion, - libraryMinorVersion: libData.minorversion, - metadata: { - license: 'U' // Stop "invalid selected option in select" for old content without license chosen. - }, - library: { - id: libData.id, - name: libData.machinename, - majorVersion: libData.majorversion, - minorVersion: libData.minorversion, - embedTypes: libData.embedtypes, - fullscreen: libData.fullscreen - } - }; + // Validate metadata. + const validator = new CoreH5PContentValidator(this, this.h5pUtils, this.textUtils, this.utils, this.translate, + siteId); + + // Validate empty metadata, like Moodle web does. + return validator.validateMetadata({}).then((metadata) => { + // Map the values to the names used by the H5P core (it's the same Moodle web does). + return { + id: contentData.id, + params: contentData.jsoncontent, + embedType: 'iframe', // Always use iframe. + disable: null, + folderName: contentData.foldername, + title: libData.title, + slug: this.h5pUtils.slugify(libData.title) + '-' + contentData.id, + filtered: contentData.filtered, + libraryMajorVersion: libData.majorversion, + libraryMinorVersion: libData.minorversion, + metadata: metadata, + library: { + id: libData.id, + name: libData.machinename, + majorVersion: libData.majorversion, + minorVersion: libData.minorversion, + embedTypes: libData.embedtypes, + fullscreen: libData.fullscreen + } + }; + }); }); }); } @@ -2113,6 +2103,31 @@ export class CoreH5PProvider { }); } + /** + * Parse library addon data. + * + * @param library Library addon data. + * @return Parsed library. + */ + parseLibAddonData(library: any): CoreH5PLibraryAddonData { + library.addto = this.textUtils.parseJSON(library.addto, null); + + return library; + } + + /** + * Parse library DB data. + * + * @param library Library DB data. + * @return Parsed library. + */ + parseLibDBData(library: any): CoreH5PLibraryDBData { + library.semantics = this.textUtils.parseJSON(library.semantics, null); + library.addto = this.textUtils.parseJSON(library.addto, null); + + return library; + } + /** * Process libraries from an H5P library, getting the required data to save them. * This code was copied from the isValidPackage function in Moodle's H5PValidator. @@ -2172,12 +2187,13 @@ export class CoreH5PProvider { * 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 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. */ protected saveCachedAssets(hash: string, dependencies: {[machineName: string]: CoreH5PContentDependencyData}, - siteId?: string): Promise { + folderName: string, siteId?: string): Promise { return this.sitesProvider.getSiteDb(siteId).then((db) => { const promises = []; @@ -2185,7 +2201,8 @@ export class CoreH5PProvider { for (const key in dependencies) { const data = { hash: key, - libraryid: dependencies[key].libraryId + libraryid: dependencies[key].libraryId, + foldername: folderName }; promises.push(db.insertRecord(this.LIBRARIES_CACHEDASSETS_TABLE, data)); @@ -2279,7 +2296,7 @@ export class CoreH5PProvider { for (const libString in librariesJsonData) { const libraryData = librariesJsonData[libString]; - // Find local library identifier + // Find local library identifier. promises.push(this.getLibraryByData(libraryData).catch(() => { // Not found. }).then((dbData) => { @@ -2422,7 +2439,7 @@ export class CoreH5PProvider { preloadedjs: preloadedJS, preloadedcss: preloadedCSS, droplibrarycss: dropLibraryCSS, - semantics: libraryData.semantics, + semantics: typeof libraryData.semantics != 'undefined' ? JSON.stringify(libraryData.semantics) : null, addto: typeof libraryData.addTo != 'undefined' ? JSON.stringify(libraryData.addTo) : null, }; @@ -2497,8 +2514,8 @@ export class CoreH5PProvider { for (const key in librariesInUse) { const dependency = librariesInUse[key]; - if (dependency.library.dropLibraryCss) { - const split = dependency.library.dropLibraryCss.split(', '); + if (( dependency.library).dropLibraryCss) { + const split = ( dependency.library).dropLibraryCss.split(', '); split.forEach((css) => { dropLibraryCssList[css] = css; @@ -2739,7 +2756,7 @@ export type CoreH5PContentDependencyData = { * Data for each content dependency in the dependency tree. */ export type CoreH5PContentDepsTreeDependency = { - library: CoreH5PLibraryData; // Library data. + library: CoreH5PLibraryData | CoreH5PLibraryAddonData; // Library data. type: string; // Dependency type. weight?: number; // An integer determining the order of the libraries when they are loaded. }; @@ -2786,7 +2803,7 @@ export type CoreH5PLibraryAddonData = { patchVersion: number; // Patch version. preloadedJs?: string; // Comma separated list of scripts to load. preloadedCss?: string; // Comma separated list of stylesheets to load. - addTo?: string; // Plugin configuration data. + addTo?: any; // Plugin configuration data. }; /** @@ -2805,8 +2822,8 @@ export type CoreH5PLibraryDBData = { 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?: string; // The semantics definition in json format. - addto?: string; // Plugin configuration data. + semantics?: any; // The semantics definition. + addto?: any; // Plugin configuration data. }; /** diff --git a/src/core/h5p/providers/utils.ts b/src/core/h5p/providers/utils.ts index 9c568acac..026b2b9b7 100644 --- a/src/core/h5p/providers/utils.ts +++ b/src/core/h5p/providers/utils.ts @@ -313,6 +313,27 @@ export class CoreH5PUtilsProvider { }; } + /** + * 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. + */ + libraryFromString(libraryString: string): {machineName: string, majorVersion: number, minorVersion: number} { + + 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; + } + /** * Convert list of library parameter values to csv. * diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index c4f051645..624371ac1 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -135,17 +135,19 @@ export class CoreUtilsProvider { /** * Converts an array of objects to an object, using a property of each entry as the key. + * It can also be used to convert an array of strings to an object where the keys are the elements of the array. * E.g. [{id: 10, name: 'A'}, {id: 11, name: 'B'}] => {10: {id: 10, name: 'A'}, 11: {id: 11, name: 'B'}} * * @param array The array to convert. - * @param propertyName The name of the property to use as the key. + * @param propertyName The name of the property to use as the key. If not provided, the whole item will be used. * @param result Object where to put the properties. If not defined, a new object will be created. * @return The object. */ - arrayToObject(array: any[], propertyName: string, result?: any): any { + arrayToObject(array: any[], propertyName?: string, result?: any): any { result = result || {}; array.forEach((entry) => { - result[entry[propertyName]] = entry; + const key = propertyName ? entry[propertyName] : entry; + result[key] = entry; }); return result;