mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-22 22:49:45 +01:00
feat: initial work
This commit is contained in:
@@ -9,9 +9,9 @@
|
||||
"dependencies": {
|
||||
"@mdi/js": "^5.9.55",
|
||||
"@mdi/react": "^1.4.0",
|
||||
"@notesnook/desktop": "./desktop/",
|
||||
"@notesnook/desktop": "file:./desktop/",
|
||||
"@rebass/forms": "^4.0.6",
|
||||
"@streetwritersco/tinymce-plugins": "^1.1.2",
|
||||
"@streetwritersco/tinymce-plugins": "file:../notesnook/packages/tinymce-plugins",
|
||||
"@tinymce/tinymce-react": "^3.12.6",
|
||||
"clipboard": "^2.0.6",
|
||||
"cogo-toast": "^4.2.3",
|
||||
@@ -48,6 +48,7 @@
|
||||
"tinymce": "5.8.1",
|
||||
"uzip": "^0.20201231.0",
|
||||
"wouter": "^2.7.3",
|
||||
"xxhash-wasm": "^0.4.2",
|
||||
"zustand": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,10 +12,21 @@ function onMessage(ev) {
|
||||
switch (type) {
|
||||
case "encrypt": {
|
||||
const { passwordOrKey, data: _data } = data;
|
||||
const cipher = encrypt.call(context, passwordOrKey, _data);
|
||||
const cipher = encrypt.call(context, passwordOrKey, _data, "base64");
|
||||
sendMessage("encrypt", cipher, messageId);
|
||||
break;
|
||||
}
|
||||
case "encryptBinary": {
|
||||
const { passwordOrKey, data: _data } = data;
|
||||
const cipher = encrypt.call(
|
||||
context,
|
||||
passwordOrKey,
|
||||
_data,
|
||||
"uint8array"
|
||||
);
|
||||
sendMessage("encryptBinary", cipher, messageId, [cipher.cipher.buffer]);
|
||||
break;
|
||||
}
|
||||
case "decrypt": {
|
||||
const { passwordOrKey, cipher } = data;
|
||||
const plainText = decrypt.call(context, passwordOrKey, cipher);
|
||||
@@ -125,7 +136,7 @@ const _getKey = (passwordOrKey) => {
|
||||
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
|
||||
* @param {{type: "plain" | "uint8array", data: string | Uint8Array}} plainData - the plaintext data
|
||||
*/
|
||||
const encrypt = (passwordOrKey, plainData) => {
|
||||
const encrypt = (passwordOrKey, plainData, outputType) => {
|
||||
const { sodium } = this;
|
||||
|
||||
if (plainData.type === "plain") {
|
||||
@@ -149,7 +160,7 @@ const encrypt = (passwordOrKey, plainData) => {
|
||||
undefined,
|
||||
nonce,
|
||||
key,
|
||||
"base64"
|
||||
outputType
|
||||
);
|
||||
const iv = sodium.to_base64(nonce);
|
||||
sodium.memzero(nonce);
|
||||
@@ -171,14 +182,20 @@ const encrypt = (passwordOrKey, plainData) => {
|
||||
* @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, output }) => {
|
||||
const decrypt = (passwordOrKey, { iv, cipher, salt, output, inputType }) => {
|
||||
const { sodium } = this;
|
||||
|
||||
const { key } = _getKey({ salt, ...passwordOrKey });
|
||||
const input =
|
||||
inputType === "uint8array"
|
||||
? input
|
||||
: inputType === "base64"
|
||||
? sodium.from_base64(cipher)
|
||||
: sodium.decode(cipher);
|
||||
|
||||
const data = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
undefined,
|
||||
sodium.from_base64(cipher),
|
||||
input,
|
||||
undefined,
|
||||
sodium.from_base64(iv),
|
||||
key,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
/security#csp-meta-tag -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' https://analytics.streetwriters.co https://cdn.paddle.com 'unsafe-inline';"
|
||||
content="script-src 'self' https://analytics.streetwriters.co https://cdn.paddle.com 'unsafe-inline' 'unsafe-eval';"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import StorageInterface from "../interfaces/storage";
|
||||
import FS from "../interfaces/fs";
|
||||
import EventSource from "eventsource";
|
||||
import Config from "../utils/config";
|
||||
import http from "notes-core/utils/http";
|
||||
@@ -12,13 +13,8 @@ import { isTesting } from "../utils/platform";
|
||||
var db;
|
||||
async function initializeDatabase() {
|
||||
const { default: Database } = await import("notes-core/api");
|
||||
db = new Database(StorageInterface, EventSource);
|
||||
db = new Database(StorageInterface, EventSource, FS);
|
||||
|
||||
// db.host({
|
||||
// API_HOST: "http://localhost:5264",
|
||||
// AUTH_HOST: "http://localhost:8264",
|
||||
// SSE_HOST: "http://localhost:7264",
|
||||
// });
|
||||
if (isTesting()) {
|
||||
db.host({
|
||||
API_HOST: "https://api.notesnook.com",
|
||||
@@ -27,9 +23,9 @@ async function initializeDatabase() {
|
||||
});
|
||||
} else {
|
||||
db.host({
|
||||
API_HOST: "http://192.168.10.23:5264",
|
||||
AUTH_HOST: "http://192.168.10.23:8264",
|
||||
SSE_HOST: "http://192.168.10.23:7264",
|
||||
API_HOST: "http://localhost:5264",
|
||||
AUTH_HOST: "http://localhost:8264",
|
||||
SSE_HOST: "http://localhost:7264",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
146
apps/web/src/components/editor/plugins/attachmentpicker.js
Normal file
146
apps/web/src/components/editor/plugins/attachmentpicker.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import Compressor from "compressorjs";
|
||||
import { db } from "../../../common/db";
|
||||
import fs from "../../../interfaces/fs";
|
||||
|
||||
function register(editor) {
|
||||
editor.ui.registry.addButton("attachment", {
|
||||
icon: "attachment",
|
||||
tooltip: "Attach a file",
|
||||
onAction: () => insertFile(editor),
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton("image", {
|
||||
icon: "image",
|
||||
tooltip: "Insert image",
|
||||
onAction: () => insertImage(editor),
|
||||
});
|
||||
|
||||
editor.addCommand("InsertImage", function () {
|
||||
insertImage(editor);
|
||||
});
|
||||
}
|
||||
|
||||
async function insertImage(editor) {
|
||||
const image = await pickImage();
|
||||
if (!image) return;
|
||||
|
||||
var content = `<img class="attachment" data-mime="${image.type}" data-hash="${image.hash}" data-filename="${image.filename}" src="${image.dataurl}" data-size="${image.size}"/>`;
|
||||
editor.insertContent(content);
|
||||
}
|
||||
|
||||
async function insertFile(editor) {
|
||||
const file = await pickFile();
|
||||
if (!file) return;
|
||||
|
||||
var content = `<span class="attachment" data-mime="${file.type}" data-filename="${file.filename}" data-hash="${file.hash}" data-size="${file.size}"/>`;
|
||||
editor.insertContent(content);
|
||||
}
|
||||
|
||||
(function init() {
|
||||
global.tinymce.PluginManager.add("attachmentpicker", register);
|
||||
})();
|
||||
|
||||
async function pickFile() {
|
||||
const selectedFile = await showFilePicker({ acceptedFileTypes: "*/*" });
|
||||
if (!selectedFile) return;
|
||||
|
||||
const key = await getEncryptionKey();
|
||||
const buffer = await selectedFile.arrayBuffer();
|
||||
const output = await fs.writeEncrypted(null, {
|
||||
data: buffer,
|
||||
type: "buffer",
|
||||
key,
|
||||
});
|
||||
|
||||
await db.attachments.add({
|
||||
...output,
|
||||
filename: selectedFile.name,
|
||||
type: selectedFile.type,
|
||||
});
|
||||
|
||||
return {
|
||||
hash: selectedFile.hash,
|
||||
filename: selectedFile.name,
|
||||
type: selectedFile.type,
|
||||
size: selectedFile.size,
|
||||
};
|
||||
}
|
||||
|
||||
async function pickImage() {
|
||||
const selectedImage = await showFilePicker({ acceptedFileTypes: "image/*" });
|
||||
if (!selectedImage) return;
|
||||
|
||||
const { dataurl, buffer } = await compressImage(selectedImage, "buffer");
|
||||
const key = await getEncryptionKey();
|
||||
|
||||
const output = await fs.writeEncrypted(null, {
|
||||
data: buffer,
|
||||
type: "buffer",
|
||||
key,
|
||||
});
|
||||
|
||||
await db.attachments.add({
|
||||
...output,
|
||||
filename: selectedImage.name,
|
||||
type: selectedImage.type,
|
||||
});
|
||||
|
||||
return {
|
||||
hash: selectedImage.hash,
|
||||
filename: selectedImage.name,
|
||||
type: selectedImage.type,
|
||||
size: output.length,
|
||||
dataurl,
|
||||
};
|
||||
}
|
||||
|
||||
async function getEncryptionKey() {
|
||||
const key = await db.user.getEncryptionKey();
|
||||
if (!key) throw new Error("No encryption key found. Are you logged in?");
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<File>}
|
||||
*/
|
||||
function showFilePicker({ acceptedFileTypes }) {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", acceptedFileTypes);
|
||||
input.dispatchEvent(new MouseEvent("click"));
|
||||
input.onchange = async function () {
|
||||
var file = this.files[0];
|
||||
if (!file) return null;
|
||||
resolve(file);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {File} file
|
||||
* @param {"base64"|"buffer"} type
|
||||
*/
|
||||
function compressImage(file, type) {
|
||||
return new Promise((resolve, reject) => {
|
||||
new Compressor(file, {
|
||||
quality: 0.8,
|
||||
mimeType: file.type,
|
||||
width: 1920,
|
||||
/**
|
||||
*
|
||||
* @param {Blob} result
|
||||
*/
|
||||
async success(result) {
|
||||
const buffer = await result.arrayBuffer();
|
||||
const base64 = Buffer.from(buffer).toString("base64");
|
||||
resolve({ dataurl: `data:${file.type};base64,${base64}`, buffer });
|
||||
},
|
||||
error(err) {
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
16
apps/web/src/components/editor/plugins/icons.js
Normal file
16
apps/web/src/components/editor/plugins/icons.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as Icons from "@mdi/js";
|
||||
|
||||
function register(editor) {
|
||||
editor.ui.registry.addIcon(
|
||||
"attachment",
|
||||
createSvgElement(Icons.mdiAttachment)
|
||||
);
|
||||
}
|
||||
|
||||
function createSvgElement(path) {
|
||||
return `<svg height="24" width="24"><path d="${path}" /></svg>`;
|
||||
}
|
||||
|
||||
(function init() {
|
||||
global.tinymce.PluginManager.add("icons", register);
|
||||
})();
|
||||
@@ -25,12 +25,13 @@ import "tinymce/plugins/media";
|
||||
import { processPastedContent } from "@streetwritersco/tinymce-plugins/codeblock";
|
||||
import "@streetwritersco/tinymce-plugins/inlinecode";
|
||||
import "@streetwritersco/tinymce-plugins/shortlink";
|
||||
import "@streetwritersco/tinymce-plugins/quickimage";
|
||||
import "@streetwritersco/tinymce-plugins/checklist";
|
||||
import "@streetwritersco/tinymce-plugins/collapsibleheaders";
|
||||
import "@streetwritersco/tinymce-plugins/paste";
|
||||
import "@streetwritersco/tinymce-plugins/shortcuts";
|
||||
import "@streetwritersco/tinymce-plugins/keyboardquirks";
|
||||
import "./plugins/attachmentpicker";
|
||||
import "./plugins/icons";
|
||||
import { Editor } from "@tinymce/tinymce-react";
|
||||
import { showBuyDialog } from "../../common/dialog-controller";
|
||||
import { useStore as useThemeStore } from "../../stores/theme-store";
|
||||
@@ -109,7 +110,7 @@ const plugins = {
|
||||
default:
|
||||
"importcss searchreplace autolink directionality media table hr advlist lists imagetools noneditable quickbars autoresize",
|
||||
custom:
|
||||
"keyboardquirks collapsibleheaders shortlink quickimage paste codeblock inlinecode shortcuts checklist",
|
||||
"icons keyboardquirks collapsibleheaders shortlink attachmentpicker paste codeblock inlinecode shortcuts checklist",
|
||||
pro: "textpattern",
|
||||
};
|
||||
|
||||
@@ -200,7 +201,7 @@ function TinyMCE(props) {
|
||||
`,
|
||||
toolbar: simple
|
||||
? false
|
||||
: `bold italic underline strikethrough inlinecode | blockquote codeblock | fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent subscript superscript | numlist bullist checklist | forecolor backcolor removeformat | hr | image media link table | ltr rtl | searchreplace`,
|
||||
: `bold italic underline strikethrough inlinecode | blockquote codeblock | fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent subscript superscript | numlist bullist checklist | forecolor backcolor removeformat | hr | image attachment media link table | ltr rtl | searchreplace`,
|
||||
quickbars_selection_toolbar: false,
|
||||
mobile: {
|
||||
toolbar_mode: "scrolling",
|
||||
|
||||
68
apps/web/src/interfaces/fs.js
Normal file
68
apps/web/src/interfaces/fs.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import NNCrypto from "./nncrypto/index";
|
||||
import localforage from "localforage";
|
||||
import xxhash from "xxhash-wasm";
|
||||
|
||||
const crypto = new NNCrypto();
|
||||
const fs = localforage.createInstance({
|
||||
storeName: "notesnook-fs",
|
||||
name: "NotesnookFS",
|
||||
driver: [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE],
|
||||
});
|
||||
|
||||
fs.hasItem = async function (key) {
|
||||
const keys = await fs.keys();
|
||||
return keys.includes(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* We perform 4 steps here:
|
||||
* 1. We convert base64 to Uint8Array (if we get base64, that is)
|
||||
* 2. We hash the Uint8Array.
|
||||
* 3. We encrypt the Uint8Array
|
||||
* 4. We save the encrypted Uint8Array
|
||||
*/
|
||||
async function writeEncrypted(filename, { data, type, key }) {
|
||||
const saveAsBuffer = localforage.supports(localforage.INDEXEDDB);
|
||||
|
||||
if (type === "base64") data = new Uint8Array(Buffer.from(data, "base64"));
|
||||
const { hash, type: hashType } = await hashBuffer(data);
|
||||
if (!filename) filename = hash;
|
||||
|
||||
if (await fs.hasItem(filename)) return { hash, hashType };
|
||||
|
||||
const output = saveAsBuffer
|
||||
? await crypto.encryptBinary(key, data, "buffer")
|
||||
: await crypto.encrypt(key, data, "buffer");
|
||||
|
||||
await fs.setItem(filename, output.cipher);
|
||||
return {
|
||||
hash,
|
||||
hashType,
|
||||
iv: output.iv,
|
||||
length: output.length,
|
||||
salt: output.salt,
|
||||
alg: output.alg,
|
||||
};
|
||||
}
|
||||
|
||||
async function hashBuffer(data) {
|
||||
const hasher = await xxhash();
|
||||
return {
|
||||
hash: Buffer.from(hasher.h64Raw(data)).toString("base64"),
|
||||
type: "xxh64",
|
||||
};
|
||||
}
|
||||
|
||||
async function readEncrypted(filename, key, cipherData) {
|
||||
const readAsBuffer = localforage.supports(localforage.INDEXEDDB);
|
||||
cipherData.cipher = await fs.getItem(filename);
|
||||
if (!cipherData.cipher)
|
||||
throw new Error(`File not found. Filename: ${filename}`);
|
||||
|
||||
return readAsBuffer
|
||||
? await crypto.decryptBinary(key, cipherData, cipherData.outputType)
|
||||
: await crypto.decrypt(key, cipherData, cipherData.outputType);
|
||||
}
|
||||
|
||||
const FS = { writeEncrypted, readEncrypted };
|
||||
export default FS;
|
||||
@@ -24,10 +24,24 @@ export default class NNCrypto {
|
||||
* @param {string} plainData - the plaintext data
|
||||
* @param {boolean}
|
||||
*/
|
||||
encrypt = async (passwordOrKey, plainData) => {
|
||||
encrypt = async (passwordOrKey, plainData, type = "plain") => {
|
||||
await this._initialize();
|
||||
return global.ncrypto.encrypt.call(this, passwordOrKey, {
|
||||
type: "plain",
|
||||
type,
|
||||
data: plainData,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
|
||||
* @param {string} plainData - the plaintext data
|
||||
* @param {string} type
|
||||
*/
|
||||
encryptBinary = async (passwordOrKey, plainData, type = "plain") => {
|
||||
await this._initialize();
|
||||
return global.ncrypto.encryptBinary.call(this, passwordOrKey, {
|
||||
type,
|
||||
data: plainData,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -65,11 +65,49 @@ export default class NNCryptoWorker {
|
||||
* @param {string} data - the plaintext data
|
||||
* @param {boolean} compress
|
||||
*/
|
||||
encrypt = (passwordOrKey, data) => {
|
||||
return this._communicate("encrypt", {
|
||||
passwordOrKey,
|
||||
data: { type: "plain", data },
|
||||
});
|
||||
encrypt = (passwordOrKey, data, type = "plain") => {
|
||||
const payload = { type, data };
|
||||
const transferables = type === "buffer" ? [payload.data] : [];
|
||||
return this._communicate(
|
||||
"encrypt",
|
||||
{
|
||||
passwordOrKey,
|
||||
data: payload,
|
||||
},
|
||||
transferables
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
|
||||
* @param {string} data - the plaintext data
|
||||
* @param {string} type
|
||||
*/
|
||||
encryptBinary = (passwordOrKey, data, type = "plain") => {
|
||||
const payload = { type, data };
|
||||
const transferables = type === "buffer" ? [payload.data] : [];
|
||||
return this._communicate(
|
||||
"encryptBinary",
|
||||
{
|
||||
passwordOrKey,
|
||||
data: payload,
|
||||
},
|
||||
transferables
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
|
||||
* @param {{alg: string, salt: string, iv: string, cipher: Uint8Array}} cipherData - the cipher data
|
||||
*/
|
||||
decryptBinary = (passwordOrKey, cipherData, outputType = "text") => {
|
||||
cipherData.output = outputType;
|
||||
cipherData.inputType = "uint8array";
|
||||
return this._communicate("decrypt", { passwordOrKey, cipher: cipherData }, [
|
||||
cipherData.cipher,
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -77,8 +115,9 @@ export default class NNCryptoWorker {
|
||||
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
|
||||
* @param {{alg: string, salt: string, iv: string, cipher: string}} cipherData - the cipher data
|
||||
*/
|
||||
decrypt = (passwordOrKey, cipherData) => {
|
||||
cipherData.output = "text";
|
||||
decrypt = (passwordOrKey, cipherData, outputType = "text") => {
|
||||
cipherData.output = outputType;
|
||||
cipherData.inputType = "base64";
|
||||
return this._communicate("decrypt", { passwordOrKey, cipher: cipherData });
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user