immersive2/src/integration/database/pouchdbPersistenceManager.ts

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);
}
}
}