// (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 '@providers/file'; import { CoreFilepool } from '@providers/filepool'; import { CoreSites } from '@providers/sites'; import { CoreMimetypeUtils } from '@providers/utils/mimetype'; import { CoreTextUtils } from '@providers/utils/text'; import { CoreUtils } from '@providers/utils/utils'; import { CoreH5PProvider } from '../providers/h5p'; import { CoreH5PCore, CoreH5PDependencyAsset, CoreH5PContentDependencyData, CoreH5PDependenciesFiles } from './core'; import { CoreH5PLibrariesCachedAssetsDBData } from './framework'; /** * Equivalent to Moodle's implementation of H5PFileStorage. */ export class CoreH5PFileStorage { static 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<void> { 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<string> { const basePath = CoreFile.instance.convertFileSrc(CoreFile.instance.getBasePathInstant()); let content = ''; for (const i in assets) { const asset = assets[i]; let fileContent: string = await CoreFile.instance.readFile(asset.path); if (type == 'scripts') { // No need to treat scripts, just append the content. content += fileContent + ';\n'; } else { // 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(/^\.\.\//)) { const urlSplit = url.split('/').filter((i) => { return i; // Remove empty values. }); // 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: CoreH5PLibrariesCachedAssetsDBData[], siteId?: string): Promise<void> { const site = await CoreSites.instance.getSite(siteId); const promises = []; 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)); }); }); try { await CoreUtils.instance.allPromises(promises); } catch (error) { // Ignore errors, maybe there's no cached asset of some type. } } /** * 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<void> { 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<void> { 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<void> { 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 ' + CoreH5PProvider.CONTENTS_LIBRARIES_TABLE + ' hcl ' + 'JOIN ' + CoreH5PProvider.CONTENT_TABLE + ' hc ON hcl.h5pid = hc.id ' + 'WHERE hcl.libraryid = ?'; const queryArgs = []; queryArgs.push(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 folderName Folder name. If not provided, it will be calculated. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ async deleteLibraryFolder(libraryData: any, folderName?: string, siteId?: string): Promise<void> { 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[]}> { // 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<CoreH5PDependencyAsset[]> { 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<string> { 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<string> { 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: any, 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<void> { const folderPath = this.getContentFolderPath(folderName, siteId); // Delete existing content for this package. try { await CoreFile.instance.removeDir(folderPath); } catch (error) { // Ignore errors, maybe it doesn't exist. } // 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: any, siteId?: string): Promise<void> { 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. } // Copy the new one. await CoreFile.instance.moveDir(libraryData.uploadDirectory, folderPath, true); } }