mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
core: migrate vault to a database collection
This commit is contained in:
@@ -72,6 +72,15 @@ exports[`get web pricing tier > get yearly web tier > yearly-web-pricing 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`get yearly price > monthly-pricing 1`] = `
|
||||
{
|
||||
"country": Any<String>,
|
||||
"countryCode": Any<String>,
|
||||
"discount": Any<Number>,
|
||||
"price": Any<Number>,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`get yearly price > yearly-pricing 1`] = `
|
||||
{
|
||||
"country": Any<String>,
|
||||
|
||||
@@ -17,17 +17,18 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { databaseTest, noteTest, TEST_NOTE } from "./utils";
|
||||
import { VAULT_ERRORS } from "../src/api/vault";
|
||||
import { databaseTest, delay, noteTest, TEST_NOTE } from "./utils";
|
||||
import { test, expect } from "vitest";
|
||||
|
||||
test("create vault", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await expect(db.vault.create("password")).resolves.toBe(true);
|
||||
const vaultKey = await db.storage().read("vaultKey");
|
||||
expect(vaultKey).toBeDefined();
|
||||
expect(vaultKey.iv).toBeDefined();
|
||||
expect(vaultKey.cipher).toBeDefined();
|
||||
expect(vaultKey.length).toBeDefined();
|
||||
const vault = await db.vaults.default();
|
||||
expect(vault).toBeDefined();
|
||||
expect(vault.key.iv).toBeDefined();
|
||||
expect(vault.key.cipher).toBeDefined();
|
||||
expect(vault.key.length).toBeDefined();
|
||||
}));
|
||||
|
||||
test("unlock vault", () =>
|
||||
@@ -57,15 +58,20 @@ test("lock a note when no vault has been created", () =>
|
||||
test("lock a note", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
await db.vault.create("password");
|
||||
|
||||
await db.vault.add(id);
|
||||
|
||||
const note = await db.notes.note(id);
|
||||
const content = await db.content.get(note.contentId);
|
||||
const vault = await db.vaults.default();
|
||||
|
||||
expect(note.headline).toBe("");
|
||||
|
||||
const content = await db.content.get(note.contentId);
|
||||
expect(content.noteId).toBeDefined();
|
||||
expect(content.data.iv).toBeDefined();
|
||||
expect(content.data.cipher).toBeDefined();
|
||||
|
||||
expect(await db.relations.from(vault, "note").has(id)).toBe(true);
|
||||
}));
|
||||
|
||||
test("locked note is not favorited", () =>
|
||||
@@ -81,23 +87,32 @@ test("unlock a note", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
await db.vault.create("password");
|
||||
await db.vault.add(id);
|
||||
|
||||
const note = await db.vault.open(id, "password");
|
||||
|
||||
const vault = await db.vaults.default();
|
||||
expect(note.id).toBe(id);
|
||||
expect(note.content.data).toBeDefined();
|
||||
expect(note.content.type).toBe(TEST_NOTE.content.type);
|
||||
expect(await db.relations.from(vault, "note").has(id)).toBe(true);
|
||||
}));
|
||||
|
||||
test("unlock a note permanently", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
await db.vault.create("password");
|
||||
await db.vault.add(id);
|
||||
|
||||
await db.vault.remove(id, "password");
|
||||
|
||||
const note = await db.notes.note(id);
|
||||
const content = await db.content.get(note.contentId);
|
||||
const vault = await db.vaults.default();
|
||||
|
||||
expect(note.id).toBe(id);
|
||||
expect(note.headline).not.toBe("");
|
||||
const content = await db.content.get(note.contentId);
|
||||
expect(content.data).toBeDefined();
|
||||
expect(typeof content.data).toBe("string");
|
||||
expect(await db.relations.from(vault, "note").has(id)).toBe(false);
|
||||
}));
|
||||
|
||||
test("save a locked note", () =>
|
||||
@@ -142,3 +157,75 @@ test("change vault password", () =>
|
||||
await expect(db.vault.open(id, "password")).rejects.toThrow();
|
||||
await expect(db.vault.open(id, "newPassword")).resolves.toBeDefined();
|
||||
}));
|
||||
|
||||
test("changing vault password without a vault should throw", () =>
|
||||
noteTest().then(async ({ db }) => {
|
||||
await expect(
|
||||
db.vault.changePassword("password", "newPassword")
|
||||
).rejects.toThrow(VAULT_ERRORS.noVault);
|
||||
}));
|
||||
|
||||
test("clear vault", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
await db.vault.create("password");
|
||||
await db.vault.add(id);
|
||||
|
||||
await db.vault.clear("password");
|
||||
|
||||
const vault = await db.vaults.default();
|
||||
expect(await db.relations.from(vault, "note").has(id)).toBe(false);
|
||||
}));
|
||||
|
||||
test("delete vault without deleting all locked notes", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
await db.vault.create("password");
|
||||
await db.vault.add(id);
|
||||
const vault = await db.vaults.default();
|
||||
|
||||
await db.vault.delete();
|
||||
|
||||
expect(await db.relations.from(vault, "note").has(id)).toBe(false);
|
||||
expect(await db.vaults.default()).toBeUndefined();
|
||||
}));
|
||||
|
||||
test("delete vault and delete all locked notes", () =>
|
||||
noteTest().then(async ({ db, id }) => {
|
||||
await db.vault.create("password");
|
||||
await db.vault.add(id);
|
||||
const vault = await db.vaults.default();
|
||||
|
||||
await db.vault.delete(true);
|
||||
|
||||
expect(await db.relations.from(vault, "note").has(id)).toBe(false);
|
||||
expect(await db.notes.exists(id)).toBe(false);
|
||||
expect(await db.vaults.default()).toBeUndefined();
|
||||
}));
|
||||
|
||||
test("vault password is cleared after specified time", () =>
|
||||
databaseTest().then(async (db) => {
|
||||
await expect(db.vault.create("password")).resolves.toBe(true);
|
||||
db.vault.eraseTime = 1000;
|
||||
await expect(db.vault.unlock("password")).resolves.toBe(true);
|
||||
expect(db.vault.unlocked).toBe(true);
|
||||
await delay(1500);
|
||||
expect(db.vault.unlocked).toBe(false);
|
||||
}));
|
||||
|
||||
// test("remove note from vault if it isn't encrypted", () =>
|
||||
// noteTest().then(async ({ db, id }) => {
|
||||
// await db.vault.create("password");
|
||||
// const vault = await db.vaults.default();
|
||||
|
||||
// await db.relations.add(vault, { id, type: "note" });
|
||||
|
||||
// const decryptedContent = await db.vault.decryptContent(
|
||||
// {
|
||||
// data: "<p>hello world</p>",
|
||||
// type: "tiptap"
|
||||
// },
|
||||
// "password"
|
||||
// );
|
||||
|
||||
// expect(await db.relations.from(vault, "note").has(id)).toBe(false);
|
||||
// expect(decryptedContent.data).toBe("<p>hello world</p>");
|
||||
// }));
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
} from "../database";
|
||||
import { Kysely, Transaction, sql } from "kysely";
|
||||
import { CachedCollection } from "../database/cached-collection";
|
||||
import { Vaults } from "../collections/vaults";
|
||||
|
||||
type EventSourceConstructor = new (
|
||||
uri: string,
|
||||
@@ -183,6 +184,7 @@ class Database {
|
||||
reminders = new Reminders(this);
|
||||
relations = new Relations(this);
|
||||
notes = new Notes(this);
|
||||
vaults = new Vaults(this);
|
||||
|
||||
/**
|
||||
* @deprecated only kept here for migration purposes
|
||||
@@ -255,7 +257,6 @@ class Database {
|
||||
);
|
||||
EV.subscribe(EVENTS.attachmentDeleted, async (attachment: Attachment) => {
|
||||
await this.fs().cancel(attachment.hash);
|
||||
await this.fs().cancel(attachment.hash);
|
||||
});
|
||||
EV.subscribe(EVENTS.userLoggedOut, async () => {
|
||||
await this.monographs.clear();
|
||||
|
||||
@@ -34,7 +34,7 @@ import { AutoSync } from "./auto-sync";
|
||||
import { logger } from "../../logger";
|
||||
import { Mutex } from "async-mutex";
|
||||
import Database from "..";
|
||||
import { migrateItem } from "../../migrations";
|
||||
import { migrateItem, migrateVaultKey } from "../../migrations";
|
||||
import { SerializedKey } from "@notesnook/crypto";
|
||||
import { Item, MaybeDeletedItem, Note, Notebook } from "../../types";
|
||||
import { SYNC_COLLECTIONS_MAP, SyncTransferItem } from "./types";
|
||||
@@ -189,6 +189,26 @@ class Sync {
|
||||
|
||||
let count = 0;
|
||||
this.connection?.off("SendItems");
|
||||
this.connection?.off("SendVaultKey");
|
||||
|
||||
this.connection?.on("SendVaultKey", async (vaultKey) => {
|
||||
if (this.connection?.state !== signalr.HubConnectionState.Connected)
|
||||
return false;
|
||||
|
||||
if (
|
||||
vaultKey &&
|
||||
vaultKey.cipher !== null &&
|
||||
vaultKey.iv !== null &&
|
||||
vaultKey.salt !== null &&
|
||||
vaultKey.length > 0
|
||||
) {
|
||||
const vault = await this.db.vaults.default();
|
||||
if (!vault) await migrateVaultKey(this.db, vaultKey, 5.9);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.connection?.on("SendItems", async (chunk) => {
|
||||
if (this.connection?.state !== signalr.HubConnectionState.Connected)
|
||||
return false;
|
||||
@@ -200,39 +220,17 @@ class Sync {
|
||||
|
||||
return true;
|
||||
});
|
||||
const serverResponse = await this.connection?.invoke(
|
||||
"RequestFetch",
|
||||
deviceId
|
||||
);
|
||||
|
||||
if (
|
||||
serverResponse.vaultKey &&
|
||||
serverResponse.vaultKey.cipher !== null &&
|
||||
serverResponse.vaultKey.iv !== null &&
|
||||
serverResponse.vaultKey.salt !== null &&
|
||||
serverResponse.vaultKey.length > 0
|
||||
) {
|
||||
await this.db.vault.setKey(serverResponse.vaultKey);
|
||||
}
|
||||
await this.connection?.invoke("RequestFetch", deviceId);
|
||||
|
||||
this.connection?.off("SendItems");
|
||||
this.connection?.off("SendVaultKey");
|
||||
}
|
||||
|
||||
async send(deviceId: string, isForceSync?: boolean) {
|
||||
await this.uploadAttachments();
|
||||
|
||||
let isSyncInitialized = false;
|
||||
let done = 0;
|
||||
for await (const item of this.collector.collect(100, isForceSync)) {
|
||||
if (!isSyncInitialized) {
|
||||
const vaultKey = await this.db.vault.getKey();
|
||||
await this.connection?.send("InitializePush", {
|
||||
vaultKey,
|
||||
synced: false
|
||||
});
|
||||
isSyncInitialized = true;
|
||||
}
|
||||
|
||||
const result = await this.pushItem(deviceId, item);
|
||||
if (result) {
|
||||
done += item.items.length;
|
||||
@@ -245,7 +243,6 @@ class Sync {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!isSyncInitialized) return false;
|
||||
await this.connection?.send("PushCompleted");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,6 @@ import Database from ".";
|
||||
import { CHECK_IDS, EV, EVENTS, checkIsUserPremium } from "../common";
|
||||
import { tinyToTiptap } from "../migrations";
|
||||
import { isCipher } from "../database/crypto";
|
||||
import { Note } from "../types";
|
||||
import {
|
||||
isEncryptedContent,
|
||||
isUnencryptedContent
|
||||
} from "../collections/content";
|
||||
import { NoteContent } from "../collections/session-content";
|
||||
|
||||
export const VAULT_ERRORS = {
|
||||
@@ -35,8 +30,8 @@ export const VAULT_ERRORS = {
|
||||
wrongPassword: "ERR_WRONG_PASSWORD"
|
||||
};
|
||||
|
||||
const ERASE_TIME = 1000 * 60 * 30;
|
||||
export default class Vault {
|
||||
eraseTime = 1000 * 60 * 30;
|
||||
private vaultPassword?: string;
|
||||
private erasureTimeout = 0;
|
||||
private key = "svvaads1212#2123";
|
||||
@@ -57,7 +52,7 @@ export default class Vault {
|
||||
this.erasureTimeout = setTimeout(() => {
|
||||
this.password = undefined;
|
||||
EV.publish(EVENTS.vaultLocked);
|
||||
}, ERASE_TIME) as unknown as number;
|
||||
}, this.eraseTime) as unknown as number;
|
||||
}
|
||||
|
||||
constructor(private readonly db: Database) {
|
||||
@@ -99,63 +94,73 @@ export default class Vault {
|
||||
}
|
||||
|
||||
async changePassword(oldPassword: string, newPassword: string) {
|
||||
const vault = await this.db.vaults.default();
|
||||
if (!vault) throw new Error(VAULT_ERRORS.noVault);
|
||||
|
||||
if (await this.unlock(oldPassword)) {
|
||||
const contentItems = [];
|
||||
for await (const note of this.db.notes.locked) {
|
||||
if (!note.contentId) continue;
|
||||
const encryptedContent = await this.db.content.get(note.contentId);
|
||||
if (!encryptedContent || !isEncryptedContent(encryptedContent))
|
||||
const relations = await this.db.relations.from(vault, "note").get();
|
||||
for (const { toId: noteId } of relations) {
|
||||
const content = await this.db.content.findByNoteId(noteId);
|
||||
if (!content || !content.locked) {
|
||||
await this.db.relations.unlink(vault, { id: noteId, type: "note" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await this.decryptContent(
|
||||
encryptedContent,
|
||||
note.id,
|
||||
const decryptedContent = await this.decryptContent(
|
||||
content,
|
||||
oldPassword
|
||||
);
|
||||
contentItems.push({
|
||||
...content,
|
||||
id: note.contentId,
|
||||
noteId: note.id
|
||||
});
|
||||
|
||||
await this.encryptContent(
|
||||
decryptedContent,
|
||||
noteId,
|
||||
newPassword,
|
||||
`${Date.now()}`
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
`Could not decrypt content of note ${note.id}. Error: ${
|
||||
`Could not decrypt content of note ${noteId}. Error: ${
|
||||
(e as Error).message
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const content of contentItems) {
|
||||
await this.encryptContent(
|
||||
content,
|
||||
content.noteId,
|
||||
newPassword,
|
||||
content.id
|
||||
);
|
||||
}
|
||||
|
||||
await this.db.storage().remove("vaultKey");
|
||||
await this.create(newPassword);
|
||||
await this.db.vaults.add({
|
||||
id: vault.id,
|
||||
key: await this.db
|
||||
.storage()
|
||||
.encrypt({ password: newPassword }, this.key)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async clear(password: string) {
|
||||
const vault = await this.db.vaults.default();
|
||||
if (!vault) return;
|
||||
|
||||
if (await this.unlock(password)) {
|
||||
for await (const note of this.db.notes.locked) {
|
||||
await this.unlockNote(note, password, true);
|
||||
const relations = await this.db.relations.from(vault, "note").get();
|
||||
for (const { toId: noteId } of relations) {
|
||||
await this.unlockNote(noteId, password, true);
|
||||
await this.db.relations.unlink(vault, { id: noteId, type: "note" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async delete(deleteAllLockedNotes = false) {
|
||||
const vault = await this.db.vaults.default();
|
||||
if (!vault) return;
|
||||
|
||||
if (deleteAllLockedNotes) {
|
||||
const lockedIds = await this.db.notes.locked.ids();
|
||||
const relations = await this.db.relations.from(vault, "note").get();
|
||||
const lockedIds = relations.map((r) => r.toId);
|
||||
await this.db.notes.remove(...lockedIds);
|
||||
}
|
||||
await this.db.storage().remove("vaultKey");
|
||||
|
||||
await this.db.vaults.remove(vault.id);
|
||||
this.password = undefined;
|
||||
}
|
||||
|
||||
@@ -173,11 +178,11 @@ export default class Vault {
|
||||
* Permanently unlocks (remove from vault) a note
|
||||
*/
|
||||
async remove(noteId: string, password: string) {
|
||||
const note = await this.db.notes.note(noteId);
|
||||
if (!note) return;
|
||||
await this.unlockNote(note, password, true);
|
||||
await this.unlockNote(noteId, password, true);
|
||||
|
||||
if (!(await this.exists())) await this.create(password);
|
||||
const vault = await this.db.vaults.default();
|
||||
if (!vault) await this.create(password);
|
||||
else await this.db.relations.unlink(vault, { id: noteId, type: "note" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,23 +192,22 @@ export default class Vault {
|
||||
const note = await this.db.notes.note(noteId);
|
||||
if (!note) return;
|
||||
|
||||
const unlockedNote = await this.unlockNote(note, password, false);
|
||||
const content = await this.unlockNote(noteId, password, false);
|
||||
if (password) {
|
||||
this.password = password;
|
||||
if (!(await this.exists())) await this.create(password);
|
||||
}
|
||||
|
||||
return unlockedNote;
|
||||
return { ...note, ...content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a note in the vault
|
||||
*/
|
||||
async save(
|
||||
note: Partial<Note & { content: NoteContent<false>; sessionId: string }> & {
|
||||
id: string;
|
||||
}
|
||||
) {
|
||||
async save(note: {
|
||||
content?: NoteContent<false>;
|
||||
sessionId?: string;
|
||||
id: string;
|
||||
}) {
|
||||
if (!note) return;
|
||||
// roll over erase timer
|
||||
this.startEraser();
|
||||
@@ -233,7 +237,6 @@ export default class Vault {
|
||||
content: NoteContent<false>,
|
||||
noteId: string,
|
||||
password: string,
|
||||
contentId?: string,
|
||||
sessionId?: string
|
||||
) {
|
||||
const encryptedContent = await this.db
|
||||
@@ -241,7 +244,6 @@ export default class Vault {
|
||||
.encrypt({ password }, JSON.stringify(content.data));
|
||||
|
||||
await this.db.content.add({
|
||||
id: contentId,
|
||||
noteId,
|
||||
sessionId,
|
||||
data: encryptedContent,
|
||||
@@ -249,23 +251,11 @@ export default class Vault {
|
||||
});
|
||||
}
|
||||
|
||||
async decryptContent(
|
||||
encryptedContent: NoteContent<true>,
|
||||
noteId: string,
|
||||
password?: string
|
||||
) {
|
||||
async decryptContent(encryptedContent: NoteContent<true>, password?: string) {
|
||||
if (!password) password = await this.getVaultPassword();
|
||||
|
||||
if (
|
||||
typeof encryptedContent.data !== "object" &&
|
||||
!isCipher(encryptedContent.data)
|
||||
) {
|
||||
await this.db.notes.add({
|
||||
id: noteId,
|
||||
locked: false
|
||||
});
|
||||
return { data: encryptedContent.data, type: encryptedContent.type };
|
||||
}
|
||||
if (!isCipher(encryptedContent.data))
|
||||
return encryptedContent as unknown as NoteContent<false>;
|
||||
|
||||
const decryptedContent = await this.db
|
||||
.storage()
|
||||
@@ -286,33 +276,30 @@ export default class Vault {
|
||||
}
|
||||
|
||||
private async lockNote(
|
||||
item: Partial<Note & { content: NoteContent<false>; sessionId: string }> & {
|
||||
id: string;
|
||||
},
|
||||
item: { content?: NoteContent<false>; sessionId?: string; id: string },
|
||||
password: string
|
||||
) {
|
||||
const { id, content, sessionId, title } = item;
|
||||
const vault = await this.db.vaults.default();
|
||||
if (!vault) throw new Error(VAULT_ERRORS.noVault);
|
||||
|
||||
const { id, content, sessionId } = item;
|
||||
let { type, data } = content || {};
|
||||
|
||||
const note = await this.db.notes.note(id);
|
||||
if (!note) return;
|
||||
|
||||
const contentId = note.contentId;
|
||||
// if (!contentId) throw new Error("Cannot lock note because it is empty.");
|
||||
const locked = await this.db.relations.from(vault, "note").has(id);
|
||||
|
||||
// Case: when note is being newly locked
|
||||
if (!note.locked && (!data || !type) && !!contentId) {
|
||||
const rawContent = await this.db.content.get(contentId);
|
||||
if (!rawContent || !isUnencryptedContent(rawContent))
|
||||
return await this.db.notes.add({
|
||||
if (!locked && (!data || !type)) {
|
||||
const content = await this.db.content.findByNoteId(id);
|
||||
if (!content || content.locked)
|
||||
return await this.db.relations.add(vault, {
|
||||
id,
|
||||
locked: true
|
||||
type: "note"
|
||||
});
|
||||
// NOTE:
|
||||
// At this point, the note already has all the attachments extracted
|
||||
// so we should just encrypt it as normal.
|
||||
data = rawContent.data;
|
||||
type = rawContent.type;
|
||||
data = content.data;
|
||||
type = content.type;
|
||||
} else if (data && type) {
|
||||
data = await this.db.content.extractAttachments({
|
||||
data,
|
||||
@@ -322,60 +309,47 @@ export default class Vault {
|
||||
}
|
||||
|
||||
if (data && type)
|
||||
await this.encryptContent(
|
||||
{ data, type },
|
||||
id,
|
||||
password,
|
||||
contentId,
|
||||
sessionId
|
||||
);
|
||||
await this.encryptContent({ data, type }, id, password, sessionId);
|
||||
|
||||
return await this.db.notes.add({
|
||||
await this.db.notes.add({
|
||||
id,
|
||||
locked: true,
|
||||
headline: "",
|
||||
title: title || note.title,
|
||||
favorite: note.favorite,
|
||||
localOnly: note.localOnly,
|
||||
readonly: note.readonly,
|
||||
dateEdited: Date.now()
|
||||
headline: ""
|
||||
});
|
||||
|
||||
await this.db.relations.add(vault, {
|
||||
id,
|
||||
type: "note"
|
||||
});
|
||||
}
|
||||
|
||||
private async unlockNote(note: Note, password?: string, perm = false) {
|
||||
if (!note.contentId) return;
|
||||
|
||||
const encryptedContent = await this.db.content.get(note.contentId);
|
||||
if (!encryptedContent || !isEncryptedContent(encryptedContent)) return;
|
||||
const content = await this.decryptContent(
|
||||
encryptedContent,
|
||||
note.id,
|
||||
password
|
||||
);
|
||||
private async unlockNote(noteId: string, password?: string, perm = false) {
|
||||
const content = await this.db.content.findByNoteId(noteId);
|
||||
if (!content || !content.locked) return;
|
||||
const decryptedContent = await this.decryptContent(content, password);
|
||||
|
||||
if (perm) {
|
||||
await this.db.notes.add({
|
||||
id: note.id,
|
||||
locked: false,
|
||||
headline: note.headline,
|
||||
contentId: note.contentId,
|
||||
content
|
||||
id: noteId,
|
||||
contentId: content.id,
|
||||
content: decryptedContent
|
||||
});
|
||||
// await this.db.content.add({ id: note.contentId, data: content });
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...note,
|
||||
content
|
||||
};
|
||||
}
|
||||
|
||||
async getKey() {
|
||||
return await this.db.storage().read<Cipher<"base64">>("vaultKey");
|
||||
const vault = await this.db.vaults.default();
|
||||
return vault?.key;
|
||||
}
|
||||
|
||||
async setKey(vaultKey: Cipher<"base64">) {
|
||||
await this.db.storage().write("vaultKey", vaultKey);
|
||||
const vault = await this.db.vaults.default();
|
||||
if (vault) return;
|
||||
await this.db.vaults.add({ title: "Default", key: vaultKey });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,13 +200,17 @@ export class Content implements ICollection {
|
||||
.execute();
|
||||
}
|
||||
|
||||
// async findByNoteId(noteId: string) {
|
||||
// await this.db
|
||||
// .sql()
|
||||
// .selectFrom("content")
|
||||
// .where("noteId", "==", noteId)
|
||||
// .execute();
|
||||
// }
|
||||
async findByNoteId(noteId: string) {
|
||||
const content = (await this.db
|
||||
.sql()
|
||||
.selectFrom("content")
|
||||
.where("noteId", "==", noteId)
|
||||
.selectAll()
|
||||
.executeTakeFirst()) as ContentItem;
|
||||
if (!content || isDeleted(content)) return;
|
||||
return content;
|
||||
}
|
||||
|
||||
// multi(ids: string[]) {
|
||||
// return this.collection.getItems(ids);
|
||||
// }
|
||||
|
||||
@@ -121,7 +121,6 @@ export class Notes implements ICollection {
|
||||
contentId,
|
||||
|
||||
pinned: item.pinned,
|
||||
locked: item.locked,
|
||||
favorite: item.favorite,
|
||||
localOnly: item.localOnly,
|
||||
conflicted: item.conflicted,
|
||||
@@ -228,17 +227,6 @@ export class Notes implements ICollection {
|
||||
);
|
||||
}
|
||||
|
||||
get locked() {
|
||||
return this.collection.createFilter<Note>(
|
||||
(qb) =>
|
||||
qb
|
||||
.where(isFalse("dateDeleted"))
|
||||
.where(isFalse("deleted"))
|
||||
.where("locked", "==", true),
|
||||
this.db.options?.batchSize
|
||||
);
|
||||
}
|
||||
|
||||
exists(id: string) {
|
||||
return this.collection.exists(id);
|
||||
}
|
||||
|
||||
@@ -194,7 +194,8 @@ const TABLE_MAP = {
|
||||
reminder: "reminders",
|
||||
tag: "tags",
|
||||
color: "colors",
|
||||
attachment: "attachments"
|
||||
attachment: "attachments",
|
||||
vault: "vaults"
|
||||
} as const;
|
||||
|
||||
type RelatableTable = typeof TABLE_MAP;
|
||||
|
||||
@@ -49,8 +49,6 @@ export class Tags implements ICollection {
|
||||
}
|
||||
|
||||
async add(item: Partial<Tag>) {
|
||||
if (item.remote)
|
||||
throw new Error("Please use db.tags.merge to merge remote tags.");
|
||||
item.title = item.title ? Tags.sanitize(item.title) : item.title;
|
||||
|
||||
const id = item.id || getId(item.dateCreated);
|
||||
@@ -68,8 +66,7 @@ export class Tags implements ICollection {
|
||||
dateCreated: item.dateCreated || oldTag?.dateCreated || Date.now(),
|
||||
dateModified: item.dateModified || oldTag?.dateModified || Date.now(),
|
||||
title: item.title || oldTag?.title || "",
|
||||
type: "tag",
|
||||
remote: false
|
||||
type: "tag"
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
83
packages/core/src/collections/vaults.ts
Normal file
83
packages/core/src/collections/vaults.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Database from "../api";
|
||||
import { Vault } from "../types";
|
||||
import { ICollection } from "./collection";
|
||||
import { SQLCollection } from "../database/sql-collection";
|
||||
import { getId } from "../utils/id";
|
||||
import { isFalse } from "../database";
|
||||
|
||||
export class Vaults implements ICollection {
|
||||
name = "vaults";
|
||||
readonly collection: SQLCollection<"vaults", Vault>;
|
||||
constructor(private readonly db: Database) {
|
||||
this.collection = new SQLCollection(
|
||||
db.sql,
|
||||
db.transaction,
|
||||
"vaults",
|
||||
db.eventManager
|
||||
);
|
||||
}
|
||||
|
||||
async init() {}
|
||||
|
||||
async add(item: Partial<Vault>) {
|
||||
const id = item.id || getId();
|
||||
const oldVault = item.id ? await this.vault(item.id) : undefined;
|
||||
|
||||
if (!item.title && !oldVault?.title) throw new Error("Title is required.");
|
||||
|
||||
await this.collection.upsert({
|
||||
id,
|
||||
dateCreated: item.dateCreated || oldVault?.dateCreated || Date.now(),
|
||||
dateModified: item.dateModified || oldVault?.dateModified || Date.now(),
|
||||
title: item.title || oldVault?.title || "",
|
||||
key: item.key,
|
||||
type: "vault"
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.db.transaction(async () => {
|
||||
await this.db.relations.unlinkOfType("vault", [id]);
|
||||
await this.collection.softDelete([id]);
|
||||
});
|
||||
}
|
||||
|
||||
vault(id: string) {
|
||||
return this.collection.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is temporary until we add proper support for multiple vaults
|
||||
* @deprecated
|
||||
*/
|
||||
async default() {
|
||||
return (await this.all.items()).at(0);
|
||||
}
|
||||
|
||||
get all() {
|
||||
return this.collection.createFilter<Vault>(
|
||||
(qb) => qb.where(isFalse("deleted")),
|
||||
this.db.options?.batchSize
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,7 @@ const itemTypeToCollectionKey = {
|
||||
shortcut: "shortcuts",
|
||||
settingitem: "settings",
|
||||
settings: "settings",
|
||||
vault: "vaults",
|
||||
|
||||
// to make ts happy
|
||||
topic: "topics"
|
||||
@@ -198,6 +199,7 @@ export default class Backup {
|
||||
yield* this.backupCollection(this.db.reminders.collection, backupState);
|
||||
yield* this.backupCollection(this.db.relations.collection, backupState);
|
||||
yield* this.backupCollection(this.db.attachments.collection, backupState);
|
||||
yield* this.backupCollection(this.db.vaults.collection, backupState);
|
||||
|
||||
if (backupState.buffer.length > 0) yield* this.bufferToFile(backupState);
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ import {
|
||||
Shortcut,
|
||||
Tag,
|
||||
TrashOrItem,
|
||||
ValueOf
|
||||
ValueOf,
|
||||
Vault
|
||||
} from "../types";
|
||||
import { NNMigrationProvider } from "./migrations";
|
||||
import { createTriggers } from "./triggers";
|
||||
@@ -85,6 +86,7 @@ export interface DatabaseSchema {
|
||||
notehistory: SQLiteItem<HistorySession>;
|
||||
sessioncontent: SQLiteItem<SessionContentItem>;
|
||||
shortcuts: SQLiteItem<Shortcut>;
|
||||
vaults: SQLiteItem<Vault>;
|
||||
}
|
||||
|
||||
export type DatabaseSchemaWithFTS = DatabaseSchema & {
|
||||
@@ -194,6 +196,9 @@ const DataMappers: Partial<Record<ItemType, (row: any) => void>> = {
|
||||
},
|
||||
attachment: (row) => {
|
||||
if (row.key) row.key = JSON.parse(row.key);
|
||||
},
|
||||
vault: (row) => {
|
||||
if (row.key) row.key = JSON.parse(row.key);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -123,6 +123,14 @@ export class NNMigrationProvider implements MigrationProvider {
|
||||
.addColumn("colorCode", "text")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable("vaults")
|
||||
.modifyEnd(sql`without rowid`)
|
||||
.$call(addBaseColumns)
|
||||
.addColumn("title", "text", COLLATE_NOCASE)
|
||||
.addColumn("key", "text")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable("relations")
|
||||
.modifyEnd(sql`without rowid`)
|
||||
|
||||
@@ -17,13 +17,14 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Cipher } from "@notesnook/crypto";
|
||||
import { DatabaseSchema } from ".";
|
||||
import Database from "../api";
|
||||
import {
|
||||
CURRENT_DATABASE_VERSION,
|
||||
sendMigrationProgressEvent
|
||||
} from "../common";
|
||||
import { migrateCollection, migrateItem } from "../migrations";
|
||||
import { migrateCollection, migrateItem, migrateVaultKey } from "../migrations";
|
||||
import {
|
||||
CollectionType,
|
||||
Collections,
|
||||
@@ -50,6 +51,9 @@ class Migrator {
|
||||
collections: MigratableCollections,
|
||||
version: number
|
||||
) {
|
||||
const vaultKey = await db.storage().read<Cipher<"base64">>("vaultKey");
|
||||
if (vaultKey) await migrateVaultKey(db, vaultKey, version);
|
||||
|
||||
for (const collection of collections) {
|
||||
sendMigrationProgressEvent(db.eventManager, collection.name, 0, 0);
|
||||
|
||||
@@ -79,6 +83,7 @@ class Migrator {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.initCollections();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -34,18 +34,8 @@ import {
|
||||
} from "./types";
|
||||
import { isCipher } from "./database/crypto";
|
||||
import { IndexedCollection } from "./database/indexed-collection";
|
||||
|
||||
const ColorToHexCode: Record<string, string> = {
|
||||
red: "#f44336",
|
||||
orange: "#FF9800",
|
||||
yellow: "#FFD600",
|
||||
green: "#4CAF50",
|
||||
blue: "#2196F3",
|
||||
purple: "#673AB7",
|
||||
gray: "#9E9E9E",
|
||||
black: "#000000",
|
||||
white: "#ffffff"
|
||||
};
|
||||
import { DefaultColors } from "./collections/colors";
|
||||
import { Cipher } from "@notesnook/crypto";
|
||||
|
||||
type MigrationType = "local" | "sync" | "backup";
|
||||
type MigrationItemType = ItemType | "notehistory" | "content" | "all";
|
||||
@@ -64,6 +54,7 @@ type Migration = {
|
||||
) => "skip" | boolean | Promise<boolean | "skip"> | void;
|
||||
};
|
||||
collection?: (collection: IndexedCollection) => Promise<void> | void;
|
||||
vaultKey?: (db: Database, key: Cipher<"base64">) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const migrations: Migration[] = [
|
||||
@@ -198,7 +189,7 @@ const migrations: Migration[] = [
|
||||
)
|
||||
return "skip";
|
||||
|
||||
const colorCode = ColorToHexCode[item.title];
|
||||
const colorCode = DefaultColors[item.title];
|
||||
if (colorCode) {
|
||||
const newColor = await db.colors.all.find((eb) =>
|
||||
eb.or([eb("title", "in", [alias, item.title])])
|
||||
@@ -258,7 +249,7 @@ const migrations: Migration[] = [
|
||||
dateCreated: oldColor?.dateCreated,
|
||||
dateModified: oldColor?.dateModified,
|
||||
title: alias || item.color,
|
||||
colorCode: ColorToHexCode[item.color],
|
||||
colorCode: DefaultColors[item.color],
|
||||
type: "color"
|
||||
}));
|
||||
if (newColorId) {
|
||||
@@ -275,6 +266,13 @@ const migrations: Migration[] = [
|
||||
}
|
||||
}
|
||||
|
||||
if (item.locked) {
|
||||
const vault = await db.vaults.default();
|
||||
if (vault)
|
||||
await db.relations.add({ type: "vault", id: vault.id }, item);
|
||||
}
|
||||
|
||||
delete item.locked;
|
||||
delete item.notebooks;
|
||||
delete item.tags;
|
||||
delete item.color;
|
||||
@@ -380,6 +378,10 @@ const migrations: Migration[] = [
|
||||
return true;
|
||||
},
|
||||
all: () => true
|
||||
},
|
||||
async vaultKey(db, key) {
|
||||
await db.vaults.add({ title: "Default", key });
|
||||
await db.storage().remove("vaultKey");
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -453,6 +455,29 @@ export async function migrateCollection(
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateVaultKey(
|
||||
db: Database,
|
||||
vaultKey: Cipher<"base64">,
|
||||
version: number
|
||||
) {
|
||||
let migrationStartIndex = migrations.findIndex((m) => m.version === version);
|
||||
if (migrationStartIndex <= -1) {
|
||||
throw new Error(
|
||||
version > CURRENT_DATABASE_VERSION
|
||||
? `Please update the app to the latest version.`
|
||||
: `You seem to be on a very outdated version. Please update the app to the latest version.`
|
||||
);
|
||||
}
|
||||
|
||||
for (; migrationStartIndex < migrations.length; ++migrationStartIndex) {
|
||||
const migration = migrations[migrationStartIndex];
|
||||
if (migration.version === CURRENT_DATABASE_VERSION) break;
|
||||
|
||||
if (!migration.vaultKey) continue;
|
||||
await migration.vaultKey(db, vaultKey);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceDateEditedWithDateModified(removeDateEditedProperty = false) {
|
||||
return function (item: any) {
|
||||
item.dateModified = item.dateEdited;
|
||||
|
||||
@@ -73,6 +73,7 @@ export type Collections = {
|
||||
notehistory: "session";
|
||||
sessioncontent: "sessioncontent";
|
||||
settingsv2: "settingitem";
|
||||
vaults: "vault";
|
||||
|
||||
/**
|
||||
* @deprecated only kept here for migration purposes
|
||||
@@ -102,6 +103,7 @@ export type GroupableItem = ValueOf<
|
||||
| "sessioncontent"
|
||||
| "settings"
|
||||
| "settingitem"
|
||||
| "vault"
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -121,6 +123,7 @@ export type ItemMap = {
|
||||
session: HistorySession;
|
||||
sessioncontent: SessionContentItem;
|
||||
settingitem: SettingItem;
|
||||
vault: Vault;
|
||||
|
||||
/**
|
||||
* @deprecated only kept here for migration purposes
|
||||
@@ -171,9 +174,12 @@ export interface Note extends BaseItem<"note"> {
|
||||
* @deprecated only kept here for migration purposes.
|
||||
*/
|
||||
notebooks?: NotebookReference[];
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
locked?: boolean;
|
||||
|
||||
pinned: boolean;
|
||||
locked: boolean;
|
||||
favorite: boolean;
|
||||
localOnly: boolean;
|
||||
conflicted: boolean;
|
||||
@@ -432,6 +438,11 @@ export interface SettingItem<
|
||||
value: SettingItemMap[TKey];
|
||||
}
|
||||
|
||||
export interface Vault extends BaseItem<"vault"> {
|
||||
title: string;
|
||||
key: Cipher<"base64">;
|
||||
}
|
||||
|
||||
export interface DeletedItem {
|
||||
id: string;
|
||||
deleted: true;
|
||||
|
||||
@@ -25,7 +25,9 @@ export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ["./globals.setup.js"],
|
||||
coverage: {
|
||||
reporter: ["text", "html"]
|
||||
reporter: ["text", "html"],
|
||||
exclude: ["src/utils/templates/html/languages/*.js"],
|
||||
include: ["src/**/*.ts"]
|
||||
},
|
||||
retry: 1,
|
||||
exclude: ["__benches__/**/*.bench.ts"],
|
||||
|
||||
Reference in New Issue
Block a user