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"