411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
import PouchDB from 'pouchdb';
|
|
import {DiagramEntity, DiagramEventType} from "../../diagram/types/diagramEntity";
|
|
import {Observable} from "@babylonjs/core";
|
|
import axios from "axios";
|
|
import {DiagramManager} from "../../diagram/diagramManager";
|
|
import log, {Logger} from "loglevel";
|
|
import {ascii_to_hex} from "../functions/hexFunctions";
|
|
import {getPath} from "../../util/functions/getPath";
|
|
import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserverMask";
|
|
import {syncDoc} from "../functions/syncDoc";
|
|
import {checkDb} from "../functions/checkDb";
|
|
import {UserModelType} from "../../users/userTypes";
|
|
import {getMe} from "../../util/me";
|
|
import {Encryption} from "../encryption";
|
|
import {Presence} from "../presence";
|
|
|
|
type PasswordEvent = {
|
|
detail: string;
|
|
}
|
|
type PasswordEvent2 = {
|
|
password: string;
|
|
id: string;
|
|
encrypted: boolean;
|
|
}
|
|
export class PouchdbPersistenceManager {
|
|
private _logger: Logger = log.getLogger('PouchdbPersistenceManager');
|
|
onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
|
onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
|
private db: PouchDB;
|
|
private remote: PouchDB;
|
|
private user: string;
|
|
private _encryption = new Encryption();
|
|
private _encKey = null;
|
|
private _diagramManager: DiagramManager;
|
|
private _salt: string;
|
|
private _failCount: number = 0;
|
|
|
|
constructor() {
|
|
document.addEventListener('passwordset', (evt) => {
|
|
this._encKey = ((evt as unknown) as PasswordEvent).detail || null;
|
|
if (this._encKey && typeof (this._encKey) == 'string') {
|
|
this.initialize().then(() => {
|
|
this._logger.debug('Initialized');
|
|
});
|
|
}
|
|
this._logger.debug(evt);
|
|
});
|
|
document.addEventListener('dbcreated', (evt: CustomEvent) => {
|
|
const detail = ((evt.detail as unknown) as PasswordEvent2);
|
|
const password = detail.password;
|
|
const id = detail.id;
|
|
if (detail.encrypted) {
|
|
this._encKey = password;
|
|
} else {
|
|
this._encKey = null;
|
|
}
|
|
//this._encKey = password;
|
|
this.db = new PouchDB(detail.id, {auto_compaction: true});
|
|
this.setupMetadata(id).then(() => {
|
|
document.location.href = '/db/' + id;
|
|
}).catch((err) => {
|
|
console.log(err);
|
|
})
|
|
|
|
});
|
|
}
|
|
|
|
public setDiagramManager(diagramManager: DiagramManager) {
|
|
this._diagramManager = diagramManager;
|
|
diagramManager.onDiagramEventObservable.add((evt) => {
|
|
this._logger.debug(evt);
|
|
if (!evt?.entity) {
|
|
this._logger.warn('no entity');
|
|
return;
|
|
}
|
|
if (!evt?.entity?.id) {
|
|
this._logger.warn('no entity id');
|
|
return;
|
|
}
|
|
switch (evt.type) {
|
|
case DiagramEventType.REMOVE:
|
|
this.remove(evt.entity.id);
|
|
break;
|
|
case DiagramEventType.ADD:
|
|
case DiagramEventType.MODIFY:
|
|
case DiagramEventType.DROP:
|
|
this.upsert(evt.entity);
|
|
break;
|
|
default:
|
|
this._logger.warn('unknown diagram event type', evt);
|
|
}
|
|
}, DiagramEventObserverMask.TO_DB);
|
|
|
|
this.onDBEntityUpdateObservable.add((evt) => {
|
|
this._logger.debug(evt);
|
|
if (evt.id != 'metadata' && evt.type != 'user') {
|
|
diagramManager.onDiagramEventObservable.notifyObservers({
|
|
type: DiagramEventType.ADD,
|
|
entity: evt
|
|
}, DiagramEventObserverMask.FROM_DB);
|
|
} else {
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
this.onDBEntityRemoveObservable.add((entity) => {
|
|
this._logger.debug(entity);
|
|
diagramManager.onDiagramEventObservable.notifyObservers(
|
|
{type: DiagramEventType.REMOVE, entity: entity}, DiagramEventObserverMask.FROM_DB);
|
|
});
|
|
}
|
|
|
|
public async remove(id: string) {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
try {
|
|
const doc = await this.db.get(id);
|
|
this.db.remove(doc);
|
|
} catch (err) {
|
|
this._logger.error(err);
|
|
}
|
|
}
|
|
|
|
public async upsert(entity: DiagramEntity) {
|
|
if (!entity) {
|
|
return;
|
|
}
|
|
if (entity.template == '#image-template' && !entity.image) {
|
|
this._logger.error('no image data', entity);
|
|
return;
|
|
}
|
|
|
|
if (this._encKey && !this._encryption.ready) {
|
|
await this._encryption.setPassword(this._encKey);
|
|
}
|
|
try {
|
|
const doc = await this.db.get(entity.id, {conflicts: true, include_docs: true});
|
|
if (doc && doc._conflicts) {
|
|
this._logger.warn('CONFLICTS!', doc._conflicts);
|
|
}
|
|
if (this._encKey) {
|
|
if (!doc.encrypted) {
|
|
this._logger.warn("current local doc is not encrypted, encrypting");
|
|
}
|
|
await this._encryption.encryptObject(entity);
|
|
const newDoc = {
|
|
_id: doc._id,
|
|
_rev: doc._rev,
|
|
encrypted: this._encryption.getEncrypted()
|
|
}
|
|
this.db.put(newDoc)
|
|
} else {
|
|
if (doc) {
|
|
if (doc.encrypted) {
|
|
this._logger.error("current local doc is encrypted, but encryption key is missing... saving in plaintext");
|
|
}
|
|
const newDoc = {_id: doc._id, _rev: doc._rev, ...entity};
|
|
this.db.put(newDoc);
|
|
} else {
|
|
this.db.put({_id: entity.id, ...entity});
|
|
}
|
|
}
|
|
|
|
} catch (err) {
|
|
if (err.status == 404) {
|
|
try {
|
|
if (this._encKey) {
|
|
if (!this._encryption.ready) {
|
|
this._logger.error('Encryption not ready, there is a potential problem when this happens, we will generate a new salt which may cause data loss and/or slowness');
|
|
await this._encryption.setPassword(this._encKey);
|
|
}
|
|
await this._encryption.encryptObject(entity);
|
|
const newDoc = {
|
|
_id: entity.id,
|
|
encrypted: this._encryption.getEncrypted()
|
|
}
|
|
this.db.put(newDoc);
|
|
} else {
|
|
this._logger.info('no encryption key, saving in plaintext');
|
|
const newEntity = {_id: entity.id, ...entity};
|
|
this.db.put(newEntity);
|
|
}
|
|
} catch (err2) {
|
|
this._logger.error("Unable to save document");
|
|
this._logger.error(err2);
|
|
}
|
|
} else {
|
|
this._logger.error("Unknown error with document get from db");
|
|
this._logger.error(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async initialize() {
|
|
if (!await this.initLocal()) {
|
|
return;
|
|
}
|
|
await this.sendLocalDataToScene();
|
|
}
|
|
|
|
private async setupMetadata(current: string): Promise<boolean> {
|
|
try {
|
|
const doc = await this.db.get('metadata');
|
|
if (doc.encrypted) {
|
|
if (!this._salt && doc.encrypted.salt) {
|
|
this._logger.warn('Missing Salt');
|
|
this._salt = doc.encrypted.salt;
|
|
}
|
|
if (!this._encKey) {
|
|
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
|
|
document.dispatchEvent(promptPassword);
|
|
return false;
|
|
}
|
|
if (!this._encryption.ready) {
|
|
this._logger.warn("Encryption not ready, setting password");
|
|
await this._encryption.setPassword(this._encKey, doc.encrypted.salt);
|
|
}
|
|
const decrypted = await this._encryption.decryptToObject(doc.encrypted.encrypted, doc.encrypted.iv);
|
|
if (decrypted.friendly) {
|
|
this._logger.info("Storing Document friendly name in local storage, decrypted");
|
|
localStorage.setItem(current, decrypted.friendly);
|
|
}
|
|
} else {
|
|
if (doc && doc.friendly) {
|
|
this._logger.info("Storing Document friendly name in local storage");
|
|
localStorage.setItem(current, doc.friendly);
|
|
this._encKey = null;
|
|
}
|
|
if (doc && doc.camera) {
|
|
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err.status == 404) {
|
|
this._logger.debug('no metadata found');
|
|
const friendly = localStorage.getItem(current);
|
|
if (friendly) {
|
|
if (this._encKey) {
|
|
if (!this._encryption.ready) {
|
|
await this._encryption.setPassword(this._encKey);
|
|
}
|
|
await this._encryption.encryptObject({friendly: friendly});
|
|
await this.db.put({_id: 'metadata', id: 'metadata', encrypted: this._encryption.getEncrypted()})
|
|
} else {
|
|
this._logger.debug('local friendly name found ', friendly, ' setting metadata');
|
|
const newDoc = {_id: 'metadata', id: 'metadata', friendly: friendly};
|
|
await this.db.put(newDoc);
|
|
}
|
|
} else {
|
|
this._logger.warn('no friendly name found');
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private async initLocal(): Promise<boolean> {
|
|
try {
|
|
|
|
let sync = false;
|
|
let current = getPath();
|
|
if (current && current != 'localdb') {
|
|
sync = true;
|
|
} else {
|
|
current = 'localdb';
|
|
}
|
|
this.db = new PouchDB(current, {auto_compaction: true});
|
|
//await this.db.compact();
|
|
if (sync) {
|
|
if (await this.setupMetadata(current)) {
|
|
await this.beginSync(current);
|
|
}
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
this._logger.error(err);
|
|
this._logger.error('cannot initialize pouchdb for sync');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async sendLocalDataToScene() {
|
|
let salt = null;
|
|
const clear = localStorage.getItem('clearLocal');
|
|
try {
|
|
const all = await this.db.allDocs({include_docs: true});
|
|
for (const dbEntity of all.rows) {
|
|
this._logger.debug(dbEntity.doc);
|
|
if (clear) {
|
|
this.remove(dbEntity.id);
|
|
} else {
|
|
if (dbEntity.doc.encrypted) {
|
|
if (!salt || salt != dbEntity.doc.encrypted.salt) {
|
|
await this._encryption.setPassword(this._encKey, dbEntity.doc.encrypted.salt);
|
|
salt = dbEntity.doc.encrypted.salt;
|
|
}
|
|
const decrypted = await this._encryption.decryptToObject(dbEntity.doc.encrypted.encrypted, dbEntity.doc.encrypted.iv);
|
|
|
|
if (decrypted.id != 'metadata') {
|
|
this.onDBEntityUpdateObservable.notifyObservers(decrypted, DiagramEventObserverMask.FROM_DB);
|
|
}
|
|
|
|
} else {
|
|
|
|
if (dbEntity.id != 'metadata') {
|
|
this.onDBEntityUpdateObservable.notifyObservers(dbEntity.doc, DiagramEventObserverMask.FROM_DB);
|
|
}
|
|
|
|
|
|
}
|
|
}
|
|
if (clear) {
|
|
localStorage.removeItem('clearLocal');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
switch (err.message) {
|
|
case 'WebCrypto_DecryptionFailure: ':
|
|
case 'Invalid data type!':
|
|
this._failCount++;
|
|
if (this._failCount < 5) {
|
|
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
|
|
document.dispatchEvent(promptPassword);
|
|
} else {
|
|
this._logger.error('Too many decryption failures, Ignoring... This may compromise your data security');
|
|
window.alert('Too many decryption failures, Ignoring... This may compromise your data security');
|
|
}
|
|
}
|
|
this._logger.error(err);
|
|
}
|
|
}
|
|
|
|
private async beginSync(localName: string) {
|
|
try {
|
|
const userHex = ascii_to_hex(localName);
|
|
const remoteDbName = 'userdb-' + userHex;
|
|
const remoteUserName = localName;
|
|
const password = this._encKey || localName;
|
|
|
|
if (await checkDb(localName, remoteDbName, password) == false) {
|
|
return;
|
|
}
|
|
|
|
const userEndpoint: string = import.meta.env.VITE_USER_ENDPOINT
|
|
this._logger.debug(userEndpoint);
|
|
this._logger.debug(remoteDbName);
|
|
const target = await axios.get(userEndpoint);
|
|
if (target.status != 200) {
|
|
this._logger.warn(target.statusText);
|
|
return;
|
|
}
|
|
if (target.data && target.data.userCtx) {
|
|
if (!target.data.userCtx.name || target.data.userCtx.name != remoteUserName) {
|
|
try {
|
|
const buildTarget = await axios.post(userEndpoint,
|
|
{username: remoteUserName, password: password});
|
|
if (buildTarget.status != 200) {
|
|
this._logger.error(buildTarget.statusText);
|
|
return;
|
|
} else {
|
|
this.user = buildTarget.data.userCtx;
|
|
this._logger.debug(this.user);
|
|
}
|
|
} catch (err) {
|
|
if (err.response && err.response.status == 401) {
|
|
this._logger.warn(err);
|
|
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
|
|
document.dispatchEvent(promptPassword);
|
|
}
|
|
|
|
// } else {
|
|
this._logger.error(err);
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
const remoteEndpoint: string = import.meta.env.VITE_SYNCDB_ENDPOINT;
|
|
this._logger.debug(remoteEndpoint + remoteDbName);
|
|
this.remote = new PouchDB(remoteEndpoint + remoteDbName,
|
|
{auth: {username: remoteUserName, password: password}, skip_setup: true});
|
|
const dbInfo = await this.remote.info();
|
|
this._logger.debug(dbInfo);
|
|
const presence: Presence = new Presence(getMe(), remoteDbName);
|
|
this._diagramManager.onUserEventObservable.add((user: UserModelType) => {
|
|
//this._logger.debug(user);
|
|
presence.sendUser(user);
|
|
}, -1, false, this);
|
|
this.db.sync(this.remote, {live: true, retry: true})
|
|
.on('change', (info) => {
|
|
syncDoc(info, this.onDBEntityRemoveObservable, this.onDBEntityUpdateObservable, this._encryption, this._encKey);
|
|
})
|
|
.on('active', (info) => {
|
|
this._logger.debug('sync active', info)
|
|
})
|
|
.on('paused', (info) => {
|
|
this._logger.debug('sync paused', info)
|
|
})
|
|
.on('error', (err) => {
|
|
this._logger.error('sync error', err)
|
|
});
|
|
} catch (err) {
|
|
this._logger.error(err);
|
|
}
|
|
}
|
|
}
|
|
|