UNPKG

gpii-universal

Version:

Cross platform, core components of the GPII personalization infrastructure.

458 lines (416 loc) 15.7 kB
/* Remote file settings handler. * * Copyright 2019 Raising the Floor - International * * Licensed under the New BSD license. You may not use this file except in * compliance with this License. * * The R&D leading to these results received funding from the * Department of Education - Grant H421A150005 (GPII-APCP). However, * these results do not necessarily represent the policy of the * Department of Education, and you should not assume endorsement by the * Federal Government. * * You may obtain a copy of the License at * https://github.com/GPII/universal/blob/master/LICENSE.txt */ "use strict"; /* * The process of applying a setting is as follows: * 1. Download the file * 2. Move the original target file out of the way, into another location. * 3. Copy the downloaded file onto the target path. * * * For restoring a setting: * 1. Remove the file in the target path. * 2. Move the original file from stash onto the target path. * * A payload for this setting handler would look something like this: * * { * "settings": { * "first-setting": "first-value", * "another-setting": "another-value", * }, * "options": { * "settings": { * "first-setting": { * // Location of the target file. * "path": "c:\\somewhere\\file.xyz", * * // From where the new file is downloaded, %value will expand to "first-value" * "url": "https://example.com/gpii-files/%value", * }, * "another-setting": { * "path": "/etc/passwd", * "url": "https://example.com/gpii-files/%value" * } * } * } * } * */ var path = require("path"), request = require("request"), fs = require("fs"), crypto = require("crypto"), fluid = require("infusion"), mkdirp = require("mkdirp"), gpii = fluid.registerNamespace("gpii"); var remoteFileSettingsHandler = fluid.registerNamespace("gpii.settingsHandlers.remoteFileSettingsHandler"); // Handles a single file download or restore. fluid.defaults("gpii.settingsHandlers.remoteFileDownload", { gradeNames: ["fluid.component"], components: { settingsDir: { type: "gpii.settingsDir" } }, members: { // The URL for the download url: null, // The path of the target file. path: null, // The setting value. value: null, // Where the download is stored. downloadPath: null, // Where the original file is kept. stashPath: null, cache: null, gpiiSettingsDir: "@expand:{settingsDir}.getGpiiSettingsDir()" }, invokers: { applyFile: { funcName: "gpii.settingsHandlers.remoteFileSettingsHandler.applyFile", args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] // path, url, value }, restoreFile: { funcName: "gpii.settingsHandlers.remoteFileSettingsHandler.restoreFile", args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] // path, stashPath, value } }, events: { applyFile: null, restoreFile: null }, listeners: { "applyFile.download": { priority: "first", funcName: "gpii.settingsHandlers.remoteFileSettingsHandler.downloadFile", args: ["{that}.url", "{that}.downloadPath", "{that}.cache"] }, "applyFile.stash": { priority: "after:download", funcName: "gpii.settingsHandlers.remoteFileSettingsHandler.transferFile", args: ["{that}.path", "{that}.stashPath"] }, "applyFile.write": { priority: "after:stash", funcName: "gpii.settingsHandlers.remoteFileSettingsHandler.transferFile", args: [ // source, destination, keepSource "{that}.downloadPath", "{that}.path", "{that}.cache" ] }, "applyFile.end": { priority: "last", funcName: "fluid.identity", args: ["{that}.stashPath"] }, "restoreFile.write": { priority: "first", funcName: "gpii.settingsHandlers.remoteFileSettingsHandler.transferFile", args: ["{that}.stashPath", "{that}.path"] } } }); /** * Hash of urls that have been downloaded and cached. This is to tell if a download has occurred in this instance, and * to use the cached download. Otherwise, download a fresh copy. */ gpii.settingsHandlers.remoteFileSettingsHandler.cached = {}; /** * Applies a file setting. * * This downloads the new file, moves the old one out the way, and copies the new file in its place. * * @param {Component} that The gpii.settingsHandlers.remoteFileDownload instance. * @param {String} targetPath The path to the target file. * @param {String} url The URL (a string template accepting a "%value" token) of the new file. * @param {String} value The setting value. * @return {Promise} Resolves when complete, with a string value of the stashed file. */ gpii.settingsHandlers.remoteFileSettingsHandler.applyFile = function (that, targetPath, url, value) { that.path = targetPath; that.value = value; that.url = fluid.stringTemplate(url, { value: encodeURIComponent(that.value) }); // Generate the download destination file based on the url var sha1 = crypto.createHash("sha1"); sha1.update(that.url); that.downloadPath = path.join(that.gpiiSettingsDir, "download", sha1.digest("hex")); var stashFile = path.basename(that.path) + "." + (new Date().toISOString().replace(/:/g, "")) + ".gpii-stashed"; that.stashPath = path.join(that.gpiiSettingsDir, "stash", stashFile); var existed = fs.existsSync(that.path); var promise = fluid.promise(); fluid.promise.fireTransformEvent(that.events.applyFile).then(function (value) { fluid.log("remoteFileSettingsHandler applied " + that.path + " from " + that.url); promise.resolve(value); }, function (reason) { fluid.log("remoteFileSettingsHandler failed " + that.path + " from " + that.url, ": ", reason); var restorePromise; // try to put things back if it fails. if (fs.existsSync(that.stashPath) === existed) { // Copy the stashed file back to the original place restorePromise = remoteFileSettingsHandler.transferFile(that.stashPath, that.path); } else { restorePromise = fluid.promise().resolve(); } restorePromise.then(function () { promise.reject(reason); }, function (innerReason) { fluid.log("remoteFileSettingsHandler failed to restore: ", innerReason); promise.reject(reason); }); }); return promise; }; /** * Restores a file. * * @param {Component} that The gpii.settingsHandlers.remoteFileDownload instance. * @param {String} targetPath The path to the setting file. * @param {String} stashPath The path to the original copy of the setting file. * @param {String} value The setting value. * @return {Promise} Resolves when complete. */ gpii.settingsHandlers.remoteFileSettingsHandler.restoreFile = function (that, targetPath, stashPath, value) { that.path = targetPath; that.stashPath = stashPath; that.value = value; return fluid.promise.fireTransformEvent(that.events.restoreFile); }; /** * Returns a function which can be used as a callback to asynchronous functions, which will resolve or reject * the given promise based on the existence of the single parameter. * * In other words, if the async function fails, the promise will reject (with the error passed to the callback), * otherwise the promise will resolve with the given value. * * @param {Promise} promise The promise to reject or resolve. * @param {Object} value [optional] The value with which the promise is resolved. * @return {Function} A callback function that can be used for standard async functions. */ gpii.settingsHandlers.remoteFileSettingsHandler.callbackToPromise = function (promise, value) { return function (err) { if (err) { promise.reject(err); } else { promise.resolve(value); } }; }; /** * Downloads a file. * * @param {String} url The remote file location. * @param {String} downloadTo The path to the local file to which the download is saved. * @param {Boolean} cache true to use the existing file at downloadTo, if it exists. * @return {Promise} Resolves, with the path of the new file. */ gpii.settingsHandlers.remoteFileSettingsHandler.downloadFile = function (url, downloadTo, cache) { var promise = fluid.promise(); var downloadExists = fs.existsSync(downloadTo); if (downloadExists && cache && gpii.settingsHandlers.remoteFileSettingsHandler.cached[url]) { promise.resolve(downloadTo); } else { // The intermediate filename (named differently to signify the download is incomplete). var tempFile = downloadTo + ".downloading"; var outStream; fluid.log("remoteFileSettingsHandler: Downloading " + url + " to " + downloadTo); promise.then(function () { gpii.settingsHandlers.remoteFileSettingsHandler.cached[url] = cache; }, function () { // Always close the output stream after an error. if (outStream) { outStream.close(); } }); var req = request.get({ uri: url }); req.on("error", promise.reject); req.on("response", function (response) { if (response.statusCode === 200) { mkdirp.sync(path.dirname(tempFile)); outStream = fs.createWriteStream(tempFile); response.pipe(outStream); response.on("end", function () { outStream.close(); outStream = null; fs.rename(tempFile, downloadTo, remoteFileSettingsHandler.callbackToPromise(promise, downloadTo)); }); } else if (downloadExists) { // Resolve with the last download. promise.resolve(downloadTo); } else { promise.reject({ isError: true, message: "Unable to download " + url + ": " + response.statusCode }); } }); } return promise; }; /** * Transfers a file from one place to another, by either copying or moving it. The destination will be overwritten. * * If the source file does not exist, then the destination will be removed (if it exists) * * @param {String} source Path to the file. * @param {String} destination Path to where the new file should be placed. * @param {Boolean} keepSource true if the source file needs to be kept (copy the file, rather than move). * @return {Promise} Resolves when complete. */ gpii.settingsHandlers.remoteFileSettingsHandler.transferFile = function (source, destination, keepSource) { var promise = fluid.promise(); if (fs.existsSync(source)) { var destDir = path.dirname(destination); if (!fs.existsSync(destDir)) { mkdirp.sync(destDir); } if (keepSource) { fs.copyFile(source, destination, remoteFileSettingsHandler.callbackToPromise(promise)); } else { fs.rename(source, destination, function (err) { if (err) { if (err.code === "EXDEV") { // Tried to move a file over different filesystems - copy + delete instead. remoteFileSettingsHandler.transferFile(source, destination, true).then(function () { fs.unlink(source, remoteFileSettingsHandler.callbackToPromise(promise)); }, promise.reject); } else { promise.reject(err); } } else { promise.resolve(); } }); } } else { fs.unlink(destination, function (err) { if (err && err.code !== "ENOENT") { promise.reject(err); } else { promise.resolve(); } }); } return promise; }; /** * Setter for the remote file settings handler. * * { * "settings": { * "word-ribbon": "file1" * }, * "options": { * "settings": { * "word-ribbon": { * "path": "c:\\ ...", * "url": "https://example.com/" * }, * "excel-ribbon": { * "path": "c:\\ ...", * "url": "https://example.com/" * } * } * } * } * * @param {Object} payload The payload. * @return {Promise} Resolves with the response. */ gpii.settingsHandlers.remoteFileSettingsHandler.setImpl = function (payload) { var promiseTogo = fluid.promise(); var results = {}; var promises = []; fluid.each(payload.settings, function (settingValue, key) { if (settingValue) { var value = settingValue.value || settingValue; var settingPromise = fluid.promise(); promises.push(settingPromise); settingPromise.then(null, function (reason) { fluid.fail("remoteFileSettingsHandler failed for setting '" + key + "' in payload ", payload, reason); }); var options = payload.options.settings[key]; var remoteFileDownload = gpii.settingsHandlers.remoteFileDownload({ members: { cache: options.cache } }); var p; if (settingValue.restore) { p = remoteFileDownload.restoreFile(options.path, settingValue.stashPath, value); } else { p = remoteFileDownload.applyFile(options.path, options.url, value); } p.then(function (result) { results[key] = { oldValue: { value: value, stashPath: result, restore: true }, newValue: value }; settingPromise.resolve(); remoteFileDownload.destroy(); }, function (reason) { fluid.log("remoteFileDownload failed", reason); results[key] = {failed: true}; settingPromise.resolve(); remoteFileDownload.destroy(); }); } }); // Resolve the return promise when the files have been processed. fluid.promise.sequence(promises).then(function () { promiseTogo.resolve(results); }); return promiseTogo; }; /** * Getter for the remote file settings handler. * * @return {Object} An empty object. */ gpii.settingsHandlers.remoteFileSettingsHandler.getImpl = function () { return {}; }; /** * Invoke the settings handler. * * @param {Object} payload The payload * @return {Promise} Resolves with the response. */ gpii.settingsHandlers.remoteFileSettingsHandler.get = function (payload) { return gpii.settingsHandlers.invokeSettingsHandler(remoteFileSettingsHandler.getImpl, payload); }; /** * Invoke the settings handler. * * @param {Object} payload The payload * @return {Promise} Resolves with the response. */ gpii.settingsHandlers.remoteFileSettingsHandler.set = function (payload) { return gpii.settingsHandlers.invokeSettingsHandler(remoteFileSettingsHandler.setImpl, payload); };