CyberChef/src/web/App.mjs
2019-11-14 08:55:27 +00:00

778 lines
25 KiB
JavaScript
Executable File

/**
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*/
import Utils, { debounce } from "../core/Utils";
import {fromBase64} from "../core/lib/Base64";
import Manager from "./Manager";
import HTMLCategory from "./HTMLCategory";
import HTMLOperation from "./HTMLOperation";
import Split from "split.js";
import moment from "moment-timezone";
/**
* HTML view for CyberChef responsible for building the web page and dealing with all user
* interactions.
*/
class App {
/**
* App constructor.
*
* @param {CatConf[]} categories - The list of categories and operations to be populated.
* @param {Object.<string, OpConf>} operations - The list of operation configuration objects.
* @param {String[]} defaultFavourites - A list of default favourite operations.
* @param {Object} options - Default setting for app options.
*/
constructor(categories, operations, defaultFavourites, defaultOptions) {
this.categories = categories;
this.operations = operations;
this.dfavourites = defaultFavourites;
this.doptions = defaultOptions;
this.options = Object.assign({}, defaultOptions);
this.manager = new Manager(this);
this.baking = false;
this.autoBake_ = false;
this.autoBakePause = false;
this.progress = 0;
this.ingId = 0;
}
/**
* This function sets up the stage and creates listeners for all events.
*
* @fires Manager#appstart
*/
setup() {
document.dispatchEvent(this.manager.appstart);
this.initialiseSplitter();
this.loadLocalStorage();
this.populateOperationsList();
this.manager.setup();
this.manager.output.saveBombe();
this.resetLayout();
this.setCompileMessage();
log.debug("App loaded");
this.appLoaded = true;
this.loadURIParams();
this.loaded();
}
/**
* Fires once all setup activities have completed.
*
* @fires Manager#apploaded
*/
loaded() {
// Check that both the app and the worker have loaded successfully, and that
// we haven't already loaded before attempting to remove the loading screen.
if (!this.workerLoaded || !this.appLoaded ||
!document.getElementById("loader-wrapper")) return;
// Trigger CSS animations to remove preloader
document.body.classList.add("loaded");
// Wait for animations to complete then remove the preloader and loaded style
// so that the animations for existing elements don't play again.
setTimeout(function() {
document.getElementById("loader-wrapper").remove();
document.body.classList.remove("loaded");
// Bake initial input
this.manager.input.bakeAll();
}.bind(this), 1000);
// Clear the loading message interval
clearInterval(window.loadingMsgsInt);
// Remove the loading error handler
window.removeEventListener("error", window.loadingErrorHandler);
document.dispatchEvent(this.manager.apploaded);
this.manager.input.calcMaxTabs();
this.manager.output.calcMaxTabs();
}
/**
* An error handler for displaying the error to the user.
*
* @param {Error} err
* @param {boolean} [logToConsole=false]
*/
handleError(err, logToConsole) {
if (logToConsole) log.error(err);
const msg = err.displayStr || err.toString();
this.alert(Utils.escapeHtml(msg), this.options.errorTimeout, !this.options.showErrors);
}
/**
* Asks the ChefWorker to bake the current input using the current recipe.
*
* @param {boolean} [step] - Set to true if we should only execute one operation instead of the
* whole recipe.
*/
bake(step=false) {
if (this.baking) return;
// Reset attemptHighlight flag
this.options.attemptHighlight = true;
// Remove all current indicators
this.manager.recipe.updateBreakpointIndicator(false);
this.manager.worker.bake(
this.getRecipeConfig(), // The configuration of the recipe
this.options, // Options set by the user
this.progress, // The current position in the recipe
step // Whether or not to take one step or execute the whole recipe
);
}
/**
* Runs Auto Bake if it is set.
*/
autoBake() {
// If autoBakePause is set, we are loading a full recipe (and potentially input), so there is no
// need to set the staleness indicator. Just exit and wait until auto bake is called after loading
// has completed.
if (this.autoBakePause) return false;
if (this.autoBake_ && !this.baking) {
log.debug("Auto-baking");
this.manager.input.inputWorker.postMessage({
action: "autobake",
data: {
activeTab: this.manager.tabs.getActiveInputTab()
}
});
} else {
this.manager.controls.showStaleIndicator();
}
}
/**
* Executes the next step of the recipe.
*/
step() {
if (this.baking) return;
// Reset status using cancelBake
this.manager.worker.cancelBake(true, false);
const activeTab = this.manager.tabs.getActiveInputTab();
if (activeTab === -1) return;
let progress = 0;
if (this.manager.output.outputs[activeTab].progress !== false) {
log.error(this.manager.output.outputs[activeTab]);
progress = this.manager.output.outputs[activeTab].progress;
}
this.manager.input.inputWorker.postMessage({
action: "step",
data: {
activeTab: activeTab,
progress: progress + 1
}
});
}
/**
* Runs a silent bake, forcing the browser to load and cache all the relevant JavaScript code needed
* to do a real bake.
*
* The output will not be modified (hence "silent" bake). This will only actually execute the recipe
* if auto-bake is enabled, otherwise it will just wake up the ChefWorker with an empty recipe.
*/
silentBake() {
let recipeConfig = [];
if (this.autoBake_) {
// If auto-bake is not enabled we don't want to actually run the recipe as it may be disabled
// for a good reason.
recipeConfig = this.getRecipeConfig();
}
this.manager.worker.silentBake(recipeConfig);
}
/**
* Sets the user's input data.
*
* @param {string} input - The string to set the input to
*/
setInput(input) {
// Get the currently active tab.
// If there isn't one, assume there are no inputs so use inputNum of 1
let inputNum = this.manager.tabs.getActiveInputTab();
if (inputNum === -1) inputNum = 1;
this.manager.input.updateInputValue(inputNum, input);
this.manager.input.inputWorker.postMessage({
action: "setInput",
data: {
inputNum: inputNum,
silent: true
}
});
}
/**
* Populates the operations accordion list with the categories and operations specified in the
* view constructor.
*
* @fires Manager#oplistcreate
*/
populateOperationsList() {
// Move edit button away before we overwrite it
document.body.appendChild(document.getElementById("edit-favourites"));
let html = "";
let i;
for (i = 0; i < this.categories.length; i++) {
const catConf = this.categories[i],
selected = i === 0,
cat = new HTMLCategory(catConf.name, selected);
for (let j = 0; j < catConf.ops.length; j++) {
const opName = catConf.ops[j];
if (!(opName in this.operations)) {
log.warn(`${opName} could not be found.`);
continue;
}
const op = new HTMLOperation(opName, this.operations[opName], this, this.manager);
cat.addOperation(op);
}
html += cat.toHtml();
}
document.getElementById("categories").innerHTML = html;
const opLists = document.querySelectorAll("#categories .op-list");
for (i = 0; i < opLists.length; i++) {
opLists[i].dispatchEvent(this.manager.oplistcreate);
}
// Add edit button to first category (Favourites)
document.querySelector("#categories a").appendChild(document.getElementById("edit-favourites"));
}
/**
* Sets up the adjustable splitter to allow the user to resize areas of the page.
*
* @param {boolean} [minimise=false] - Set this flag if attempting to minimise frames to 0 width
*/
initialiseSplitter(minimise=false) {
if (this.columnSplitter) this.columnSplitter.destroy();
if (this.ioSplitter) this.ioSplitter.destroy();
this.columnSplitter = Split(["#operations", "#recipe", "#IO"], {
sizes: [20, 30, 50],
minSize: minimise ? [0, 0, 0] : [240, 310, 450],
gutterSize: 4,
expandToMin: true,
onDrag: debounce(function() {
this.manager.recipe.adjustWidth();
this.manager.input.calcMaxTabs();
this.manager.output.calcMaxTabs();
}, 50, "dragSplitter", this, [])
});
this.ioSplitter = Split(["#input", "#output"], {
direction: "vertical",
gutterSize: 4,
minSize: minimise ? [0, 0] : [100, 100]
});
this.resetLayout();
}
/**
* Loads the information previously saved to the HTML5 local storage object so that user options
* and favourites can be restored.
*/
loadLocalStorage() {
// Load options
let lOptions;
if (this.isLocalStorageAvailable() && localStorage.options !== undefined) {
lOptions = JSON.parse(localStorage.options);
}
this.manager.options.load(lOptions);
// Load favourites
this.loadFavourites();
}
/**
* Loads the user's favourite operations from the HTML5 local storage object and populates the
* Favourites category with them.
* If the user currently has no saved favourites, the defaults from the view constructor are used.
*/
loadFavourites() {
let favourites;
if (this.isLocalStorageAvailable()) {
favourites = localStorage.favourites && localStorage.favourites.length > 2 ?
JSON.parse(localStorage.favourites) :
this.dfavourites;
favourites = this.validFavourites(favourites);
this.saveFavourites(favourites);
} else {
favourites = this.dfavourites;
}
const favCat = this.categories.filter(function(c) {
return c.name === "Favourites";
})[0];
if (favCat) {
favCat.ops = favourites;
} else {
this.categories.unshift({
name: "Favourites",
ops: favourites
});
}
}
/**
* Filters the list of favourite operations that the user had stored and removes any that are no
* longer available. The user is notified if this is the case.
* @param {string[]} favourites - A list of the user's favourite operations
* @returns {string[]} A list of the valid favourites
*/
validFavourites(favourites) {
const validFavs = [];
for (let i = 0; i < favourites.length; i++) {
if (favourites[i] in this.operations) {
validFavs.push(favourites[i]);
} else {
this.alert(`The operation "${Utils.escapeHtml(favourites[i])}" is no longer available. ` +
"It has been removed from your favourites.");
}
}
return validFavs;
}
/**
* Saves a list of favourite operations to the HTML5 local storage object.
*
* @param {string[]} favourites - A list of the user's favourite operations
*/
saveFavourites(favourites) {
if (!this.isLocalStorageAvailable()) {
this.alert(
"Your security settings do not allow access to local storage so your favourites cannot be saved.",
5000
);
return false;
}
localStorage.setItem("favourites", JSON.stringify(this.validFavourites(favourites)));
}
/**
* Resets favourite operations back to the default as specified in the view constructor and
* refreshes the operation list.
*/
resetFavourites() {
this.saveFavourites(this.dfavourites);
this.loadFavourites();
this.populateOperationsList();
this.manager.recipe.initialiseOperationDragNDrop();
}
/**
* Adds an operation to the user's favourites.
*
* @param {string} name - The name of the operation
*/
addFavourite(name) {
const favourites = JSON.parse(localStorage.favourites);
if (favourites.indexOf(name) >= 0) {
this.alert(`'${name}' is already in your favourites`, 3000);
return;
}
favourites.push(name);
this.saveFavourites(favourites);
this.loadFavourites();
this.populateOperationsList();
this.manager.recipe.initialiseOperationDragNDrop();
}
/**
* Gets the URI params from the window and parses them to extract the actual values.
*
* @returns {object}
*/
getURIParams() {
// Load query string or hash from URI (depending on which is populated)
// We prefer getting the hash by splitting the href rather than referencing
// location.hash as some browsers (Firefox) automatically URL decode it,
// which cause issues.
const params = window.location.search ||
window.location.href.split("#")[1] ||
window.location.hash;
const parsedParams = Utils.parseURIParams(params);
return parsedParams;
}
/**
* Searches the URI parameters for recipe and input parameters.
* If recipe is present, replaces the current recipe with the recipe provided in the URI.
* If input is present, decodes and sets the input to the one provided in the URI.
* If theme is present, uses the theme.
*
* @fires Manager#statechange
*/
loadURIParams() {
this.autoBakePause = true;
this.uriParams = this.getURIParams();
// Read in recipe from URI params
if (this.uriParams.recipe) {
try {
const recipeConfig = Utils.parseRecipeConfig(this.uriParams.recipe);
this.setRecipeConfig(recipeConfig);
} catch (err) {}
} else if (this.uriParams.op) {
// If there's no recipe, look for single operations
this.manager.recipe.clearRecipe();
// Search for nearest match and add it
const matchedOps = this.manager.ops.filterOperations(this.uriParams.op, false);
if (matchedOps.length) {
this.manager.recipe.addOperation(matchedOps[0].name);
}
// Populate search with the string
const search = document.getElementById("search");
search.value = this.uriParams.op;
search.dispatchEvent(new Event("search"));
}
// Read in input data from URI params
if (this.uriParams.input) {
try {
const inputData = fromBase64(this.uriParams.input);
this.setInput(inputData);
} catch (err) {}
}
// Read in theme from URI params
if (this.uriParams.theme) {
this.manager.options.changeTheme(Utils.escapeHtml(this.uriParams.theme));
}
this.autoBakePause = false;
window.dispatchEvent(this.manager.statechange);
}
/**
* Returns the next ingredient ID and increments it for next time.
*
* @returns {number}
*/
nextIngId() {
return this.ingId++;
}
/**
* Gets the current recipe configuration.
*
* @returns {Object[]}
*/
getRecipeConfig() {
return this.manager.recipe.getConfig();
}
/**
* Given a recipe configuration, sets the recipe to that configuration.
*
* @fires Manager#statechange
* @param {Object[]} recipeConfig - The recipe configuration
*/
setRecipeConfig(recipeConfig) {
document.getElementById("rec-list").innerHTML = null;
// Pause auto-bake while loading but don't modify `this.autoBake_`
// otherwise `manualBake` cannot trigger.
this.autoBakePause = true;
for (let i = 0; i < recipeConfig.length; i++) {
const item = this.manager.recipe.addOperation(recipeConfig[i].op);
// Populate arguments
log.debug(`Populating arguments for ${recipeConfig[i].op}`);
const args = item.querySelectorAll(".arg");
for (let j = 0; j < args.length; j++) {
if (recipeConfig[i].args[j] === undefined) continue;
if (args[j].getAttribute("type") === "checkbox") {
// checkbox
args[j].checked = recipeConfig[i].args[j];
} else if (args[j].classList.contains("toggle-string")) {
// toggleString
args[j].value = recipeConfig[i].args[j].string;
args[j].parentNode.parentNode.querySelector("button").innerHTML =
Utils.escapeHtml(recipeConfig[i].args[j].option);
} else {
// all others
args[j].value = recipeConfig[i].args[j];
}
}
// Set disabled and breakpoint
if (recipeConfig[i].disabled) {
item.querySelector(".disable-icon").click();
}
if (recipeConfig[i].breakpoint) {
item.querySelector(".breakpoint").click();
}
this.manager.recipe.triggerArgEvents(item);
this.progress = 0;
}
// Unpause auto bake
this.autoBakePause = false;
}
/**
* Resets the splitter positions to default.
*/
resetLayout() {
this.columnSplitter.setSizes([20, 30, 50]);
this.ioSplitter.setSizes([50, 50]);
this.manager.recipe.adjustWidth();
this.manager.input.calcMaxTabs();
this.manager.output.calcMaxTabs();
}
/**
* Sets the compile message.
*/
setCompileMessage() {
// Display time since last build and compile message
const now = new Date(),
msSinceCompile = now.getTime() - window.compileTime,
timeSinceCompile = moment.duration(msSinceCompile, "milliseconds").humanize();
// Calculate previous version to compare to
const prev = PKG_VERSION.split(".").map(n => {
return parseInt(n, 10);
});
if (prev[2] > 0) prev[2]--;
else if (prev[1] > 0) prev[1]--;
else prev[0]--;
// const compareURL = `https://github.com/gchq/CyberChef/compare/v${prev.join(".")}...v${PKG_VERSION}`;
let compileInfo = `<a href='https://github.com/gchq/CyberChef/blob/master/CHANGELOG.md'>Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago</a>`;
if (window.compileMessage !== "") {
compileInfo += " - " + window.compileMessage;
}
const notice = document.getElementById("notice");
notice.innerHTML = compileInfo;
notice.setAttribute("title", Utils.stripHtmlTags(window.compileMessage));
}
/**
* Determines whether the browser supports Local Storage and if it is accessible.
*
* @returns {boolean}
*/
isLocalStorageAvailable() {
try {
if (!localStorage) return false;
return true;
} catch (err) {
// Access to LocalStorage is denied
return false;
}
}
/**
* Pops up a message to the user and writes it to the console log.
*
* @param {string} str - The message to display (HTML supported)
* @param {number} [timeout=0] - The number of milliseconds before the alert closes automatically
* 0 for never (until the user closes it)
* @param {boolean} [silent=false] - Don't show the message in the popup, only print it to the
* console
*
* @example
* // Pops up a box with the message "Error: Something has gone wrong!" that will need to be
* // dismissed by the user.
* this.alert("Error: Something has gone wrong!", 0);
*
* // Pops up a box with the message "Happy Christmas!" that will disappear after 5 seconds.
* this.alert("Happy Christmas!", 5000);
*/
alert(str, timeout=0, silent=false) {
const time = new Date();
log.info("[" + time.toLocaleString() + "] " + str);
if (silent) return;
this.currentSnackbar = $.snackbar({
content: str,
timeout: timeout,
htmlAllowed: true,
onClose: () => {
this.currentSnackbar.remove();
}
});
}
/**
* Pops up a box asking the user a question and sending the answer to a specified callback function.
*
* @param {string} title - The title of the box
* @param {string} body - The question (HTML supported)
* @param {string} accept - The text of the accept button
* @param {string} reject - The text of the reject button
* @param {function} callback - A function accepting one boolean argument which handles the
* response e.g. function(answer) {...}
* @param {Object} [scope=this] - The object to bind to the callback function
*
* @example
* // Pops up a box asking if the user would like a cookie. Prints the answer to the console.
* this.confirm("Question", "Would you like a cookie?", "Yes", "No", function(answer) {console.log(answer);});
*/
confirm(title, body, accept, reject, callback, scope) {
scope = scope || this;
document.getElementById("confirm-title").innerHTML = title;
document.getElementById("confirm-body").innerHTML = body;
document.getElementById("confirm-yes").innerText = accept;
document.getElementById("confirm-no").innerText = reject;
document.getElementById("confirm-modal").style.display = "block";
this.confirmClosed = false;
$("#confirm-modal").modal()
.one("show.bs.modal", function(e) {
this.confirmClosed = false;
}.bind(this))
.one("click", "#confirm-yes", function() {
this.confirmClosed = true;
callback.bind(scope)(true);
$("#confirm-modal").modal("hide");
}.bind(this))
.one("click", "#confirm-no", function() {
this.confirmClosed = true;
callback.bind(scope)(false);
}.bind(this))
.one("hide.bs.modal", function(e) {
if (!this.confirmClosed) {
callback.bind(scope)(undefined);
}
this.confirmClosed = true;
}.bind(this));
}
/**
* Handler for CyerChef statechange events.
* Fires whenever the input or recipe changes in any way.
*
* @listens Manager#statechange
* @param {event} e
*/
stateChange(e) {
this.progress = 0;
this.autoBake();
this.updateTitle(false, null, true);
}
/**
* Update the page title to contain the new recipe
*
* @param {boolean} includeInput
* @param {string} input
* @param {boolean} [changeUrl=true]
*/
updateTitle(includeInput, input, changeUrl=true) {
// Set title
const recipeConfig = this.getRecipeConfig();
let title = "CyberChef";
if (recipeConfig.length === 1) {
title = `${recipeConfig[0].op} - ${title}`;
} else if (recipeConfig.length > 1) {
// See how long the full recipe is
const ops = recipeConfig.map(op => op.op).join(", ");
if (ops.length < 45) {
title = `${ops} - ${title}`;
} else {
// If it's too long, just use the first one and say how many more there are
title = `${recipeConfig[0].op}, ${recipeConfig.length - 1} more - ${title}`;
}
}
document.title = title;
// Update the current history state (not creating a new one)
if (this.options.updateUrl && changeUrl) {
this.lastStateUrl = this.manager.controls.generateStateUrl(true, includeInput, input, recipeConfig);
window.history.replaceState({}, title, this.lastStateUrl);
}
}
/**
* Handler for the history popstate event.
* Reloads parameters from the URL.
*
* @param {event} e
*/
popState(e) {
this.loadURIParams();
}
}
export default App;