import * as config from "../config.json" import { Lock, Observable } from "@hibas123/utils" 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); } } // 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 aesjs from "aes-js"; 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"; import Notifications, { MessageType } from "./notifications"; 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, date?: Date): Promise; getNote(id: string): Promise; deleteNote(id: string): Promise; } const awaitTimeout = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); class NotesProvider { private notesObservableServer = new Observable() public notesObservable = this.notesObservableServer.getPublicApi() private syncObservableServer = new Observable() /** * Will send false once finished and true on start */ public syncObservable = this.syncObservableServer.getPublicApi() 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; private syncedObservableServer = new Observable(); public syncedObservable = this.syncedObservableServer.getPublicApi(); public async isSynced() { return (await this.oplogDB.getAll()).length <= 0 } private _name; public get name() { return this._name; } 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); localStorage.setItem("name", res.profile.name); this._name = res.profile.name; 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) => { try { let jwt = await this.getJWT(); callback(null, jwt); } catch (err) { callback(err, null); } }) let key = localStorage.getItem("enc_key"); if (key) { this.generalEncryption = b64.decode(key) } this._name = localStorage.getItem("name"); } public async start() { const next = () => { setTimeout(() => { this.sync().then(() => next); }, 30000) } this.syncedObservableServer.send((await this.oplogDB.getAll()).length <= 0); let prs = this.apiLockRls.then(lock => lock.release()); prs.then(() => awaitTimeout(5000)).then(() => this.sync()).then(() => next()); return prs; } 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) { Notifications.sendNotification("offline", MessageType.INFO); throw new Error("Offline") } 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(); } async sync() { const lock = await this.syncLock.getLock() const log = (...message: any[]) => { console.log("[SYNC]: ", ...message) } this.syncObservableServer.send(true); 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"); let stats = { remote_change: 0, remote_delete: 0, remote_create: 0, local_change: 0, local_delete: 0, do_nothing: 0, error: 0 } 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"); stats.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"); stats.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"); stats.remote_create++; await this._secureFile.create( "", op.values.value, "binary", local.folder, b64.encode(op.values.preview), id, op.date ); break; } } } const localChange = (id: string) => { //TODO implement } const create = async () => { log(id, "LOCAL CREATAE/UPDATE"); stats.local_change++; 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); localChange(id); } 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 { stats.do_nothing++; log(id, "DO NOTHING"); } } else { await create() } } else if (!remote && local && !oplog) { // No local changes, but remote deleted log("LOCAL DELETE"); stats.local_delete++; await this.noteDB.delete(id); localChange(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 (old) await create() // Will recreate local entry await apply(old) // Will apply changes to remote } else { log(id, "DO NOTHING"); stats.do_nothing++; } } catch (err) { console.error(err); stats.error++; Notifications.sendNotification("Error syncing: " + id, MessageType.ERROR); } await this.oplogDB.delete(id); } log("Stats", stats); this.syncedObservableServer.send((await this.oplogDB.getAll()).length <= 0) } finally { log("Finished") lock.release() this.syncObservableServer.send(false); } } public forgetVaultKey(vault_id: string) { this.vaultKeys.delete(vault_id); localStorage.removeItem("vault_" + vault_id); } 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 (this.decrypt(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 }, vault.time, 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 async deleteVault(vault_id: string) { let vault = await this.noteDB.get(vault_id); if (!vault) throw new Error("Vault not found!"); let v = new NotesProvider.Vault(vault); await Promise.all((await v.getAllNotes()).map(note => this.delete(note._id))); await this.delete(v.id); // This can also delete a vault } 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) return new Uint8Array(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 }, date: Date, transaction?: Transaction) { let tx = transaction || this.oplogDB.transaction(); let oplog = await this.oplogDB.get(note_id, tx); if (!oplog) oplog = { logs: [], id: note_id }; oplog.logs.push({ date: date, type, values }) this.syncedObservableServer.send(false); await this.oplogDB.set(note_id, oplog, tx); } async delete(id: string) { let lock = await this.syncLock.getLock(); let tx = this.database.transaction(this.oplogDB, this.noteDB) await Promise.all([ this.addop(id, OpLogType.DELETE, null, new Date(), tx), this.noteDB.delete(id, tx) ]) lock.release(); } 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, date = new Date()) { 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).substr(0, 128)) new_note.time = date; await Promise.all([ Notes.addop(note._id, !old_note ? OpLogType.CREATE : OpLogType.CHANGE, { value: new_note.__value, preview: new_note.preview }, date, 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; } deleteNote(id: string) { return Notes.delete(id); } } } const Notes = new NotesProvider() export default Notes; (window).api = Notes