From 07715bd1676a96a1881c9e55fc79cbcd33a91708 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Tue, 15 May 2018 17:36:45 +0000 Subject: [PATCH] ESM: Rewritten src/web/ in ESM format. --- src/core/operations/FromHex.mjs | 1 + src/web/App.js | 1312 +++++++++-------- src/web/App.mjs | 753 ++++++++++ src/web/BindingsWaiter.js | 217 --- src/web/BindingsWaiter.mjs | 224 +++ src/web/ControlsWaiter.js | 441 ------ src/web/ControlsWaiter.mjs | 449 ++++++ src/web/HTMLCategory.js | 52 - src/web/HTMLCategory.mjs | 60 + src/web/HTMLIngredient.js | 215 --- src/web/HTMLIngredient.mjs | 223 +++ src/web/HTMLOperation.js | 128 -- src/web/HTMLOperation.mjs | 129 ++ src/web/HighlighterWaiter.js | 461 ------ src/web/HighlighterWaiter.mjs | 468 ++++++ src/web/InputWaiter.js | 321 ---- src/web/InputWaiter.mjs | 329 +++++ src/web/Manager.js | 299 ---- src/web/Manager.mjs | 307 ++++ src/web/OperationsWaiter.js | 313 ---- src/web/OperationsWaiter.mjs | 321 ++++ .../{OptionsWaiter.js => OptionsWaiter.mjs} | 0 src/web/OutputWaiter.js | 441 ------ src/web/OutputWaiter.mjs | 449 ++++++ src/web/RecipeWaiter.js | 467 ------ src/web/RecipeWaiter.mjs | 475 ++++++ src/web/SeasonalWaiter.js | 48 - src/web/SeasonalWaiter.mjs | 56 + src/web/WindowWaiter.js | 54 - src/web/WindowWaiter.mjs | 62 + src/web/WorkerWaiter.js | 231 --- src/web/WorkerWaiter.mjs | 239 +++ src/web/index.js | 2 +- webpack.config.js | 1 + 34 files changed, 5207 insertions(+), 4341 deletions(-) mode change 100755 => 100644 src/web/App.js create mode 100755 src/web/App.mjs delete mode 100755 src/web/BindingsWaiter.js create mode 100755 src/web/BindingsWaiter.mjs delete mode 100755 src/web/ControlsWaiter.js create mode 100755 src/web/ControlsWaiter.mjs delete mode 100755 src/web/HTMLCategory.js create mode 100755 src/web/HTMLCategory.mjs delete mode 100755 src/web/HTMLIngredient.js create mode 100755 src/web/HTMLIngredient.mjs delete mode 100755 src/web/HTMLOperation.js create mode 100755 src/web/HTMLOperation.mjs delete mode 100755 src/web/HighlighterWaiter.js create mode 100755 src/web/HighlighterWaiter.mjs delete mode 100755 src/web/InputWaiter.js create mode 100755 src/web/InputWaiter.mjs delete mode 100755 src/web/Manager.js create mode 100755 src/web/Manager.mjs delete mode 100755 src/web/OperationsWaiter.js create mode 100755 src/web/OperationsWaiter.mjs rename src/web/{OptionsWaiter.js => OptionsWaiter.mjs} (100%) delete mode 100755 src/web/OutputWaiter.js create mode 100755 src/web/OutputWaiter.mjs delete mode 100755 src/web/RecipeWaiter.js create mode 100755 src/web/RecipeWaiter.mjs delete mode 100755 src/web/SeasonalWaiter.js create mode 100755 src/web/SeasonalWaiter.mjs delete mode 100755 src/web/WindowWaiter.js create mode 100755 src/web/WindowWaiter.mjs delete mode 100755 src/web/WorkerWaiter.js create mode 100755 src/web/WorkerWaiter.mjs diff --git a/src/core/operations/FromHex.mjs b/src/core/operations/FromHex.mjs index b35c37cb..9d85b49a 100644 --- a/src/core/operations/FromHex.mjs +++ b/src/core/operations/FromHex.mjs @@ -53,6 +53,7 @@ class FromHex extends Operation { * @returns {Object[]} pos */ highlight(pos, args) { + if (args[0] === "Auto") return false; const delim = Utils.charRep(args[0] || "Space"), len = delim === "\r\n" ? 1 : delim.length, width = len + 2; diff --git a/src/web/App.js b/src/web/App.js old mode 100755 new mode 100644 index 2dc037d3..c08658a7 --- a/src/web/App.js +++ b/src/web/App.js @@ -1,745 +1,753 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + import Utils from "../core/Utils"; import {fromBase64} from "../core/lib/Base64"; -import Manager from "./Manager.js"; -import HTMLCategory from "./HTMLCategory.js"; -import HTMLOperation from "./HTMLOperation.js"; +import Manager from "./Manager"; +import HTMLCategory from "./HTMLCategory"; +import HTMLOperation from "./HTMLOperation"; import Split from "split.js"; /** * HTML view for CyberChef responsible for building the web page and dealing with all user * interactions. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {CatConf[]} categories - The list of categories and operations to be populated. - * @param {Object.} operations - The list of operation configuration objects. - * @param {String[]} defaultFavourites - A list of default favourite operations. - * @param {Object} options - Default setting for app options. */ -const App = function(categories, operations, defaultFavourites, defaultOptions) { - this.categories = categories; - this.operations = operations; - this.dfavourites = defaultFavourites; - this.doptions = defaultOptions; - this.options = Object.assign({}, defaultOptions); +class App { - this.manager = new Manager(this); + /** + * App constructor. + * + * @param {CatConf[]} categories - The list of categories and operations to be populated. + * @param {Object.} 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.baking = false; - this.autoBake_ = false; - this.autoBakePause = false; - this.progress = 0; - this.ingId = 0; -}; + this.manager = new Manager(this); - -/** - * This function sets up the stage and creates listeners for all events. - * - * @fires Manager#appstart - */ -App.prototype.setup = function() { - document.dispatchEvent(this.manager.appstart); - this.initialiseSplitter(); - this.loadLocalStorage(); - this.populateOperationsList(); - this.manager.setup(); - 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 - */ -App.prototype.loaded = function() { - // 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"); - }, 1000); - - // Clear the loading message interval - clearInterval(window.loadingMsgsInt); - - // Remove the loading error handler - window.removeEventListener("error", window.loadingErrorHandler); - - document.dispatchEvent(this.manager.apploaded); -}; - - -/** - * An error handler for displaying the error to the user. - * - * @param {Error} err - * @param {boolean} [logToConsole=false] - */ -App.prototype.handleError = function(err, logToConsole) { - if (logToConsole) log.error(err); - const msg = err.displayStr || err.toString(); - this.alert(msg, "danger", 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. - */ -App.prototype.bake = function(step) { - if (this.baking) return; - - // Reset attemptHighlight flag - this.options.attemptHighlight = true; - - this.manager.worker.bake( - this.getInput(), // The user's input - 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. - */ -App.prototype.autoBake = function() { - // 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.bake(); - } else { - this.manager.controls.showStaleIndicator(); - } -}; - - -/** - * 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. - */ -App.prototype.silentBake = function() { - 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.baking = false; + this.autoBake_ = false; + this.autoBakePause = false; + this.progress = 0; + this.ingId = 0; } - this.manager.worker.silentBake(recipeConfig); -}; + /** + * 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.resetLayout(); + this.setCompileMessage(); -/** - * Gets the user's input data. - * - * @returns {string} - */ -App.prototype.getInput = function() { - return this.manager.input.get(); -}; + log.debug("App loaded"); + this.appLoaded = true; - -/** - * Sets the user's input data. - * - * @param {string} input - The string to set the input to - */ -App.prototype.setInput = function(input) { - this.manager.input.set(input); -}; - - -/** - * Populates the operations accordion list with the categories and operations specified in the - * view constructor. - * - * @fires Manager#oplistcreate - */ -App.prototype.populateOperationsList = function() { - // 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 (!this.operations.hasOwnProperty(opName)) { - 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(); + this.loadURIParams(); + this.loaded(); } - document.getElementById("categories").innerHTML = html; - const opLists = document.querySelectorAll("#categories .op-list"); + /** + * 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; - for (i = 0; i < opLists.length; i++) { - opLists[i].dispatchEvent(this.manager.oplistcreate); + // 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"); + }, 1000); + + // Clear the loading message interval + clearInterval(window.loadingMsgsInt); + + // Remove the loading error handler + window.removeEventListener("error", window.loadingErrorHandler); + + document.dispatchEvent(this.manager.apploaded); } - // 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. - */ -App.prototype.initialiseSplitter = function() { - this.columnSplitter = Split(["#operations", "#recipe", "#IO"], { - sizes: [20, 30, 50], - minSize: [240, 325, 450], - gutterSize: 4, - onDrag: function() { - this.manager.controls.adjustWidth(); - this.manager.output.adjustWidth(); - }.bind(this) - }); - - this.ioSplitter = Split(["#input", "#output"], { - direction: "vertical", - gutterSize: 4, - }); - - this.resetLayout(); -}; - - -/** - * Loads the information previously saved to the HTML5 local storage object so that user options - * and favourites can be restored. - */ -App.prototype.loadLocalStorage = function() { - // 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. - */ -App.prototype.loadFavourites = function() { - 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; + /** + * 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(msg, "danger", this.options.errorTimeout, !this.options.showErrors); } - 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 - }); - } -}; + /** + * 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) { + if (this.baking) return; + // Reset attemptHighlight flag + this.options.attemptHighlight = true; -/** - * 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 - */ -App.prototype.validFavourites = function(favourites) { - const validFavs = []; - for (let i = 0; i < favourites.length; i++) { - if (this.operations.hasOwnProperty(favourites[i])) { - validFavs.push(favourites[i]); - } else { - this.alert("The operation \"" + Utils.escapeHtml(favourites[i]) + - "\" is no longer available. It has been removed from your favourites.", "info"); - } - } - 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 - */ -App.prototype.saveFavourites = function(favourites) { - if (!this.isLocalStorageAvailable()) { - this.alert( - "Your security settings do not allow access to local storage so your favourites cannot be saved.", - "danger", - 5000 + this.manager.worker.bake( + this.getInput(), // The user's input + 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 ); - return false; } - localStorage.setItem("favourites", JSON.stringify(this.validFavourites(favourites))); -}; + /** + * 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; -/** - * Resets favourite operations back to the default as specified in the view constructor and - * refreshes the operation list. - */ -App.prototype.resetFavourites = function() { - 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 - */ -App.prototype.addFavourite = function(name) { - const favourites = JSON.parse(localStorage.favourites); - - if (favourites.indexOf(name) >= 0) { - this.alert("'" + name + "' is already in your favourites", "info", 2000); - return; + if (this.autoBake_ && !this.baking) { + log.debug("Auto-baking"); + this.bake(); + } else { + this.manager.controls.showStaleIndicator(); + } } - favourites.push(name); - this.saveFavourites(favourites); - this.loadFavourites(); - this.populateOperationsList(); - this.manager.recipe.initialiseOperationDragNDrop(); -}; + /** + * 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 = []; -/** - * Checks for input and recipe in the URI parameters and loads them if present. - */ -App.prototype.loadURIParams = function() { - // 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; - this.uriParams = Utils.parseURIParams(params); - this.autoBakePause = true; - - // 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); + 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(); } - // Populate search with the string - const search = document.getElementById("search"); - - search.value = this.uriParams.op; - search.dispatchEvent(new Event("search")); + this.manager.worker.silentBake(recipeConfig); } - // Read in input data from URI params - if (this.uriParams.input) { - try { - const inputData = fromBase64(this.uriParams.input); - this.setInput(inputData); - } catch (err) {} + + /** + * Gets the user's input data. + * + * @returns {string} + */ + getInput() { + return this.manager.input.get(); } - this.autoBakePause = false; - this.autoBake(); -}; + + /** + * Sets the user's input data. + * + * @param {string} input - The string to set the input to + */ + setInput(input) { + this.manager.input.set(input); + } -/** - * Returns the next ingredient ID and increments it for next time. - * - * @returns {number} - */ -App.prototype.nextIngId = function() { - return this.ingId++; -}; + /** + * 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 (!this.operations.hasOwnProperty(opName)) { + 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")); + } -/** - * Gets the current recipe configuration. - * - * @returns {Object[]} - */ -App.prototype.getRecipeConfig = function() { - return this.manager.recipe.getConfig(); -}; + /** + * Sets up the adjustable splitter to allow the user to resize areas of the page. + */ + initialiseSplitter() { + this.columnSplitter = Split(["#operations", "#recipe", "#IO"], { + sizes: [20, 30, 50], + minSize: [240, 325, 450], + gutterSize: 4, + onDrag: function() { + this.manager.controls.adjustWidth(); + this.manager.output.adjustWidth(); + }.bind(this) + }); + + this.ioSplitter = Split(["#input", "#output"], { + direction: "vertical", + gutterSize: 4, + }); + + this.resetLayout(); + } -/** - * Given a recipe configuration, sets the recipe to that configuration. - * - * @fires Manager#statechange - * @param {Object[]} recipeConfig - The recipe configuration - */ -App.prototype.setRecipeConfig = function(recipeConfig) { - document.getElementById("rec-list").innerHTML = null; + /** + * 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); - // Pause auto-bake while loading but don't modify `this.autoBake_` - // otherwise `manualBake` cannot trigger. - this.autoBakePause = true; + // Load favourites + this.loadFavourites(); + } - for (let i = 0; i < recipeConfig.length; i++) { - const item = this.manager.recipe.addOperation(recipeConfig[i].op); - // Populate arguments - 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].previousSibling.children[0].innerHTML = - Utils.escapeHtml(recipeConfig[i].args[j].option) + - " "; + /** + * 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 (this.operations.hasOwnProperty(favourites[i])) { + validFavs.push(favourites[i]); } else { - // all others - args[j].value = recipeConfig[i].args[j]; + this.alert("The operation \"" + Utils.escapeHtml(favourites[i]) + + "\" is no longer available. It has been removed from your favourites.", "info"); } } + return validFavs; + } - // Set disabled and breakpoint - if (recipeConfig[i].disabled) { - item.querySelector(".disable-icon").click(); - } - if (recipeConfig[i].breakpoint) { - item.querySelector(".breakpoint").click(); + + /** + * 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.", + "danger", + 5000 + ); + return false; } - this.progress = 0; + localStorage.setItem("favourites", JSON.stringify(this.validFavourites(favourites))); } - // Unpause auto bake - this.autoBakePause = false; -}; - -/** - * Resets the splitter positions to default. - */ -App.prototype.resetLayout = function() { - this.columnSplitter.setSizes([20, 30, 50]); - this.ioSplitter.setSizes([50, 50]); - - this.manager.controls.adjustWidth(); - this.manager.output.adjustWidth(); -}; - - -/** - * Sets the compile message. - */ -App.prototype.setCompileMessage = function() { - // Display time since last build and compile message - const now = new Date(), - timeSinceCompile = Utils.fuzzyTime(now.getTime() - window.compileTime); - - // 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 = `Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago`; - - if (window.compileMessage !== "") { - compileInfo += " - " + window.compileMessage; + /** + * 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(); } - document.getElementById("notice").innerHTML = compileInfo; -}; + /** + * Adds an operation to the user's favourites. + * + * @param {string} name - The name of the operation + */ + addFavourite(name) { + const favourites = JSON.parse(localStorage.favourites); -/** - * Determines whether the browser supports Local Storage and if it is accessible. - * - * @returns {boolean} - */ -App.prototype.isLocalStorageAvailable = function() { - try { - if (!localStorage) return false; - return true; - } catch (err) { - // Access to LocalStorage is denied - return false; - } -}; + if (favourites.indexOf(name) >= 0) { + this.alert("'" + name + "' is already in your favourites", "info", 2000); + return; + } - -/** - * Pops up a message to the user and writes it to the console log. - * - * @param {string} str - The message to display (HTML supported) - * @param {string} style - The colour of the popup - * "danger" = red - * "warning" = amber - * "info" = blue - * "success" = green - * @param {number} timeout - The number of milliseconds before the popup 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 red box with the message "[current time] Error: Something has gone wrong!" - * // that will need to be dismissed by the user. - * this.alert("Error: Something has gone wrong!", "danger", 0); - * - * // Pops up a blue information box with the message "[current time] Happy Christmas!" - * // that will disappear after 5 seconds. - * this.alert("Happy Christmas!", "info", 5000); - */ -App.prototype.alert = function(str, style, timeout, silent) { - const time = new Date(); - - log.info("[" + time.toLocaleString() + "] " + str); - if (silent) return; - - style = style || "danger"; - timeout = timeout || 0; - - const alertEl = document.getElementById("alert"), - alertContent = document.getElementById("alert-content"); - - alertEl.classList.remove("alert-danger"); - alertEl.classList.remove("alert-warning"); - alertEl.classList.remove("alert-info"); - alertEl.classList.remove("alert-success"); - alertEl.classList.add("alert-" + style); - - // If the box hasn't been closed, append to it rather than replacing - if (alertEl.style.display === "block") { - alertContent.innerHTML += - "

[" + time.toLocaleTimeString() + "] " + str; - } else { - alertContent.innerHTML = - "[" + time.toLocaleTimeString() + "] " + str; + favourites.push(name); + this.saveFavourites(favourites); + this.loadFavourites(); + this.populateOperationsList(); + this.manager.recipe.initialiseOperationDragNDrop(); } - // Stop the animation if it is in progress - $("#alert").stop(); - alertEl.style.display = "block"; - alertEl.style.opacity = 1; - if (timeout > 0) { - clearTimeout(this.alertTimeout); - this.alertTimeout = setTimeout(function(){ - $("#alert").slideUp(100); - }, timeout); + /** + * Checks for input and recipe in the URI parameters and loads them if present. + */ + loadURIParams() { + // 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; + this.uriParams = Utils.parseURIParams(params); + this.autoBakePause = true; + + // 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) {} + } + + this.autoBakePause = false; + this.autoBake(); } -}; -/** - * 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 {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?", function(answer) {console.log(answer);}); - */ -App.prototype.confirm = function(title, body, callback, scope) { - scope = scope || this; - document.getElementById("confirm-title").innerHTML = title; - document.getElementById("confirm-body").innerHTML = body; - 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("hide.bs.modal", function(e) { - if (!this.confirmClosed) - callback.bind(scope)(false); - this.confirmClosed = true; - }.bind(this)); -}; + /** + * Returns the next ingredient ID and increments it for next time. + * + * @returns {number} + */ + nextIngId() { + return this.ingId++; + } -/** - * Handler for the alert close button click event. - * Closes the alert box. - */ -App.prototype.alertCloseClick = function() { - document.getElementById("alert").style.display = "none"; -}; + /** + * Gets the current recipe configuration. + * + * @returns {Object[]} + */ + getRecipeConfig() { + return this.manager.recipe.getConfig(); + } -/** - * Handler for CyerChef statechange events. - * Fires whenever the input or recipe changes in any way. - * - * @listens Manager#statechange - * @param {event} e - */ -App.prototype.stateChange = function(e) { - this.autoBake(); + /** + * 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; - // 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}`; + // 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 + 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].previousSibling.children[0].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.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.controls.adjustWidth(); + this.manager.output.adjustWidth(); + } + + + /** + * Sets the compile message. + */ + setCompileMessage() { + // Display time since last build and compile message + const now = new Date(), + timeSinceCompile = Utils.fuzzyTime(now.getTime() - window.compileTime); + + // 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 = `Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago`; + + if (window.compileMessage !== "") { + compileInfo += " - " + window.compileMessage; + } + + document.getElementById("notice").innerHTML = compileInfo; + } + + + /** + * 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 {string} style - The colour of the popup + * "danger" = red + * "warning" = amber + * "info" = blue + * "success" = green + * @param {number} timeout - The number of milliseconds before the popup 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 red box with the message "[current time] Error: Something has gone wrong!" + * // that will need to be dismissed by the user. + * this.alert("Error: Something has gone wrong!", "danger", 0); + * + * // Pops up a blue information box with the message "[current time] Happy Christmas!" + * // that will disappear after 5 seconds. + * this.alert("Happy Christmas!", "info", 5000); + */ + alert(str, style, timeout, silent) { + const time = new Date(); + + log.info("[" + time.toLocaleString() + "] " + str); + if (silent) return; + + style = style || "danger"; + timeout = timeout || 0; + + const alertEl = document.getElementById("alert"), + alertContent = document.getElementById("alert-content"); + + alertEl.classList.remove("alert-danger"); + alertEl.classList.remove("alert-warning"); + alertEl.classList.remove("alert-info"); + alertEl.classList.remove("alert-success"); + alertEl.classList.add("alert-" + style); + + // If the box hasn't been closed, append to it rather than replacing + if (alertEl.style.display === "block") { + alertContent.innerHTML += + "

[" + time.toLocaleTimeString() + "] " + str; } 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}`; + alertContent.innerHTML = + "[" + time.toLocaleTimeString() + "] " + str; + } + + // Stop the animation if it is in progress + $("#alert").stop(); + alertEl.style.display = "block"; + alertEl.style.opacity = 1; + + if (timeout > 0) { + clearTimeout(this.alertTimeout); + this.alertTimeout = setTimeout(function(){ + $("#alert").slideUp(100); + }, timeout); } } - document.title = title; - // Update the current history state (not creating a new one) - if (this.options.updateUrl) { - this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, recipeConfig); - window.history.replaceState({}, title, this.lastStateUrl); + + /** + * 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 {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?", function(answer) {console.log(answer);}); + */ + confirm(title, body, callback, scope) { + scope = scope || this; + document.getElementById("confirm-title").innerHTML = title; + document.getElementById("confirm-body").innerHTML = body; + 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("hide.bs.modal", function(e) { + if (!this.confirmClosed) + callback.bind(scope)(false); + this.confirmClosed = true; + }.bind(this)); } -}; -/** - * Handler for the history popstate event. - * Reloads parameters from the URL. - * - * @param {event} e - */ -App.prototype.popState = function(e) { - this.loadURIParams(); -}; + /** + * Handler for the alert close button click event. + * Closes the alert box. + */ + alertCloseClick() { + document.getElementById("alert").style.display = "none"; + } -/** - * Function to call an external API from this view. - */ -App.prototype.callApi = function(url, type, data, dataType, contentType) { - type = type || "POST"; - data = data || {}; - dataType = dataType || undefined; - contentType = contentType || "application/json"; + /** + * Handler for CyerChef statechange events. + * Fires whenever the input or recipe changes in any way. + * + * @listens Manager#statechange + * @param {event} e + */ + stateChange(e) { + this.autoBake(); - let response = null, - success = false; + // 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; - $.ajax({ - url: url, - async: false, - type: type, - data: data, - dataType: dataType, - contentType: contentType, - success: function(data) { - success = true; - response = data; - }, - error: function(data) { + // Update the current history state (not creating a new one) + if (this.options.updateUrl) { + this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, 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(); + } + + + /** + * Function to call an external API from this view. + */ + callApi(url, type, data, dataType, contentType) { + type = type || "POST"; + data = data || {}; + dataType = dataType || undefined; + contentType = contentType || "application/json"; + + let response = null, success = false; - response = data; - }, - }); - return { - success: success, - response: response - }; -}; + $.ajax({ + url: url, + async: false, + type: type, + data: data, + dataType: dataType, + contentType: contentType, + success: function(data) { + success = true; + response = data; + }, + error: function(data) { + success = false; + response = data; + }, + }); + + return { + success: success, + response: response + }; + } + +} export default App; diff --git a/src/web/App.mjs b/src/web/App.mjs new file mode 100755 index 00000000..c08658a7 --- /dev/null +++ b/src/web/App.mjs @@ -0,0 +1,753 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import Utils 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"; + + +/** + * 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.} 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.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"); + }, 1000); + + // Clear the loading message interval + clearInterval(window.loadingMsgsInt); + + // Remove the loading error handler + window.removeEventListener("error", window.loadingErrorHandler); + + document.dispatchEvent(this.manager.apploaded); + } + + + /** + * 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(msg, "danger", 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) { + if (this.baking) return; + + // Reset attemptHighlight flag + this.options.attemptHighlight = true; + + this.manager.worker.bake( + this.getInput(), // The user's input + 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.bake(); + } else { + this.manager.controls.showStaleIndicator(); + } + } + + + /** + * 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); + } + + + /** + * Gets the user's input data. + * + * @returns {string} + */ + getInput() { + return this.manager.input.get(); + } + + + /** + * Sets the user's input data. + * + * @param {string} input - The string to set the input to + */ + setInput(input) { + this.manager.input.set(input); + } + + + /** + * 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 (!this.operations.hasOwnProperty(opName)) { + 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. + */ + initialiseSplitter() { + this.columnSplitter = Split(["#operations", "#recipe", "#IO"], { + sizes: [20, 30, 50], + minSize: [240, 325, 450], + gutterSize: 4, + onDrag: function() { + this.manager.controls.adjustWidth(); + this.manager.output.adjustWidth(); + }.bind(this) + }); + + this.ioSplitter = Split(["#input", "#output"], { + direction: "vertical", + gutterSize: 4, + }); + + 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 (this.operations.hasOwnProperty(favourites[i])) { + validFavs.push(favourites[i]); + } else { + this.alert("The operation \"" + Utils.escapeHtml(favourites[i]) + + "\" is no longer available. It has been removed from your favourites.", "info"); + } + } + 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.", + "danger", + 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", "info", 2000); + return; + } + + favourites.push(name); + this.saveFavourites(favourites); + this.loadFavourites(); + this.populateOperationsList(); + this.manager.recipe.initialiseOperationDragNDrop(); + } + + + /** + * Checks for input and recipe in the URI parameters and loads them if present. + */ + loadURIParams() { + // 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; + this.uriParams = Utils.parseURIParams(params); + this.autoBakePause = true; + + // 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) {} + } + + this.autoBakePause = false; + this.autoBake(); + } + + + /** + * 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 + 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].previousSibling.children[0].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.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.controls.adjustWidth(); + this.manager.output.adjustWidth(); + } + + + /** + * Sets the compile message. + */ + setCompileMessage() { + // Display time since last build and compile message + const now = new Date(), + timeSinceCompile = Utils.fuzzyTime(now.getTime() - window.compileTime); + + // 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 = `Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago`; + + if (window.compileMessage !== "") { + compileInfo += " - " + window.compileMessage; + } + + document.getElementById("notice").innerHTML = compileInfo; + } + + + /** + * 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 {string} style - The colour of the popup + * "danger" = red + * "warning" = amber + * "info" = blue + * "success" = green + * @param {number} timeout - The number of milliseconds before the popup 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 red box with the message "[current time] Error: Something has gone wrong!" + * // that will need to be dismissed by the user. + * this.alert("Error: Something has gone wrong!", "danger", 0); + * + * // Pops up a blue information box with the message "[current time] Happy Christmas!" + * // that will disappear after 5 seconds. + * this.alert("Happy Christmas!", "info", 5000); + */ + alert(str, style, timeout, silent) { + const time = new Date(); + + log.info("[" + time.toLocaleString() + "] " + str); + if (silent) return; + + style = style || "danger"; + timeout = timeout || 0; + + const alertEl = document.getElementById("alert"), + alertContent = document.getElementById("alert-content"); + + alertEl.classList.remove("alert-danger"); + alertEl.classList.remove("alert-warning"); + alertEl.classList.remove("alert-info"); + alertEl.classList.remove("alert-success"); + alertEl.classList.add("alert-" + style); + + // If the box hasn't been closed, append to it rather than replacing + if (alertEl.style.display === "block") { + alertContent.innerHTML += + "

[" + time.toLocaleTimeString() + "] " + str; + } else { + alertContent.innerHTML = + "[" + time.toLocaleTimeString() + "] " + str; + } + + // Stop the animation if it is in progress + $("#alert").stop(); + alertEl.style.display = "block"; + alertEl.style.opacity = 1; + + if (timeout > 0) { + clearTimeout(this.alertTimeout); + this.alertTimeout = setTimeout(function(){ + $("#alert").slideUp(100); + }, timeout); + } + } + + + /** + * 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 {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?", function(answer) {console.log(answer);}); + */ + confirm(title, body, callback, scope) { + scope = scope || this; + document.getElementById("confirm-title").innerHTML = title; + document.getElementById("confirm-body").innerHTML = body; + 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("hide.bs.modal", function(e) { + if (!this.confirmClosed) + callback.bind(scope)(false); + this.confirmClosed = true; + }.bind(this)); + } + + + /** + * Handler for the alert close button click event. + * Closes the alert box. + */ + alertCloseClick() { + document.getElementById("alert").style.display = "none"; + } + + + /** + * Handler for CyerChef statechange events. + * Fires whenever the input or recipe changes in any way. + * + * @listens Manager#statechange + * @param {event} e + */ + stateChange(e) { + this.autoBake(); + + // 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) { + this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, 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(); + } + + + /** + * Function to call an external API from this view. + */ + callApi(url, type, data, dataType, contentType) { + type = type || "POST"; + data = data || {}; + dataType = dataType || undefined; + contentType = contentType || "application/json"; + + let response = null, + success = false; + + $.ajax({ + url: url, + async: false, + type: type, + data: data, + dataType: dataType, + contentType: contentType, + success: function(data) { + success = true; + response = data; + }, + error: function(data) { + success = false; + response = data; + }, + }); + + return { + success: success, + response: response + }; + } + +} + +export default App; diff --git a/src/web/BindingsWaiter.js b/src/web/BindingsWaiter.js deleted file mode 100755 index 802590b8..00000000 --- a/src/web/BindingsWaiter.js +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Waiter to handle keybindings to CyberChef functions (i.e. Bake, Step, Save, Load etc.) - * - * @author Matt C [matt@artemisbot.uk] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const BindingsWaiter = function (app, manager) { - this.app = app; - this.manager = manager; -}; - - -/** - * Handler for all keydown events - * Checks whether valid keyboard shortcut has been instated - * - * @fires Manager#statechange - * @param {event} e - */ -BindingsWaiter.prototype.parseInput = function(e) { - const modKey = this.app.options.useMetaKey ? e.metaKey : e.altKey; - - if (e.ctrlKey && modKey) { - let elem; - switch (e.code) { - case "KeyF": // Focus search - e.preventDefault(); - document.getElementById("search").focus(); - break; - case "KeyI": // Focus input - e.preventDefault(); - document.getElementById("input-text").focus(); - break; - case "KeyO": // Focus output - e.preventDefault(); - document.getElementById("output-text").focus(); - break; - case "Period": // Focus next operation - e.preventDefault(); - try { - elem = document.activeElement.closest(".operation") || document.querySelector("#rec-list .operation"); - if (elem.parentNode.lastChild === elem) { - // If operation is last in recipe, loop around to the top operation's first argument - elem.parentNode.firstChild.querySelectorAll(".arg")[0].focus(); - } else { - // Focus first argument of next operation - elem.nextSibling.querySelectorAll(".arg")[0].focus(); - } - } catch (e) { - // do nothing, just don't throw an error - } - break; - case "KeyB": // Set breakpoint - e.preventDefault(); - try { - elem = document.activeElement.closest(".operation").querySelectorAll(".breakpoint")[0]; - if (elem.getAttribute("break") === "false") { - elem.setAttribute("break", "true"); // add break point if not already enabled - elem.classList.add("breakpoint-selected"); - } else { - elem.setAttribute("break", "false"); // remove break point if already enabled - elem.classList.remove("breakpoint-selected"); - } - window.dispatchEvent(this.manager.statechange); - } catch (e) { - // do nothing, just don't throw an error - } - break; - case "KeyD": // Disable operation - e.preventDefault(); - try { - elem = document.activeElement.closest(".operation").querySelectorAll(".disable-icon")[0]; - if (elem.getAttribute("disabled") === "false") { - elem.setAttribute("disabled", "true"); // disable operation if enabled - elem.classList.add("disable-elem-selected"); - elem.parentNode.parentNode.classList.add("disabled"); - } else { - elem.setAttribute("disabled", "false"); // enable operation if disabled - elem.classList.remove("disable-elem-selected"); - elem.parentNode.parentNode.classList.remove("disabled"); - } - this.app.progress = 0; - window.dispatchEvent(this.manager.statechange); - } catch (e) { - // do nothing, just don't throw an error - } - break; - case "Space": // Bake - e.preventDefault(); - this.app.bake(); - break; - case "Quote": // Step through - e.preventDefault(); - this.app.bake(true); - break; - case "KeyC": // Clear recipe - e.preventDefault(); - this.manager.recipe.clearRecipe(); - break; - case "KeyS": // Save output to file - e.preventDefault(); - this.manager.output.saveClick(); - break; - case "KeyL": // Load recipe - e.preventDefault(); - this.manager.controls.loadClick(); - break; - case "KeyM": // Switch input and output - e.preventDefault(); - this.manager.output.switchClick(); - break; - default: - if (e.code.match(/Digit[0-9]/g)) { // Select nth operation - e.preventDefault(); - try { - // Select the first argument of the operation corresponding to the number pressed - document.querySelector(`li:nth-child(${e.code.substr(-1)}) .arg`).focus(); - } catch (e) { - // do nothing, just don't throw an error - } - } - break; - } - } -}; - - -/** - * Updates keybinding list when metaKey option is toggled - * - */ -BindingsWaiter.prototype.updateKeybList = function() { - let modWinLin = "Alt"; - let modMac = "Opt"; - if (this.app.options.useMetaKey) { - modWinLin = "Win"; - modMac = "Cmd"; - } - document.getElementById("keybList").innerHTML = ` - - Command - Shortcut (Win/Linux) - Shortcut (Mac) - - - Place cursor in search field - Ctrl+${modWinLin}+f - Ctrl+${modMac}+f - - Place cursor in input box - Ctrl+${modWinLin}+i - Ctrl+${modMac}+i - - - Place cursor in output box - Ctrl+${modWinLin}+o - Ctrl+${modMac}+o - - - Place cursor in first argument field of the next operation in the recipe - Ctrl+${modWinLin}+. - Ctrl+${modMac}+. - - - Place cursor in first argument field of the nth operation in the recipe - Ctrl+${modWinLin}+[1-9] - Ctrl+${modMac}+[1-9] - - - Disable current operation - Ctrl+${modWinLin}+d - Ctrl+${modMac}+d - - - Set/clear breakpoint - Ctrl+${modWinLin}+b - Ctrl+${modMac}+b - - - Bake - Ctrl+${modWinLin}+Space - Ctrl+${modMac}+Space - - - Step - Ctrl+${modWinLin}+' - Ctrl+${modMac}+' - - - Clear recipe - Ctrl+${modWinLin}+c - Ctrl+${modMac}+c - - - Save to file - Ctrl+${modWinLin}+s - Ctrl+${modMac}+s - - - Load recipe - Ctrl+${modWinLin}+l - Ctrl+${modMac}+l - - - Move output to input - Ctrl+${modWinLin}+m - Ctrl+${modMac}+m - - `; -}; - -export default BindingsWaiter; diff --git a/src/web/BindingsWaiter.mjs b/src/web/BindingsWaiter.mjs new file mode 100755 index 00000000..74262c61 --- /dev/null +++ b/src/web/BindingsWaiter.mjs @@ -0,0 +1,224 @@ +/** + * @author Matt C [matt@artemisbot.uk] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +/** + * Waiter to handle keybindings to CyberChef functions (i.e. Bake, Step, Save, Load etc.) + */ +class BindingsWaiter { + + /** + * BindingsWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + } + + + /** + * Handler for all keydown events + * Checks whether valid keyboard shortcut has been instated + * + * @fires Manager#statechange + * @param {event} e + */ + parseInput(e) { + const modKey = this.app.options.useMetaKey ? e.metaKey : e.altKey; + + if (e.ctrlKey && modKey) { + let elem; + switch (e.code) { + case "KeyF": // Focus search + e.preventDefault(); + document.getElementById("search").focus(); + break; + case "KeyI": // Focus input + e.preventDefault(); + document.getElementById("input-text").focus(); + break; + case "KeyO": // Focus output + e.preventDefault(); + document.getElementById("output-text").focus(); + break; + case "Period": // Focus next operation + e.preventDefault(); + try { + elem = document.activeElement.closest(".operation") || document.querySelector("#rec-list .operation"); + if (elem.parentNode.lastChild === elem) { + // If operation is last in recipe, loop around to the top operation's first argument + elem.parentNode.firstChild.querySelectorAll(".arg")[0].focus(); + } else { + // Focus first argument of next operation + elem.nextSibling.querySelectorAll(".arg")[0].focus(); + } + } catch (e) { + // do nothing, just don't throw an error + } + break; + case "KeyB": // Set breakpoint + e.preventDefault(); + try { + elem = document.activeElement.closest(".operation").querySelectorAll(".breakpoint")[0]; + if (elem.getAttribute("break") === "false") { + elem.setAttribute("break", "true"); // add break point if not already enabled + elem.classList.add("breakpoint-selected"); + } else { + elem.setAttribute("break", "false"); // remove break point if already enabled + elem.classList.remove("breakpoint-selected"); + } + window.dispatchEvent(this.manager.statechange); + } catch (e) { + // do nothing, just don't throw an error + } + break; + case "KeyD": // Disable operation + e.preventDefault(); + try { + elem = document.activeElement.closest(".operation").querySelectorAll(".disable-icon")[0]; + if (elem.getAttribute("disabled") === "false") { + elem.setAttribute("disabled", "true"); // disable operation if enabled + elem.classList.add("disable-elem-selected"); + elem.parentNode.parentNode.classList.add("disabled"); + } else { + elem.setAttribute("disabled", "false"); // enable operation if disabled + elem.classList.remove("disable-elem-selected"); + elem.parentNode.parentNode.classList.remove("disabled"); + } + this.app.progress = 0; + window.dispatchEvent(this.manager.statechange); + } catch (e) { + // do nothing, just don't throw an error + } + break; + case "Space": // Bake + e.preventDefault(); + this.app.bake(); + break; + case "Quote": // Step through + e.preventDefault(); + this.app.bake(true); + break; + case "KeyC": // Clear recipe + e.preventDefault(); + this.manager.recipe.clearRecipe(); + break; + case "KeyS": // Save output to file + e.preventDefault(); + this.manager.output.saveClick(); + break; + case "KeyL": // Load recipe + e.preventDefault(); + this.manager.controls.loadClick(); + break; + case "KeyM": // Switch input and output + e.preventDefault(); + this.manager.output.switchClick(); + break; + default: + if (e.code.match(/Digit[0-9]/g)) { // Select nth operation + e.preventDefault(); + try { + // Select the first argument of the operation corresponding to the number pressed + document.querySelector(`li:nth-child(${e.code.substr(-1)}) .arg`).focus(); + } catch (e) { + // do nothing, just don't throw an error + } + } + break; + } + } + } + + + /** + * Updates keybinding list when metaKey option is toggled + */ + updateKeybList() { + let modWinLin = "Alt"; + let modMac = "Opt"; + if (this.app.options.useMetaKey) { + modWinLin = "Win"; + modMac = "Cmd"; + } + document.getElementById("keybList").innerHTML = ` + + Command + Shortcut (Win/Linux) + Shortcut (Mac) + + + Place cursor in search field + Ctrl+${modWinLin}+f + Ctrl+${modMac}+f + + Place cursor in input box + Ctrl+${modWinLin}+i + Ctrl+${modMac}+i + + + Place cursor in output box + Ctrl+${modWinLin}+o + Ctrl+${modMac}+o + + + Place cursor in first argument field of the next operation in the recipe + Ctrl+${modWinLin}+. + Ctrl+${modMac}+. + + + Place cursor in first argument field of the nth operation in the recipe + Ctrl+${modWinLin}+[1-9] + Ctrl+${modMac}+[1-9] + + + Disable current operation + Ctrl+${modWinLin}+d + Ctrl+${modMac}+d + + + Set/clear breakpoint + Ctrl+${modWinLin}+b + Ctrl+${modMac}+b + + + Bake + Ctrl+${modWinLin}+Space + Ctrl+${modMac}+Space + + + Step + Ctrl+${modWinLin}+' + Ctrl+${modMac}+' + + + Clear recipe + Ctrl+${modWinLin}+c + Ctrl+${modMac}+c + + + Save to file + Ctrl+${modWinLin}+s + Ctrl+${modMac}+s + + + Load recipe + Ctrl+${modWinLin}+l + Ctrl+${modMac}+l + + + Move output to input + Ctrl+${modWinLin}+m + Ctrl+${modMac}+m + + `; + } + +} + +export default BindingsWaiter; diff --git a/src/web/ControlsWaiter.js b/src/web/ControlsWaiter.js deleted file mode 100755 index bf3082cd..00000000 --- a/src/web/ControlsWaiter.js +++ /dev/null @@ -1,441 +0,0 @@ -import Utils from "../core/Utils"; -import {toBase64} from "../core/lib/Base64"; - - -/** - * Waiter to handle events related to the CyberChef controls (i.e. Bake, Step, Save, Load etc.) - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const ControlsWaiter = function(app, manager) { - this.app = app; - this.manager = manager; -}; - - -/** - * Adjusts the display properties of the control buttons so that they fit within the current width - * without wrapping or overflowing. - */ -ControlsWaiter.prototype.adjustWidth = function() { - const controls = document.getElementById("controls"); - const step = document.getElementById("step"); - const clrBreaks = document.getElementById("clr-breaks"); - const saveImg = document.querySelector("#save img"); - const loadImg = document.querySelector("#load img"); - const stepImg = document.querySelector("#step img"); - const clrRecipImg = document.querySelector("#clr-recipe img"); - const clrBreaksImg = document.querySelector("#clr-breaks img"); - - if (controls.clientWidth < 470) { - step.childNodes[1].nodeValue = " Step"; - } else { - step.childNodes[1].nodeValue = " Step through"; - } - - if (controls.clientWidth < 400) { - saveImg.style.display = "none"; - loadImg.style.display = "none"; - stepImg.style.display = "none"; - clrRecipImg.style.display = "none"; - clrBreaksImg.style.display = "none"; - } else { - saveImg.style.display = "inline"; - loadImg.style.display = "inline"; - stepImg.style.display = "inline"; - clrRecipImg.style.display = "inline"; - clrBreaksImg.style.display = "inline"; - } - - if (controls.clientWidth < 330) { - clrBreaks.childNodes[1].nodeValue = " Clear breaks"; - } else { - clrBreaks.childNodes[1].nodeValue = " Clear breakpoints"; - } -}; - - -/** - * Checks or unchecks the Auto Bake checkbox based on the given value. - * - * @param {boolean} value - The new value for Auto Bake. - */ -ControlsWaiter.prototype.setAutoBake = function(value) { - const autoBakeCheckbox = document.getElementById("auto-bake"); - - if (autoBakeCheckbox.checked !== value) { - autoBakeCheckbox.click(); - } -}; - - -/** - * Handler to trigger baking. - */ -ControlsWaiter.prototype.bakeClick = function() { - if (document.getElementById("bake").textContent.indexOf("Bake") > 0) { - this.app.bake(); - } else { - this.manager.worker.cancelBake(); - } -}; - - -/** - * Handler for the 'Step through' command. Executes the next step of the recipe. - */ -ControlsWaiter.prototype.stepClick = function() { - this.app.bake(true); -}; - - -/** - * Handler for changes made to the Auto Bake checkbox. - */ -ControlsWaiter.prototype.autoBakeChange = function() { - const autoBakeLabel = document.getElementById("auto-bake-label"); - const autoBakeCheckbox = document.getElementById("auto-bake"); - - this.app.autoBake_ = autoBakeCheckbox.checked; - - if (autoBakeCheckbox.checked) { - autoBakeLabel.classList.add("btn-success"); - autoBakeLabel.classList.remove("btn-default"); - } else { - autoBakeLabel.classList.add("btn-default"); - autoBakeLabel.classList.remove("btn-success"); - } -}; - - -/** - * Handler for the 'Clear recipe' command. Removes all operations from the recipe. - */ -ControlsWaiter.prototype.clearRecipeClick = function() { - this.manager.recipe.clearRecipe(); -}; - - -/** - * Handler for the 'Clear breakpoints' command. Removes all breakpoints from operations in the - * recipe. - */ -ControlsWaiter.prototype.clearBreaksClick = function() { - const bps = document.querySelectorAll("#rec-list li.operation .breakpoint"); - - for (let i = 0; i < bps.length; i++) { - bps[i].setAttribute("break", "false"); - bps[i].classList.remove("breakpoint-selected"); - } -}; - - -/** - * Populates the save disalog box with a URL incorporating the recipe and input. - * - * @param {Object[]} [recipeConfig] - The recipe configuration object array. - */ -ControlsWaiter.prototype.initialiseSaveLink = function(recipeConfig) { - recipeConfig = recipeConfig || this.app.getRecipeConfig(); - - const includeRecipe = document.getElementById("save-link-recipe-checkbox").checked; - const includeInput = document.getElementById("save-link-input-checkbox").checked; - const saveLinkEl = document.getElementById("save-link"); - const saveLink = this.generateStateUrl(includeRecipe, includeInput, recipeConfig); - - saveLinkEl.innerHTML = Utils.truncate(saveLink, 120); - saveLinkEl.setAttribute("href", saveLink); -}; - - -/** - * Generates a URL containing the current recipe and input state. - * - * @param {boolean} includeRecipe - Whether to include the recipe in the URL. - * @param {boolean} includeInput - Whether to include the input in the URL. - * @param {Object[]} [recipeConfig] - The recipe configuration object array. - * @param {string} [baseURL] - The CyberChef URL, set to the current URL if not included - * @returns {string} - */ -ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput, recipeConfig, baseURL) { - recipeConfig = recipeConfig || this.app.getRecipeConfig(); - - const link = baseURL || window.location.protocol + "//" + - window.location.host + - window.location.pathname; - const recipeStr = Utils.generatePrettyRecipe(recipeConfig); - const inputStr = toBase64(this.app.getInput(), "A-Za-z0-9+/"); // B64 alphabet with no padding - - includeRecipe = includeRecipe && (recipeConfig.length > 0); - // Only inlcude input if it is less than 50KB (51200 * 4/3 as it is Base64 encoded) - includeInput = includeInput && (inputStr.length > 0) && (inputStr.length <= 68267); - - const params = [ - includeRecipe ? ["recipe", recipeStr] : undefined, - includeInput ? ["input", inputStr] : undefined, - ]; - - const hash = params - .filter(v => v) - .map(([key, value]) => `${key}=${Utils.encodeURIFragment(value)}`) - .join("&"); - - if (hash) { - return `${link}#${hash}`; - } - - return link; -}; - - -/** - * Handler for changes made to the save dialog text area. Re-initialises the save link. - */ -ControlsWaiter.prototype.saveTextChange = function(e) { - try { - const recipeConfig = Utils.parseRecipeConfig(e.target.value); - this.initialiseSaveLink(recipeConfig); - } catch (err) {} -}; - - -/** - * Handler for the 'Save' command. Pops up the save dialog box. - */ -ControlsWaiter.prototype.saveClick = function() { - const recipeConfig = this.app.getRecipeConfig(); - const recipeStr = JSON.stringify(recipeConfig); - - document.getElementById("save-text-chef").value = Utils.generatePrettyRecipe(recipeConfig, true); - document.getElementById("save-text-clean").value = JSON.stringify(recipeConfig, null, 2) - .replace(/{\n\s+"/g, "{ \"") - .replace(/\[\n\s{3,}/g, "[") - .replace(/\n\s{3,}]/g, "]") - .replace(/\s*\n\s*}/g, " }") - .replace(/\n\s{6,}/g, " "); - document.getElementById("save-text-compact").value = recipeStr; - - this.initialiseSaveLink(recipeConfig); - $("#save-modal").modal(); -}; - - -/** - * Handler for the save link recipe checkbox change event. - */ -ControlsWaiter.prototype.slrCheckChange = function() { - this.initialiseSaveLink(); -}; - - -/** - * Handler for the save link input checkbox change event. - */ -ControlsWaiter.prototype.sliCheckChange = function() { - this.initialiseSaveLink(); -}; - - -/** - * Handler for the 'Load' command. Pops up the load dialog box. - */ -ControlsWaiter.prototype.loadClick = function() { - this.populateLoadRecipesList(); - $("#load-modal").modal(); -}; - - -/** - * Saves the recipe specified in the save textarea to local storage. - */ -ControlsWaiter.prototype.saveButtonClick = function() { - if (!this.app.isLocalStorageAvailable()) { - this.app.alert( - "Your security settings do not allow access to local storage so your recipe cannot be saved.", - "danger", - 5000 - ); - return false; - } - - const recipeName = Utils.escapeHtml(document.getElementById("save-name").value); - const recipeStr = document.querySelector("#save-texts .tab-pane.active textarea").value; - - if (!recipeName) { - this.app.alert("Please enter a recipe name", "danger", 2000); - return; - } - - const savedRecipes = localStorage.savedRecipes ? - JSON.parse(localStorage.savedRecipes) : []; - let recipeId = localStorage.recipeId || 0; - - savedRecipes.push({ - id: ++recipeId, - name: recipeName, - recipe: recipeStr - }); - - localStorage.savedRecipes = JSON.stringify(savedRecipes); - localStorage.recipeId = recipeId; - - this.app.alert("Recipe saved as \"" + recipeName + "\".", "success", 2000); -}; - - -/** - * Populates the list of saved recipes in the load dialog box from local storage. - */ -ControlsWaiter.prototype.populateLoadRecipesList = function() { - if (!this.app.isLocalStorageAvailable()) return false; - - const loadNameEl = document.getElementById("load-name"); - - // Remove current recipes from select - let i = loadNameEl.options.length; - while (i--) { - loadNameEl.remove(i); - } - - // Add recipes to select - const savedRecipes = localStorage.savedRecipes ? - JSON.parse(localStorage.savedRecipes) : []; - - for (i = 0; i < savedRecipes.length; i++) { - const opt = document.createElement("option"); - opt.value = savedRecipes[i].id; - // Unescape then re-escape in case localStorage has been corrupted - opt.innerHTML = Utils.escapeHtml(Utils.unescapeHtml(savedRecipes[i].name)); - - loadNameEl.appendChild(opt); - } - - // Populate textarea with first recipe - document.getElementById("load-text").value = savedRecipes.length ? savedRecipes[0].recipe : ""; -}; - - -/** - * Removes the currently selected recipe from local storage. - */ -ControlsWaiter.prototype.loadDeleteClick = function() { - if (!this.app.isLocalStorageAvailable()) return false; - - const id = parseInt(document.getElementById("load-name").value, 10); - const rawSavedRecipes = localStorage.savedRecipes ? - JSON.parse(localStorage.savedRecipes) : []; - - const savedRecipes = rawSavedRecipes.filter(r => r.id !== id); - - localStorage.savedRecipes = JSON.stringify(savedRecipes); - this.populateLoadRecipesList(); -}; - - -/** - * Displays the selected recipe in the load text box. - */ -ControlsWaiter.prototype.loadNameChange = function(e) { - if (!this.app.isLocalStorageAvailable()) return false; - - const el = e.target; - const savedRecipes = localStorage.savedRecipes ? - JSON.parse(localStorage.savedRecipes) : []; - const id = parseInt(el.value, 10); - - const recipe = savedRecipes.find(r => r.id === id); - - document.getElementById("load-text").value = recipe.recipe; -}; - - -/** - * Loads the selected recipe and populates the Recipe with its operations. - */ -ControlsWaiter.prototype.loadButtonClick = function() { - try { - const recipeConfig = Utils.parseRecipeConfig(document.getElementById("load-text").value); - this.app.setRecipeConfig(recipeConfig); - this.app.autoBake(); - - $("#rec-list [data-toggle=popover]").popover(); - } catch (e) { - this.app.alert("Invalid recipe", "danger", 2000); - } -}; - - -/** - * Populates the bug report information box with useful technical info. - * - * @param {event} e - */ -ControlsWaiter.prototype.supportButtonClick = function(e) { - e.preventDefault(); - - const reportBugInfo = document.getElementById("report-bug-info"); - const saveLink = this.generateStateUrl(true, true, null, "https://gchq.github.io/CyberChef/"); - - if (reportBugInfo) { - reportBugInfo.innerHTML = `* Version: ${PKG_VERSION + (typeof INLINE === "undefined" ? "" : "s")} -* Compile time: ${COMPILE_TIME} -* User-Agent: -${navigator.userAgent} -* [Link to reproduce](${saveLink}) - -`; - } -}; - - -/** - * Shows the stale indicator to show that the input or recipe has changed - * since the last bake. - */ -ControlsWaiter.prototype.showStaleIndicator = function() { - const staleIndicator = document.getElementById("stale-indicator"); - - staleIndicator.style.visibility = "visible"; - staleIndicator.style.opacity = 1; -}; - - -/** - * Hides the stale indicator to show that the input or recipe has not changed - * since the last bake. - */ -ControlsWaiter.prototype.hideStaleIndicator = function() { - const staleIndicator = document.getElementById("stale-indicator"); - - staleIndicator.style.opacity = 0; - staleIndicator.style.visibility = "hidden"; -}; - - -/** - * Switches the Bake button between 'Bake' and 'Cancel' functions. - * - * @param {boolean} cancel - Whether to change to cancel or not - */ -ControlsWaiter.prototype.toggleBakeButtonFunction = function(cancel) { - const bakeButton = document.getElementById("bake"), - btnText = bakeButton.querySelector("span"); - - if (cancel) { - btnText.innerText = "Cancel"; - bakeButton.classList.remove("btn-success"); - bakeButton.classList.add("btn-danger"); - } else { - btnText.innerText = "Bake!"; - bakeButton.classList.remove("btn-danger"); - bakeButton.classList.add("btn-success"); - } -}; - -export default ControlsWaiter; diff --git a/src/web/ControlsWaiter.mjs b/src/web/ControlsWaiter.mjs new file mode 100755 index 00000000..2e8da9a0 --- /dev/null +++ b/src/web/ControlsWaiter.mjs @@ -0,0 +1,449 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import Utils from "../core/Utils"; +import {toBase64} from "../core/lib/Base64"; + + +/** + * Waiter to handle events related to the CyberChef controls (i.e. Bake, Step, Save, Load etc.) + */ +class ControlsWaiter { + + /** + * ControlsWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + } + + + /** + * Adjusts the display properties of the control buttons so that they fit within the current width + * without wrapping or overflowing. + */ + adjustWidth() { + const controls = document.getElementById("controls"); + const step = document.getElementById("step"); + const clrBreaks = document.getElementById("clr-breaks"); + const saveImg = document.querySelector("#save img"); + const loadImg = document.querySelector("#load img"); + const stepImg = document.querySelector("#step img"); + const clrRecipImg = document.querySelector("#clr-recipe img"); + const clrBreaksImg = document.querySelector("#clr-breaks img"); + + if (controls.clientWidth < 470) { + step.childNodes[1].nodeValue = " Step"; + } else { + step.childNodes[1].nodeValue = " Step through"; + } + + if (controls.clientWidth < 400) { + saveImg.style.display = "none"; + loadImg.style.display = "none"; + stepImg.style.display = "none"; + clrRecipImg.style.display = "none"; + clrBreaksImg.style.display = "none"; + } else { + saveImg.style.display = "inline"; + loadImg.style.display = "inline"; + stepImg.style.display = "inline"; + clrRecipImg.style.display = "inline"; + clrBreaksImg.style.display = "inline"; + } + + if (controls.clientWidth < 330) { + clrBreaks.childNodes[1].nodeValue = " Clear breaks"; + } else { + clrBreaks.childNodes[1].nodeValue = " Clear breakpoints"; + } + } + + + /** + * Checks or unchecks the Auto Bake checkbox based on the given value. + * + * @param {boolean} value - The new value for Auto Bake. + */ + setAutoBake(value) { + const autoBakeCheckbox = document.getElementById("auto-bake"); + + if (autoBakeCheckbox.checked !== value) { + autoBakeCheckbox.click(); + } + } + + + /** + * Handler to trigger baking. + */ + bakeClick() { + if (document.getElementById("bake").textContent.indexOf("Bake") > 0) { + this.app.bake(); + } else { + this.manager.worker.cancelBake(); + } + } + + + /** + * Handler for the 'Step through' command. Executes the next step of the recipe. + */ + stepClick() { + this.app.bake(true); + } + + + /** + * Handler for changes made to the Auto Bake checkbox. + */ + autoBakeChange() { + const autoBakeLabel = document.getElementById("auto-bake-label"); + const autoBakeCheckbox = document.getElementById("auto-bake"); + + this.app.autoBake_ = autoBakeCheckbox.checked; + + if (autoBakeCheckbox.checked) { + autoBakeLabel.classList.add("btn-success"); + autoBakeLabel.classList.remove("btn-default"); + } else { + autoBakeLabel.classList.add("btn-default"); + autoBakeLabel.classList.remove("btn-success"); + } + } + + + /** + * Handler for the 'Clear recipe' command. Removes all operations from the recipe. + */ + clearRecipeClick() { + this.manager.recipe.clearRecipe(); + } + + + /** + * Handler for the 'Clear breakpoints' command. Removes all breakpoints from operations in the + * recipe. + */ + clearBreaksClick() { + const bps = document.querySelectorAll("#rec-list li.operation .breakpoint"); + + for (let i = 0; i < bps.length; i++) { + bps[i].setAttribute("break", "false"); + bps[i].classList.remove("breakpoint-selected"); + } + } + + + /** + * Populates the save disalog box with a URL incorporating the recipe and input. + * + * @param {Object[]} [recipeConfig] - The recipe configuration object array. + */ + initialiseSaveLink(recipeConfig) { + recipeConfig = recipeConfig || this.app.getRecipeConfig(); + + const includeRecipe = document.getElementById("save-link-recipe-checkbox").checked; + const includeInput = document.getElementById("save-link-input-checkbox").checked; + const saveLinkEl = document.getElementById("save-link"); + const saveLink = this.generateStateUrl(includeRecipe, includeInput, recipeConfig); + + saveLinkEl.innerHTML = Utils.truncate(saveLink, 120); + saveLinkEl.setAttribute("href", saveLink); + } + + + /** + * Generates a URL containing the current recipe and input state. + * + * @param {boolean} includeRecipe - Whether to include the recipe in the URL. + * @param {boolean} includeInput - Whether to include the input in the URL. + * @param {Object[]} [recipeConfig] - The recipe configuration object array. + * @param {string} [baseURL] - The CyberChef URL, set to the current URL if not included + * @returns {string} + */ + generateStateUrl(includeRecipe, includeInput, recipeConfig, baseURL) { + recipeConfig = recipeConfig || this.app.getRecipeConfig(); + + const link = baseURL || window.location.protocol + "//" + + window.location.host + + window.location.pathname; + const recipeStr = Utils.generatePrettyRecipe(recipeConfig); + const inputStr = toBase64(this.app.getInput(), "A-Za-z0-9+/"); // B64 alphabet with no padding + + includeRecipe = includeRecipe && (recipeConfig.length > 0); + // Only inlcude input if it is less than 50KB (51200 * 4/3 as it is Base64 encoded) + includeInput = includeInput && (inputStr.length > 0) && (inputStr.length <= 68267); + + const params = [ + includeRecipe ? ["recipe", recipeStr] : undefined, + includeInput ? ["input", inputStr] : undefined, + ]; + + const hash = params + .filter(v => v) + .map(([key, value]) => `${key}=${Utils.encodeURIFragment(value)}`) + .join("&"); + + if (hash) { + return `${link}#${hash}`; + } + + return link; + } + + + /** + * Handler for changes made to the save dialog text area. Re-initialises the save link. + */ + saveTextChange(e) { + try { + const recipeConfig = Utils.parseRecipeConfig(e.target.value); + this.initialiseSaveLink(recipeConfig); + } catch (err) {} + } + + + /** + * Handler for the 'Save' command. Pops up the save dialog box. + */ + saveClick() { + const recipeConfig = this.app.getRecipeConfig(); + const recipeStr = JSON.stringify(recipeConfig); + + document.getElementById("save-text-chef").value = Utils.generatePrettyRecipe(recipeConfig, true); + document.getElementById("save-text-clean").value = JSON.stringify(recipeConfig, null, 2) + .replace(/{\n\s+"/g, "{ \"") + .replace(/\[\n\s{3,}/g, "[") + .replace(/\n\s{3,}]/g, "]") + .replace(/\s*\n\s*}/g, " }") + .replace(/\n\s{6,}/g, " "); + document.getElementById("save-text-compact").value = recipeStr; + + this.initialiseSaveLink(recipeConfig); + $("#save-modal").modal(); + } + + + /** + * Handler for the save link recipe checkbox change event. + */ + slrCheckChange() { + this.initialiseSaveLink(); + } + + + /** + * Handler for the save link input checkbox change event. + */ + sliCheckChange() { + this.initialiseSaveLink(); + } + + + /** + * Handler for the 'Load' command. Pops up the load dialog box. + */ + loadClick() { + this.populateLoadRecipesList(); + $("#load-modal").modal(); + } + + + /** + * Saves the recipe specified in the save textarea to local storage. + */ + saveButtonClick() { + if (!this.app.isLocalStorageAvailable()) { + this.app.alert( + "Your security settings do not allow access to local storage so your recipe cannot be saved.", + "danger", + 5000 + ); + return false; + } + + const recipeName = Utils.escapeHtml(document.getElementById("save-name").value); + const recipeStr = document.querySelector("#save-texts .tab-pane.active textarea").value; + + if (!recipeName) { + this.app.alert("Please enter a recipe name", "danger", 2000); + return; + } + + const savedRecipes = localStorage.savedRecipes ? + JSON.parse(localStorage.savedRecipes) : []; + let recipeId = localStorage.recipeId || 0; + + savedRecipes.push({ + id: ++recipeId, + name: recipeName, + recipe: recipeStr + }); + + localStorage.savedRecipes = JSON.stringify(savedRecipes); + localStorage.recipeId = recipeId; + + this.app.alert("Recipe saved as \"" + recipeName + "\".", "success", 2000); + } + + + /** + * Populates the list of saved recipes in the load dialog box from local storage. + */ + populateLoadRecipesList() { + if (!this.app.isLocalStorageAvailable()) return false; + + const loadNameEl = document.getElementById("load-name"); + + // Remove current recipes from select + let i = loadNameEl.options.length; + while (i--) { + loadNameEl.remove(i); + } + + // Add recipes to select + const savedRecipes = localStorage.savedRecipes ? + JSON.parse(localStorage.savedRecipes) : []; + + for (i = 0; i < savedRecipes.length; i++) { + const opt = document.createElement("option"); + opt.value = savedRecipes[i].id; + // Unescape then re-escape in case localStorage has been corrupted + opt.innerHTML = Utils.escapeHtml(Utils.unescapeHtml(savedRecipes[i].name)); + + loadNameEl.appendChild(opt); + } + + // Populate textarea with first recipe + document.getElementById("load-text").value = savedRecipes.length ? savedRecipes[0].recipe : ""; + } + + + /** + * Removes the currently selected recipe from local storage. + */ + loadDeleteClick() { + if (!this.app.isLocalStorageAvailable()) return false; + + const id = parseInt(document.getElementById("load-name").value, 10); + const rawSavedRecipes = localStorage.savedRecipes ? + JSON.parse(localStorage.savedRecipes) : []; + + const savedRecipes = rawSavedRecipes.filter(r => r.id !== id); + + localStorage.savedRecipes = JSON.stringify(savedRecipes); + this.populateLoadRecipesList(); + } + + + /** + * Displays the selected recipe in the load text box. + */ + loadNameChange(e) { + if (!this.app.isLocalStorageAvailable()) return false; + + const el = e.target; + const savedRecipes = localStorage.savedRecipes ? + JSON.parse(localStorage.savedRecipes) : []; + const id = parseInt(el.value, 10); + + const recipe = savedRecipes.find(r => r.id === id); + + document.getElementById("load-text").value = recipe.recipe; + } + + + /** + * Loads the selected recipe and populates the Recipe with its operations. + */ + loadButtonClick() { + try { + const recipeConfig = Utils.parseRecipeConfig(document.getElementById("load-text").value); + this.app.setRecipeConfig(recipeConfig); + this.app.autoBake(); + + $("#rec-list [data-toggle=popover]").popover(); + } catch (e) { + this.app.alert("Invalid recipe", "danger", 2000); + } + } + + + /** + * Populates the bug report information box with useful technical info. + * + * @param {event} e + */ + supportButtonClick(e) { + e.preventDefault(); + + const reportBugInfo = document.getElementById("report-bug-info"); + const saveLink = this.generateStateUrl(true, true, null, "https://gchq.github.io/CyberChef/"); + + if (reportBugInfo) { + reportBugInfo.innerHTML = `* Version: ${PKG_VERSION + (typeof INLINE === "undefined" ? "" : "s")} +* Compile time: ${COMPILE_TIME} +* User-Agent: +${navigator.userAgent} +* [Link to reproduce](${saveLink}) + +`; + } + } + + + /** + * Shows the stale indicator to show that the input or recipe has changed + * since the last bake. + */ + showStaleIndicator() { + const staleIndicator = document.getElementById("stale-indicator"); + + staleIndicator.style.visibility = "visible"; + staleIndicator.style.opacity = 1; + } + + + /** + * Hides the stale indicator to show that the input or recipe has not changed + * since the last bake. + */ + hideStaleIndicator() { + const staleIndicator = document.getElementById("stale-indicator"); + + staleIndicator.style.opacity = 0; + staleIndicator.style.visibility = "hidden"; + } + + + /** + * Switches the Bake button between 'Bake' and 'Cancel' functions. + * + * @param {boolean} cancel - Whether to change to cancel or not + */ + toggleBakeButtonFunction(cancel) { + const bakeButton = document.getElementById("bake"), + btnText = bakeButton.querySelector("span"); + + if (cancel) { + btnText.innerText = "Cancel"; + bakeButton.classList.remove("btn-success"); + bakeButton.classList.add("btn-danger"); + } else { + btnText.innerText = "Bake!"; + bakeButton.classList.remove("btn-danger"); + bakeButton.classList.add("btn-success"); + } + } + +} + +export default ControlsWaiter; diff --git a/src/web/HTMLCategory.js b/src/web/HTMLCategory.js deleted file mode 100755 index e3a168ba..00000000 --- a/src/web/HTMLCategory.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Object to handle the creation of operation categories. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {string} name - The name of the category. - * @param {boolean} selected - Whether this category is pre-selected or not. - */ -const HTMLCategory = function(name, selected) { - this.name = name; - this.selected = selected; - this.opList = []; -}; - - -/** - * Adds an operation to this category. - * - * @param {HTMLOperation} operation - The operation to add. - */ -HTMLCategory.prototype.addOperation = function(operation) { - this.opList.push(operation); -}; - - -/** - * Renders the category and all operations within it in HTML. - * - * @returns {string} - */ -HTMLCategory.prototype.toHtml = function() { - const catName = "cat" + this.name.replace(/[\s/-:_]/g, ""); - let html = "
\ - \ - " + this.name + "\ - \ -
    "; - - for (let i = 0; i < this.opList.length; i++) { - html += this.opList[i].toStubHtml(); - } - - html += "
"; - return html; -}; - -export default HTMLCategory; diff --git a/src/web/HTMLCategory.mjs b/src/web/HTMLCategory.mjs new file mode 100755 index 00000000..5a2e3f7e --- /dev/null +++ b/src/web/HTMLCategory.mjs @@ -0,0 +1,60 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +/** + * Object to handle the creation of operation categories. + */ +class HTMLCategory { + + /** + * HTMLCategory constructor. + * + * @param {string} name - The name of the category. + * @param {boolean} selected - Whether this category is pre-selected or not. + */ + constructor(name, selected) { + this.name = name; + this.selected = selected; + this.opList = []; + } + + + /** + * Adds an operation to this category. + * + * @param {HTMLOperation} operation - The operation to add. + */ + addOperation(operation) { + this.opList.push(operation); + } + + + /** + * Renders the category and all operations within it in HTML. + * + * @returns {string} + */ + toHtml() { + const catName = "cat" + this.name.replace(/[\s/-:_]/g, ""); + let html = "
\ + \ + " + this.name + "\ + \ +
    "; + + for (let i = 0; i < this.opList.length; i++) { + html += this.opList[i].toStubHtml(); + } + + html += "
"; + return html; + } + +} + +export default HTMLCategory; diff --git a/src/web/HTMLIngredient.js b/src/web/HTMLIngredient.js deleted file mode 100755 index ca28ab01..00000000 --- a/src/web/HTMLIngredient.js +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Object to handle the creation of operation ingredients. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {Object} config - The configuration object for this ingredient. - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const HTMLIngredient = function(config, app, manager) { - this.app = app; - this.manager = manager; - - this.name = config.name; - this.type = config.type; - this.value = config.value; - this.disabled = config.disabled || false; - this.disableArgs = config.disableArgs || false; - this.placeholder = config.placeholder || false; - this.target = config.target; - this.toggleValues = config.toggleValues; - this.id = "ing-" + this.app.nextIngId(); -}; - - -/** - * Renders the ingredient in HTML. - * - * @returns {string} - */ -HTMLIngredient.prototype.toHtml = function() { - const inline = ( - this.type === "boolean" || - this.type === "number" || - this.type === "option" || - this.type === "shortString" || - this.type === "binaryShortString" - ); - let html = inline ? "" : "
 
", - i, m; - - html += "
"; - - switch (this.type) { - case "string": - case "binaryString": - case "byteArray": - html += ""; - break; - case "shortString": - case "binaryShortString": - html += ""; - break; - case "toggleString": - html += "
\ -
"; - break; - case "number": - html += ""; - break; - case "boolean": - html += ""; - - if (this.disableArgs) { - this.manager.addDynamicListener("#" + this.id, "click", this.toggleDisableArgs, this); - } - break; - case "option": - html += ""; - break; - case "populateOption": - html += ""; - - this.manager.addDynamicListener("#" + this.id, "change", this.populateOptionChange, this); - break; - case "editableOption": - html += "
"; - html += ""; - html += ""; - html += "
"; - - - this.manager.addDynamicListener("#sel-" + this.id, "change", this.editableOptionChange, this); - break; - case "text": - html += ""; - break; - default: - break; - } - html += "
"; - - return html; -}; - - -/** - * Handler for argument disable toggle. - * Toggles disabled state for all arguments in the disableArgs list for this ingredient. - * - * @param {event} e - */ -HTMLIngredient.prototype.toggleDisableArgs = function(e) { - const el = e.target; - const op = el.parentNode.parentNode; - const args = op.querySelectorAll(".arg-group"); - - for (let i = 0; i < this.disableArgs.length; i++) { - const els = args[this.disableArgs[i]].querySelectorAll("input, select, button"); - - for (let j = 0; j < els.length; j++) { - if (els[j].getAttribute("disabled")) { - els[j].removeAttribute("disabled"); - } else { - els[j].setAttribute("disabled", "disabled"); - } - } - } - - this.manager.recipe.ingChange(); -}; - - -/** - * Handler for populate option changes. - * Populates the relevant argument with the specified value. - * - * @param {event} e - */ -HTMLIngredient.prototype.populateOptionChange = function(e) { - const el = e.target; - const op = el.parentNode.parentNode; - const target = op.querySelectorAll(".arg-group")[this.target].querySelector("input, select, textarea"); - - target.value = el.childNodes[el.selectedIndex].getAttribute("populate-value"); - - this.manager.recipe.ingChange(); -}; - - -/** - * Handler for editable option changes. - * Populates the input box with the selected value. - * - * @param {event} e - */ -HTMLIngredient.prototype.editableOptionChange = function(e) { - const select = e.target, - input = select.nextSibling; - - input.value = select.childNodes[select.selectedIndex].value; - - this.manager.recipe.ingChange(); -}; - -export default HTMLIngredient; diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs new file mode 100755 index 00000000..2ce3f51f --- /dev/null +++ b/src/web/HTMLIngredient.mjs @@ -0,0 +1,223 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +/** + * Object to handle the creation of operation ingredients. + */ +class HTMLIngredient { + + /** + * HTMLIngredient constructor. + * + * @param {Object} config - The configuration object for this ingredient. + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(config, app, manager) { + this.app = app; + this.manager = manager; + + this.name = config.name; + this.type = config.type; + this.value = config.value; + this.disabled = config.disabled || false; + this.disableArgs = config.disableArgs || false; + this.placeholder = config.placeholder || false; + this.target = config.target; + this.toggleValues = config.toggleValues; + this.id = "ing-" + this.app.nextIngId(); + } + + + /** + * Renders the ingredient in HTML. + * + * @returns {string} + */ + toHtml() { + const inline = ( + this.type === "boolean" || + this.type === "number" || + this.type === "option" || + this.type === "shortString" || + this.type === "binaryShortString" + ); + let html = inline ? "" : "
 
", + i, m; + + html += "
"; + + switch (this.type) { + case "string": + case "binaryString": + case "byteArray": + html += ""; + break; + case "shortString": + case "binaryShortString": + html += ""; + break; + case "toggleString": + html += "
\ +
"; + break; + case "number": + html += ""; + break; + case "boolean": + html += ""; + + if (this.disableArgs) { + this.manager.addDynamicListener("#" + this.id, "click", this.toggleDisableArgs, this); + } + break; + case "option": + html += ""; + break; + case "populateOption": + html += ""; + + this.manager.addDynamicListener("#" + this.id, "change", this.populateOptionChange, this); + break; + case "editableOption": + html += "
"; + html += ""; + html += ""; + html += "
"; + + + this.manager.addDynamicListener("#sel-" + this.id, "change", this.editableOptionChange, this); + break; + case "text": + html += ""; + break; + default: + break; + } + html += "
"; + + return html; + } + + + /** + * Handler for argument disable toggle. + * Toggles disabled state for all arguments in the disableArgs list for this ingredient. + * + * @param {event} e + */ + toggleDisableArgs(e) { + const el = e.target; + const op = el.parentNode.parentNode; + const args = op.querySelectorAll(".arg-group"); + + for (let i = 0; i < this.disableArgs.length; i++) { + const els = args[this.disableArgs[i]].querySelectorAll("input, select, button"); + + for (let j = 0; j < els.length; j++) { + if (els[j].getAttribute("disabled")) { + els[j].removeAttribute("disabled"); + } else { + els[j].setAttribute("disabled", "disabled"); + } + } + } + + this.manager.recipe.ingChange(); + } + + + /** + * Handler for populate option changes. + * Populates the relevant argument with the specified value. + * + * @param {event} e + */ + populateOptionChange(e) { + const el = e.target; + const op = el.parentNode.parentNode; + const target = op.querySelectorAll(".arg-group")[this.target].querySelector("input, select, textarea"); + + target.value = el.childNodes[el.selectedIndex].getAttribute("populate-value"); + + this.manager.recipe.ingChange(); + } + + + /** + * Handler for editable option changes. + * Populates the input box with the selected value. + * + * @param {event} e + */ + editableOptionChange(e) { + const select = e.target, + input = select.nextSibling; + + input.value = select.childNodes[select.selectedIndex].value; + + this.manager.recipe.ingChange(); + } + +} + +export default HTMLIngredient; diff --git a/src/web/HTMLOperation.js b/src/web/HTMLOperation.js deleted file mode 100755 index e709066f..00000000 --- a/src/web/HTMLOperation.js +++ /dev/null @@ -1,128 +0,0 @@ -import HTMLIngredient from "./HTMLIngredient.js"; - - -/** - * Object to handle the creation of operations. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {string} name - The name of the operation. - * @param {Object} config - The configuration object for this operation. - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const HTMLOperation = function(name, config, app, manager) { - this.app = app; - this.manager = manager; - - this.name = name; - this.description = config.description; - this.manualBake = config.manualBake || false; - this.config = config; - this.ingList = []; - - for (let i = 0; i < config.args.length; i++) { - const ing = new HTMLIngredient(config.args[i], this.app, this.manager); - this.ingList.push(ing); - } -}; - - -/** - * @constant - */ -HTMLOperation.INFO_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAByElEQVR4XqVTzWoaYRQ9KZJmoVaS1J1QiYTIuOgqi9lEugguQhYhdGs3hTyAi0CWJTvJIks30ZBNsimUtlqkVLoQCuJsphRriyFjabWtEyf/Rv3iWcwwymTlgQuH851z5hu43wRGkEwmXwCIA4hiGAUAmUQikQbhEHwyGCWVSglVVUW73RYmyKnxjB56ncJ6NpsVxHGrI/ZLuniVb3DIqQmCHnrNkgcggNeSJPlisRgyJR2b737j/TcDsQUPwv6H5NR4BnroZcb6Z16N2PvyX6yna9Z8qp6JQ0Uf0ughmGHWBSAuyzJqrQ7eqKewY/dzE363C71e39LoWQq5wUwul4uzIBoIBHD01RgyrkZ8eDbvwUWnj623v2DHx4qB51IAzLIAXq8XP/7W0bUVVJtXWIk8wvlN364TA+/1IDMLwmWK/Hq3axmhaBdoGLeklm73ElaBYRgIzkyifHIOO4QQJKM3oJcZq6CgaVp0OTyHw9K/kQI4FiyHfdC0n2CWe5ApFosIPZ7C2tNpXpcDOehGyD/FIbd0euhlhllzFxRzC3fydbG4XRYbB9/tQ41n9m1U7l3lyp9LkfygiZeZCoecmtMqj/+Yxn7Od3v0j50qCO3zAAAAAElFTkSuQmCC"; -/** - * @constant - */ -HTMLOperation.REMOVE_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABwklEQVR42qRTPU8CQRB9K2CCMRJ6NTQajOUaqfxIbLCRghhjQixosLAgFNBQ3l8wsabxLxBJbCyVUBiMCVQEQkOEKBbCnefM3p4eohWXzM3uvHlv52b2hG3bmOWZw4yPn1/XQkCQ9wFxcgZZ0QLKpifpN8Z1n1L13griBBjHhYK0nMT4b+wom53ClAAFQacZJ/m8rNfrSOZy0vxJjPP6IJ2WzWYTO6mUwiwtILiJJSHUKVSWkchkZK1WQzQaxU2pVGUglkjIbreLUCiEx0qlStlFCpfPiPstYDtVKJH9ZFI2Gw1FGA6H6LTbCAaDeGu1FJl6UuYjpwTGzucokZW1NfnS66kyfT4fXns9RaZmlgNcuhZQU+jowLzuOK/HgwEW3E5ZlhLXVWKk11P3wNYNWw+HZdA0sUgx1zjGmD05nckx0ilGjBJdUq3fr7K5e8bGf43RdL7fOPSQb4lI8SLbrUfkUIuY32VTI1bJn5BqDnh4Dodt9ryPUDzyD7aquWoKQohl2i9sAbubwPkTcHkP3FHsg+yT+7sN7G0AF3Xg6sHB3onbdgWWKBDQg/BcTuVt51dQA/JrnIcyIu6rmPV3/hJgACPc0BMEYTg+AAAAAElFTkSuQmCC"; - - -/** - * Renders the operation in HTML as a stub operation with no ingredients. - * - * @returns {string} - */ -HTMLOperation.prototype.toStubHtml = function(removeIcon) { - let html = "
  • "; - } - - if (this.description) { - html += ""; - } - - html += "
  • "; - - return html; -}; - - -/** - * Renders the operation in HTML as a full operation with ingredients. - * - * @returns {string} - */ -HTMLOperation.prototype.toFullHtml = function() { - let html = "
    " + this.name + "
    "; - - for (let i = 0; i < this.ingList.length; i++) { - html += this.ingList[i].toHtml(); - } - - html += "
    \ -
    \ -
    "; - - html += "
    \ -
     
    "; - - return html; -}; - - -/** - * Highlights the searched string in the name and description of the operation. - * - * @param {string} searchStr - * @param {number} namePos - The position of the search string in the operation name - * @param {number} descPos - The position of the search string in the operation description - */ -HTMLOperation.prototype.highlightSearchString = function(searchStr, namePos, descPos) { - if (namePos >= 0) { - this.name = this.name.slice(0, namePos) + "" + - this.name.slice(namePos, namePos + searchStr.length) + "" + - this.name.slice(namePos + searchStr.length); - } - - if (this.description && descPos >= 0) { - // Find HTML tag offsets - const re = /<[^>]+>/g; - let match; - while ((match = re.exec(this.description))) { - // If the search string occurs within an HTML tag, return without highlighting it. - if (descPos >= match.index && descPos <= (match.index + match[0].length)) - return; - } - - this.description = this.description.slice(0, descPos) + "" + - this.description.slice(descPos, descPos + searchStr.length) + "" + - this.description.slice(descPos + searchStr.length); - } -}; - -export default HTMLOperation; diff --git a/src/web/HTMLOperation.mjs b/src/web/HTMLOperation.mjs new file mode 100755 index 00000000..5d798308 --- /dev/null +++ b/src/web/HTMLOperation.mjs @@ -0,0 +1,129 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import HTMLIngredient from "./HTMLIngredient"; + +const INFO_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAByElEQVR4XqVTzWoaYRQ9KZJmoVaS1J1QiYTIuOgqi9lEugguQhYhdGs3hTyAi0CWJTvJIks30ZBNsimUtlqkVLoQCuJsphRriyFjabWtEyf/Rv3iWcwwymTlgQuH851z5hu43wRGkEwmXwCIA4hiGAUAmUQikQbhEHwyGCWVSglVVUW73RYmyKnxjB56ncJ6NpsVxHGrI/ZLuniVb3DIqQmCHnrNkgcggNeSJPlisRgyJR2b737j/TcDsQUPwv6H5NR4BnroZcb6Z16N2PvyX6yna9Z8qp6JQ0Uf0ughmGHWBSAuyzJqrQ7eqKewY/dzE363C71e39LoWQq5wUwul4uzIBoIBHD01RgyrkZ8eDbvwUWnj623v2DHx4qB51IAzLIAXq8XP/7W0bUVVJtXWIk8wvlN364TA+/1IDMLwmWK/Hq3axmhaBdoGLeklm73ElaBYRgIzkyifHIOO4QQJKM3oJcZq6CgaVp0OTyHw9K/kQI4FiyHfdC0n2CWe5ApFosIPZ7C2tNpXpcDOehGyD/FIbd0euhlhllzFxRzC3fydbG4XRYbB9/tQ41n9m1U7l3lyp9LkfygiZeZCoecmtMqj/+Yxn7Od3v0j50qCO3zAAAAAElFTkSuQmCC"; +const REMOVE_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABwklEQVR42qRTPU8CQRB9K2CCMRJ6NTQajOUaqfxIbLCRghhjQixosLAgFNBQ3l8wsabxLxBJbCyVUBiMCVQEQkOEKBbCnefM3p4eohWXzM3uvHlv52b2hG3bmOWZw4yPn1/XQkCQ9wFxcgZZ0QLKpifpN8Z1n1L13griBBjHhYK0nMT4b+wom53ClAAFQacZJ/m8rNfrSOZy0vxJjPP6IJ2WzWYTO6mUwiwtILiJJSHUKVSWkchkZK1WQzQaxU2pVGUglkjIbreLUCiEx0qlStlFCpfPiPstYDtVKJH9ZFI2Gw1FGA6H6LTbCAaDeGu1FJl6UuYjpwTGzucokZW1NfnS66kyfT4fXns9RaZmlgNcuhZQU+jowLzuOK/HgwEW3E5ZlhLXVWKk11P3wNYNWw+HZdA0sUgx1zjGmD05nckx0ilGjBJdUq3fr7K5e8bGf43RdL7fOPSQb4lI8SLbrUfkUIuY32VTI1bJn5BqDnh4Dodt9ryPUDzyD7aquWoKQohl2i9sAbubwPkTcHkP3FHsg+yT+7sN7G0AF3Xg6sHB3onbdgWWKBDQg/BcTuVt51dQA/JrnIcyIu6rmPV3/hJgACPc0BMEYTg+AAAAAElFTkSuQmCC"; + + +/** + * Object to handle the creation of operations. + */ +class HTMLOperation { + + /** + * HTMLOperation constructor. + * + * @param {string} name - The name of the operation. + * @param {Object} config - The configuration object for this operation. + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(name, config, app, manager) { + this.app = app; + this.manager = manager; + + this.name = name; + this.description = config.description; + this.manualBake = config.manualBake || false; + this.config = config; + this.ingList = []; + + for (let i = 0; i < config.args.length; i++) { + const ing = new HTMLIngredient(config.args[i], this.app, this.manager); + this.ingList.push(ing); + } + } + + + /** + * Renders the operation in HTML as a stub operation with no ingredients. + * + * @returns {string} + */ + toStubHtml(removeIcon) { + let html = "
  • "; + } + + if (this.description) { + html += ""; + } + + html += "
  • "; + + return html; + } + + + /** + * Renders the operation in HTML as a full operation with ingredients. + * + * @returns {string} + */ + toFullHtml() { + let html = "
    " + this.name + "
    "; + + for (let i = 0; i < this.ingList.length; i++) { + html += this.ingList[i].toHtml(); + } + + html += "
    \ +
    \ +
    "; + + html += "
    \ +
     
    "; + + return html; + } + + + /** + * Highlights the searched string in the name and description of the operation. + * + * @param {string} searchStr + * @param {number} namePos - The position of the search string in the operation name + * @param {number} descPos - The position of the search string in the operation description + */ + highlightSearchString(searchStr, namePos, descPos) { + if (namePos >= 0) { + this.name = this.name.slice(0, namePos) + "" + + this.name.slice(namePos, namePos + searchStr.length) + "" + + this.name.slice(namePos + searchStr.length); + } + + if (this.description && descPos >= 0) { + // Find HTML tag offsets + const re = /<[^>]+>/g; + let match; + while ((match = re.exec(this.description))) { + // If the search string occurs within an HTML tag, return without highlighting it. + if (descPos >= match.index && descPos <= (match.index + match[0].length)) + return; + } + + this.description = this.description.slice(0, descPos) + "" + + this.description.slice(descPos, descPos + searchStr.length) + "" + + this.description.slice(descPos + searchStr.length); + } + } + +} + +export default HTMLOperation; diff --git a/src/web/HighlighterWaiter.js b/src/web/HighlighterWaiter.js deleted file mode 100755 index cfc36128..00000000 --- a/src/web/HighlighterWaiter.js +++ /dev/null @@ -1,461 +0,0 @@ -/** - * Waiter to handle events related to highlighting in CyberChef. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const HighlighterWaiter = function(app, manager) { - this.app = app; - this.manager = manager; - - this.mouseButtonDown = false; - this.mouseTarget = null; -}; - - -/** - * HighlighterWaiter data type enum for the input. - * @readonly - * @enum - */ -HighlighterWaiter.INPUT = 0; -/** - * HighlighterWaiter data type enum for the output. - * @readonly - * @enum - */ -HighlighterWaiter.OUTPUT = 1; - - -/** - * Determines if the current text selection is running backwards or forwards. - * StackOverflow answer id: 12652116 - * - * @private - * @returns {boolean} - */ -HighlighterWaiter.prototype._isSelectionBackwards = function() { - let backwards = false; - const sel = window.getSelection(); - - if (!sel.isCollapsed) { - const range = document.createRange(); - range.setStart(sel.anchorNode, sel.anchorOffset); - range.setEnd(sel.focusNode, sel.focusOffset); - backwards = range.collapsed; - range.detach(); - } - return backwards; -}; - - -/** - * Calculates the text offset of a position in an HTML element, ignoring HTML tags. - * - * @private - * @param {element} node - The parent HTML node. - * @param {number} offset - The offset since the last HTML element. - * @returns {number} - */ -HighlighterWaiter.prototype._getOutputHtmlOffset = function(node, offset) { - const sel = window.getSelection(); - const range = document.createRange(); - - range.selectNodeContents(document.getElementById("output-html")); - range.setEnd(node, offset); - sel.removeAllRanges(); - sel.addRange(range); - - return sel.toString().length; -}; - - -/** - * Gets the current selection offsets in the output HTML, ignoring HTML tags. - * - * @private - * @returns {Object} pos - * @returns {number} pos.start - * @returns {number} pos.end - */ -HighlighterWaiter.prototype._getOutputHtmlSelectionOffsets = function() { - const sel = window.getSelection(); - let range, - start = 0, - end = 0, - backwards = false; - - if (sel.rangeCount) { - range = sel.getRangeAt(sel.rangeCount - 1); - backwards = this._isSelectionBackwards(); - start = this._getOutputHtmlOffset(range.startContainer, range.startOffset); - end = this._getOutputHtmlOffset(range.endContainer, range.endOffset); - sel.removeAllRanges(); - sel.addRange(range); - - if (backwards) { - // If selecting backwards, reverse the start and end offsets for the selection to - // prevent deselecting as the drag continues. - sel.collapseToEnd(); - sel.extend(sel.anchorNode, range.startOffset); - } - } - - return { - start: start, - end: end - }; -}; - - -/** - * Handler for input scroll events. - * Scrolls the highlighter pane to match the input textarea position. - * - * @param {event} e - */ -HighlighterWaiter.prototype.inputScroll = function(e) { - const el = e.target; - document.getElementById("input-highlighter").scrollTop = el.scrollTop; - document.getElementById("input-highlighter").scrollLeft = el.scrollLeft; -}; - - -/** - * Handler for output scroll events. - * Scrolls the highlighter pane to match the output textarea position. - * - * @param {event} e - */ -HighlighterWaiter.prototype.outputScroll = function(e) { - const el = e.target; - document.getElementById("output-highlighter").scrollTop = el.scrollTop; - document.getElementById("output-highlighter").scrollLeft = el.scrollLeft; -}; - - -/** - * Handler for input mousedown events. - * Calculates the current selection info, and highlights the corresponding data in the output. - * - * @param {event} e - */ -HighlighterWaiter.prototype.inputMousedown = function(e) { - this.mouseButtonDown = true; - this.mouseTarget = HighlighterWaiter.INPUT; - this.removeHighlights(); - - const el = e.target; - const start = el.selectionStart; - const end = el.selectionEnd; - - if (start !== 0 || end !== 0) { - document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end); - this.highlightOutput([{start: start, end: end}]); - } -}; - - -/** - * Handler for output mousedown events. - * Calculates the current selection info, and highlights the corresponding data in the input. - * - * @param {event} e - */ -HighlighterWaiter.prototype.outputMousedown = function(e) { - this.mouseButtonDown = true; - this.mouseTarget = HighlighterWaiter.OUTPUT; - this.removeHighlights(); - - const el = e.target; - const start = el.selectionStart; - const end = el.selectionEnd; - - if (start !== 0 || end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(start, end); - this.highlightInput([{start: start, end: end}]); - } -}; - - -/** - * Handler for output HTML mousedown events. - * Calculates the current selection info. - * - * @param {event} e - */ -HighlighterWaiter.prototype.outputHtmlMousedown = function(e) { - this.mouseButtonDown = true; - this.mouseTarget = HighlighterWaiter.OUTPUT; - - const sel = this._getOutputHtmlSelectionOffsets(); - if (sel.start !== 0 || sel.end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(sel.start, sel.end); - } -}; - - -/** - * Handler for input mouseup events. - * - * @param {event} e - */ -HighlighterWaiter.prototype.inputMouseup = function(e) { - this.mouseButtonDown = false; -}; - - -/** - * Handler for output mouseup events. - * - * @param {event} e - */ -HighlighterWaiter.prototype.outputMouseup = function(e) { - this.mouseButtonDown = false; -}; - - -/** - * Handler for output HTML mouseup events. - * - * @param {event} e - */ -HighlighterWaiter.prototype.outputHtmlMouseup = function(e) { - this.mouseButtonDown = false; -}; - - -/** - * Handler for input mousemove events. - * Calculates the current selection info, and highlights the corresponding data in the output. - * - * @param {event} e - */ -HighlighterWaiter.prototype.inputMousemove = function(e) { - // Check that the left mouse button is pressed - if (!this.mouseButtonDown || - e.which !== 1 || - this.mouseTarget !== HighlighterWaiter.INPUT) - return; - - const el = e.target; - const start = el.selectionStart; - const end = el.selectionEnd; - - if (start !== 0 || end !== 0) { - document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end); - this.highlightOutput([{start: start, end: end}]); - } -}; - - -/** - * Handler for output mousemove events. - * Calculates the current selection info, and highlights the corresponding data in the input. - * - * @param {event} e - */ -HighlighterWaiter.prototype.outputMousemove = function(e) { - // Check that the left mouse button is pressed - if (!this.mouseButtonDown || - e.which !== 1 || - this.mouseTarget !== HighlighterWaiter.OUTPUT) - return; - - const el = e.target; - const start = el.selectionStart; - const end = el.selectionEnd; - - if (start !== 0 || end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(start, end); - this.highlightInput([{start: start, end: end}]); - } -}; - - -/** - * Handler for output HTML mousemove events. - * Calculates the current selection info. - * - * @param {event} e - */ -HighlighterWaiter.prototype.outputHtmlMousemove = function(e) { - // Check that the left mouse button is pressed - if (!this.mouseButtonDown || - e.which !== 1 || - this.mouseTarget !== HighlighterWaiter.OUTPUT) - return; - - const sel = this._getOutputHtmlSelectionOffsets(); - if (sel.start !== 0 || sel.end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(sel.start, sel.end); - } -}; - - -/** - * Given start and end offsets, writes the HTML for the selection info element with the correct - * padding. - * - * @param {number} start - The start offset. - * @param {number} end - The end offset. - * @returns {string} - */ -HighlighterWaiter.prototype.selectionInfo = function(start, end) { - const len = end.toString().length; - const width = len < 2 ? 2 : len; - const startStr = start.toString().padStart(width, " ").replace(/ /g, " "); - const endStr = end.toString().padStart(width, " ").replace(/ /g, " "); - const lenStr = (end-start).toString().padStart(width, " ").replace(/ /g, " "); - - return "start: " + startStr + "
    end: " + endStr + "
    length: " + lenStr; -}; - - -/** - * Removes highlighting and selection information. - */ -HighlighterWaiter.prototype.removeHighlights = function() { - document.getElementById("input-highlighter").innerHTML = ""; - document.getElementById("output-highlighter").innerHTML = ""; - document.getElementById("input-selection-info").innerHTML = ""; - document.getElementById("output-selection-info").innerHTML = ""; -}; - - -/** - * Highlights the given offsets in the output. - * We will only highlight if: - * - input hasn't changed since last bake - * - last bake was a full bake - * - all operations in the recipe support highlighting - * - * @param {Object} pos - The position object for the highlight. - * @param {number} pos.start - The start offset. - * @param {number} pos.end - The end offset. - */ -HighlighterWaiter.prototype.highlightOutput = function(pos) { - if (!this.app.autoBake_ || this.app.baking) return false; - this.manager.worker.highlight(this.app.getRecipeConfig(), "forward", pos); -}; - - -/** - * Highlights the given offsets in the input. - * We will only highlight if: - * - input hasn't changed since last bake - * - last bake was a full bake - * - all operations in the recipe support highlighting - * - * @param {Object} pos - The position object for the highlight. - * @param {number} pos.start - The start offset. - * @param {number} pos.end - The end offset. - */ -HighlighterWaiter.prototype.highlightInput = function(pos) { - if (!this.app.autoBake_ || this.app.baking) return false; - this.manager.worker.highlight(this.app.getRecipeConfig(), "reverse", pos); -}; - - -/** - * Displays highlight offsets sent back from the Chef. - * - * @param {Object} pos - The position object for the highlight. - * @param {number} pos.start - The start offset. - * @param {number} pos.end - The end offset. - * @param {string} direction - */ -HighlighterWaiter.prototype.displayHighlights = function(pos, direction) { - if (!pos) return; - - const io = direction === "forward" ? "output" : "input"; - - document.getElementById(io + "-selection-info").innerHTML = this.selectionInfo(pos[0].start, pos[0].end); - this.highlight( - document.getElementById(io + "-text"), - document.getElementById(io + "-highlighter"), - pos); -}; - - -/** - * Adds the relevant HTML to the specified highlight element such that highlighting appears - * underneath the correct offset. - * - * @param {element} textarea - The input or output textarea. - * @param {element} highlighter - The input or output highlighter element. - * @param {Object} pos - The position object for the highlight. - * @param {number} pos.start - The start offset. - * @param {number} pos.end - The end offset. - */ -HighlighterWaiter.prototype.highlight = async function(textarea, highlighter, pos) { - if (!this.app.options.showHighlighter) return false; - if (!this.app.options.attemptHighlight) return false; - - // Check if there is a carriage return in the output dish as this will not - // be displayed by the HTML textarea and will mess up highlighting offsets. - if (await this.manager.output.containsCR()) return false; - - const startPlaceholder = "[startHighlight]"; - const startPlaceholderRegex = /\[startHighlight\]/g; - const endPlaceholder = "[endHighlight]"; - const endPlaceholderRegex = /\[endHighlight\]/g; - let text = textarea.value; - - // Put placeholders in position - // If there's only one value, select that - // If there are multiple, ignore the first one and select all others - if (pos.length === 1) { - if (pos[0].end < pos[0].start) return; - text = text.slice(0, pos[0].start) + - startPlaceholder + text.slice(pos[0].start, pos[0].end) + endPlaceholder + - text.slice(pos[0].end, text.length); - } else { - // O(n^2) - Can anyone improve this without overwriting placeholders? - let result = "", - endPlaced = true; - - for (let i = 0; i < text.length; i++) { - for (let j = 1; j < pos.length; j++) { - if (pos[j].end < pos[j].start) continue; - if (pos[j].start === i) { - result += startPlaceholder; - endPlaced = false; - } - if (pos[j].end === i) { - result += endPlaceholder; - endPlaced = true; - } - } - result += text[i]; - } - if (!endPlaced) result += endPlaceholder; - text = result; - } - - const cssClass = "hl1"; - //if (colour) cssClass += "-"+colour; - - // Remove HTML tags - text = text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\n/g, " ") - // Convert placeholders to tags - .replace(startPlaceholderRegex, "") - .replace(endPlaceholderRegex, "") + " "; - - // Adjust width to allow for scrollbars - highlighter.style.width = textarea.clientWidth + "px"; - highlighter.innerHTML = text; - highlighter.scrollTop = textarea.scrollTop; - highlighter.scrollLeft = textarea.scrollLeft; -}; - -export default HighlighterWaiter; diff --git a/src/web/HighlighterWaiter.mjs b/src/web/HighlighterWaiter.mjs new file mode 100755 index 00000000..99ae10b1 --- /dev/null +++ b/src/web/HighlighterWaiter.mjs @@ -0,0 +1,468 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +/** + * HighlighterWaiter data type enum for the input. + * @enum + */ +const INPUT = 0; + +/** + * HighlighterWaiter data type enum for the output. + * @enum + */ +const OUTPUT = 1; + + +/** + * Waiter to handle events related to highlighting in CyberChef. + */ +class HighlighterWaiter { + + /** + * HighlighterWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + + this.mouseButtonDown = false; + this.mouseTarget = null; + } + + + /** + * Determines if the current text selection is running backwards or forwards. + * StackOverflow answer id: 12652116 + * + * @private + * @returns {boolean} + */ + _isSelectionBackwards() { + let backwards = false; + const sel = window.getSelection(); + + if (!sel.isCollapsed) { + const range = document.createRange(); + range.setStart(sel.anchorNode, sel.anchorOffset); + range.setEnd(sel.focusNode, sel.focusOffset); + backwards = range.collapsed; + range.detach(); + } + return backwards; + } + + + /** + * Calculates the text offset of a position in an HTML element, ignoring HTML tags. + * + * @private + * @param {element} node - The parent HTML node. + * @param {number} offset - The offset since the last HTML element. + * @returns {number} + */ + _getOutputHtmlOffset(node, offset) { + const sel = window.getSelection(); + const range = document.createRange(); + + range.selectNodeContents(document.getElementById("output-html")); + range.setEnd(node, offset); + sel.removeAllRanges(); + sel.addRange(range); + + return sel.toString().length; + } + + + /** + * Gets the current selection offsets in the output HTML, ignoring HTML tags. + * + * @private + * @returns {Object} pos + * @returns {number} pos.start + * @returns {number} pos.end + */ + _getOutputHtmlSelectionOffsets() { + const sel = window.getSelection(); + let range, + start = 0, + end = 0, + backwards = false; + + if (sel.rangeCount) { + range = sel.getRangeAt(sel.rangeCount - 1); + backwards = this._isSelectionBackwards(); + start = this._getOutputHtmlOffset(range.startContainer, range.startOffset); + end = this._getOutputHtmlOffset(range.endContainer, range.endOffset); + sel.removeAllRanges(); + sel.addRange(range); + + if (backwards) { + // If selecting backwards, reverse the start and end offsets for the selection to + // prevent deselecting as the drag continues. + sel.collapseToEnd(); + sel.extend(sel.anchorNode, range.startOffset); + } + } + + return { + start: start, + end: end + }; + } + + + /** + * Handler for input scroll events. + * Scrolls the highlighter pane to match the input textarea position. + * + * @param {event} e + */ + inputScroll(e) { + const el = e.target; + document.getElementById("input-highlighter").scrollTop = el.scrollTop; + document.getElementById("input-highlighter").scrollLeft = el.scrollLeft; + } + + + /** + * Handler for output scroll events. + * Scrolls the highlighter pane to match the output textarea position. + * + * @param {event} e + */ + outputScroll(e) { + const el = e.target; + document.getElementById("output-highlighter").scrollTop = el.scrollTop; + document.getElementById("output-highlighter").scrollLeft = el.scrollLeft; + } + + + /** + * Handler for input mousedown events. + * Calculates the current selection info, and highlights the corresponding data in the output. + * + * @param {event} e + */ + inputMousedown(e) { + this.mouseButtonDown = true; + this.mouseTarget = INPUT; + this.removeHighlights(); + + const el = e.target; + const start = el.selectionStart; + const end = el.selectionEnd; + + if (start !== 0 || end !== 0) { + document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end); + this.highlightOutput([{start: start, end: end}]); + } + } + + + /** + * Handler for output mousedown events. + * Calculates the current selection info, and highlights the corresponding data in the input. + * + * @param {event} e + */ + outputMousedown(e) { + this.mouseButtonDown = true; + this.mouseTarget = OUTPUT; + this.removeHighlights(); + + const el = e.target; + const start = el.selectionStart; + const end = el.selectionEnd; + + if (start !== 0 || end !== 0) { + document.getElementById("output-selection-info").innerHTML = this.selectionInfo(start, end); + this.highlightInput([{start: start, end: end}]); + } + } + + + /** + * Handler for output HTML mousedown events. + * Calculates the current selection info. + * + * @param {event} e + */ + outputHtmlMousedown(e) { + this.mouseButtonDown = true; + this.mouseTarget = OUTPUT; + + const sel = this._getOutputHtmlSelectionOffsets(); + if (sel.start !== 0 || sel.end !== 0) { + document.getElementById("output-selection-info").innerHTML = this.selectionInfo(sel.start, sel.end); + } + } + + + /** + * Handler for input mouseup events. + * + * @param {event} e + */ + inputMouseup(e) { + this.mouseButtonDown = false; + } + + + /** + * Handler for output mouseup events. + * + * @param {event} e + */ + outputMouseup(e) { + this.mouseButtonDown = false; + } + + + /** + * Handler for output HTML mouseup events. + * + * @param {event} e + */ + outputHtmlMouseup(e) { + this.mouseButtonDown = false; + } + + + /** + * Handler for input mousemove events. + * Calculates the current selection info, and highlights the corresponding data in the output. + * + * @param {event} e + */ + inputMousemove(e) { + // Check that the left mouse button is pressed + if (!this.mouseButtonDown || + e.which !== 1 || + this.mouseTarget !== INPUT) + return; + + const el = e.target; + const start = el.selectionStart; + const end = el.selectionEnd; + + if (start !== 0 || end !== 0) { + document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end); + this.highlightOutput([{start: start, end: end}]); + } + } + + + /** + * Handler for output mousemove events. + * Calculates the current selection info, and highlights the corresponding data in the input. + * + * @param {event} e + */ + outputMousemove(e) { + // Check that the left mouse button is pressed + if (!this.mouseButtonDown || + e.which !== 1 || + this.mouseTarget !== OUTPUT) + return; + + const el = e.target; + const start = el.selectionStart; + const end = el.selectionEnd; + + if (start !== 0 || end !== 0) { + document.getElementById("output-selection-info").innerHTML = this.selectionInfo(start, end); + this.highlightInput([{start: start, end: end}]); + } + } + + + /** + * Handler for output HTML mousemove events. + * Calculates the current selection info. + * + * @param {event} e + */ + outputHtmlMousemove(e) { + // Check that the left mouse button is pressed + if (!this.mouseButtonDown || + e.which !== 1 || + this.mouseTarget !== OUTPUT) + return; + + const sel = this._getOutputHtmlSelectionOffsets(); + if (sel.start !== 0 || sel.end !== 0) { + document.getElementById("output-selection-info").innerHTML = this.selectionInfo(sel.start, sel.end); + } + } + + + /** + * Given start and end offsets, writes the HTML for the selection info element with the correct + * padding. + * + * @param {number} start - The start offset. + * @param {number} end - The end offset. + * @returns {string} + */ + selectionInfo(start, end) { + const len = end.toString().length; + const width = len < 2 ? 2 : len; + const startStr = start.toString().padStart(width, " ").replace(/ /g, " "); + const endStr = end.toString().padStart(width, " ").replace(/ /g, " "); + const lenStr = (end-start).toString().padStart(width, " ").replace(/ /g, " "); + + return "start: " + startStr + "
    end: " + endStr + "
    length: " + lenStr; + } + + + /** + * Removes highlighting and selection information. + */ + removeHighlights() { + document.getElementById("input-highlighter").innerHTML = ""; + document.getElementById("output-highlighter").innerHTML = ""; + document.getElementById("input-selection-info").innerHTML = ""; + document.getElementById("output-selection-info").innerHTML = ""; + } + + + /** + * Highlights the given offsets in the output. + * We will only highlight if: + * - input hasn't changed since last bake + * - last bake was a full bake + * - all operations in the recipe support highlighting + * + * @param {Object} pos - The position object for the highlight. + * @param {number} pos.start - The start offset. + * @param {number} pos.end - The end offset. + */ + highlightOutput(pos) { + if (!this.app.autoBake_ || this.app.baking) return false; + this.manager.worker.highlight(this.app.getRecipeConfig(), "forward", pos); + } + + + /** + * Highlights the given offsets in the input. + * We will only highlight if: + * - input hasn't changed since last bake + * - last bake was a full bake + * - all operations in the recipe support highlighting + * + * @param {Object} pos - The position object for the highlight. + * @param {number} pos.start - The start offset. + * @param {number} pos.end - The end offset. + */ + highlightInput(pos) { + if (!this.app.autoBake_ || this.app.baking) return false; + this.manager.worker.highlight(this.app.getRecipeConfig(), "reverse", pos); + } + + + /** + * Displays highlight offsets sent back from the Chef. + * + * @param {Object} pos - The position object for the highlight. + * @param {number} pos.start - The start offset. + * @param {number} pos.end - The end offset. + * @param {string} direction + */ + displayHighlights(pos, direction) { + if (!pos) return; + + const io = direction === "forward" ? "output" : "input"; + + document.getElementById(io + "-selection-info").innerHTML = this.selectionInfo(pos[0].start, pos[0].end); + this.highlight( + document.getElementById(io + "-text"), + document.getElementById(io + "-highlighter"), + pos); + } + + + /** + * Adds the relevant HTML to the specified highlight element such that highlighting appears + * underneath the correct offset. + * + * @param {element} textarea - The input or output textarea. + * @param {element} highlighter - The input or output highlighter element. + * @param {Object} pos - The position object for the highlight. + * @param {number} pos.start - The start offset. + * @param {number} pos.end - The end offset. + */ + async highlight(textarea, highlighter, pos) { + if (!this.app.options.showHighlighter) return false; + if (!this.app.options.attemptHighlight) return false; + + // Check if there is a carriage return in the output dish as this will not + // be displayed by the HTML textarea and will mess up highlighting offsets. + if (await this.manager.output.containsCR()) return false; + + const startPlaceholder = "[startHighlight]"; + const startPlaceholderRegex = /\[startHighlight\]/g; + const endPlaceholder = "[endHighlight]"; + const endPlaceholderRegex = /\[endHighlight\]/g; + let text = textarea.value; + + // Put placeholders in position + // If there's only one value, select that + // If there are multiple, ignore the first one and select all others + if (pos.length === 1) { + if (pos[0].end < pos[0].start) return; + text = text.slice(0, pos[0].start) + + startPlaceholder + text.slice(pos[0].start, pos[0].end) + endPlaceholder + + text.slice(pos[0].end, text.length); + } else { + // O(n^2) - Can anyone improve this without overwriting placeholders? + let result = "", + endPlaced = true; + + for (let i = 0; i < text.length; i++) { + for (let j = 1; j < pos.length; j++) { + if (pos[j].end < pos[j].start) continue; + if (pos[j].start === i) { + result += startPlaceholder; + endPlaced = false; + } + if (pos[j].end === i) { + result += endPlaceholder; + endPlaced = true; + } + } + result += text[i]; + } + if (!endPlaced) result += endPlaceholder; + text = result; + } + + const cssClass = "hl1"; + //if (colour) cssClass += "-"+colour; + + // Remove HTML tags + text = text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, " ") + // Convert placeholders to tags + .replace(startPlaceholderRegex, "") + .replace(endPlaceholderRegex, "") + " "; + + // Adjust width to allow for scrollbars + highlighter.style.width = textarea.clientWidth + "px"; + highlighter.innerHTML = text; + highlighter.scrollTop = textarea.scrollTop; + highlighter.scrollLeft = textarea.scrollLeft; + } + +} + +export default HighlighterWaiter; diff --git a/src/web/InputWaiter.js b/src/web/InputWaiter.js deleted file mode 100755 index d0b648ec..00000000 --- a/src/web/InputWaiter.js +++ /dev/null @@ -1,321 +0,0 @@ -import LoaderWorker from "worker-loader?inline&fallback=false!./LoaderWorker.js"; -import Utils from "../core/Utils"; - - -/** - * Waiter to handle events related to the input. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const InputWaiter = function(app, manager) { - this.app = app; - this.manager = manager; - - // Define keys that don't change the input so we don't have to autobake when they are pressed - this.badKeys = [ - 16, //Shift - 17, //Ctrl - 18, //Alt - 19, //Pause - 20, //Caps - 27, //Esc - 33, 34, 35, 36, //PgUp, PgDn, End, Home - 37, 38, 39, 40, //Directional - 44, //PrntScrn - 91, 92, //Win - 93, //Context - 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, //F1-12 - 144, //Num - 145, //Scroll - ]; - - this.loaderWorker = null; - this.fileBuffer = null; -}; - - -/** - * Gets the user's input from the input textarea. - * - * @returns {string} - */ -InputWaiter.prototype.get = function() { - return this.fileBuffer || document.getElementById("input-text").value; -}; - - -/** - * Sets the input in the input area. - * - * @param {string|File} input - * - * @fires Manager#statechange - */ -InputWaiter.prototype.set = function(input) { - const inputText = document.getElementById("input-text"); - if (input instanceof File) { - this.setFile(input); - inputText.value = ""; - this.setInputInfo(input.size, null); - } else { - inputText.value = input; - this.closeFile(); - window.dispatchEvent(this.manager.statechange); - const lines = input.length < (this.app.options.ioDisplayThreshold * 1024) ? - input.count("\n") + 1 : null; - this.setInputInfo(input.length, lines); - } -}; - - -/** - * Shows file details. - * - * @param {File} file - */ -InputWaiter.prototype.setFile = function(file) { - // Display file overlay in input area with details - const fileOverlay = document.getElementById("input-file"), - fileName = document.getElementById("input-file-name"), - fileSize = document.getElementById("input-file-size"), - fileType = document.getElementById("input-file-type"), - fileLoaded = document.getElementById("input-file-loaded"); - - this.fileBuffer = new ArrayBuffer(); - fileOverlay.style.display = "block"; - fileName.textContent = file.name; - fileSize.textContent = file.size.toLocaleString() + " bytes"; - fileType.textContent = file.type || "unknown"; - fileLoaded.textContent = "0%"; -}; - - -/** - * Displays information about the input. - * - * @param {number} length - The length of the current input string - * @param {number} lines - The number of the lines in the current input string - */ -InputWaiter.prototype.setInputInfo = function(length, lines) { - let width = length.toString().length; - width = width < 2 ? 2 : width; - - const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); - let msg = "length: " + lengthStr; - - if (typeof lines === "number") { - const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); - msg += "
    lines: " + linesStr; - } - - document.getElementById("input-info").innerHTML = msg; -}; - - -/** - * Handler for input change events. - * - * @param {event} e - * - * @fires Manager#statechange - */ -InputWaiter.prototype.inputChange = function(e) { - // Ignore this function if the input is a File - if (this.fileBuffer) return; - - // Remove highlighting from input and output panes as the offsets might be different now - this.manager.highlighter.removeHighlights(); - - // Reset recipe progress as any previous processing will be redundant now - this.app.progress = 0; - - // Update the input metadata info - const inputText = this.get(); - const lines = inputText.length < (this.app.options.ioDisplayThreshold * 1024) ? - inputText.count("\n") + 1 : null; - - this.setInputInfo(inputText.length, lines); - - if (e && this.badKeys.indexOf(e.keyCode) < 0) { - // Fire the statechange event as the input has been modified - window.dispatchEvent(this.manager.statechange); - } -}; - - -/** - * Handler for input paste events. - * Checks that the size of the input is below the display limit, otherwise treats it as a file/blob. - * - * @param {event} e - */ -InputWaiter.prototype.inputPaste = function(e) { - const pastedData = e.clipboardData.getData("Text"); - - if (pastedData.length < (this.app.options.ioDisplayThreshold * 1024)) { - this.inputChange(e); - } else { - e.preventDefault(); - e.stopPropagation(); - - const file = new File([pastedData], "PastedData", { - type: "text/plain", - lastModified: Date.now() - }); - - this.loaderWorker = new LoaderWorker(); - this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); - this.loaderWorker.postMessage({"file": file}); - this.set(file); - return false; - } -}; - - -/** - * Handler for input dragover events. - * Gives the user a visual cue to show that items can be dropped here. - * - * @param {event} e - */ -InputWaiter.prototype.inputDragover = function(e) { - // This will be set if we're dragging an operation - if (e.dataTransfer.effectAllowed === "move") - return false; - - e.stopPropagation(); - e.preventDefault(); - e.target.closest("#input-text,#input-file").classList.add("dropping-file"); -}; - - -/** - * Handler for input dragleave events. - * Removes the visual cue. - * - * @param {event} e - */ -InputWaiter.prototype.inputDragleave = function(e) { - e.stopPropagation(); - e.preventDefault(); - document.getElementById("input-text").classList.remove("dropping-file"); - document.getElementById("input-file").classList.remove("dropping-file"); -}; - - -/** - * Handler for input drop events. - * Loads the dragged data into the input textarea. - * - * @param {event} e - */ -InputWaiter.prototype.inputDrop = function(e) { - // This will be set if we're dragging an operation - if (e.dataTransfer.effectAllowed === "move") - return false; - - e.stopPropagation(); - e.preventDefault(); - - const file = e.dataTransfer.files[0]; - const text = e.dataTransfer.getData("Text"); - - document.getElementById("input-text").classList.remove("dropping-file"); - document.getElementById("input-file").classList.remove("dropping-file"); - - if (text) { - this.closeFile(); - this.set(text); - return; - } - - if (file) { - this.closeFile(); - this.loaderWorker = new LoaderWorker(); - this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); - this.loaderWorker.postMessage({"file": file}); - this.set(file); - } -}; - - -/** - * Handler for messages sent back by the LoaderWorker. - * - * @param {MessageEvent} e - */ -InputWaiter.prototype.handleLoaderMessage = function(e) { - const r = e.data; - if (r.hasOwnProperty("progress")) { - const fileLoaded = document.getElementById("input-file-loaded"); - fileLoaded.textContent = r.progress + "%"; - } - - if (r.hasOwnProperty("error")) { - this.app.alert(r.error, "danger", 10000); - } - - if (r.hasOwnProperty("fileBuffer")) { - log.debug("Input file loaded"); - this.fileBuffer = r.fileBuffer; - this.displayFilePreview(); - window.dispatchEvent(this.manager.statechange); - } -}; - - -/** - * Shows a chunk of the file in the input behind the file overlay. - */ -InputWaiter.prototype.displayFilePreview = function() { - const inputText = document.getElementById("input-text"), - fileSlice = this.fileBuffer.slice(0, 4096); - - inputText.style.overflow = "hidden"; - inputText.classList.add("blur"); - inputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); - if (this.fileBuffer.byteLength > 4096) { - inputText.value += "[truncated]..."; - } -}; - - -/** - * Handler for file close events. - */ -InputWaiter.prototype.closeFile = function() { - if (this.loaderWorker) this.loaderWorker.terminate(); - this.fileBuffer = null; - document.getElementById("input-file").style.display = "none"; - const inputText = document.getElementById("input-text"); - inputText.style.overflow = "auto"; - inputText.classList.remove("blur"); -}; - - -/** - * Handler for clear IO events. - * Resets the input, output and info areas. - * - * @fires Manager#statechange - */ -InputWaiter.prototype.clearIoClick = function() { - this.closeFile(); - this.manager.output.closeFile(); - this.manager.highlighter.removeHighlights(); - document.getElementById("input-text").value = ""; - document.getElementById("output-text").value = ""; - document.getElementById("input-info").innerHTML = ""; - document.getElementById("output-info").innerHTML = ""; - document.getElementById("input-selection-info").innerHTML = ""; - document.getElementById("output-selection-info").innerHTML = ""; - window.dispatchEvent(this.manager.statechange); -}; - -export default InputWaiter; diff --git a/src/web/InputWaiter.mjs b/src/web/InputWaiter.mjs new file mode 100755 index 00000000..f1728527 --- /dev/null +++ b/src/web/InputWaiter.mjs @@ -0,0 +1,329 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import LoaderWorker from "worker-loader?inline&fallback=false!./LoaderWorker"; +import Utils from "../core/Utils"; + + +/** + * Waiter to handle events related to the input. + */ +class InputWaiter { + + /** + * InputWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + + // Define keys that don't change the input so we don't have to autobake when they are pressed + this.badKeys = [ + 16, //Shift + 17, //Ctrl + 18, //Alt + 19, //Pause + 20, //Caps + 27, //Esc + 33, 34, 35, 36, //PgUp, PgDn, End, Home + 37, 38, 39, 40, //Directional + 44, //PrntScrn + 91, 92, //Win + 93, //Context + 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, //F1-12 + 144, //Num + 145, //Scroll + ]; + + this.loaderWorker = null; + this.fileBuffer = null; + } + + + /** + * Gets the user's input from the input textarea. + * + * @returns {string} + */ + get() { + return this.fileBuffer || document.getElementById("input-text").value; + } + + + /** + * Sets the input in the input area. + * + * @param {string|File} input + * + * @fires Manager#statechange + */ + set(input) { + const inputText = document.getElementById("input-text"); + if (input instanceof File) { + this.setFile(input); + inputText.value = ""; + this.setInputInfo(input.size, null); + } else { + inputText.value = input; + this.closeFile(); + window.dispatchEvent(this.manager.statechange); + const lines = input.length < (this.app.options.ioDisplayThreshold * 1024) ? + input.count("\n") + 1 : null; + this.setInputInfo(input.length, lines); + } + } + + + /** + * Shows file details. + * + * @param {File} file + */ + setFile(file) { + // Display file overlay in input area with details + const fileOverlay = document.getElementById("input-file"), + fileName = document.getElementById("input-file-name"), + fileSize = document.getElementById("input-file-size"), + fileType = document.getElementById("input-file-type"), + fileLoaded = document.getElementById("input-file-loaded"); + + this.fileBuffer = new ArrayBuffer(); + fileOverlay.style.display = "block"; + fileName.textContent = file.name; + fileSize.textContent = file.size.toLocaleString() + " bytes"; + fileType.textContent = file.type || "unknown"; + fileLoaded.textContent = "0%"; + } + + + /** + * Displays information about the input. + * + * @param {number} length - The length of the current input string + * @param {number} lines - The number of the lines in the current input string + */ + setInputInfo(length, lines) { + let width = length.toString().length; + width = width < 2 ? 2 : width; + + const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); + let msg = "length: " + lengthStr; + + if (typeof lines === "number") { + const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); + msg += "
    lines: " + linesStr; + } + + document.getElementById("input-info").innerHTML = msg; + } + + + /** + * Handler for input change events. + * + * @param {event} e + * + * @fires Manager#statechange + */ + inputChange(e) { + // Ignore this function if the input is a File + if (this.fileBuffer) return; + + // Remove highlighting from input and output panes as the offsets might be different now + this.manager.highlighter.removeHighlights(); + + // Reset recipe progress as any previous processing will be redundant now + this.app.progress = 0; + + // Update the input metadata info + const inputText = this.get(); + const lines = inputText.length < (this.app.options.ioDisplayThreshold * 1024) ? + inputText.count("\n") + 1 : null; + + this.setInputInfo(inputText.length, lines); + + if (e && this.badKeys.indexOf(e.keyCode) < 0) { + // Fire the statechange event as the input has been modified + window.dispatchEvent(this.manager.statechange); + } + } + + + /** + * Handler for input paste events. + * Checks that the size of the input is below the display limit, otherwise treats it as a file/blob. + * + * @param {event} e + */ + inputPaste(e) { + const pastedData = e.clipboardData.getData("Text"); + + if (pastedData.length < (this.app.options.ioDisplayThreshold * 1024)) { + this.inputChange(e); + } else { + e.preventDefault(); + e.stopPropagation(); + + const file = new File([pastedData], "PastedData", { + type: "text/plain", + lastModified: Date.now() + }); + + this.loaderWorker = new LoaderWorker(); + this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); + this.loaderWorker.postMessage({"file": file}); + this.set(file); + return false; + } + } + + + /** + * Handler for input dragover events. + * Gives the user a visual cue to show that items can be dropped here. + * + * @param {event} e + */ + inputDragover(e) { + // This will be set if we're dragging an operation + if (e.dataTransfer.effectAllowed === "move") + return false; + + e.stopPropagation(); + e.preventDefault(); + e.target.closest("#input-text,#input-file").classList.add("dropping-file"); + } + + + /** + * Handler for input dragleave events. + * Removes the visual cue. + * + * @param {event} e + */ + inputDragleave(e) { + e.stopPropagation(); + e.preventDefault(); + document.getElementById("input-text").classList.remove("dropping-file"); + document.getElementById("input-file").classList.remove("dropping-file"); + } + + + /** + * Handler for input drop events. + * Loads the dragged data into the input textarea. + * + * @param {event} e + */ + inputDrop(e) { + // This will be set if we're dragging an operation + if (e.dataTransfer.effectAllowed === "move") + return false; + + e.stopPropagation(); + e.preventDefault(); + + const file = e.dataTransfer.files[0]; + const text = e.dataTransfer.getData("Text"); + + document.getElementById("input-text").classList.remove("dropping-file"); + document.getElementById("input-file").classList.remove("dropping-file"); + + if (text) { + this.closeFile(); + this.set(text); + return; + } + + if (file) { + this.closeFile(); + this.loaderWorker = new LoaderWorker(); + this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this)); + this.loaderWorker.postMessage({"file": file}); + this.set(file); + } + } + + + /** + * Handler for messages sent back by the LoaderWorker. + * + * @param {MessageEvent} e + */ + handleLoaderMessage(e) { + const r = e.data; + if (r.hasOwnProperty("progress")) { + const fileLoaded = document.getElementById("input-file-loaded"); + fileLoaded.textContent = r.progress + "%"; + } + + if (r.hasOwnProperty("error")) { + this.app.alert(r.error, "danger", 10000); + } + + if (r.hasOwnProperty("fileBuffer")) { + log.debug("Input file loaded"); + this.fileBuffer = r.fileBuffer; + this.displayFilePreview(); + window.dispatchEvent(this.manager.statechange); + } + } + + + /** + * Shows a chunk of the file in the input behind the file overlay. + */ + displayFilePreview() { + const inputText = document.getElementById("input-text"), + fileSlice = this.fileBuffer.slice(0, 4096); + + inputText.style.overflow = "hidden"; + inputText.classList.add("blur"); + inputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); + if (this.fileBuffer.byteLength > 4096) { + inputText.value += "[truncated]..."; + } + } + + + /** + * Handler for file close events. + */ + closeFile() { + if (this.loaderWorker) this.loaderWorker.terminate(); + this.fileBuffer = null; + document.getElementById("input-file").style.display = "none"; + const inputText = document.getElementById("input-text"); + inputText.style.overflow = "auto"; + inputText.classList.remove("blur"); + } + + + /** + * Handler for clear IO events. + * Resets the input, output and info areas. + * + * @fires Manager#statechange + */ + clearIoClick() { + this.closeFile(); + this.manager.output.closeFile(); + this.manager.highlighter.removeHighlights(); + document.getElementById("input-text").value = ""; + document.getElementById("output-text").value = ""; + document.getElementById("input-info").innerHTML = ""; + document.getElementById("output-info").innerHTML = ""; + document.getElementById("input-selection-info").innerHTML = ""; + document.getElementById("output-selection-info").innerHTML = ""; + window.dispatchEvent(this.manager.statechange); + } + +} + +export default InputWaiter; diff --git a/src/web/Manager.js b/src/web/Manager.js deleted file mode 100755 index 8e5e7374..00000000 --- a/src/web/Manager.js +++ /dev/null @@ -1,299 +0,0 @@ -import WorkerWaiter from "./WorkerWaiter.js"; -import WindowWaiter from "./WindowWaiter.js"; -import ControlsWaiter from "./ControlsWaiter.js"; -import RecipeWaiter from "./RecipeWaiter.js"; -import OperationsWaiter from "./OperationsWaiter.js"; -import InputWaiter from "./InputWaiter.js"; -import OutputWaiter from "./OutputWaiter.js"; -import OptionsWaiter from "./OptionsWaiter.js"; -import HighlighterWaiter from "./HighlighterWaiter.js"; -import SeasonalWaiter from "./SeasonalWaiter.js"; -import BindingsWaiter from "./BindingsWaiter.js"; - - -/** - * This object controls the Waiters responsible for handling events from all areas of the app. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - */ -const Manager = function(app) { - this.app = app; - - // Define custom events - /** - * @event Manager#appstart - */ - this.appstart = new CustomEvent("appstart", {bubbles: true}); - /** - * @event Manager#apploaded - */ - this.apploaded = new CustomEvent("apploaded", {bubbles: true}); - /** - * @event Manager#operationadd - */ - this.operationadd = new CustomEvent("operationadd", {bubbles: true}); - /** - * @event Manager#operationremove - */ - this.operationremove = new CustomEvent("operationremove", {bubbles: true}); - /** - * @event Manager#oplistcreate - */ - this.oplistcreate = new CustomEvent("oplistcreate", {bubbles: true}); - /** - * @event Manager#statechange - */ - this.statechange = new CustomEvent("statechange", {bubbles: true}); - - // Define Waiter objects to handle various areas - this.worker = new WorkerWaiter(this.app, this); - this.window = new WindowWaiter(this.app); - this.controls = new ControlsWaiter(this.app, this); - this.recipe = new RecipeWaiter(this.app, this); - this.ops = new OperationsWaiter(this.app, this); - this.input = new InputWaiter(this.app, this); - this.output = new OutputWaiter(this.app, this); - this.options = new OptionsWaiter(this.app, this); - this.highlighter = new HighlighterWaiter(this.app, this); - this.seasonal = new SeasonalWaiter(this.app, this); - this.bindings = new BindingsWaiter(this.app, this); - - // Object to store dynamic handlers to fire on elements that may not exist yet - this.dynamicHandlers = {}; - - this.initialiseEventListeners(); -}; - - -/** - * Sets up the various components and listeners. - */ -Manager.prototype.setup = function() { - this.worker.registerChefWorker(); - this.recipe.initialiseOperationDragNDrop(); - this.controls.autoBakeChange(); - this.bindings.updateKeybList(); - this.seasonal.load(); -}; - - -/** - * Main function to handle the creation of the event listeners. - */ -Manager.prototype.initialiseEventListeners = function() { - // Global - window.addEventListener("resize", this.window.windowResize.bind(this.window)); - window.addEventListener("blur", this.window.windowBlur.bind(this.window)); - window.addEventListener("focus", this.window.windowFocus.bind(this.window)); - window.addEventListener("statechange", this.app.stateChange.bind(this.app)); - window.addEventListener("popstate", this.app.popState.bind(this.app)); - - // Controls - document.getElementById("bake").addEventListener("click", this.controls.bakeClick.bind(this.controls)); - document.getElementById("auto-bake").addEventListener("change", this.controls.autoBakeChange.bind(this.controls)); - document.getElementById("step").addEventListener("click", this.controls.stepClick.bind(this.controls)); - document.getElementById("clr-recipe").addEventListener("click", this.controls.clearRecipeClick.bind(this.controls)); - document.getElementById("clr-breaks").addEventListener("click", this.controls.clearBreaksClick.bind(this.controls)); - document.getElementById("save").addEventListener("click", this.controls.saveClick.bind(this.controls)); - document.getElementById("save-button").addEventListener("click", this.controls.saveButtonClick.bind(this.controls)); - document.getElementById("save-link-recipe-checkbox").addEventListener("change", this.controls.slrCheckChange.bind(this.controls)); - document.getElementById("save-link-input-checkbox").addEventListener("change", this.controls.sliCheckChange.bind(this.controls)); - document.getElementById("load").addEventListener("click", this.controls.loadClick.bind(this.controls)); - document.getElementById("load-delete-button").addEventListener("click", this.controls.loadDeleteClick.bind(this.controls)); - document.getElementById("load-name").addEventListener("change", this.controls.loadNameChange.bind(this.controls)); - document.getElementById("load-button").addEventListener("click", this.controls.loadButtonClick.bind(this.controls)); - document.getElementById("support").addEventListener("click", this.controls.supportButtonClick.bind(this.controls)); - this.addMultiEventListeners("#save-texts textarea", "keyup paste", this.controls.saveTextChange, this.controls); - - // Operations - this.addMultiEventListener("#search", "keyup paste search", this.ops.searchOperations, this.ops); - this.addDynamicListener(".op-list li.operation", "dblclick", this.ops.operationDblclick, this.ops); - document.getElementById("edit-favourites").addEventListener("click", this.ops.editFavouritesClick.bind(this.ops)); - document.getElementById("save-favourites").addEventListener("click", this.ops.saveFavouritesClick.bind(this.ops)); - document.getElementById("reset-favourites").addEventListener("click", this.ops.resetFavouritesClick.bind(this.ops)); - this.addDynamicListener(".op-list .op-icon", "mouseover", this.ops.opIconMouseover, this.ops); - this.addDynamicListener(".op-list .op-icon", "mouseleave", this.ops.opIconMouseleave, this.ops); - this.addDynamicListener(".op-list", "oplistcreate", this.ops.opListCreate, this.ops); - this.addDynamicListener("li.operation", "operationadd", this.recipe.opAdd, this.recipe); - - // Recipe - this.addDynamicListener(".arg:not(select)", "input", this.recipe.ingChange, this.recipe); - this.addDynamicListener(".arg[type=checkbox], .arg[type=radio], select.arg", "change", this.recipe.ingChange, this.recipe); - this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe); - this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, this.recipe); - this.addDynamicListener("#rec-list li.operation", "dblclick", this.recipe.operationDblclick, this.recipe); - this.addDynamicListener("#rec-list li.operation > div", "dblclick", this.recipe.operationChildDblclick, this.recipe); - this.addDynamicListener("#rec-list .input-group .dropdown-menu a", "click", this.recipe.dropdownToggleClick, this.recipe); - this.addDynamicListener("#rec-list", "operationremove", this.recipe.opRemove.bind(this.recipe)); - - // Input - this.addMultiEventListener("#input-text", "keyup", this.input.inputChange, this.input); - this.addMultiEventListener("#input-text", "paste", this.input.inputPaste, this.input); - document.getElementById("reset-layout").addEventListener("click", this.app.resetLayout.bind(this.app)); - document.getElementById("clr-io").addEventListener("click", this.input.clearIoClick.bind(this.input)); - this.addListeners("#input-text,#input-file", "dragover", this.input.inputDragover, this.input); - this.addListeners("#input-text,#input-file", "dragleave", this.input.inputDragleave, this.input); - this.addListeners("#input-text,#input-file", "drop", this.input.inputDrop, this.input); - document.getElementById("input-text").addEventListener("scroll", this.highlighter.inputScroll.bind(this.highlighter)); - document.getElementById("input-text").addEventListener("mouseup", this.highlighter.inputMouseup.bind(this.highlighter)); - document.getElementById("input-text").addEventListener("mousemove", this.highlighter.inputMousemove.bind(this.highlighter)); - this.addMultiEventListener("#input-text", "mousedown dblclick select", this.highlighter.inputMousedown, this.highlighter); - document.querySelector("#input-file .close").addEventListener("click", this.input.clearIoClick.bind(this.input)); - - // Output - document.getElementById("save-to-file").addEventListener("click", this.output.saveClick.bind(this.output)); - document.getElementById("copy-output").addEventListener("click", this.output.copyClick.bind(this.output)); - document.getElementById("switch").addEventListener("click", this.output.switchClick.bind(this.output)); - document.getElementById("undo-switch").addEventListener("click", this.output.undoSwitchClick.bind(this.output)); - document.getElementById("maximise-output").addEventListener("click", this.output.maximiseOutputClick.bind(this.output)); - document.getElementById("output-text").addEventListener("scroll", this.highlighter.outputScroll.bind(this.highlighter)); - document.getElementById("output-text").addEventListener("mouseup", this.highlighter.outputMouseup.bind(this.highlighter)); - document.getElementById("output-text").addEventListener("mousemove", this.highlighter.outputMousemove.bind(this.highlighter)); - document.getElementById("output-html").addEventListener("mouseup", this.highlighter.outputHtmlMouseup.bind(this.highlighter)); - document.getElementById("output-html").addEventListener("mousemove", this.highlighter.outputHtmlMousemove.bind(this.highlighter)); - this.addMultiEventListener("#output-text", "mousedown dblclick select", this.highlighter.outputMousedown, this.highlighter); - this.addMultiEventListener("#output-html", "mousedown dblclick select", this.highlighter.outputHtmlMousedown, this.highlighter); - this.addDynamicListener("#output-file-download", "click", this.output.downloadFile, this.output); - this.addDynamicListener("#output-file-slice", "click", this.output.displayFileSlice, this.output); - document.getElementById("show-file-overlay").addEventListener("click", this.output.showFileOverlayClick.bind(this.output)); - - // Options - document.getElementById("options").addEventListener("click", this.options.optionsClick.bind(this.options)); - document.getElementById("reset-options").addEventListener("click", this.options.resetOptionsClick.bind(this.options)); - $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.switchChange.bind(this.options)); - $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.setWordWrap.bind(this.options)); - $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox#useMetaKey", this.bindings.updateKeybList.bind(this.bindings)); - this.addDynamicListener(".option-item input[type=number]", "keyup", this.options.numberChange, this.options); - this.addDynamicListener(".option-item input[type=number]", "change", this.options.numberChange, this.options); - this.addDynamicListener(".option-item select", "change", this.options.selectChange, this.options); - document.getElementById("theme").addEventListener("change", this.options.themeChange.bind(this.options)); - document.getElementById("logLevel").addEventListener("change", this.options.logLevelChange.bind(this.options)); - - // Misc - window.addEventListener("keydown", this.bindings.parseInput.bind(this.bindings)); - document.getElementById("alert-close").addEventListener("click", this.app.alertCloseClick.bind(this.app)); -}; - - -/** - * Adds an event listener to each element in the specified group. - * - * @param {string} selector - A selector string for the element group to add the event to, see - * this.getAll() - * @param {string} eventType - The event to listen for - * @param {function} callback - The function to execute when the event is triggered - * @param {Object} [scope=this] - The object to bind to the callback function - * - * @example - * // Calls the clickable function whenever any element with the .clickable class is clicked - * this.addListeners(".clickable", "click", this.clickable, this); - */ -Manager.prototype.addListeners = function(selector, eventType, callback, scope) { - scope = scope || this; - [].forEach.call(document.querySelectorAll(selector), function(el) { - el.addEventListener(eventType, callback.bind(scope)); - }); -}; - - -/** - * Adds multiple event listeners to the specified element. - * - * @param {string} selector - A selector string for the element to add the events to - * @param {string} eventTypes - A space-separated string of all the event types to listen for - * @param {function} callback - The function to execute when the events are triggered - * @param {Object} [scope=this] - The object to bind to the callback function - * - * @example - * // Calls the search function whenever the the keyup, paste or search events are triggered on the - * // search element - * this.addMultiEventListener("search", "keyup paste search", this.search, this); - */ -Manager.prototype.addMultiEventListener = function(selector, eventTypes, callback, scope) { - const evs = eventTypes.split(" "); - for (let i = 0; i < evs.length; i++) { - document.querySelector(selector).addEventListener(evs[i], callback.bind(scope)); - } -}; - - -/** - * Adds multiple event listeners to each element in the specified group. - * - * @param {string} selector - A selector string for the element group to add the events to - * @param {string} eventTypes - A space-separated string of all the event types to listen for - * @param {function} callback - The function to execute when the events are triggered - * @param {Object} [scope=this] - The object to bind to the callback function - * - * @example - * // Calls the save function whenever the the keyup or paste events are triggered on any element - * // with the .saveable class - * this.addMultiEventListener(".saveable", "keyup paste", this.save, this); - */ -Manager.prototype.addMultiEventListeners = function(selector, eventTypes, callback, scope) { - const evs = eventTypes.split(" "); - for (let i = 0; i < evs.length; i++) { - this.addListeners(selector, evs[i], callback, scope); - } -}; - - -/** - * Adds an event listener to the global document object which will listen on dynamic elements which - * may not exist in the DOM yet. - * - * @param {string} selector - A selector string for the element(s) to add the event to - * @param {string} eventType - The event(s) to listen for - * @param {function} callback - The function to execute when the event(s) is/are triggered - * @param {Object} [scope=this] - The object to bind to the callback function - * - * @example - * // Pops up an alert whenever any button is clicked, even if it is added to the DOM after this - * // listener is created - * this.addDynamicListener("button", "click", alert, this); - */ -Manager.prototype.addDynamicListener = function(selector, eventType, callback, scope) { - const eventConfig = { - selector: selector, - callback: callback.bind(scope || this) - }; - - if (this.dynamicHandlers.hasOwnProperty(eventType)) { - // Listener already exists, add new handler to the appropriate list - this.dynamicHandlers[eventType].push(eventConfig); - } else { - this.dynamicHandlers[eventType] = [eventConfig]; - // Set up listener for this new type - document.addEventListener(eventType, this.dynamicListenerHandler.bind(this)); - } -}; - - -/** - * Handler for dynamic events. This function is called for any dynamic event and decides which - * callback(s) to execute based on the type and selector. - * - * @param {Event} e - The event to be handled - */ -Manager.prototype.dynamicListenerHandler = function(e) { - const { type, target } = e; - const handlers = this.dynamicHandlers[type]; - const matches = target.matches || - target.webkitMatchesSelector || - target.mozMatchesSelector || - target.msMatchesSelector || - target.oMatchesSelector; - - for (let i = 0; i < handlers.length; i++) { - if (matches && matches.call(target, handlers[i].selector)) { - handlers[i].callback(e); - } - } -}; - -export default Manager; diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs new file mode 100755 index 00000000..4f004edc --- /dev/null +++ b/src/web/Manager.mjs @@ -0,0 +1,307 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import WorkerWaiter from "./WorkerWaiter"; +import WindowWaiter from "./WindowWaiter"; +import ControlsWaiter from "./ControlsWaiter"; +import RecipeWaiter from "./RecipeWaiter"; +import OperationsWaiter from "./OperationsWaiter"; +import InputWaiter from "./InputWaiter"; +import OutputWaiter from "./OutputWaiter"; +import OptionsWaiter from "./OptionsWaiter"; +import HighlighterWaiter from "./HighlighterWaiter"; +import SeasonalWaiter from "./SeasonalWaiter"; +import BindingsWaiter from "./BindingsWaiter"; + + +/** + * This object controls the Waiters responsible for handling events from all areas of the app. + */ +class Manager { + + /** + * Manager constructor. + * + * @param {App} app - The main view object for CyberChef. + */ + constructor(app) { + this.app = app; + + // Define custom events + /** + * @event Manager#appstart + */ + this.appstart = new CustomEvent("appstart", {bubbles: true}); + /** + * @event Manager#apploaded + */ + this.apploaded = new CustomEvent("apploaded", {bubbles: true}); + /** + * @event Manager#operationadd + */ + this.operationadd = new CustomEvent("operationadd", {bubbles: true}); + /** + * @event Manager#operationremove + */ + this.operationremove = new CustomEvent("operationremove", {bubbles: true}); + /** + * @event Manager#oplistcreate + */ + this.oplistcreate = new CustomEvent("oplistcreate", {bubbles: true}); + /** + * @event Manager#statechange + */ + this.statechange = new CustomEvent("statechange", {bubbles: true}); + + // Define Waiter objects to handle various areas + this.worker = new WorkerWaiter(this.app, this); + this.window = new WindowWaiter(this.app); + this.controls = new ControlsWaiter(this.app, this); + this.recipe = new RecipeWaiter(this.app, this); + this.ops = new OperationsWaiter(this.app, this); + this.input = new InputWaiter(this.app, this); + this.output = new OutputWaiter(this.app, this); + this.options = new OptionsWaiter(this.app, this); + this.highlighter = new HighlighterWaiter(this.app, this); + this.seasonal = new SeasonalWaiter(this.app, this); + this.bindings = new BindingsWaiter(this.app, this); + + // Object to store dynamic handlers to fire on elements that may not exist yet + this.dynamicHandlers = {}; + + this.initialiseEventListeners(); + } + + + /** + * Sets up the various components and listeners. + */ + setup() { + this.worker.registerChefWorker(); + this.recipe.initialiseOperationDragNDrop(); + this.controls.autoBakeChange(); + this.bindings.updateKeybList(); + this.seasonal.load(); + } + + + /** + * Main function to handle the creation of the event listeners. + */ + initialiseEventListeners() { + // Global + window.addEventListener("resize", this.window.windowResize.bind(this.window)); + window.addEventListener("blur", this.window.windowBlur.bind(this.window)); + window.addEventListener("focus", this.window.windowFocus.bind(this.window)); + window.addEventListener("statechange", this.app.stateChange.bind(this.app)); + window.addEventListener("popstate", this.app.popState.bind(this.app)); + + // Controls + document.getElementById("bake").addEventListener("click", this.controls.bakeClick.bind(this.controls)); + document.getElementById("auto-bake").addEventListener("change", this.controls.autoBakeChange.bind(this.controls)); + document.getElementById("step").addEventListener("click", this.controls.stepClick.bind(this.controls)); + document.getElementById("clr-recipe").addEventListener("click", this.controls.clearRecipeClick.bind(this.controls)); + document.getElementById("clr-breaks").addEventListener("click", this.controls.clearBreaksClick.bind(this.controls)); + document.getElementById("save").addEventListener("click", this.controls.saveClick.bind(this.controls)); + document.getElementById("save-button").addEventListener("click", this.controls.saveButtonClick.bind(this.controls)); + document.getElementById("save-link-recipe-checkbox").addEventListener("change", this.controls.slrCheckChange.bind(this.controls)); + document.getElementById("save-link-input-checkbox").addEventListener("change", this.controls.sliCheckChange.bind(this.controls)); + document.getElementById("load").addEventListener("click", this.controls.loadClick.bind(this.controls)); + document.getElementById("load-delete-button").addEventListener("click", this.controls.loadDeleteClick.bind(this.controls)); + document.getElementById("load-name").addEventListener("change", this.controls.loadNameChange.bind(this.controls)); + document.getElementById("load-button").addEventListener("click", this.controls.loadButtonClick.bind(this.controls)); + document.getElementById("support").addEventListener("click", this.controls.supportButtonClick.bind(this.controls)); + this.addMultiEventListeners("#save-texts textarea", "keyup paste", this.controls.saveTextChange, this.controls); + + // Operations + this.addMultiEventListener("#search", "keyup paste search", this.ops.searchOperations, this.ops); + this.addDynamicListener(".op-list li.operation", "dblclick", this.ops.operationDblclick, this.ops); + document.getElementById("edit-favourites").addEventListener("click", this.ops.editFavouritesClick.bind(this.ops)); + document.getElementById("save-favourites").addEventListener("click", this.ops.saveFavouritesClick.bind(this.ops)); + document.getElementById("reset-favourites").addEventListener("click", this.ops.resetFavouritesClick.bind(this.ops)); + this.addDynamicListener(".op-list .op-icon", "mouseover", this.ops.opIconMouseover, this.ops); + this.addDynamicListener(".op-list .op-icon", "mouseleave", this.ops.opIconMouseleave, this.ops); + this.addDynamicListener(".op-list", "oplistcreate", this.ops.opListCreate, this.ops); + this.addDynamicListener("li.operation", "operationadd", this.recipe.opAdd, this.recipe); + + // Recipe + this.addDynamicListener(".arg:not(select)", "input", this.recipe.ingChange, this.recipe); + this.addDynamicListener(".arg[type=checkbox], .arg[type=radio], select.arg", "change", this.recipe.ingChange, this.recipe); + this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe); + this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, this.recipe); + this.addDynamicListener("#rec-list li.operation", "dblclick", this.recipe.operationDblclick, this.recipe); + this.addDynamicListener("#rec-list li.operation > div", "dblclick", this.recipe.operationChildDblclick, this.recipe); + this.addDynamicListener("#rec-list .input-group .dropdown-menu a", "click", this.recipe.dropdownToggleClick, this.recipe); + this.addDynamicListener("#rec-list", "operationremove", this.recipe.opRemove.bind(this.recipe)); + + // Input + this.addMultiEventListener("#input-text", "keyup", this.input.inputChange, this.input); + this.addMultiEventListener("#input-text", "paste", this.input.inputPaste, this.input); + document.getElementById("reset-layout").addEventListener("click", this.app.resetLayout.bind(this.app)); + document.getElementById("clr-io").addEventListener("click", this.input.clearIoClick.bind(this.input)); + this.addListeners("#input-text,#input-file", "dragover", this.input.inputDragover, this.input); + this.addListeners("#input-text,#input-file", "dragleave", this.input.inputDragleave, this.input); + this.addListeners("#input-text,#input-file", "drop", this.input.inputDrop, this.input); + document.getElementById("input-text").addEventListener("scroll", this.highlighter.inputScroll.bind(this.highlighter)); + document.getElementById("input-text").addEventListener("mouseup", this.highlighter.inputMouseup.bind(this.highlighter)); + document.getElementById("input-text").addEventListener("mousemove", this.highlighter.inputMousemove.bind(this.highlighter)); + this.addMultiEventListener("#input-text", "mousedown dblclick select", this.highlighter.inputMousedown, this.highlighter); + document.querySelector("#input-file .close").addEventListener("click", this.input.clearIoClick.bind(this.input)); + + // Output + document.getElementById("save-to-file").addEventListener("click", this.output.saveClick.bind(this.output)); + document.getElementById("copy-output").addEventListener("click", this.output.copyClick.bind(this.output)); + document.getElementById("switch").addEventListener("click", this.output.switchClick.bind(this.output)); + document.getElementById("undo-switch").addEventListener("click", this.output.undoSwitchClick.bind(this.output)); + document.getElementById("maximise-output").addEventListener("click", this.output.maximiseOutputClick.bind(this.output)); + document.getElementById("output-text").addEventListener("scroll", this.highlighter.outputScroll.bind(this.highlighter)); + document.getElementById("output-text").addEventListener("mouseup", this.highlighter.outputMouseup.bind(this.highlighter)); + document.getElementById("output-text").addEventListener("mousemove", this.highlighter.outputMousemove.bind(this.highlighter)); + document.getElementById("output-html").addEventListener("mouseup", this.highlighter.outputHtmlMouseup.bind(this.highlighter)); + document.getElementById("output-html").addEventListener("mousemove", this.highlighter.outputHtmlMousemove.bind(this.highlighter)); + this.addMultiEventListener("#output-text", "mousedown dblclick select", this.highlighter.outputMousedown, this.highlighter); + this.addMultiEventListener("#output-html", "mousedown dblclick select", this.highlighter.outputHtmlMousedown, this.highlighter); + this.addDynamicListener("#output-file-download", "click", this.output.downloadFile, this.output); + this.addDynamicListener("#output-file-slice", "click", this.output.displayFileSlice, this.output); + document.getElementById("show-file-overlay").addEventListener("click", this.output.showFileOverlayClick.bind(this.output)); + + // Options + document.getElementById("options").addEventListener("click", this.options.optionsClick.bind(this.options)); + document.getElementById("reset-options").addEventListener("click", this.options.resetOptionsClick.bind(this.options)); + $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.switchChange.bind(this.options)); + $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.setWordWrap.bind(this.options)); + $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox#useMetaKey", this.bindings.updateKeybList.bind(this.bindings)); + this.addDynamicListener(".option-item input[type=number]", "keyup", this.options.numberChange, this.options); + this.addDynamicListener(".option-item input[type=number]", "change", this.options.numberChange, this.options); + this.addDynamicListener(".option-item select", "change", this.options.selectChange, this.options); + document.getElementById("theme").addEventListener("change", this.options.themeChange.bind(this.options)); + document.getElementById("logLevel").addEventListener("change", this.options.logLevelChange.bind(this.options)); + + // Misc + window.addEventListener("keydown", this.bindings.parseInput.bind(this.bindings)); + document.getElementById("alert-close").addEventListener("click", this.app.alertCloseClick.bind(this.app)); + } + + + /** + * Adds an event listener to each element in the specified group. + * + * @param {string} selector - A selector string for the element group to add the event to, see + * this.getAll() + * @param {string} eventType - The event to listen for + * @param {function} callback - The function to execute when the event is triggered + * @param {Object} [scope=this] - The object to bind to the callback function + * + * @example + * // Calls the clickable function whenever any element with the .clickable class is clicked + * this.addListeners(".clickable", "click", this.clickable, this); + */ + addListeners(selector, eventType, callback, scope) { + scope = scope || this; + [].forEach.call(document.querySelectorAll(selector), function(el) { + el.addEventListener(eventType, callback.bind(scope)); + }); + } + + + /** + * Adds multiple event listeners to the specified element. + * + * @param {string} selector - A selector string for the element to add the events to + * @param {string} eventTypes - A space-separated string of all the event types to listen for + * @param {function} callback - The function to execute when the events are triggered + * @param {Object} [scope=this] - The object to bind to the callback function + * + * @example + * // Calls the search function whenever the the keyup, paste or search events are triggered on the + * // search element + * this.addMultiEventListener("search", "keyup paste search", this.search, this); + */ + addMultiEventListener(selector, eventTypes, callback, scope) { + const evs = eventTypes.split(" "); + for (let i = 0; i < evs.length; i++) { + document.querySelector(selector).addEventListener(evs[i], callback.bind(scope)); + } + } + + + /** + * Adds multiple event listeners to each element in the specified group. + * + * @param {string} selector - A selector string for the element group to add the events to + * @param {string} eventTypes - A space-separated string of all the event types to listen for + * @param {function} callback - The function to execute when the events are triggered + * @param {Object} [scope=this] - The object to bind to the callback function + * + * @example + * // Calls the save function whenever the the keyup or paste events are triggered on any element + * // with the .saveable class + * this.addMultiEventListener(".saveable", "keyup paste", this.save, this); + */ + addMultiEventListeners(selector, eventTypes, callback, scope) { + const evs = eventTypes.split(" "); + for (let i = 0; i < evs.length; i++) { + this.addListeners(selector, evs[i], callback, scope); + } + } + + + /** + * Adds an event listener to the global document object which will listen on dynamic elements which + * may not exist in the DOM yet. + * + * @param {string} selector - A selector string for the element(s) to add the event to + * @param {string} eventType - The event(s) to listen for + * @param {function} callback - The function to execute when the event(s) is/are triggered + * @param {Object} [scope=this] - The object to bind to the callback function + * + * @example + * // Pops up an alert whenever any button is clicked, even if it is added to the DOM after this + * // listener is created + * this.addDynamicListener("button", "click", alert, this); + */ + addDynamicListener(selector, eventType, callback, scope) { + const eventConfig = { + selector: selector, + callback: callback.bind(scope || this) + }; + + if (this.dynamicHandlers.hasOwnProperty(eventType)) { + // Listener already exists, add new handler to the appropriate list + this.dynamicHandlers[eventType].push(eventConfig); + } else { + this.dynamicHandlers[eventType] = [eventConfig]; + // Set up listener for this new type + document.addEventListener(eventType, this.dynamicListenerHandler.bind(this)); + } + } + + + /** + * Handler for dynamic events. This function is called for any dynamic event and decides which + * callback(s) to execute based on the type and selector. + * + * @param {Event} e - The event to be handled + */ + dynamicListenerHandler(e) { + const { type, target } = e; + const handlers = this.dynamicHandlers[type]; + const matches = target.matches || + target.webkitMatchesSelector || + target.mozMatchesSelector || + target.msMatchesSelector || + target.oMatchesSelector; + + for (let i = 0; i < handlers.length; i++) { + if (matches && matches.call(target, handlers[i].selector)) { + handlers[i].callback(e); + } + } + } + +} + +export default Manager; diff --git a/src/web/OperationsWaiter.js b/src/web/OperationsWaiter.js deleted file mode 100755 index 48bbe158..00000000 --- a/src/web/OperationsWaiter.js +++ /dev/null @@ -1,313 +0,0 @@ -import HTMLOperation from "./HTMLOperation.js"; -import Sortable from "sortablejs"; - - -/** - * Waiter to handle events related to the operations. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const OperationsWaiter = function(app, manager) { - this.app = app; - this.manager = manager; - - this.options = {}; - this.removeIntent = false; -}; - - -/** - * Handler for search events. - * Finds operations which match the given search term and displays them under the search box. - * - * @param {event} e - */ -OperationsWaiter.prototype.searchOperations = function(e) { - let ops, selected; - - if (e.type === "search") { // Search - e.preventDefault(); - ops = document.querySelectorAll("#search-results li"); - if (ops.length) { - selected = this.getSelectedOp(ops); - if (selected > -1) { - this.manager.recipe.addOperation(ops[selected].innerHTML); - } - } - } - - if (e.keyCode === 13) { // Return - e.preventDefault(); - } else if (e.keyCode === 40) { // Down - e.preventDefault(); - ops = document.querySelectorAll("#search-results li"); - if (ops.length) { - selected = this.getSelectedOp(ops); - if (selected > -1) { - ops[selected].classList.remove("selected-op"); - } - if (selected === ops.length-1) selected = -1; - ops[selected+1].classList.add("selected-op"); - } - } else if (e.keyCode === 38) { // Up - e.preventDefault(); - ops = document.querySelectorAll("#search-results li"); - if (ops.length) { - selected = this.getSelectedOp(ops); - if (selected > -1) { - ops[selected].classList.remove("selected-op"); - } - if (selected === 0) selected = ops.length; - ops[selected-1].classList.add("selected-op"); - } - } else { - const searchResultsEl = document.getElementById("search-results"); - const el = e.target; - const str = el.value; - - while (searchResultsEl.firstChild) { - try { - $(searchResultsEl.firstChild).popover("destroy"); - } catch (err) {} - searchResultsEl.removeChild(searchResultsEl.firstChild); - } - - $("#categories .in").collapse("hide"); - if (str) { - const matchedOps = this.filterOperations(str, true); - const matchedOpsHtml = matchedOps - .map(v => v.toStubHtml()) - .join(""); - - searchResultsEl.innerHTML = matchedOpsHtml; - searchResultsEl.dispatchEvent(this.manager.oplistcreate); - } - } -}; - - -/** - * Filters operations based on the search string and returns the matching ones. - * - * @param {string} searchStr - * @param {boolean} highlight - Whether or not to highlight the matching string in the operation - * name and description - * @returns {string[]} - */ -OperationsWaiter.prototype.filterOperations = function(inStr, highlight) { - const matchedOps = []; - const matchedDescs = []; - - const searchStr = inStr.toLowerCase(); - - for (const opName in this.app.operations) { - const op = this.app.operations[opName]; - const namePos = opName.toLowerCase().indexOf(searchStr); - const descPos = op.description.toLowerCase().indexOf(searchStr); - - if (namePos >= 0 || descPos >= 0) { - const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager); - if (highlight) { - operation.highlightSearchString(searchStr, namePos, descPos); - } - - if (namePos < 0) { - matchedOps.push(operation); - } else { - matchedDescs.push(operation); - } - } - } - - return matchedDescs.concat(matchedOps); -}; - - -/** - * Finds the operation which has been selected using keyboard shortcuts. This will have the class - * 'selected-op' set. Returns the index of the operation within the given list. - * - * @param {element[]} ops - * @returns {number} - */ -OperationsWaiter.prototype.getSelectedOp = function(ops) { - for (let i = 0; i < ops.length; i++) { - if (ops[i].classList.contains("selected-op")) { - return i; - } - } - return -1; -}; - - -/** - * Handler for oplistcreate events. - * - * @listens Manager#oplistcreate - * @param {event} e - */ -OperationsWaiter.prototype.opListCreate = function(e) { - this.manager.recipe.createSortableSeedList(e.target); - this.enableOpsListPopovers(e.target); -}; - - -/** - * Sets up popovers, allowing the popover itself to gain focus which enables scrolling - * and other interactions. - * - * @param {Element} el - The element to start selecting from - */ -OperationsWaiter.prototype.enableOpsListPopovers = function(el) { - $(el).find("[data-toggle=popover]").addBack("[data-toggle=popover]") - .popover({trigger: "manual"}) - .on("mouseenter", function(e) { - if (e.buttons > 0) return; // Mouse button held down - likely dragging an opertion - const _this = this; - $(this).popover("show"); - $(".popover").on("mouseleave", function () { - $(_this).popover("hide"); - }); - }).on("mouseleave", function () { - const _this = this; - setTimeout(function() { - // Determine if the popover associated with this element is being hovered over - if ($(_this).data("bs.popover") && - ($(_this).data("bs.popover").$tip && !$(_this).data("bs.popover").$tip.is(":hover"))) { - $(_this).popover("hide"); - } - }, 50); - }); -}; - - -/** - * Handler for operation doubleclick events. - * Adds the operation to the recipe and auto bakes. - * - * @param {event} e - */ -OperationsWaiter.prototype.operationDblclick = function(e) { - const li = e.target; - - this.manager.recipe.addOperation(li.textContent); -}; - - -/** - * Handler for edit favourites click events. - * Sets up the 'Edit favourites' pane and displays it. - * - * @param {event} e - */ -OperationsWaiter.prototype.editFavouritesClick = function(e) { - e.preventDefault(); - e.stopPropagation(); - - // Add favourites to modal - const favCat = this.app.categories.filter(function(c) { - return c.name === "Favourites"; - })[0]; - - let html = ""; - for (let i = 0; i < favCat.ops.length; i++) { - const opName = favCat.ops[i]; - const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager); - html += operation.toStubHtml(true); - } - - const editFavouritesList = document.getElementById("edit-favourites-list"); - editFavouritesList.innerHTML = html; - this.removeIntent = false; - - const editableList = Sortable.create(editFavouritesList, { - filter: ".remove-icon", - onFilter: function (evt) { - const el = editableList.closest(evt.item); - if (el && el.parentNode) { - $(el).popover("destroy"); - el.parentNode.removeChild(el); - } - }, - onEnd: function(evt) { - if (this.removeIntent) { - $(evt.item).popover("destroy"); - evt.item.remove(); - } - }.bind(this), - }); - - Sortable.utils.on(editFavouritesList, "dragleave", function() { - this.removeIntent = true; - }.bind(this)); - - Sortable.utils.on(editFavouritesList, "dragover", function() { - this.removeIntent = false; - }.bind(this)); - - $("#edit-favourites-list [data-toggle=popover]").popover(); - $("#favourites-modal").modal(); -}; - - -/** - * Handler for save favourites click events. - * Saves the selected favourites and reloads them. - */ -OperationsWaiter.prototype.saveFavouritesClick = function() { - const favs = document.querySelectorAll("#edit-favourites-list li"); - const favouritesList = Array.from(favs, e => e.textContent); - - this.app.saveFavourites(favouritesList); - this.app.loadFavourites(); - this.app.populateOperationsList(); - this.manager.recipe.initialiseOperationDragNDrop(); -}; - - -/** - * Handler for reset favourites click events. - * Resets favourites to their defaults. - */ -OperationsWaiter.prototype.resetFavouritesClick = function() { - this.app.resetFavourites(); -}; - - -/** - * Handler for opIcon mouseover events. - * Hides any popovers already showing on the operation so that there aren't two at once. - * - * @param {event} e - */ -OperationsWaiter.prototype.opIconMouseover = function(e) { - const opEl = e.target.parentNode; - if (e.target.getAttribute("data-toggle") === "popover") { - $(opEl).popover("hide"); - } -}; - - -/** - * Handler for opIcon mouseleave events. - * If this icon created a popover and we're moving back to the operation element, display the - * operation popover again. - * - * @param {event} e - */ -OperationsWaiter.prototype.opIconMouseleave = function(e) { - const opEl = e.target.parentNode; - const toEl = e.toElement || e.relatedElement; - - if (e.target.getAttribute("data-toggle") === "popover" && toEl === opEl) { - $(opEl).popover("show"); - } -}; - -export default OperationsWaiter; diff --git a/src/web/OperationsWaiter.mjs b/src/web/OperationsWaiter.mjs new file mode 100755 index 00000000..d890cedc --- /dev/null +++ b/src/web/OperationsWaiter.mjs @@ -0,0 +1,321 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import HTMLOperation from "./HTMLOperation"; +import Sortable from "sortablejs"; + + +/** + * Waiter to handle events related to the operations. + */ +class OperationsWaiter { + + /** + * OperationsWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + + this.options = {}; + this.removeIntent = false; + } + + + /** + * Handler for search events. + * Finds operations which match the given search term and displays them under the search box. + * + * @param {event} e + */ + searchOperations(e) { + let ops, selected; + + if (e.type === "search") { // Search + e.preventDefault(); + ops = document.querySelectorAll("#search-results li"); + if (ops.length) { + selected = this.getSelectedOp(ops); + if (selected > -1) { + this.manager.recipe.addOperation(ops[selected].innerHTML); + } + } + } + + if (e.keyCode === 13) { // Return + e.preventDefault(); + } else if (e.keyCode === 40) { // Down + e.preventDefault(); + ops = document.querySelectorAll("#search-results li"); + if (ops.length) { + selected = this.getSelectedOp(ops); + if (selected > -1) { + ops[selected].classList.remove("selected-op"); + } + if (selected === ops.length-1) selected = -1; + ops[selected+1].classList.add("selected-op"); + } + } else if (e.keyCode === 38) { // Up + e.preventDefault(); + ops = document.querySelectorAll("#search-results li"); + if (ops.length) { + selected = this.getSelectedOp(ops); + if (selected > -1) { + ops[selected].classList.remove("selected-op"); + } + if (selected === 0) selected = ops.length; + ops[selected-1].classList.add("selected-op"); + } + } else { + const searchResultsEl = document.getElementById("search-results"); + const el = e.target; + const str = el.value; + + while (searchResultsEl.firstChild) { + try { + $(searchResultsEl.firstChild).popover("destroy"); + } catch (err) {} + searchResultsEl.removeChild(searchResultsEl.firstChild); + } + + $("#categories .in").collapse("hide"); + if (str) { + const matchedOps = this.filterOperations(str, true); + const matchedOpsHtml = matchedOps + .map(v => v.toStubHtml()) + .join(""); + + searchResultsEl.innerHTML = matchedOpsHtml; + searchResultsEl.dispatchEvent(this.manager.oplistcreate); + } + } + } + + + /** + * Filters operations based on the search string and returns the matching ones. + * + * @param {string} searchStr + * @param {boolean} highlight - Whether or not to highlight the matching string in the operation + * name and description + * @returns {string[]} + */ + filterOperations(inStr, highlight) { + const matchedOps = []; + const matchedDescs = []; + + const searchStr = inStr.toLowerCase(); + + for (const opName in this.app.operations) { + const op = this.app.operations[opName]; + const namePos = opName.toLowerCase().indexOf(searchStr); + const descPos = op.description.toLowerCase().indexOf(searchStr); + + if (namePos >= 0 || descPos >= 0) { + const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager); + if (highlight) { + operation.highlightSearchString(searchStr, namePos, descPos); + } + + if (namePos < 0) { + matchedOps.push(operation); + } else { + matchedDescs.push(operation); + } + } + } + + return matchedDescs.concat(matchedOps); + } + + + /** + * Finds the operation which has been selected using keyboard shortcuts. This will have the class + * 'selected-op' set. Returns the index of the operation within the given list. + * + * @param {element[]} ops + * @returns {number} + */ + getSelectedOp(ops) { + for (let i = 0; i < ops.length; i++) { + if (ops[i].classList.contains("selected-op")) { + return i; + } + } + return -1; + } + + + /** + * Handler for oplistcreate events. + * + * @listens Manager#oplistcreate + * @param {event} e + */ + opListCreate(e) { + this.manager.recipe.createSortableSeedList(e.target); + this.enableOpsListPopovers(e.target); + } + + + /** + * Sets up popovers, allowing the popover itself to gain focus which enables scrolling + * and other interactions. + * + * @param {Element} el - The element to start selecting from + */ + enableOpsListPopovers(el) { + $(el).find("[data-toggle=popover]").addBack("[data-toggle=popover]") + .popover({trigger: "manual"}) + .on("mouseenter", function(e) { + if (e.buttons > 0) return; // Mouse button held down - likely dragging an opertion + const _this = this; + $(this).popover("show"); + $(".popover").on("mouseleave", function () { + $(_this).popover("hide"); + }); + }).on("mouseleave", function () { + const _this = this; + setTimeout(function() { + // Determine if the popover associated with this element is being hovered over + if ($(_this).data("bs.popover") && + ($(_this).data("bs.popover").$tip && !$(_this).data("bs.popover").$tip.is(":hover"))) { + $(_this).popover("hide"); + } + }, 50); + }); + } + + + /** + * Handler for operation doubleclick events. + * Adds the operation to the recipe and auto bakes. + * + * @param {event} e + */ + operationDblclick(e) { + const li = e.target; + + this.manager.recipe.addOperation(li.textContent); + } + + + /** + * Handler for edit favourites click events. + * Sets up the 'Edit favourites' pane and displays it. + * + * @param {event} e + */ + editFavouritesClick(e) { + e.preventDefault(); + e.stopPropagation(); + + // Add favourites to modal + const favCat = this.app.categories.filter(function(c) { + return c.name === "Favourites"; + })[0]; + + let html = ""; + for (let i = 0; i < favCat.ops.length; i++) { + const opName = favCat.ops[i]; + const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager); + html += operation.toStubHtml(true); + } + + const editFavouritesList = document.getElementById("edit-favourites-list"); + editFavouritesList.innerHTML = html; + this.removeIntent = false; + + const editableList = Sortable.create(editFavouritesList, { + filter: ".remove-icon", + onFilter: function (evt) { + const el = editableList.closest(evt.item); + if (el && el.parentNode) { + $(el).popover("destroy"); + el.parentNode.removeChild(el); + } + }, + onEnd: function(evt) { + if (this.removeIntent) { + $(evt.item).popover("destroy"); + evt.item.remove(); + } + }.bind(this), + }); + + Sortable.utils.on(editFavouritesList, "dragleave", function() { + this.removeIntent = true; + }.bind(this)); + + Sortable.utils.on(editFavouritesList, "dragover", function() { + this.removeIntent = false; + }.bind(this)); + + $("#edit-favourites-list [data-toggle=popover]").popover(); + $("#favourites-modal").modal(); + } + + + /** + * Handler for save favourites click events. + * Saves the selected favourites and reloads them. + */ + saveFavouritesClick() { + const favs = document.querySelectorAll("#edit-favourites-list li"); + const favouritesList = Array.from(favs, e => e.textContent); + + this.app.saveFavourites(favouritesList); + this.app.loadFavourites(); + this.app.populateOperationsList(); + this.manager.recipe.initialiseOperationDragNDrop(); + } + + + /** + * Handler for reset favourites click events. + * Resets favourites to their defaults. + */ + resetFavouritesClick() { + this.app.resetFavourites(); + } + + + /** + * Handler for opIcon mouseover events. + * Hides any popovers already showing on the operation so that there aren't two at once. + * + * @param {event} e + */ + opIconMouseover(e) { + const opEl = e.target.parentNode; + if (e.target.getAttribute("data-toggle") === "popover") { + $(opEl).popover("hide"); + } + } + + + /** + * Handler for opIcon mouseleave events. + * If this icon created a popover and we're moving back to the operation element, display the + * operation popover again. + * + * @param {event} e + */ + opIconMouseleave(e) { + const opEl = e.target.parentNode; + const toEl = e.toElement || e.relatedElement; + + if (e.target.getAttribute("data-toggle") === "popover" && toEl === opEl) { + $(opEl).popover("show"); + } + } + +} + +export default OperationsWaiter; diff --git a/src/web/OptionsWaiter.js b/src/web/OptionsWaiter.mjs similarity index 100% rename from src/web/OptionsWaiter.js rename to src/web/OptionsWaiter.mjs diff --git a/src/web/OutputWaiter.js b/src/web/OutputWaiter.js deleted file mode 100755 index 6d2f3840..00000000 --- a/src/web/OutputWaiter.js +++ /dev/null @@ -1,441 +0,0 @@ -import Utils from "../core/Utils"; -import FileSaver from "file-saver"; - - -/** - * Waiter to handle events related to the output. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const OutputWaiter = function(app, manager) { - this.app = app; - this.manager = manager; - - this.dishBuffer = null; - this.dishStr = null; -}; - - -/** - * Gets the output string from the output textarea. - * - * @returns {string} - */ -OutputWaiter.prototype.get = function() { - return document.getElementById("output-text").value; -}; - - -/** - * Sets the output in the output textarea. - * - * @param {string|ArrayBuffer} data - The output string/HTML/ArrayBuffer - * @param {string} type - The data type of the output - * @param {number} duration - The length of time (ms) it took to generate the output - * @param {boolean} [preserveBuffer=false] - Whether to preserve the dishBuffer - */ -OutputWaiter.prototype.set = async function(data, type, duration, preserveBuffer) { - log.debug("Output type: " + type); - const outputText = document.getElementById("output-text"); - const outputHtml = document.getElementById("output-html"); - const outputFile = document.getElementById("output-file"); - const outputHighlighter = document.getElementById("output-highlighter"); - const inputHighlighter = document.getElementById("input-highlighter"); - let scriptElements, lines, length; - - if (!preserveBuffer) { - this.closeFile(); - this.dishStr = null; - document.getElementById("show-file-overlay").style.display = "none"; - } - - switch (type) { - case "html": - outputText.style.display = "none"; - outputHtml.style.display = "block"; - outputFile.style.display = "none"; - outputHighlighter.display = "none"; - inputHighlighter.display = "none"; - - outputText.value = ""; - outputHtml.innerHTML = data; - - // Execute script sections - scriptElements = outputHtml.querySelectorAll("script"); - for (let i = 0; i < scriptElements.length; i++) { - try { - eval(scriptElements[i].innerHTML); // eslint-disable-line no-eval - } catch (err) { - log.error(err); - } - } - - await this.getDishStr(); - length = this.dishStr.length; - lines = this.dishStr.count("\n") + 1; - break; - case "ArrayBuffer": - outputText.style.display = "block"; - outputHtml.style.display = "none"; - outputHighlighter.display = "none"; - inputHighlighter.display = "none"; - - outputText.value = ""; - outputHtml.innerHTML = ""; - length = data.byteLength; - - this.setFile(data); - break; - case "string": - default: - outputText.style.display = "block"; - outputHtml.style.display = "none"; - outputFile.style.display = "none"; - outputHighlighter.display = "block"; - inputHighlighter.display = "block"; - - outputText.value = Utils.printable(data, true); - outputHtml.innerHTML = ""; - - lines = data.count("\n") + 1; - length = data.length; - this.dishStr = data; - break; - } - - this.manager.highlighter.removeHighlights(); - this.setOutputInfo(length, lines, duration); -}; - - -/** - * Shows file details. - * - * @param {ArrayBuffer} buf - */ -OutputWaiter.prototype.setFile = function(buf) { - this.dishBuffer = buf; - const file = new File([buf], "output.dat"); - - // Display file overlay in output area with details - const fileOverlay = document.getElementById("output-file"), - fileSize = document.getElementById("output-file-size"); - - fileOverlay.style.display = "block"; - fileSize.textContent = file.size.toLocaleString() + " bytes"; - - // Display preview slice in the background - const outputText = document.getElementById("output-text"), - fileSlice = this.dishBuffer.slice(0, 4096); - - outputText.classList.add("blur"); - outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); -}; - - -/** - * Removes the output file and nulls its memory. - */ -OutputWaiter.prototype.closeFile = function() { - this.dishBuffer = null; - document.getElementById("output-file").style.display = "none"; - document.getElementById("output-text").classList.remove("blur"); -}; - - -/** - * Handler for file download events. - */ -OutputWaiter.prototype.downloadFile = async function() { - this.filename = window.prompt("Please enter a filename:", this.filename || "download.dat"); - await this.getDishBuffer(); - const file = new File([this.dishBuffer], this.filename); - if (this.filename) FileSaver.saveAs(file, this.filename, false); -}; - - -/** - * Handler for file slice display events. - */ -OutputWaiter.prototype.displayFileSlice = function() { - const startTime = new Date().getTime(), - showFileOverlay = document.getElementById("show-file-overlay"), - sliceFromEl = document.getElementById("output-file-slice-from"), - sliceToEl = document.getElementById("output-file-slice-to"), - sliceFrom = parseInt(sliceFromEl.value, 10), - sliceTo = parseInt(sliceToEl.value, 10), - str = Utils.arrayBufferToStr(this.dishBuffer.slice(sliceFrom, sliceTo)); - - document.getElementById("output-text").classList.remove("blur"); - showFileOverlay.style.display = "block"; - this.set(str, "string", new Date().getTime() - startTime, true); -}; - - -/** - * Handler for show file overlay events. - * - * @param {Event} e - */ -OutputWaiter.prototype.showFileOverlayClick = function(e) { - const outputFile = document.getElementById("output-file"), - showFileOverlay = e.target; - - document.getElementById("output-text").classList.add("blur"); - outputFile.style.display = "block"; - showFileOverlay.style.display = "none"; - this.setOutputInfo(this.dishBuffer.byteLength, null, 0); -}; - - -/** - * Displays information about the output. - * - * @param {number} length - The length of the current output string - * @param {number} lines - The number of the lines in the current output string - * @param {number} duration - The length of time (ms) it took to generate the output - */ -OutputWaiter.prototype.setOutputInfo = function(length, lines, duration) { - let width = length.toString().length; - width = width < 4 ? 4 : width; - - const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); - const timeStr = (duration.toString() + "ms").padStart(width, " ").replace(/ /g, " "); - - let msg = "time: " + timeStr + "
    length: " + lengthStr; - - if (typeof lines === "number") { - const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); - msg += "
    lines: " + linesStr; - } - - document.getElementById("output-info").innerHTML = msg; - document.getElementById("input-selection-info").innerHTML = ""; - document.getElementById("output-selection-info").innerHTML = ""; -}; - - -/** - * Adjusts the display properties of the output buttons so that they fit within the current width - * without wrapping or overflowing. - */ -OutputWaiter.prototype.adjustWidth = function() { - const output = document.getElementById("output"); - const saveToFile = document.getElementById("save-to-file"); - const copyOutput = document.getElementById("copy-output"); - const switchIO = document.getElementById("switch"); - const undoSwitch = document.getElementById("undo-switch"); - const maximiseOutput = document.getElementById("maximise-output"); - - if (output.clientWidth < 680) { - saveToFile.childNodes[1].nodeValue = ""; - copyOutput.childNodes[1].nodeValue = ""; - switchIO.childNodes[1].nodeValue = ""; - undoSwitch.childNodes[1].nodeValue = ""; - maximiseOutput.childNodes[1].nodeValue = ""; - } else { - saveToFile.childNodes[1].nodeValue = " Save to file"; - copyOutput.childNodes[1].nodeValue = " Copy output"; - switchIO.childNodes[1].nodeValue = " Move output to input"; - undoSwitch.childNodes[1].nodeValue = " Undo"; - maximiseOutput.childNodes[1].nodeValue = - maximiseOutput.getAttribute("title") === "Maximise" ? " Max" : " Restore"; - } -}; - - -/** - * Handler for save click events. - * Saves the current output to a file. - */ -OutputWaiter.prototype.saveClick = function() { - this.downloadFile(); -}; - - -/** - * Handler for copy click events. - * Copies the output to the clipboard. - */ -OutputWaiter.prototype.copyClick = async function() { - await this.getDishStr(); - - // Create invisible textarea to populate with the raw dish string (not the printable version that - // contains dots instead of the actual bytes) - const textarea = document.createElement("textarea"); - textarea.style.position = "fixed"; - textarea.style.top = 0; - textarea.style.left = 0; - textarea.style.width = 0; - textarea.style.height = 0; - textarea.style.border = "none"; - - textarea.value = this.dishStr; - document.body.appendChild(textarea); - - // Select and copy the contents of this textarea - let success = false; - try { - textarea.select(); - success = this.dishStr && document.execCommand("copy"); - } catch (err) { - success = false; - } - - if (success) { - this.app.alert("Copied raw output successfully.", "success", 2000); - } else { - this.app.alert("Sorry, the output could not be copied.", "danger", 2000); - } - - // Clean up - document.body.removeChild(textarea); -}; - - -/** - * Handler for switch click events. - * Moves the current output into the input textarea. - */ -OutputWaiter.prototype.switchClick = async function() { - this.switchOrigData = this.manager.input.get(); - document.getElementById("undo-switch").disabled = false; - if (this.dishBuffer) { - this.manager.input.setFile(new File([this.dishBuffer], "output.dat")); - this.manager.input.handleLoaderMessage({ - data: { - progress: 100, - fileBuffer: this.dishBuffer - } - }); - } else { - await this.getDishStr(); - this.app.setInput(this.dishStr); - } -}; - - -/** - * Handler for undo switch click events. - * Removes the output from the input and replaces the input that was removed. - */ -OutputWaiter.prototype.undoSwitchClick = function() { - this.app.setInput(this.switchOrigData); - document.getElementById("undo-switch").disabled = true; -}; - - -/** - * Handler for maximise output click events. - * Resizes the output frame to be as large as possible, or restores it to its original size. - */ -OutputWaiter.prototype.maximiseOutputClick = function(e) { - const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode; - - if (el.getAttribute("title") === "Maximise") { - this.app.columnSplitter.collapse(0); - this.app.columnSplitter.collapse(1); - this.app.ioSplitter.collapse(0); - - el.setAttribute("title", "Restore"); - el.innerHTML = " Restore"; - this.adjustWidth(); - } else { - el.setAttribute("title", "Maximise"); - el.innerHTML = " Max"; - this.app.resetLayout(); - } -}; - - -/** - * Shows or hides the loading icon. - * - * @param {boolean} value - */ -OutputWaiter.prototype.toggleLoader = function(value) { - const outputLoader = document.getElementById("output-loader"), - outputElement = document.getElementById("output-text"); - - if (value) { - this.manager.controls.hideStaleIndicator(); - this.bakingStatusTimeout = setTimeout(function() { - outputElement.disabled = true; - outputLoader.style.visibility = "visible"; - outputLoader.style.opacity = 1; - this.manager.controls.toggleBakeButtonFunction(true); - }.bind(this), 200); - } else { - clearTimeout(this.bakingStatusTimeout); - outputElement.disabled = false; - outputLoader.style.opacity = 0; - outputLoader.style.visibility = "hidden"; - this.manager.controls.toggleBakeButtonFunction(false); - this.setStatusMsg(""); - } -}; - - -/** - * Sets the baking status message value. - * - * @param {string} msg - */ -OutputWaiter.prototype.setStatusMsg = function(msg) { - const el = document.querySelector("#output-loader .loading-msg"); - - el.textContent = msg; -}; - - -/** - * Returns true if the output contains carriage returns - * - * @returns {boolean} - */ -OutputWaiter.prototype.containsCR = async function() { - await this.getDishStr(); - return this.dishStr.indexOf("\r") >= 0; -}; - - -/** - * Retrieves the current dish as a string, returning the cached version if possible. - * - * @returns {string} - */ -OutputWaiter.prototype.getDishStr = async function() { - if (this.dishStr) return this.dishStr; - - this.dishStr = await new Promise(resolve => { - this.manager.worker.getDishAs(this.app.dish, "string", r => { - resolve(r.value); - }); - }); - return this.dishStr; -}; - - -/** - * Retrieves the current dish as an ArrayBuffer, returning the cached version if possible. - * - * @returns {ArrayBuffer} - */ -OutputWaiter.prototype.getDishBuffer = async function() { - if (this.dishBuffer) return this.dishBuffer; - - this.dishBuffer = await new Promise(resolve => { - this.manager.worker.getDishAs(this.app.dish, "ArrayBuffer", r => { - resolve(r.value); - }); - }); - return this.dishBuffer; -}; - -export default OutputWaiter; diff --git a/src/web/OutputWaiter.mjs b/src/web/OutputWaiter.mjs new file mode 100755 index 00000000..166dde0f --- /dev/null +++ b/src/web/OutputWaiter.mjs @@ -0,0 +1,449 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import Utils from "../core/Utils"; +import FileSaver from "file-saver"; + + +/** + * Waiter to handle events related to the output. + */ +class OutputWaiter { + + /** + * OutputWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + + this.dishBuffer = null; + this.dishStr = null; + } + + + /** + * Gets the output string from the output textarea. + * + * @returns {string} + */ + get() { + return document.getElementById("output-text").value; + } + + + /** + * Sets the output in the output textarea. + * + * @param {string|ArrayBuffer} data - The output string/HTML/ArrayBuffer + * @param {string} type - The data type of the output + * @param {number} duration - The length of time (ms) it took to generate the output + * @param {boolean} [preserveBuffer=false] - Whether to preserve the dishBuffer + */ + async set(data, type, duration, preserveBuffer) { + log.debug("Output type: " + type); + const outputText = document.getElementById("output-text"); + const outputHtml = document.getElementById("output-html"); + const outputFile = document.getElementById("output-file"); + const outputHighlighter = document.getElementById("output-highlighter"); + const inputHighlighter = document.getElementById("input-highlighter"); + let scriptElements, lines, length; + + if (!preserveBuffer) { + this.closeFile(); + this.dishStr = null; + document.getElementById("show-file-overlay").style.display = "none"; + } + + switch (type) { + case "html": + outputText.style.display = "none"; + outputHtml.style.display = "block"; + outputFile.style.display = "none"; + outputHighlighter.display = "none"; + inputHighlighter.display = "none"; + + outputText.value = ""; + outputHtml.innerHTML = data; + + // Execute script sections + scriptElements = outputHtml.querySelectorAll("script"); + for (let i = 0; i < scriptElements.length; i++) { + try { + eval(scriptElements[i].innerHTML); // eslint-disable-line no-eval + } catch (err) { + log.error(err); + } + } + + await this.getDishStr(); + length = this.dishStr.length; + lines = this.dishStr.count("\n") + 1; + break; + case "ArrayBuffer": + outputText.style.display = "block"; + outputHtml.style.display = "none"; + outputHighlighter.display = "none"; + inputHighlighter.display = "none"; + + outputText.value = ""; + outputHtml.innerHTML = ""; + length = data.byteLength; + + this.setFile(data); + break; + case "string": + default: + outputText.style.display = "block"; + outputHtml.style.display = "none"; + outputFile.style.display = "none"; + outputHighlighter.display = "block"; + inputHighlighter.display = "block"; + + outputText.value = Utils.printable(data, true); + outputHtml.innerHTML = ""; + + lines = data.count("\n") + 1; + length = data.length; + this.dishStr = data; + break; + } + + this.manager.highlighter.removeHighlights(); + this.setOutputInfo(length, lines, duration); + } + + + /** + * Shows file details. + * + * @param {ArrayBuffer} buf + */ + setFile(buf) { + this.dishBuffer = buf; + const file = new File([buf], "output.dat"); + + // Display file overlay in output area with details + const fileOverlay = document.getElementById("output-file"), + fileSize = document.getElementById("output-file-size"); + + fileOverlay.style.display = "block"; + fileSize.textContent = file.size.toLocaleString() + " bytes"; + + // Display preview slice in the background + const outputText = document.getElementById("output-text"), + fileSlice = this.dishBuffer.slice(0, 4096); + + outputText.classList.add("blur"); + outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); + } + + + /** + * Removes the output file and nulls its memory. + */ + closeFile() { + this.dishBuffer = null; + document.getElementById("output-file").style.display = "none"; + document.getElementById("output-text").classList.remove("blur"); + } + + + /** + * Handler for file download events. + */ + async downloadFile() { + this.filename = window.prompt("Please enter a filename:", this.filename || "download.dat"); + await this.getDishBuffer(); + const file = new File([this.dishBuffer], this.filename); + if (this.filename) FileSaver.saveAs(file, this.filename, false); + } + + + /** + * Handler for file slice display events. + */ + displayFileSlice() { + const startTime = new Date().getTime(), + showFileOverlay = document.getElementById("show-file-overlay"), + sliceFromEl = document.getElementById("output-file-slice-from"), + sliceToEl = document.getElementById("output-file-slice-to"), + sliceFrom = parseInt(sliceFromEl.value, 10), + sliceTo = parseInt(sliceToEl.value, 10), + str = Utils.arrayBufferToStr(this.dishBuffer.slice(sliceFrom, sliceTo)); + + document.getElementById("output-text").classList.remove("blur"); + showFileOverlay.style.display = "block"; + this.set(str, "string", new Date().getTime() - startTime, true); + } + + + /** + * Handler for show file overlay events. + * + * @param {Event} e + */ + showFileOverlayClick(e) { + const outputFile = document.getElementById("output-file"), + showFileOverlay = e.target; + + document.getElementById("output-text").classList.add("blur"); + outputFile.style.display = "block"; + showFileOverlay.style.display = "none"; + this.setOutputInfo(this.dishBuffer.byteLength, null, 0); + } + + + /** + * Displays information about the output. + * + * @param {number} length - The length of the current output string + * @param {number} lines - The number of the lines in the current output string + * @param {number} duration - The length of time (ms) it took to generate the output + */ + setOutputInfo(length, lines, duration) { + let width = length.toString().length; + width = width < 4 ? 4 : width; + + const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); + const timeStr = (duration.toString() + "ms").padStart(width, " ").replace(/ /g, " "); + + let msg = "time: " + timeStr + "
    length: " + lengthStr; + + if (typeof lines === "number") { + const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); + msg += "
    lines: " + linesStr; + } + + document.getElementById("output-info").innerHTML = msg; + document.getElementById("input-selection-info").innerHTML = ""; + document.getElementById("output-selection-info").innerHTML = ""; + } + + + /** + * Adjusts the display properties of the output buttons so that they fit within the current width + * without wrapping or overflowing. + */ + adjustWidth() { + const output = document.getElementById("output"); + const saveToFile = document.getElementById("save-to-file"); + const copyOutput = document.getElementById("copy-output"); + const switchIO = document.getElementById("switch"); + const undoSwitch = document.getElementById("undo-switch"); + const maximiseOutput = document.getElementById("maximise-output"); + + if (output.clientWidth < 680) { + saveToFile.childNodes[1].nodeValue = ""; + copyOutput.childNodes[1].nodeValue = ""; + switchIO.childNodes[1].nodeValue = ""; + undoSwitch.childNodes[1].nodeValue = ""; + maximiseOutput.childNodes[1].nodeValue = ""; + } else { + saveToFile.childNodes[1].nodeValue = " Save to file"; + copyOutput.childNodes[1].nodeValue = " Copy output"; + switchIO.childNodes[1].nodeValue = " Move output to input"; + undoSwitch.childNodes[1].nodeValue = " Undo"; + maximiseOutput.childNodes[1].nodeValue = + maximiseOutput.getAttribute("title") === "Maximise" ? " Max" : " Restore"; + } + } + + + /** + * Handler for save click events. + * Saves the current output to a file. + */ + saveClick() { + this.downloadFile(); + } + + + /** + * Handler for copy click events. + * Copies the output to the clipboard. + */ + async copyClick() { + await this.getDishStr(); + + // Create invisible textarea to populate with the raw dish string (not the printable version that + // contains dots instead of the actual bytes) + const textarea = document.createElement("textarea"); + textarea.style.position = "fixed"; + textarea.style.top = 0; + textarea.style.left = 0; + textarea.style.width = 0; + textarea.style.height = 0; + textarea.style.border = "none"; + + textarea.value = this.dishStr; + document.body.appendChild(textarea); + + // Select and copy the contents of this textarea + let success = false; + try { + textarea.select(); + success = this.dishStr && document.execCommand("copy"); + } catch (err) { + success = false; + } + + if (success) { + this.app.alert("Copied raw output successfully.", "success", 2000); + } else { + this.app.alert("Sorry, the output could not be copied.", "danger", 2000); + } + + // Clean up + document.body.removeChild(textarea); + } + + + /** + * Handler for switch click events. + * Moves the current output into the input textarea. + */ + async switchClick() { + this.switchOrigData = this.manager.input.get(); + document.getElementById("undo-switch").disabled = false; + if (this.dishBuffer) { + this.manager.input.setFile(new File([this.dishBuffer], "output.dat")); + this.manager.input.handleLoaderMessage({ + data: { + progress: 100, + fileBuffer: this.dishBuffer + } + }); + } else { + await this.getDishStr(); + this.app.setInput(this.dishStr); + } + } + + + /** + * Handler for undo switch click events. + * Removes the output from the input and replaces the input that was removed. + */ + undoSwitchClick() { + this.app.setInput(this.switchOrigData); + document.getElementById("undo-switch").disabled = true; + } + + + /** + * Handler for maximise output click events. + * Resizes the output frame to be as large as possible, or restores it to its original size. + */ + maximiseOutputClick(e) { + const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode; + + if (el.getAttribute("title") === "Maximise") { + this.app.columnSplitter.collapse(0); + this.app.columnSplitter.collapse(1); + this.app.ioSplitter.collapse(0); + + el.setAttribute("title", "Restore"); + el.innerHTML = " Restore"; + this.adjustWidth(); + } else { + el.setAttribute("title", "Maximise"); + el.innerHTML = " Max"; + this.app.resetLayout(); + } + } + + + /** + * Shows or hides the loading icon. + * + * @param {boolean} value + */ + toggleLoader(value) { + const outputLoader = document.getElementById("output-loader"), + outputElement = document.getElementById("output-text"); + + if (value) { + this.manager.controls.hideStaleIndicator(); + this.bakingStatusTimeout = setTimeout(function() { + outputElement.disabled = true; + outputLoader.style.visibility = "visible"; + outputLoader.style.opacity = 1; + this.manager.controls.toggleBakeButtonFunction(true); + }.bind(this), 200); + } else { + clearTimeout(this.bakingStatusTimeout); + outputElement.disabled = false; + outputLoader.style.opacity = 0; + outputLoader.style.visibility = "hidden"; + this.manager.controls.toggleBakeButtonFunction(false); + this.setStatusMsg(""); + } + } + + + /** + * Sets the baking status message value. + * + * @param {string} msg + */ + setStatusMsg(msg) { + const el = document.querySelector("#output-loader .loading-msg"); + + el.textContent = msg; + } + + + /** + * Returns true if the output contains carriage returns + * + * @returns {boolean} + */ + async containsCR() { + await this.getDishStr(); + return this.dishStr.indexOf("\r") >= 0; + } + + + /** + * Retrieves the current dish as a string, returning the cached version if possible. + * + * @returns {string} + */ + async getDishStr() { + if (this.dishStr) return this.dishStr; + + this.dishStr = await new Promise(resolve => { + this.manager.worker.getDishAs(this.app.dish, "string", r => { + resolve(r.value); + }); + }); + return this.dishStr; + } + + + /** + * Retrieves the current dish as an ArrayBuffer, returning the cached version if possible. + * + * @returns {ArrayBuffer} + */ + async getDishBuffer() { + if (this.dishBuffer) return this.dishBuffer; + + this.dishBuffer = await new Promise(resolve => { + this.manager.worker.getDishAs(this.app.dish, "ArrayBuffer", r => { + resolve(r.value); + }); + }); + return this.dishBuffer; + } + +} + +export default OutputWaiter; diff --git a/src/web/RecipeWaiter.js b/src/web/RecipeWaiter.js deleted file mode 100755 index 40c4bb08..00000000 --- a/src/web/RecipeWaiter.js +++ /dev/null @@ -1,467 +0,0 @@ -import HTMLOperation from "./HTMLOperation.js"; -import Sortable from "sortablejs"; -import Utils from "../core/Utils"; - - -/** - * Waiter to handle events related to the recipe. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const RecipeWaiter = function(app, manager) { - this.app = app; - this.manager = manager; - this.removeIntent = false; -}; - - -/** - * Sets up the drag and drop capability for operations in the operations and recipe areas. - */ -RecipeWaiter.prototype.initialiseOperationDragNDrop = function() { - const recList = document.getElementById("rec-list"); - - // Recipe list - Sortable.create(recList, { - group: "recipe", - sort: true, - animation: 0, - delay: 0, - filter: ".arg-input,.arg", - preventOnFilter: false, - setData: function(dataTransfer, dragEl) { - dataTransfer.setData("Text", dragEl.querySelector(".arg-title").textContent); - }, - onEnd: function(evt) { - if (this.removeIntent) { - evt.item.remove(); - evt.target.dispatchEvent(this.manager.operationremove); - } - }.bind(this), - onSort: function(evt) { - if (evt.from.id === "rec-list") { - document.dispatchEvent(this.manager.statechange); - } - }.bind(this) - }); - - Sortable.utils.on(recList, "dragover", function() { - this.removeIntent = false; - }.bind(this)); - - Sortable.utils.on(recList, "dragleave", function() { - this.removeIntent = true; - this.app.progress = 0; - }.bind(this)); - - Sortable.utils.on(recList, "touchend", function(e) { - const loc = e.changedTouches[0]; - const target = document.elementFromPoint(loc.clientX, loc.clientY); - - this.removeIntent = !recList.contains(target); - }.bind(this)); - - // Favourites category - document.querySelector("#categories a").addEventListener("dragover", this.favDragover.bind(this)); - document.querySelector("#categories a").addEventListener("dragleave", this.favDragleave.bind(this)); - document.querySelector("#categories a").addEventListener("drop", this.favDrop.bind(this)); -}; - - -/** - * Creates a drag-n-droppable seed list of operations. - * - * @param {element} listEl - The list to initialise - */ -RecipeWaiter.prototype.createSortableSeedList = function(listEl) { - Sortable.create(listEl, { - group: { - name: "recipe", - pull: "clone", - put: false, - }, - sort: false, - setData: function(dataTransfer, dragEl) { - dataTransfer.setData("Text", dragEl.textContent); - }, - onStart: function(evt) { - // Removes popover element and event bindings from the dragged operation but not the - // event bindings from the one left in the operations list. Without manually removing - // these bindings, we cannot re-initialise the popover on the stub operation. - $(evt.item).popover("destroy").removeData("bs.popover").off("mouseenter").off("mouseleave"); - $(evt.clone).off(".popover").removeData("bs.popover"); - evt.item.setAttribute("data-toggle", "popover-disabled"); - }, - onEnd: this.opSortEnd.bind(this) - }); -}; - - -/** - * Handler for operation sort end events. - * Removes the operation from the list if it has been dropped outside. If not, adds it to the list - * at the appropriate place and initialises it. - * - * @fires Manager#operationadd - * @param {event} evt - */ -RecipeWaiter.prototype.opSortEnd = function(evt) { - if (this.removeIntent) { - if (evt.item.parentNode.id === "rec-list") { - evt.item.remove(); - } - return; - } - - // Reinitialise the popover on the original element in the ops list because for some reason it - // gets destroyed and recreated. - this.manager.ops.enableOpsListPopovers(evt.clone); - - if (evt.item.parentNode.id !== "rec-list") { - return; - } - - this.buildRecipeOperation(evt.item); - evt.item.dispatchEvent(this.manager.operationadd); -}; - - -/** - * Handler for favourite dragover events. - * If the element being dragged is an operation, displays a visual cue so that the user knows it can - * be dropped here. - * - * @param {event} e - */ -RecipeWaiter.prototype.favDragover = function(e) { - if (e.dataTransfer.effectAllowed !== "move") - return false; - - e.stopPropagation(); - e.preventDefault(); - if (e.target.className && e.target.className.indexOf("category-title") > -1) { - // Hovering over the a - e.target.classList.add("favourites-hover"); - } else if (e.target.parentNode.className && e.target.parentNode.className.indexOf("category-title") > -1) { - // Hovering over the Edit button - e.target.parentNode.classList.add("favourites-hover"); - } else if (e.target.parentNode.parentNode.className && e.target.parentNode.parentNode.className.indexOf("category-title") > -1) { - // Hovering over the image on the Edit button - e.target.parentNode.parentNode.classList.add("favourites-hover"); - } -}; - - -/** - * Handler for favourite dragleave events. - * Removes the visual cue. - * - * @param {event} e - */ -RecipeWaiter.prototype.favDragleave = function(e) { - e.stopPropagation(); - e.preventDefault(); - document.querySelector("#categories a").classList.remove("favourites-hover"); -}; - - -/** - * Handler for favourite drop events. - * Adds the dragged operation to the favourites list. - * - * @param {event} e - */ -RecipeWaiter.prototype.favDrop = function(e) { - e.stopPropagation(); - e.preventDefault(); - e.target.classList.remove("favourites-hover"); - - const opName = e.dataTransfer.getData("Text"); - this.app.addFavourite(opName); -}; - - -/** - * Handler for ingredient change events. - * - * @fires Manager#statechange - */ -RecipeWaiter.prototype.ingChange = function(e) { - window.dispatchEvent(this.manager.statechange); -}; - - -/** - * Handler for disable click events. - * Updates the icon status. - * - * @fires Manager#statechange - * @param {event} e - */ -RecipeWaiter.prototype.disableClick = function(e) { - const icon = e.target; - - if (icon.getAttribute("disabled") === "false") { - icon.setAttribute("disabled", "true"); - icon.classList.add("disable-icon-selected"); - icon.parentNode.parentNode.classList.add("disabled"); - } else { - icon.setAttribute("disabled", "false"); - icon.classList.remove("disable-icon-selected"); - icon.parentNode.parentNode.classList.remove("disabled"); - } - - this.app.progress = 0; - window.dispatchEvent(this.manager.statechange); -}; - - -/** - * Handler for breakpoint click events. - * Updates the icon status. - * - * @fires Manager#statechange - * @param {event} e - */ -RecipeWaiter.prototype.breakpointClick = function(e) { - const bp = e.target; - - if (bp.getAttribute("break") === "false") { - bp.setAttribute("break", "true"); - bp.classList.add("breakpoint-selected"); - } else { - bp.setAttribute("break", "false"); - bp.classList.remove("breakpoint-selected"); - } - - window.dispatchEvent(this.manager.statechange); -}; - - -/** - * Handler for operation doubleclick events. - * Removes the operation from the recipe and auto bakes. - * - * @fires Manager#statechange - * @param {event} e - */ -RecipeWaiter.prototype.operationDblclick = function(e) { - e.target.remove(); - this.opRemove(e); -}; - - -/** - * Handler for operation child doubleclick events. - * Removes the operation from the recipe. - * - * @fires Manager#statechange - * @param {event} e - */ -RecipeWaiter.prototype.operationChildDblclick = function(e) { - e.target.parentNode.remove(); - this.opRemove(e); -}; - - -/** - * Generates a configuration object to represent the current recipe. - * - * @returns {recipeConfig} - */ -RecipeWaiter.prototype.getConfig = function() { - const config = []; - let ingredients, ingList, disabled, bp, item; - const operations = document.querySelectorAll("#rec-list li.operation"); - - for (let i = 0; i < operations.length; i++) { - ingredients = []; - disabled = operations[i].querySelector(".disable-icon"); - bp = operations[i].querySelector(".breakpoint"); - ingList = operations[i].querySelectorAll(".arg"); - - for (let j = 0; j < ingList.length; j++) { - if (ingList[j].getAttribute("type") === "checkbox") { - // checkbox - ingredients[j] = ingList[j].checked; - } else if (ingList[j].classList.contains("toggle-string")) { - // toggleString - ingredients[j] = { - option: ingList[j].previousSibling.children[0].textContent.slice(0, -1), - string: ingList[j].value - }; - } else if (ingList[j].getAttribute("type") === "number") { - // number - ingredients[j] = parseFloat(ingList[j].value, 10); - } else { - // all others - ingredients[j] = ingList[j].value; - } - } - - item = { - op: operations[i].querySelector(".arg-title").textContent, - args: ingredients - }; - - if (disabled && disabled.getAttribute("disabled") === "true") { - item.disabled = true; - } - - if (bp && bp.getAttribute("break") === "true") { - item.breakpoint = true; - } - - config.push(item); - } - - return config; -}; - - -/** - * Moves or removes the breakpoint indicator in the recipe based on the position. - * - * @param {number} position - */ -RecipeWaiter.prototype.updateBreakpointIndicator = function(position) { - const operations = document.querySelectorAll("#rec-list li.operation"); - for (let i = 0; i < operations.length; i++) { - if (i === position) { - operations[i].classList.add("break"); - } else { - operations[i].classList.remove("break"); - } - } -}; - - -/** - * Given an operation stub element, this function converts it into a full recipe element with - * arguments. - * - * @param {element} el - The operation stub element from the operations pane - */ -RecipeWaiter.prototype.buildRecipeOperation = function(el) { - const opName = el.textContent; - const op = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager); - el.innerHTML = op.toFullHtml(); - - if (this.app.operations[opName].flowControl) { - el.classList.add("flow-control-op"); - } - - // Disable auto-bake if this is a manual op - if (op.manualBake && this.app.autoBake_) { - this.manager.controls.setAutoBake(false); - this.app.alert("Auto-Bake is disabled by default when using this operation.", "info", 5000); - } -}; - -/** - * Adds the specified operation to the recipe. - * - * @fires Manager#operationadd - * @param {string} name - The name of the operation to add - * @returns {element} - */ -RecipeWaiter.prototype.addOperation = function(name) { - const item = document.createElement("li"); - - item.classList.add("operation"); - item.innerHTML = name; - this.buildRecipeOperation(item); - document.getElementById("rec-list").appendChild(item); - - item.dispatchEvent(this.manager.operationadd); - return item; -}; - - -/** - * Removes all operations from the recipe. - * - * @fires Manager#operationremove - */ -RecipeWaiter.prototype.clearRecipe = function() { - const recList = document.getElementById("rec-list"); - while (recList.firstChild) { - recList.removeChild(recList.firstChild); - } - recList.dispatchEvent(this.manager.operationremove); -}; - - -/** - * Handler for operation dropdown events from toggleString arguments. - * Sets the selected option as the name of the button. - * - * @param {event} e - */ -RecipeWaiter.prototype.dropdownToggleClick = function(e) { - const el = e.target; - const button = el.parentNode.parentNode.previousSibling; - - button.innerHTML = el.textContent + " "; - this.ingChange(); -}; - - -/** - * Handler for operationadd events. - * - * @listens Manager#operationadd - * @fires Manager#statechange - * @param {event} e - */ -RecipeWaiter.prototype.opAdd = function(e) { - log.debug(`'${e.target.querySelector(".arg-title").textContent}' added to recipe`); - window.dispatchEvent(this.manager.statechange); -}; - - -/** - * Handler for operationremove events. - * - * @listens Manager#operationremove - * @fires Manager#statechange - * @param {event} e - */ -RecipeWaiter.prototype.opRemove = function(e) { - log.debug("Operation removed from recipe"); - window.dispatchEvent(this.manager.statechange); -}; - - -/** - * Sets register values. - * - * @param {number} opIndex - * @param {number} numPrevRegisters - * @param {string[]} registers - */ -RecipeWaiter.prototype.setRegisters = function(opIndex, numPrevRegisters, registers) { - const op = document.querySelector(`#rec-list .operation:nth-child(${opIndex + 1})`), - prevRegList = op.querySelector(".register-list"); - - // Remove previous div - if (prevRegList) prevRegList.remove(); - - const registerList = []; - for (let i = 0; i < registers.length; i++) { - registerList.push(`$R${numPrevRegisters + i} = ${Utils.escapeHtml(Utils.truncate(Utils.printable(registers[i]), 100))}`); - } - const registerListEl = `
    - ${registerList.join("
    ")} -
    `; - - op.insertAdjacentHTML("beforeend", registerListEl); -}; - -export default RecipeWaiter; diff --git a/src/web/RecipeWaiter.mjs b/src/web/RecipeWaiter.mjs new file mode 100755 index 00000000..60589766 --- /dev/null +++ b/src/web/RecipeWaiter.mjs @@ -0,0 +1,475 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import HTMLOperation from "./HTMLOperation"; +import Sortable from "sortablejs"; +import Utils from "../core/Utils"; + + +/** + * Waiter to handle events related to the recipe. + */ +class RecipeWaiter { + + /** + * RecipeWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + this.removeIntent = false; + } + + + /** + * Sets up the drag and drop capability for operations in the operations and recipe areas. + */ + initialiseOperationDragNDrop() { + const recList = document.getElementById("rec-list"); + + // Recipe list + Sortable.create(recList, { + group: "recipe", + sort: true, + animation: 0, + delay: 0, + filter: ".arg-input,.arg", + preventOnFilter: false, + setData: function(dataTransfer, dragEl) { + dataTransfer.setData("Text", dragEl.querySelector(".arg-title").textContent); + }, + onEnd: function(evt) { + if (this.removeIntent) { + evt.item.remove(); + evt.target.dispatchEvent(this.manager.operationremove); + } + }.bind(this), + onSort: function(evt) { + if (evt.from.id === "rec-list") { + document.dispatchEvent(this.manager.statechange); + } + }.bind(this) + }); + + Sortable.utils.on(recList, "dragover", function() { + this.removeIntent = false; + }.bind(this)); + + Sortable.utils.on(recList, "dragleave", function() { + this.removeIntent = true; + this.app.progress = 0; + }.bind(this)); + + Sortable.utils.on(recList, "touchend", function(e) { + const loc = e.changedTouches[0]; + const target = document.elementFromPoint(loc.clientX, loc.clientY); + + this.removeIntent = !recList.contains(target); + }.bind(this)); + + // Favourites category + document.querySelector("#categories a").addEventListener("dragover", this.favDragover.bind(this)); + document.querySelector("#categories a").addEventListener("dragleave", this.favDragleave.bind(this)); + document.querySelector("#categories a").addEventListener("drop", this.favDrop.bind(this)); + } + + + /** + * Creates a drag-n-droppable seed list of operations. + * + * @param {element} listEl - The list to initialise + */ + createSortableSeedList(listEl) { + Sortable.create(listEl, { + group: { + name: "recipe", + pull: "clone", + put: false, + }, + sort: false, + setData: function(dataTransfer, dragEl) { + dataTransfer.setData("Text", dragEl.textContent); + }, + onStart: function(evt) { + // Removes popover element and event bindings from the dragged operation but not the + // event bindings from the one left in the operations list. Without manually removing + // these bindings, we cannot re-initialise the popover on the stub operation. + $(evt.item).popover("destroy").removeData("bs.popover").off("mouseenter").off("mouseleave"); + $(evt.clone).off(".popover").removeData("bs.popover"); + evt.item.setAttribute("data-toggle", "popover-disabled"); + }, + onEnd: this.opSortEnd.bind(this) + }); + } + + + /** + * Handler for operation sort end events. + * Removes the operation from the list if it has been dropped outside. If not, adds it to the list + * at the appropriate place and initialises it. + * + * @fires Manager#operationadd + * @param {event} evt + */ + opSortEnd(evt) { + if (this.removeIntent) { + if (evt.item.parentNode.id === "rec-list") { + evt.item.remove(); + } + return; + } + + // Reinitialise the popover on the original element in the ops list because for some reason it + // gets destroyed and recreated. + this.manager.ops.enableOpsListPopovers(evt.clone); + + if (evt.item.parentNode.id !== "rec-list") { + return; + } + + this.buildRecipeOperation(evt.item); + evt.item.dispatchEvent(this.manager.operationadd); + } + + + /** + * Handler for favourite dragover events. + * If the element being dragged is an operation, displays a visual cue so that the user knows it can + * be dropped here. + * + * @param {event} e + */ + favDragover(e) { + if (e.dataTransfer.effectAllowed !== "move") + return false; + + e.stopPropagation(); + e.preventDefault(); + if (e.target.className && e.target.className.indexOf("category-title") > -1) { + // Hovering over the a + e.target.classList.add("favourites-hover"); + } else if (e.target.parentNode.className && e.target.parentNode.className.indexOf("category-title") > -1) { + // Hovering over the Edit button + e.target.parentNode.classList.add("favourites-hover"); + } else if (e.target.parentNode.parentNode.className && e.target.parentNode.parentNode.className.indexOf("category-title") > -1) { + // Hovering over the image on the Edit button + e.target.parentNode.parentNode.classList.add("favourites-hover"); + } + } + + + /** + * Handler for favourite dragleave events. + * Removes the visual cue. + * + * @param {event} e + */ + favDragleave(e) { + e.stopPropagation(); + e.preventDefault(); + document.querySelector("#categories a").classList.remove("favourites-hover"); + } + + + /** + * Handler for favourite drop events. + * Adds the dragged operation to the favourites list. + * + * @param {event} e + */ + favDrop(e) { + e.stopPropagation(); + e.preventDefault(); + e.target.classList.remove("favourites-hover"); + + const opName = e.dataTransfer.getData("Text"); + this.app.addFavourite(opName); + } + + + /** + * Handler for ingredient change events. + * + * @fires Manager#statechange + */ + ingChange(e) { + window.dispatchEvent(this.manager.statechange); + } + + + /** + * Handler for disable click events. + * Updates the icon status. + * + * @fires Manager#statechange + * @param {event} e + */ + disableClick(e) { + const icon = e.target; + + if (icon.getAttribute("disabled") === "false") { + icon.setAttribute("disabled", "true"); + icon.classList.add("disable-icon-selected"); + icon.parentNode.parentNode.classList.add("disabled"); + } else { + icon.setAttribute("disabled", "false"); + icon.classList.remove("disable-icon-selected"); + icon.parentNode.parentNode.classList.remove("disabled"); + } + + this.app.progress = 0; + window.dispatchEvent(this.manager.statechange); + } + + + /** + * Handler for breakpoint click events. + * Updates the icon status. + * + * @fires Manager#statechange + * @param {event} e + */ + breakpointClick(e) { + const bp = e.target; + + if (bp.getAttribute("break") === "false") { + bp.setAttribute("break", "true"); + bp.classList.add("breakpoint-selected"); + } else { + bp.setAttribute("break", "false"); + bp.classList.remove("breakpoint-selected"); + } + + window.dispatchEvent(this.manager.statechange); + } + + + /** + * Handler for operation doubleclick events. + * Removes the operation from the recipe and auto bakes. + * + * @fires Manager#statechange + * @param {event} e + */ + operationDblclick(e) { + e.target.remove(); + this.opRemove(e); + } + + + /** + * Handler for operation child doubleclick events. + * Removes the operation from the recipe. + * + * @fires Manager#statechange + * @param {event} e + */ + operationChildDblclick(e) { + e.target.parentNode.remove(); + this.opRemove(e); + } + + + /** + * Generates a configuration object to represent the current recipe. + * + * @returns {recipeConfig} + */ + getConfig() { + const config = []; + let ingredients, ingList, disabled, bp, item; + const operations = document.querySelectorAll("#rec-list li.operation"); + + for (let i = 0; i < operations.length; i++) { + ingredients = []; + disabled = operations[i].querySelector(".disable-icon"); + bp = operations[i].querySelector(".breakpoint"); + ingList = operations[i].querySelectorAll(".arg"); + + for (let j = 0; j < ingList.length; j++) { + if (ingList[j].getAttribute("type") === "checkbox") { + // checkbox + ingredients[j] = ingList[j].checked; + } else if (ingList[j].classList.contains("toggle-string")) { + // toggleString + ingredients[j] = { + option: ingList[j].previousSibling.children[0].textContent.slice(0, -1), + string: ingList[j].value + }; + } else if (ingList[j].getAttribute("type") === "number") { + // number + ingredients[j] = parseFloat(ingList[j].value, 10); + } else { + // all others + ingredients[j] = ingList[j].value; + } + } + + item = { + op: operations[i].querySelector(".arg-title").textContent, + args: ingredients + }; + + if (disabled && disabled.getAttribute("disabled") === "true") { + item.disabled = true; + } + + if (bp && bp.getAttribute("break") === "true") { + item.breakpoint = true; + } + + config.push(item); + } + + return config; + } + + + /** + * Moves or removes the breakpoint indicator in the recipe based on the position. + * + * @param {number} position + */ + updateBreakpointIndicator(position) { + const operations = document.querySelectorAll("#rec-list li.operation"); + for (let i = 0; i < operations.length; i++) { + if (i === position) { + operations[i].classList.add("break"); + } else { + operations[i].classList.remove("break"); + } + } + } + + + /** + * Given an operation stub element, this function converts it into a full recipe element with + * arguments. + * + * @param {element} el - The operation stub element from the operations pane + */ + buildRecipeOperation(el) { + const opName = el.textContent; + const op = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager); + el.innerHTML = op.toFullHtml(); + + if (this.app.operations[opName].flowControl) { + el.classList.add("flow-control-op"); + } + + // Disable auto-bake if this is a manual op + if (op.manualBake && this.app.autoBake_) { + this.manager.controls.setAutoBake(false); + this.app.alert("Auto-Bake is disabled by default when using this operation.", "info", 5000); + } + } + + /** + * Adds the specified operation to the recipe. + * + * @fires Manager#operationadd + * @param {string} name - The name of the operation to add + * @returns {element} + */ + addOperation(name) { + const item = document.createElement("li"); + + item.classList.add("operation"); + item.innerHTML = name; + this.buildRecipeOperation(item); + document.getElementById("rec-list").appendChild(item); + + item.dispatchEvent(this.manager.operationadd); + return item; + } + + + /** + * Removes all operations from the recipe. + * + * @fires Manager#operationremove + */ + clearRecipe() { + const recList = document.getElementById("rec-list"); + while (recList.firstChild) { + recList.removeChild(recList.firstChild); + } + recList.dispatchEvent(this.manager.operationremove); + } + + + /** + * Handler for operation dropdown events from toggleString arguments. + * Sets the selected option as the name of the button. + * + * @param {event} e + */ + dropdownToggleClick(e) { + const el = e.target; + const button = el.parentNode.parentNode.previousSibling; + + button.innerHTML = el.textContent + " "; + this.ingChange(); + } + + + /** + * Handler for operationadd events. + * + * @listens Manager#operationadd + * @fires Manager#statechange + * @param {event} e + */ + opAdd(e) { + log.debug(`'${e.target.querySelector(".arg-title").textContent}' added to recipe`); + window.dispatchEvent(this.manager.statechange); + } + + + /** + * Handler for operationremove events. + * + * @listens Manager#operationremove + * @fires Manager#statechange + * @param {event} e + */ + opRemove(e) { + log.debug("Operation removed from recipe"); + window.dispatchEvent(this.manager.statechange); + } + + + /** + * Sets register values. + * + * @param {number} opIndex + * @param {number} numPrevRegisters + * @param {string[]} registers + */ + setRegisters(opIndex, numPrevRegisters, registers) { + const op = document.querySelector(`#rec-list .operation:nth-child(${opIndex + 1})`), + prevRegList = op.querySelector(".register-list"); + + // Remove previous div + if (prevRegList) prevRegList.remove(); + + const registerList = []; + for (let i = 0; i < registers.length; i++) { + registerList.push(`$R${numPrevRegisters + i} = ${Utils.escapeHtml(Utils.truncate(Utils.printable(registers[i]), 100))}`); + } + const registerListEl = `
    + ${registerList.join("
    ")} +
    `; + + op.insertAdjacentHTML("beforeend", registerListEl); + } + +} + +export default RecipeWaiter; diff --git a/src/web/SeasonalWaiter.js b/src/web/SeasonalWaiter.js deleted file mode 100755 index fb671024..00000000 --- a/src/web/SeasonalWaiter.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Waiter to handle seasonal events and easter eggs. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const SeasonalWaiter = function(app, manager) { - this.app = app; - this.manager = manager; -}; - - -/** - * Loads all relevant items depending on the current date. - */ -SeasonalWaiter.prototype.load = function() { - // Konami code - this.kkeys = []; - window.addEventListener("keydown", this.konamiCodeListener.bind(this)); -}; - - -/** - * Listen for the Konami code sequence of keys. Turn the page upside down if they are all heard in - * sequence. - * #konamicode - */ -SeasonalWaiter.prototype.konamiCodeListener = function(e) { - this.kkeys.push(e.keyCode); - const konami = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]; - for (let i = 0; i < this.kkeys.length; i++) { - if (this.kkeys[i] !== konami[i]) { - this.kkeys = []; - break; - } - if (i === konami.length - 1) { - $("body").children().toggleClass("konami"); - this.kkeys = []; - } - } -}; - -export default SeasonalWaiter; diff --git a/src/web/SeasonalWaiter.mjs b/src/web/SeasonalWaiter.mjs new file mode 100755 index 00000000..e6611a89 --- /dev/null +++ b/src/web/SeasonalWaiter.mjs @@ -0,0 +1,56 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +/** + * Waiter to handle seasonal events and easter eggs. + */ +class SeasonalWaiter { + + /** + * SeasonalWaiter contructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + } + + + /** + * Loads all relevant items depending on the current date. + */ + load() { + // Konami code + this.kkeys = []; + window.addEventListener("keydown", this.konamiCodeListener.bind(this)); + } + + + /** + * Listen for the Konami code sequence of keys. Turn the page upside down if they are all heard in + * sequence. + * #konamicode + */ + konamiCodeListener(e) { + this.kkeys.push(e.keyCode); + const konami = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]; + for (let i = 0; i < this.kkeys.length; i++) { + if (this.kkeys[i] !== konami[i]) { + this.kkeys = []; + break; + } + if (i === konami.length - 1) { + $("body").children().toggleClass("konami"); + this.kkeys = []; + } + } + } + +} + +export default SeasonalWaiter; diff --git a/src/web/WindowWaiter.js b/src/web/WindowWaiter.js deleted file mode 100755 index e0d3944c..00000000 --- a/src/web/WindowWaiter.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Waiter to handle events related to the window object. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - */ -const WindowWaiter = function(app) { - this.app = app; -}; - - -/** - * Handler for window resize events. - * Resets the layout of CyberChef's panes after 200ms (so that continuous resizing doesn't cause - * continuous resetting). - */ -WindowWaiter.prototype.windowResize = function() { - clearTimeout(this.resetLayoutTimeout); - this.resetLayoutTimeout = setTimeout(this.app.resetLayout.bind(this.app), 200); -}; - - -/** - * Handler for window blur events. - * Saves the current time so that we can calculate how long the window was unfocussed for when - * focus is returned. - */ -WindowWaiter.prototype.windowBlur = function() { - this.windowBlurTime = new Date().getTime(); -}; - - -/** - * Handler for window focus events. - * - * When a browser tab is unfocused and the browser has to run lots of dynamic content in other - * tabs, it swaps out the memory for that tab. - * If the CyberChef tab has been unfocused for more than a minute, we run a silent bake which will - * force the browser to load and cache all the relevant JavaScript code needed to do a real bake. - * This will stop baking taking a long time when the CyberChef browser tab has been unfocused for - * a long time and the browser has swapped out all its memory. - */ -WindowWaiter.prototype.windowFocus = function() { - const unfocusedTime = new Date().getTime() - this.windowBlurTime; - if (unfocusedTime > 60000) { - this.app.silentBake(); - } -}; - -export default WindowWaiter; diff --git a/src/web/WindowWaiter.mjs b/src/web/WindowWaiter.mjs new file mode 100755 index 00000000..a8e124f5 --- /dev/null +++ b/src/web/WindowWaiter.mjs @@ -0,0 +1,62 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +/** + * Waiter to handle events related to the window object. + */ +class WindowWaiter { + + /** + * WindowWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + */ + constructor(app) { + this.app = app; + } + + + /** + * Handler for window resize events. + * Resets the layout of CyberChef's panes after 200ms (so that continuous resizing doesn't cause + * continuous resetting). + */ + windowResize() { + clearTimeout(this.resetLayoutTimeout); + this.resetLayoutTimeout = setTimeout(this.app.resetLayout.bind(this.app), 200); + } + + + /** + * Handler for window blur events. + * Saves the current time so that we can calculate how long the window was unfocussed for when + * focus is returned. + */ + windowBlur() { + this.windowBlurTime = new Date().getTime(); + } + + + /** + * Handler for window focus events. + * + * When a browser tab is unfocused and the browser has to run lots of dynamic content in other + * tabs, it swaps out the memory for that tab. + * If the CyberChef tab has been unfocused for more than a minute, we run a silent bake which will + * force the browser to load and cache all the relevant JavaScript code needed to do a real bake. + * This will stop baking taking a long time when the CyberChef browser tab has been unfocused for + * a long time and the browser has swapped out all its memory. + */ + windowFocus() { + const unfocusedTime = new Date().getTime() - this.windowBlurTime; + if (unfocusedTime > 60000) { + this.app.silentBake(); + } + } + +} + +export default WindowWaiter; diff --git a/src/web/WorkerWaiter.js b/src/web/WorkerWaiter.js deleted file mode 100755 index 4f3bad1b..00000000 --- a/src/web/WorkerWaiter.js +++ /dev/null @@ -1,231 +0,0 @@ -import ChefWorker from "worker-loader?inline&fallback=false!../core/ChefWorker.js"; - -/** - * Waiter to handle conversations with the ChefWorker. - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2017 - * @license Apache-2.0 - * - * @constructor - * @param {App} app - The main view object for CyberChef. - * @param {Manager} manager - The CyberChef event manager. - */ -const WorkerWaiter = function(app, manager) { - this.app = app; - this.manager = manager; - - this.callbacks = {}; - this.callbackID = 0; -}; - - -/** - * Sets up the ChefWorker and associated listeners. - */ -WorkerWaiter.prototype.registerChefWorker = function() { - log.debug("Registering new ChefWorker"); - this.chefWorker = new ChefWorker(); - this.chefWorker.addEventListener("message", this.handleChefMessage.bind(this)); - this.setLogLevel(); - - let docURL = document.location.href.split(/[#?]/)[0]; - const index = docURL.lastIndexOf("/"); - if (index > 0) { - docURL = docURL.substring(0, index); - } - this.chefWorker.postMessage({"action": "docURL", "data": docURL}); -}; - - -/** - * Handler for messages sent back by the ChefWorker. - * - * @param {MessageEvent} e - */ -WorkerWaiter.prototype.handleChefMessage = function(e) { - const r = e.data; - log.debug("Receiving '" + r.action + "' from ChefWorker"); - - switch (r.action) { - case "bakeComplete": - this.bakingComplete(r.data); - break; - case "bakeError": - this.app.handleError(r.data); - this.setBakingStatus(false); - break; - case "dishReturned": - this.callbacks[r.data.id](r.data); - break; - case "silentBakeComplete": - break; - case "workerLoaded": - this.app.workerLoaded = true; - log.debug("ChefWorker loaded"); - this.app.loaded(); - break; - case "statusMessage": - this.manager.output.setStatusMsg(r.data); - break; - case "optionUpdate": - log.debug(`Setting ${r.data.option} to ${r.data.value}`); - this.app.options[r.data.option] = r.data.value; - break; - case "setRegisters": - this.manager.recipe.setRegisters(r.data.opIndex, r.data.numPrevRegisters, r.data.registers); - break; - case "highlightsCalculated": - this.manager.highlighter.displayHighlights(r.data.pos, r.data.direction); - break; - default: - log.error("Unrecognised message from ChefWorker", e); - break; - } -}; - - -/** - * Updates the UI to show if baking is in process or not. - * - * @param {bakingStatus} - */ -WorkerWaiter.prototype.setBakingStatus = function(bakingStatus) { - this.app.baking = bakingStatus; - - this.manager.output.toggleLoader(bakingStatus); -}; - - -/** - * Cancels the current bake by terminating the ChefWorker and creating a new one. - */ -WorkerWaiter.prototype.cancelBake = function() { - this.chefWorker.terminate(); - this.registerChefWorker(); - this.setBakingStatus(false); - this.manager.controls.showStaleIndicator(); -}; - - -/** - * Handler for completed bakes. - * - * @param {Object} response - */ -WorkerWaiter.prototype.bakingComplete = function(response) { - this.setBakingStatus(false); - - if (!response) return; - - if (response.error) { - this.app.handleError(response.error); - } - - this.app.progress = response.progress; - this.app.dish = response.dish; - this.manager.recipe.updateBreakpointIndicator(response.progress); - this.manager.output.set(response.result, response.type, response.duration); - log.debug("--- Bake complete ---"); -}; - - -/** - * Asks the ChefWorker to bake the current input using the current recipe. - * - * @param {string} input - * @param {Object[]} recipeConfig - * @param {Object} options - * @param {number} progress - * @param {boolean} step - */ -WorkerWaiter.prototype.bake = function(input, recipeConfig, options, progress, step) { - this.setBakingStatus(true); - - this.chefWorker.postMessage({ - action: "bake", - data: { - input: input, - recipeConfig: recipeConfig, - options: options, - progress: progress, - step: step - } - }); -}; - - -/** - * Asks the ChefWorker to run a silent bake, forcing the browser to load and cache all the relevant - * JavaScript code needed to do a real bake. - * - * @param {Object[]} [recipeConfig] - */ -WorkerWaiter.prototype.silentBake = function(recipeConfig) { - this.chefWorker.postMessage({ - action: "silentBake", - data: { - recipeConfig: recipeConfig - } - }); -}; - - -/** - * Asks the ChefWorker to calculate highlight offsets if possible. - * - * @param {Object[]} recipeConfig - * @param {string} direction - * @param {Object} pos - The position object for the highlight. - * @param {number} pos.start - The start offset. - * @param {number} pos.end - The end offset. - */ -WorkerWaiter.prototype.highlight = function(recipeConfig, direction, pos) { - this.chefWorker.postMessage({ - action: "highlight", - data: { - recipeConfig: recipeConfig, - direction: direction, - pos: pos - } - }); -}; - - -/** - * Asks the ChefWorker to return the dish as the specified type - * - * @param {Dish} dish - * @param {string} type - * @param {Function} callback - */ -WorkerWaiter.prototype.getDishAs = function(dish, type, callback) { - const id = this.callbackID++; - this.callbacks[id] = callback; - this.chefWorker.postMessage({ - action: "getDishAs", - data: { - dish: dish, - type: type, - id: id - } - }); -}; - - -/** - * Sets the console log level in the worker. - * - * @param {string} level - */ -WorkerWaiter.prototype.setLogLevel = function(level) { - if (!this.chefWorker) return; - - this.chefWorker.postMessage({ - action: "setLogLevel", - data: log.getLevel() - }); -}; - - -export default WorkerWaiter; diff --git a/src/web/WorkerWaiter.mjs b/src/web/WorkerWaiter.mjs new file mode 100755 index 00000000..7ef72263 --- /dev/null +++ b/src/web/WorkerWaiter.mjs @@ -0,0 +1,239 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2017 + * @license Apache-2.0 + */ + +import ChefWorker from "worker-loader?inline&fallback=false!../core/ChefWorker"; + +/** + * Waiter to handle conversations with the ChefWorker. + */ +class WorkerWaiter { + + /** + * WorkerWaiter constructor. + * + * @param {App} app - The main view object for CyberChef. + * @param {Manager} manager - The CyberChef event manager. + */ + constructor(app, manager) { + this.app = app; + this.manager = manager; + + this.callbacks = {}; + this.callbackID = 0; + } + + + /** + * Sets up the ChefWorker and associated listeners. + */ + registerChefWorker() { + log.debug("Registering new ChefWorker"); + this.chefWorker = new ChefWorker(); + this.chefWorker.addEventListener("message", this.handleChefMessage.bind(this)); + this.setLogLevel(); + + let docURL = document.location.href.split(/[#?]/)[0]; + const index = docURL.lastIndexOf("/"); + if (index > 0) { + docURL = docURL.substring(0, index); + } + this.chefWorker.postMessage({"action": "docURL", "data": docURL}); + } + + + /** + * Handler for messages sent back by the ChefWorker. + * + * @param {MessageEvent} e + */ + handleChefMessage(e) { + const r = e.data; + log.debug("Receiving '" + r.action + "' from ChefWorker"); + + switch (r.action) { + case "bakeComplete": + this.bakingComplete(r.data); + break; + case "bakeError": + this.app.handleError(r.data); + this.setBakingStatus(false); + break; + case "dishReturned": + this.callbacks[r.data.id](r.data); + break; + case "silentBakeComplete": + break; + case "workerLoaded": + this.app.workerLoaded = true; + log.debug("ChefWorker loaded"); + this.app.loaded(); + break; + case "statusMessage": + this.manager.output.setStatusMsg(r.data); + break; + case "optionUpdate": + log.debug(`Setting ${r.data.option} to ${r.data.value}`); + this.app.options[r.data.option] = r.data.value; + break; + case "setRegisters": + this.manager.recipe.setRegisters(r.data.opIndex, r.data.numPrevRegisters, r.data.registers); + break; + case "highlightsCalculated": + this.manager.highlighter.displayHighlights(r.data.pos, r.data.direction); + break; + default: + log.error("Unrecognised message from ChefWorker", e); + break; + } + } + + + /** + * Updates the UI to show if baking is in process or not. + * + * @param {bakingStatus} + */ + setBakingStatus(bakingStatus) { + this.app.baking = bakingStatus; + + this.manager.output.toggleLoader(bakingStatus); + } + + + /** + * Cancels the current bake by terminating the ChefWorker and creating a new one. + */ + cancelBake() { + this.chefWorker.terminate(); + this.registerChefWorker(); + this.setBakingStatus(false); + this.manager.controls.showStaleIndicator(); + } + + + /** + * Handler for completed bakes. + * + * @param {Object} response + */ + bakingComplete(response) { + this.setBakingStatus(false); + + if (!response) return; + + if (response.error) { + this.app.handleError(response.error); + } + + this.app.progress = response.progress; + this.app.dish = response.dish; + this.manager.recipe.updateBreakpointIndicator(response.progress); + this.manager.output.set(response.result, response.type, response.duration); + log.debug("--- Bake complete ---"); + } + + + /** + * Asks the ChefWorker to bake the current input using the current recipe. + * + * @param {string} input + * @param {Object[]} recipeConfig + * @param {Object} options + * @param {number} progress + * @param {boolean} step + */ + bake(input, recipeConfig, options, progress, step) { + this.setBakingStatus(true); + + this.chefWorker.postMessage({ + action: "bake", + data: { + input: input, + recipeConfig: recipeConfig, + options: options, + progress: progress, + step: step + } + }); + } + + + /** + * Asks the ChefWorker to run a silent bake, forcing the browser to load and cache all the relevant + * JavaScript code needed to do a real bake. + * + * @param {Object[]} [recipeConfig] + */ + silentBake(recipeConfig) { + this.chefWorker.postMessage({ + action: "silentBake", + data: { + recipeConfig: recipeConfig + } + }); + } + + + /** + * Asks the ChefWorker to calculate highlight offsets if possible. + * + * @param {Object[]} recipeConfig + * @param {string} direction + * @param {Object} pos - The position object for the highlight. + * @param {number} pos.start - The start offset. + * @param {number} pos.end - The end offset. + */ + highlight(recipeConfig, direction, pos) { + this.chefWorker.postMessage({ + action: "highlight", + data: { + recipeConfig: recipeConfig, + direction: direction, + pos: pos + } + }); + } + + + /** + * Asks the ChefWorker to return the dish as the specified type + * + * @param {Dish} dish + * @param {string} type + * @param {Function} callback + */ + getDishAs(dish, type, callback) { + const id = this.callbackID++; + this.callbacks[id] = callback; + this.chefWorker.postMessage({ + action: "getDishAs", + data: { + dish: dish, + type: type, + id: id + } + }); + } + + + /** + * Sets the console log level in the worker. + * + * @param {string} level + */ + setLogLevel(level) { + if (!this.chefWorker) return; + + this.chefWorker.postMessage({ + action: "setLogLevel", + data: log.getLevel() + }); + } + +} + + +export default WorkerWaiter; diff --git a/src/web/index.js b/src/web/index.js index 406958ee..9e7f045d 100755 --- a/src/web/index.js +++ b/src/web/index.js @@ -16,7 +16,7 @@ import moment from "moment-timezone"; import CanvasComponents from "../core/vendor/canvascomponents.js"; // CyberChef -import App from "./App.js"; +import App from "./App"; import Categories from "../core/config/Categories.json"; import OperationConfig from "../core/config/OperationConfig.json"; diff --git a/webpack.config.js b/webpack.config.js index 822c8561..4d463042 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -67,6 +67,7 @@ module.exports = { { test: /\.m?js$/, exclude: /node_modules\/(?!jsesc)/, + type: "javascript/auto", loader: "babel-loader?compact=false" }, {