diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js index bf8cee4c5..9a903edb0 100644 --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -1,423 +1,431 @@ // @flow import html from 'common-tags/lib/html/index.js'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import _keyBy from 'lodash/fp/keyBy.js'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer.js'; import { freshMessageStore } from 'lib/reducers/message-reducer.js'; import { mostRecentlyReadThread } from 'lib/selectors/thread-selectors.js'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js'; import { threadHasPermission, threadIsPending, parsePendingThreadID, createPendingThread, } from 'lib/shared/thread-utils.js'; import { defaultWebEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { defaultNumberPerThread } from 'lib/types/message-types.js'; import { defaultEnabledReports } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { threadPermissions, threadTypes } from 'lib/types/thread-types.js'; import { currentDateInTimeZone } from 'lib/utils/date-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import getTitle from 'web/title/getTitle.js'; import { navInfoFromURL } from 'web/url-utils.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { hasAnyNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers.js'; import { getWebPushConfig } from '../push/providers.js'; import { setNewSession } from '../session/cookies.js'; import { Viewer } from '../session/viewer.js'; import { streamJSON, waitForStream } from '../utils/json-stream.js'; import { getAppURLFactsFromRequestURL } from '../utils/urls.js'; const { renderToNodeStream } = ReactDOMServer; const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600&display=swap'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } -type AssetInfo = { jsURL: string, fontsURL: string, cssInclude: string }; +type AssetInfo = { + +jsURL: string, + +fontsURL: string, + +cssInclude: string, + +olmFilename: string, +}; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', + olmFilename: '', }; return assetInfo; } try { const assetsString = await readFile('../web/dist/assets.json', 'utf8'); const assets = JSON.parse(assetsString); assetInfo = { jsURL: `compiled/${assets.browser.js}`, fontsURL: googleFontsURL, cssInclude: html` `, + olmFilename: assets[''].wasm, }; return assetInfo; } catch { throw new Error( 'Could not load assets.json for web build. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } let webpackCompiledRootComponent: ?React.ComponentType<{}> = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe web/dist doesn't always exist const webpackBuild = await import('web/dist/app.build.cjs'); webpackCompiledRootComponent = webpackBuild.app.default; return webpackCompiledRootComponent; } catch { throw new Error( 'Could not load app.build.cjs. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } async function websiteResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const appURLFacts = getAppURLFactsFromRequestURL(req.originalUrl); const { basePath, baseDomain } = appURLFacts; const baseURL = basePath.replace(/\/$/, ''); const baseHref = baseDomain + baseURL; const loadingPromise = getWebpackCompiledRootComponentForSSR(); const hasNotAcknowledgedPoliciesPromise = hasAnyNotAcknowledgedPolicies( viewer.id, baseLegalPolicies, ); let initialNavInfo; try { initialNavInfo = navInfoFromURL(req.url, { now: currentDateInTimeZone(viewer.timeZone), }); } catch (e) { throw new ServerError(e.message); } const calendarQuery = { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; const messageSelectionCriteria = { joinedThreads: true }; const initialTime = Date.now(); const assetInfoPromise = getAssetInfo(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, messageSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { if (viewer.loggedIn) { await setNewSession(viewer, calendarQuery, initialTime); } return viewer.sessionID; })(); const threadStorePromise = (async () => { const [{ threadInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ threadInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return { threadInfos: hasNotAcknowledgedPolicies ? {} : threadInfos }; })(); const messageStorePromise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, hasNotAcknowledgedPolicies, ] = await Promise.all([ threadInfoPromise, messageInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { messages: {}, threads: {}, local: {}, currentAsOf: 0, }; } const { messageStore: freshStore } = freshMessageStore( rawMessageInfos, truncationStatuses, mostRecentMessageTimestamp(rawMessageInfos, initialTime), threadInfos, ); return freshStore; })(); const entryStorePromise = (async () => { const [{ rawEntryInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ entryInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }; } return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), lastUserInteractionCalendar: initialTime, }; })(); const userStorePromise = (async () => { const [userInfos, hasNotAcknowledgedPolicies] = await Promise.all([ userInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return { userInfos: hasNotAcknowledgedPolicies ? {} : userInfos, inconsistencyReports: [], }; })(); const navInfoPromise = (async () => { const [{ threadInfos }, messageStore, currentUserInfo, userStore] = await Promise.all([ threadInfoPromise, messageStorePromise, currentUserInfoPromise, userStorePromise, ]); const finalNavInfo = initialNavInfo; const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadIsPending(requestedActiveChatThreadID) && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentlyReadThread( messageStore, threadInfos, ); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } if ( finalNavInfo.activeChatThreadID && threadIsPending(finalNavInfo.activeChatThreadID) && finalNavInfo.pendingThread?.id !== finalNavInfo.activeChatThreadID ) { const pendingThreadData = parsePendingThreadID( finalNavInfo.activeChatThreadID, ); if ( pendingThreadData && pendingThreadData.threadType !== threadTypes.SIDEBAR && currentUserInfo.id ) { const { userInfos } = userStore; const members = [...pendingThreadData.memberIDs, currentUserInfo.id] .map(id => { const userInfo = userInfos[id]; if (!userInfo || !userInfo.username) { return undefined; } const { username } = userInfo; return { id, username }; }) .filter(Boolean); const newPendingThread = createPendingThread({ viewerID: currentUserInfo.id, threadType: pendingThreadData.threadType, members, }); finalNavInfo.activeChatThreadID = newPendingThread.id; finalNavInfo.pendingThread = newPendingThread; } } return finalNavInfo; })(); const currentAsOfPromise = (async () => { const hasNotAcknowledgedPolicies = await hasNotAcknowledgedPoliciesPromise; return hasNotAcknowledgedPolicies ? 0 : initialTime; })(); const pushApiPublicKeyPromise = (async () => { const pushConfig = await getWebPushConfig(); if (!pushConfig) { if (process.env.NODE_ENV !== 'development') { console.warn('keyserver/secrets/web_push_config.json should exist'); } return null; } return pushConfig.publicKey; })(); - const { jsURL, fontsURL, cssInclude } = await assetInfoPromise; + const { jsURL, fontsURL, cssInclude, olmFilename } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const Loading = await loadingPromise; const reactStream = renderToNodeStream(); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.write(html`
`); } export { websiteResponder }; diff --git a/web/account/log-in-form.react.js b/web/account/log-in-form.react.js index 7fccadb6f..6591dc5d4 100644 --- a/web/account/log-in-form.react.js +++ b/web/account/log-in-form.react.js @@ -1,129 +1,130 @@ // @flow import olm from '@matrix-org/olm'; import { useConnectModal } from '@rainbow-me/rainbowkit'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import uuid from 'uuid'; import { useSigner } from 'wagmi'; import css from './log-in-form.css'; import SIWEButton from './siwe-button.react.js'; import SIWELoginForm from './siwe-login-form.react.js'; import TraditionalLoginForm from './traditional-login-form.react.js'; import OrBreak from '../components/or-break.react.js'; +import { initOlm } from '../olm/olm-utils.js'; import { setPrimaryIdentityKeys, setNotificationIdentityKeys, setPickledPrimaryAccount, setPickledNotificationAccount, } from '../redux/crypto-store-reducer.js'; import { useSelector } from '../redux/redux-utils.js'; function LoginForm(): React.Node { const { openConnectModal } = useConnectModal(); const { data: signer } = useSigner(); const dispatch = useDispatch(); const primaryIdentityPublicKeys = useSelector( state => state.cryptoStore.primaryIdentityKeys, ); const notificationIdentityPublicKeys = useSelector( state => state.cryptoStore.notificationIdentityKeys, ); React.useEffect(() => { (async () => { if ( primaryIdentityPublicKeys !== null && primaryIdentityPublicKeys !== undefined && notificationIdentityPublicKeys !== null && notificationIdentityPublicKeys !== undefined ) { return; } - await olm.init(); + await initOlm(); const identityAccount = new olm.Account(); identityAccount.create(); const { ed25519: identityED25519, curve25519: identityCurve25519 } = JSON.parse(identityAccount.identity_keys()); dispatch({ type: setPrimaryIdentityKeys, payload: { ed25519: identityED25519, curve25519: identityCurve25519 }, }); const identityAccountPicklingKey = uuid.v4(); const pickledIdentityAccount = identityAccount.pickle( identityAccountPicklingKey, ); dispatch({ type: setPickledPrimaryAccount, payload: { picklingKey: identityAccountPicklingKey, pickledAccount: pickledIdentityAccount, }, }); const notificationAccount = new olm.Account(); notificationAccount.create(); const { ed25519: notificationED25519, curve25519: notificationCurve25519, } = JSON.parse(notificationAccount.identity_keys()); dispatch({ type: setNotificationIdentityKeys, payload: { ed25519: notificationED25519, curve25519: notificationCurve25519, }, }); const notificationAccountPicklingKey = uuid.v4(); const pickledNotificationAccount = notificationAccount.pickle( notificationAccountPicklingKey, ); dispatch({ type: setPickledNotificationAccount, payload: { picklingKey: notificationAccountPicklingKey, pickledAccount: pickledNotificationAccount, }, }); })(); }, [dispatch, notificationIdentityPublicKeys, primaryIdentityPublicKeys]); const [siweAuthFlowSelected, setSIWEAuthFlowSelected] = React.useState(false); const onSIWEButtonClick = React.useCallback(() => { setSIWEAuthFlowSelected(true); openConnectModal && openConnectModal(); }, [openConnectModal]); const cancelSIWEAuthFlow = React.useCallback(() => { setSIWEAuthFlowSelected(false); }, []); if (siweAuthFlowSelected && signer) { return (
); } return (
); } export default LoginForm; diff --git a/web/olm/olm-utils.js b/web/olm/olm-utils.js new file mode 100644 index 000000000..5e5d6a652 --- /dev/null +++ b/web/olm/olm-utils.js @@ -0,0 +1,16 @@ +// @flow + +import olm from '@matrix-org/olm'; + +declare var olmFilename: string; + +async function initOlm(): Promise { + if (!olmFilename) { + return await olm.init(); + } + const locateFile = (wasmFilename: string, httpAssetsHost: string) => + httpAssetsHost + olmFilename; + return await olm.init({ locateFile }); +} + +export { initOlm }; diff --git a/web/webpack.config.cjs b/web/webpack.config.cjs index 629301879..c2411b254 100644 --- a/web/webpack.config.cjs +++ b/web/webpack.config.cjs @@ -1,126 +1,133 @@ const AssetsPlugin = require('assets-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const path = require('path'); const { createProdBrowserConfig, createDevBrowserConfig, createNodeServerRenderingConfig, createWebWorkersConfig, } = require('lib/webpack/shared.cjs'); const babelConfig = require('./babel.config.cjs'); const baseBrowserConfig = { - plugins: [ - new CopyPlugin({ - patterns: [ - { - from: 'node_modules/@matrix-org/olm/olm.wasm', - to: path.join(__dirname, 'dist'), - }, - ], - }), - ], 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/@matrix-org/olm/olm.wasm', + to: path.join(__dirname, 'dist'), + }, + ], + }), + ], }; const baseProdBrowserConfig = { ...baseBrowserConfig, plugins: [ - ...baseBrowserConfig.plugins, + new CopyPlugin({ + patterns: [ + { + from: 'node_modules/@matrix-org/olm/olm.wasm', + to: path.join(__dirname, 'dist', 'olm.[contenthash:12].wasm'), + }, + ], + }), new AssetsPlugin({ filename: 'assets.json', path: path.join(__dirname, 'dist'), removeFullPathAutoPrefix: true, }), ], }; 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 = { plugins: [ new CopyPlugin({ patterns: [ { from: 'node_modules/sql.js/dist/sql-wasm.wasm', to: path.join(__dirname, 'dist'), }, ], }), ], entry: { pushNotif: './push-notif/service-worker.js', }, output: { filename: '[name].build.js', path: path.join(__dirname, 'dist', 'webworkers'), }, }; module.exports = function (env) { const browserConfig = env.prod ? createProdBrowserConfig(baseProdBrowserConfig, babelConfig) : createDevBrowserConfig(baseDevBrowserConfig, babelConfig); const nodeConfig = createNodeServerRenderingConfig( baseNodeServerRenderingConfig, babelConfig, ); const nodeServerRenderingConfig = { ...nodeConfig, mode: env.prod ? 'production' : 'development', }; const webWorkersConfig = createWebWorkersConfig( env, baseWebWorkersConfig, babelConfig, ); return [browserConfig, nodeServerRenderingConfig, webWorkersConfig]; };