diff --git a/.gitignore b/.gitignore index 23ecad7..bcaf5b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .vscode -.temp \ No newline at end of file +.temp +.DS_Store \ No newline at end of file diff --git a/background.js b/background.js index d8f9190..d3cda39 100644 --- a/background.js +++ b/background.js @@ -1,14 +1,430 @@ -// FIXME: Init storage def values -// chrome.runtime.onInstalled.addListener(({ reason }) => { -// if (reason === 'install') { -// chrome.storage.local.set({ -// apiSuggestions: ['tabs', 'storage', 'scripting'] -// }); -// } -// }); - -// chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { -// if (msg.query && msg.query == "getTabId") { -// sendResponse(sender.tab.id); -// } -// }); +class Constants { + // All constants added here are accessible externally + // If needed, restrict access for certain constants + THEMES = { + LIGHT: "light", + DARK: "dark" + }; + VAR_PREFIX = "var_"; + PRE_ENC_CHAR = "_"; + DEFAULT_LOCK_TAG = "#private"; + RELOAD_REASONS = { + UPDATE: "extensionUpdate", + KEY_CHANGE: "keyChange", + TAG_CHANGE: "tagChange" + }; + ACTIONS = { + SET_KEY: "setLockKey", + MOVE_KEY: "migrateLockKey", + OPEN_WORKFLOWY: "openWorkflowy", + WELCOME: "welcome" + }; + + get(key) { + return this[key]; + } +} +const c = new Constants(); + +class ExtensionStorage { + async get(key, defVal = null) { + return new Promise(resolve => { + chrome.storage.local.get(key, function(data) { + resolve(data[key] || defVal); + }); + }); + } + + async set(key, val) { + return new Promise(resolve => { + chrome.storage.local.set({[key]: val}, function() { + resolve(); + }); + }); + } + + async setVar(key, val) { + return await this.set(c.VAR_PREFIX + key, val); + } + + async getVar(key, defVal = null) { + return await this.get(c.VAR_PREFIX + key, defVal); + } +} +const storage = new ExtensionStorage(); + +class Cache { + async get(key, defVal = null) { + let cacheData = await storage.get("lockCache", undefined); + cacheData = (cacheData !== null && cacheData !== undefined) ? JSON.parse(cacheData) : {}; + if (cacheData[key]) { + cacheData[key].lastAccessed = Date.now(); + await storage.set("lockCache", JSON.stringify(cacheData)); + return cacheData[key].val; + } + return defVal; + } + + async set(key, val) { + let cacheData = await storage.get("lockCache", undefined); + cacheData = (cacheData !== null && cacheData !== undefined) ? JSON.parse(cacheData) : {}; + cacheData[key] = { + val: val, + lastAccessed: Date.now() + }; + await storage.set("lockCache", JSON.stringify(cacheData)); + } + + async clear(light = true) { + if (!light) { + await storage.set("lockCache", undefined); + return; + } + + let cacheData = await storage.get("lockCache", undefined); + cacheData = (cacheData !== null && cacheData !== undefined) ? JSON.parse(cacheData) : {}; + + let now = Date.now(); + let lifeDuration = 1000 * 60 * 60 * 24 * 7; // 1 week + for (let key in cacheData) { + if (now > lifeDuration + cacheData[key].lastAccessed) { + delete cacheData[key]; + } + } + + await storage.set("lockCache", JSON.stringify(cacheData)); + } +} +const cache = new Cache(); + +class Utils { + async getLockTag() { + return await storage.get("lockTag", c.DEFAULT_LOCK_TAG); + } + + async broadcastReload(reason = null) { + await storage.setVar("reloadBroadcast", { + reason: reason, + time: new Date().getTime() + }); + } + + async getResUrl(path) { + return chrome.runtime.getURL(path); + } +} +const utils = new Utils(); + +class Encrypter { + SECRET = null; + enc; + dec; + + constructor() { + this.enc = new TextEncoder(); + this.dec = new TextDecoder(); + } + + async loadSecret() { + this.SECRET = await storage.get("lockSecret", null); + } + + async secretLoaded(bypassBlockerCheck = false) { + if (!bypassBlockerCheck && (await this.getBlocker()) !== null) { + return false; + } + + await this.loadSecret(); + return await this.isValidSecret(this.SECRET); + } + + async isValidSecret(secret) { + return secret !== null && secret !== "null" && secret !== ""; + } + + async getBlocker() { + return await storage.get("blocker", null); + } + + async setBlocker(blocker, bypassCheck = false) { + if (bypassCheck || (await this.getBlocker()) === null) { + await storage.set("blocker", blocker); + } + } + + async encrypt(data) { + if (!(await this.secretLoaded())) { + return data; + } + const encryptedData = await this.encryptData(data, this.SECRET); + await cache.set(c.PRE_ENC_CHAR + encryptedData, data); + return c.PRE_ENC_CHAR + encryptedData; + } + + async decrypt(data) { + if ( + (!data.startsWith(c.PRE_ENC_CHAR)) || + (!(await this.secretLoaded())) + ) { + return data; + } + + let cachedDecryptedData = await cache.get(data, null); + if (cachedDecryptedData !== null && cachedDecryptedData !== undefined) { + return cachedDecryptedData; + } + + let origData = data; + data = data.substring(c.PRE_ENC_CHAR.length); + const decryptedData = await this.decryptData(data, this.SECRET); + await cache.set(origData, decryptedData); + return decryptedData || data; + } + + // Encryption helper functions [https://github.com/bradyjoslin/webcrypto-example] + buff_to_base64 = (buff) => btoa( + new Uint8Array(buff).reduce( + (data, byte) => data + String.fromCharCode(byte), '' + ) + ); + + base64_to_buf = (b64) => + Uint8Array.from(atob(b64), (c) => c.charCodeAt(null)); + + getPasswordKey = (password) => + crypto.subtle.importKey("raw", this.enc.encode(password), "PBKDF2", false, [ + "deriveKey", + ]); + + deriveKey = (passwordKey, salt, keyUsage) => + crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 250000, + hash: "SHA-256", + }, + passwordKey, + { name: "AES-GCM", length: 256 }, + false, + keyUsage + ); + + async encryptData(secretData, password) { + try { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const passwordKey = await this.getPasswordKey(password); + const aesKey = await this.deriveKey(passwordKey, salt, ["encrypt"]); + const encryptedContent = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + aesKey, + this.enc.encode(secretData) + ); + + const encryptedContentArr = new Uint8Array(encryptedContent); + let buff = new Uint8Array( + salt.byteLength + iv.byteLength + encryptedContentArr.byteLength + ); + buff.set(salt, 0); + buff.set(iv, salt.byteLength); + buff.set(encryptedContentArr, salt.byteLength + iv.byteLength); + const base64Buff = this.buff_to_base64(buff); + return base64Buff; + } catch (e) { + console.warn(`[Workflowy Encrypter] Encryption error`, e); + return ""; + } + } + + async decryptData(encryptedData, password) { + try { + const encryptedDataBuff = this.base64_to_buf(encryptedData); + const salt = encryptedDataBuff.slice(0, 16); + const iv = encryptedDataBuff.slice(16, 16 + 12); + const data = encryptedDataBuff.slice(16 + 12); + const passwordKey = await this.getPasswordKey(password); + const aesKey = await this.deriveKey(passwordKey, salt, ["decrypt"]); + const decryptedContent = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + aesKey, + data + ); + return this.dec.decode(decryptedContent); + } catch (e) { + console.warn(`[Workflowy Encrypter] Encryption error`, e); + return ""; + } + } +} +const encrypter = new Encrypter(); + +class InstallHandler { + async onInstall(welcome = true) { + if (welcome) { + // Open Workflowy to set lock key + await encrypter.setBlocker(c.ACTIONS.WELCOME); + await this.openOptionsPage(c.ACTIONS.OPEN_WORKFLOWY); + } + } + + async onUpdate() { + // Handle backward compatibility related actions + let prevVersionId = await storage.get("versionId", 0); + if (prevVersionId === 0) { + if (!(await encrypter.secretLoaded())) { + // TODO: Inject script to reload open workflowy pages + await encrypter.setBlocker(c.ACTIONS.MOVE_KEY); + } + return await this.onInstall(false); + } + } + + async onListenerAction(reason) { + await staller.waitUntilReady(); + switch (reason) { + case 'install': + await this.onInstall(); + break; + case 'update': + await this.onUpdate(); + break; + } + await storage.set("versionId", this.getVersionId()); + } + + async openOptionsPage(action = null, arg = null) { + await storage.set("optionsAction", [action, arg]); + chrome.runtime.openOptionsPage(); + } + + getVersionId() { + // Workflowy Enxcrypter uses semantic versioning (MAJOR.MINOR.PATCH) + // Each version component is assumed to be max 2 digits long + const currentVersion = chrome.runtime.getManifest().version; + let versionId = ""; + for (let versionComponent of currentVersion.split(".")) { + versionId += versionComponent.padStart(2, "0"); + } + return parseInt(versionId); + } +} +const installHandler = new InstallHandler(); + +class ExtensionGatewayHandler { + funcMapper(func, internal) { + // Define externally accessible functions + const publicFunctions = ["encrypt", "decrypt", "secretLoaded", "getBlocker", "setBlocker", "clearCache", "openOptionsPage", "setVar", "getVar", "getConstant", "getLockTag", "getResUrl"]; + if (internal === false && !publicFunctions.includes(func)) { + return null; + } + + // All functions need to be async + switch (func) { + // Public + case "encrypt": + return encrypter.encrypt.bind(encrypter); + case "decrypt": + return encrypter.decrypt.bind(encrypter); + case "secretLoaded": + return encrypter.secretLoaded.bind(encrypter); + case "getBlocker": + return encrypter.getBlocker.bind(encrypter); + case "setBlocker": + return encrypter.setBlocker.bind(encrypter); + case "clearCache": + return cache.clear.bind(cache); + case "openOptionsPage": + return installHandler.openOptionsPage.bind(installHandler); + case "setVar": + return storage.setVar.bind(storage); + case "getVar": + return storage.getVar.bind(storage); + case "getConstant": + return c.get.bind(c); + case "getLockTag": + return utils.getLockTag.bind(utils); + case "getResUrl": + return utils.getResUrl.bind(utils); + + // Private + case "setStorage": + return storage.set.bind(storage); + case "getStorage": + return storage.get.bind(storage); + case "loadSecret": + return encrypter.loadSecret.bind(encrypter); + case "isValidSecret": + return encrypter.isValidSecret.bind(encrypter); + case "broadcastReload": + return utils.broadcastReload.bind(utils); + + default: + return null; + } + } + + async funcCallHandler(func, params, internal) { + await staller.waitUntilReady(); + let callableFunc = this.funcMapper(func, internal); + if (callableFunc) { + return await callableFunc(...params); + } + return {result: "error", message: "Function not found"}; + } + + initialFuncCallHandler(request, sender, sendResponse, internal = false) { + if (!request.func || !request.params) { + sendResponse({result: "error", message: "Invalid request"}); + return; + } + + this.funcCallHandler(request.func, request.params, internal).then(sendResponse); + return true; + } +} +const gateway = new ExtensionGatewayHandler(); + +class Staller { + static items = []; + static ready = false; + + static addItem(resolve) { + Staller.items.push(resolve); + } + + waitUntilReady() { + return new Promise(resolve => { + if (Staller.ready) { + return resolve(); + } + Staller.addItem(resolve); + }); + } + + ready() { + Staller.ready = true; + for (let resolve of Staller.items) { + resolve(); + } + Staller.items = []; + } +} +const staller = new Staller(); + +chrome.runtime.onInstalled.addListener(({ reason }) => installHandler.onListenerAction(reason)); +chrome.runtime.onMessageExternal.addListener((request, sender, sendResponse) => gateway.initialFuncCallHandler(request, sender, sendResponse)); +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => gateway.initialFuncCallHandler(request, sender, sendResponse, true)); + +(async () => { + // Init + await encrypter.loadSecret(); + + staller.ready(); +})(); \ No newline at end of file diff --git a/layouts/popup_welcome_1.html b/layouts/popup_welcome_1.html index cb44a4a..c80c371 100644 --- a/layouts/popup_welcome_1.html +++ b/layouts/popup_welcome_1.html @@ -3,8 +3,8 @@

