Move waiters and workers into separate folders.

This commit is contained in:
j433866 2019-06-06 09:09:48 +01:00
parent 31a3af1f84
commit b77239fc15
16 changed files with 31 additions and 31 deletions

View file

@ -0,0 +1,157 @@
/**
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2018
* @license Apache-2.0
*/
import ChefWorker from "worker-loader?inline&fallback=false!../../core/ChefWorker";
/**
* Waiter to handle conversations with a ChefWorker in the background.
*/
class BackgroundWorkerWaiter {
/**
* BackgroundWorkerWaiter 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;
this.completedCallback = -1;
this.timeout = null;
}
/**
* Sets up the ChefWorker and associated listeners.
*/
registerChefWorker() {
log.debug("Registering new background ChefWorker");
this.chefWorker = new ChefWorker();
this.chefWorker.addEventListener("message", this.handleChefMessage.bind(this));
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 in the background");
switch (r.action) {
case "bakeComplete":
case "bakeError":
if (typeof r.data.id !== "undefined") {
clearTimeout(this.timeout);
this.callbacks[r.data.id].bind(this)(r.data);
this.completedCallback = r.data.id;
}
break;
case "workerLoaded":
log.debug("Background ChefWorker loaded");
break;
case "optionUpdate":
case "statusMessage":
// Ignore these messages
break;
default:
log.error("Unrecognised message from background ChefWorker", e);
break;
}
}
/**
* Cancels the current bake by terminating the ChefWorker and creating a new one.
*/
cancelBake() {
if (this.chefWorker)
this.chefWorker.terminate();
this.registerChefWorker();
}
/**
* Asks the ChefWorker to bake the input using the specified recipe.
*
* @param {string} input
* @param {Object[]} recipeConfig
* @param {Object} options
* @param {number} progress
* @param {boolean} step
* @param {Function} callback
*/
bake(input, recipeConfig, options, progress, step, callback) {
const id = this.callbackID++;
this.callbacks[id] = callback;
this.chefWorker.postMessage({
action: "bake",
data: {
input: input,
recipeConfig: recipeConfig,
options: options,
progress: progress,
step: step,
id: id
}
});
}
/**
* Asks the Magic operation what it can do with the input data.
*
* @param {string|ArrayBuffer} input
*/
magic(input) {
// If we're still working on the previous bake, cancel it before starting a new one.
if (this.completedCallback + 1 < this.callbackID) {
clearTimeout(this.timeout);
this.cancelBake();
}
this.bake(input, [
{
"op": "Magic",
"args": [3, false, false]
}
], {}, 0, false, this.magicComplete);
// Cancel this bake if it takes too long.
this.timeout = setTimeout(this.cancelBake.bind(this), 3000);
}
/**
* Handler for completed Magic bakes.
*
* @param {Object} response
*/
magicComplete(response) {
log.debug("--- Background Magic Bake complete ---");
if (!response || response.error) return;
this.manager.output.backgroundMagicResult(response.dish.value);
}
}
export default BackgroundWorkerWaiter;

View file

