From cf1ba60a10ff043342f4fd83d4ac3b153a37f911 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Tue, 15 Aug 2017 16:26:42 +0000 Subject: [PATCH 1/3] Added new 'pretty' recipe format to make URLs more readable --- src/core/Utils.js | 133 +++++++++++++++++++++++++ src/web/App.js | 2 +- src/web/ControlsWaiter.js | 21 ++-- src/web/Manager.js | 2 +- src/web/RecipeWaiter.js | 3 + src/web/html/index.html | 17 +++- src/web/stylesheets/layout/_modals.css | 8 +- 7 files changed, 175 insertions(+), 11 deletions(-) diff --git a/src/core/Utils.js b/src/core/Utils.js index 1c07a13c..400ae502 100755 --- a/src/core/Utils.js +++ b/src/core/Utils.js @@ -843,6 +843,139 @@ const Utils = { }, + /** + * Encodes a URI fragment (#) or query (?) using a minimal amount of percent-encoding. + * + * RFC 3986 defines legal characters for the fragment and query parts of a URL to be as follows: + * + * fragment = *( pchar / "/" / "?" ) + * query = *( pchar / "/" / "?" ) + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * pct-encoded = "%" HEXDIG HEXDIG + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + * + * Meaning that the list of characters that need not be percent-encoded are alphanumeric plus: + * -._~!$&'()*+,;=:@/? + * + * & and = are still escaped as they are used to serialise the key-value pairs in CyberChef + * fragments. + is also escaped so as to prevent it being decoded to a space. + * + * @param {string} str + * @returns {string} + */ + encodeURIFragment: function(str) { + const LEGAL_CHARS = { + "%2D": "-", + "%2E": ".", + "%5F": "_", + "%7E": "~", + "%21": "!", + "%24": "$", + //"%26": "&", + "%27": "'", + "%28": "(", + "%29": ")", + "%2A": "*", + //"%2B": "+", + "%2C": ",", + "%3B": ";", + //"%3D": "=", + "%3A": ":", + "%40": "@", + "%2F": "/", + "%3F": "?" + }; + str = encodeURIComponent(str); + + return str.replace(/%[0-9A-F]{2}/g, function (match) { + return LEGAL_CHARS[match] || match; + }); + }, + + + /** + * Generates a "pretty" recipe format from a recipeConfig object. + * + * "Pretty" CyberChef recipe formats are designed to be included in the fragment (#) or query (?) + * parts of the URL. They can also be loaded into CyberChef through the 'Load' interface. In order + * to make this format as readable as possible, various special characters are used unescaped. This + * reduces the amount of percent-encoding included in the URL which is typically difficult to read, + * as well as substantially increasing the overall length. These characteristics can be quite + * offputting for users. + * + * @param {Object[]} recipeConfig + * @param {boolean} newline - whether to add a newline after each operation + * @returns {string} + */ + generatePrettyRecipe: function(recipeConfig, newline) { + let prettyConfig = "", + name = "", + args = "", + disabled = "", + bp = ""; + + recipeConfig.forEach(op => { + name = op.op.replace(/ /g, "_"); + args = JSON.stringify(op.args) + .slice(1, -1) // Remove [ and ] as they are implied + // We now need to switch double-quoted (") strings to single-quotes (') as these do not + // need to be percent-encoded. + .replace(/'/g, "\\'") // Escape single quotes + .replace(/\\"/g, '"') // Unescape double quotes + .replace(/(^|,)"/g, "$1'") // Replace opening " with ' + .replace(/"(,|$)/g, "'$1"); // Replace closing " with ' + + disabled = op.disabled ? "/disabled": ""; + bp = op.breakpoint ? "/breakpoint" : ""; + prettyConfig += `${name}(${args}${disabled}${bp})`; + if (newline) prettyConfig += "\n"; + }); + return prettyConfig; + }, + + + /** + * Converts a recipe string to the JSON representation of the recipe. + * Accepts either stringified JSON or bespoke "pretty" recipe format. + * + * @param {string} recipe + * @returns {Object[]} + */ + parseRecipeConfig: function(recipe) { + recipe = recipe.trim(); + if (recipe.length === 0) return []; + if (recipe[0] === "[") return JSON.parse(recipe); + + // Parse bespoke recipe format + recipe = recipe.replace(/\n/g, ""); + let m, + recipeRegex = /([^(]+)\(((?:'[^'\\]*(?:\\.[^'\\]*)*'|[^)/])*)(\/[^)]+)?\)/g, + recipeConfig = [], + args; + + while ((m = recipeRegex.exec(recipe))) { + // Translate strings in args back to double-quotes + args = m[2] + .replace(/"/g, '\\"') // Escape double quotes + .replace(/(^|,)'/g, '$1"') // Replace opening ' with " + .replace(/([^\\])'(,|$)/g, '$1"$2') // Replace closing ' with " + .replace(/\\'/g, "'"); // Unescape single quotes + args = "[" + args + "]"; + + let op = { + op: m[1].replace(/_/g, " "), + args: JSON.parse(args) + }; + if (m[3] && m[3].indexOf("disabled") > 0) op.disabled = true; + if (m[3] && m[3].indexOf("breakpoint") > 0) op.breakpoint = true; + recipeConfig.push(op); + } + return recipeConfig; + }, + + /** * Expresses a number of milliseconds in a human readable format. * diff --git a/src/web/App.js b/src/web/App.js index c17305f9..8887a927 100755 --- a/src/web/App.js +++ b/src/web/App.js @@ -411,7 +411,7 @@ App.prototype.loadURIParams = function() { // Read in recipe from URI params if (this.uriParams.recipe) { try { - const recipeConfig = JSON.parse(this.uriParams.recipe); + const recipeConfig = Utils.parseRecipeConfig(this.uriParams.recipe); this.setRecipeConfig(recipeConfig); } catch (err) {} } else if (this.uriParams.op) { diff --git a/src/web/ControlsWaiter.js b/src/web/ControlsWaiter.js index b6a8b626..03c54136 100755 --- a/src/web/ControlsWaiter.js +++ b/src/web/ControlsWaiter.js @@ -170,7 +170,7 @@ ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput const link = baseURL || window.location.protocol + "//" + window.location.host + window.location.pathname; - const recipeStr = JSON.stringify(recipeConfig); + const recipeStr = Utils.generatePrettyRecipe(recipeConfig); const inputStr = Utils.toBase64(this.app.getInput(), "A-Za-z0-9+/"); // B64 alphabet with no padding includeRecipe = includeRecipe && (recipeConfig.length > 0); @@ -184,7 +184,7 @@ ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput const hash = params .filter(v => v) - .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .map(([key, value]) => `${key}=${Utils.encodeURIFragment(value)}`) .join("&"); if (hash) { @@ -198,9 +198,9 @@ ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput /** * Handler for changes made to the save dialog text area. Re-initialises the save link. */ -ControlsWaiter.prototype.saveTextChange = function() { +ControlsWaiter.prototype.saveTextChange = function(e) { try { - const recipeConfig = JSON.parse(document.getElementById("save-text").value); + const recipeConfig = Utils.parseRecipeConfig(e.target.value); this.initialiseSaveLink(recipeConfig); } catch (err) {} }; @@ -211,9 +211,16 @@ ControlsWaiter.prototype.saveTextChange = function() { */ ControlsWaiter.prototype.saveClick = function() { const recipeConfig = this.app.getRecipeConfig(); - const recipeStr = JSON.stringify(recipeConfig).replace(/},{/g, "},\n{"); + const recipeStr = JSON.stringify(recipeConfig); - document.getElementById("save-text").value = recipeStr; + 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(); @@ -339,7 +346,7 @@ ControlsWaiter.prototype.loadNameChange = function(e) { */ ControlsWaiter.prototype.loadButtonClick = function() { try { - const recipeConfig = JSON.parse(document.getElementById("load-text").value); + const recipeConfig = Utils.parseRecipeConfig(document.getElementById("load-text").value); this.app.setRecipeConfig(recipeConfig); $("#rec-list [data-toggle=popover]").popover(); diff --git a/src/web/Manager.js b/src/web/Manager.js index c9396020..0948af10 100755 --- a/src/web/Manager.js +++ b/src/web/Manager.js @@ -102,7 +102,7 @@ Manager.prototype.initialiseEventListeners = function() { document.getElementById("load-name").addEventListener("change", this.controls.loadNameChange.bind(this.controls)); document.getElementById("load-button").addEventListener("click", this.controls.loadButtonClick.bind(this.controls)); document.getElementById("support").addEventListener("click", this.controls.supportButtonClick.bind(this.controls)); - this.addMultiEventListener("#save-text", "keyup paste", this.controls.saveTextChange, this.controls); + this.addMultiEventListeners("#save-texts textarea", "keyup paste", this.controls.saveTextChange, this.controls); // Operations this.addMultiEventListener("#search", "keyup paste search", this.ops.searchOperations, this.ops); diff --git a/src/web/RecipeWaiter.js b/src/web/RecipeWaiter.js index ea09d325..80c2fe96 100755 --- a/src/web/RecipeWaiter.js +++ b/src/web/RecipeWaiter.js @@ -295,6 +295,9 @@ RecipeWaiter.prototype.getConfig = function() { option: ingList[j].previousSibling.children[0].textContent.slice(0, -1), string: ingList[j].value }; + } else if (ingList[j].getAttribute("type") === "number") { + // number + ingredients[j] = parseFloat(ingList[j].value, 10); } else { // all others ingredients[j] = ingList[j].value; diff --git a/src/web/html/index.html b/src/web/html/index.html index 60f03a36..c79e849c 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -215,7 +215,22 @@