added "DB dump" tool, WIP

This commit is contained in:
zadam 2022-02-10 23:37:25 +01:00
parent df91192b97
commit 6c9fc364a3
7 changed files with 1446 additions and 0 deletions

22
dump-db/README.md Normal file
View file

@ -0,0 +1,22 @@
# Trilium Notes DB dump tool
This is a simple tool to dump the content of Trilium's document.db onto filesystem.
It is meant as a last resort solution when the standard mean to access your data (through main Trilium application) fail.
## Installation
This tool requires node.js, testing has been done on 16.14.0, but it will probably work on other versions as well.
```
npm install
```
## Running
See output of `node dump-db.js --help`:
```
Trilium Notes DB dump tool. Usage:
node dump-db.js PATH_TO_DOCUMENT_DB TARGET_PATH
```

132
dump-db/dump-db.js Executable file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env node
const fs = require('fs');
const sql = require("./inc/sql");
const args = process.argv.slice(2);
const sanitize = require('sanitize-filename');
const path = require("path");
const mimeTypes = require("mime-types");
if (args[0] === '-h' || args[0] === '--help') {
printHelp();
process.exit(0);
}
if (args.length !== 2) {
console.error(`Exactly 2 arguments are expected. Run with --help to see usage.`);
process.exit(1);
}
const [documentPath, targetPath] = args;
if (!fs.existsSync(documentPath)) {
console.error(`Path to document '${documentPath}' has not been found. Run with --help to see usage.`);
process.exit(1);
}
if (!fs.existsSync(targetPath)) {
const ret = fs.mkdirSync(targetPath, { recursive: true });
if (!ret) {
console.error(`Target path '${targetPath}' could not be created. Run with --help to see usage.`);
process.exit(1);
}
}
sql.openDatabase(documentPath);
const existingPaths = {};
dumpNote(targetPath, 'root');
function getFileName(note, childTargetPath, safeTitle) {
let existingExtension = path.extname(safeTitle).toLowerCase();
let newExtension;
if (note.type === 'text') {
newExtension = 'html';
} else if (note.mime === 'application/x-javascript' || note.mime === 'text/javascript') {
newExtension = 'js';
} else if (existingExtension.length > 0) { // if the page already has an extension, then we'll just keep it
newExtension = null;
} else {
if (note.mime?.toLowerCase()?.trim() === "image/jpg") { // image/jpg is invalid but pretty common
newExtension = 'jpg';
} else {
newExtension = mimeTypes.extension(note.mime) || "dat";
}
}
let fileNameWithPath = childTargetPath;
// if the note is already named with extension (e.g. "jquery"), then it's silly to append exact same extension again
if (newExtension && existingExtension !== "." + newExtension.toLowerCase()) {
fileNameWithPath += "." + newExtension;
}
return fileNameWithPath;
}
function dumpNote(targetPath, noteId) {
console.log(`Dumping note ${noteId}`);
const note = sql.getRow("SELECT * FROM notes WHERE noteId = ?", [noteId]);
let safeTitle = sanitize(note.title);
if (safeTitle.length > 20) {
safeTitle = safeTitle.substring(0, 20);
}
let childTargetPath = targetPath + '/' + safeTitle;
for (let i = 1; i < 100000 && childTargetPath in existingPaths; i++) {
childTargetPath = targetPath + '/' + safeTitle + '_' + i;
}
existingPaths[childTargetPath] = true;
try {
const {content} = sql.getRow("SELECT content FROM note_contents WHERE noteId = ?", [noteId]);
if (!isContentEmpty(content)) {
const fileNameWithPath = getFileName(note, childTargetPath, safeTitle);
fs.writeFileSync(fileNameWithPath, content);
}
}
catch (e) {
console.log(`Writing ${note.noteId} failed with error ${e.message}`);
}
const childNoteIds = sql.getColumn("SELECT noteId FROM branches WHERE parentNoteId = ?", [noteId]);
if (childNoteIds.length > 0) {
fs.mkdirSync(childTargetPath, { recursive: true });
for (const childNoteId of childNoteIds) {
dumpNote(childTargetPath, childNoteId);
}
}
}
function isContentEmpty(content) {
if (!content) {
return true;
}
if (typeof content === "string") {
return !content.trim() || content.trim() === '<p></p>';
}
else if (Buffer.isBuffer(content)) {
return content.length === 0;
}
else {
return false;
}
}
function printHelp() {
console.log(`Trilium Notes DB dump tool. Usage:
node dump-db.js PATH_TO_DOCUMENT_DB TARGET_PATH`);
}