@ -0,0 +1,260 @@
/**
* @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.manager.controls.bakeClick();
break;
case "Quote": // Step through
e.preventDefault();
this.manager.controls.stepClick();
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;
case "KeyT": // New tab
e.preventDefault();
this.manager.input.addInputClick();
break;
case "KeyW": // Close tab
e.preventDefault();
this.manager.input.removeInput(this.manager.input.getActiveTab());
break;
case "ArrowLeft": // Go to previous tab
e.preventDefault();
this.manager.input.changeTabLeft();
break;
case "ArrowRight": // Go to next tab
e.preventDefault();
this.manager.input.changeTabRight();
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 = `
<tr>
<td><b>Command</b></td>
<td><b>Shortcut (Win/Linux)</b></td>
<td><b>Shortcut (Mac)</b></td>
</tr>
<tr>
<td>Place cursor in search field</td>
<td>Ctrl+${modWinLin}+f</td>
<td>Ctrl+${modMac}+f</td>
<tr>
<td>Place cursor in input box</td>
<td>Ctrl+${modWinLin}+i</td>
<td>Ctrl+${modMac}+i</td>
</tr>
<tr>
<td>Place cursor in output box</td>
<td>Ctrl+${modWinLin}+o</td>
<td>Ctrl+${modMac}+o</td>
</tr>
<tr>
<td>Place cursor in first argument field of the next operation in the recipe</td>
<td>Ctrl+${modWinLin}+.</td>
<td>Ctrl+${modMac}+.</td>
</tr>
<tr>
<td>Place cursor in first argument field of the nth operation in the recipe</td>
<td>Ctrl+${modWinLin}+[1-9]</td>
<td>Ctrl+${modMac}+[1-9]</td>
</tr>
<tr>
<td>Disable current operation</td>
<td>Ctrl+${modWinLin}+d</td>
<td>Ctrl+${modMac}+d</td>
</tr>
<tr>
<td>Set/clear breakpoint</td>
<td>Ctrl+${modWinLin}+b</td>
<td>Ctrl+${modMac}+b</td>
</tr>
<tr>
<td>Bake</td>
<td>Ctrl+${modWinLin}+Space</td>
<td>Ctrl+${modMac}+Space</td>
</tr>
<tr>
<td>Step</td>
<td>Ctrl+${modWinLin}+'</td>
<td>Ctrl+${modMac}+'</td>
</tr>
<tr>
<td>Clear recipe</td>
<td>Ctrl+${modWinLin}+c</td>
<td>Ctrl+${modMac}+c</td>
</tr>
<tr>
<td>Save to file</td>
<td>Ctrl+${modWinLin}+s</td>
<td>Ctrl+${modMac}+s</td>
</tr>
<tr>
<td>Load recipe</td>
<td>Ctrl+${modWinLin}+l</td>
<td>Ctrl+${modMac}+l</td>
</tr>
<tr>
<td>Move output to input</td>
<td>Ctrl+${modWinLin}+m</td>
<td>Ctrl+${modMac}+m</td>
</tr>
<tr>
<td>Create a new tab</td>
<td>Ctrl+${modWinLin}+t</td>
<td>Ctrl+${modMac}+t</td>
</tr>
<tr>
<td>Close the current tab</td>
<td>Ctrl+${modWinLin}+w</td>
<td>Ctrl+${modMac}+w</td>
</tr>
<tr>
<td>Go to next tab</td>
<td>Ctrl+${modWinLin}+RightArrow</td>
<td>Ctrl+${modMac}+RightArrow</td>
</tr>
<tr>
<td>Go to previous tab</td>
<td>Ctrl+${modWinLin}+LeftArrow</td>
<td>Ctrl+${modMac}+LeftArrow</td>
</tr>
`;
}
}
export default BindingsWaiter;

View file

@ -0,0 +1,426 @@
/**
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*/
import Utils from "../../core/Utils";
/**
* 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;
}
/**
* Initialise Bootstrap componenets
*/
initComponents() {
$("body").bootstrapMaterialDesign();
$("[data-toggle=tooltip]").tooltip({
animation: false,
container: "body",
boundary: "viewport",
trigger: "hover"
});
}
/**
* 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() {
const btnBake = document.getElementById("bake");
if (btnBake.textContent.indexOf("Bake") > 0) {
this.app.manager.input.bakeAll();
} else if (btnBake.textContent.indexOf("Cancel") > 0) {
this.manager.worker.cancelBake(false, true);
}
}
/**
* Handler for the 'Step through' command. Executes the next step of the recipe.
*/
async stepClick() {
if (this.app.baking) return;
// Reset status using cancelBake
this.manager.worker.cancelBake(true, false);
const activeTab = this.manager.input.getActiveTab();
let progress = 0;
if (this.manager.output.outputs[activeTab].progress !== false) {
progress = this.manager.output.outputs[activeTab].progress;
}
this.manager.input.inputWorker.postMessage({
action: "step",
data: {
activeTab: activeTab,
progress: progress + 1
}
});
}
/**
* Handler for changes made to the Auto Bake checkbox.
*/
autoBakeChange() {
this.app.autoBake_ = document.getElementById("auto-bake").checked;
}
/**
* Handler for the 'Clear recipe' command. Removes all operations from the recipe.
*/
clearRecipeClick() {
this.manager.recipe.clearRecipe();
}
/**
* Populates the save dialog 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 {string} input
* @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, input, recipeConfig, baseURL) {
recipeConfig = recipeConfig || this.app.getRecipeConfig();
const link = baseURL || window.location.protocol + "//" +
window.location.host +
window.location.pathname;
const recipeStr = Utils.generatePrettyRecipe(recipeConfig);
includeRecipe = includeRecipe && (recipeConfig.length > 0);
// If we don't get passed an input, get it from the current URI
if (input === null) {
const params = this.app.getURIParams();
if (params.input) {
includeInput = true;
input = params.input;
}
}
const params = [
includeRecipe ? ["recipe", recipeStr] : undefined,
includeInput ? ["input", input] : 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.",
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", 3000);
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}".`, 3000);
}
/**
* 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
const loadText = document.getElementById("load-text");
const evt = new Event("change");
loadText.value = savedRecipes.length ? savedRecipes[0].recipe : "";
loadText.dispatchEvent(evt);
}
/**
* 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", 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, null, "https://gchq.github.io/CyberChef/");
if (reportBugInfo) {
reportBugInfo.innerHTML = `* Version: ${PKG_VERSION}
* 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.classList.remove("hidden");
}
/**
* 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.classList.add("hidden");
}
/**
* Switches the Bake button between 'Bake', 'Cancel' and 'Loading' functions.
*
* @param {boolean} cancel - Whether to change to cancel or not
* @param {boolean} loading - Whether to change to loading or not
*/
toggleBakeButtonFunction(cancel, loading) {
const bakeButton = document.getElementById("bake"),
btnText = bakeButton.querySelector("span");
if (cancel) {
btnText.innerText = "Cancel";
bakeButton.classList.remove("btn-success");
bakeButton.classList.remove("btn-warning");
bakeButton.classList.add("btn-danger");
} else if (loading) {
bakeButton.style.background = "";
btnText.innerText = "Loading...";
bakeButton.classList.remove("btn-success");
bakeButton.classList.remove("btn-danger");
bakeButton.classList.add("btn-warning");
} else {
bakeButton.style.background = "";
btnText.innerText = "Bake!";
bakeButton.classList.remove("btn-danger");
bakeButton.classList.remove("btn-warning");
bakeButton.classList.add("btn-success");
}
}
}
export default ControlsWaiter;

View file

