mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-29 00:20:04 +01:00
304 lines
7.9 KiB
JavaScript
304 lines
7.9 KiB
JavaScript
/*
|
|
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 { toChunks } from "../utils/array.js";
|
|
import { migrateItem } from "../migrations.js";
|
|
import Indexer from "./indexer.js";
|
|
|
|
const COLORS = [
|
|
"red",
|
|
"orange",
|
|
"yellow",
|
|
"green",
|
|
"blue",
|
|
"purple",
|
|
"gray",
|
|
"black",
|
|
"white"
|
|
];
|
|
|
|
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"
|
|
};
|
|
|
|
const validTypes = ["mobile", "web", "node"];
|
|
export default class Backup {
|
|
/**
|
|
*
|
|
* @param {import("../api/index.js").default} db
|
|
*/
|
|
constructor(db) {
|
|
this._db = db;
|
|
this._migrator = new Migrator();
|
|
}
|
|
|
|
lastBackupTime() {
|
|
return this._db.storage.read("lastBackupTime");
|
|
}
|
|
|
|
async updateBackupTime() {
|
|
await this._db.storage.write("lastBackupTime", Date.now());
|
|
}
|
|
/**
|
|
*
|
|
* @param {"web"|"mobile"|"node"} type
|
|
* @param {boolean} encrypt
|
|
*/
|
|
async *export(type, 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.getUser()))
|
|
throw new Error("Please login to create encrypted backups.");
|
|
|
|
yield {
|
|
path: ".nnbackup",
|
|
data: ""
|
|
};
|
|
|
|
let keys = await this._db.storage.getAllKeys();
|
|
const key = await this._db.user.getEncryptionKey();
|
|
const chunks = toChunks(keys, 20);
|
|
let buffer = [];
|
|
let bufferLength = 0;
|
|
const MAX_CHUNK_SIZE = 10 * 1024 * 1024;
|
|
let chunkIndex = 0;
|
|
|
|
while (chunks.length > 0) {
|
|
const chunk = chunks.pop();
|
|
|
|
const items = await this._db.storage.readMulti(chunk);
|
|
items.forEach(([id, item]) => {
|
|
if (
|
|
!item ||
|
|
invalidKeys.includes(id) ||
|
|
(item.deleted && !item.type) ||
|
|
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 this._db.compressor.compress(itemsJSON);
|
|
|
|
const hash = SparkMD5.hash(itemsJSON);
|
|
|
|
if (encrypt) itemsJSON = await this._db.storage.encrypt(key, itemsJSON);
|
|
|
|
yield {
|
|
path: `${chunkIndex++}-${encrypt ? "encrypted" : "plain"}-${hash}`,
|
|
data: `{
|
|
"version": ${CURRENT_DATABASE_VERSION},
|
|
"type": "${type}",
|
|
"date": ${Date.now()},
|
|
"data": ${JSON.stringify(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();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {any} backup the backup data
|
|
*/
|
|
async import(backup, password) {
|
|
if (!backup) return;
|
|
|
|
if (!this._validate(backup)) throw new Error("Invalid backup.");
|
|
|
|
backup = this._migrateBackup(backup);
|
|
|
|
let db = backup.data;
|
|
const isEncrypted = db.salt && db.iv && db.cipher;
|
|
if (backup.encrypted || isEncrypted) {
|
|
if (!password)
|
|
throw new Error(
|
|
"Please provide a password to decrypt this backup & restore it."
|
|
);
|
|
|
|
const key = await this._db.storage.generateCryptoKey(password, db.salt);
|
|
if (!key)
|
|
throw new Error("Could not generate encryption key for backup.");
|
|
|
|
try {
|
|
backup.data = await this._db.storage.decrypt(key, db);
|
|
} catch (e) {
|
|
if (
|
|
e.message.includes("ciphertext cannot be decrypted") ||
|
|
e.message === "FAILURE"
|
|
)
|
|
throw new Error("Incorrect password.");
|
|
|
|
throw new Error(`Could not decrypt backup: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
if (backup.hash && !this._verify(backup))
|
|
throw new Error("Backup file has been tempered, aborting...");
|
|
|
|
if (backup.compressed)
|
|
backup.data = await this._db.compressor.decompress(backup.data);
|
|
backup.data =
|
|
typeof backup.data === "string" ? JSON.parse(backup.data) : backup.data;
|
|
|
|
await this._migrateData(backup);
|
|
}
|
|
|
|
_migrateBackup(backup) {
|
|
const { version = 0 } = backup;
|
|
if (version > CURRENT_DATABASE_VERSION)
|
|
throw new Error(
|
|
"This backup was made from a newer version of Notesnook. Cannot restore."
|
|
);
|
|
|
|
switch (version) {
|
|
case CURRENT_DATABASE_VERSION:
|
|
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.");
|
|
}
|
|
}
|
|
|
|
async _migrateData(backup) {
|
|
const { data, version = 0 } = backup;
|
|
|
|
const toAdd = {};
|
|
for (const item of Array.isArray(data) ? data : Object.values(data)) {
|
|
// we do not want to restore deleted items
|
|
if (!item || (!item.type && item.deleted)) continue;
|
|
// in v5.6 of the database, we did not set note history session's type
|
|
if (!item.type && item.sessionContentId) item.type = "notehistory";
|
|
|
|
await migrateItem(item, version, item.type, this._db, "backup");
|
|
// 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)
|
|
await migrateItem(item, version, item.itemType, this._db, "backup");
|
|
|
|
// colors are naively of type "tag" instead of "color" so we have to fix that.
|
|
const itemType =
|
|
item.type === "tag" && COLORS.includes(item.title.toLowerCase())
|
|
? "color"
|
|
: item.itemType || item.type;
|
|
|
|
const collectionKey = itemTypeToCollectionKey[itemType];
|
|
if (collectionKey) {
|
|
toAdd[collectionKey] = toAdd[collectionKey] || [];
|
|
toAdd[collectionKey].push([item.id, item]);
|
|
} else if (item.type === "settings")
|
|
await this._db.storage.write("settings", item);
|
|
}
|
|
|
|
for (const collectionKey in toAdd) {
|
|
const indexer = new Indexer(this._db.storage, collectionKey);
|
|
await indexer.init();
|
|
await indexer.writeMulti(toAdd[collectionKey]);
|
|
}
|
|
}
|
|
|
|
_validate(backup) {
|
|
return (
|
|
!!backup.date &&
|
|
!!backup.data &&
|
|
!!backup.type &&
|
|
validTypes.some((t) => t === backup.type)
|
|
);
|
|
}
|
|
|
|
_verify(backup) {
|
|
const { compressed, hash, hash_type, data: db } = backup;
|
|
switch (hash_type) {
|
|
case "md5": {
|
|
return hash === SparkMD5.hash(compressed ? db : JSON.stringify(db));
|
|
}
|
|
default: {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|