App.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. import Utils from "../core/Utils.js";
  2. import Manager from "./Manager.js";
  3. import HTMLCategory from "./HTMLCategory.js";
  4. import HTMLOperation from "./HTMLOperation.js";
  5. import Split from "split.js";
  6. /**
  7. * HTML view for CyberChef responsible for building the web page and dealing with all user
  8. * interactions.
  9. *
  10. * @author n1474335 [n1474335@gmail.com]
  11. * @copyright Crown Copyright 2016
  12. * @license Apache-2.0
  13. *
  14. * @constructor
  15. * @param {CatConf[]} categories - The list of categories and operations to be populated.
  16. * @param {Object.<string, OpConf>} operations - The list of operation configuration objects.
  17. * @param {String[]} defaultFavourites - A list of default favourite operations.
  18. * @param {Object} options - Default setting for app options.
  19. */
  20. const App = function(categories, operations, defaultFavourites, defaultOptions) {
  21. this.categories = categories;
  22. this.operations = operations;
  23. this.dfavourites = defaultFavourites;
  24. this.doptions = defaultOptions;
  25. this.options = Utils.extend({}, defaultOptions);
  26. this.manager = new Manager(this);
  27. this.baking = false;
  28. this.autoBake_ = false;
  29. this.autoBakePause = false;
  30. this.progress = 0;
  31. this.ingId = 0;
  32. };
  33. /**
  34. * This function sets up the stage and creates listeners for all events.
  35. *
  36. * @fires Manager#appstart
  37. */
  38. App.prototype.setup = function() {
  39. document.dispatchEvent(this.manager.appstart);
  40. this.initialiseSplitter();
  41. this.loadLocalStorage();
  42. this.populateOperationsList();
  43. this.manager.setup();
  44. this.resetLayout();
  45. this.setCompileMessage();
  46. this.loadURIParams();
  47. this.appLoaded = true;
  48. this.loaded();
  49. };
  50. /**
  51. * Fires once all setup activities have completed.
  52. *
  53. * @fires Manager#apploaded
  54. */
  55. App.prototype.loaded = function() {
  56. // Check that both the app and the worker have loaded successfully, and that
  57. // we haven't already loaded before attempting to remove the loading screen.
  58. if (!this.workerLoaded || !this.appLoaded ||
  59. !document.getElementById("loader-wrapper")) return;
  60. // Trigger CSS animations to remove preloader
  61. document.body.classList.add("loaded");
  62. // Wait for animations to complete then remove the preloader and loaded style
  63. // so that the animations for existing elements don't play again.
  64. setTimeout(function() {
  65. document.getElementById("loader-wrapper").remove();
  66. document.body.classList.remove("loaded");
  67. }, 1000);
  68. // Clear the loading message interval
  69. clearInterval(window.loadingMsgsInt);
  70. document.dispatchEvent(this.manager.apploaded);
  71. };
  72. /**
  73. * An error handler for displaying the error to the user.
  74. *
  75. * @param {Error} err
  76. */
  77. App.prototype.handleError = function(err) {
  78. console.error(err);
  79. const msg = err.displayStr || err.toString();
  80. this.alert(msg, "danger", this.options.errorTimeout, !this.options.showErrors);
  81. };
  82. /**
  83. * Asks the ChefWorker to bake the current input using the current recipe.
  84. *
  85. * @param {boolean} [step] - Set to true if we should only execute one operation instead of the
  86. * whole recipe.
  87. */
  88. App.prototype.bake = function(step) {
  89. if (this.baking) return;
  90. this.manager.worker.bake(
  91. this.getInput(), // The user's input
  92. this.getRecipeConfig(), // The configuration of the recipe
  93. this.options, // Options set by the user
  94. this.progress, // The current position in the recipe
  95. step // Whether or not to take one step or execute the whole recipe
  96. );
  97. };
  98. /**
  99. * Runs Auto Bake if it is set.
  100. */
  101. App.prototype.autoBake = function() {
  102. if (this.autoBake_ && !this.autoBakePause && !this.baking) {
  103. this.bake();
  104. } else {
  105. this.manager.controls.showStaleIndicator();
  106. }
  107. };
  108. /**
  109. * Runs a silent bake, forcing the browser to load and cache all the relevant JavaScript code needed
  110. * to do a real bake.
  111. *
  112. * The output will not be modified (hence "silent" bake). This will only actually execute the recipe
  113. * if auto-bake is enabled, otherwise it will just wake up the ChefWorker with an empty recipe.
  114. */
  115. App.prototype.silentBake = function() {
  116. let recipeConfig = [];
  117. if (this.autoBake_) {
  118. // If auto-bake is not enabled we don't want to actually run the recipe as it may be disabled
  119. // for a good reason.
  120. recipeConfig = this.getRecipeConfig();
  121. }
  122. this.manager.worker.silentBake(recipeConfig);
  123. };
  124. /**
  125. * Gets the user's input data.
  126. *
  127. * @returns {string}
  128. */
  129. App.prototype.getInput = function() {
  130. return this.manager.input.get();
  131. };
  132. /**
  133. * Sets the user's input data.
  134. *
  135. * @param {string} input - The string to set the input to
  136. */
  137. App.prototype.setInput = function(input) {
  138. this.manager.input.set(input);
  139. };
  140. /**
  141. * Populates the operations accordion list with the categories and operations specified in the
  142. * view constructor.
  143. *
  144. * @fires Manager#oplistcreate
  145. */
  146. App.prototype.populateOperationsList = function() {
  147. // Move edit button away before we overwrite it
  148. document.body.appendChild(document.getElementById("edit-favourites"));
  149. let html = "";
  150. let i;
  151. for (i = 0; i < this.categories.length; i++) {
  152. let catConf = this.categories[i],
  153. selected = i === 0,
  154. cat = new HTMLCategory(catConf.name, selected);
  155. for (let j = 0; j < catConf.ops.length; j++) {
  156. let opName = catConf.ops[j],
  157. op = new HTMLOperation(opName, this.operations[opName], this, this.manager);
  158. cat.addOperation(op);
  159. }
  160. html += cat.toHtml();
  161. }
  162. document.getElementById("categories").innerHTML = html;
  163. const opLists = document.querySelectorAll("#categories .op-list");
  164. for (i = 0; i < opLists.length; i++) {
  165. opLists[i].dispatchEvent(this.manager.oplistcreate);
  166. }
  167. // Add edit button to first category (Favourites)
  168. document.querySelector("#categories a").appendChild(document.getElementById("edit-favourites"));
  169. };
  170. /**
  171. * Sets up the adjustable splitter to allow the user to resize areas of the page.
  172. */
  173. App.prototype.initialiseSplitter = function() {
  174. this.columnSplitter = Split(["#operations", "#recipe", "#IO"], {
  175. sizes: [20, 30, 50],
  176. minSize: [240, 325, 450],
  177. gutterSize: 4,
  178. onDrag: function() {
  179. this.manager.controls.adjustWidth();
  180. this.manager.output.adjustWidth();
  181. }.bind(this)
  182. });
  183. this.ioSplitter = Split(["#input", "#output"], {
  184. direction: "vertical",
  185. gutterSize: 4,
  186. });
  187. this.resetLayout();
  188. };
  189. /**
  190. * Loads the information previously saved to the HTML5 local storage object so that user options
  191. * and favourites can be restored.
  192. */
  193. App.prototype.loadLocalStorage = function() {
  194. // Load options
  195. let lOptions;
  196. if (localStorage.options !== undefined) {
  197. lOptions = JSON.parse(localStorage.options);
  198. }
  199. this.manager.options.load(lOptions);
  200. // Load favourites
  201. this.loadFavourites();
  202. };
  203. /**
  204. * Loads the user's favourite operations from the HTML5 local storage object and populates the
  205. * Favourites category with them.
  206. * If the user currently has no saved favourites, the defaults from the view constructor are used.
  207. */
  208. App.prototype.loadFavourites = function() {
  209. let favourites = localStorage.favourites &&
  210. localStorage.favourites.length > 2 ?
  211. JSON.parse(localStorage.favourites) :
  212. this.dfavourites;
  213. favourites = this.validFavourites(favourites);
  214. this.saveFavourites(favourites);
  215. const favCat = this.categories.filter(function(c) {
  216. return c.name === "Favourites";
  217. })[0];
  218. if (favCat) {
  219. favCat.ops = favourites;
  220. } else {
  221. this.categories.unshift({
  222. name: "Favourites",
  223. ops: favourites
  224. });
  225. }
  226. };
  227. /**
  228. * Filters the list of favourite operations that the user had stored and removes any that are no
  229. * longer available. The user is notified if this is the case.
  230. * @param {string[]} favourites - A list of the user's favourite operations
  231. * @returns {string[]} A list of the valid favourites
  232. */
  233. App.prototype.validFavourites = function(favourites) {
  234. const validFavs = [];
  235. for (let i = 0; i < favourites.length; i++) {
  236. if (this.operations.hasOwnProperty(favourites[i])) {
  237. validFavs.push(favourites[i]);
  238. } else {
  239. this.alert("The operation \"" + Utils.escapeHtml(favourites[i]) +
  240. "\" is no longer available. It has been removed from your favourites.", "info");
  241. }
  242. }
  243. return validFavs;
  244. };
  245. /**
  246. * Saves a list of favourite operations to the HTML5 local storage object.
  247. *
  248. * @param {string[]} favourites - A list of the user's favourite operations
  249. */
  250. App.prototype.saveFavourites = function(favourites) {
  251. localStorage.setItem("favourites", JSON.stringify(this.validFavourites(favourites)));
  252. };
  253. /**
  254. * Resets favourite operations back to the default as specified in the view constructor and
  255. * refreshes the operation list.
  256. */
  257. App.prototype.resetFavourites = function() {
  258. this.saveFavourites(this.dfavourites);
  259. this.loadFavourites();
  260. this.populateOperationsList();
  261. this.manager.recipe.initialiseOperationDragNDrop();
  262. };
  263. /**
  264. * Adds an operation to the user's favourites.
  265. *
  266. * @param {string} name - The name of the operation
  267. */
  268. App.prototype.addFavourite = function(name) {
  269. const favourites = JSON.parse(localStorage.favourites);
  270. if (favourites.indexOf(name) >= 0) {
  271. this.alert("'" + name + "' is already in your favourites", "info", 2000);
  272. return;
  273. }
  274. favourites.push(name);
  275. this.saveFavourites(favourites);
  276. this.loadFavourites();
  277. this.populateOperationsList();
  278. this.manager.recipe.initialiseOperationDragNDrop();
  279. };
  280. /**
  281. * Checks for input and recipe in the URI parameters and loads them if present.
  282. */
  283. App.prototype.loadURIParams = function() {
  284. // Load query string or hash from URI (depending on which is populated)
  285. // We prefer getting the hash by splitting the href rather than referencing
  286. // location.hash as some browsers (Firefox) automatically URL decode it,
  287. // which cause issues.
  288. const params = window.location.search ||
  289. window.location.href.split("#")[1] ||
  290. window.location.hash;
  291. this.uriParams = Utils.parseURIParams(params);
  292. // Pause auto-bake while loading but don't modify `this.autoBake_`
  293. // otherwise `manualBake` cannot trigger.
  294. this.autoBakePause = true;
  295. // Read in recipe from URI params
  296. if (this.uriParams.recipe) {
  297. try {
  298. const recipeConfig = Utils.parseRecipeConfig(this.uriParams.recipe);
  299. this.setRecipeConfig(recipeConfig);
  300. } catch (err) {}
  301. } else if (this.uriParams.op) {
  302. // If there's no recipe, look for single operations
  303. this.manager.recipe.clearRecipe();
  304. try {
  305. this.manager.recipe.addOperation(this.uriParams.op);
  306. } catch (err) {
  307. // If no exact match, search for nearest match and add that
  308. const matchedOps = this.manager.ops.filterOperations(this.uriParams.op, false);
  309. if (matchedOps.length) {
  310. this.manager.recipe.addOperation(matchedOps[0].name);
  311. }
  312. // Populate search with the string
  313. const search = document.getElementById("search");
  314. search.value = this.uriParams.op;
  315. search.dispatchEvent(new Event("search"));
  316. }
  317. }
  318. // Read in input data from URI params
  319. if (this.uriParams.input) {
  320. try {
  321. const inputData = Utils.fromBase64(this.uriParams.input);
  322. this.setInput(inputData);
  323. } catch (err) {}
  324. }
  325. // Unpause auto-bake
  326. this.autoBakePause = false;
  327. this.autoBake();
  328. };
  329. /**
  330. * Returns the next ingredient ID and increments it for next time.
  331. *
  332. * @returns {number}
  333. */
  334. App.prototype.nextIngId = function() {
  335. return this.ingId++;
  336. };
  337. /**
  338. * Gets the current recipe configuration.
  339. *
  340. * @returns {Object[]}
  341. */
  342. App.prototype.getRecipeConfig = function() {
  343. return this.manager.recipe.getConfig();
  344. };
  345. /**
  346. * Given a recipe configuration, sets the recipe to that configuration.
  347. *
  348. * @param {Object[]} recipeConfig - The recipe configuration
  349. */
  350. App.prototype.setRecipeConfig = function(recipeConfig) {
  351. document.getElementById("rec-list").innerHTML = null;
  352. for (let i = 0; i < recipeConfig.length; i++) {
  353. const item = this.manager.recipe.addOperation(recipeConfig[i].op);
  354. // Populate arguments
  355. const args = item.querySelectorAll(".arg");
  356. for (let j = 0; j < args.length; j++) {
  357. if (recipeConfig[i].args[j] === undefined) continue;
  358. if (args[j].getAttribute("type") === "checkbox") {
  359. // checkbox
  360. args[j].checked = recipeConfig[i].args[j];
  361. } else if (args[j].classList.contains("toggle-string")) {
  362. // toggleString
  363. args[j].value = recipeConfig[i].args[j].string;
  364. args[j].previousSibling.children[0].innerHTML =
  365. Utils.escapeHtml(recipeConfig[i].args[j].option) +
  366. " <span class='caret'></span>";
  367. } else {
  368. // all others
  369. args[j].value = recipeConfig[i].args[j];
  370. }
  371. }
  372. // Set disabled and breakpoint
  373. if (recipeConfig[i].disabled) {
  374. item.querySelector(".disable-icon").click();
  375. }
  376. if (recipeConfig[i].breakpoint) {
  377. item.querySelector(".breakpoint").click();
  378. }
  379. this.progress = 0;
  380. }
  381. };
  382. /**
  383. * Resets the splitter positions to default.
  384. */
  385. App.prototype.resetLayout = function() {
  386. this.columnSplitter.setSizes([20, 30, 50]);
  387. this.ioSplitter.setSizes([50, 50]);
  388. this.manager.controls.adjustWidth();
  389. this.manager.output.adjustWidth();
  390. };
  391. /**
  392. * Sets the compile message.
  393. */
  394. App.prototype.setCompileMessage = function() {
  395. // Display time since last build and compile message
  396. let now = new Date(),
  397. timeSinceCompile = Utils.fuzzyTime(now.getTime() - window.compileTime);
  398. // Calculate previous version to compare to
  399. let prev = PKG_VERSION.split(".").map(n => {
  400. return parseInt(n, 10);
  401. });
  402. if (prev[2] > 0) prev[2]--;
  403. else if (prev[1] > 0) prev[1]--;
  404. else prev[0]--;
  405. const compareURL = `https://github.com/gchq/CyberChef/compare/v${prev.join(".")}...v${PKG_VERSION}`;
  406. let compileInfo = `<a href='${compareURL}'>Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago</a>`;
  407. if (window.compileMessage !== "") {
  408. compileInfo += " - " + window.compileMessage;
  409. }
  410. document.getElementById("notice").innerHTML = compileInfo;
  411. };
  412. /**
  413. * Pops up a message to the user and writes it to the console log.
  414. *
  415. * @param {string} str - The message to display (HTML supported)
  416. * @param {string} style - The colour of the popup
  417. * "danger" = red
  418. * "warning" = amber
  419. * "info" = blue
  420. * "success" = green
  421. * @param {number} timeout - The number of milliseconds before the popup closes automatically
  422. * 0 for never (until the user closes it)
  423. * @param {boolean} [silent=false] - Don't show the message in the popup, only print it to the
  424. * console
  425. *
  426. * @example
  427. * // Pops up a red box with the message "[current time] Error: Something has gone wrong!"
  428. * // that will need to be dismissed by the user.
  429. * this.alert("Error: Something has gone wrong!", "danger", 0);
  430. *
  431. * // Pops up a blue information box with the message "[current time] Happy Christmas!"
  432. * // that will disappear after 5 seconds.
  433. * this.alert("Happy Christmas!", "info", 5000);
  434. */
  435. App.prototype.alert = function(str, style, timeout, silent) {
  436. const time = new Date();
  437. console.log("[" + time.toLocaleString() + "] " + str);
  438. if (silent) return;
  439. style = style || "danger";
  440. timeout = timeout || 0;
  441. let alertEl = document.getElementById("alert"),
  442. alertContent = document.getElementById("alert-content");
  443. alertEl.classList.remove("alert-danger");
  444. alertEl.classList.remove("alert-warning");
  445. alertEl.classList.remove("alert-info");
  446. alertEl.classList.remove("alert-success");
  447. alertEl.classList.add("alert-" + style);
  448. // If the box hasn't been closed, append to it rather than replacing
  449. if (alertEl.style.display === "block") {
  450. alertContent.innerHTML +=
  451. "<br><br>[" + time.toLocaleTimeString() + "] " + str;
  452. } else {
  453. alertContent.innerHTML =
  454. "[" + time.toLocaleTimeString() + "] " + str;
  455. }
  456. // Stop the animation if it is in progress
  457. $("#alert").stop();
  458. alertEl.style.display = "block";
  459. alertEl.style.opacity = 1;
  460. if (timeout > 0) {
  461. clearTimeout(this.alertTimeout);
  462. this.alertTimeout = setTimeout(function(){
  463. $("#alert").slideUp(100);
  464. }, timeout);
  465. }
  466. };
  467. /**
  468. * Pops up a box asking the user a question and sending the answer to a specified callback function.
  469. *
  470. * @param {string} title - The title of the box
  471. * @param {string} body - The question (HTML supported)
  472. * @param {function} callback - A function accepting one boolean argument which handles the
  473. * response e.g. function(answer) {...}
  474. * @param {Object} [scope=this] - The object to bind to the callback function
  475. *
  476. * @example
  477. * // Pops up a box asking if the user would like a cookie. Prints the answer to the console.
  478. * this.confirm("Question", "Would you like a cookie?", function(answer) {console.log(answer);});
  479. */
  480. App.prototype.confirm = function(title, body, callback, scope) {
  481. scope = scope || this;
  482. document.getElementById("confirm-title").innerHTML = title;
  483. document.getElementById("confirm-body").innerHTML = body;
  484. document.getElementById("confirm-modal").style.display = "block";
  485. this.confirmClosed = false;
  486. $("#confirm-modal").modal()
  487. .one("show.bs.modal", function(e) {
  488. this.confirmClosed = false;
  489. }.bind(this))
  490. .one("click", "#confirm-yes", function() {
  491. this.confirmClosed = true;
  492. callback.bind(scope)(true);
  493. $("#confirm-modal").modal("hide");
  494. }.bind(this))
  495. .one("hide.bs.modal", function(e) {
  496. if (!this.confirmClosed)
  497. callback.bind(scope)(false);
  498. this.confirmClosed = true;
  499. }.bind(this));
  500. };
  501. /**
  502. * Handler for the alert close button click event.
  503. * Closes the alert box.
  504. */
  505. App.prototype.alertCloseClick = function() {
  506. document.getElementById("alert").style.display = "none";
  507. };
  508. /**
  509. * Handler for CyerChef statechange events.
  510. * Fires whenever the input or recipe changes in any way.
  511. *
  512. * @listens Manager#statechange
  513. * @param {event} e
  514. */
  515. App.prototype.stateChange = function(e) {
  516. this.autoBake();
  517. // Set title
  518. const recipeConfig = this.getRecipeConfig();
  519. let title = "CyberChef";
  520. if (recipeConfig.length === 1) {
  521. title = `${recipeConfig[0].op} - ${title}`;
  522. } else if (recipeConfig.length > 1) {
  523. // See how long the full recipe is
  524. const ops = recipeConfig.map(op => op.op).join(", ");
  525. if (ops.length < 45) {
  526. title = `${ops} - ${title}`;
  527. } else {
  528. // If it's too long, just use the first one and say how many more there are
  529. title = `${recipeConfig[0].op}, ${recipeConfig.length - 1} more - ${title}`;
  530. }
  531. }
  532. document.title = title;
  533. // Update the current history state (not creating a new one)
  534. if (this.options.updateUrl) {
  535. this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, recipeConfig);
  536. window.history.replaceState({}, title, this.lastStateUrl);
  537. }
  538. };
  539. /**
  540. * Handler for the history popstate event.
  541. * Reloads parameters from the URL.
  542. *
  543. * @param {event} e
  544. */
  545. App.prototype.popState = function(e) {
  546. this.loadURIParams();
  547. };
  548. /**
  549. * Function to call an external API from this view.
  550. */
  551. App.prototype.callApi = function(url, type, data, dataType, contentType) {
  552. type = type || "POST";
  553. data = data || {};
  554. dataType = dataType || undefined;
  555. contentType = contentType || "application/json";
  556. let response = null,
  557. success = false;
  558. $.ajax({
  559. url: url,
  560. async: false,
  561. type: type,
  562. data: data,
  563. dataType: dataType,
  564. contentType: contentType,
  565. success: function(data) {
  566. success = true;
  567. response = data;
  568. },
  569. error: function(data) {
  570. success = false;
  571. response = data;
  572. },
  573. });
  574. return {
  575. success: success,
  576. response: response
  577. };
  578. };
  579. export default App;