mirror of
https://github.com/eliasstepanik/filesystem-livesync.git
synced 2026-01-11 05:28:31 +00:00
initial (POC, but looks like works)
This commit is contained in:
commit
5d937a68b1
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
dat/**
|
||||
!dat/.gitempty
|
||||
!dat/config.sample.json
|
||||
node_modules
|
||||
dist/*
|
||||
.idea/*
|
||||
.vscode/*
|
||||
utils/**
|
||||
vault/**
|
||||
|
||||
0
dat/.gitempty
Normal file
0
dat/.gitempty
Normal file
19
dat/config.sample.json
Normal file
19
dat/config.sample.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"config_1": {
|
||||
"server": {
|
||||
"uri": "http://localhost:5984/private1_vault",
|
||||
"auth": {
|
||||
"username": "username_of_private_vault",
|
||||
"password": "password_of_private_vault",
|
||||
"passphrase": "passphrase_of_private_vault"
|
||||
},
|
||||
"path": "shared/"
|
||||
},
|
||||
"local": {
|
||||
"path": "./export",
|
||||
"processor": "utils/build.sh",
|
||||
"initialScan": false
|
||||
},
|
||||
"auto_reconnect": true
|
||||
}
|
||||
}
|
||||
38
esbuild.config.mjs
Normal file
38
esbuild.config.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/index.ts"],
|
||||
bundle: true,
|
||||
external: [...builtins, "./node_modules/*", "src/pouchdb.js"],
|
||||
format: "cjs",
|
||||
watch: false,
|
||||
target: "node16",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
plugins: [
|
||||
// sveltePlugin({
|
||||
// preprocess: sveltePreprocess(),
|
||||
// compilerOptions: { css: true },
|
||||
// }),
|
||||
],
|
||||
outfile: "dist/index.js",
|
||||
})
|
||||
.catch((e) => {
|
||||
console.dir(e);
|
||||
process.exit(1);
|
||||
});
|
||||
2394
package-lock.json
generated
Normal file
2394
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "filesystem-livesync",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs && node dist/index.js",
|
||||
"build": "node esbuild.config.mjs production"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.3",
|
||||
"pouchdb": "^7.2.2",
|
||||
"pouchdb-adapter-http": "^7.2.2",
|
||||
"pouchdb-adapter-leveldb": "^7.2.2",
|
||||
"pouchdb-core": "^7.2.2",
|
||||
"pouchdb-mapreduce": "^7.2.2",
|
||||
"pouchdb-node": "^7.2.2",
|
||||
"pouchdb-replication": "^7.2.2",
|
||||
"xxhash-wasm": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"@types/node": "^17.0.18",
|
||||
"@types/pouchdb": "^6.4.0",
|
||||
"builtin-modules": "^3.2.0",
|
||||
"esbuild": "^0.14.22",
|
||||
"ts-node": "^10.5.0",
|
||||
"typescript": "^4.5.5"
|
||||
}
|
||||
}
|
||||
175
src/e2ee.ts
Normal file
175
src/e2ee.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { Logger } from "./logger";
|
||||
import { LOG_LEVEL } from "./types";
|
||||
import { webcrypto as crypto } from "crypto";
|
||||
|
||||
export type encodedData = [encryptedData: string, iv: string, salt: string];
|
||||
export type KeyBuffer = {
|
||||
index: string;
|
||||
key: CryptoKey;
|
||||
salt: Uint8Array;
|
||||
};
|
||||
|
||||
let KeyBuffs: KeyBuffer[] = [];
|
||||
let decKeyBuffs: KeyBuffer[] = [];
|
||||
|
||||
const KEY_RECYCLE_COUNT = 100;
|
||||
let recycleCount = KEY_RECYCLE_COUNT;
|
||||
|
||||
let semiStaticFieldBuffer: Uint8Array;
|
||||
const nonceBuffer: Uint32Array = new Uint32Array(1);
|
||||
|
||||
export async function getKeyForEncrypt(passphrase: string): Promise<[CryptoKey, Uint8Array]> {
|
||||
// For performance, the plugin reuses the key KEY_RECYCLE_COUNT times.
|
||||
const f = KeyBuffs.find((e) => e.index == passphrase);
|
||||
if (f) {
|
||||
recycleCount--;
|
||||
if (recycleCount > 0) {
|
||||
return [f.key, f.salt];
|
||||
}
|
||||
KeyBuffs = KeyBuffs.filter((e) => e != f);
|
||||
recycleCount = KEY_RECYCLE_COUNT;
|
||||
}
|
||||
const xpassphrase = new TextEncoder().encode(passphrase);
|
||||
const digest = await crypto.subtle.digest({ name: "SHA-256" }, xpassphrase);
|
||||
const keyMaterial = await crypto.subtle.importKey("raw", digest, { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
KeyBuffs.push({
|
||||
index: passphrase,
|
||||
key,
|
||||
salt,
|
||||
});
|
||||
while (KeyBuffs.length > 50) {
|
||||
KeyBuffs.shift();
|
||||
}
|
||||
return [key, salt];
|
||||
}
|
||||
|
||||
export async function getKeyForDecryption(passphrase: string, salt: Uint8Array): Promise<[CryptoKey, Uint8Array]> {
|
||||
const bufKey = passphrase + uint8ArrayToHexString(salt);
|
||||
const f = decKeyBuffs.find((e) => e.index == bufKey);
|
||||
if (f) {
|
||||
return [f.key, f.salt];
|
||||
}
|
||||
const xpassphrase = new TextEncoder().encode(passphrase);
|
||||
const digest = await crypto.subtle.digest({ name: "SHA-256" }, xpassphrase);
|
||||
const keyMaterial = await crypto.subtle.importKey("raw", digest, { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||
const key = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
decKeyBuffs.push({
|
||||
index: bufKey,
|
||||
key,
|
||||
salt,
|
||||
});
|
||||
while (decKeyBuffs.length > 50) {
|
||||
decKeyBuffs.shift();
|
||||
}
|
||||
return [key, salt];
|
||||
}
|
||||
|
||||
function getSemiStaticField(reset?: boolean) {
|
||||
// return fixed field of iv.
|
||||
if (semiStaticFieldBuffer != null && !reset) {
|
||||
return semiStaticFieldBuffer;
|
||||
}
|
||||
semiStaticFieldBuffer = crypto.getRandomValues(new Uint8Array(12));
|
||||
return semiStaticFieldBuffer;
|
||||
}
|
||||
|
||||
function getNonce() {
|
||||
// This is nonce, so do not send same thing.
|
||||
nonceBuffer[0]++;
|
||||
if (nonceBuffer[0] > 10000) {
|
||||
// reset semi-static field.
|
||||
getSemiStaticField(true);
|
||||
}
|
||||
return nonceBuffer;
|
||||
}
|
||||
|
||||
function uint8ArrayToHexString(src: Uint8Array): string {
|
||||
return Array.from(src)
|
||||
.map((e: number): string => `00${e.toString(16)}`.slice(-2))
|
||||
.join("");
|
||||
}
|
||||
function hexStringToUint8Array(src: string): Uint8Array {
|
||||
const srcArr = [...src];
|
||||
const arr = srcArr.reduce((acc, _, i) => (i % 2 ? acc : [...acc, srcArr.slice(i, i + 2).join("")]), [] as string[]).map((e) => parseInt(e, 16));
|
||||
return Uint8Array.from(arr);
|
||||
}
|
||||
function btoa(src: string): string {
|
||||
return Buffer.from(src, "binary").toString("base64");
|
||||
}
|
||||
function atob(src: string): string {
|
||||
return Buffer.from(src, "base64").toString("binary");
|
||||
}
|
||||
export async function encrypt(input: string, passphrase: string) {
|
||||
const [key, salt] = await getKeyForEncrypt(passphrase);
|
||||
// Create initial vector with semifixed part and incremental part
|
||||
// I think it's not good against related-key attacks.
|
||||
const fixedPart = getSemiStaticField();
|
||||
const invocationPart = getNonce();
|
||||
const iv = Uint8Array.from([...fixedPart, ...new Uint8Array(invocationPart.buffer)]);
|
||||
const plainStringified: string = JSON.stringify(input);
|
||||
const plainStringBuffer: Uint8Array = new TextEncoder().encode(plainStringified);
|
||||
const encryptedDataArrayBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plainStringBuffer);
|
||||
|
||||
const encryptedData = btoa(Array.from(new Uint8Array(encryptedDataArrayBuffer), (char) => String.fromCharCode(char)).join(""));
|
||||
|
||||
//return data with iv and salt.
|
||||
const response: encodedData = [encryptedData, uint8ArrayToHexString(iv), uint8ArrayToHexString(salt)];
|
||||
const ret = JSON.stringify(response);
|
||||
return ret;
|
||||
}
|
||||
|
||||
export async function decrypt(encryptedResult: string, passphrase: string): Promise<string> {
|
||||
try {
|
||||
const [encryptedData, ivString, salt]: encodedData = JSON.parse(encryptedResult);
|
||||
const [key] = await getKeyForDecryption(passphrase, hexStringToUint8Array(salt));
|
||||
const iv = hexStringToUint8Array(ivString);
|
||||
// decode base 64, it should increase speed and i should with in MAX_DOC_SIZE_BIN, so it won't OOM.
|
||||
const encryptedDataBin = atob(encryptedData);
|
||||
const encryptedDataArrayBuffer = Uint8Array.from(encryptedDataBin.split(""), (char) => char.charCodeAt(0));
|
||||
const plainStringBuffer: ArrayBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encryptedDataArrayBuffer);
|
||||
const plainStringified = new TextDecoder().decode(plainStringBuffer);
|
||||
const plain = JSON.parse(plainStringified);
|
||||
return plain;
|
||||
} catch (ex) {
|
||||
Logger("Couldn't decode! You should wrong the passphrases", LOG_LEVEL.VERBOSE);
|
||||
Logger(ex, LOG_LEVEL.VERBOSE);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testCrypt() {
|
||||
const src = "supercalifragilisticexpialidocious";
|
||||
const encoded = await encrypt(src, "passwordTest");
|
||||
const decrypted = await decrypt(encoded, "passwordTest");
|
||||
if (src != decrypted) {
|
||||
Logger("WARNING! Your device would not support encryption.", LOG_LEVEL.VERBOSE);
|
||||
return false;
|
||||
} else {
|
||||
Logger("CRYPT LOGIC OK", LOG_LEVEL.VERBOSE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
542
src/index.ts
Normal file
542
src/index.ts
Normal file
@ -0,0 +1,542 @@
|
||||
import { decrypt, encrypt } from "./e2ee.js";
|
||||
import chokidar from "chokidar";
|
||||
//@ts-ignore
|
||||
import { PouchDB as PouchDB_src } from "./pouchdb.js";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import * as util from "util";
|
||||
import { exec } from "child_process";
|
||||
|
||||
import { Logger } from "./logger.js";
|
||||
import { configFile, connectConfig, eachConf, Entry, EntryLeaf, LoadedEntry, LOG_LEVEL, MAX_DOC_SIZE, MAX_DOC_SIZE_BIN, NewEntry, PlainEntry } from "./types.js";
|
||||
import { Stats } from "fs";
|
||||
import { addTouchedFile, isKnownFile, isPlainText, isTouchedFile, path2unix } from "./util.js";
|
||||
|
||||
const xxhash = require("xxhash-wasm");
|
||||
|
||||
const PouchDB: PouchDB.Static<{}> = PouchDB_src;
|
||||
|
||||
const statFile = "./dat/stat.json";
|
||||
|
||||
let running: { [key: string]: boolean } = {};
|
||||
|
||||
let h32Raw: (inputBuffer: Uint8Array, seed?: number | undefined) => number;
|
||||
let h32: (input: string, seed?: number) => string;
|
||||
|
||||
let syncStat: { [key: string]: string };
|
||||
let saveStatTimer: NodeJS.Timeout | undefined = undefined;
|
||||
let saveStatCount: 0;
|
||||
|
||||
function log(log: any) {
|
||||
Logger(log, LOG_LEVEL.INFO);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((res) => setTimeout(() => res(), ms));
|
||||
}
|
||||
|
||||
async function saveStat() {
|
||||
await fs.writeFile(statFile, JSON.stringify(syncStat));
|
||||
}
|
||||
function triggerSaveStat() {
|
||||
if (saveStatTimer == undefined) clearTimeout(saveStatTimer);
|
||||
if (saveStatCount > 25) {
|
||||
saveStatTimer = undefined;
|
||||
saveStatCount = 0;
|
||||
saveStat();
|
||||
} else {
|
||||
saveStatCount++;
|
||||
saveStatTimer = setTimeout(() => {
|
||||
saveStat();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
let processorTimer: NodeJS.Timeout | undefined = undefined;
|
||||
async function runEngine() {
|
||||
let processors = [...waitingProcessorList];
|
||||
waitingProcessorList = [];
|
||||
log("Run external commands.");
|
||||
const execPromise = util.promisify(exec);
|
||||
const procs = processors.map((e) => execPromise(e));
|
||||
let results = await Promise.allSettled(procs);
|
||||
for (const result of results) {
|
||||
if (result.status == "rejected") {
|
||||
log(`Failed! Reason:${result.reason}`);
|
||||
} else {
|
||||
log(`OK: stdout:${result.value.stdout}`);
|
||||
log(`OK: stderr:${result.value.stderr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
let waitingProcessorList: string[] = [];
|
||||
function triggerProcessor(procs: string) {
|
||||
if (procs == "") return;
|
||||
waitingProcessorList = Array.from(new Set([...waitingProcessorList, procs]));
|
||||
if (processorTimer == undefined) clearTimeout(processorTimer);
|
||||
processorTimer = setTimeout(() => {
|
||||
runEngine();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
log("LiveSync-classroom starting up.");
|
||||
let xx = await xxhash();
|
||||
h32Raw = xx.h32Raw;
|
||||
h32 = xx.h32ToString;
|
||||
let config: configFile = JSON.parse((await fs.readFile("./dat/config.json")) + "");
|
||||
|
||||
try {
|
||||
syncStat = JSON.parse((await fs.readFile(statFile)) + "");
|
||||
} catch (ex) {
|
||||
log("could not read pervious sync status, initialized.");
|
||||
syncStat = {};
|
||||
}
|
||||
|
||||
for (const conf of Object.entries(config)) {
|
||||
setTimeout(() => eachProc(conf[0], conf[1]), 100);
|
||||
}
|
||||
}
|
||||
|
||||
let hashCache: {
|
||||
[key: string]: string;
|
||||
} = {};
|
||||
let hashCacheRev: {
|
||||
[key: string]: string;
|
||||
} = {};
|
||||
|
||||
// putDBEntry:COPIED FROM obsidian-livesync
|
||||
async function putDBEntry(note: LoadedEntry, passphrase: string, database: PouchDB.Database<NewEntry | PlainEntry | Entry | EntryLeaf>) {
|
||||
let leftData = note.data;
|
||||
const savenNotes = [];
|
||||
let processed = 0;
|
||||
let made = 0;
|
||||
let skiped = 0;
|
||||
let pieceSize = MAX_DOC_SIZE_BIN;
|
||||
let plainSplit = false;
|
||||
let cacheUsed = 0;
|
||||
const userpasswordHash = h32Raw(new TextEncoder().encode(passphrase));
|
||||
if (isPlainText(note._id)) {
|
||||
pieceSize = MAX_DOC_SIZE;
|
||||
plainSplit = true;
|
||||
}
|
||||
const newLeafs: EntryLeaf[] = [];
|
||||
do {
|
||||
// To keep low bandwith and database size,
|
||||
// Dedup pieces on database.
|
||||
// from 0.1.10, for best performance. we use markdown delimiters
|
||||
// 1. \n[^\n]{longLineThreshold}[^\n]*\n -> long sentence shuld break.
|
||||
// 2. \n\n shold break
|
||||
// 3. \r\n\r\n should break
|
||||
// 4. \n# should break.
|
||||
let cPieceSize = pieceSize;
|
||||
if (plainSplit) {
|
||||
let minimumChunkSize = 20; //default
|
||||
if (minimumChunkSize < 10) minimumChunkSize = 10;
|
||||
let longLineThreshold = 250; //default
|
||||
if (longLineThreshold < 100) longLineThreshold = 100;
|
||||
cPieceSize = 0;
|
||||
// lookup for next splittion .
|
||||
// we're standing on "\n"
|
||||
do {
|
||||
const n1 = leftData.indexOf("\n", cPieceSize + 1);
|
||||
const n2 = leftData.indexOf("\n\n", cPieceSize + 1);
|
||||
const n3 = leftData.indexOf("\r\n\r\n", cPieceSize + 1);
|
||||
const n4 = leftData.indexOf("\n#", cPieceSize + 1);
|
||||
if (n1 == -1 && n2 == -1 && n3 == -1 && n4 == -1) {
|
||||
cPieceSize = MAX_DOC_SIZE;
|
||||
break;
|
||||
}
|
||||
|
||||
if (n1 > longLineThreshold) {
|
||||
// long sentence is an established piece
|
||||
cPieceSize = n1;
|
||||
} else {
|
||||
// cPieceSize = Math.min.apply([n2, n3, n4].filter((e) => e > 1));
|
||||
// ^ heavy.
|
||||
if (n1 > 0 && cPieceSize < n1) cPieceSize = n1;
|
||||
if (n2 > 0 && cPieceSize < n2) cPieceSize = n2 + 1;
|
||||
if (n3 > 0 && cPieceSize < n3) cPieceSize = n3 + 3;
|
||||
// Choose shorter, empty line and \n#
|
||||
if (n4 > 0 && cPieceSize > n4) cPieceSize = n4 + 0;
|
||||
cPieceSize++;
|
||||
}
|
||||
} while (cPieceSize < minimumChunkSize);
|
||||
}
|
||||
|
||||
// piece size determined.
|
||||
const piece = leftData.substring(0, cPieceSize);
|
||||
leftData = leftData.substring(cPieceSize);
|
||||
processed++;
|
||||
let leafid = "";
|
||||
// Get hash of piece.
|
||||
let hashedPiece = "";
|
||||
let needMake = true;
|
||||
if (typeof hashCache[piece] !== "undefined") {
|
||||
hashedPiece = "";
|
||||
leafid = hashCache[piece];
|
||||
needMake = false;
|
||||
skiped++;
|
||||
cacheUsed++;
|
||||
} else {
|
||||
if (passphrase != "") {
|
||||
// When encryption has been enabled, make hash to be different between each passphrase to avoid inferring password.
|
||||
hashedPiece = "+" + (h32Raw(new TextEncoder().encode(piece)) ^ userpasswordHash).toString(16);
|
||||
} else {
|
||||
hashedPiece = h32(piece);
|
||||
}
|
||||
leafid = "h:" + hashedPiece;
|
||||
|
||||
//have to make
|
||||
const savePiece = await encrypt(piece, passphrase);
|
||||
|
||||
const d: EntryLeaf = {
|
||||
_id: leafid,
|
||||
data: savePiece,
|
||||
type: "leaf",
|
||||
};
|
||||
newLeafs.push(d);
|
||||
hashCache[piece] = leafid;
|
||||
hashCacheRev[leafid] = piece;
|
||||
made++;
|
||||
}
|
||||
savenNotes.push(leafid);
|
||||
} while (leftData != "");
|
||||
let saved = true;
|
||||
if (newLeafs.length > 0) {
|
||||
try {
|
||||
const result = await database.bulkDocs(newLeafs);
|
||||
for (const item of result) {
|
||||
if ((item as any).ok) {
|
||||
Logger(`save ok:id:${item.id} rev:${item.rev}`, LOG_LEVEL.VERBOSE);
|
||||
} else {
|
||||
if ((item as any).status && (item as any).status == 409) {
|
||||
// conflicted, but it would be ok in childrens.
|
||||
} else {
|
||||
Logger(`save failed:id:${item.id} rev:${item.rev}`, LOG_LEVEL.NOTICE);
|
||||
Logger(item);
|
||||
// this.disposeHashCache();
|
||||
saved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
Logger("ERROR ON SAVING LEAVES:", LOG_LEVEL.NOTICE);
|
||||
Logger(ex, LOG_LEVEL.NOTICE);
|
||||
saved = false;
|
||||
}
|
||||
}
|
||||
if (saved) {
|
||||
Logger(`note content saven, pieces:${processed} new:${made}, skip:${skiped}, cache:${cacheUsed}`);
|
||||
const newDoc: PlainEntry | NewEntry = {
|
||||
NewNote: true,
|
||||
children: savenNotes,
|
||||
_id: note._id,
|
||||
ctime: note.ctime,
|
||||
mtime: note.mtime,
|
||||
size: note.size,
|
||||
type: plainSplit ? "plain" : "newnote",
|
||||
};
|
||||
// Here for upsert logic,
|
||||
try {
|
||||
const old = await database.get(newDoc._id);
|
||||
if (!old.type || old.type == "notes" || old.type == "newnote" || old.type == "plain") {
|
||||
// simple use rev for new doc
|
||||
newDoc._rev = old._rev;
|
||||
}
|
||||
} catch (ex: any) {
|
||||
if (ex.status && ex.status == 404) {
|
||||
// NO OP/
|
||||
} else {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
const r = await database.put(newDoc, { force: true });
|
||||
Logger(`note saved:${newDoc._id}:${r.rev}`);
|
||||
} else {
|
||||
Logger(`note coud not saved:${note._id}`);
|
||||
}
|
||||
}
|
||||
async function eachProc(syncKey: string, config: eachConf) {
|
||||
log(`${syncKey} started`);
|
||||
|
||||
const serverURI = config.server.uri;
|
||||
const serverAuth = config.server.auth;
|
||||
const serverPath = config.server.path;
|
||||
|
||||
const exportPath = config.local?.path ?? "";
|
||||
const processor = config.local?.processor ?? "";
|
||||
|
||||
const remote = new PouchDB(serverURI, { auth: serverAuth });
|
||||
|
||||
async function sanityCheck() {
|
||||
let mr = await remote.info();
|
||||
log("Main Remote Database");
|
||||
log(mr);
|
||||
}
|
||||
|
||||
if (!(syncKey in syncStat)) {
|
||||
syncStat[syncKey] = "now";
|
||||
}
|
||||
|
||||
try {
|
||||
await sanityCheck();
|
||||
} catch (ex) {
|
||||
log("Error on checking database");
|
||||
log(ex);
|
||||
process.exit(-1);
|
||||
}
|
||||
log("Start Database watching");
|
||||
|
||||
function openConnection(e: connectConfig, auto_reconnect: boolean) {
|
||||
Logger(`Connecting ${e.syncKey} with auto_reconnect:${auto_reconnect}`);
|
||||
e.fromDB
|
||||
.changes({
|
||||
live: true,
|
||||
include_docs: true,
|
||||
style: "all_docs",
|
||||
since: syncStat[syncKey],
|
||||
filter: (doc, _) => {
|
||||
return doc._id.startsWith(e.fromPrefix) && isVaildDoc(doc._id);
|
||||
},
|
||||
})
|
||||
.on("change", async function (change) {
|
||||
if (change.doc?._id.startsWith(e.fromPrefix) && isVaildDoc(change.doc._id)) {
|
||||
let x = await transferDoc(e.syncKey, e.fromDB, change.doc, e.fromPrefix, e.passphrase, exportPath);
|
||||
if (x) {
|
||||
syncStat[syncKey] = change.seq + "";
|
||||
triggerSaveStat();
|
||||
triggerProcessor(processor);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on("error", function (err) {
|
||||
Logger("Error");
|
||||
Logger(err);
|
||||
if (auto_reconnect) {
|
||||
Logger("Performing auto_reconnect");
|
||||
setTimeout(() => {
|
||||
openConnection(e, auto_reconnect);
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.on("complete", (result) => {
|
||||
Logger("Connection completed");
|
||||
Logger(result);
|
||||
if (auto_reconnect) {
|
||||
Logger("Performing auto_reconnect");
|
||||
setTimeout(() => {
|
||||
openConnection(e, auto_reconnect);
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log("start vault watching");
|
||||
const watcher = chokidar.watch(config.local.path, { ignoreInitial: !config.local.initialScan });
|
||||
const vaultPath = path.posix.normalize(config.local.path);
|
||||
|
||||
const db_add = async (pathSrc: string, stat: Stats) => {
|
||||
const id = serverPath + path2unix(path.relative(path.resolve(vaultPath), path.resolve(pathSrc)));
|
||||
const docId = id.startsWith("_") ? "/" + id : id;
|
||||
try {
|
||||
let doc = (await remote.get(docId)) as NewEntry;
|
||||
if (doc.mtime) {
|
||||
const mtime_srv = ~~(doc.mtime / 1000);
|
||||
const mtime_loc = ~~(stat.mtime.getTime() / 1000);
|
||||
if (mtime_loc == mtime_srv) {
|
||||
log(`Should be not modified on ${pathSrc}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (ex: any) {
|
||||
if (ex.status && ex.status == 404) {
|
||||
// NO OP.
|
||||
} else {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
let content = "";
|
||||
let datatype: "newnote" | "plain" = "newnote";
|
||||
const d = await fs.readFile(pathSrc);
|
||||
if (!isPlainText(pathSrc)) {
|
||||
// const contentBin = await this.app.vault.readBinary(file);
|
||||
// content = await arrayBufferToBase64(contentBin);
|
||||
content = d.toString("base64");
|
||||
datatype = "newnote";
|
||||
} else {
|
||||
content = d.toString();
|
||||
datatype = "plain";
|
||||
}
|
||||
const newNote: LoadedEntry = {
|
||||
_id: docId,
|
||||
children: [],
|
||||
ctime: stat.ctime.getTime(),
|
||||
mtime: stat.mtime.getTime(),
|
||||
size: stat.size,
|
||||
datatype: datatype,
|
||||
data: content,
|
||||
// type: "plain",
|
||||
};
|
||||
await putDBEntry(newNote, conf.passphrase, remote as PouchDB.Database<NewEntry | PlainEntry | Entry | EntryLeaf>);
|
||||
};
|
||||
const db_delete = async (pathSrc: string) => {
|
||||
const id = serverPath + path2unix(path.relative(path.resolve(vaultPath), path.resolve(pathSrc)));
|
||||
const docId = id.startsWith("_") ? "/" + id : id;
|
||||
try {
|
||||
let oldNote: any = await remote.get(docId);
|
||||
oldNote._deleted = true;
|
||||
await remote.put(oldNote);
|
||||
addTouchedFile(pathSrc, 0);
|
||||
} catch (ex: any) {
|
||||
if (ex.status && ex.status == 404) {
|
||||
// NO OP.
|
||||
} else {
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
};
|
||||
watcher.on("change", async (pathSrc: string, stat: Stats) => {
|
||||
const filePath = pathSrc;
|
||||
log(`Detected:change:${filePath}`);
|
||||
const mtime = ~~(stat.mtime.getTime() / 1000);
|
||||
if (isTouchedFile(filePath, mtime)) {
|
||||
log("Self-detect");
|
||||
return;
|
||||
}
|
||||
addTouchedFile(pathSrc, mtime);
|
||||
await db_add(pathSrc, stat);
|
||||
});
|
||||
watcher.on("unlink", async (pathSrc: string, stat: Stats) => {
|
||||
const filePath = pathSrc;
|
||||
log(`Detected:delete:${filePath}`);
|
||||
if (isTouchedFile(filePath, 0)) {
|
||||
log("self-detect");
|
||||
}
|
||||
await db_delete(pathSrc);
|
||||
});
|
||||
watcher.on("add", async (pathSrc: string, stat: Stats) => {
|
||||
const filePath = pathSrc;
|
||||
log(`Detected:created:${filePath}`);
|
||||
const mtime = ~~(stat.mtime.getTime() / 1000);
|
||||
if (isTouchedFile(filePath, mtime)) {
|
||||
log("Self-detect");
|
||||
return;
|
||||
}
|
||||
addTouchedFile(pathSrc, mtime);
|
||||
await db_add(pathSrc, stat);
|
||||
|
||||
// this.watchVaultChange(path, stat);
|
||||
});
|
||||
let conf: connectConfig = {
|
||||
syncKey: syncKey,
|
||||
fromDB: remote,
|
||||
fromPrefix: serverPath,
|
||||
passphrase: serverAuth.passphrase,
|
||||
};
|
||||
openConnection(conf, config.auto_reconnect ?? false);
|
||||
}
|
||||
|
||||
async function getChildren(children: string[], db: PouchDB.Database) {
|
||||
let items = await db.allDocs({ include_docs: true, keys: [...children] });
|
||||
return items.rows.map((e) => e.doc);
|
||||
}
|
||||
|
||||
function isVaildDoc(id: string): boolean {
|
||||
if (id == "obsydian_livesync_version") return false;
|
||||
if (id.indexOf(":") !== -1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function transferDoc(syncKey: string, fromDB: PouchDB.Database, fromDoc: PouchDB.Core.ExistingDocument<PouchDB.Core.ChangesMeta>, fromPrefix: string, passphrase: string, exportPath: string): Promise<boolean> {
|
||||
const docKey = `${syncKey}: ${fromDoc._id} (${fromDoc._rev})`;
|
||||
while (running[syncKey]) {
|
||||
await delay(100);
|
||||
}
|
||||
try {
|
||||
running[syncKey] = true;
|
||||
if (isKnownFile(syncKey, fromDoc._id, fromDoc._rev)) {
|
||||
return true;
|
||||
}
|
||||
log(`doc:${docKey} begin Transfer`);
|
||||
let continue_count = 3;
|
||||
try {
|
||||
const docName = fromDoc._id.substring(fromPrefix.length);
|
||||
let sendDoc: PouchDB.Core.ExistingDocument<PouchDB.Core.ChangesMeta> & { children?: string[]; type?: string; mtime?: number } = { ...fromDoc, _id: docName.startsWith("_") ? "/" + docName : docName };
|
||||
let retry = false;
|
||||
const userpasswordHash = h32Raw(new TextEncoder().encode(passphrase));
|
||||
do {
|
||||
if (retry) {
|
||||
continue_count--;
|
||||
if (continue_count == 0) {
|
||||
log(`doc:${docKey} retry failed`);
|
||||
return false;
|
||||
}
|
||||
await delay(1500);
|
||||
}
|
||||
if (sendDoc._deleted && exportPath != "") {
|
||||
const writePath = path.join(exportPath, docName);
|
||||
log(`doc:${docKey}: Deleted, so delete from ${writePath}`);
|
||||
addTouchedFile(writePath, 0);
|
||||
await fs.unlink(writePath);
|
||||
}
|
||||
retry = false;
|
||||
if (!sendDoc.children) {
|
||||
log(`doc:${docKey}: Warning! document doesn't have chunks, skipped`);
|
||||
return false;
|
||||
}
|
||||
let cx = sendDoc.children;
|
||||
let children = await getChildren(cx, fromDB);
|
||||
|
||||
if (children.includes(undefined)) {
|
||||
log(`doc:${docKey}: Warning! there's missing chunks, skipped`);
|
||||
return false;
|
||||
} else {
|
||||
children = children.filter((e) => !!e);
|
||||
for (const v of children) {
|
||||
delete (v as any)?._rev;
|
||||
}
|
||||
|
||||
let decrypted_children =
|
||||
passphrase == ""
|
||||
? children
|
||||
: (
|
||||
await Promise.allSettled(
|
||||
children.map(async (e: any) => {
|
||||
e.data = await decrypt(e.data, passphrase);
|
||||
return e;
|
||||
})
|
||||
)
|
||||
).map((e) => (e.status == "fulfilled" ? e.value : null));
|
||||
// If exporting is enabled, write contents to the real file.
|
||||
if (exportPath != "" && !sendDoc._deleted) {
|
||||
const writePath = path.join(exportPath, docName);
|
||||
const dirName = path.dirname(writePath);
|
||||
log(`doc:${docKey}: Exporting to ${writePath}`);
|
||||
await fs.mkdir(dirName, { recursive: true });
|
||||
const dt_plain = decrypted_children.map((e) => e.data).join("");
|
||||
const mtime = sendDoc.mtime ?? new Date().getTime();
|
||||
const tmtime = ~~(mtime / 1000);
|
||||
addTouchedFile(writePath, tmtime);
|
||||
if (sendDoc.type == "plain") {
|
||||
await fs.writeFile(writePath, dt_plain);
|
||||
await fs.utimes(writePath, tmtime, tmtime);
|
||||
} else {
|
||||
const dt_bin = Buffer.from(dt_plain, "base64");
|
||||
await fs.writeFile(writePath, dt_bin, { encoding: "binary" });
|
||||
await fs.utimes(writePath, tmtime, tmtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (retry);
|
||||
} catch (ex) {
|
||||
log("Exception on transfer doc");
|
||||
log(ex);
|
||||
}
|
||||
} finally {
|
||||
running[syncKey] = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
main().then((_) => {});
|
||||
13
src/logger.ts
Normal file
13
src/logger.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { LOG_LEVEL } from "./types";
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
export let Logger: (message: any, level?: LOG_LEVEL) => void = (message, _) => {
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const messagecontent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
|
||||
const newmessage = timestamp + "->" + messagecontent;
|
||||
console.log(newmessage);
|
||||
};
|
||||
|
||||
export function setLogger(loggerFun: (message: any, level?: LOG_LEVEL) => Promise<void>) {
|
||||
Logger = loggerFun;
|
||||
}
|
||||
7
src/pouchdb.js
Normal file
7
src/pouchdb.js
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
const pouchdb_src = require("pouchdb-core").plugin(require("pouchdb-adapter-leveldb")).plugin(require("pouchdb-adapter-http")).plugin(require("pouchdb-mapreduce")).plugin(require("pouchdb-replication"));
|
||||
const PouchDB = pouchdb_src;
|
||||
/**
|
||||
* @type {PouchDB.Static<>}
|
||||
*/
|
||||
export { PouchDB };
|
||||
93
src/types.ts
Normal file
93
src/types.ts
Normal file
@ -0,0 +1,93 @@
|
||||
export const LOG_LEVEL = {
|
||||
VERBOSE: 1,
|
||||
INFO: 10,
|
||||
NOTICE: 100,
|
||||
URGENT: 1000,
|
||||
} as const;
|
||||
export type LOG_LEVEL = typeof LOG_LEVEL[keyof typeof LOG_LEVEL];
|
||||
|
||||
export interface config {
|
||||
uri: string;
|
||||
auth: {
|
||||
username: string;
|
||||
password: string;
|
||||
passphrase: string;
|
||||
};
|
||||
path: string;
|
||||
}
|
||||
export interface localConfig {
|
||||
path: string;
|
||||
initialScan: boolean;
|
||||
processor: string;
|
||||
}
|
||||
|
||||
export interface eachConf {
|
||||
server: config;
|
||||
local: localConfig;
|
||||
auto_reconnect?: boolean;
|
||||
}
|
||||
|
||||
export interface configFile {
|
||||
[key: string]: eachConf;
|
||||
}
|
||||
export interface connectConfig {
|
||||
syncKey: string;
|
||||
fromDB: PouchDB.Database;
|
||||
fromPrefix: string;
|
||||
passphrase: string;
|
||||
}
|
||||
|
||||
//---LiveSync's data
|
||||
|
||||
export const MAX_DOC_SIZE = 1000; // for .md file, but if delimiters exists. use that before.
|
||||
export const MAX_DOC_SIZE_BIN = 102400; // 100kb
|
||||
|
||||
export interface EntryLeaf {
|
||||
_id: string;
|
||||
data: string;
|
||||
_deleted?: boolean;
|
||||
type: "leaf";
|
||||
_rev?: string;
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
_id: string;
|
||||
data: string;
|
||||
_rev?: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
size: number;
|
||||
_deleted?: boolean;
|
||||
_conflicts?: string[];
|
||||
type?: "notes";
|
||||
}
|
||||
|
||||
export interface NewEntry {
|
||||
_id: string;
|
||||
children: string[];
|
||||
_rev?: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
size: number;
|
||||
_deleted?: boolean;
|
||||
_conflicts?: string[];
|
||||
NewNote: true;
|
||||
type: "newnote";
|
||||
}
|
||||
export interface PlainEntry {
|
||||
_id: string;
|
||||
children: string[];
|
||||
_rev?: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
size: number;
|
||||
_deleted?: boolean;
|
||||
NewNote: true;
|
||||
_conflicts?: string[];
|
||||
type: "plain";
|
||||
}
|
||||
|
||||
export type LoadedEntry = Entry & {
|
||||
children: string[];
|
||||
datatype: "plain" | "newnote";
|
||||
};
|
||||
37
src/util.ts
Normal file
37
src/util.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import path from "path";
|
||||
|
||||
export function isPlainText(filename: string): boolean {
|
||||
if (filename.endsWith(".md")) return true;
|
||||
if (filename.endsWith(".txt")) return true;
|
||||
if (filename.endsWith(".svg")) return true;
|
||||
if (filename.endsWith(".html")) return true;
|
||||
if (filename.endsWith(".csv")) return true;
|
||||
if (filename.endsWith(".css")) return true;
|
||||
if (filename.endsWith(".js")) return true;
|
||||
if (filename.endsWith(".xml")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const path2unix = (pathStr: string) => pathStr.split(path.sep).join(path.posix.sep);
|
||||
|
||||
let known_files: string[] = [];
|
||||
let touchedFile: string[] = [];
|
||||
export function addKnownFile(syncKey: string, id: string, rev: string) {
|
||||
known_files.push(`${syncKey}-${id}-${rev}`);
|
||||
known_files = known_files.slice(-50);
|
||||
}
|
||||
export function isKnownFile(syncKey: string, id: string, rev: string) {
|
||||
return known_files.indexOf(`${syncKey}-${id}-${rev}`) !== -1;
|
||||
}
|
||||
export function addTouchedFile(pathSrc: string, mtime: number) {
|
||||
const targetFile = path.resolve(pathSrc);
|
||||
const key = `${targetFile}-${~~(mtime / 10)}`;
|
||||
touchedFile.push(key);
|
||||
touchedFile = touchedFile.slice(-50);
|
||||
}
|
||||
export function isTouchedFile(pathSrc: string, mtime: number) {
|
||||
const targetFile = path.resolve(pathSrc);
|
||||
const key = `${targetFile}-${~~(mtime / 10)}`;
|
||||
return touchedFile.indexOf(key) !== -1;
|
||||
}
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@tsconfig/node16/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "src/pouchdb.js"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user