Workflowy Encrypter

- -

_ciSw6eyI9hOthlspe

+ +

_ciSw6eyI9hOthlspe

\ No newline at end of file diff --git a/layouts/popup_welcome_2_loader.html b/layouts/popup_welcome_2_loader.html new file mode 100644 index 0000000..8970252 --- /dev/null +++ b/layouts/popup_welcome_2_loader.html @@ -0,0 +1,4 @@ + diff --git a/layouts/popup_welcome_4.html b/layouts/popup_welcome_4.html index fe9c42b..11db820 100644 --- a/layouts/popup_welcome_4.html +++ b/layouts/popup_welcome_4.html @@ -2,7 +2,7 @@
- +
\ No newline at end of file diff --git a/manifest.json b/manifest.json index 86eb112..97caf85 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Workflowy Encrypter", - "version": "1.0.3", + "version": "1.1.0", "description": "Seamless client-side encryption for Workflowy", "author": "contact@alpafyonluoglu.dev", "icons": { @@ -11,7 +11,7 @@ "128": "/src/logo_128.png" }, "content_security_policy": { - "extension_pages": "default-src 'self'" + "extension_pages": "default-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com" }, "action": { "default_title": "Workflowy Encrypter", @@ -21,6 +21,9 @@ "service_worker": "./background.js", "type": "module" }, + "externally_connectable": { + "matches": ["*://*.workflowy.com/*"] + }, "content_scripts": [ { "matches": ["*://*.workflowy.com/*"], @@ -28,6 +31,7 @@ "js": ["/scripts/content.js"] } ], + "options_page": "options/options.html", "web_accessible_resources": [ { "resources": [ @@ -51,6 +55,7 @@ "/layouts/popup_close.html", "/layouts/popup_welcome_1.html", "/layouts/popup_welcome_2.html", + "/layouts/popup_welcome_2_loader.html", "/layouts/popup_welcome_3.html", "/layouts/popup_welcome_4.html" ], @@ -58,6 +63,7 @@ } ], "permissions": [ + "storage" ], "host_permissions": [ "*://*.workflowy.com/*" diff --git a/options/options.css b/options/options.css new file mode 100644 index 0000000..3a36bf4 --- /dev/null +++ b/options/options.css @@ -0,0 +1,405 @@ +/* TODO: Change color based on theme */ +body { + background: #2a3135; + padding: 0; + margin: 0; +} + +.top { + height: 48px; + background-color: #2a3135; + border-bottom: 1px solid #43484b; + + display: flex; + align-items: center; +} + +.top-logo { + width: 18px; + height: 18px; + margin-right: 4px; + margin-left: 129px; + user-select: none; +} + +.top-separator { + color: #9ea1a2; + margin-left: 8px; +} + +.top-first-separator { + margin-left: 13px; +} + +.top-text { + color: #9ea1a2; + font-family: Open Sans, sans-serif; + font-size: .84rem; + line-height: 1.2rem; + cursor: pointer; + margin-left: 8px; + vertical-align: baseline; + background: transparent; + user-select: none; + opacity: 0.7; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +.top-text:hover { + opacity: 1; + text-decoration: underline; +} + +.top-text-active { + opacity: 1; +} + +.content { + line-height: 34px; + height: 100px; + color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + + position: relative; + user-select: none; + margin-left: auto; + margin-right: auto; + max-width: 700px; + padding: 24px 56px; + margin-top: 23px; + text-size-adjust: none; +} + +.content-title { + margin: 0; + padding: 0; + line-height: 34px; + font-size: 27px; + font-weight: bold; + cursor: default; + margin-bottom: 14px; +} + +.node { + position: relative; + cursor: default; + margin-top: -.5px; + margin-bottom: 8.5px; + +} +.input-node { + display: flex; + flex-wrap: wrap; + margin-top: 48px; + margin-bottom: 24px; +} + +.node-text { + white-space: pre-wrap; + word-break: break-word; + font-weight: normal; + + margin: 0; + padding: 0; + line-height: 24px; + font-size: 15px; + margin-left: 24px; +} + +.input-node .node-text { + min-width: 96px; + font-size: 15px; + line-height: 30px; +} + +.node-subtext { + white-space: pre-wrap; + word-break: break-word; + font-weight: normal; + color: #9ea1a2; + + margin: 0; + padding: 0; + line-height: 17px; + font-size: 13px; + margin-left: 24px; + margin-bottom: 6px; +} + +.node-flex-break { + flex-basis: 100%; + height: 0; +} + +.node-input-subtext { + white-space: pre-wrap; + word-break: break-word; + font-weight: normal; + color: #9ea1a2; + + margin: 0; + padding: 0; + line-height: 17px; + font-size: 13px; + margin-top: 8px; + margin-left: 120px; + margin-bottom: 6px; +} + +.node-input { + display: flex; + flex-direction: column; + border-radius: 4px; + margin: 0px; + padding: 8px; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + + background: rgb(42, 49, 53); + color: rgb(255, 255, 255); + border: 1px solid rgb(92, 96, 98); +} +.node-input-inner { + display: flex; + margin-top: 12px; + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-size: 100%; + vertical-align: baseline; + background: transparent; + color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +.node-circle { + background: #5c6062; + position: absolute; + display: inline-block; + left: 1px; + top: 3px; + width: 18px; + height: 18px; + border-radius: 50%; + color: #d9dbdb; + cursor: pointer; +} + +.clickable-node { + cursor: pointer; + margin-bottom: 4px; + margin-top: 0; +} +.clickable-node:hover .node-circle { + background: #7d7f81; +} + +.node-buttons { + text-align: center; + margin-top: 24px; +} + +.node-button { + text-rendering: auto; + letter-spacing: normal; + word-spacing: normal; + text-transform: none; + text-indent: 0px; + text-shadow: none; + align-items: flex-start; + margin: 0em; + padding-block: 1px; + padding-inline: 6px; + display: inline-block; + position: relative; + appearance: none; + border: none; + outline: none; + border-radius: 12px; + box-sizing: border-box; + line-height: 16px; + padding: 4px 12px; + background: rgb(236, 238, 240); + color: rgb(42, 49, 53); + font-family: inherit; + font-weight: 500; + font-size: 12px; + text-align: center; + text-decoration: none; + cursor: default; + -webkit-tap-highlight-color: transparent; + + background: rgb(66, 72, 75); + color: rgb(255, 255, 255); +} +.node-button:hover { + background: rgb(94, 96, 98); +} + +.banner { + position: relative; + /* background-color: #6989ad; */ + background-color: #4f6782; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + box-sizing: border-box; + padding: 20px; + margin-top: 12px; + outline: 1.5px solid #8ea6c2; + margin-bottom: 20px; + overflow: hidden; +} + +.animated-banner { + background-color: black; + color: white; + outline: transparent; +} + +.banner-animated-background { + position: relative; + white-space: initial; + word-wrap: break-word; + width: 100%; + height: 130px; + padding: 0; + margin: 0; + + font-family: "Nto Sans Moono", monospace; + font-variation-settings: "wdth" 100; + font-optical-sizing: auto; + font-weight: 500; + line-height: 12px; + font-size: 12px; +} + +.banner-foreground { + font-size: 124px; + font-weight: bold; + width: 100%; + height: 100%; + text-align: center; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + text-align: center; + vertical-align: middle; + line-height: 150px; + + font-family: "Varela Round", sans-serif; + font-weight: 600; + font-style: normal; + + background-color: black; + color: white; + mix-blend-mode: multiply; +} + +.banner-color { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background: #2a3135; + mix-blend-mode: lighten; +} + +.banner-anim-subtext { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 14px; + line-height: 14px; + position: absolute; + bottom: 14px; + color: #ffffff; + opacity: .8; + width: 460px; + text-align: right; + left: 0; + right: 0; + margin-inline: auto; +} + +.banner-title { + color: #ffffff; + font-family: Open Sans, sans-serif; + font-size: 16px; + font-weight: bold; + opacity: .95; + margin: 0 0 4px 0; + + line-height: 24px; + font-size: 15px; +} + +.banner-text { + color: #ffffff; + font-family: Open Sans, sans-serif; + font-size: 16px; + opacity: .9; + margin: 0 0 20px 0; + + line-height: 24px; + font-size: 15px; +} + +.banner-link { + color: #ffffff; + text-decoration: none; + text-decoration: underline; + opacity: .9; +} +.banner-link:hover { + opacity: 1; +} + +.banner-image { + position: absolute; + bottom: 18px; + right: 18px; + height: 22px; + opacity: .7; +} + +.transition { + transition: all .1s; +} + +.textbox { + margin: 0 auto; + width: 95%; +} + +.subtext { + color: #666; + word-break: normal; + cursor: default; + + white-space: pre-wrap; + font-size: 10px; + line-height: 14px; + text-align: center; + + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +.subtext a { + color: #666; +} + +.tag { + color: rgb(134, 140, 144); + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-size: 100%; + vertical-align: baseline; + background: transparent; + cursor: text; +} \ No newline at end of file diff --git a/options/options.html b/options/options.html new file mode 100644 index 0000000..990c670 --- /dev/null +++ b/options/options.html @@ -0,0 +1,22 @@ + + + + Workflowy Encrypter Options + + + + + + + +
+ +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..2b6da6a --- /dev/null +++ b/options/options.js @@ -0,0 +1,523 @@ +var action = null; +var actionArg = null; + +// IMPROVE: add back/forward buttons +// IMPROVE: change URL on pgae path change + +class BaseUtils { + sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + setAttributes(element, attributes) { + for (let key in attributes) { + element.setAttributeNS(null, key, attributes[key]); + } + } + + getPathString(path) { + return path.join(" > "); + } + + bannerLink(text, link) { + return "" + text + ""; + } + + bold(text) { + return "" + text + ""; + } + + // IMPROVE: common function + randomStr(length) { // [https://stackoverflow.com/a/1349426] + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + let counter = 0; + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + counter += 1; + } + return result; + } +} +const u = new BaseUtils(); + +class ExtensionGateway { + constructor() { + return new Proxy({}, { + get(target, key) { + return (...args) => { + return ExtensionGateway.call(key, ...args); + }; + } + }); + } + + static call(func, ...params) { + return new Promise(resolve => { + chrome.runtime.sendMessage({ + func: func, + params: params + }, + (response) => { + resolve(response); + } + ); + }); + } +} +const gateway = new ExtensionGateway(); + +class Constants { + PAGES = { + OPTIONS: ["Options"], + SET_KEY: ["Options", "Set Key"], + MOVE_KEY: ["Options", "Move Key"] + }; + + constructor() { + return new Proxy(this, { + set(target, key, value) { + if (key in target && target[key] !== undefined) { + return false; + } + return (target[key] = value); + }, + deleteProperty(target, key) { + return false; + } + }); + } + + async init() { + const constantsToFetch = ["RELOAD_REASONS", "ACTIONS"]; + for (let key of constantsToFetch) { + this[key] = await gateway.getConstant(key); + } + } +} +const c = new Constants(); + +class ContentManager { + async getBannerContent() { + const secretLoaded = await gateway.secretLoaded(true); + const blocker = await gateway.getStorage("blocker", null); + if (secretLoaded && blocker === null) { + return null; + } + + switch (blocker) { + case c.ACTIONS.WELCOME: + return { + title: "Let's Secure Your Data", + text: "Welcome to Workflowy Encrypter! To get started, visit " + u.bannerLink("workflowy.com", "https://workflowy.com/") + "." + }; + case c.ACTIONS.MOVE_KEY: + return { + title: "A Little Rearrangement", + text: "We are updating the location where your key is stored on your device to enhance its security. Visit " + u.bannerLink("workflowy.com", "https://workflowy.com/") + " to finish setting things up." + }; + default: // setLockKey + return { + title: "Encryption disabled", + text: "Workflowy Encrypter cannot access your key. Visit " + u.bannerLink("workflowy.com", "https://workflowy.com/") + " to set your key." + }; + } + } + + async loadOptionsContent(parent) { + parent.appendChild(pageManager.createTextNode("Feel the comfort of privacy... Use the options given below to customize Workflowy Encrypter just the way you want.")); + parent.appendChild(pageManager.createTextNode("Set Key", "Set a key to be used to encrypt your data.", () => { + pageManager.setPage(c.PAGES.SET_KEY); + })); + // TODO: Set #private tag + // TODO: about & contact page (github page, rate us (on Chrome Webstore), contact developer, version number) + } + + async loadSetKeyContent(parent, props = {}) { + let text1 = props.moveText ? props.moveText : "Register your key that will be used to encrypt your data. If this is your first time here, just enter a new key and make sure to note it down."; + let text2 = props.moveText ? null : u.bold("It will be impossible to recover your encrypted data if you forget your key."); + + parent.appendChild(pageManager.createTextNode(text1)); + if (text2) { + parent.appendChild(pageManager.createTextNode(text2)); + } + parent.appendChild(pageManager.createInputNode("Key", "secret", "key-input")); + const lockSecret = await gateway.getStorage("lockSecret", null); + document.getElementById("key-input").value = props.moveText ? props.secretToMove : lockSecret; + + parent.appendChild(pageManager.createButtonNode([ + { + text: "Save", + onclick: async () => { + const subtext = document.getElementById("key-input-subtext"); + const key = document.getElementById("key-input").value; + if (!(await gateway.isValidSecret(key.replaceAll(" ", "")))) { + subtext.textContent = "Invalid key"; + subtext.style.visibility = "visible"; + return + } + + if (!props.moveText) { + // TODO: show warning if an existing key is about to be overriden ("existing encrypted data will not be accessible.") + } + + await gateway.broadcastReload(c.RELOAD_REASONS.KEY_CHANGE); + await gateway.clearCache(false); + await gateway.setStorage("lockSecret", key); + await gateway.loadSecret(); + if (props.onsave) { + await props.onsave(); + } + subtext.textContent = [c.ACTIONS.SET_KEY, c.ACTIONS.MOVE_KEY].includes(action) ? "Key saved! You can close this tab now." : "Key saved!"; + subtext.style.visibility = "visible"; + } + } + ])); + + // IMPROVE: warning subtext visibility + } + + async loadMoveKeyContent(parent) { + await this.loadSetKeyContent(parent, { + moveText: "Confirm your key below to move it to its new location. From now on, you will be able to manage your key and customize the encryption options from the extension options page.", + secretToMove: actionArg, + onsave: async () => { + await gateway.setVar("keyMoved", true); + await gateway.setStorage("blocker", null); + } + }); + } +} +const content = new ContentManager(); + +class PageManager { + async setPage(path) { + await this.updatePageContent(path, path.length === 1); + } + + async updatePageContent(path, showBanner = false) { + this.updateTopBar(path); + + const contentParent = document.getElementById("content"); + contentParent.innerHTML = ""; + + contentParent.appendChild(this.createTitle(path[path.length - 1])); + if (showBanner) { + let [banner, animate] = await this.createBanner(); + if (banner) { + contentParent.appendChild(banner); + if (animate) { + animator.bannerAnim(); + } + } + } + + // Load content + switch (u.getPathString(path)) { + case u.getPathString(c.PAGES.OPTIONS): + await content.loadOptionsContent(contentParent); + break; + case u.getPathString(c.PAGES.SET_KEY): + await content.loadSetKeyContent(contentParent); + break; + case u.getPathString(c.PAGES.MOVE_KEY): + await content.loadMoveKeyContent(contentParent); + break; + } + } + + createTitle(text) { + const title = document.createElement("p"); + title.classList.add("content-title"); + title.textContent = text; + return title; + } + + async createBanner() { + const banner = document.createElement("div"); + banner.classList.add("banner"); + let animate = false; + + let bannerContent = await content.getBannerContent(); + if (bannerContent === null) { + animate = true; + banner.classList.add("animated-banner"); + + const animatedBackground = document.createElement("p"); + animatedBackground.classList.add("banner-animated-background"); + animatedBackground.id = "bannerAnimatedBackground"; + banner.appendChild(animatedBackground); + + const foreground = document.createElement("div"); + foreground.classList.add("banner-foreground"); + foreground.textContent = "#private"; + banner.appendChild(foreground); + + const color = document.createElement("div"); + color.classList.add("banner-color"); + banner.appendChild(color); + + const subtext = document.createElement("p"); + subtext.classList.add("banner-anim-subtext"); + subtext.innerHTML = "with " + u.bold("Workflowy Encrypter"); + banner.appendChild(subtext); + } else { + const title = document.createElement("p"); + title.classList.add("banner-title"); + title.textContent = bannerContent.title; + banner.appendChild(title); + + const text = document.createElement("p"); + text.classList.add("banner-text"); + text.innerHTML = bannerContent.text; + banner.appendChild(text); + + const img = document.createElement("img"); + img.classList.add("banner-image"); + img.src = "/src/logo_w_128.png"; + banner.appendChild(img); + + // IMPROVE: Add button to banner + } + + return [banner, animate]; + } + + createButtonNode(buttons) { + // ""; + const node = document.createElement("div"); + node.classList.add("node-buttons"); + node.classList.add("node"); + + for (let button of buttons) { + const buttonElement = document.createElement("button"); + buttonElement.classList.add("node-button"); + buttonElement.textContent = button.text; + buttonElement.addEventListener('click', button.onclick); + node.appendChild(buttonElement); + } + + return node; + } + + createInputNode(text, hintText, inputId) { + const node = document.createElement("div"); + node.classList.add("node"); + node.classList.add("input-node"); + + const textElement = document.createElement("p"); + textElement.classList.add("node-text"); + textElement.innerHTML = text; + node.appendChild(textElement); + + const nodeInput = document.createElement("div"); + nodeInput.classList.add("node-input"); + node.appendChild(nodeInput); + + const input = document.createElement("input"); + input.classList.add("node-input-inner"); + input.type = "text"; + input.placeholder = hintText; + input.id = inputId; + nodeInput.appendChild(input); + + const breakElement = document.createElement("div"); + breakElement.classList.add("node-flex-break"); + node.appendChild(breakElement); + + const subtextElement = document.createElement("p"); + subtextElement.classList.add("node-input-subtext"); + subtextElement.textContent = "."; + subtextElement.style.visibility = "hidden"; + subtextElement.id = inputId + "-subtext"; + node.appendChild(subtextElement); + + return node; + } + + createTextNode(text, subtext = null, func = null) { + const clickable = func !== null; + const node = document.createElement("div"); + node.classList.add("node"); + if (clickable) { + node.classList.add("clickable-node"); + node.addEventListener('click', func); + } + + if (clickable) { + const circle = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + u.setAttributes(circle, { + "width": "100%", + "height": "100%", + "viewBox": "0 0 18 18", + "fill": "currentColor", + "class": "node-circle" + }); + const circlePoint = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + u.setAttributes(circlePoint, { + "cx": "9", + "cy": "9", + "r": "3.5" + }); + circle.appendChild(circlePoint); + node.appendChild(circle); + } + + const textElement = document.createElement("p"); + textElement.classList.add("node-text"); + if (!clickable) { + textElement.classList.add("content-text"); + } + textElement.innerHTML = text; + node.appendChild(textElement); + + if (subtext) { + const subtextElement = document.createElement("p"); + subtextElement.classList.add("node-subtext"); + subtextElement.textContent = subtext; + node.appendChild(subtextElement); + } + + return node; + } + + updateTopBar(path) { + const top = document.getElementById("top"); + top.innerHTML = ""; + + const img = document.createElement("img"); + img.classList.add("top-logo"); + img.src = "/src/logo_outline_dark_32.png"; + img.alt = "Logo"; + top.appendChild(img); + + for (let i = 0; i < path.length; i++) { + // IMPROVE: prevent innerHTML use + top.innerHTML += ""; + + let p = document.createElement("p"); + p.id = "top-text-" + i; + p.classList.add("top-text"); + if (i === path.length - 1) { + p.classList.add("top-text-active"); + } + p.textContent = path[i]; + top.appendChild(p); + } + + // Moved onclick assignments to new loop due to innerHTML use + for (let i = 0; i < path.length; i++) { + document.getElementById("top-text-" + i).addEventListener('click', () => { + this.setPage(path.slice(0, i + 1)); + }); + } + } +} +const pageManager = new PageManager(); + +class Staller { + static items = []; + static ready = false; + + static addItem(resolve) { + Staller.items.push(resolve); + } + + waitUntilReady() { + return new Promise(resolve => { + if (Staller.ready) { + return resolve(); + } + Staller.addItem(resolve); + }); + } + + ready() { + Staller.ready = true; + for (let resolve of Staller.items) { + resolve(); + } + Staller.items = []; + } +} +const staller = new Staller(); + +// TODO: change onload w/ on focus +window.onload = async () => { + await staller.waitUntilReady(); + [action, actionArg] = await gateway.getStorage("optionsAction", [null, null]); + switch (action) { + case c.ACTIONS.OPEN_WORKFLOWY: + await gateway.setStorage("optionsAction", null); + window.location.replace("https://workflowy.com/"); + return; + case c.ACTIONS.SET_KEY: + await pageManager.setPage(c.PAGES.SET_KEY); + break; + case c.ACTIONS.MOVE_KEY: + await pageManager.setPage(c.PAGES.MOVE_KEY); + break; + default: + await pageManager.setPage(c.PAGES.OPTIONS); + break; + } + await gateway.setStorage("optionsAction", [null, null]); +} + +class Animator { + async bannerAnim() { + const element = document.getElementById("bannerAnimatedBackground"); + const height = element.clientHeight; + const width = element.clientWidth; + + let rows = Math.ceil(height / 12); + let cols = Math.floor(width / 12 * (5/3)); + let n = rows * cols; + + for (let i = 0; i < n; i++) { + // Create span element + let span = document.createElement("span"); + span.textContent = u.randomStr(1); + span.id = "bannerAnimatedSpan" + i; + element.appendChild(span); + } + + while (true) { + for (let i = 0; i < cols + rows; i++) { + // Uncomment for animation + for (let j = 0; j < rows; j++) { + if (i - j < 0 || i - j > cols - 1) { + continue; + } + let id = (i - j) + j * cols; + let element = document.getElementById("bannerAnimatedSpan" + id); + // element.classList.remove("transition"); + // element.style.opacity = 0; + element.textContent = u.randomStr(1); + } + // await u.sleep(0); + // for (let j = 0; j < rows; j++) { + // if (i - j < 0 || i - j > cols - 1) { + // continue; + // } + // let id = (i - j) + j * cols; + // let element = document.getElementById("bannerAnimatedSpan" + id); + // element.classList.add("transition"); + // element.style.opacity = 1; + // } + await u.sleep(20); + } + + await u.sleep(3200); + } + } +} +const animator = new Animator(); + +(async () => { + // Init + await c.init(); + + staller.ready(); +})(); \ No newline at end of file diff --git a/popup/popup.html b/popup/popup.html index 9e2a94b..cb5177e 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -11,5 +11,6 @@