33
dump-db/inc/data_key.js Normal file
View file

@ -0,0 +1,33 @@
import crypto from "crypto";
import sql from "./sql.js";
function getDataKey(password) {
const passwordDerivedKey = getPasswordDerivedKey(password);
const encryptedDataKey = getOption('encryptedDataKey');
const decryptedDataKey = decrypt(passwordDerivedKey, encryptedDataKey, 16);
return decryptedDataKey;
}
function getPasswordDerivedKey(password) {
const salt = getOption('passwordDerivedKeySalt');
return getScryptHash(password, salt);
}
function getScryptHash(password, salt) {
const hashed = crypto.scryptSync(password, salt, 32,
{N: 16384, r:8, p:1});
return hashed;
}
function getOption(name) {
return sql.getValue("SELECT value FROM options WHERE name = ?", name);
}
module.exports = {
getDataKey
};

79
dump-db/inc/decrypt.js Normal file
View file

@ -0,0 +1,79 @@
import crypto from "crypto";
function decryptString(dataKey, cipherText) {
const buffer = decrypt(dataKey, cipherText);
if (buffer === null) {
return null;
}
const str = buffer.toString('utf-8');
if (str === 'false') {
throw new Error("Could not decrypt string.");
}
return str;
}
function decrypt(key, cipherText, ivLength = 13) {
if (cipherText === null) {
return null;
}
if (!key) {
return "[protected]";
}
try {
const cipherTextBufferWithIv = Buffer.from(cipherText.toString(), 'base64');
const iv = cipherTextBufferWithIv.slice(0, ivLength);
const cipherTextBuffer = cipherTextBufferWithIv.slice(ivLength);
const decipher = crypto.createDecipheriv('aes-128-cbc', pad(key), pad(iv));
const decryptedBytes = Buffer.concat([decipher.update(cipherTextBuffer), decipher.final()]);
const digest = decryptedBytes.slice(0, 4);
const payload = decryptedBytes.slice(4);
const computedDigest = shaArray(payload).slice(0, 4);
if (!arraysIdentical(digest, computedDigest)) {
return false;
}
return payload;
}
catch (e) {
// recovery from https://github.com/zadam/trilium/issues/510
if (e.message && e.message.includes("WRONG_FINAL_BLOCK_LENGTH")) {
log.info("Caught WRONG_FINAL_BLOCK_LENGTH, returning cipherText instead");
return cipherText;
}
else {
throw e;
}
}
}
function arraysIdentical(a, b) {
let i = a.length;
if (i !== b.length) return false;
while (i--) {
if (a[i] !== b[i]) return false;
}
return true;
}
function shaArray(content) {
// we use this as simple checksum and don't rely on its security so SHA-1 is good enough
return crypto.createHash('sha1').update(content).digest();
}
module.exports = {
decrypt,
decryptString
};

17
dump-db/inc/sql.js Normal file
View file

@ -0,0 +1,17 @@
const Database = require("better-sqlite3");
let dbConnection;
const openDatabase = (documentPath) => { dbConnection = new Database(documentPath, { readonly: true }) };
const getRow = (query, params = []) => dbConnection.prepare(query).get(params);
const getRows = (query, params = []) => dbConnection.prepare(query).all(params);
const getValue = (query, params = []) => dbConnection.prepare(query).pluck().get(params);
const getColumn = (query, params = []) => dbConnection.prepare(query).pluck().all(params);
module.exports = {
openDatabase,
getRow,
getRows,
getValue,
getColumn
};

1139
dump-db/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
dump-db/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "dump-db",
"version": "1.0.0",
"description": "Standalone tool to dump contents of Trilium document.db file into a directory tree of notes",
"main": "dump-db.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/zadam/trilium.git"
},
"author": "zadam",
"license": "ISC",
"bugs": {
"url": "https://github.com/zadam/trilium/issues"
},
"homepage": "https://github.com/zadam/trilium/dump-db#readme",
"dependencies": {
"better-sqlite3": "7.5.0",
"mime-types": "2.1.34",
"sanitize-filename": "1.6.3"
}
}