@ -0,0 +1,470 @@
/**
* @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, "&nbsp;");
const endStr = end.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
const lenStr = (end-start).toString().padStart(width, " ").replace(/ /g, "&nbsp;");
return "start: " + startStr + "<br>end: " + endStr + "<br>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;
if (this.manager.input.getActiveTab() !== this.manager.output.getActiveTab()) 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "&#10;")
// Convert placeholders to tags
.replace(startPlaceholderRegex, "<span class=\""+cssClass+"\">")
.replace(endPlaceholderRegex, "</span>") + "&nbsp;";
// 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;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,288 @@
/**
* @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" || e.keyCode === 13) { // Search or Return
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 === 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("dispose");
} catch (err) {}
searchResultsEl.removeChild(searchResultsEl.firstChild);
}
$("#categories .show").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("dispose");
el.parentNode.removeChild(el);
}
},
onEnd: function(evt) {
if (this.removeIntent) {
$(evt.item).popover("dispose");
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.childNodes[0].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();
}
}
export default OperationsWaiter;

174
src/web/waiters/OptionsWaiter.mjs Executable file
View file

@ -0,0 +1,174 @@
/**
* Waiter to handle events related to the CyberChef options.
*
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*
* @constructor
* @param {App} app - The main view object for CyberChef.
*/
const OptionsWaiter = function(app, manager) {
this.app = app;
this.manager = manager;
};
/**
* Loads options and sets values of switches and inputs to match them.
*
* @param {Object} options
*/
OptionsWaiter.prototype.load = function(options) {
for (const option in options) {
this.app.options[option] = options[option];
}
// Set options to match object
const cboxes = document.querySelectorAll("#options-body input[type=checkbox]");
let i;
for (i = 0; i < cboxes.length; i++) {
cboxes[i].checked = this.app.options[cboxes[i].getAttribute("option")];
}
const nboxes = document.querySelectorAll("#options-body input[type=number]");
for (i = 0; i < nboxes.length; i++) {
nboxes[i].value = this.app.options[nboxes[i].getAttribute("option")];
nboxes[i].dispatchEvent(new CustomEvent("change", {bubbles: true}));
}
const selects = document.querySelectorAll("#options-body select");
for (i = 0; i < selects.length; i++) {
const val = this.app.options[selects[i].getAttribute("option")];
if (val) {
selects[i].value = val;
selects[i].dispatchEvent(new CustomEvent("change", {bubbles: true}));
} else {
selects[i].selectedIndex = 0;
}
}
};
/**
* Handler for options click events.
* Dispays the options pane.
*
* @param {event} e
*/
OptionsWaiter.prototype.optionsClick = function(e) {
e.preventDefault();
$("#options-modal").modal();
};
/**
* Handler for reset options click events.
* Resets options back to their default values.
*/
OptionsWaiter.prototype.resetOptionsClick = function() {
this.load(this.app.doptions);
};
/**
* Handler for switch change events.
* Modifies the option state and saves it to local storage.
*
* @param {event} e
*/
OptionsWaiter.prototype.switchChange = function(e) {
const el = e.target;
const option = el.getAttribute("option");
const state = el.checked;
log.debug(`Setting ${option} to ${state}`);
this.app.options[option] = state;
if (this.app.isLocalStorageAvailable())
localStorage.setItem("options", JSON.stringify(this.app.options));
};
/**
* Handler for number change events.
* Modifies the option value and saves it to local storage.
*
* @param {event} e
*/
OptionsWaiter.prototype.numberChange = function(e) {
const el = e.target;
const option = el.getAttribute("option");
const val = parseInt(el.value, 10);
log.debug(`Setting ${option} to ${val}`);
this.app.options[option] = val;
if (this.app.isLocalStorageAvailable())
localStorage.setItem("options", JSON.stringify(this.app.options));
};
/**
* Handler for select change events.
* Modifies the option value and saves it to local storage.
*
* @param {event} e
*/
OptionsWaiter.prototype.selectChange = function(e) {
const el = e.target;
const option = el.getAttribute("option");
log.debug(`Setting ${option} to ${el.value}`);
this.app.options[option] = el.value;
if (this.app.isLocalStorageAvailable())
localStorage.setItem("options", JSON.stringify(this.app.options));
};
/**
* Sets or unsets word wrap on the input and output depending on the wordWrap option value.
*/
OptionsWaiter.prototype.setWordWrap = function() {
document.getElementById("input-text").classList.remove("word-wrap");
document.getElementById("output-text").classList.remove("word-wrap");
document.getElementById("output-html").classList.remove("word-wrap");
document.getElementById("input-highlighter").classList.remove("word-wrap");
document.getElementById("output-highlighter").classList.remove("word-wrap");
if (!this.app.options.wordWrap) {
document.getElementById("input-text").classList.add("word-wrap");
document.getElementById("output-text").classList.add("word-wrap");
document.getElementById("output-html").classList.add("word-wrap");
document.getElementById("input-highlighter").classList.add("word-wrap");
document.getElementById("output-highlighter").classList.add("word-wrap");
}
};
/**
* Changes the theme by setting the class of the <html> element.
*
* @param {Event} e
*/
OptionsWaiter.prototype.themeChange = function (e) {
const themeClass = e.target.value;
document.querySelector(":root").className = themeClass;
};
/**
* Changes the console logging level.
*
* @param {Event} e
*/
OptionsWaiter.prototype.logLevelChange = function (e) {
const level = e.target.value;
log.setLevel(level, false);
this.manager.worker.setLogLevel();
this.manager.input.setLogLevel();
};
export default OptionsWaiter;

1521
src/web/waiters/OutputWaiter.mjs Executable file

File diff suppressed because it is too large Load diff

619
src/web/waiters/RecipeWaiter.mjs Executable file
View file

@ -0,0 +1,619 @@
/**
* @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",
preventOnFilter: false,
setData: function(dataTransfer, dragEl) {
dataTransfer.setData("Text", dragEl.querySelector(".op-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("dispose")
.removeData("bs.popover")
.off("mouseenter")
.off("mouseleave")
.attr("data-toggle", "popover-disabled");
$(evt.clone)
.off(".popover")
.removeData("bs.popover");
},
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) {
if (e && e.target && e.target.classList.contains("no-state-change")) return;
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].parentNode.parentNode.querySelector("button").textContent,
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(".op-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|boolean} position - If boolean, turn off all indicators
*/
updateBreakpointIndicator(position) {
const operations = document.querySelectorAll("#rec-list li.operation");
if (typeof position === "boolean") position = operations.length;
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.", 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) {
e.stopPropagation();
e.preventDefault();
const el = e.target;
const button = el.parentNode.parentNode.querySelector("button");
button.innerHTML = el.textContent;
this.ingChange();
}
/**
* Triggers various change events for operation arguments that have just been initialised.
*
* @param {HTMLElement} op
*/
triggerArgEvents(op) {
// Trigger populateOption and argSelector events
const triggerableOptions = op.querySelectorAll(".populate-option, .arg-selector");
const evt = new Event("change", {bubbles: true});
if (triggerableOptions.length) {
for (const el of triggerableOptions) {
el.dispatchEvent(evt);
}
}
}
/**
* Handler for operationadd events.
*
* @listens Manager#operationadd
* @fires Manager#statechange
* @param {event} e
*/
opAdd(e) {
log.debug(`'${e.target.querySelector(".op-title").textContent}' added to recipe`);
this.triggerArgEvents(e.target);
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);
}
/**
* Handler for text argument dragover events.
* Gives the user a visual cue to show that items can be dropped here.
*
* @param {event} e
*/
textArgDragover (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("textarea.arg").classList.add("dropping-file");
}
/**
* Handler for text argument dragleave events.
* Removes the visual cue.
*
* @param {event} e
*/
textArgDragLeave (e) {
e.stopPropagation();
e.preventDefault();
e.target.classList.remove("dropping-file");
}
/**
* Handler for text argument drop events.
* Loads the dragged data into the argument textarea.
*
* @param {event} e
*/
textArgDrop(e) {
// This will be set if we're dragging an operation
if (e.dataTransfer.effectAllowed === "move")
return false;
e.stopPropagation();
e.preventDefault();
const targ = e.target;
const file = e.dataTransfer.files[0];
const text = e.dataTransfer.getData("Text");
targ.classList.remove("dropping-file");
if (text) {
targ.value = text;
return;
}
if (file) {
const reader = new FileReader();
const self = this;
reader.onload = function (e) {
targ.value = e.target.result;
// Trigger floating label move
const changeEvent = new Event("change");
targ.dispatchEvent(changeEvent);
window.dispatchEvent(self.manager.statechange);
};
reader.readAsText(file);
}
}
/**
* 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 = `<div class="register-list">
${registerList.join("<br>")}
</div>`;
op.insertAdjacentHTML("beforeend", registerListEl);
}
/**
* Adjusts the number of ingredient columns as the width of the recipe changes.
*/
adjustWidth() {
const recList = document.getElementById("rec-list");
if (!this.ingredientRuleID) {
this.ingredientRuleID = null;
this.ingredientChildRuleID = null;
// Find relevant rules in the stylesheet
// try/catch for chrome 64+ CORS error on cssRules.
try {
for (const i in document.styleSheets[0].cssRules) {
if (document.styleSheets[0].cssRules[i].selectorText === ".ingredients") {
this.ingredientRuleID = i;
}
if (document.styleSheets[0].cssRules[i].selectorText === ".ingredients > div") {
this.ingredientChildRuleID = i;
}
}
} catch (e) {
// Do nothing.
}
}
if (!this.ingredientRuleID || !this.ingredientChildRuleID) return;
const ingredientRule = document.styleSheets[0].cssRules[this.ingredientRuleID];
const ingredientChildRule = document.styleSheets[0].cssRules[this.ingredientChildRuleID];
if (recList.clientWidth < 450) {
ingredientRule.style.gridTemplateColumns = "auto auto";
ingredientChildRule.style.gridColumn = "1 / span 2";
} else if (recList.clientWidth < 620) {
ingredientRule.style.gridTemplateColumns = "auto auto auto";
ingredientChildRule.style.gridColumn = "1 / span 3";
} else {
ingredientRule.style.gridTemplateColumns = "auto auto auto auto";
ingredientChildRule.style.gridColumn = "1 / span 4";
}
}
}
export default RecipeWaiter;