Beta version | View on GitHub

+ \ No newline at end of file diff --git a/popup/popup.js b/popup/popup.js index 2c51449..b9b8896 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -14,3 +14,5 @@ document.addEventListener('DOMContentLoaded', function () { // Show version number document.getElementById("version").innerText = chrome.runtime.getManifest().version; + +// TODO: update lock key and add link to options page \ No newline at end of file diff --git a/scripts/content.js b/scripts/content.js index df639c4..ef032c2 100644 --- a/scripts/content.js +++ b/scripts/content.js @@ -1,24 +1,5 @@ -// Inject variables -injectVar("logoUrl", chrome.runtime.getURL('/src/logo_128.png')); -injectVar("logoWUrl", chrome.runtime.getURL('/src/logo_w_128.png')); -injectVar("keyUrl", chrome.runtime.getURL('/src/key_128.png')); -injectVar("ss1Url", chrome.runtime.getURL('/src/ss1.png')); -injectVar("ss1DarkUrl", chrome.runtime.getURL('/src/ss1_dark.png')); -injectVar("htmlToastContainer", chrome.runtime.getURL('/layouts/toast_container.html')); -injectVar("htmlPopupContainer", chrome.runtime.getURL('/layouts/popup_container.html')); -injectVar("htmlPopupClose", chrome.runtime.getURL('/layouts/popup_close.html')); -injectVar("htmlPopupWelcome1", chrome.runtime.getURL('/layouts/popup_welcome_1.html')); -injectVar("htmlPopupWelcome2", chrome.runtime.getURL('/layouts/popup_welcome_2.html')); -injectVar("htmlPopupWelcome3", chrome.runtime.getURL('/layouts/popup_welcome_3.html')); -injectVar("htmlPopupWelcome4", chrome.runtime.getURL('/layouts/popup_welcome_4.html')); -injectVar("cssWelcome", chrome.runtime.getURL('/styles/welcome.css')); -injectVar("cssWelcomeDark", chrome.runtime.getURL('/styles/welcome_dark.css')); -injectVar("cssPopup", chrome.runtime.getURL('/styles/popup.css')); -injectVar("cssPopupDark", chrome.runtime.getURL('/styles/popup_dark.css')); -injectVar("cssPopupType0", chrome.runtime.getURL('/styles/popup_type0.css')); -injectVar("cssPopupType0Dark", chrome.runtime.getURL('/styles/popup_type0_dark.css')); -injectVar("cssPopupType1", chrome.runtime.getURL('/styles/popup_type1.css')); -injectVar("cssPopupType1Dark", chrome.runtime.getURL('/styles/popup_type1_dark.css')); +// Inject +injectVar("extensionId", chrome.runtime.id); injectScript(chrome.runtime.getURL('/scripts/lock.js')); injectStyle(chrome.runtime.getURL('/styles/toast.css')); @@ -42,4 +23,4 @@ function injectVar(key, value) { variable.id = "wfe-internal-" + key; variable.setAttribute('value', value); document.body.appendChild(variable); -} \ No newline at end of file +} diff --git a/scripts/lock.js b/scripts/lock.js index f2456fa..d3e8b3c 100644 --- a/scripts/lock.js +++ b/scripts/lock.js @@ -1,12 +1,11 @@ -const DOMAIN = "https://workflowy.com"; -const LOCK_TAG = "#private"; -const PRE_ENC_CHAR = "_"; var shared = []; // Share IDs - var trackedChanges = []; var cacheClearPerformed = false; var quarantine = false; +var bypassLock = false; var theme = null; +var broadcastCheckTime = new Date().getTime(); +var pendingReload = false; var crosscheckUserId = ""; var clientId = ""; @@ -16,60 +15,11 @@ var mostRecentOperationTransactionId = ""; const {fetch: origFetch} = window; -const DEFAULT_SHARE_ID = 'DEFAULT'; - -const POPUP_TYPES = { - DEFAULT: 0, - MINI: 1 -}; - -const TOAST_STATES = { - HIDDEN: 0, - TRANSITIONING: 1, - SHOWN: 2 -}; - -const THEMES = { - LIGHT: "light", - DARK: "dark" -}; - -const PROPERTIES = { - NAME: "name", - DESCRIPTION: "description", - LOCKED: "locked", - PARENT: "parent", - SHARE_ID: "shareId", - LOCAL_ID: "localId" -}; - -const SENSITIVE_PROPERTIES = [ - PROPERTIES.NAME, - PROPERTIES.DESCRIPTION -]; - -const FLAGS = { - FORCE_DECRYPT: 0, - SUPPRESS_WARNINGS: 1, - NO_FETCH: 2, - TRACK_ENCRYPTION_CHANGES: 3, - IGNORE_NULL_PARENT: 4 -}; - -const OUTCOMES = { - IGNORE: -1, - CANCEL: 0, - PREV: 1, - NEXT: 2, - COMPLETE: 3, - CUSTOM: 4 -} - class BaseUtil { updateTheme() { var body = document.getElementsByTagName("body")[0]; var bodyBgColor = window.getComputedStyle(body, null).getPropertyValue("background-color"); - theme = bodyBgColor === "rgb(42, 49, 53)" ? THEMES.DARK : THEMES.LIGHT; + theme = bodyBgColor === "rgb(42, 49, 53)" ? c.THEMES.DARK : c.THEMES.LIGHT; } sleep(ms) { @@ -81,9 +31,9 @@ class BaseUtil { } endpointMatches(path, method, url, params) { - return url.includes(DOMAIN + path) && method === params.method; + return url.includes(c.DOMAIN + path) && method === params.method; } - + isString(val) { return typeof val === 'string' || val instanceof String; } @@ -101,7 +51,165 @@ class BaseUtil { } } const u = new BaseUtil(); -u.updateTheme(); + +class ExtensionGateway { + constructor() { + return new Proxy({}, { + get(target, key) { + return (...args) => { + return ExtensionGateway.call(key, ...args); + }; + } + }); + } + + static call(func, ...params) { + return new Promise(resolve => { + chrome.runtime.sendMessage(c.EXTENSION_ID, + { + func: func, + params: params + }, + (response) => { + resolve(response); + } + ); + }); + } +} +const gateway = new ExtensionGateway(); + +class Constants { + EXTENSION_ID = undefined; + LOCK_TAG = undefined; + DOMAIN = "https://workflowy.com"; + DEFAULT_SHARE_ID = 'DEFAULT'; + POPUP_TYPES = { + DEFAULT: 0, + MINI: 1 + }; + TOAST_STATES = { + HIDDEN: 0, + TRANSITIONING: 1, + SHOWN: 2 + }; + PROPERTIES = { + NAME: "name", + DESCRIPTION: "description", + LOCKED: "locked", + PARENT: "parent", + SHARE_ID: "shareId", + LOCAL_ID: "localId" + }; + SENSITIVE_PROPERTIES = [ + this.PROPERTIES.NAME, + this.PROPERTIES.DESCRIPTION + ]; + FLAGS = { + FORCE_DECRYPT: 0, + SUPPRESS_WARNINGS: 1, + NO_FETCH: 2, + TRACK_ENCRYPTION_CHANGES: 3, + IGNORE_NULL_PARENT: 4 + }; + OUTCOMES = { + IGNORE: -1, + CANCEL: 0, + PREV: 1, + NEXT: 2, + COMPLETE: 3, + CUSTOM: 4 + }; + + constructor() { + return new Proxy(this, { + set(target, key, value) { + if (key in target && target[key] !== undefined) { + return false; + } + return (target[key] = value); + }, + deleteProperty(target, key) { + return false; + } + }); + } + + async init() { + this.EXTENSION_ID = u.getInternalVar("extensionId"); + this.LOCK_TAG = await gateway.getLockTag(); + + const constantsToFetch = ["THEMES", "PRE_ENC_CHAR", "RELOAD_REASONS", "ACTIONS"]; + for (let key of constantsToFetch) { + this[key] = await gateway.getConstant(key); + } + } +} +const c = new Constants(); + +class FocusTracker { + action = null; + + async onChange() { + if (this.action !== null) { + return this.action(); + } + + const reloadBroadcast = await gateway.getVar("reloadBroadcast", null) ?? {}; + if ((reloadBroadcast.time !== undefined && reloadBroadcast.time > broadcastCheckTime) && !pendingReload) { + let popupTitle = "Quick Refresh Needed"; + let popupText = "Some behind-the-scenes changes need a quick refresh to take effect. Reload the page to stay up to date."; + switch (reloadBroadcast.reason) { + case c.RELOAD_REASONS.UPDATE: + popupTitle = "Update Ready"; + popupText = "We've updated Workflowy Encrypter with new features and improvements! Reload this page to enjoy the latest version."; + break; + case c.RELOAD_REASONS.KEY_CHANGE: + popupTitle = "Key Updated"; + popupText = "Your key has been successfully updated. Reload this page to apply your changes."; + break; + case c.RELOAD_REASONS.TAG_CHANGE: + popupTitle = "Tag Updated"; + popupText = "The encryption tag has been successfully updated. Reload this page to apply your changes."; + break; + } + + pendingReload = true; + await popup.create(null, null, [], false, { + style: await components.getWelcomeCss(), + pages: [ + { + title: popupTitle, + text: popupText, + buttons: [{ + outcome: c.OUTCOMES.COMPLETE, + text: "Reload" + }], + html: [{ + position: "afterbegin", + content: await components.getWelcomeHtml(2, { + key_url: await gateway.getResUrl('/src/logo_128.png') + }) + }] + } + ] + }); + window.onbeforeunload = null; + location.reload(); + } else { + broadcastCheckTime = new Date().getTime(); + } + } + + setAction(action) { + this.action = action; + } + + clearAction() { + this.action = null; + } +} +const focusTracker = new FocusTracker(); class NodeTracker { NODES = {}; @@ -122,7 +230,7 @@ class NodeTracker { } let node = this.NODES[id] ?? {}; - let isSharedRoot = node[PROPERTIES.SHARE_ID] !== undefined; + let isSharedRoot = node[c.PROPERTIES.SHARE_ID] !== undefined; for (let property in properties) { if (properties[property] === undefined && node[property] !== undefined) { @@ -131,11 +239,11 @@ class NodeTracker { } if (isSharedRoot && !enforce) { - delete properties[PROPERTIES.PARENT]; + delete properties[c.PROPERTIES.PARENT]; } let updatedNode = {...node, ...properties}; - updatedNode[PROPERTIES.LOCKED] = (updatedNode[PROPERTIES.NAME] ?? "").includes(LOCK_TAG); + updatedNode[c.PROPERTIES.LOCKED] = (updatedNode[c.PROPERTIES.NAME] ?? "").includes(c.LOCK_TAG); this.NODES[id] = updatedNode; return true; @@ -163,17 +271,17 @@ class NodeTracker { } else if (node[property] !== undefined && !ignored.includes(node[property])) { return node[property]; } else if (recursiveCheck) { - return this.get(node[PROPERTIES.PARENT], property, recursiveCheck, ignored); + return this.get(node[c.PROPERTIES.PARENT], property, recursiveCheck, ignored); } return undefined; } getShareId(id) { - return this.get(id, PROPERTIES.SHARE_ID, true); + return this.get(id, c.PROPERTIES.SHARE_ID, true); } getParent(id) { - return this.get(id, PROPERTIES.PARENT, false); + return this.get(id, c.PROPERTIES.PARENT, false); } /** @@ -181,15 +289,15 @@ class NodeTracker { * Setting direct to false will check the property of the node's parents */ isLocked(id, direct = false) { - return this.get(id, PROPERTIES.LOCKED, !direct, [false]) ?? false; + return this.get(id, c.PROPERTIES.LOCKED, !direct, [false]) ?? false; } hasChild(id) { - return this.find(PROPERTIES.PARENT, id, true).length > 0; + return this.find(c.PROPERTIES.PARENT, id, true).length > 0; } getChildren(id) { - return this.find(PROPERTIES.PARENT, id); + return this.find(c.PROPERTIES.PARENT, id); } find(property, value, single = false) { @@ -210,30 +318,30 @@ const nodes = new NodeTracker(); class ComponentLoader { // For a native look, HTML and CSS are taken from the Workflowy's site async getPopupContainerHTML() { - let path = u.getInternalVar("htmlPopupContainer"); + let path = await gateway.getResUrl('/layouts/popup_container.html'); return await this.readFile(path); } async getToastContainerHTML() { - let path = u.getInternalVar("htmlToastContainer"); + let path = await gateway.getResUrl('/layouts/toast_container.html'); return await this.readFile(path); } async getPopupCloseHTML() { - let path = u.getInternalVar("htmlPopupClose"); + let path = await gateway.getResUrl('/layouts/popup_close.html'); return await this.readFile(path); } async getWelcomeCss() { - let path = u.getInternalVar("cssWelcome"); + let path = await gateway.getResUrl('/styles/welcome.css'); let css = await this.readFile(path); switch (theme) { - case THEMES.DARK: - let path = u.getInternalVar("cssWelcomeDark"); + case c.THEMES.DARK: + let path = await gateway.getResUrl('/styles/welcome_dark.css'); css += '\n' + await this.readFile(path); break; - case THEMES.LIGHT: + case c.THEMES.LIGHT: default: break; } @@ -241,22 +349,22 @@ class ComponentLoader { return css; } - async getPopupCss(type = POPUP_TYPES.DEFAULT) { - let path = u.getInternalVar("cssPopup"); + async getPopupCss(type = c.POPUP_TYPES.DEFAULT) { + let path = await gateway.getResUrl('/styles/popup.css'); let css = await this.readFile(path); - path = u.getInternalVar("cssPopupType" + type); + path = await gateway.getResUrl('/styles/popup_type' + type + '.css'); css += '\n' + await this.readFile(path); switch (theme) { - case THEMES.DARK: - let path = u.getInternalVar("cssPopupDark"); + case c.THEMES.DARK: + let path = await gateway.getResUrl('/styles/popup_dark.css'); css += '\n' + await this.readFile(path); - path = u.getInternalVar("cssPopupType" + type + "Dark"); + path = await gateway.getResUrl('/styles/popup_type' + type + '_dark.css'); css += '\n' + await this.readFile(path); break; - case THEMES.LIGHT: + case c.THEMES.LIGHT: default: break; } @@ -265,7 +373,7 @@ class ComponentLoader { } async getWelcomeHtml(id, properties = {}) { - let path = u.getInternalVar("htmlPopupWelcome" + id); + let path = await gateway.getResUrl('/layouts/popup_welcome_' + id + '.html'); return await this.parseProperties(await this.readFile(path), properties); } @@ -289,7 +397,7 @@ const components = new ComponentLoader(); */ class Toast { static PROCESSES = {} - static state = TOAST_STATES.HIDDEN; + static state = c.TOAST_STATES.HIDDEN; static timeoutShow = null; static timeoutHide = null; delay = 100; @@ -304,8 +412,8 @@ class Toast { text: text }; - if (Toast.state === TOAST_STATES.TRANSITIONING) { - while (Toast.state === TOAST_STATES.TRANSITIONING) { + if (Toast.state === c.TOAST_STATES.TRANSITIONING) { + while (Toast.state === c.TOAST_STATES.TRANSITIONING) { await u.sleep(50); } } @@ -315,9 +423,9 @@ class Toast { Toast.timeoutHide = null; } - if (Toast.state === TOAST_STATES.HIDDEN && Toast.timeoutShow === null) { + if (Toast.state === c.TOAST_STATES.HIDDEN && Toast.timeoutShow === null) { Toast.timeoutShow = setTimeout(async function () { - Toast.state = TOAST_STATES.TRANSITIONING; + Toast.state = c.TOAST_STATES.TRANSITIONING; let process = Object.values(Toast.PROCESSES)[0]; let title = process.title; @@ -335,7 +443,7 @@ class Toast { await u.sleep(300); Toast.timeoutShow = null; - Toast.state = TOAST_STATES.SHOWN; + Toast.state = c.TOAST_STATES.SHOWN; }, this.delay); } } @@ -351,8 +459,8 @@ class Toast { return; } - if (Toast.state === TOAST_STATES.TRANSITIONING) { - while (Toast.state === TOAST_STATES.TRANSITIONING) { + if (Toast.state === c.TOAST_STATES.TRANSITIONING) { + while (Toast.state === c.TOAST_STATES.TRANSITIONING) { await u.sleep(50); } } @@ -362,9 +470,9 @@ class Toast { Toast.timeoutShow = null; } - if (Toast.state === TOAST_STATES.SHOWN && Toast.timeoutHide === null) { + if (Toast.state === c.TOAST_STATES.SHOWN && Toast.timeoutHide === null) { Toast.timeoutHide = setTimeout(async function () { - Toast.state = TOAST_STATES.TRANSITIONING; + Toast.state = c.TOAST_STATES.TRANSITIONING; let toastElement = document.getElementById("_toast2"); let height = toastElement.offsetHeight; @@ -374,13 +482,12 @@ class Toast { toastElement.style.transition = "all 0s"; Toast.timeoutHide = null; - Toast.state = TOAST_STATES.HIDDEN; + Toast.state = c.TOAST_STATES.HIDDEN; }, this.delay); } } } const toast = new Toast(); -toast.init(); /** * Can be called by a single process at a time @@ -446,14 +553,14 @@ class Popup { Popup.args.pageCount = args.pages.length; Popup.args.currentPage = 0; Popup.args.cancellable = cancellable; - Popup.args.type = Popup.args.type ?? POPUP_TYPES.DEFAULT; + Popup.args.type = Popup.args.type ?? c.POPUP_TYPES.DEFAULT; this.setPage(0); this.show(); if (cancellable) { document.getElementById("_popup").addEventListener('click', function(evt) { if ( evt.target != this ) return false; - Popup.onClick(null, OUTCOMES.CANCEL); + Popup.onClick(null, c.OUTCOMES.CANCEL); }); } }); @@ -478,13 +585,13 @@ class Popup { Popup.args.activeElement = document.activeElement; document.activeElement.blur(); } - if (Popup.args.type === POPUP_TYPES.MINI) { + if (Popup.args.type === c.POPUP_TYPES.MINI) { document.addEventListener('keydown', Popup.onKeyPress); } } - async hide(outcome = OUTCOMES.CANCEL) { - if (Popup.args.type === POPUP_TYPES.MINI) { + async hide(outcome = c.OUTCOMES.CANCEL) { + if (Popup.args.type === c.POPUP_TYPES.MINI) { document.removeEventListener('keydown', Popup.onKeyPress); } if (Popup.args.activeElement) { @@ -517,7 +624,7 @@ class Popup { const text = page["text"] ?? ""; const input = page["input"] ?? null; const buttons = page["buttons"] ?? []; - const htmlList = page["html"] ?? []; + let htmlList = page["html"] ?? []; const script = page["script"] ?? (() => {}); // Remove current page @@ -536,6 +643,7 @@ class Popup { var textElement = document.createElement('p'); textElement.classList.add("_popup-text"); + textElement.id = "_popup-text"; textElement.innerHTML = text; content.appendChild(textElement); @@ -561,20 +669,30 @@ class Popup { divElement2.appendChild(inputElement); } + if (htmlList.length > 0) { + htmlList = htmlList.filter((htmlItem) => { + if (htmlItem.position === "beforebuttons") { + content.insertAdjacentHTML("beforeend", htmlItem.content); + return false; + } + return true; + }); + } + var buttonsElement = document.createElement('div'); buttonsElement.classList.add("_popup-buttons"); buttonsElement.id = "_popup-buttons"; content.appendChild(buttonsElement); if (buttons.length === 0) { - if (type === POPUP_TYPES.DEFAULT) { + if (type === c.POPUP_TYPES.DEFAULT) { buttons.push({ - outcome: (endOfPages ? OUTCOMES.COMPLETE : OUTCOMES.NEXT), + outcome: (endOfPages ? c.OUTCOMES.COMPLETE : c.OUTCOMES.NEXT), text: (endOfPages ? "Close" : "Next"), }) } else { buttons.push({ - outcome: OUTCOMES.COMPLETE, + outcome: c.OUTCOMES.COMPLETE, text: "Close", primary: true }) @@ -585,20 +703,20 @@ class Popup { const buttonData = buttons[i]; var buttonElement = document.createElement('button'); - buttonElement.classList.add(type === POPUP_TYPES.DEFAULT ? "_popup-button" : (buttonData.primary ? "_popup-button-primary" : "_popup-button-secondary")); + buttonElement.classList.add(type === c.POPUP_TYPES.DEFAULT ? "_popup-button" : (buttonData.primary ? "_popup-button-primary" : "_popup-button-secondary")); buttonElement.id = "_popup-button" + i; buttonElement.type = "button"; buttonElement.setAttribute("data-id", i); // Possibly change assigned keys for primary and secondary buttons in the future - buttonElement.innerHTML = type === POPUP_TYPES.DEFAULT + buttonElement.innerHTML = type === c.POPUP_TYPES.DEFAULT ? buttonData.text : ('' + buttonData.text + '' + (buttonData.primary ? ' ⏎' : ' esc') + ''); - let onClickFunc = () => { - Popup.onClick(i, buttonData.outcome); - } - buttonElement.onclick = onClickFunc; - if (type === POPUP_TYPES.MINI) { + let onClickFunc = () => { + Popup.onClick(i, buttonData.outcome); + } + buttonElement.onclick = onClickFunc; + if (type === c.POPUP_TYPES.MINI) { if (buttonData.primary) { Popup.args.primaryOnClick = onClickFunc; } else { @@ -619,10 +737,10 @@ class Popup { } } - if (cancellable && type === POPUP_TYPES.MINI) { + if (cancellable && type === c.POPUP_TYPES.MINI) { content.insertAdjacentHTML('beforeend', await components.getPopupCloseHTML()); document.getElementById("_popup-close").onclick = () => { - Popup.onClick(null, OUTCOMES.CANCEL); + Popup.onClick(null, c.OUTCOMES.CANCEL); }; } @@ -640,23 +758,23 @@ class Popup { static async onClick(id, outcome) { const currentPage = Popup.args.currentPage; - if (outcome === OUTCOMES.CUSTOM) { + if (outcome === c.OUTCOMES.CUSTOM) { outcome = (await Popup.args.pages[currentPage].buttons[id].onClick() ?? outcome); } switch (outcome) { - case OUTCOMES.PREV: + case c.OUTCOMES.PREV: popup.setPage(currentPage - 1); break; - case OUTCOMES.NEXT: + case c.OUTCOMES.NEXT: popup.setPage(currentPage + 1); break; - case OUTCOMES.CANCEL: - case OUTCOMES.COMPLETE: + case c.OUTCOMES.CANCEL: + case c.OUTCOMES.COMPLETE: popup.hide(outcome); break; default: - case OUTCOMES.IGNORE: + case c.OUTCOMES.IGNORE: return; } } @@ -684,8 +802,8 @@ class PopupHelper { html: [{ position: "afterbegin", content: await components.getWelcomeHtml(1, { - logo_url: u.getInternalVar("logoUrl"), - logo_w_url: u.getInternalVar("logoWUrl") + logo_url: await gateway.getResUrl('/src/logo_128.png'), + logo_w_url: await gateway.getResUrl('/src/logo_w_128.png') }) }], script: () => { @@ -707,7 +825,7 @@ class PopupHelper { text2.style.width = text1.offsetWidth + "px"; let setRandomText = (text2) => { - text2.textContent = PRE_ENC_CHAR + u.randomStr(15 - PRE_ENC_CHAR.length); + text2.textContent = c.PRE_ENC_CHAR + u.randomStr(15 - c.PRE_ENC_CHAR.length); setTimeout(() => { setRandomText(text2) }, 7 * 1000); @@ -719,41 +837,74 @@ class PopupHelper { }, { title: "Craft Your Key", - text: "Register your key that will be used to encrypt your data. If this is your first time here, just enter a new key and make sure to note it down. It will be impossible to recover your encrypted data if you forget your key.", - input: { - label: "Key", - placeholder: "secret" - }, + text: "Use the button below to open a secure area where you can safely register your key to be used for encryption. This will open a new tab.", buttons: [{ - outcome: OUTCOMES.CUSTOM, - text: "Next", + outcome: c.OUTCOMES.CUSTOM, + text: "Set key", onClick: async function() { - let key = document.getElementById("_input-box").value; - if (key.replaceAll(" ", "").length === 0) { - toast.show("Key cannot be empty.", "Provide a valid key and try again.", "KEY"); - await u.sleep(3000); - toast.hide("KEY"); - return OUTCOMES.IGNORE; - } else { - window.localStorage.setItem("lockSecret", key); - return OUTCOMES.NEXT; + let button = document.getElementById("_popup-button0"); + let loader = document.getElementById("_loader"); + let text = document.getElementById("_popup-text"); + let buttonAction = button.getAttribute("data-action") ?? "registerKey"; + let checkSecretAction = async () => { + // Ignore broadcasted actions + broadcastCheckTime = new Date().getTime(); + + if (await gateway.secretLoaded(true)) { + focusTracker.clearAction(); + + loader.style.display = "none"; + text.innerHTML = "Great, you have successfully registered your key."; + button.textContent = "Next"; + button.setAttribute("data-action", "next"); + } + }; + + switch (buttonAction) { + case "registerKey": + await gateway.openOptionsPage(c.ACTIONS.SET_KEY); + + focusTracker.setAction(checkSecretAction); + + loader.style.display = "block"; + text.innerHTML = "The setup will continue once you have registered your key. If the tab didn't open, click here or navigate to the extension's options page."; + button.textContent = "Check key"; + button.setAttribute("data-action", "checkKey"); + + return c.OUTCOMES.IGNORE; + case "checkKey": + if (await gateway.secretLoaded()) { + await checkSecretAction(); + return c.OUTCOMES.IGNORE; + } else { + toast.show("Key not set", "Register a key to continue", "KEY"); + await u.sleep(3000); + toast.hide("KEY"); + return c.OUTCOMES.IGNORE; + } + case "next": + return c.OUTCOMES.NEXT; } } }], html: [{ position: "afterbegin", content: await components.getWelcomeHtml(2, { - key_url: u.getInternalVar("keyUrl") + key_url: await gateway.getResUrl('/src/key_128.png') }) + }, + { + position: "beforebuttons", + content: await components.getWelcomeHtml("2_loader") }] }, { title: "Use Your Key", - text: "Now that your key is ready, you can use it seamlessly just by adding a " + LOCK_TAG + " tag to any node you want to secure. All sub-nodes of the selected node, including the ones you will add later, will be encrypted automatically.", + text: "Now that your key is ready, you can use it seamlessly just by adding a " + c.LOCK_TAG + " tag to any node you want to secure. All sub-nodes of the selected node, including the ones you will add later, will be encrypted automatically.", html: [{ position: "afterbegin", content: await components.getWelcomeHtml(3, { - ss1_url: theme === THEMES.LIGHT ? u.getInternalVar("ss1Url") : u.getInternalVar("ss1DarkUrl") + ss1_url: theme === c.THEMES.LIGHT ? (await gateway.getResUrl('/src/ss1.png')) : (await gateway.getResUrl('/src/ss1_dark.png')) }) }] }, @@ -763,8 +914,8 @@ class PopupHelper { html: [{ position: "afterbegin", content: await components.getWelcomeHtml(4, { - logo_url: u.getInternalVar("logoUrl"), - logo_w_url: u.getInternalVar("logoWUrl") + logo_url: await gateway.getResUrl('/src/logo_128.png'), + logo_w_url: await gateway.getResUrl('/src/logo_w_128.png') }) }], script: () => { @@ -786,57 +937,87 @@ class PopupHelper { ] }); } + + async migrateLockKey() { + await u.sleep(1000); + await popup.create(null, null, [], false, { + style: await components.getWelcomeCss(), + pages: [ + { + title: "A Little Rearrangement", + text: "We are updating the location where your key is stored on your device to enhance its security. Use the button below to move your key to the new location. This will open a new tab.", + buttons: [{ + outcome: c.OUTCOMES.CUSTOM, + text: "Move key", + onClick: async function() { + let button = document.getElementById("_popup-button0"); + let loader = document.getElementById("_loader"); + let text = document.getElementById("_popup-text"); + let buttonAction = button.getAttribute("data-action") ?? "moveKey"; + + let checkSecretAction = async () => { + // Ignore broadcasted actions + broadcastCheckTime = new Date().getTime(); + + if (await gateway.getVar("keyMoved", false)) { + focusTracker.clearAction(); + + window.localStorage.removeItem("lockSecret"); + window.localStorage.removeItem("lockCache"); + + loader.style.display = "none"; + text.innerHTML = "You have successfully moved your key to its new secure location. If you bave other Workflowy tabs, reload them to prevent encryption issues."; + button.textContent = "Close"; + button.setAttribute("data-action", "next"); + } + }; + + switch (buttonAction) { + case "moveKey": + let secret = window.localStorage.getItem("lockSecret"); + await gateway.setVar("keyMoved", false); + await gateway.openOptionsPage(c.ACTIONS.MOVE_KEY, secret); + focusTracker.setAction(checkSecretAction); + + loader.style.display = "block"; + text.innerHTML = "Waiting for you key to be moved to its new location. If the tab didn't open, click here or navigate to the extension's options page."; + button.textContent = "Check key"; + button.setAttribute("data-action", "checkKey"); + + return c.OUTCOMES.IGNORE; + case "checkKey": + if (await gateway.getVar("keyMoved", false)) { + await checkSecretAction(); + return c.OUTCOMES.IGNORE; + } else { + toast.show("Key not set", "Confirm moving your key to continue", "KEY"); + await u.sleep(3000); + toast.hide("KEY"); + return c.OUTCOMES.IGNORE; + } + case "next": + return c.OUTCOMES.COMPLETE; + } + } + }], + html: [{ + position: "afterbegin", + content: await components.getWelcomeHtml(2, { + key_url: await gateway.getResUrl('/src/logo_128.png') + }) + }, + { + position: "beforebuttons", + content: await components.getWelcomeHtml("2_loader") + }] + } + ] + }); + } } const popupHelper = new PopupHelper(); class API { - // Tree-related part is for fetching the most up-to-date tree data, which is no longer - // needed as a copy of the whole tree is always tracked and kept in the memory - - // TREE = {}; - - // async loadTree() { - // this.removeTree(); - - // await this.loadSpecificTree("/get_tree_data/"); - // for (let shareId of shared) { - // await this.loadSpecificTree("/get_tree_data/?share_id=" + shareId); - // } - // } - - // async loadSpecificTree(path) { - // const treeDataRaw = await origFetch(DOMAIN + path); - // const treeData = await treeDataRaw.json(); - - // let notArray = false; - // for (let data of treeData.items) { - // if (notArray || !Array.isArray(data)) { - // notArray = true; - // await this.addNodeToParsedData(this.TREE, data); - // continue; - // } - - // for (let subData of data) { - // await this.addNodeToParsedData(this.TREE, subData); - // } - // } - // } - - // async addNodeToParsedData(parsedData, item) { - // let id = item.id; - // parsedData[id] = {}; - // if (item.nm !== undefined) { - // parsedData[id].name = await encrypter.decrypt(item.nm); - // } - // if (item.no !== undefined) { - // parsedData[id].description = await encrypter.decrypt(item.no); - // } - // } - - // async removeTree() { - // this.TREE = {}; - // } - async pushAndPoll(operations) { let rawBody = { client_id: clientId, @@ -852,7 +1033,7 @@ class API { most_recent_operation_transaction_id: mostRecentOperationTransactionId, operations: operationsInstance }; - if (shareId !== DEFAULT_SHARE_ID) { + if (shareId !== c.DEFAULT_SHARE_ID) { pushPollDataInstance.share_id = shareId; } rawBody.push_poll_data.push(pushPollDataInstance); @@ -861,7 +1042,7 @@ class API { } let body = await util.decodeBody(rawBody); - let response = await origFetch(DOMAIN + "/push_and_poll", { + let response = await origFetch(c.DOMAIN + "/push_and_poll", { method: 'POST', credentials: "same-origin", headers: { @@ -870,185 +1051,69 @@ class API { 'Wf_build_date': wfBuildDate }, body: body, - url: DOMAIN + "/push_and_poll" + url: c.DOMAIN + "/push_and_poll" }); } } const api = new API(); -class Cache { - get(key, defVal = null) { - let cacheData = window.localStorage.getItem("lockCache"); - cacheData = cacheData ? JSON.parse(cacheData) : {}; - return cacheData[key] ? cacheData[key].val : defVal; +class Encrypter { + async encrypt(data) { + return await gateway.encrypt(data); } - set(key, val) { - let cacheData = window.localStorage.getItem("lockCache"); - cacheData = (cacheData !== null && cacheData !== undefined) ? JSON.parse(cacheData) : {}; - cacheData[key] = { - val: val, - lastAccessed: Date.now() - }; - window.localStorage.setItem("lockCache", JSON.stringify(cacheData)); + async decrypt(data) { + return await gateway.decrypt(data); } - clear(light = true) { - if (!light) { - window.localStorage.setItem("lockCache", undefined); - return; + async checkSecret() { + if (await gateway.secretLoaded()) { + return true; } - let cacheData = window.localStorage.getItem("lockCache"); - cacheData = (cacheData !== null && cacheData !== undefined) ? JSON.parse(cacheData) : {}; + // Secret is not loaded + bypassLock = true; + let reloadPage = true; + staller.ready(); - let now = Date.now(); - let lifeDuration = 1000 * 60 * 60 * 24 * 7; // 1 week - for (let key in cacheData) { - if (now - cacheData[key].lastAccessed > lifeDuration) { - delete cacheData[key]; - } + let blocker = await gateway.getBlocker(); + switch (blocker) { + case c.ACTIONS.MOVE_KEY: + await popupHelper.migrateLockKey(); + break; + case c.ACTIONS.WELCOME: + await popupHelper.welcome(); + await gateway.setBlocker(null, true); + break; + default: + await popup.create( + "Encryption disabled", + "Workflowy Encrypter cannot access your key. Use the button below to set your key, if you haven't already, or cancel to use Workflowy without encryption.", [ + { + text: "Cancel", + outcome: c.OUTCOMES.CANCEL + }, + { + text: "Set key", + outcome: c.OUTCOMES.CUSTOM, + primary: true, + onClick: async function() { + await gateway.openOptionsPage(c.ACTIONS.SET_KEY); + return c.OUTCOMES.IGNORE; + } + } + ], true, {type: c.POPUP_TYPES.MINI}); + reloadPage = false; + break; } - window.localStorage.setItem("lockCache", JSON.stringify(cacheData)); - } -} -const cache = new Cache(); - -class Encrypter { - SECRET; - enc; - dec; - secretLoaded = false; - - constructor() { - this.enc = new TextEncoder(); - this.dec = new TextDecoder(); - } - - async loadSecret() { - let secret = window.localStorage.getItem("lockSecret"); - if (!secret || secret === null | secret === "null" || secret === "") { - await popupHelper.welcome(); + if (reloadPage) { window.onbeforeunload = null; location.reload(); } - this.SECRET = secret; - this.secretLoaded = true; - } - - async encrypt(data) { - if (!this.SECRET || this.SECRET === null | this.SECRET === "null" || this.SECRET === "") { - return data; - } - const encryptedData = await this.encryptData(data, this.SECRET); - cache.set(PRE_ENC_CHAR + encryptedData, data); - return PRE_ENC_CHAR + encryptedData; - } - - async decrypt(data) { - if (!data.startsWith(PRE_ENC_CHAR)) { - return data; - } else if (!this.SECRET || this.SECRET === null | this.SECRET === "null" || this.SECRET === "") { - return data; - } - - let cachedDecryptedData = cache.get(data, null); - if (cachedDecryptedData !== null) { - return cachedDecryptedData; - } - - let origData = data; - data = data.substring(PRE_ENC_CHAR.length); - const decryptedData = await this.decryptData(data, this.SECRET); - cache.set(origData, decryptedData); - return decryptedData || data; - } - - // Encryption helper functions [https://github.com/bradyjoslin/webcrypto-example] - buff_to_base64 = (buff) => btoa( - new Uint8Array(buff).reduce( - (data, byte) => data + String.fromCharCode(byte), '' - ) - ); - - base64_to_buf = (b64) => - Uint8Array.from(atob(b64), (c) => c.charCodeAt(null)); - - getPasswordKey = (password) => - window.crypto.subtle.importKey("raw", this.enc.encode(password), "PBKDF2", false, [ - "deriveKey", - ]); - - deriveKey = (passwordKey, salt, keyUsage) => - window.crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: salt, - iterations: 250000, - hash: "SHA-256", - }, - passwordKey, - { name: "AES-GCM", length: 256 }, - false, - keyUsage - ); - - async encryptData(secretData, password) { - try { - const salt = window.crypto.getRandomValues(new Uint8Array(16)); - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const passwordKey = await this.getPasswordKey(password); - const aesKey = await this.deriveKey(passwordKey, salt, ["encrypt"]); - const encryptedContent = await window.crypto.subtle.encrypt( - { - name: "AES-GCM", - iv: iv, - }, - aesKey, - this.enc.encode(secretData) - ); - - const encryptedContentArr = new Uint8Array(encryptedContent); - let buff = new Uint8Array( - salt.byteLength + iv.byteLength + encryptedContentArr.byteLength - ); - buff.set(salt, 0); - buff.set(iv, salt.byteLength); - buff.set(encryptedContentArr, salt.byteLength + iv.byteLength); - const base64Buff = this.buff_to_base64(buff); - return base64Buff; - } catch (e) { - console.warn(`[Workflowy Encrypter] Encryption error`, e); - return ""; - } - } - - async decryptData(encryptedData, password) { - try { - const encryptedDataBuff = this.base64_to_buf(encryptedData); - const salt = encryptedDataBuff.slice(0, 16); - const iv = encryptedDataBuff.slice(16, 16 + 12); - const data = encryptedDataBuff.slice(16 + 12); - const passwordKey = await this.getPasswordKey(password); - const aesKey = await this.deriveKey(passwordKey, salt, ["decrypt"]); - const decryptedContent = await window.crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: iv, - }, - aesKey, - data - ); - return this.dec.decode(decryptedContent); - } catch (e) { - console.warn(`[Workflowy Encrypter] Encryption error`, e); - return ""; - } } } const encrypter = new Encrypter(); -encrypter.loadSecret(); class Util { async encodeBody(rawBody) { @@ -1087,33 +1152,25 @@ class Util { let enforce = false; let id = data.id; let properties = {}; - properties[PROPERTIES.PARENT] = data.prnt; + properties[c.PROPERTIES.PARENT] = data.prnt; if (data.nm !== undefined) { data.nm = await encrypter.decrypt(data.nm); - properties[PROPERTIES.NAME] = data.nm; + properties[c.PROPERTIES.NAME] = data.nm; } if (data.no !== undefined) { data.no = await encrypter.decrypt(data.no); - properties[PROPERTIES.DESCRIPTION] = data.no; + properties[c.PROPERTIES.DESCRIPTION] = data.no; } if (data.as) { - properties[PROPERTIES.LOCAL_ID] = data.id; - id = nodes.find(PROPERTIES.SHARE_ID, data.as, true)[0] ?? id; + properties[c.PROPERTIES.LOCAL_ID] = data.id; + id = nodes.find(c.PROPERTIES.SHARE_ID, data.as, true)[0] ?? id; enforce = true; // Enforce parent } nodes.update(id, properties, enforce); } - // decodeVal(val) { - // if (!u.isString(val)) { - // val = JSON.stringify(val); - // } - // val = encodeURIComponent(val).replaceAll("%20", "+"); - // return val; - // } - async decryptServerResponse(json, trackedChangeData, shareId = null) { // trackedChanges is global and holds temporary data // trackedChangeData holds the data that is currently being processed @@ -1126,9 +1183,9 @@ class Util { let stringifyJson = []; trackedChanges = []; - let flags = [FLAGS.SUPPRESS_WARNINGS, FLAGS.NO_FETCH, FLAGS.TRACK_ENCRYPTION_CHANGES]; + let flags = [c.FLAGS.SUPPRESS_WARNINGS, c.FLAGS.NO_FETCH, c.FLAGS.TRACK_ENCRYPTION_CHANGES]; if (shareId !== null) { - flags.push(FLAGS.IGNORE_NULL_PARENT); + flags.push(c.FLAGS.IGNORE_NULL_PARENT); } let result = await util.processOperation(op, dataObj, stringifyJson, flags); if (result === false) { @@ -1145,14 +1202,14 @@ class Util { } // Process data objects - result = await util.processDataObjects(dataObj, [FLAGS.FORCE_DECRYPT]); + result = await util.processDataObjects(dataObj, [c.FLAGS.FORCE_DECRYPT]); result = await util.processDataToStringify(stringifyJson); for (let changed of trackedChanges) { let id = changed["id"]; let data = trackedChangeData[id] ?? {}; data["final"] = nodes.isLocked(id); - data["name"] = nodes.get(id, PROPERTIES.NAME); + data["name"] = nodes.get(id, c.PROPERTIES.NAME); trackedChangeData[id] = data; } } @@ -1177,7 +1234,7 @@ class Util { } async processCreateOperation(operation, dataObj, stringifyJson, flags = []) { - var parent = operation.data.parentid !== "None" ? operation.data.parentid : (flags.includes(FLAGS.IGNORE_NULL_PARENT) ? undefined : null); + var parent = operation.data.parentid !== "None" ? operation.data.parentid : (flags.includes(c.FLAGS.IGNORE_NULL_PARENT) ? undefined : null); operation.data.project_trees = JSON.parse(operation.data.project_trees); stringifyJson.push({ contentTag: "project_trees", @@ -1193,21 +1250,21 @@ class Util { process: [], properties: {} }; - obj.properties[PROPERTIES.PARENT] = parent; + obj.properties[c.PROPERTIES.PARENT] = parent; if (project.nm) { obj.process.push({ node: project, contentTag: "nm" }); - obj.properties[PROPERTIES.NAME] = project.nm; + obj.properties[c.PROPERTIES.NAME] = project.nm; } if (project.no) { obj.process.push({ node: project, contentTag: "no" }); - obj.properties[PROPERTIES.DESCRIPTION] = project.no; + obj.properties[c.PROPERTIES.DESCRIPTION] = project.no; } dataObj.push(obj); @@ -1229,7 +1286,7 @@ class Util { }; if (operation.data.description !== undefined) { - obj.properties[PROPERTIES.DESCRIPTION] = operation.data["description"]; + obj.properties[c.PROPERTIES.DESCRIPTION] = operation.data["description"]; obj.process.push({ node: operation.data, contentTag: "description" @@ -1240,7 +1297,7 @@ class Util { }); } if (operation.data.name !== undefined) { - obj.properties[PROPERTIES.NAME] = operation.data["name"]; + obj.properties[c.PROPERTIES.NAME] = operation.data["name"]; obj.process.push({ node: operation.data, contentTag: "name" @@ -1253,37 +1310,37 @@ class Util { // Process child nodes if exists const name = operation.data.name; const id = operation.data.projectid; - if (!nodes.isLocked(id) && name.includes(LOCK_TAG) && nodes.hasChild(id)) { // Encryption added - if (flags.includes(FLAGS.TRACK_ENCRYPTION_CHANGES)) { + if (!nodes.isLocked(id) && name.includes(c.LOCK_TAG) && nodes.hasChild(id)) { // Encryption added + if (flags.includes(c.FLAGS.TRACK_ENCRYPTION_CHANGES)) { trackedChanges.push({ id: id }); } await this.updateChildNodeEncryption(id, true, false, flags); - } else if (nodes.isLocked(id, true) && !nodes.isLocked(nodes.getParent(id)) && !name.includes(LOCK_TAG) && nodes.hasChild(id)) { // Encryption removed - if (flags.includes(FLAGS.TRACK_ENCRYPTION_CHANGES)) { + } else if (nodes.isLocked(id, true) && !nodes.isLocked(nodes.getParent(id)) && !name.includes(c.LOCK_TAG) && nodes.hasChild(id)) { // Encryption removed + if (flags.includes(c.FLAGS.TRACK_ENCRYPTION_CHANGES)) { trackedChanges.push({ id: id }); } if ( - flags.includes(FLAGS.SUPPRESS_WARNINGS) + flags.includes(c.FLAGS.SUPPRESS_WARNINGS) || (await popup.create( "Confirm decryption", - "Are you sure you want to remove the " + LOCK_TAG + " tag and decrypt all child nodes? This will send decrypted content to Workflowy servers.", + "Are you sure you want to remove the " + c.LOCK_TAG + " tag and decrypt all child nodes? This will send decrypted content to Workflowy servers.", [ { text: "Cancel", - outcome: OUTCOMES.CANCEL + outcome: c.OUTCOMES.CANCEL }, { text: "Decrypt", - outcome: OUTCOMES.COMPLETE, + outcome: c.OUTCOMES.COMPLETE, primary: true } - ], true, {type: POPUP_TYPES.MINI})) === OUTCOMES.COMPLETE + ], true, {type: c.POPUP_TYPES.MINI})) === c.OUTCOMES.COMPLETE ) { await this.updateChildNodeEncryption(id, false, false, flags); } else { @@ -1300,7 +1357,7 @@ class Util { async updateChildNodeEncryption(id, encrypt, processParentNode, flags = [], processingParent = true, rootId = null, operations = null) { if (processingParent) { - if (flags.includes(FLAGS.NO_FETCH)) { + if (flags.includes(c.FLAGS.NO_FETCH)) { return true; } await toast.show((encrypt ? "Encryption" : "Decryption") + " in progress...", "Keep the page open until this message disappears.", id); @@ -1347,19 +1404,19 @@ class Util { } }; - let name = nodes.get(id, PROPERTIES.NAME); + let name = nodes.get(id, c.PROPERTIES.NAME); if (name !== undefined) { operation.data.name = encrypt ? await encrypter.encrypt(name) : name; operation.undo_data.previous_name = encrypt ? await encrypter.encrypt(name) : name; } - let description = nodes.get(id, PROPERTIES.DESCRIPTION); + let description = nodes.get(id, c.PROPERTIES.DESCRIPTION); if (description !== undefined) { operation.data.description = encrypt ? await encrypter.encrypt(description) : description; operation.undo_data.previous_description = encrypt ? await encrypter.encrypt(description) : description; } - let branch = nodes.getShareId(id) === undefined ? DEFAULT_SHARE_ID : nodes.getShareId(id); + let branch = nodes.getShareId(id) === undefined ? c.DEFAULT_SHARE_ID : nodes.getShareId(id); let operationsBranch = operations[branch] ?? []; operationsBranch.push(operation); operations[branch] = operationsBranch; @@ -1367,7 +1424,7 @@ class Util { } async processMoveOperation(operation, dataObj, flags = []) { - var parent = operation.data.parentid !== "None" ? operation.data.parentid : (flags.includes(FLAGS.IGNORE_NULL_PARENT) ? undefined : null); + var parent = operation.data.parentid !== "None" ? operation.data.parentid : (flags.includes(c.FLAGS.IGNORE_NULL_PARENT) ? undefined : null); let ids = JSON.parse(operation.data.projectids_json); let decryptionAllowed = false; for (let id of ids) { @@ -1375,12 +1432,12 @@ class Util { id: id, properties: {} } - obj.properties[PROPERTIES.PARENT] = parent; + obj.properties[c.PROPERTIES.PARENT] = parent; dataObj.push(obj); // Process child nodes if exists if (nodes.isLocked(parent) && !nodes.isLocked(id)) { // Encryption added - if (flags.includes(FLAGS.TRACK_ENCRYPTION_CHANGES)) { + if (flags.includes(c.FLAGS.TRACK_ENCRYPTION_CHANGES)) { trackedChanges.push({ id: id }); @@ -1388,14 +1445,14 @@ class Util { await this.updateChildNodeEncryption(id, true, true, flags); } else if (!nodes.isLocked(parent) && nodes.isLocked(nodes.getParent(id))) { // Encryption removed - if (flags.includes(FLAGS.TRACK_ENCRYPTION_CHANGES)) { + if (flags.includes(c.FLAGS.TRACK_ENCRYPTION_CHANGES)) { trackedChanges.push({ id: id }); } if ( - flags.includes(FLAGS.SUPPRESS_WARNINGS) + flags.includes(c.FLAGS.SUPPRESS_WARNINGS) || decryptionAllowed || (await popup.create( "Confirm decryption", @@ -1403,14 +1460,14 @@ class Util { [ { text: "Cancel", - outcome: OUTCOMES.CANCEL + outcome: c.OUTCOMES.CANCEL }, { text: "Decrypt", - outcome: OUTCOMES.COMPLETE, + outcome: c.OUTCOMES.COMPLETE, primary: true } - ], true, {type: POPUP_TYPES.MINI})) === OUTCOMES.COMPLETE + ], true, {type: c.POPUP_TYPES.MINI})) === c.OUTCOMES.COMPLETE ) { decryptionAllowed = true; await this.updateChildNodeEncryption(id, false, true, flags); @@ -1453,9 +1510,9 @@ class Util { if (data["delete"] === true) { nodes.delete(id); } else if (data["properties"]) { - if (flags.includes(FLAGS.FORCE_DECRYPT)) { + if (flags.includes(c.FLAGS.FORCE_DECRYPT)) { for (let property in data["properties"]) { - if (SENSITIVE_PROPERTIES.includes(property)) { + if (c.SENSITIVE_PROPERTIES.includes(property)) { data["properties"][property] = await encrypter.decrypt(data["properties"][property]); } } @@ -1473,7 +1530,7 @@ class Util { continue; } - if (flags.includes(FLAGS.FORCE_DECRYPT)) { + if (flags.includes(c.FLAGS.FORCE_DECRYPT)) { node[contentTag] = await encrypter.decrypt(node[contentTag]); } else if (nodes.isLocked(nodes.getParent(id)) && node && contentTag && node[contentTag] && u.isString(node[contentTag]) && node[contentTag].length > 0) { node[contentTag] = await encrypter.encrypt(node[contentTag]); @@ -1500,8 +1557,8 @@ class Util { let treeNode = { share_id: nodes.getShareId(nodeId), id: nodeId, - name: nodes.get(nodeId, PROPERTIES.NAME), - description: nodes.get(nodeId, PROPERTIES.DESCRIPTION), + name: nodes.get(nodeId, c.PROPERTIES.NAME), + description: nodes.get(nodeId, c.PROPERTIES.DESCRIPTION), locked: nodes.isLocked(nodeId), data: node, children: await this.getTree(nodeId) @@ -1551,7 +1608,7 @@ class RouteHandler { async postPushAndPoll(responseData) { // Find another point to clear cache later if (!cacheClearPerformed) { - cache.clear(); + await gateway.clearCache(); cacheClearPerformed = true; } @@ -1579,8 +1636,8 @@ class RouteHandler { attentionNeeded.push(trackedChangeData[id]["name"]); } } - if (attentionNeeded.length > 0 && encrypter.secretLoaded) { - await popup.create("Heads Up!", LOCK_TAG + " tag is removed from the following node(s) via a remote session. Add the tag again to keep your data protected; otherwise, your decrypted data will be sent to Workflowy servers:
- " + attentionNeeded.join("
- "), [], true, {type: POPUP_TYPES.MINI}); + if (attentionNeeded.length > 0 && (await gateway.secretLoaded())) { + await popup.create("Heads Up!", c.LOCK_TAG + " tag is removed from the following node(s) via a remote session. Add the tag again to keep your data protected; otherwise, your decrypted data will be sent to Workflowy servers:
- " + attentionNeeded.join("
- "), [], true, {type: c.POPUP_TYPES.MINI}); } return new Response(JSON.stringify(responseData)); @@ -1610,17 +1667,17 @@ class RouteHandler { for (let info of responseData.projectTreeData.auxiliaryProjectTreeInfos) { if (info.rootProject.id !== undefined) { let node = { - [PROPERTIES.SHARE_ID]: info.shareId + [c.PROPERTIES.SHARE_ID]: info.shareId }; if (info.rootProject.nm !== undefined) { info.rootProject.nm = await encrypter.decrypt(info.rootProject.nm); - node[PROPERTIES.NAME] = info.rootProject.nm; + node[c.PROPERTIES.NAME] = info.rootProject.nm; } if (info.rootProject.no !== undefined) { info.rootProject.no = await encrypter.decrypt(info.rootProject.no); - node[PROPERTIES.DESCRIPTION] = info.rootProject.no; + node[c.PROPERTIES.DESCRIPTION] = info.rootProject.no; } nodes.update(info.rootProject.id, node); @@ -1637,6 +1694,10 @@ class FetchWrapper { * Modify and return request params */ async onPreFetch(url, params) { + if (bypassLock && !quarantine) { + return params; + } + if (u.endpointMatches("/push_and_poll", "POST", url, params)) { return await routes.prePushAndPoll(params); } @@ -1647,6 +1708,10 @@ class FetchWrapper { * Modify response body */ async onPostFetch(url, params, response) { + if (bypassLock && !quarantine) { + return response; + } + let responseData = await response.clone().json(); if (responseData.results && Array.isArray(responseData.results) && responseData.results.length > 0 && responseData.results[0].new_most_recent_operation_transaction_id) { mostRecentOperationTransactionId = responseData.results[0].new_most_recent_operation_transaction_id; @@ -1664,8 +1729,36 @@ class FetchWrapper { } const fetchWrapper = new FetchWrapper(); +class Staller { + static items = []; + static ready = false; + + static addItem(resolve) { + Staller.items.push(resolve); + } + + waitUntilReady() { + return new Promise(resolve => { + if (Staller.ready) { + return resolve(); + } + Staller.addItem(resolve); + }); + } + + ready() { + Staller.ready = true; + for (let resolve of Staller.items) { + resolve(); + } + Staller.items = []; + } +} +const staller = new Staller(); + // Fetch wrapper [https://stackoverflow.com/a/64961272] window.fetch = async (...args) => { + await staller.waitUntilReady(); if (quarantine) { return; } @@ -1683,3 +1776,15 @@ window.fetch = async (...args) => { return await fetchWrapper.onPostFetch(url, params, response); }; + +(async () => { + // Init + await c.init(); + u.updateTheme(); + await gateway.setVar("theme", theme); + toast.init(); + window.onfocus = focusTracker.onChange.bind(focusTracker); + + await encrypter.checkSecret(); + staller.ready(); +})(); \ No newline at end of file diff --git a/src/logo_outline_dark_32.png b/src/logo_outline_dark_32.png new file mode 100644 index 0000000..25a3445 Binary files /dev/null and b/src/logo_outline_dark_32.png differ diff --git a/src/logo_outline_light_32.png b/src/logo_outline_light_32.png new file mode 100644 index 0000000..897ce64 Binary files /dev/null and b/src/logo_outline_light_32.png differ diff --git a/styles/welcome.css b/styles/welcome.css index ec68864..8ac95ce 100644 --- a/styles/welcome.css +++ b/styles/welcome.css @@ -216,3 +216,39 @@ margin-top: -500px; } } + +/* Loader animation [https://css-loaders.com/pulsing/] */ +._loader { + width: 12px; + aspect-ratio: 1; + border-radius: 50%; + background: rgb(112, 136, 170); + box-shadow: 0 0 0 0 rgba(112, 136, 170, 0.7); + animation: anim-loader 2s infinite; + display: inline-block; + vertical-align: middle; +} +@keyframes anim-loader { + 60% {box-shadow: 0 0 0 0 rgba(112, 136, 170, 0.7)} + 100% {box-shadow: 0 0 0 10px rgba(112, 136, 170, 0)} +} + +._loader-text { + display: inline-block; + text-align: center; + vertical-align: middle; + padding-left: 8px; + opacity: 0.4; +} + +._loader-box { + position: relative; + margin: 0 auto; + align-items: center; + text-align: center; + padding: 0; + + margin-top: -8px; + font-size: 14px; + line-height: 14px; +} \ No newline at end of file