diff --git a/lib/utils/call-server-endpoint.js b/lib/utils/call-server-endpoint.js index e4ec34bf9..6e66c7eaf 100644 --- a/lib/utils/call-server-endpoint.js +++ b/lib/utils/call-server-endpoint.js @@ -1,243 +1,230 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import { getConfig } from './config.js'; import { ServerError, FetchTimeout, SocketOffline, SocketTimeout, } from './errors.js'; import sleep from './sleep.js'; import { uploadBlob, type UploadBlob } from './upload-blob.js'; import { updateLastCommunicatedPlatformDetailsActionType } from '../actions/device-actions.js'; import { callServerEndpointTimeout } from '../shared/timeouts.js'; import type { Shape } from '../types/core.js'; import type { PlatformDetails } from '../types/device-types.js'; import { type Endpoint, type SocketAPIHandler, endpointIsSocketPreferred, endpointIsSocketOnly, } from '../types/endpoints.js'; import { forcePolicyAcknowledgmentActionType } from '../types/policy-types.js'; import type { Dispatch } from '../types/redux-types.js'; import type { ServerSessionChange, ClientSessionChange, } from '../types/session-types.js'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types.js'; export type CallServerEndpointOptions = Shape<{ // null timeout means no timeout, which is the default for uploadBlob +timeout: ?number, // in milliseconds // getResultInfo will be called right before callServerEndpoint successfully // resolves and includes additional information about the request +getResultInfo: (resultInfo: CallServerEndpointResultInfo) => mixed, +blobUpload: boolean | UploadBlob, // the rest (onProgress, abortHandler) only work with blobUpload +onProgress: (percent: number) => void, // abortHandler will receive an abort function once the upload starts +abortHandler: (abort: () => void) => void, // Overrides urlPrefix in Redux +urlPrefixOverride: string, }>; export type CallServerEndpointResultInfoInterface = 'socket' | 'REST'; export type CallServerEndpointResultInfo = { +interface: CallServerEndpointResultInfoInterface, }; export type CallServerEndpointResponse = Shape<{ +cookieChange: ServerSessionChange, +currentUserInfo: CurrentUserInfo, +error: string, +payload: Object, }>; // You'll notice that this is not the type of the callServerEndpoint // function below. This is because the first several parameters to that // function get bound in by the helpers in lib/utils/action-utils.js. // This type represents the form of the callServerEndpoint function that // gets passed to the action function in lib/actions. export type CallServerEndpoint = ( endpoint: Endpoint, input: Object, options?: ?CallServerEndpointOptions, ) => Promise; type RequestData = { input: { [key: string]: mixed }, cookie?: ?string, sessionID?: ?string, platformDetails?: PlatformDetails, }; -// If cookie is undefined, then we will defer to the underlying environment to -// handle cookies, and we won't worry about them. We do this on the web since -// our cookies are httponly to protect against XSS attacks. On the other hand, -// on native we want to keep track of the cookies since we don't trust the -// underlying implementations and prefer for things to be explicit, and XSS -// isn't a thing on native. Note that for native, cookie might be null -// (indicating we don't have one), and we will then set an empty Cookie header. async function callServerEndpoint( cookie: ?string, setNewSession: (sessionChange: ClientSessionChange, error: ?string) => void, waitIfCookieInvalidated: () => Promise, cookieInvalidationRecovery: ( sessionChange: ClientSessionChange, ) => Promise, urlPrefix: string, sessionID: ?string, connectionStatus: ConnectionStatus, lastCommunicatedPlatformDetails: ?PlatformDetails, socketAPIHandler: ?SocketAPIHandler, endpoint: Endpoint, input: { [key: string]: mixed }, dispatch: Dispatch, options?: ?CallServerEndpointOptions, loggedIn: boolean, ): Promise { const possibleReplacement = await waitIfCookieInvalidated(); if (possibleReplacement) { return await possibleReplacement(endpoint, input, options); } const shouldSendPlatformDetails = lastCommunicatedPlatformDetails && !_isEqual(lastCommunicatedPlatformDetails)(getConfig().platformDetails); if ( endpointIsSocketPreferred(endpoint) && connectionStatus === 'connected' && socketAPIHandler ) { try { const result = await socketAPIHandler({ endpoint, input }); options?.getResultInfo?.({ interface: 'socket' }); return result; } catch (e) { if (endpointIsSocketOnly(endpoint)) { throw e; } else if (e instanceof SocketOffline) { // nothing } else if (e instanceof SocketTimeout) { // nothing } else { throw e; } } } if (endpointIsSocketOnly(endpoint)) { throw new SocketOffline('socket_offline'); } const resolvedURLPrefix = options?.urlPrefixOverride ?? urlPrefix; const url = resolvedURLPrefix ? `${resolvedURLPrefix}/${endpoint}` : endpoint; let json; if (options && options.blobUpload) { const uploadBlobCallback = typeof options.blobUpload === 'function' ? options.blobUpload : uploadBlob; json = await uploadBlobCallback(url, cookie, sessionID, input, options); } else { const mergedData: RequestData = { input }; - if (getConfig().setCookieOnRequest) { - // We make sure that if setCookieOnRequest is true, we never set cookie to - // undefined. null has a special meaning here: we don't currently have a - // cookie, and we want the server to specify the new cookie it will - // generate in the response body rather than the response header. See - // session-types.js for more details on why we specify cookies in the body - mergedData.cookie = cookie ? cookie : null; - } + mergedData.cookie = cookie ? cookie : null; + if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user // is not logged in on web. mergedData.sessionID = sessionID ? sessionID : null; } if (shouldSendPlatformDetails) { mergedData.platformDetails = getConfig().platformDetails; } const callEndpointPromise = (async (): Promise => { const response = await fetch(url, { method: 'POST', // This is necessary to allow cookie headers to get passed down to us credentials: 'same-origin', body: JSON.stringify(mergedData), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); const text = await response.text(); try { return JSON.parse(text); } catch (e) { console.log(text); throw e; } })(); const timeout = options && options.timeout ? options.timeout : callServerEndpointTimeout; if (!timeout) { json = await callEndpointPromise; } else { const rejectPromise = (async () => { await sleep(timeout); throw new FetchTimeout( `callServerEndpoint timed out call to ${endpoint}`, endpoint, ); })(); json = await Promise.race([callEndpointPromise, rejectPromise]); } } const { cookieChange, error, payload, currentUserInfo } = json; const sessionChange: ?ServerSessionChange = cookieChange; if (sessionChange) { const { threadInfos, userInfos, ...rest } = sessionChange; const clientSessionChange = rest.cookieInvalidated ? rest : { cookieInvalidated: false, currentUserInfo, ...rest }; if (clientSessionChange.cookieInvalidated) { const maybeReplacement = await cookieInvalidationRecovery( clientSessionChange, ); if (maybeReplacement) { return await maybeReplacement(endpoint, input, options); } } setNewSession(clientSessionChange, error); } if (!error && shouldSendPlatformDetails) { dispatch({ type: updateLastCommunicatedPlatformDetailsActionType, payload: getConfig().platformDetails, }); } if (error === 'policies_not_accepted' && loggedIn) { dispatch({ type: forcePolicyAcknowledgmentActionType, payload, }); } if (error) { throw new ServerError(error, payload); } options?.getResultInfo?.({ interface: 'REST' }); return json; } export default callServerEndpoint; diff --git a/lib/utils/config.js b/lib/utils/config.js index 0d6e2bc53..25b6a057c 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,34 +1,33 @@ // @flow import invariant from 'invariant'; import type { DispatchRecoveryAttempt } from './action-utils.js'; import type { CallServerEndpoint } from './call-server-endpoint.js'; import type { LogInActionSource } from '../types/account-types.js'; import type { PlatformDetails } from '../types/device-types.js'; export type Config = { +resolveInvalidatedCookie: ?( callServerEndpoint: CallServerEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, logInActionSource: LogInActionSource, getInitialNotificationsEncryptedMessage?: () => Promise, ) => Promise, - +setCookieOnRequest: boolean, +setSessionIDOnRequest: boolean, +calendarRangeInactivityLimit: ?number, +platformDetails: PlatformDetails, }; let registeredConfig: ?Config = null; const registerConfig = (config: $Shape) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; export { registerConfig, getConfig }; diff --git a/lib/utils/upload-blob.js b/lib/utils/upload-blob.js index 477415133..a1511cb78 100644 --- a/lib/utils/upload-blob.js +++ b/lib/utils/upload-blob.js @@ -1,121 +1,115 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import type { CallServerEndpointOptions, CallServerEndpointResponse, } from './call-server-endpoint.js'; import { getConfig } from './config.js'; function uploadBlob( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?CallServerEndpointOptions, ): Promise { const formData = new FormData(); - if (getConfig().setCookieOnRequest) { - // We make sure that if setCookieOnRequest is true, we never set cookie to - // undefined. null has a special meaning here: we don't currently have a - // cookie, and we want the server to specify the new cookie it will generate - // in the response body rather than the response header. See - // session-types.js for more details on why we specify cookies in the body. - formData.append('cookie', cookie ? cookie : ''); - } + formData.append('cookie', cookie ? cookie : ''); + if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user is // not logged in on web. formData.append('sessionID', sessionID ? sessionID : ''); } for (const key in input) { if (key === 'multimedia' || key === 'cookie' || key === 'sessionID') { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); formData.append(key, value); } const { multimedia } = input; if (multimedia && Array.isArray(multimedia)) { for (const media of multimedia) { // We perform an any-cast here because of React Native. Though Blob // support was introduced in react-native@0.54, it isn't compatible with // FormData. Instead, React Native requires a specific object format. formData.append('multimedia', (media: any)); } } const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.withCredentials = true; xhr.setRequestHeader('Accept', 'application/json'); if (options && options.timeout) { xhr.timeout = options.timeout; } if (options && options.onProgress) { const { onProgress } = options; xhr.upload.onprogress = _throttle( ({ loaded, total }) => onProgress(loaded / total), 50, ); } let failed = false; const responsePromise = new Promise((resolve, reject) => { xhr.onload = () => { if (failed) { return; } const text = xhr.responseText; try { resolve(JSON.parse(text)); } catch (e) { console.log(text); reject(e); } }; xhr.onabort = () => { failed = true; reject(new Error('request aborted')); }; xhr.onerror = event => { failed = true; reject(event); }; if (options && options.timeout) { xhr.ontimeout = event => { failed = true; reject(event); }; } if (options && options.abortHandler) { options.abortHandler(() => { failed = true; reject(new Error('request aborted')); xhr.abort(); }); } }); if (!failed) { xhr.send(formData); } return responsePromise; } export type UploadBlob = typeof uploadBlob; export { uploadBlob }; diff --git a/native/config.js b/native/config.js index 441d594f0..bbe7ab688 100644 --- a/native/config.js +++ b/native/config.js @@ -1,20 +1,19 @@ // @flow import { Platform } from 'react-native'; import { registerConfig } from 'lib/utils/config.js'; import { resolveInvalidatedCookie } from './account/resolve-invalidated-cookie.js'; import { persistConfig, codeVersion } from './redux/persist.js'; registerConfig({ resolveInvalidatedCookie, - setCookieOnRequest: true, setSessionIDOnRequest: false, calendarRangeInactivityLimit: 15 * 60 * 1000, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, }); diff --git a/web/app.react.js b/web/app.react.js index d5378526a..76d24d54b 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,391 +1,390 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { WagmiConfig } from 'wagmi'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { webAndKeyserverCodeVersion } from 'lib/facts/version.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { registerConfig } from 'lib/utils/config.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { AlchemyENSCacheProvider, wagmiConfig } from 'lib/utils/wagmi-utils.js'; import QrCodeLogin from './account/qr-code-login.react.js'; import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import { TooltipProvider } from './chat/tooltip-provider.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { initOpaque } from './crypto/opaque-utils.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import useBadgeHandler from './push-notif/badge-handler.react.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import DeviceIDUpdater from './redux/device-id-updater.js'; import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { type NavInfo } from './types/nav-types.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; initOpaque(); // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, - setCookieOnRequest: true, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: webAndKeyserverCodeVersion, stateVersion: persistConfig.version, }, }); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: NavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render() { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( {content} ); } onHeaderDoubleClick = () => electron?.doubleClickTopBar(); stopDoubleClickPropagation = electron ? e => e.stopPropagation() : null; renderLoginPage() { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent() { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher() { const { tab, settingsSection } = this.props.navInfo; let mainContent; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{mainContent}
); } } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); useBadgeHandler(); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider;