mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	added "DB dump" tool, WIP
This commit is contained in:
		
							
								
								
									
										22
									
								
								dump-db/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								dump-db/README.md
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										132
									
								
								dump-db/dump-db.js
									
									
									
									
									
										Executable 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
									
								
							
							
						
						
									
										33
									
								
								dump-db/inc/data_key.js
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										79
									
								
								dump-db/inc/decrypt.js
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										17
									
								
								dump-db/inc/sql.js
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										24
									
								
								dump-db/package.json
									
									
									
									
									
										Normal 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" | ||||||
|  |   } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user