Added support for multiple tabs in URL

This adds support for storing multiple tabs' contents and headers in the URL (with backwards compatibility for the current standard '&input=...') and moves the tab header information to the InputWorker instead of an array in the TabWaiter.
This commit is contained in:
Michael Rowley 2022-11-07 18:07:45 +00:00
parent b615d1350d
commit bbea58dabb
7 changed files with 206 additions and 167 deletions

View file

@ -212,29 +212,6 @@ class App {
this.manager.worker.silentBake(recipeConfig);
}
/**
* Sets the user's input data.
*
* @param {string} input - The string to set the input to
*/
setInput(input) {
// Get the currently active tab.
// If there isn't one, assume there are no inputs so use inputNum of 1
let inputNum = this.manager.tabs.getActiveInputTab();
if (inputNum === -1) inputNum = 1;
this.manager.input.updateInputValue(inputNum, input);
this.manager.input.inputWorker.postMessage({
action: "setInput",
data: {
inputNum: inputNum,
silent: true
}
});
}
/**
* Populates the operations accordion list with the categories and operations specified in the
* view constructor.
@ -483,12 +460,36 @@ class App {
}
// Read in input data from URI params
if (this.uriParams.input) {
try {
const inputData = fromBase64(this.uriParams.input);
this.setInput(inputData);
} catch (err) {}
this.manager.input.clearAllIoClick();
let maxInputNum = 1;
for (const [param, value] of Object.entries(this.uriParams)) {
if (typeof param !== "string") {
continue;
}
const inputHeaderRegex = /input\[('?[A-Za-z0-9\-_]+)'?\]/.exec(param);
if (inputHeaderRegex === null && param !== "input") {
// Invalid parameter key.
continue;
}
if (maxInputNum > 1) {
this.manager.input.addInput(false);
}
if (inputHeaderRegex !== null && inputHeaderRegex.length !== 1) {
// This input has a custom header that can be 'imported'.
const header = inputHeaderRegex[1];
if (header[0] === "'") {
this.manager.input.setTabName(maxInputNum, fromBase64(header.substring(1)));
}
}
this.manager.input.updateInputValue(maxInputNum, fromBase64(value));
maxInputNum++;
}
this.manager.input.changeTab(1, true);
this.manager.input.bakeAll();
// Read in theme from URI params
if (this.uriParams.theme) {
@ -730,18 +731,16 @@ class App {
this.progress = 0;
this.autoBake();
this.updateTitle(true, null, true);
this.updateTitle(true);
}
/**
* Update the page title to contain the new recipe
*
* @param {boolean} includeInput
* @param {string} input
* @param {boolean} [changeUrl=true]
*/
updateTitle(includeInput, input, changeUrl=true) {
updateTitle(changeUrl=true) {
// Set title
const recipeConfig = this.getRecipeConfig();
let title = "CyberChef";
@ -761,8 +760,10 @@ class App {
// Update the current history state (not creating a new one)
if (this.options.updateUrl && changeUrl) {
this.lastStateUrl = this.manager.controls.generateStateUrl(true, includeInput, input, recipeConfig);
window.history.replaceState({}, title, this.lastStateUrl);
this.manager.controls.generateStateUrl(true, true, recipeConfig).then((lastStateUrl) => {
this.lastStateUrl = lastStateUrl;
window.history.replaceState({}, title, this.lastStateUrl);
});
}
}

View file

@ -586,6 +586,13 @@
Keep the current tab in sync between the input and output
</label>
</div>
<div class="checkbox option-item">
<label for="emptyInputPreserve">
<input type="checkbox" option="emptyInputPreserve" id="emptyInputPreserve" checked>
Include empty inputs in URL
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="reset-options">Reset options to default</button>

View file

@ -54,7 +54,8 @@ function main() {
autoMagic: true,
imagePreview: true,
syncTabs: true,
preserveCR: "entropy"
preserveCR: "entropy",
emptyInputPreserve: false
};
document.removeEventListener("DOMContentLoaded", main, false);

View file

@ -5,6 +5,7 @@
*/
import Utils from "../../core/Utils.mjs";
import { toBase64 } from "../../core/lib/Base64.mjs";
/**
@ -100,10 +101,10 @@ class ControlsWaiter {
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, null, recipeConfig);
saveLinkEl.innerHTML = Utils.escapeHtml(Utils.truncate(saveLink, 120));
saveLinkEl.setAttribute("href", saveLink);
this.generateStateUrl(includeRecipe, includeInput, recipeConfig).then((saveLink) => {
saveLinkEl.innerHTML = Utils.escapeHtml(Utils.truncate(saveLink, 120));
saveLinkEl.setAttribute("href", saveLink);
});
}
@ -112,12 +113,11 @@ class ControlsWaiter {
*
* @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) {
async generateStateUrl(includeRecipe, includeInput, recipeConfig, baseURL) {
recipeConfig = recipeConfig || this.app.getRecipeConfig();
const link = baseURL || window.location.protocol + "//" +
@ -127,22 +127,31 @@ class ControlsWaiter {
includeRecipe = includeRecipe && (recipeConfig.length > 0);
// If we don't get passed an input, get it from the current URI
if (input === null && includeInput) {
const params = this.app.getURIParams();
if (params.input) {
includeInput = true;
input = params.input;
} else {
includeInput = false;
const params = [includeRecipe ? ["recipe", recipeStr] : undefined];
if (includeInput) {
// getTabList() only returns visible tabs so we need to use getInputNums().
const inputRange = (await this.manager.input.getInputNums()).inputNums;
for (let i = 0; i < inputRange.length; i++) {
const inputNum = parseInt(inputRange[i], 10);
const inputValue = toBase64(await this.manager.input.getInputValue(inputNum), "A-Za-z0-9-_");
if (typeof inputValue !== "string" || inputValue.length > 2048 ||
(!this.app.options.emptyInputPreserve && inputValue.length === 0)) {
// Don't store other datatypes or strings that are too long (arbitrary size limit).
continue;
}
let inputIdentifier = inputNum.toString();
const tabHeader = await this.manager.input.getTabName(inputNum);
if (typeof tabHeader === "string" && tabHeader.length > 0) {
inputIdentifier = "'" + toBase64(tabHeader, "A-Za-z0-9-_") + "'";
}
params.push([`input[${inputIdentifier}]`, inputValue]);
}
}
const params = [
includeRecipe ? ["recipe", recipeStr] : undefined,
includeInput ? ["input", Utils.escapeHtml(input)] : undefined,
];
const hash = params
.filter(v => v)
.map(([key, value]) => `${key}=${Utils.encodeURIFragment(value)}`)
@ -344,17 +353,17 @@ class ControlsWaiter {
e.preventDefault();
const reportBugInfo = document.getElementById("report-bug-info");
const saveLink = this.generateStateUrl(true, true, null, null, "https://gchq.github.io/CyberChef/");
this.generateStateUrl(true, true, null, "https://gchq.github.io/CyberChef/").then((saveLink) => {
if (reportBugInfo) {
reportBugInfo.innerHTML = `* Version: ${PKG_VERSION}
* Compile time: ${COMPILE_TIME}
* User-Agent:
${navigator.userAgent}
* [Link to reproduce](${saveLink})
if (reportBugInfo) {
reportBugInfo.innerHTML = `* Version: ${PKG_VERSION}
* Compile time: ${COMPILE_TIME}
* User-Agent:
${navigator.userAgent}
* [Link to reproduce](${saveLink})
`;
}
`;
}
});
}

View file

@ -8,7 +8,6 @@
import LoaderWorker from "worker-loader?inline=no-fallback!../workers/LoaderWorker.js";
import InputWorker from "worker-loader?inline=no-fallback!../workers/InputWorker.mjs";
import Utils, { debounce } from "../../core/Utils.mjs";
import { toBase64 } from "../../core/lib/Base64.mjs";
import { isImage } from "../../core/lib/FileType.mjs";
@ -291,7 +290,7 @@ class InputWaiter {
this.app.handleError(r.data);
break;
case "setUrl":
this.setUrl(r.data);
this.setUrl();
break;
case "inputSwitch":
this.manager.output.inputSwitch(r.data);
@ -306,6 +305,9 @@ class InputWaiter {
case "fileLoaded":
this.fileLoaded(r.data.inputNum);
break;
case "getTabName":
this.callbacks[r.data.id](r.data);
break;
default:
log.error(`Unknown action ${r.action}.`);
}
@ -363,14 +365,7 @@ class InputWaiter {
inputData.input.count("\n") + 1 : null;
this.setInputInfo(inputData.input.length, lines);
// Set URL to current input
const inputStr = toBase64(inputData.input, "A-Za-z0-9+/");
if (inputStr.length > 0 && inputStr.length <= 68267) {
this.setUrl({
includeInput: true,
input: inputStr
});
}
this.setUrl();
if (!silent) window.dispatchEvent(this.manager.statechange);
} else {
@ -527,15 +522,7 @@ class InputWaiter {
* @param {boolean} [force=false] - If true, forces the value to be updated even if the type is different to the currently stored type
*/
updateInputValue(inputNum, value, force=false) {
let includeInput = false;
const recipeStr = toBase64(value, "A-Za-z0-9+/"); // B64 alphabet with no padding
if (recipeStr.length > 0 && recipeStr.length <= 68267) {
includeInput = true;
}
this.setUrl({
includeInput: includeInput,
input: recipeStr
});
this.setUrl();
// Value is either a string set by the input or an ArrayBuffer from a LoaderWorker,
// so is safe to use typeof === "string"
@ -1084,6 +1071,7 @@ class InputWaiter {
this.setupInputWorker();
this.manager.worker.setupChefWorker();
this.addInput(true);
this.manager.input.changeTab(1, true);
this.bakeAll();
}
@ -1095,7 +1083,7 @@ class InputWaiter {
const inputNum = this.manager.tabs.getActiveInputTab();
if (inputNum === -1) return;
this.manager.tabs.removeTabHeaderAlias(inputNum);
this.manager.input.setTabName(inputNum, "");
this.manager.highlighter.removeHighlights();
getSelection().removeAllRanges();
@ -1234,7 +1222,6 @@ class InputWaiter {
removeChefWorker: true
}
});
this.manager.tabs.removeTabHeaderAlias(inputNum);
this.manager.output.removeTab(inputNum);
}
@ -1248,10 +1235,9 @@ class InputWaiter {
if (!mouseEvent.target) {
return;
}
const tabNumStr = mouseEvent.target.closest("button").parentElement.getAttribute("inputNum");
if (tabNumStr) {
const tabNum = parseInt(tabNumStr, 10);
this.removeInput(tabNum);
const tabNum = mouseEvent.target.closest("button").parentElement.getAttribute("inputNum");
if (tabNum) {
this.removeInput(parseInt(tabNum, 10));
}
}
@ -1437,13 +1423,61 @@ class InputWaiter {
/**
* Update the input URL to the new value
*
* @param {object} urlData - Object containing the URL data
* @param {boolean} urlData.includeInput - If true, the input is included in the title
* @param {string} urlData.input - The input data to be included
*/
setUrl(urlData) {
this.app.updateTitle(urlData.includeInput, urlData.input, true);
setUrl() {
this.app.updateTitle(true);
}
/**
* Retrieves the custom name of any tab
*
* @param {number} inputNum - The input number of the tab
* @returns {string} - The tab's custom name or null if it has not been assigned one
*/
async getTabName(inputNum) {
if (inputNum <= 0) {
return null;
}
const tabName = (await new Promise(resolve => {
this.queryTabName(r => {
resolve(r);
}, inputNum);
})).data;
return tabName === null ? "" : tabName;
}
/**
* Sends the inputWorker a message requesting the custom name of a given tab
*
* @param {object} callback - The callback to be executed after the worker finishes
* @param {number} inputNum - The input number of the tab
*/
queryTabName(callback, inputNum) {
const callbackId = this.callbackID++;
this.callbacks[callbackId] = callback;
this.inputWorker.postMessage({
action: "getTabName",
data: {
id: callbackId,
inputNum: inputNum
}
});
}
/**
* Assigns the provided tab with a custo name
*
* @param {number} inputNum - The input number of the tab
* @param {string} tabName - The new name for the tab
*/
setTabName(inputNum, tabName) {
this.inputWorker.postMessage({
action: "setTabName",
data: {
inputNum: inputNum,
tabName: tabName
}
});
}
/**
@ -1461,9 +1495,6 @@ class InputWaiter {
let renameContents = targetElement.textContent;
const renameContentsColon = renameContents.indexOf(":");
// Calling 'getInputValue()' might take a long time for large datasets,
// it could be beneficial to modify the API to allow for querying whether
// there is any input rather than getting the full string just to access its length.
const inputLength = (await this.getInputValue(this.manager.tabs.getActiveInputTab())).length;
// Remove the data from the renaming section
@ -1499,20 +1530,25 @@ class InputWaiter {
if (activeInputTabNum === -1) {
return;
}
const activeInputTabElement = this.manager.tabs.getTabItem(activeInputTabNum, "input");
if (activeInputTabElement == null || activeInputTabElement.children.size < 1) {
return;
}
const tabContent = activeInputTabElement.children[0];
const tabHeader = tabContent.children[0].children[0].value;
const inputContents = await this.getInputValue(activeInputTabNum);
if (tabHeader.length === 0)
this.manager.tabs.removeTabHeaderAlias(activeInputTabNum, false);
this.manager.input.setTabName(activeInputTabNum, "");
else
this.manager.tabs.addTabHeaderAlias(activeInputTabNum, tabHeader);
this.manager.input.setTabName(activeInputTabNum, tabHeader);
this.manager.tabs.updateInputTabHeader(activeInputTabNum, inputContents);
this.manager.tabs.updateOutputTabHeader(activeInputTabNum, inputContents);
this.manager.input.setUrl();
}
}

View file

@ -18,7 +18,6 @@ class TabWaiter {
constructor(app, manager) {
this.app = app;
this.manager = manager;
this.tabHeaderAliases = []; // Mapping custom tab headers to indexes/numbers.
}
/**
@ -151,6 +150,8 @@ class TabWaiter {
const newTab = document.createElement("li");
newTab.setAttribute("inputNum", inputNum.toString());
newTab.setAttribute("tabHeader", "");
if (active) newTab.classList.add(`active-${io}-tab`);
const newTabContent = document.createElement("div");
@ -178,6 +179,8 @@ class TabWaiter {
newTab.appendChild(newTabButton);
}
this.manager.input.setUrl();
return newTab;
}
@ -353,11 +356,9 @@ class TabWaiter {
const tab = this.getTabItem(inputNum, io);
if (tab == null) return;
const customHeaderData = this.getTabHeaderAlias(inputNum);
const customHeaderData = await this.manager.input.getTabName(inputNum);
// When 'customHeaderData === `Tab ${inputNum}`' is true, it's usually due to
// a user having opened the rename textbox but then closing it without change.
const isStandardHeader = customHeaderData === null || customHeaderData === `Tab ${inputNum}`;
const isStandardHeader = customHeaderData.length === 0 || customHeaderData === `Tab ${inputNum}`;
let headerData = isStandardHeader ? `Tab ${inputNum}` : customHeaderData;
const dataIsFile = data instanceof ArrayBuffer;
@ -375,9 +376,11 @@ class TabWaiter {
headerData += `: ${dataPreview}`;
}
tab.firstElementChild.innerText = headerData;
if (!isStandardHeader && !includeData)
if (!isStandardHeader && !includeData) {
tab.firstElementChild.innerText = `'${headerData}'`;
}
}
/**
@ -441,63 +444,6 @@ class TabWaiter {
updateOutputTabProgress(inputNum, progress, total) {
this.updateTabProgress(inputNum, progress, total, "output");
}
/**
* Adds an alias between a custom tab header and a tab number so that
* mapping between the two is possible if DOM element is removed.
*
* @param {number} tabNum - The index of the tab being aliased
* @param {string} tabHeader - The custom tab header
*/
addTabHeaderAlias(tabNum, tabHeader) {
// First, we try to overwrite an existing alias.
for (let i = 0; i < this.tabHeaderAliases.length; i++) {
if (this.tabHeaderAliases.at(i).tabNumber === tabNum) {
this.tabHeaderAliases.at(i).customHeader = tabHeader;
return;
}
}
this.tabHeaderAliases.push({tabNumber: tabNum, customHeader: tabHeader});
}
/**
* Removes a previously-assigned header alias.
*
* @param {number} tabNum - The index of the tab that should be removed.
* @param {boolean} shouldThrow - A boolean representing whether the function should throw an exception or return silently if it cannot locate the tab header.
*/
removeTabHeaderAlias(tabNum, shouldThrow) {
for (let i = 0; i < this.tabHeaderAliases.length; i++) {
if (this.tabHeaderAliases.at(i).tabNumber === tabNum) {
this.tabHeaderAliases.splice(i, 1);
return;
}
}
if (shouldThrow)
throw `Unable to locate header alias at tab index ${tabNum.toString()}.`;
}
/**
* Retrieves the custom header for a given tab.
*
* @param {number} tabNum - The index of the tab whose alias should be retrieved.
* @param {boolean} shouldThrow - Whether the function should throw an exception (instead of returning null) in the event of it being unable to locate the tab.
* @returns {string} customHeader - The custom header for the requested tab.
*/
getTabHeaderAlias(tabNum, shouldThrow) {
for (let i = 0; i < this.tabHeaderAliases.length; i++) {
if (this.tabHeaderAliases.at(i).tabNumber === tabNum)
return this.tabHeaderAliases.at(i).customHeader;
}
if (shouldThrow)
throw `Unable to locate header alias at tab index ${tabNum.toString()}.`;
return null;
}
}
export default TabWaiter;

View file

@ -113,6 +113,12 @@ self.addEventListener("message", function(e) {
case "getInputNums":
self.getInputNums(r.data);
break;
case "getTabName":
self.getTabName(r.data);
break;
case "setTabName":
self.setTabName(r.data.inputNum, r.data.tabName);
break;
default:
log.error(`Unknown action '${r.action}'.`);
}
@ -413,14 +419,17 @@ self.getNearbyNums = function(inputNum, direction) {
};
/**
* Gets the data to display in the tab header for an input, and
* posts it back to the inputWaiter
* Gets the data to display in the tab header for an input and
* posts it back to the inputWaiter.
*
* @param {number} inputNum - The inputNum of the tab header
* @param {number} inputNum - The inputNum of the tab header.
*/
self.updateTabHeader = function(inputNum) {
const input = self.getInputObj(inputNum);
if (input === null || input === undefined) return;
if (input === null || input === undefined) {
return;
}
let inputData = input.data;
if (typeof inputData !== "string") {
inputData = input.data.name;
@ -1079,3 +1088,33 @@ self.inputSwitch = function(switchData) {
});
};
/**
* Gets the custom name of a tab, returning null if one has not been set
*
* @param {object} inputData
* @param {number} inputData.inputNum - The input number of the tab
* @param {number} inputData.id - The callback ID for the callvack to run when returning to the inputWaiter
*/
self.getTabName = function(inputData) {
const { inputNum, id } = inputData;
const inputName = self.inputs[inputNum].inputName;
const tabName = typeof inputName !== "string" ? null : inputName;
self.postMessage({
action: "getTabName",
data: {
data: tabName,
id: id
}
});
};
/**
* Sets the custom name of a tab, overwriting any previously-assigned one
*
* @param {number} inputNum - The input number of the tab
* @param {string} tabName - The custom name that should be assigned to the tab
*/
self.setTabName = function(inputNum, tabName) {
self.inputs[inputNum].inputName = tabName;
};