diff --git a/web/database/database-module-provider.js b/web/database/database-module-provider.js index 3d54d1116..5a32f74cb 100644 --- a/web/database/database-module-provider.js +++ b/web/database/database-module-provider.js @@ -1,156 +1,156 @@ // @flow import invariant from 'invariant'; import localforage from 'localforage'; import { DATABASE_WORKER_PATH, - DATABASE_MODULE_FILE_PATH, + WORKERS_MODULES_DIR_PATH, SQLITE_ENCRYPTION_KEY, } from './utils/constants.js'; import { isDesktopSafari, isSQLiteSupported } from './utils/db-utils.js'; import WorkerConnectionProxy from './utils/WorkerConnectionProxy.js'; import { exportKeyToJWK, generateCryptoKey, } from '../crypto/aes-gcm-crypto-utils.js'; import { workerRequestMessageTypes, type WorkerRequestMessage, type WorkerResponseMessage, } from '../types/worker-types.js'; declare var baseURL: string; declare var commQueryExecutorFilename: string; const databaseStatuses = Object.freeze({ notRunning: 'NOT_RUNNING', initSuccess: 'INIT_SUCCESS', initInProgress: 'INIT_IN_PROGRESS', initError: 'INIT_ERROR', }); type DatabaseStatus = | { +type: 'NOT_RUNNING' | 'INIT_SUCCESS' | 'INIT_ERROR' } | { +type: 'INIT_IN_PROGRESS', +initPromise: Promise }; type InitOptions = { +clearDatabase: boolean }; class DatabaseModule { worker: ?SharedWorker; workerProxy: ?WorkerConnectionProxy; status: DatabaseStatus = { type: databaseStatuses.notRunning }; async init({ clearDatabase }: InitOptions): Promise { if (!isSQLiteSupported()) { console.warn('SQLite is not supported'); this.status = { type: databaseStatuses.initError }; return; } if (clearDatabase && this.status.type === databaseStatuses.initSuccess) { console.info('Clearing sensitive data'); invariant(this.workerProxy, 'Worker proxy should exist'); await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.CLEAR_SENSITIVE_DATA, }); this.status = { type: databaseStatuses.notRunning }; } if (this.status.type === databaseStatuses.initInProgress) { await this.status.initPromise; return; } if ( this.status.type === databaseStatuses.initSuccess || this.status.type === databaseStatuses.initError ) { return; } 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; const initPromise = (async () => { try { let encryptionKey = null; if (isDesktopSafari) { encryptionKey = await getSafariEncryptionKey(); } invariant(this.workerProxy, 'Worker proxy should exist'); await this.workerProxy.scheduleOnWorker({ type: workerRequestMessageTypes.INIT, - databaseModuleFilePath: `${origin}${baseURL}${DATABASE_MODULE_FILE_PATH}`, + databaseModuleFilePath: `${origin}${baseURL}${WORKERS_MODULES_DIR_PATH}`, encryptionKey, commQueryExecutorFilename, }); this.status = { type: databaseStatuses.initSuccess }; console.info('Database initialization success'); } catch (error) { this.status = { type: databaseStatuses.initError }; console.error(`Database initialization failure`, error); } })(); this.status = { type: databaseStatuses.initInProgress, initPromise }; await initPromise; } async isDatabaseSupported(): Promise { if (this.status.type === databaseStatuses.initInProgress) { await this.status.initPromise; } return this.status.type === databaseStatuses.initSuccess; } async schedule( payload: WorkerRequestMessage, ): Promise { if (this.status.type === databaseStatuses.notRunning) { throw new Error('Database not running'); } if (this.status.type === databaseStatuses.initInProgress) { await this.status.initPromise; } if (this.status.type === databaseStatuses.initError) { throw new Error('Database could not be initialized'); } invariant(this.workerProxy, 'Worker proxy should exist'); return this.workerProxy.scheduleOnWorker(payload); } } async function getSafariEncryptionKey(): Promise { const encryptionKey = await localforage.getItem(SQLITE_ENCRYPTION_KEY); if (encryptionKey) { return await exportKeyToJWK(encryptionKey); } const newEncryptionKey = await generateCryptoKey({ extractable: true, }); await localforage.setItem(SQLITE_ENCRYPTION_KEY, newEncryptionKey); return await exportKeyToJWK(newEncryptionKey); } let databaseModule: ?DatabaseModule = null; async function getDatabaseModule(): Promise { if (!databaseModule) { databaseModule = new DatabaseModule(); await databaseModule.init({ clearDatabase: false }); } return databaseModule; } // Start initializing the database immediately getDatabaseModule(); export { getDatabaseModule }; diff --git a/web/database/utils/constants.js b/web/database/utils/constants.js index ddb0e20df..5a57dc269 100644 --- a/web/database/utils/constants.js +++ b/web/database/utils/constants.js @@ -1,43 +1,45 @@ // @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 DATABASE_MODULE_FILE_PATH = '/compiled/webworkers'; +export const WORKERS_MODULES_DIR_PATH = '/compiled/webworkers'; export const DEFAULT_COMM_QUERY_EXECUTOR_FILENAME = 'comm_query_executor.wasm'; +export const DEFAULT_OLM_FILENAME = 'olm.wasm'; + export const COMM_SQLITE_DATABASE_PATH = 'comm.sqlite'; export const NOTIFICATIONS_OLM_DATA_CONTENT = 'notificationsOlmDataContent'; export const NOTIFICATIONS_OLM_DATA_ENCRYPTION_KEY = 'notificationsOlmDataEncryptionKey'; 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/push-notif/notif-crypto-utils.js b/web/push-notif/notif-crypto-utils.js new file mode 100644 index 000000000..4b513a2ab --- /dev/null +++ b/web/push-notif/notif-crypto-utils.js @@ -0,0 +1,8 @@ +// @flow + +export type WebNotifsServiceUtilsData = { + +olmWasmPath: string, + +staffCanSee: boolean, +}; + +export const WEB_NOTIFS_SERVICE_UTILS_KEY = 'webNotifsServiceUtils'; diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js index 6f6e42ea6..37258bc0e 100644 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,194 +1,207 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { useSetDeviceTokenFanout, setDeviceTokenActionTypes, } from 'lib/actions/device-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert, recordNotifPermissionAlertActionType, } from 'lib/utils/push-alerts.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; +import { + WORKERS_MODULES_DIR_PATH, + DEFAULT_OLM_FILENAME, +} from '../database/utils/constants.js'; import electron from '../electron.js'; import PushNotifModal from '../modals/push-notif-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; +import { useStaffCanSee } from '../utils/staff-utils.js'; + +declare var baseURL: string; function useCreateDesktopPushSubscription() { const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); React.useEffect( () => electron?.onDeviceTokenRegistered?.(token => { dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), ); }), [callSetDeviceToken, dispatchActionPromise], ); const dispatch = useDispatch(); React.useEffect( () => electron?.onNotificationClicked?.(({ threadID }) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, ashoatKeyserverID, ); const payload = { chatMode: 'view', activeChatThreadID: convertedThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); }), [dispatch], ); } function useCreatePushSubscription(): () => Promise { const publicKey = useSelector(state => state.pushApiPublicKey); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useSetDeviceTokenFanout(); + const staffCanSee = useStaffCanSee(); return React.useCallback(async () => { if (!publicKey) { return; } const workerRegistration = await navigator.serviceWorker?.ready; if (!workerRegistration) { return; } + const origin = window.location.origin; + const olmWasmDirPath = `${origin}${baseURL}${WORKERS_MODULES_DIR_PATH}`; + const olmWasmPath = `${olmWasmDirPath}/${DEFAULT_OLM_FILENAME}`; + workerRegistration.active?.postMessage({ olmWasmPath, staffCanSee }); + const subscription = await workerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }); dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(JSON.stringify(subscription)), ); - }, [callSetDeviceToken, dispatchActionPromise, publicKey]); + }, [callSetDeviceToken, dispatchActionPromise, publicKey, staffCanSee]); } function PushNotificationsHandler(): React.Node { useCreateDesktopPushSubscription(); const createPushSubscription = useCreatePushSubscription(); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); const modalContext = useModalContext(); const loggedIn = useSelector(isLoggedIn); const dispatch = useDispatch(); const supported = 'Notification' in window && !electron; React.useEffect(() => { (async () => { if (!navigator.serviceWorker || !supported) { return; } await navigator.serviceWorker.register('worker/notif', { scope: '/' }); if (Notification.permission === 'granted') { // Make sure the subscription is current if we have the permissions await createPushSubscription(); } else if ( Notification.permission === 'default' && loggedIn && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { // Ask existing users that are already logged in for permission modalContext.pushModal(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Ask for permission on login const prevLoggedIn = React.useRef(loggedIn); React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return; } if (!prevLoggedIn.current && loggedIn) { if (Notification.permission === 'granted') { createPushSubscription(); } else if ( Notification.permission === 'default' && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { modalContext.pushModal(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } } prevLoggedIn.current = loggedIn; }, [ createPushSubscription, dispatch, loggedIn, modalContext, notifPermissionAlertInfo, prevLoggedIn, supported, ]); // Redirect to thread on notification click React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return undefined; } const callback = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { return; } if (event.data.targetThreadID) { const payload = { chatMode: 'view', activeChatThreadID: event.data.targetThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); } }; navigator.serviceWorker.addEventListener('message', callback); return () => navigator.serviceWorker?.removeEventListener('message', callback); }, [dispatch, supported]); return null; } export { PushNotificationsHandler, useCreatePushSubscription }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index 87333483e..bf1c4b91e 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,80 +1,112 @@ // @flow +import localforage from 'localforage'; + import type { PlainTextWebNotification } from 'lib/types/notif-types.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; +import { + WEB_NOTIFS_SERVICE_UTILS_KEY, + type WebNotifsServiceUtilsData, +} from './notif-crypto-utils.js'; +import { localforageConfig } from '../database/utils/constants.js'; + declare class PushMessageData { json(): Object; } declare class PushEvent extends ExtendableEvent { +data: PushMessageData; } +declare class CommAppMessage extends ExtendableEvent { + +data: { +olmWasmPath?: string, +staffCanSee?: boolean }; +} + declare var clients: Clients; declare function skipWaiting(): Promise; self.addEventListener('install', () => { skipWaiting(); }); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); +self.addEventListener('message', (event: CommAppMessage) => { + localforage.config(localforageConfig); + event.waitUntil( + (async () => { + if (!event.data.olmWasmPath || event.data.staffCanSee === undefined) { + return; + } + const webNotifsServiceUtils: WebNotifsServiceUtilsData = { + olmWasmPath: event.data.olmWasmPath, + staffCanSee: event.data.staffCanSee, + }; + + await localforage.setItem( + WEB_NOTIFS_SERVICE_UTILS_KEY, + webNotifsServiceUtils, + ); + })(), + ); +}); + self.addEventListener('push', (event: PushEvent) => { const data: PlainTextWebNotification = event.data.json(); event.waitUntil( (async () => { let body = data.body; if (data.prefix) { body = `${data.prefix} ${body}`; } await self.registration.showNotification(data.title, { body, badge: 'https://web.comm.app/favicon.ico', icon: 'https://web.comm.app/favicon.ico', tag: data.id, data: { unreadCount: data.unreadCount, threadID: data.threadID, }, }); })(), ); }); self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); event.waitUntil( (async () => { const clientList: Array = (await clients.matchAll({ type: 'window', }): any); const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; const threadID = convertNonPendingIDToNewSchema( event.notification.data.threadID, ashoatKeyserverID, ); if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } selectedClient.postMessage({ targetThreadID: threadID, }); } else { const url = (process.env.NODE_ENV === 'production' ? 'https://web.comm.app' : 'http://localhost:3000/webapp') + `/chat/thread/${threadID}/`; clients.openWindow(url); } })(), ); }); diff --git a/web/webpack.config.cjs b/web/webpack.config.cjs index b545982c8..2c57435b8 100644 --- a/web/webpack.config.cjs +++ b/web/webpack.config.cjs @@ -1,204 +1,225 @@ const CopyPlugin = require('copy-webpack-plugin'); const path = require('path'); const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); const { createProdBrowserConfig, createDevBrowserConfig, createNodeServerRenderingConfig, createWebWorkersConfig, } = require('lib/webpack/shared.cjs'); const babelConfig = require('./babel.config.cjs'); async function getConfig(configName) { const { getCommConfig } = await import( // eslint-disable-next-line monorepo/no-relative-import '../keyserver/dist/lib/utils/comm-config.js' ); return await getCommConfig(configName); } const baseBrowserConfig = { entry: { browser: ['./script.js'], }, output: { filename: 'prod.[contenthash:12].build.js', path: path.join(__dirname, 'dist'), }, resolve: { alias: { '../images': path.resolve('../keyserver/images'), }, fallback: { crypto: false, fs: false, path: false, }, }, }; const baseDevBrowserConfig = { ...baseBrowserConfig, output: { ...baseBrowserConfig.output, filename: 'dev.build.js', pathinfo: true, publicPath: 'http://localhost:8080/', }, devServer: { port: 8080, headers: { 'Access-Control-Allow-Origin': '*' }, allowedHosts: ['all'], host: '0.0.0.0', static: { directory: path.join(__dirname, 'dist'), }, }, plugins: [ new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/olm/olm.wasm', to: path.join(__dirname, 'dist'), }, ], }), new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/opaque-ke-wasm' + '/pkg/comm_opaque2_wasm_bg.wasm', to: path.join(__dirname, 'dist', 'opaque-ke.wasm'), }, ], }), ], }; const baseProdBrowserConfig = { ...baseBrowserConfig, plugins: [ new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/olm/olm.wasm', to: path.join(__dirname, 'dist', 'olm.[contenthash:12].wasm'), }, ], }), new CopyPlugin({ patterns: [ { from: 'node_modules/@commapp/opaque-ke-wasm' + '/pkg/comm_opaque2_wasm_bg.wasm', to: path.join(__dirname, 'dist', 'opaque-ke.[contenthash:12].wasm'), }, ], }), new WebpackManifestPlugin({ publicPath: '', }), ], }; const baseNodeServerRenderingConfig = { externals: ['react', 'react-dom', 'react-redux'], entry: { keyserver: ['./loading.react.js'], }, output: { filename: 'app.build.cjs', library: 'app', libraryTarget: 'commonjs2', path: path.join(__dirname, 'dist'), }, }; const baseWebWorkersConfig = { entry: { pushNotif: './push-notif/service-worker.js', database: './database/worker/db-worker.js', }, output: { filename: '[name].build.js', path: path.join(__dirname, 'dist', 'webworkers'), }, resolve: { fallback: { crypto: false, fs: false, path: false, }, }, }; const devWebWorkersPlugins = [ new CopyPlugin({ patterns: [ { from: 'database/_generated/comm_query_executor.wasm', to: path.join(__dirname, 'dist', 'webworkers'), }, ], }), + new CopyPlugin({ + patterns: [ + { + from: 'node_modules/@commapp/olm/olm.wasm', + to: path.join(__dirname, 'dist', 'webworkers'), + }, + ], + }), ]; const prodWebWorkersPlugins = [ new CopyPlugin({ patterns: [ { from: 'database/_generated/comm_query_executor.wasm', to: path.join( __dirname, 'dist', 'webworkers', 'comm_query_executor.[contenthash:12].wasm', ), }, ], }), + new CopyPlugin({ + patterns: [ + { + from: 'node_modules/@commapp/olm/olm.wasm', + to: path.join( + __dirname, + 'dist', + 'webworkers', + 'olm.[contenthash:12].wasm', + ), + }, + ], + }), new WebpackManifestPlugin({ publicPath: '', }), ]; module.exports = async function (env) { const identityServiceConfig = await getConfig({ folder: 'secrets', name: 'identity_service_config', }); const envVars = { IDENTITY_SERVICE_CONFIG: JSON.stringify(identityServiceConfig), }; const browserConfigPromise = env.prod ? createProdBrowserConfig(baseProdBrowserConfig, babelConfig, envVars) : createDevBrowserConfig(baseDevBrowserConfig, babelConfig, envVars); const nodeConfigPromise = createNodeServerRenderingConfig( baseNodeServerRenderingConfig, babelConfig, ); const [browserConfig, nodeConfig] = await Promise.all([ browserConfigPromise, nodeConfigPromise, ]); const nodeServerRenderingConfig = { ...nodeConfig, mode: env.prod ? 'production' : 'development', }; const workersConfig = { ...baseWebWorkersConfig, plugins: env.prod ? prodWebWorkersPlugins : devWebWorkersPlugins, }; const webWorkersConfig = createWebWorkersConfig( env, workersConfig, babelConfig, ); return [browserConfig, nodeServerRenderingConfig, webWorkersConfig]; };