From 04fa3634a813ec3bc0df5c18c34fb2defbc977db Mon Sep 17 00:00:00 2001 From: thecodrr Date: Fri, 18 Sep 2020 12:40:41 +0500 Subject: [PATCH] feat: improve editor performance this addresses a few things: 1. editting a locked note was excruciatingly slow due to synchronous crypto 2. libsodium is, unfortunately, synchronous hence it locks the UI 3. quill was sending unnecessary onChange events on every key stroke How they are handled: 1 & 2. all crypto related functions have been moved to a web worker for asynchrony with synchronous as fallback This allows for non-blocking crypto and fluid editting experience 3. onChange events are only sent when user stops typing KNOWN ISSUES: 1. The word counter may be a bit slow. It should be moved into quill as a module. --- apps/web/.gitignore | 4 +- apps/web/package.json | 4 +- apps/web/public/crypto.worker.js | 137 +++++++++++++ apps/web/src/components/editor/index.js | 27 ++- apps/web/src/components/editor/react-quill.js | 9 +- apps/web/src/interfaces/crypto.js | 184 ++++++++++-------- apps/web/src/stores/editor-store.js | 32 +-- apps/web/yarn.lock | 14 +- 8 files changed, 281 insertions(+), 130 deletions(-) create mode 100644 apps/web/public/crypto.worker.js diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 2ebc441cd..667b15af7 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -22,4 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -.now \ No newline at end of file +.now + +public/sodium.js \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 7d025ba4f..12f9b81ac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,6 @@ "framer-motion": "^1.10.3", "hookrouter": "^1.2.3", "immer": "^6.0.3", - "libsodium-wrappers": "^0.7.6", "localforage": "^1.7.3", "localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git", "notes-core": "npm:@streetwriters/notesnook-core@latest", @@ -51,6 +50,7 @@ "debug": "BROWSER=none react-scripts start", "test": "react-scripts test", "eject": "react-scripts eject", + "sodium": "cd public && wget https://github.com/jedisct1/libsodium.js/raw/master/dist/browsers/sodium.js && cd ..", "update": "npm i @streetwriters/editor@latest @streetwriters/notesnook-core@latest @streetwriters/theme@latest" }, "eslintConfig": { @@ -71,4 +71,4 @@ "last 4 edge version" ] } -} +} \ No newline at end of file diff --git a/apps/web/public/crypto.worker.js b/apps/web/public/crypto.worker.js new file mode 100644 index 000000000..eaed640e7 --- /dev/null +++ b/apps/web/public/crypto.worker.js @@ -0,0 +1,137 @@ +/* eslint-disable */ + +var context; +if (!self instanceof Window) { + self.sodium = { + onload: function (_sodium) { + context = { sodium: _sodium }; + sendMessage("loaded"); + }, + }; + importScripts("sodium.js"); + + self.addEventListener("message", onMessage); +} + +function onMessage(ev) { + const { type, data, messageId } = ev.data; + if (type === "encrypt") { + const { passwordOrKey, data: _data } = data; + const cipher = encrypt.call(context, passwordOrKey, _data); + sendMessage("encrypt", cipher, messageId); + } else if (type === "decrypt") { + const { passwordOrKey, cipher } = data; + const plainText = decrypt.call(context, passwordOrKey, cipher); + sendMessage("decrypt", plainText, messageId); + } else if (type === "deriveKey") { + const { password, salt, exportKey } = data; + const derivedKey = deriveKey.call(context, password, salt, exportKey); + sendMessage("deriveKey", derivedKey, messageId); + } +} + +function sendMessage(type, data, messageId) { + postMessage({ type, data, messageId }); +} + +const deriveKey = (password, salt, exportKey = false) => { + const { sodium } = this; + + if (!salt) salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + else { + salt = sodium.from_base64(salt); + } + + const key = sodium.crypto_pwhash( + sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES, + password, + salt, + 3, // operations limit + 1024 * 1024 * 8, // memory limit (8MB) + sodium.crypto_pwhash_ALG_ARGON2I13, + exportKey ? "base64" : "uint8array" + ); + const saltHex = sodium.to_base64(salt); + sodium.memzero(salt); + if (exportKey) { + return key; + } + return { key, salt: saltHex }; +}; + +const _getKey = (passwordOrKey) => { + const { sodium } = this; + + let { salt, key, password } = passwordOrKey; + if (password) { + const result = deriveKey(password, salt); + key = result.key; + salt = result.salt; + } else if (key && salt) { + salt = passwordOrKey.salt; + key = sodium.from_base64(key); + } + return { key, salt }; +}; + +/** + * + * @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key + * @param {string|Object} data - the plaintext data + */ +const encrypt = (passwordOrKey, data) => { + const { sodium } = this; + + const { key, salt } = _getKey(passwordOrKey); + + const nonce = sodium.randombytes_buf( + sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES + ); + const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( + data, + undefined, + undefined, + nonce, + key, + "base64" + ); + const iv = sodium.to_base64(nonce); + sodium.memzero(nonce); + sodium.memzero(key); + return { + cipher, + iv, + salt, + length: data.length, + }; +}; + +/** + * + * @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key + * @param {{salt: string, iv: string, cipher: string}} cipher - the cipher data + */ +const decrypt = (passwordOrKey, { iv, cipher, salt }) => { + const { sodium } = this; + + const { key } = _getKey({ salt, ...passwordOrKey }); + + const plainText = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + undefined, + sodium.from_base64(cipher), + undefined, + sodium.from_base64(iv), + key, + "text" + ); + sodium.memzero(key); + return plainText; +}; + +if (self instanceof Window) { + self.ncrypto = { + decrypt, + deriveKey, + encrypt, + }; +} diff --git a/apps/web/src/components/editor/index.js b/apps/web/src/components/editor/index.js index e58a5ab35..8f8966d44 100644 --- a/apps/web/src/components/editor/index.js +++ b/apps/web/src/components/editor/index.js @@ -3,7 +3,11 @@ import "./editor.css"; import ReactQuill from "./react-quill"; import { Flex } from "rebass"; import Properties from "../properties"; -import { useStore, SESSION_STATES } from "../../stores/editor-store"; +import { + useStore, + SESSION_STATES, + store as editorstore, +} from "../../stores/editor-store"; import { useStore as useAppStore } from "../../stores/app-store"; import { useStore as useUserStore } from "../../stores/user-store"; import Animated from "../animated"; @@ -14,7 +18,7 @@ import SplitEditor from "../spliteditor"; import Unlock from "../unlock"; function Editor() { - const delta = useStore((store) => store.session.content.delta); + console.log("rendering editor"); const sessionState = useStore((store) => store.session.state); const setSession = useStore((store) => store.setSession); const saveSession = useStore((store) => store.saveSession); @@ -45,15 +49,21 @@ function Editor() { useEffect(() => { if (!quillRef || !quillRef.current) return; + if (sessionState === SESSION_STATES.new) { + const { + content: { delta }, + } = editorstore.get().session; + const { quill } = quillRef.current; quill.setContents(delta, "init"); quill.history.clear(); if (!delta.ops || !delta.ops.length) return; const text = quill.getText(); quill.setSelection(text.length, 0, "init"); + editorstore.set((state) => (state.session.state = SESSION_STATES.stale)); } - }, [sessionState, delta, quillRef]); + }, [sessionState, quillRef]); if (unlock) return ; if (diff) return ; @@ -84,17 +94,20 @@ function Editor() { onFocus={() => { toggleProperties(false); }} - initialContent={delta} placeholder="Type anything here" container=".editor" onSave={() => { saveSession(); }} - onChange={(editor) => { + changeInterval={500} + onChange={() => { + const { quill } = quillRef.current; + const delta = quill.getContents().ops; + const text = quill.getText(); setSession((state) => { state.session.content = { - delta: { ops: editor.getContents().ops }, - text: editor.getText(), + delta: { ops: delta }, + text: text, }; }); }} diff --git a/apps/web/src/components/editor/react-quill.js b/apps/web/src/components/editor/react-quill.js index 0ae5d6fd4..702c9cd6d 100644 --- a/apps/web/src/components/editor/react-quill.js +++ b/apps/web/src/components/editor/react-quill.js @@ -53,6 +53,7 @@ const simpleQuillModules = { export default class ReactQuill extends Component { /** @private */ quill; + changeTimeout; getEditor() { return this.quill.editor; } @@ -108,9 +109,13 @@ export default class ReactQuill extends Component { this.quill.off("text-change", this.textChangeHandler); } - textChangeHandler = (delta, oldDelta, source) => { + textChangeHandler = (_delta, _oldDelta, source) => { if (source === "init") return; - this.props.onChange(this.quill); + clearTimeout(this.changeTimeout); + this.changeTimeout = setTimeout( + this.props.onChange, + this.props.changeInterval + ); }; render() { diff --git a/apps/web/src/interfaces/crypto.js b/apps/web/src/interfaces/crypto.js index ef31bcc56..5c5faa827 100644 --- a/apps/web/src/interfaces/crypto.js +++ b/apps/web/src/interfaces/crypto.js @@ -5,51 +5,19 @@ class Crypto { } async _initialize() { if (this.isReady) return; - const { default: _sodium } = await import("libsodium-wrappers"); - await _sodium.ready; - this.sodium = _sodium; - this.isReady = true; + return new Promise(async (resolve) => { + window.sodium = { + onload: (_sodium) => { + if (this.isReady) return; + this.isReady = true; + this.sodium = _sodium; + loadScript("crypto.worker.js").then(resolve); + }, + }; + await loadScript("sodium.js"); + }); } - deriveKey = async (password, salt, exportKey = false) => { - await this._initialize(); - - if (!salt) - salt = this.sodium.randombytes_buf(this.sodium.crypto_pwhash_SALTBYTES); - else { - salt = this.sodium.from_base64(salt); - } - - const key = this.sodium.crypto_pwhash( - this.sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES, - password, - salt, - 3, // operations limit - 1024 * 1024 * 8, // memory limit (8MB) - this.sodium.crypto_pwhash_ALG_ARGON2I13, - exportKey ? "base64" : "uint8array" - ); - const saltHex = this.sodium.to_base64(salt); - this.sodium.memzero(salt); - if (exportKey) { - return key; - } - return { key, salt: saltHex }; - }; - - _getKey = async (passwordOrKey) => { - let { salt, key, password } = passwordOrKey; - if (password) { - const result = await this.deriveKey(password, salt); - key = result.key; - salt = result.salt; - } else if (key && salt) { - salt = passwordOrKey.salt; - key = this.sodium.from_base64(key); - } - return { key, salt }; - }; - /** * * @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key @@ -57,29 +25,7 @@ class Crypto { */ encrypt = async (passwordOrKey, data) => { await this._initialize(); - - const { key, salt } = await this._getKey(passwordOrKey); - - const nonce = this.sodium.randombytes_buf( - this.sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES - ); - const cipher = this.sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( - data, - undefined, - undefined, - nonce, - key, - "base64" - ); - const iv = this.sodium.to_base64(nonce); - this.sodium.memzero(nonce); - this.sodium.memzero(key); - return { - cipher, - iv, - salt, - length: data.length, - }; + return global.ncrypto.encrypt.call(this, passwordOrKey, data); }; /** @@ -87,20 +33,100 @@ class Crypto { * @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key * @param {{salt: string, iv: string, cipher: string}} cipher - the cipher data */ - decrypt = async (passwordOrKey, { iv, cipher, salt }) => { + decrypt = async (passwordOrKey, cipher) => { await this._initialize(); - const { key } = await this._getKey({ salt, ...passwordOrKey }); + return global.ncrypto.decrypt.call(this, passwordOrKey, cipher); + }; - const plainText = this.sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( - undefined, - this.sodium.from_base64(cipher), - undefined, - this.sodium.from_base64(iv), - key, - "text" - ); - this.sodium.memzero(key); - return plainText; + deriveKey = async (password, salt, exportKey = false) => { + await this._initialize(); + return global.ncrypto.deriveKey.call(this, password, salt, exportKey); }; } -export default Crypto; + +class CryptoWorker { + constructor() { + this.isReady = false; + } + async _initialize() { + if (this.isReady) return; + return new Promise((resolve) => { + this.worker = new Worker("crypto.worker.js"); + this.worker.onmessage = (ev) => { + const { type } = ev.data; + if (type === "loaded") { + this.worker.onmessage = undefined; + this.isReady = true; + resolve(true); + } + }; + }); + } + + _communicate(type, data) { + return new Promise(async (resolve) => { + await this._initialize(); + const messageId = Math.random().toString(36).substr(2, 9); + const onMessage = (e) => { + const { type: _type, messageId: _mId } = e.data; + if (_type === type && _mId === messageId) { + this.worker.removeEventListener("message", onMessage); + resolve(e.data.data); + } + }; + this.worker.addEventListener("message", onMessage); + this.worker.postMessage({ + type, + data, + messageId, + }); + }); + } + + /** + * + * @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key + * @param {string|Object} data - the plaintext data + */ + encrypt = (passwordOrKey, data) => { + return this._communicate("encrypt", { + passwordOrKey, + data, + }); + }; + + /** + * + * @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key + * @param {{salt: string, iv: string, cipher: string}} cipher - the cipher data + */ + decrypt = (passwordOrKey, cipher) => { + return this._communicate("decrypt", { passwordOrKey, cipher }); + }; + + deriveKey = (password, salt, exportKey = false) => { + return this._communicate("deriveKey", { password, salt, exportKey }); + }; +} + +const NCrypto = + "Worker" in window || "Worker" in global ? CryptoWorker : Crypto; +export default NCrypto; + +function loadScript(url) { + return new Promise((resolve) => { + // adding the script tag to the head as suggested before + var head = document.getElementsByTagName("head")[0]; + var script = document.createElement("script"); + script.type = "text/javascript"; + script.src = url; + + // then bind the event to the callback function + // there are several events for cross browser compatibility + script.onreadystatechange = resolve; + script.onload = resolve; + + // fire the loading + head.appendChild(script); + }); +} diff --git a/apps/web/src/stores/editor-store.js b/apps/web/src/stores/editor-store.js index 29a5dcf88..9d44f31fe 100644 --- a/apps/web/src/stores/editor-store.js +++ b/apps/web/src/stores/editor-store.js @@ -13,7 +13,6 @@ const DEFAULT_SESSION = { state: undefined, isSaving: false, title: "", - timeout: 0, id: "", pinned: false, favorite: false, @@ -68,7 +67,6 @@ class EditorStore extends BaseStore { let note = db.notes.note(noteId); if (!note) return; note = note.data; - clearTimeout(this.get().session.timeout); noteStore.setSelectedNote(note.id); @@ -95,6 +93,7 @@ class EditorStore extends BaseStore { saveSession = (oldSession) => { this.set((state) => (state.session.isSaving = true)); + this._saveFn()(this.get().session).then(async (id) => { /* eslint-disable */ storeSync: if (oldSession) { @@ -135,7 +134,6 @@ class EditorStore extends BaseStore { newSession = (context = {}) => { setHashParam({ note: 0 }); - clearTimeout(this.get().session.timeout); this.set(function (state) { state.session = { ...DEFAULT_SESSION, @@ -149,7 +147,6 @@ class EditorStore extends BaseStore { clearSession = () => { setHashParam({}); - clearTimeout(this.get().session.timeout); this.set(function (state) { state.session = { ...DEFAULT_SESSION, @@ -159,27 +156,10 @@ class EditorStore extends BaseStore { noteStore.setSelectedNote(0); }; - setSession = (set, immediate = false) => { - clearTimeout(this.get().session.timeout); + setSession = (set) => { const oldSession = { ...this.get().session }; - - // TODO test this - this.set((state) => { - if (state.session.state !== SESSION_STATES.stale) - state.session.state = SESSION_STATES.stale; - }); - - this.set((state) => { - set(state); - - state.session.timeout = setTimeout( - () => { - this.session = this.get().session; - this.saveSession(oldSession); - }, - immediate ? 0 : 500 - ); - }); + this.set(set); + this.saveSession(oldSession); }; toggleLocked = () => { @@ -224,11 +204,11 @@ class EditorStore extends BaseStore { let index = arr.indexOf(value); if (index > -1) { note[`un${key}`](value).then(() => { - this.setSession((state) => state.session[array].splice(index, 1), true); + this.setSession((state) => state.session[array].splice(index, 1)); }); } else { note[key](value).then(() => { - this.setSession((state) => state.session[array].push(value), true); + this.setSession((state) => state.session[array].push(value)); }); } } diff --git a/apps/web/yarn.lock b/apps/web/yarn.lock index f650f99c4..92d36af8d 100644 --- a/apps/web/yarn.lock +++ b/apps/web/yarn.lock @@ -6951,18 +6951,6 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -libsodium-wrappers@^0.7.6: - version "0.7.8" - resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.8.tgz#d95cdf3e7236c2aef76844bf8e1929ba9eef3e9e" - integrity sha512-PDhPWXBqd/SaqAFUBgH2Ux7b3VEEJgyD6BQB+VdNFJb9PbExGr/T/myc/MBoSvl8qLzfm0W0IVByOQS5L1MrCg== - dependencies: - libsodium "0.7.8" - -libsodium@0.7.8: - version "0.7.8" - resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.8.tgz#fbd12247b7b1353f88d8de1cbc66bc1a07b2e008" - integrity sha512-/Qc+APf0jbeWSaeEruH0L1/tbbT+sbf884ZL0/zV/0JXaDPBzYkKbyb/wmxMHgAHzm3t6gqe7bOOXAVwfqVikQ== - lie@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" @@ -7638,7 +7626,7 @@ normalize-url@^3.0.0, normalize-url@^3.0.1: "notes-core@git+ssh://git@github.com:streetwriters/notesnook-core.git": version "1.5.0" - resolved "git+ssh://git@github.com:streetwriters/notesnook-core.git#fa55c0d6732cdfda5108a4e77071dc9e3cb89e70" + resolved "git+ssh://git@github.com:streetwriters/notesnook-core.git#24951bd7957f86c48ac303d831c1b36e1ad0df5d" dependencies: fast-sort "^2.0.1" fuzzysearch "^1.0.3"