View file

@ -0,0 +1,349 @@
/**
* @author n1474335 [n1474335@gmail.com]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*/
import clippy from "clippyjs";
import "../static/clippy_assets/agents/Clippy/agent.js";
import clippyMap from "../static/clippy_assets/agents/Clippy/map.png";
/**
* 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;
this.clippyAgent = null;
}
/**
* Loads all relevant items depending on the current date.
*/
load() {
// Konami code
this.kkeys = [];
window.addEventListener("keydown", this.konamiCodeListener.bind(this));
// Clippy
const now = new Date();
if (now.getMonth() === 3 && now.getDate() === 1) {
this.addClippyOption();
this.manager.addDynamicListener(".option-item #clippy", "change", this.setupClippy, this);
this.setupClippy();
}
}
/**
* 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 = [];
}
}
}
/**
* Creates an option in the Options menu for turning Clippy on or off
*/
addClippyOption() {
const optionsBody = document.getElementById("options-body"),
optionItem = document.createElement("span");
optionItem.className = "bmd-form-group is-filled";
optionItem.innerHTML = `<div class="checkbox option-item">
<label for="clippy">
<input type="checkbox" option="clippy" id="clippy" checked="">
Use the Clippy helper
</label>
</div>`;
optionsBody.appendChild(optionItem);
if (!this.app.options.hasOwnProperty("clippy")) {
this.app.options.clippy = true;
}
this.manager.options.load();
}
/**
* Sets up Clippy for April Fools Day
*/
setupClippy() {
// Destroy any previous agents
if (this.clippyAgent) {
this.clippyAgent.closeBalloonImmediately();
this.clippyAgent.hide();
}
if (!this.app.options.clippy) {
if (this.clippyTimeouts) this.clippyTimeouts.forEach(t => clearTimeout(t));
return;
}
// Set base path to # to prevent external network requests
const clippyAssets = "#";
// Shim the library to prevent external network requests
shimClippy(clippy);
const self = this;
clippy.load("Clippy", (agent) => {
shimClippyAgent(agent);
self.clippyAgent = agent;
agent.show();
agent.speak("Hello, I'm Clippy, your personal cyber assistant!");
}, undefined, clippyAssets);
// Watch for the Auto Magic button appearing
const magic = document.getElementById("magic");
const observer = new MutationObserver((mutationsList, observer) => {
// Read in message and recipe
let msg, recipe;
for (const mutation of mutationsList) {
if (mutation.attributeName === "data-original-title") {
msg = magic.getAttribute("data-original-title");
}
if (mutation.attributeName === "data-recipe") {
recipe = magic.getAttribute("data-recipe");
}
}
// Close balloon if it is currently showing a magic hint
const balloon = self.clippyAgent._balloon._balloon;
if (balloon.is(":visible") && balloon.text().indexOf("That looks like encoded data") >= 0) {
self.clippyAgent._balloon.hide(true);
this.clippyAgent._balloon._hidden = true;
}
// If a recipe was found, get Clippy to tell the user
if (recipe) {
recipe = this.manager.controls.generateStateUrl(true, true, JSON.parse(recipe));
msg = `That looks like encoded data!<br><br>${msg}<br><br>Click <a class="clippyMagicRecipe" href="${recipe}">here</a> to load this recipe.`;
// Stop current balloon activity immediately and trigger speak again
this.clippyAgent.closeBalloonImmediately();
self.clippyAgent.speak(msg, true);
// self.clippyAgent._queue.next();
}
});
observer.observe(document.getElementById("magic"), {attributes: true});
// Play animations for various things
this.manager.addListeners("#search", "click", () => {
this.clippyAgent.play("Searching");
}, this);
this.manager.addListeners("#save,#save-to-file", "click", () => {
this.clippyAgent.play("Save");
}, this);
this.manager.addListeners("#clr-recipe,#clr-io", "click", () => {
this.clippyAgent.play("EmptyTrash");
}, this);
this.manager.addListeners("#bake", "click", e => {
if (e.target.closest("button").textContent.toLowerCase().indexOf("bake") >= 0) {
this.clippyAgent.play("Thinking");
} else {
this.clippyAgent.play("EmptyTrash");
}
this.clippyAgent._queue.clear();
}, this);
this.manager.addListeners("#input-text", "keydown", () => {
this.clippyAgent.play("Writing");
this.clippyAgent._queue.clear();
}, this);
this.manager.addDynamicListener("a.clippyMagicRecipe", "click", (e) => {
this.clippyAgent.play("Congratulate");
}, this);
this.clippyTimeouts = [];
// Show challenge after timeout
this.clippyTimeouts.push(setTimeout(() => {
const hex = "1f 8b 08 00 ae a1 9b 5c 00 ff 05 40 a1 12 00 10 0c fd 26 61 5b 76 aa 9d 26 a8 02 02 37 84 f7 fb bb c5 a4 5f 22 c6 09 e5 6e c5 4c 2d 3f e9 30 a6 ea 41 a2 f2 ac 1c 00 00 00";
self.clippyAgent.speak(`How about a fun challenge?<br><br>Try decoding this (click to load):<br><a href="#recipe=[]&input=${encodeURIComponent(btoa(hex))}">${hex}</a>`, true);
self.clippyAgent.play("GetAttention");
}, 1 * 60 * 1000));
this.clippyTimeouts.push(setTimeout(() => {
self.clippyAgent.speak("<i>Did you know?</i><br><br>You can load files into CyberChef up to around 500MB using drag and drop or the load file button.", 15000);
self.clippyAgent.play("Wave");
}, 2 * 60 * 1000));
this.clippyTimeouts.push(setTimeout(() => {
self.clippyAgent.speak("<i>Did you know?</i><br><br>You can use the 'Fork' operation to split up your input and run the recipe over each branch separately.<br><br><a class='clippyMagicRecipe' href=\"#recipe=Fork('%5C%5Cn','%5C%5Cn',false)From_UNIX_Timestamp('Seconds%20(s)')&amp;input=OTc4MzQ2ODAwCjEwMTI2NTEyMDAKMTA0NjY5NjQwMAoxMDgxMDg3MjAwCjExMTUzMDUyMDAKMTE0OTYwOTYwMA\">Here's an example</a>.", 15000);
self.clippyAgent.play("Print");
}, 3 * 60 * 1000));
this.clippyTimeouts.push(setTimeout(() => {
self.clippyAgent.speak("<i>Did you know?</i><br><br>The 'Magic' operation uses a number of methods to detect encoded data and the operations which can be used to make sense of it. A technical description of these methods can be found <a href=\"https://github.com/gchq/CyberChef/wiki/Automatic-detection-of-encoded-data-using-CyberChef-Magic\">here</a>.", 15000);
self.clippyAgent.play("Alert");
}, 4 * 60 * 1000));
this.clippyTimeouts.push(setTimeout(() => {
self.clippyAgent.speak("<i>Did you know?</i><br><br>You can use parts of the input as arguments to operations.<br><br><a class='clippyMagicRecipe' href=\"#recipe=Register('key%3D(%5B%5C%5Cda-f%5D*)',true,false)Find_/_Replace(%7B'option':'Regex','string':'.*data%3D(.*)'%7D,'$1',true,false,true)RC4(%7B'option':'Hex','string':'$R0'%7D,'Hex','Latin1')&amp;input=aHR0cDovL21hbHdhcmV6LmJpei9iZWFjb24ucGhwP2tleT0wZTkzMmE1YyZkYXRhPThkYjdkNWViZTM4NjYzYTU0ZWNiYjMzNGUzZGIxMQ\">Click here for an example</a>.", 15000);
self.clippyAgent.play("CheckingSomething");
}, 5 * 60 * 1000));
}
}
/**
* Shims various ClippyJS functions to modify behaviour.
*
* @param {Clippy} clippy - The Clippy library
*/
function shimClippy(clippy) {
// Shim _loadSounds so that it doesn't actually try to load any sounds
clippy.load._loadSounds = function _loadSounds (name, path) {
let dfd = clippy.load._sounds[name];
if (dfd) return dfd;
// set dfd if not defined
dfd = clippy.load._sounds[name] = $.Deferred();
// Resolve immediately without loading
dfd.resolve({});
return dfd.promise();
};
// Shim _loadMap so that it uses the local copy
clippy.load._loadMap = function _loadMap (path) {
let dfd = clippy.load._maps[path];
if (dfd) return dfd;
// set dfd if not defined
dfd = clippy.load._maps[path] = $.Deferred();
const src = clippyMap;
const img = new Image();
img.onload = dfd.resolve;
img.onerror = dfd.reject;
// start loading the map;
img.setAttribute("src", src);
return dfd.promise();
};
// Make sure we don't request the remote map
clippy.Animator.prototype._setupElement = function _setupElement (el) {
const frameSize = this._data.framesize;
el.css("display", "none");
el.css({ width: frameSize[0], height: frameSize[1] });
el.css("background", "url('" + clippyMap + "') no-repeat");
return el;
};
}
/**
* Shims various ClippyJS Agent functions to modify behaviour.
*
* @param {Agent} agent - The Clippy Agent
*/
function shimClippyAgent(agent) {
// Turn off all sounds
agent._animator._playSound = () => {};
// Improve speak function to support HTML markup
const self = agent._balloon;
agent._balloon.speak = (complete, text, hold) => {
self._hidden = false;
self.show();
const c = self._content;
// set height to auto
c.height("auto");
c.width("auto");
// add the text
c.html(text);
// set height
c.height(c.height());
c.width(c.width());
c.text("");
self.reposition();
self._complete = complete;
self._sayWords(text, hold, complete);
if (hold) agent._queue.next();
};
// Improve the _sayWords function to allow HTML and support timeouts
agent._balloon.WORD_SPEAK_TIME = 60;
agent._balloon._sayWords = (text, hold, complete) => {
self._active = true;
self._hold = hold;
const words = text.split(/[^\S-]/);
const time = self.WORD_SPEAK_TIME;
const el = self._content;
let idx = 1;
clearTimeout(self.holdTimeout);
self._addWord = $.proxy(function () {
if (!self._active) return;
if (idx > words.length) {
delete self._addWord;
self._active = false;
if (!self._hold) {
complete();
self.hide();
} else if (typeof hold === "number") {
self.holdTimeout = setTimeout(() => {
self._hold = false;
complete();
self.hide();
}, hold);
}
} else {
el.html(words.slice(0, idx).join(" "));
idx++;
self._loop = window.setTimeout($.proxy(self._addWord, self), time);
}
}, self);
self._addWord();
};
// Add break-word to balloon CSS
agent._balloon._balloon.css("word-break", "break-word");
// Close the balloon on click (unless it was a link)
agent._balloon._balloon.click(e => {
if (e.target.nodeName !== "A") {
agent._balloon.hide(true);
agent._balloon._hidden = true;
}
});
// Add function to immediately close the balloon even if it is currently doing something
agent.closeBalloonImmediately = () => {
agent._queue.clear();
agent._balloon.hide(true);
agent._balloon._hidden = true;
agent._queue.next();
};
}
export default SeasonalWaiter;

