Files
notesnook/packages/core/src/database/backup.ts
2025-10-04 10:21:38 +05:00

738 lines
20 KiB
TypeScript

/*
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 SparkMD5 from "spark-md5";
import { CURRENT_DATABASE_VERSION } from "../common.js";
import Migrator from "./migrator.js";
import Database from "../api/index.js";
import {
Attachment,
Item,
MaybeDeletedItem,
Note,
Notebook,
Relation,
ValueOf,
isDeleted
} from "../types.js";
import { Cipher, SerializedKey } from "@notesnook/crypto";
import { isCipher } from "../utils/crypto.js";
import { migrateItem } from "../migrations.js";
import { DatabaseCollection } from "./index.js";
import { DefaultColors } from "../collections/colors.js";
import { toChunks } from "../utils/array.js";
import { logger } from "../logger.js";
type BackupDataItem = MaybeDeletedItem<Item> | string[];
type BackupPlatform = "web" | "mobile" | "node";
type BaseBackupFile = {
version: number;
type: BackupPlatform;
date: number;
// encrypted?: boolean;
// compressed?: boolean;
};
type LegacyUnencryptedBackupFile = BaseBackupFile & {
data: Record<string, BackupDataItem> | string;
hash: string;
hash_type: "md5";
};
type LegacyEncryptedBackupFile = BaseBackupFile & {
data: Cipher<"base64">;
};
type UnencryptedBackupFile = BaseBackupFile & {
data: string;
hash: string;
hash_type: "md5";
compressed: true;
encrypted: false;
};
type EncryptedBackupFile = BaseBackupFile & {
data: Cipher<"base64">;
hash: string;
hash_type: "md5";
compressed: true;
encrypted: true;
};
export type BackupFile = UnencryptedBackupFile | EncryptedBackupFile;
export type LegacyBackupFile =
| LegacyUnencryptedBackupFile
| LegacyEncryptedBackupFile;
type BackupState = {
buffer: string[];
bufferLength: number;
chunkIndex: number;
key?: SerializedKey;
encrypt: boolean;
type: BackupPlatform;
};
function isEncryptedBackup(
backup: LegacyBackupFile | BackupFile
): backup is EncryptedBackupFile | LegacyEncryptedBackupFile {
return "encrypted" in backup ? backup.encrypted : isCipher(backup.data);
}
/**
* Due to a bug in v3.0, legacy backups were created with version set to 6.1
* while their actual data was at version 5.9. This caused various issues when
* restoring such a backup.
* This function tries to work around that bug by detecting the version based on
* the actual data.
*/
function isLegacyBackup(data: BackupDataItem[]) {
const note = data.find(
(c): c is Note => !isDeleted(c) && !Array.isArray(c) && c.type === "note"
);
if (note)
return (
"color" in note ||
"notebooks" in note ||
"tags" in note ||
"locked" in note
);
const notebook = data.find(
(c): c is Notebook =>
!isDeleted(c) && !Array.isArray(c) && c.type === "notebook"
);
if (notebook) return "topics" in notebook;
const attachment = data.find(
(c): c is Attachment =>
!isDeleted(c) && !Array.isArray(c) && c.type === "attachment"
);
if (attachment) return "noteIds" in attachment;
const relation = data.find(
(c): c is Relation =>
!isDeleted(c) && !Array.isArray(c) && c.type === "relation"
);
if (relation) return "from" in relation || "to" in relation;
return false;
}
const MAX_CHUNK_SIZE = 10 * 1024 * 1024;
const invalidKeys = [
"user",
"t",
"v",
"lastBackupTime",
"lastSynced",
// all indexes
"notes",
"notebooks",
"content",
"tags",
"colors",
"attachments",
"relations",
"reminders",
"sessioncontent",
"notehistory",
"shortcuts",
"vaultKey",
"hasConflict",
"token",
"monographs"
];
const itemTypeToCollectionKey = {
note: "notes",
notebook: "notebooks",
tiptap: "content",
tiny: "content",
tag: "tags",
color: "colors",
attachment: "attachments",
relation: "relations",
reminder: "reminders",
sessioncontent: "sessioncontent",
session: "noteHistory",
notehistory: "noteHistory",
content: "content",
shortcut: "shortcuts",
settingitem: "settings",
settings: "settings",
vault: "vaults"
} as const;
type CollectionName = ValueOf<typeof itemTypeToCollectionKey>;
const validTypes = ["mobile", "web", "node"];
export default class Backup {
migrator = new Migrator();
constructor(private readonly db: Database) {}
lastBackupTime() {
return this.db.kv().read("lastBackupTime");
}
async updateBackupTime() {
await this.db.kv().write("lastBackupTime", Date.now());
}
/**
* @deprecated
*/
async *exportLegacy(type: BackupPlatform, encrypt = false) {
if (!validTypes.some((t) => t === type))
throw new Error("Invalid type. It must be one of 'mobile' or 'web'.");
if (encrypt && !(await this.db.user.getLegacyUser())) encrypt = false;
const key = await this.db.user.getLegacyEncryptionKey();
if (encrypt && !key) encrypt = false;
const keys = await this.db.storage().getAllKeys();
const chunks = toChunks(keys, 20);
let buffer: string[] = [];
let bufferLength = 0;
const MAX_CHUNK_SIZE = 10 * 1024 * 1024;
let chunkIndex = 0;
while (chunks.length > 0) {
const chunk = chunks.pop();
if (!chunk) break;
const items = await this.db.storage().readMulti(chunk);
items.forEach(([id, item]) => {
const isDeleted =
item &&
typeof item === "object" &&
"deleted" in item &&
!("type" in item);
if (
!item ||
invalidKeys.includes(id) ||
isDeleted ||
id.startsWith("_uk_")
)
return;
const data = JSON.stringify(item);
buffer.push(data);
bufferLength += data.length;
});
if (bufferLength >= MAX_CHUNK_SIZE || chunks.length === 0) {
let itemsJSON = `[${buffer.join(",")}]`;
buffer = [];
bufferLength = 0;
itemsJSON = await (await this.db.compressor()).compress(itemsJSON);
const hash = SparkMD5.hash(itemsJSON);
if (encrypt && key)
itemsJSON = JSON.stringify(
await this.db.storage().encrypt(key, itemsJSON)
);
else itemsJSON = JSON.stringify(itemsJSON);
yield {
type: "file" as const,
path: `${chunkIndex++}-${encrypt ? "encrypted" : "plain"}-${hash}`,
data: `{
"version": 5.9,
"type": "${type}",
"date": ${Date.now()},
"data": ${itemsJSON},
"hash": "${hash}",
"hash_type": "md5",
"compressed": true,
"encrypted": ${encrypt ? "true" : "false"}
}`
};
}
}
if (bufferLength > 0 || buffer.length > 0)
throw new Error("Buffer not empty.");
await this.updateBackupTime();
}
async *export(options: {
type: BackupPlatform;
encrypt?: boolean;
mode?: "full" | "partial";
}): AsyncGenerator<
| {
type: "file";
path: string;
data: string;
}
| {
type: "attachment";
path: string;
hash: string;
total: number;
current: number;
},
void,
unknown
> {
const { encrypt = false, type, mode = "partial" } = options;
if (this.db.migrations.version === 5.9) {
yield* this.exportLegacy(type, encrypt);
return;
}
if (!validTypes.some((t) => t === type))
throw new Error("Invalid type. It must be one of 'mobile' or 'web'.");
const user = await this.db.user.getUser();
if (encrypt && !user)
throw new Error("Please login to create encrypted backups.");
const key = await this.db.user.getEncryptionKey();
if (encrypt && !key) throw new Error("No encryption key found.");
yield {
type: "file",
path: ".nnbackup",
data: ""
};
const backupState: BackupState = {
buffer: [] as string[],
bufferLength: 0,
chunkIndex: 0,
key,
encrypt,
type
};
yield* this.backupCollection(this.db.notes.collection, backupState);
yield* this.backupCollection(this.db.notebooks.collection, backupState);
yield* this.backupCollection(this.db.content.collection, backupState);
yield* this.backupCollection(this.db.noteHistory.collection, backupState);
yield* this.backupCollection(
this.db.noteHistory.sessionContent.collection,
backupState
);
yield* this.backupCollection(this.db.colors.collection, backupState);
yield* this.backupCollection(this.db.tags.collection, backupState);
yield* this.backupCollection(this.db.settings.collection, backupState);
yield* this.backupCollection(this.db.shortcuts.collection, backupState);
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);
if (mode === "partial") {
await this.updateBackupTime();
return;
}
const total = await this.db.attachments.all.count();
if (total > 0 && user && user.attachmentsKey) {
yield {
type: "file",
path: `attachments/.attachments_key`,
data: backupState.encrypt
? JSON.stringify(user.attachmentsKey)
: JSON.stringify((await this.db.user.getAttachmentsKey()) || {})
};
let current = 0;
for await (const attachment of this.db.attachments.all.iterate()) {
current++;
if (
!(await this.db
.fs()
.downloadFile("backup", attachment.hash, attachment.chunkSize))
)
continue;
yield {
type: "attachment",
hash: attachment.hash,
path: `attachments/${attachment.hash}`,
total,
current
};
}
}
await this.updateBackupTime();
}
private async *backupCollection<T, B extends boolean>(
collection: DatabaseCollection<T, B>,
state: BackupState
) {
for await (const item of collection.stream(
this.db.options.batchSize
) as any) {
const data = JSON.stringify(item);
state.buffer.push(data);
state.bufferLength += data.length;
if (state.bufferLength >= MAX_CHUNK_SIZE) {
yield* this.bufferToFile(state);
}
}
}
private async *bufferToFile(state: BackupState) {
let itemsJSON = `[${state.buffer.join(",")}]`;
state.buffer = [];
state.bufferLength = 0;
itemsJSON = await (await this.db.compressor()).compress(itemsJSON);
const hash = SparkMD5.hash(itemsJSON);
if (state.encrypt && state.key)
itemsJSON = JSON.stringify(
await this.db.storage().encrypt(state.key, itemsJSON)
);
else itemsJSON = JSON.stringify(itemsJSON);
yield {
type: "file" as const,
path: `${state.chunkIndex++}-${
state.encrypt ? "encrypted" : "plain"
}-${hash}`,
data: `{
"version": ${CURRENT_DATABASE_VERSION},
"type": "${state.type}",
"date": ${Date.now()},
"data": ${itemsJSON},
"hash": "${hash}",
"hash_type": "md5",
"compressed": true,
"encrypted": ${state.encrypt ? "true" : "false"}
}`
};
}
async import(
backup: LegacyBackupFile | BackupFile,
options: {
password?: string;
encryptionKey?: string;
attachmentsKey?: SerializedKey | Cipher<"base64">;
} = {}
) {
if (!this.validate(backup)) throw new Error("Invalid backup.");
const { encryptionKey, password, attachmentsKey } = options;
backup = this.migrateBackup(backup);
let decryptedData: string | Record<string, BackupDataItem> | undefined =
undefined;
let decryptedAttachmentsKey: SerializedKey | undefined = undefined;
if (isEncryptedBackup(backup)) {
if (!password && !encryptionKey)
throw new Error(
"Please provide a password to decrypt this backup & restore it."
);
const key = encryptionKey
? { key: encryptionKey, salt: backup.data.salt }
: password
? await this.db.storage().generateCryptoKey(password, backup.data.salt)
: undefined;
if (!key)
throw new Error("Could not generate encryption key for backup.");
decryptedAttachmentsKey = isCipher(attachmentsKey)
? (JSON.parse(
await this.db.storage().decrypt(key, attachmentsKey)
) as Cipher<"base64">)
: attachmentsKey;
try {
decryptedData = await this.db.storage().decrypt(key, backup.data);
} catch (e) {
logger.error(e, "Failed to import backup");
if (e instanceof Error) {
if (
e.message.includes("ciphertext cannot be decrypted") ||
e.message === "FAILURE"
)
throw new Error(
encryptionKey ? "Invalid encryption key." : "Incorrect password."
);
throw new Error(`Could not decrypt backup: ${e.message}`);
}
}
} else {
if (!isCipher(attachmentsKey)) decryptedAttachmentsKey = attachmentsKey;
decryptedData = backup.data;
}
if (!decryptedData) return;
if ("hash" in backup && !this.verify(backup, decryptedData))
throw new Error("Backup file has been tempered, aborting...");
if ("compressed" in backup && typeof decryptedData === "string")
decryptedData = await (
await this.db.compressor()
).decompress(decryptedData);
const data =
typeof decryptedData === "string"
? (JSON.parse(decryptedData) as unknown)
: Object.values(decryptedData);
if (!data) throw new Error("No data found.");
const normalizedData: BackupDataItem[] = Array.isArray(data)
? (data as BackupDataItem[])
: typeof data === "object"
? Object.values(data)
: [];
await this.migrateData(
normalizedData,
backup.version === 6.1 && isLegacyBackup(normalizedData)
? 5.9
: backup.version,
decryptedAttachmentsKey
);
}
private migrateBackup(backup: BackupFile | LegacyBackupFile) {
const { version = 0 } = backup;
if (version > CURRENT_DATABASE_VERSION)
throw new Error(
"This backup was made from a newer version of Notesnook. Cannot migrate."
);
switch (version) {
case CURRENT_DATABASE_VERSION:
case 6.0:
case 5.9:
case 5.8:
case 5.7:
case 5.6:
case 5.5:
case 5.4:
case 5.3:
case 5.2:
case 5.1:
case 5.0: {
return backup;
}
default:
throw new Error("Unknown backup version.");
}
}
private async migrateData(
data: BackupDataItem[],
version: number,
attachmentsKey?: SerializedKey
) {
const queue: Partial<Record<CollectionName, MaybeDeletedItem<Item>[]>> = {};
for (let item of data) {
// we do not want to restore deleted items
if (
!item ||
typeof item !== "object" ||
Array.isArray(item) ||
isDeleted(item) ||
item.type === "searchResult"
)
continue;
// in v5.6 of the database, we did not set note history session's type
if ("sessionContentId" in item && item.type !== "session")
(item as any).type = "notehistory";
if (
(await migrateItem(
item,
version,
CURRENT_DATABASE_VERSION,
item.type,
this.db,
"backup"
)) === "skip"
)
continue;
// since items in trash can have their own set of migrations,
// we have to run the migration again to account for that.
if (item.type === "trash" && item.itemType)
if (
(await migrateItem(
item as unknown as Note | Notebook,
version,
CURRENT_DATABASE_VERSION,
item.itemType,
this.db,
"backup"
)) === "skip"
)
continue;
const itemType =
// colors are naively of type "tag" instead of "color" so we have to fix that.
item.type === "tag" && DefaultColors[item.title.toLowerCase()]
? "color"
: item.type === "trash" && "itemType" in item && item.itemType
? item.itemType
: item.type;
if (!itemType || itemType === "topic" || itemType === "settings")
continue;
if (item.type === "attachment" && (item.metadata?.hash || item.hash)) {
const attachment = await this.db.attachments.attachment(
item.metadata?.hash || item.hash
);
const isSameKey =
!!attachment &&
attachment.key.iv === item.key.iv &&
attachment.key.cipher === item.key.cipher &&
attachment.key.salt === item.key.salt;
if (attachmentsKey && !isSameKey) {
const newKey = await this.db.attachments.encryptKey(
JSON.parse(
await this.db.storage().decrypt(attachmentsKey, item.key)
)
);
if (attachment) {
attachment.key = newKey;
attachment.iv = item.iv;
attachment.salt = item.salt;
attachment.size = item.size;
} else item.key = newKey;
delete item.dateUploaded;
delete item.failed;
}
if (attachment) {
if (
attachmentsKey &&
isSameKey &&
!(await this.db.fs().exists(attachment.hash)) &&
(await this.db.fs().getUploadedFileSize(attachment.hash)) <= 0
) {
delete item.dateUploaded;
delete item.failed;
}
const isNewGeneric =
item.metadata?.type === "application/octet-stream" ||
item.mimeType === "application/octet-stream";
const isOldGeneric =
attachment.mimeType === "application/octet-stream";
item = {
...attachment,
mimeType:
// we keep whichever mime type is more specific
isNewGeneric && !isOldGeneric
? attachment.mimeType
: item.metadata?.type || item.mimeType,
filename:
// we keep the filename based on which item's mime type we kept
isNewGeneric && !isOldGeneric
? attachment.filename
: item.metadata?.filename || item.filename
};
for (const noteId of item.noteIds || []) {
await this.db.relations.add(
{
id: noteId,
type: "note"
},
attachment
);
}
} else {
delete item.dateUploaded;
delete item.failed;
}
}
const collectionKey: CollectionName =
itemTypeToCollectionKey[
itemType as keyof typeof itemTypeToCollectionKey
];
if (!collectionKey) continue;
if (itemType === "color") {
item.dateModified = Date.now();
item.synced = false;
await this.db.colors.collection.upsert(item as any);
} else if (itemType === "tag") {
item.dateModified = Date.now();
item.synced = false;
await this.db.tags.collection.upsert(item as any);
} else {
queue[collectionKey] = queue[collectionKey] || [];
queue[collectionKey]?.push(item);
}
}
for (const key in queue) {
const collectionKey = key as CollectionName;
const collection =
collectionKey === "sessioncontent"
? this.db.noteHistory.sessionContent.collection
: this.db[collectionKey].collection;
if (!collection) continue;
const items = queue[collectionKey];
if (!items) continue;
await collection.put(items as any[]);
}
}
private validate(backup: LegacyBackupFile | BackupFile) {
return (
!!backup.date &&
!!backup.data &&
!!backup.type &&
validTypes.some((t) => t === backup.type)
);
}
private verify(
backup: BackupFile | LegacyUnencryptedBackupFile,
data: string | Record<string, BackupDataItem>
) {
const { hash, hash_type } = backup;
switch (hash_type) {
case "md5": {
return (
hash ===
SparkMD5.hash(typeof data === "string" ? data : JSON.stringify(data))
);
}
default: {
return false;
}
}
}
}