gpii-universal
Version:
Cross platform, core components of the GPII personalization infrastructure.
458 lines (416 loc) • 15.7 kB
JavaScript
/* 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);
};