diff --git a/web/database/database-module-provider.js b/web/database/database-module-provider.js index 050a5af86..a56203646 100644 --- a/web/database/database-module-provider.js +++ b/web/database/database-module-provider.js @@ -1,123 +1,126 @@ // @flow -import { DATABASE_WORKER_PATH, SQLJS_FILE_PATH } from './utils/constants.js'; +import { + DATABASE_WORKER_PATH, + DATABASE_MODULE_FILE_PATH, +} from './utils/constants.js'; import { isDesktopSafari, isSQLiteSupported } from './utils/db-utils.js'; import WorkerConnectionProxy from './utils/WorkerConnectionProxy.js'; import type { AppState } from '../redux/redux-setup.js'; import { workerRequestMessageTypes, type WorkerRequestMessage, type WorkerResponseMessage, } from '../types/worker-types.js'; declare var commQueryExecutorFilename: string; declare var preloadedState: AppState; const databaseStatuses = Object.freeze({ notSupported: 'NOT_SUPPORTED', initSuccess: 'INIT_SUCCESS', initInProgress: 'INIT_IN_PROGRESS', initError: 'INIT_ERROR', }); type DatabaseStatus = $Values; class DatabaseModule { worker: SharedWorker; workerProxy: WorkerConnectionProxy; initPromise: Promise; status: DatabaseStatus; constructor() { const currentLoggedInUserID = preloadedState.currentUserInfo?.anonymous ? undefined : preloadedState.currentUserInfo?.id; const isSupported = isSQLiteSupported(currentLoggedInUserID); if (!isSupported || isDesktopSafari) { this.status = databaseStatuses.notSupported; } else { this.init(); } } init(encryptionKey?: ?SubtleCrypto$JsonWebKey) { this.status = databaseStatuses.initInProgress; this.worker = new SharedWorker(DATABASE_WORKER_PATH); this.worker.onerror = console.error; this.workerProxy = new WorkerConnectionProxy( this.worker.port, console.error, ); const origin = window.location.origin; this.initPromise = (async () => { try { await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.INIT, - sqljsFilePath: `${origin}${SQLJS_FILE_PATH}`, + databaseModuleFilePath: `${origin}${DATABASE_MODULE_FILE_PATH}`, encryptionKey, commQueryExecutorFilename, }); this.status = databaseStatuses.initSuccess; console.info('Database initialization success'); } catch (error) { this.status = databaseStatuses.initError; console.error(`Database initialization failure`, error); } })(); } initDBForLoggedInUser( currentLoggedInUserID: ?string, encryptionKey?: ?SubtleCrypto$JsonWebKey, ) { if (this.status === databaseStatuses.initSuccess) { return; } if ( this.status === databaseStatuses.notSupported && isSQLiteSupported(currentLoggedInUserID) ) { this.init(encryptionKey); } } async clearSensitiveData(): Promise { this.status = databaseStatuses.notSupported; await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.CLEAR_SENSITIVE_DATA, }); } async isDatabaseSupported(): Promise { if (this.status === databaseStatuses.initInProgress) { await this.initPromise; } return this.status === databaseStatuses.initSuccess; } async schedule( payload: WorkerRequestMessage, ): Promise { if (this.status === databaseStatuses.notSupported) { throw new Error('Database not supported'); } if (this.status === databaseStatuses.initInProgress) { await this.initPromise; } if (this.status === databaseStatuses.initError) { throw new Error('Database could not be initialized'); } return this.workerProxy.scheduleOnWorker(payload); } } const databaseModule: DatabaseModule = new DatabaseModule(); export { databaseModule }; diff --git a/web/database/utils/constants.js b/web/database/utils/constants.js index 331347dd7..1e91c851e 100644 --- a/web/database/utils/constants.js +++ b/web/database/utils/constants.js @@ -1,38 +1,38 @@ // @flow import localforage from 'localforage'; export const SQLITE_CONTENT = 'sqliteFileContent'; export const SQLITE_ENCRYPTION_KEY = 'encryptionKey'; export const CURRENT_USER_ID_KEY = 'current_user_id'; export const DATABASE_WORKER_PATH = '/worker/database'; -export const SQLJS_FILE_PATH = '/compiled/webworkers'; +export const DATABASE_MODULE_FILE_PATH = '/compiled/webworkers'; export const DEFAULT_COMM_QUERY_EXECUTOR_FILENAME = 'comm_query_executor.wasm'; export const COMM_SQLITE_DATABASE_PATH = 'comm.sqlite'; export const DB_SUPPORTED_OS: $ReadOnlyArray = [ 'Windows 10', 'Linux', 'Mac OS', ]; export const DB_SUPPORTED_BROWSERS: $ReadOnlyArray = [ 'edge', 'edge-chromium', 'chrome', 'firefox', 'opera', 'safari', ]; export const localforageConfig: PartialConfig = { driver: localforage.INDEXEDDB, name: 'comm', storeName: 'commStorage', description: 'Comm encrypted database storage', version: '1.0', }; diff --git a/web/database/worker/db-worker.js b/web/database/worker/db-worker.js index 28c8e47ca..a87d2e77b 100644 --- a/web/database/worker/db-worker.js +++ b/web/database/worker/db-worker.js @@ -1,313 +1,316 @@ // @flow import localforage from 'localforage'; import type { ClientDBReportStoreOperation } from 'lib/ops/report-store-ops.js'; import type { ClientDBDraftStoreOperation, DraftStoreOperation, } from 'lib/types/draft-types.js'; import type { ClientDBStore } from 'lib/types/store-ops-types.js'; import { type SharedWorkerMessageEvent, type WorkerRequestMessage, type WorkerResponseMessage, workerRequestMessageTypes, workerResponseMessageTypes, type WorkerRequestProxyMessage, workerWriteRequests, } from '../../types/worker-types.js'; import { getDatabaseModule } from '../db-module.js'; import { type EmscriptenModule } from '../types/module.js'; import { type SQLiteQueryExecutor } from '../types/sqlite-query-executor.js'; import { COMM_SQLITE_DATABASE_PATH, CURRENT_USER_ID_KEY, localforageConfig, SQLITE_CONTENT, SQLITE_ENCRYPTION_KEY, } from '../utils/constants.js'; import { clearSensitiveData, exportDatabaseContent, importDatabaseContent, } from '../utils/db-utils.js'; import { decryptDatabaseFile, encryptDatabaseFile, generateDatabaseCryptoKey, importJWKKey, } from '../utils/worker-crypto-utils.js'; localforage.config(localforageConfig); let encryptionKey: ?CryptoKey = null; let sqliteQueryExecutor: ?SQLiteQueryExecutor = null; let dbModule: ?EmscriptenModule = null; let persistNeeded: boolean = false; let persistInProgress: boolean = false; async function initDatabase( - sqljsFilePath: string, + databaseModuleFilePath: string, commQueryExecutorFilename: ?string, encryptionKeyJWK?: ?SubtleCrypto$JsonWebKey, ) { - dbModule = getDatabaseModule(commQueryExecutorFilename, sqljsFilePath); + dbModule = getDatabaseModule( + commQueryExecutorFilename, + databaseModuleFilePath, + ); try { const result = dbModule.CommQueryExecutor.testDBOperation(); console.log(result); } catch (e) { console.error(e); } if (encryptionKeyJWK) { encryptionKey = await importJWKKey(encryptionKeyJWK); } else { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); if (!encryptionKey) { const cryptoKey = await generateDatabaseCryptoKey({ extractable: false }); await localforage.setItem(SQLITE_ENCRYPTION_KEY, cryptoKey); } } const encryptedContent = await localforage.getItem(SQLITE_CONTENT); let dbContent = null; try { if (encryptionKey && encryptedContent) { dbContent = await decryptDatabaseFile(encryptedContent, encryptionKey); } } catch (e) { console.error('Error while decrypting content, clearing database content'); await localforage.removeItem(SQLITE_CONTENT); } if (dbContent) { importDatabaseContent(dbContent, dbModule, COMM_SQLITE_DATABASE_PATH); console.info( 'Database exists and is properly encrypted, using persisted data', ); } else { console.info('Creating fresh database'); } sqliteQueryExecutor = new dbModule.SQLiteQueryExecutor( COMM_SQLITE_DATABASE_PATH, ); } function processDraftStoreOperations( operations: $ReadOnlyArray, ) { if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } for (const operation: DraftStoreOperation of operations) { if (operation.type === 'remove_all') { sqliteQueryExecutor?.removeAllDrafts(); } else if (operation.type === 'update') { const { key, text } = operation.payload; sqliteQueryExecutor?.updateDraft(key, text); } else if (operation.type === 'move') { const { oldKey, newKey } = operation.payload; sqliteQueryExecutor?.moveDraft(oldKey, newKey); } else { throw new Error('Unsupported draft operation'); } } } function processReportStoreOperations( operations: $ReadOnlyArray, ) { if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } for (const operation: ClientDBReportStoreOperation of operations) { if (operation.type === 'remove_all_reports') { sqliteQueryExecutor?.removeAllReports(); } else if (operation.type === 'remove_reports') { const { ids } = operation.payload; sqliteQueryExecutor?.removeReports(ids); } else if (operation.type === 'replace_report') { const { id, report } = operation.payload; sqliteQueryExecutor?.replaceReport({ id, report }); } else { throw new Error('Unsupported report operation'); } } } function getClientStore(): ClientDBStore { if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } const drafts = sqliteQueryExecutor?.getAllDrafts() ?? []; const reports = sqliteQueryExecutor?.getAllReports() ?? []; return { drafts, messages: [], threads: [], messageStoreThreads: [], reports, }; } async function persist() { persistInProgress = true; if (!sqliteQueryExecutor || !dbModule) { persistInProgress = false; throw new Error('Database not initialized'); } if (!encryptionKey) { encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); } while (persistNeeded) { persistNeeded = false; const dbData = exportDatabaseContent(dbModule, COMM_SQLITE_DATABASE_PATH); if (!encryptionKey) { persistInProgress = false; throw new Error('Encryption key is missing'); } const encryptedData = await encryptDatabaseFile(dbData, encryptionKey); await localforage.setItem(SQLITE_CONTENT, encryptedData); } persistInProgress = false; } async function processAppRequest( message: WorkerRequestMessage, ): Promise { // non-database operations if (message.type === workerRequestMessageTypes.PING) { return { type: workerResponseMessageTypes.PONG, text: 'PONG', }; } else if ( message.type === workerRequestMessageTypes.GENERATE_DATABASE_ENCRYPTION_KEY ) { const cryptoKey = await generateDatabaseCryptoKey({ extractable: false }); await localforage.setItem(SQLITE_ENCRYPTION_KEY, cryptoKey); return undefined; } // database operations if (message.type === workerRequestMessageTypes.INIT) { await initDatabase( - message.sqljsFilePath, + message.databaseModuleFilePath, message.commQueryExecutorFilename, message.encryptionKey, ); return undefined; } else if (message.type === workerRequestMessageTypes.CLEAR_SENSITIVE_DATA) { encryptionKey = null; await localforage.clear(); if (dbModule && sqliteQueryExecutor) { clearSensitiveData( dbModule, COMM_SQLITE_DATABASE_PATH, sqliteQueryExecutor, ); } sqliteQueryExecutor = null; return undefined; } if (!sqliteQueryExecutor) { throw new Error('Database not initialized'); } // read-only operations if (message.type === workerRequestMessageTypes.GET_CLIENT_STORE) { return { type: workerResponseMessageTypes.CLIENT_STORE, store: getClientStore(), }; } else if (message.type === workerRequestMessageTypes.GET_CURRENT_USER_ID) { return { type: workerResponseMessageTypes.GET_CURRENT_USER_ID, userID: sqliteQueryExecutor.getMetadata(CURRENT_USER_ID_KEY), }; } else if ( message.type === workerRequestMessageTypes.GET_PERSIST_STORAGE_ITEM ) { return { type: workerResponseMessageTypes.GET_PERSIST_STORAGE_ITEM, item: sqliteQueryExecutor.getPersistStorageItem(message.key), }; } // write operations if (!workerWriteRequests.includes(message.type)) { throw new Error('Request type not supported'); } if (message.type === workerRequestMessageTypes.PROCESS_STORE_OPERATIONS) { const { draftStoreOperations, reportStoreOperations } = message.storeOperations; if (draftStoreOperations) { processDraftStoreOperations(draftStoreOperations); } if (reportStoreOperations) { processReportStoreOperations(reportStoreOperations); } } else if (message.type === workerRequestMessageTypes.SET_CURRENT_USER_ID) { sqliteQueryExecutor?.setMetadata(CURRENT_USER_ID_KEY, message.userID); } else if ( message.type === workerRequestMessageTypes.SET_PERSIST_STORAGE_ITEM ) { sqliteQueryExecutor?.setPersistStorageItem(message.key, message.item); } else if ( message.type === workerRequestMessageTypes.REMOVE_PERSIST_STORAGE_ITEM ) { sqliteQueryExecutor?.removePersistStorageItem(message.key); } persistNeeded = true; if (!persistInProgress) { persist(); } return undefined; } function connectHandler(event: SharedWorkerMessageEvent) { if (!event.ports.length) { return; } const port: MessagePort = event.ports[0]; console.log('Web database worker alive!'); port.onmessage = async function (messageEvent: MessageEvent) { const data: WorkerRequestProxyMessage = (messageEvent.data: any); const { id, message } = data; if (!id) { port.postMessage({ error: 'Request without identifier', }); } try { const result = await processAppRequest(message); port.postMessage({ id, message: result, }); } catch (e) { port.postMessage({ id, error: e.message, }); } }; } self.addEventListener('connect', connectHandler); diff --git a/web/types/worker-types.js b/web/types/worker-types.js index c55fc8a13..b6cf7188c 100644 --- a/web/types/worker-types.js +++ b/web/types/worker-types.js @@ -1,146 +1,146 @@ // @flow import type { ClientDBStore, ClientDBStoreOperations, } from 'lib/types/store-ops-types.js'; // The types of messages sent from app to worker export const workerRequestMessageTypes = Object.freeze({ PING: 0, INIT: 1, GENERATE_DATABASE_ENCRYPTION_KEY: 2, PROCESS_STORE_OPERATIONS: 3, GET_CLIENT_STORE: 4, SET_CURRENT_USER_ID: 5, GET_CURRENT_USER_ID: 6, GET_PERSIST_STORAGE_ITEM: 7, SET_PERSIST_STORAGE_ITEM: 8, REMOVE_PERSIST_STORAGE_ITEM: 9, CLEAR_SENSITIVE_DATA: 10, }); export const workerWriteRequests: $ReadOnlyArray = [ workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, workerRequestMessageTypes.SET_CURRENT_USER_ID, workerRequestMessageTypes.SET_PERSIST_STORAGE_ITEM, workerRequestMessageTypes.REMOVE_PERSIST_STORAGE_ITEM, ]; export type PingWorkerRequestMessage = { +type: 0, +text: string, }; export type InitWorkerRequestMessage = { +type: 1, - +sqljsFilePath: string, + +databaseModuleFilePath: string, +commQueryExecutorFilename: ?string, +encryptionKey?: ?SubtleCrypto$JsonWebKey, }; export type GenerateDatabaseEncryptionKeyRequestMessage = { +type: 2, }; export type ProcessStoreOperationsRequestMessage = { +type: 3, +storeOperations: ClientDBStoreOperations, }; export type GetClientStoreRequestMessage = { +type: 4, }; export type SetCurrentUserIDRequestMessage = { +type: 5, +userID: string, }; export type GetCurrentUserIDRequestMessage = { +type: 6, }; export type GetPersistStorageItemRequestMessage = { +type: 7, +key: string, }; export type SetPersistStorageItemRequestMessage = { +type: 8, +key: string, +item: string, }; export type RemovePersistStorageItemRequestMessage = { +type: 9, +key: string, }; export type ClearSensitiveDataRequestMessage = { +type: 10, }; export type WorkerRequestMessage = | PingWorkerRequestMessage | InitWorkerRequestMessage | GenerateDatabaseEncryptionKeyRequestMessage | ProcessStoreOperationsRequestMessage | GetClientStoreRequestMessage | SetCurrentUserIDRequestMessage | GetCurrentUserIDRequestMessage | GetPersistStorageItemRequestMessage | SetPersistStorageItemRequestMessage | RemovePersistStorageItemRequestMessage | ClearSensitiveDataRequestMessage; export type WorkerRequestProxyMessage = { +id: number, +message: WorkerRequestMessage, }; // The types of messages sent from worker to app export const workerResponseMessageTypes = Object.freeze({ PONG: 0, CLIENT_STORE: 1, GET_CURRENT_USER_ID: 2, GET_PERSIST_STORAGE_ITEM: 3, }); export type PongWorkerResponseMessage = { +type: 0, +text: string, }; export type ClientStoreResponseMessage = { +type: 1, +store: ClientDBStore, }; export type GetCurrentUserIDResponseMessage = { +type: 2, +userID: ?string, }; export type GetPersistStorageItemResponseMessage = { +type: 3, +item: string, }; export type WorkerResponseMessage = | PongWorkerResponseMessage | ClientStoreResponseMessage | GetCurrentUserIDResponseMessage | GetPersistStorageItemResponseMessage; export type WorkerResponseProxyMessage = { +id?: number, +message?: WorkerResponseMessage, +error?: string, }; // SharedWorker types export type SharedWorkerMessageEvent = MessageEvent & { +ports: $ReadOnlyArray, ... };