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) {
+ // "Next ";
+ 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
+