View file

@ -0,0 +1,61 @@
/**
* @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() {
this.app.debounce(this.app.resetLayout, 200, "windowResize", this.app, [])();
}
/**
* 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;

View file

@ -0,0 +1,716 @@
/**
* @author n1474335 [n1474335@gmail.com]
* @author j433866 [j433866@gmail.com]
* @copyright Crown Copyright 2019
* @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.loaded = false;
this.chefWorkers = [];
this.dishWorker = null;
this.maxWorkers = navigator.hardwareConcurrency || 4;
this.inputs = [];
this.inputNums = [];
this.totalOutputs = 0;
this.loadingOutputs = 0;
this.bakeId = 0;
this.callbacks = {};
this.callbackID = 0;
}
/**
* Terminates any existing ChefWorkers and sets up a new worker
*/
setupChefWorker() {
for (let i = this.chefWorkers.length - 1; i >= 0; i--) {
this.removeChefWorker(this.chefWorkers[i]);
}
this.addChefWorker();
this.setupDishWorker();
}
/**
* Sets up a separate ChefWorker for performing dish operations.
* Using a separate worker so that we can run dish operations without
* affecting a bake which may be in progress.
*/
setupDishWorker() {
if (this.dishWorker !== null) {
this.dishWorker.terminate();
}
log.debug("Adding new ChefWorker (DishWorker)");
this.dishWorker = new ChefWorker();
this.dishWorker.addEventListener("message", this.handleChefMessage.bind(this));
let docURL = document.location.href.split(/[#?]/)[0];
const index = docURL.lastIndexOf("/");
if (index > 0) {
docURL = docURL.substring(0, index);
}
this.dishWorker.postMessage({"action": "docURL", "data": docURL});
this.dishWorker.postMessage({
action: "setLogLevel",
data: log.getLevel()
});
}
/**
* Adds a new ChefWorker
*
* @returns {number} The index of the created worker
*/
addChefWorker() {
if (this.chefWorkers.length === this.maxWorkers) {
// Can't create any more workers
return -1;
}
log.debug("Adding new ChefWorker");
// Create a new ChefWorker and send it the docURL
const newWorker = new ChefWorker();
newWorker.addEventListener("message", this.handleChefMessage.bind(this));
let docURL = document.location.href.split(/[#?]/)[0];
const index = docURL.lastIndexOf("/");
if (index > 0) {
docURL = docURL.substring(0, index);
}
newWorker.postMessage({"action": "docURL", "data": docURL});
newWorker.postMessage({
action: "setLogLevel",
data: log.getLevel()
});
// Store the worker, whether or not it's active, and the inputNum as an object
const newWorkerObj = {
worker: newWorker,
active: false,
inputNum: -1
};
this.chefWorkers.push(newWorkerObj);
return this.chefWorkers.indexOf(newWorkerObj);
}
/**
* Gets an inactive ChefWorker to be used for baking
*
* @param {boolean} [setActive=true] - If true, set the worker status to active
* @returns {number} - The index of the ChefWorker
*/
getInactiveChefWorker(setActive=true) {
for (let i = 0; i < this.chefWorkers.length; i++) {
if (!this.chefWorkers[i].active) {
this.chefWorkers[i].active = setActive;
return i;
}
}
return -1;
}
/**
* Removes a ChefWorker
*
* @param {Object} workerObj
*/
removeChefWorker(workerObj) {
const index = this.chefWorkers.indexOf(workerObj);
if (index === -1) {
return;
}
if (this.chefWorkers.length > 1 || this.chefWorkers[index].active) {
log.debug(`Removing ChefWorker at index ${index}`);
this.chefWorkers[index].worker.terminate();
this.chefWorkers.splice(index, 1);
}
// There should always be a ChefWorker loaded
if (this.chefWorkers.length === 0) {
this.addChefWorker();
}
}
/**
* Finds and returns the object for the ChefWorker of a given inputNum
*
* @param {number} inputNum
*/
getChefWorker(inputNum) {
for (let i = 0; i < this.chefWorkers.length; i++) {
if (this.chefWorkers[i].inputNum === inputNum) {
return this.chefWorkers[i];
}
}
}
/**
* Handler for messages sent back by the ChefWorkers
*
* @param {MessageEvent} e
*/
handleChefMessage(e) {
const r = e.data;
let inputNum = 0;
log.debug(`Receiving ${r.action} from ChefWorker.`);
if (r.data.hasOwnProperty("inputNum")) {
inputNum = r.data.inputNum;
}
const currentWorker = this.getChefWorker(inputNum);
switch (r.action) {
case "bakeComplete":
log.debug(`Bake ${inputNum} complete.`);
if (r.data.error) {
this.app.handleError(r.data.error);
this.manager.output.updateOutputError(r.data.error, inputNum, r.data.progress);
} else {
this.updateOutput(r.data, r.data.inputNum, r.data.bakeId, r.data.progress);
}
this.app.progress = r.data.progress;
if (r.data.progress === this.recipeConfig.length) {
this.step = false;
}
this.workerFinished(currentWorker);
break;
case "bakeError":
this.app.handleError(r.data.error);
this.manager.output.updateOutputError(r.data.error, inputNum, r.data.progress);
this.app.progress = r.data.progress;
this.workerFinished(currentWorker);
break;
case "dishReturned":
this.callbacks[r.data.id](r.data);
this.dishWorker.terminate();
this.dishWorker = null;
break;
case "silentBakeComplete":
break;
case "workerLoaded":
this.app.workerLoaded = true;
log.debug("ChefWorker loaded.");
if (!this.loaded) {
this.app.loaded();
this.loaded = true;
} else {
this.bakeNextInput(this.getInactiveChefWorker(false));
}
break;
case "statusMessage":
// Status message should be done per output
this.manager.output.updateOutputMessage(r.data.message, r.data.inputNum, true);
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;
}
}
/**
* Update the value of an output
*
* @param {Object} data
* @param {number} inputNum
* @param {number} bakeId
* @param {number} progress
*/
updateOutput(data, inputNum, bakeId, progress) {
this.manager.output.updateOutputBakeId(bakeId, inputNum);
if (progress === this.recipeConfig.length) {
progress = false;
}
this.manager.output.updateOutputProgress(progress, inputNum);
this.manager.output.updateOutputValue(data, inputNum, false);
this.manager.output.updateOutputStatus("baked", inputNum);
}
/**
* Updates the UI to show if baking is in process or not.
*
* @param {boolean} bakingStatus
*/
setBakingStatus(bakingStatus) {
this.app.baking = bakingStatus;
this.manager.controls.toggleBakeButtonFunction(bakingStatus);
if (bakingStatus) this.manager.output.hideMagicButton();
}
/**
* Get the progress of the ChefWorkers
*/
getBakeProgress() {
const pendingInputs = this.inputNums.length + this.loadingOutputs + this.inputs.length;
let bakingInputs = 0;
for (let i = 0; i < this.chefWorkers.length; i++) {
if (this.chefWorkers[i].active) {
bakingInputs++;
}
}
const total = this.totalOutputs;
const bakedInputs = total - pendingInputs - bakingInputs;
return {
total: total,
pending: pendingInputs,
baking: bakingInputs,
baked: bakedInputs
};
}
/**
* Cancels the current bake by terminating and removing all ChefWorkers
*
* @param {boolean} [silent=false] - If true, don't set the output
* @param {boolean} killAll - If true, kills all chefWorkers regardless of status
*/
cancelBake(silent, killAll) {
for (let i = this.chefWorkers.length - 1; i >= 0; i--) {
if (this.chefWorkers[i].active || killAll) {
const inputNum = this.chefWorkers[i].inputNum;
this.removeChefWorker(this.chefWorkers[i]);
this.manager.output.updateOutputStatus("inactive", inputNum);
}
}
this.setBakingStatus(false);
for (let i = 0; i < this.inputs.length; i++) {
this.manager.output.updateOutputStatus("inactive", this.inputs[i].inputNum);
}
for (let i = 0; i < this.inputNums.length; i++) {
this.manager.output.updateOutputStatus("inactive", this.inputNums[i]);
}
this.inputs = [];
this.inputNums = [];
this.totalOutputs = 0;
this.loadingOutputs = 0;
if (!silent) this.manager.output.set(this.manager.output.getActiveTab());
}
/**
* Handle a worker completing baking
*
* @param {object} workerObj - Object containing the worker information
* @param {ChefWorker} workerObj.worker - The actual worker object
* @param {number} workerObj.inputNum - The inputNum of the input being baked by the worker
* @param {boolean} workerObj.active - If true, the worker is currrently baking an input
*/
workerFinished(workerObj) {
const workerIdx = this.chefWorkers.indexOf(workerObj);
this.chefWorkers[workerIdx].active = false;
if (this.inputs.length > 0) {
this.bakeNextInput(workerIdx);
} else if (this.inputNums.length === 0 && this.loadingOutputs === 0) {
// The ChefWorker is no longer needed
log.debug("No more inputs to bake.");
const progress = this.getBakeProgress();
if (progress.total === progress.baked) {
this.bakingComplete();
}
}
}
/**
* Handler for completed bakes
*/
bakingComplete() {
this.setBakingStatus(false);
let duration = new Date().getTime() - this.bakeStartTime;
duration = duration.toLocaleString() + "ms";
const progress = this.getBakeProgress();
if (progress.total > 1) {
let width = progress.total.toLocaleString().length;
if (duration.length > width) {
width = duration.length;
}
width = width < 2 ? 2 : width;
const totalStr = progress.total.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const durationStr = duration.padStart(width, " ").replace(/ /g, "&nbsp;");
const inputNums = Object.keys(this.manager.output.outputs);
let avgTime = 0,
numOutputs = 0;
for (let i = 0; i < inputNums.length; i++) {
const output = this.manager.output.outputs[inputNums[i]];
if (output.status === "baked") {
numOutputs++;
avgTime += output.data.duration;
}
}
avgTime = Math.round(avgTime / numOutputs).toLocaleString() + "ms";
avgTime = avgTime.padStart(width, " ").replace(/ /g, "&nbsp;");
const msg = `total: ${totalStr}<br>time: ${durationStr}<br>average: ${avgTime}`;
const bakeInfo = document.getElementById("bake-info");
bakeInfo.innerHTML = msg;
bakeInfo.style.display = "";
} else {
document.getElementById("bake-info").style.display = "none";
}
document.getElementById("bake").style.background = "";
this.totalOutputs = 0; // Reset for next time
log.debug("--- Bake complete ---");
}
/**
* Bakes the next input and tells the inputWorker to load the next input
*
* @param {number} workerIdx - The index of the worker to bake with
*/
bakeNextInput(workerIdx) {
if (this.inputs.length === 0) return;
if (workerIdx === -1) return;
if (!this.chefWorkers[workerIdx]) return;
this.chefWorkers[workerIdx].active = true;
const nextInput = this.inputs.splice(0, 1)[0];
if (typeof nextInput.inputNum === "string") nextInput.inputNum = parseInt(nextInput.inputNum, 10);
log.debug(`Baking input ${nextInput.inputNum}.`);
this.manager.output.updateOutputMessage(`Baking input ${nextInput.inputNum}...`, nextInput.inputNum, false);
this.manager.output.updateOutputStatus("baking", nextInput.inputNum);
this.chefWorkers[workerIdx].inputNum = nextInput.inputNum;
const input = nextInput.input,
recipeConfig = this.recipeConfig;
if (this.step) {
// Remove all breakpoints from the recipe up to progress
for (let i = 0; i < this.app.progress; i++) {
if (recipeConfig[i].hasOwnProperty("breakpoint")) {
delete recipeConfig[i].breakpoint;
}
}
// Set a breakpoint at the next operation so we stop baking there
if (recipeConfig[this.app.progress]) recipeConfig[this.app.progress].breakpoint = true;
}
let transferable;
if (input instanceof ArrayBuffer || ArrayBuffer.isView(input)) {
transferable = [input];
}
this.chefWorkers[workerIdx].worker.postMessage({
action: "bake",
data: {
input: input,
recipeConfig: recipeConfig,
options: this.options,
inputNum: nextInput.inputNum,
bakeId: this.bakeId
}
}, transferable);
if (this.inputNums.length > 0) {
this.manager.input.inputWorker.postMessage({
action: "bakeNext",
data: {
inputNum: this.inputNums.splice(0, 1)[0],
bakeId: this.bakeId
}
});
this.loadingOutputs++;
}
}
/**
* Bakes the current input using the current recipe.
*
* @param {Object[]} recipeConfig
* @param {Object} options
* @param {number} progress
* @param {boolean} step
*/
bake(recipeConfig, options, progress, step) {
this.setBakingStatus(true);
this.manager.recipe.updateBreakpointIndicator(false);
this.bakeStartTime = new Date().getTime();
this.bakeId++;
this.recipeConfig = recipeConfig;
this.options = options;
this.progress = progress;
this.step = step;
this.displayProgress();
}
/**
* Queues an input ready to be baked
*
* @param {object} inputData
* @param {string | ArrayBuffer} inputData.input
* @param {number} inputData.inputNum
* @param {number} inputData.bakeId
*/
queueInput(inputData) {
this.loadingOutputs--;
if (this.app.baking && inputData.bakeId === this.bakeId) {
this.inputs.push(inputData);
this.bakeNextInput(this.getInactiveChefWorker(true));
}
}
/**
* Handles if an error is thrown by QueueInput
*
* @param {object} inputData
* @param {number} inputData.inputNum
* @param {number} inputData.bakeId
*/
queueInputError(inputData) {
this.loadingOutputs--;
if (this.app.baking && inputData.bakeId === this.bakeId) {
this.manager.output.updateOutputError("Error queueing the input for a bake.", inputData.inputNum, 0);
if (this.inputNums.length === 0) return;
// Load the next input
this.manager.input.inputWorker.postMessage({
action: "bakeNext",
data: {
inputNum: this.inputNums.splice(0, 1)[0],
bakeId: this.bakeId
}
});
this.loadingOutputs++;
}
}
/**
* Queues a list of inputNums to be baked by ChefWorkers, and begins baking
*
* @param {object} inputData
* @param {number[]} inputData.nums - The inputNums to be queued for baking
* @param {boolean} inputData.step - If true, only execute the next operation in the recipe
* @param {number} inputData.progress - The current progress through the recipe. Used when stepping
*/
async bakeAllInputs(inputData) {
return await new Promise(resolve => {
if (this.app.baking) return;
const inputNums = inputData.nums;
const step = inputData.step;
// Use cancelBake to clear out the inputs
this.cancelBake(true, false);
this.inputNums = inputNums;
this.totalOutputs = inputNums.length;
this.app.progress = inputData.progress;
let inactiveWorkers = 0;
for (let i = 0; i < this.chefWorkers.length; i++) {
if (!this.chefWorkers[i].active) {
inactiveWorkers++;
}
}
for (let i = 0; i < inputNums.length - inactiveWorkers; i++) {
if (this.addChefWorker() === -1) break;
}
this.app.bake(step);
for (let i = 0; i < this.inputNums.length; i++) {
this.manager.output.updateOutputMessage(`Input ${inputNums[i]} has not been baked yet.`, inputNums[i], false);
this.manager.output.updateOutputStatus("pending", inputNums[i]);
}
let numBakes = this.chefWorkers.length;
if (this.inputNums.length < numBakes) {
numBakes = this.inputNums.length;
}
for (let i = 0; i < numBakes; i++) {
this.manager.input.inputWorker.postMessage({
action: "bakeNext",
data: {
inputNum: this.inputNums.splice(0, 1)[0],
bakeId: this.bakeId
}
});
this.loadingOutputs++;
}
});
}
/**
* 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) {
// If there aren't any active ChefWorkers, try to add one
let workerId = this.getInactiveChefWorker();
if (workerId === -1) {
workerId = this.addChefWorker();
}
if (workerId === -1) return;
this.chefWorkers[workerId].worker.postMessage({
action: "silentBake",
data: {
recipeConfig: recipeConfig
}
});
}
/**
* 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;
if (this.dishWorker === null) this.setupDishWorker();
this.dishWorker.postMessage({
action: "getDishAs",
data: {
dish: dish,
type: type,
id: id
}
});
}
/**
* Sets the console log level in the workers.
*
* @param {string} level
*/
setLogLevel(level) {
for (let i = 0; i < this.chefWorkers.length; i++) {
this.chefWorkers[i].worker.postMessage({
action: "setLogLevel",
data: log.getLevel()
});
}
}
/**
* Display the bake progress in the output bar and bake button
*/
displayProgress() {
const progress = this.getBakeProgress();
if (progress.total === progress.baked) return;
const percentComplete = ((progress.pending + progress.baking) / progress.total) * 100;
const bakeButton = document.getElementById("bake");
if (this.app.baking) {
if (percentComplete < 100) {
document.getElementById("bake").style.background = `linear-gradient(to left, #fea79a ${percentComplete}%, #f44336 ${percentComplete}%)`;
} else {
bakeButton.style.background = "";
}
} else {
// not baking
bakeButton.style.background = "";
}
const bakeInfo = document.getElementById("bake-info");
if (progress.total > 1) {
let width = progress.total.toLocaleString().length;
width = width < 2 ? 2 : width;
const totalStr = progress.total.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const bakedStr = progress.baked.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const pendingStr = progress.pending.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const bakingStr = progress.baking.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
let msg = "total: " + totalStr;
msg += "<br>baked: " + bakedStr;
if (progress.pending > 0) {
msg += "<br>pending: " + pendingStr;
} else if (progress.baking > 0) {
msg += "<br>baking: " + bakingStr;
}
bakeInfo.innerHTML = msg;
bakeInfo.style.display = "";
} else {
bakeInfo.style.display = "none";
}
if (progress.total !== progress.baked) {
setTimeout(function() {
this.displayProgress();
}.bind(this), 100);
}
}
/**
* 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) {
let workerIdx = this.getInactiveChefWorker(false);
if (workerIdx === -1) {
workerIdx = this.addChefWorker();
}
if (workerIdx === -1) return;
this.chefWorkers[workerIdx].worker.postMessage({
action: "highlight",
data: {
recipeConfig: recipeConfig,
direction: direction,
pos: pos
}
});
}
}
export default WorkerWaiter;