import Observable from "./helper/observable"; import * as config from "../config.json" import Lock from "./helper/lock" import SecureFile, { IFile } from "@hibas123/secure-file-wrapper"; import * as b64 from "./helper/base64" export class HttpError extends Error { constructor(public status: number, public statusText: string) { super(statusText); console.log(statusText); } } export enum MessageType { INFO, WARNING, ERROR } // const newSymbol = Symbol("Symbol for new Notes") export interface Note { _id: string; folder: string; time: Date; } interface DBNote extends Note { __value: Uint8Array; preview: Uint8Array; } export interface BaseNote extends Note { preview: string; } export interface ViewNote extends BaseNote { __value: string; } // import * as AES from "crypto-js/aes" import * as aesjs from "aes-js"; // import { } from "js-sha512" import { sha256 } from "js-sha256" import clonedeep = require("lodash.clonedeep"); import * as uuidv4 from "uuid/v4" import IDB from "./helper/indexeddb"; import { Transaction } from "idb"; console.log(aesjs) const Encoder = new TextEncoder(); const Decoder = new TextDecoder(); enum OpLogType { CREATE, CHANGE, DELETE } interface OpLog { /** * Type of operation */ type: OpLogType; /** * The value */ values: { value: Uint8Array, preview: Uint8Array }; /** * Date of change */ date: Date; } export type VaultList = { name: string, encrypted: boolean, id: string }[]; export interface IVault { name: string; id: string; encrypted: boolean; getAllNotes(): Promise; searchNote(term: string): Promise newNote(): ViewNote; saveNote(note: ViewNote): Promise; getNote(id: string): Promise; deleteNote(id: string): Promise; } class NotesProvider { private notesObservableServer = new Observable(true) public notesObservable = this.notesObservableServer.getPublicApi() public messageObservableServer = new Observable<{ message: string, type: MessageType }>(false) public messageObservable = this.messageObservableServer.getPublicApi() public sendErrorMessage(error: Error) { this.messageObservableServer.send({ message: error.message, type: MessageType.ERROR }) } private database = new IDB("notes", ["notes", "oplog"]); private noteDB = this.database.getStore("notes"); private oplogDB = this.database.getStore<{ id: string, logs: OpLog[] }>("oplog"); private vaultKeys = new Map(); public apiLock = new Lock(); public apiLockRls = this.apiLock.getLock() private syncLock = new Lock(); private _secureFile: SecureFile; private generalEncryption: Uint8Array = undefined; loginRequired() { return !localStorage.getItem("refreshtoken") || !this.generalEncryption; } login() { window.location.href = `${config.auth_server}/auth?client_id=${config.client_id}&scope=${config.permission}&redirect_uri=${encodeURIComponent(config.callback_url)}&response_type=code` } async getToken(code: string) { let req = await fetch(`${config.auth_server}/api/oauth/refresh?grant_type=authorization_code&client_id=${config.client_id}&code=${code}`); let res = await req.json(); if (!res.error) { localStorage.setItem("refreshtoken", res.token); let kb = this.passwordToKey(res.profile.enc_key); localStorage.setItem("enc_key", b64.encode(kb)); this.generalEncryption = kb } else { return "Invalid Code" } } constructor(public readonly baseurl = "") { this._secureFile = new SecureFile(config.secure_file_server); this._secureFile.jwtObservable.subscribe(async ([callback]) => { let jwt = await this.getJWT(); callback(jwt); }) let key = localStorage.getItem("enc_key"); if (key) { this.generalEncryption = b64.decode(key) } } public start() { return this.apiLockRls.then(lock => lock.release()).then(() => { this.sync() }) } private async getJWT() { let lock = await this.apiLock.getLock() try { console.log("Getting JWT"); let req = await fetch(config.auth_server + "/api/oauth/jwt?refreshtoken=" + localStorage.getItem("refreshtoken")); if (req.status !== 200) { this.messageObservableServer.send({ message: "offline", type: MessageType.INFO }); throw new Error("Need login!") } let res = await req.json(); if (res.error) { console.log("Refresh token invalid, forward to login") localStorage.removeItem("refreshtoken"); this.login() throw new Error("Need login!") } else { return res.token } } finally { lock.release() } } async Logout() { localStorage.removeItem("refreshtoken"); window.location.reload(); } private async sync() { const lock = await this.syncLock.getLock() const log = (...message: any[]) => { console.log("[SYNC]: ", ...message) } log("Start") try { log("Fetching"); let [remotes, locals, oplogs] = await Promise.all([this._secureFile.list(), this.noteDB.getAll(), this.oplogDB.getAll()]); log("Fetched"); // Create sync pairs (remote & local) log("Building pairs"); let pairs: { local: DBNote, remote: IFile, id: string, oplog: OpLog[] }[] = []; remotes.map(r => { let lIdx = locals.findIndex(e => e._id === r._id); let l: DBNote = undefined; if (lIdx >= 0) { l = locals[lIdx]; locals.splice(lIdx, 1); } let oIdx = oplogs.findIndex(e => e.id === r._id); let oplog: OpLog[]; if (oIdx >= 0) { oplog = oplogs[oIdx].logs; oplogs.splice(oIdx, 1); } pairs.push({ remote: r, local: l, oplog, id: r._id }) }) locals.forEach(l => { let oIdx = oplogs.findIndex(e => e.id === l._id); let oplog: OpLog[] = undefined; if (oIdx >= 0) { oplog = oplogs[oIdx].logs; if (oplog.length <= 0) oplog = undefined; oplogs.splice(oIdx, 1); } pairs.push({ remote: undefined, local: l, oplog, id: l._id }) }) oplogs.forEach(oplog => { if (!oplog) return; if (oplog.logs.length > 0) pairs.push({ remote: undefined, local: undefined, oplog: oplog.logs, id: oplog.id }) }) log("Pairs builded"); log("Start inspection"); for (let { local, remote, oplog, id } of pairs) { const apply = async (old = false) => { log("Apply OPLOG to", id); for (let op of oplog) { switch (op.type) { case OpLogType.CHANGE: log(id, "REMOTE CHANGE"); await this._secureFile.update(id, op.values.value, b64.encode(op.values.preview), op.date, old); break; case OpLogType.DELETE: log(id, "REMOTE DELETE"); if (old) break; // if the deletion is old, just ignore await this._secureFile.delete(id) break; case OpLogType.CREATE: log(id, "REMOTE CREATE"); await this._secureFile.create( "", op.values.value, "binary", local.folder, b64.encode(op.values.preview), id, op.date ); break; } } } const create = async () => { log(id, "LOCAL CREATAE/UPDATE"); let value = await this._secureFile.get(id); let note: DBNote = { _id: remote._id, folder: remote.folder, preview: b64.decode(remote.active.preview), time: remote.active.time, __value: new Uint8Array(value) } await this.noteDB.set(id, note); } try { log(id, "LRO: ", !!local, !!remote, !!oplog) if (remote && !oplog) { if (local) { let old = remote.active.time.valueOf() > local.time.valueOf(); if (old) await create() else log(id, "DO NOTHING"); } else { await create() } } else if (!remote && local && !oplog) { // No local changes, but remote deleted log("LOCAL DELETE"); await this.noteDB.delete(id); } else if (!remote && oplog) { // Remote does not exist, but oplog, just apply all changes including possible delete await apply() } else if (remote && oplog) { let last = oplog[oplog.length - 1] let old = remote.active.time.valueOf() > last.date.valueOf(); // if (local) { //If local changes and remote exist if (old) await create() // Will recreate local entry await apply(old) // Will apply changes to remote // } // if (!local) { //If local ist deleted but remote exists check what is newer // if (old) // await create() // await apply(old) // } } else { log(id, "DO NOTHING"); } } catch (err) { console.error(err); this.messageObservableServer.send({ message: "Error syncing: " + id, type: MessageType.ERROR }) } await this.oplogDB.delete(id); } } finally { log("Finished") lock.release() } } public getVaultKey(vault_id: string) { let key = this.vaultKeys.get(vault_id); if (!key) { let lsk = localStorage.getItem("vault_" + vault_id); if (lsk) { key = b64.decode(lsk); this.vaultKeys.set(vault_id, key); } } return key; } public saveVaultKey(vault_id: string, key: Uint8Array, permanent?: boolean) { this.vaultKeys.set(vault_id, key); if (permanent) { localStorage.setItem("vault_" + vault_id, b64.encode(key)); } } public getVaults(): Promise { return this.noteDB.getAll() .then(notes => notes .filter(e => Decoder.decode(e.preview) === "__VAULT__") .map(e => { let value = e.__value; let encrypted = false; if (Decoder.decode(value) !== "__BASELINE__") encrypted = true; return { name: e.folder, encrypted, id: e._id } })); } public async createVault(name: string, key?: Uint8Array) { let vault: DBNote = { __value: this.encrypt("__BASELINE__", key), _id: uuidv4(), folder: name, preview: Encoder.encode("__VAULT__"), time: new Date() } let tx = this.database.transaction(); await Promise.all([ this.addop(vault._id, OpLogType.CREATE, { value: vault.__value, preview: vault.preview }, tx), this.noteDB.set(vault._id, vault) ]); } public async getVault(vault_id: string, key?: Uint8Array): Promise { let vault = await this.noteDB.get(vault_id); if (!vault) throw new Error("Vault not found!"); if (this.decrypt(vault.__value, key) !== "__BASELINE__") throw new Error("Invalid password!"); return new NotesProvider.Vault(vault, key) } public passwordToKey(password: string) { return new Uint8Array(sha256.arrayBuffer(password + config.client_id)) } private _encrypt(value: Uint8Array, key?: Uint8Array): Uint8Array { if (!key) return value; var aesCtr = new aesjs.ModeOfOperation.ctr(key); var encryptedBytes = aesCtr.encrypt(value); return new Uint8Array(encryptedBytes); } private encrypt(value: string, key?: Uint8Array): Uint8Array { let msg = this._encrypt(Encoder.encode(value), key) return new Uint8Array(this._encrypt(msg, this.generalEncryption)) } private _decrypt(value: ArrayBuffer, key?: Uint8Array): Uint8Array { if (!key) value; var aesCtr = new aesjs.ModeOfOperation.ctr(key); var decryptedBytes = aesCtr.decrypt(value); return new Uint8Array(decryptedBytes) } private decrypt(value: ArrayBuffer, key: Uint8Array): string { let msg = this._decrypt(value, key) return Decoder.decode(this._decrypt(msg, this.generalEncryption)) } async addop(note_id: string, type: OpLogType, values: { value: Uint8Array, preview: Uint8Array }, transaction?: Transaction) { let tx = transaction || Notes.oplogDB.transaction(); let oplog = await Notes.oplogDB.get(note_id, tx); if (!oplog) oplog = { logs: [], id: note_id }; oplog.logs.push({ date: new Date(), type, values }) await Notes.oplogDB.set(note_id, oplog, tx); } static Vault = class implements IVault { id: string; name: string; encrypted: boolean = false; constructor(private vault: Note, private key?: Uint8Array) { if (key) this.encrypted = true; this.id = vault._id; this.name = vault.folder; } private encrypt(data: string) { return Notes.encrypt(data, this.key); } private decrypt(data: ArrayBuffer) { return Notes.decrypt(data, this.key); } async getAllNotes() { return Notes.noteDB.getAll() .then(all => all .filter(e => e.folder === this.vault._id) .map(e => { let new_note = clonedeep(e) as BaseNote delete (new_note).__value new_note.preview = this.decrypt(e.preview) return new_note; })); } async searchNote(term: string) { let all = await this.getAllNotes(); return all.filter(e => e.preview.indexOf(term) >= 0) } newNote(): ViewNote { return { _id: uuidv4(), folder: this.vault._id, time: new Date(), __value: "", preview: "" } } async saveNote(note: ViewNote) { let lock = await Notes.syncLock.getLock(); const tx = Notes.database.transaction(Notes.noteDB, Notes.oplogDB); let old_note = await Notes.noteDB.get(note._id, tx); let new_note = clonedeep(note) as DBNote; new_note.__value = this.encrypt(note.__value) let [title, preview] = note.__value.split("\n"); if (preview) preview = "\n" + preview; else preview = "" new_note.preview = this.encrypt(title + preview) await Promise.all([ Notes.addop(note._id, !old_note ? OpLogType.CREATE : OpLogType.CHANGE, { value: new_note.__value, preview: new_note.preview }, tx), Notes.noteDB.set(note._id, new_note, tx) ]) lock.release(); } async getNote(id: string): Promise { let note = await Notes.noteDB.get(id); if (!note) return undefined; let new_note = clonedeep(note) as ViewNote; new_note.__value = this.decrypt(note.__value); return new_note; } async deleteNote(id: string) { let lock = await Notes.syncLock.getLock(); let tx = Notes.database.transaction(Notes.oplogDB, Notes.noteDB) await Promise.all([ Notes.addop(id, OpLogType.DELETE, null, tx), Notes.noteDB.delete(id, tx) ]) lock.release(); } } } const Notes = new NotesProvider() export default Notes; (window).api = Notes