diff --git a/keyserver/src/services/blob.js b/keyserver/src/services/blob.js index d88b17ed8..021167fef 100644 --- a/keyserver/src/services/blob.js +++ b/keyserver/src/services/blob.js @@ -1,143 +1,146 @@ // @flow import type { BlobHashAndHolder } from 'lib/types/holder-types.js'; import { downloadBlob, removeBlobHolder, assignBlobHolder, } from 'lib/utils/blob-service.js'; import { uploadBlob, removeMultipleHolders, type BlobOperationResult, } from 'lib/utils/blob-service.js'; import { createHTTPAuthorizationHeader } from 'lib/utils/services-utils.js'; import { clearIdentityInfo } from '../user/identity.js'; import { verifyUserLoggedIn } from '../user/login.js'; import { getContentSigningKey } from '../utils/olm-utils.js'; async function createRequestHeaders( includeContentType: boolean = true, ): Promise<{ [string]: string }> { const [{ userId: userID, accessToken }, deviceID] = await Promise.all([ verifyUserLoggedIn(), getContentSigningKey(), ]); const authorization = createHTTPAuthorizationHeader({ userID, deviceID, accessToken, }); return { Authorization: authorization, ...(includeContentType && { 'Content-Type': 'application/json' }), }; } type BlobDescriptor = { +hash: string, +holder: string, }; async function assignHolder( params: BlobDescriptor, ): Promise { const { hash: blobHash, holder } = params; const headers = await createRequestHeaders(); const assignResult = await assignBlobHolder({ blobHash, holder }, headers); if (!assignResult.success && assignResult.reason === 'INVALID_CSAT') { await clearIdentityInfo(); } return assignResult; } async function uploadBlobKeyserverWrapper( blob: Blob, hash: string, ): Promise { const authHeaders = await createRequestHeaders(false); const uploadResult = await uploadBlob(blob, hash, authHeaders); if (!uploadResult.success && uploadResult.reason === 'INVALID_CSAT') { await clearIdentityInfo(); } return uploadResult; } async function upload( blob: Blob, params: BlobDescriptor, ): Promise< | { +success: true, } | { +success: false, +assignHolderResult: BlobOperationResult, +uploadBlobResult: BlobOperationResult, }, > { const { hash, holder } = params; const [holderResult, uploadResult] = await Promise.all([ assignHolder({ hash, holder }), uploadBlobKeyserverWrapper(blob, hash), ]); if (holderResult.success && uploadResult.success) { return { success: true }; } return { success: false, assignHolderResult: holderResult, uploadBlobResult: uploadResult, }; } export type BlobDownloadResult = | { +found: false, +status: number, } | { +found: true, +blob: Blob, }; async function download(hash: string): Promise { const headers = await createRequestHeaders(); const blobResult = await downloadBlob(hash, headers); - if (blobResult.result !== 'success') { + if (blobResult.result === 'error') { return { found: false, status: blobResult.status }; + } else if (blobResult.result === 'invalid_csat') { + await clearIdentityInfo(); + return { found: false, status: 401 }; } const blob = await blobResult.response.blob(); return { found: true, blob }; } async function deleteBlob(params: BlobDescriptor, instant?: boolean) { const { hash: blobHash, holder } = params; const headers = await createRequestHeaders(); const removeResult = await removeBlobHolder( { blobHash, holder }, headers, instant, ); if (!removeResult.success && removeResult.reason === 'INVALID_CSAT') { await clearIdentityInfo(); } } async function removeBlobHolders(holders: $ReadOnlyArray) { const headers = await createRequestHeaders(false); await removeMultipleHolders(holders, headers); } export { upload, uploadBlob, assignHolder, download, deleteBlob, uploadBlobKeyserverWrapper, removeBlobHolders, }; diff --git a/lib/components/base-auto-join-community-handler.react.js b/lib/components/base-auto-join-community-handler.react.js index 03e7882f7..f085906be 100644 --- a/lib/components/base-auto-join-community-handler.react.js +++ b/lib/components/base-auto-join-community-handler.react.js @@ -1,337 +1,344 @@ // @flow import invariant from 'invariant'; import _pickBy from 'lodash/fp/pickBy.js'; import * as React from 'react'; import { NeynarClientContext } from './neynar-client-provider.react.js'; +import { useInvalidCSATLogOut } from '../actions/user-actions.js'; import { useIsLoggedInToIdentityAndAuthoritativeKeyserver } from '../hooks/account-hooks.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { farcasterChannelTagBlobHash, useJoinCommunity, } from '../shared/community-utils.js'; import type { AuthMetadata } from '../shared/identity-client-context.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { KeyserverOverride } from '../shared/invite-links.js'; import type { OngoingJoinCommunityData, JoinCommunityStep, } from '../types/community-types.js'; import type { CalendarQuery } from '../types/entry-types.js'; import type { SetState } from '../types/hook-types.js'; import { defaultThreadSubscription } from '../types/subscription-types.js'; import { downloadBlob } from '../utils/blob-service.js'; import { useCurrentUserFID } from '../utils/farcaster-utils.js'; import { promiseAll } from '../utils/promises.js'; import { useSelector } from '../utils/redux-utils.js'; import { usingCommServicesAccessToken, createDefaultHTTPRequestHeaders, } from '../utils/services-utils.js'; import sleep from '../utils/sleep.js'; type JoinStatus = 'inactive' | 'joining' | 'joined'; type CommunityToAutoJoin = { +batch: number, +communityID: string, +keyserverOverride: ?KeyserverOverride, +joinStatus: JoinStatus, }; type CommunityDatas = { +[communityID: string]: CommunityToAutoJoin, }; type CommunitiesToAutoJoin = { +curBatch: number, +communityDatas: CommunityDatas, }; type Props = { +calendarQuery: () => CalendarQuery, }; function BaseAutoJoinCommunityHandler(props: Props): React.Node { const { calendarQuery } = props; const isActive = useSelector(state => state.lifecycleState !== 'background'); const loggedIn = useIsLoggedInToIdentityAndAuthoritativeKeyserver(); const fid = useCurrentUserFID(); const fcCache = React.useContext(NeynarClientContext)?.fcCache; const identityClientContext = React.useContext(IdentityClientContext); invariant(identityClientContext, 'IdentityClientContext should be set'); const { getAuthMetadata } = identityClientContext; const threadInfos = useSelector(state => state.threadStore.threadInfos); const keyserverInfos = useSelector( state => state.keyserverStore.keyserverInfos, ); const [communitiesToAutoJoin, baseSetCommunitiesToAutoJoin] = React.useState(); const prevCanQueryRef = React.useRef(); const canQuery = loggedIn && !!fid; + const invalidTokenLogOut = useInvalidCSATLogOut(); + React.useEffect(() => { if (canQuery === prevCanQueryRef.current) { return; } prevCanQueryRef.current = canQuery; if (!canQuery || !isActive || !fid || !fcCache) { return; } void (async () => { const authMetadataPromise: Promise = (async () => { if (!usingCommServicesAccessToken) { return undefined; } return await getAuthMetadata(); })(); const followedFarcasterChannelsPromise = fcCache.getFollowedFarcasterChannelsForFID(fid); const [authMetadata, followedFarcasterChannels] = await Promise.all([ authMetadataPromise, followedFarcasterChannelsPromise, ]); if (!followedFarcasterChannels) { return; } const headers = authMetadata ? createDefaultHTTPRequestHeaders(authMetadata) : {}; const followedFarcasterChannelIDs = followedFarcasterChannels.map( channel => channel.id, ); const promises: { [string]: Promise>, } = {}; for (const channelID of followedFarcasterChannelIDs) { promises[channelID] = (async () => { const blobHash = farcasterChannelTagBlobHash(channelID); const blobResult = await downloadBlob(blobHash, headers); if (blobResult.result !== 'success') { + if (blobResult.result === 'invalid_csat') { + void invalidTokenLogOut(); + } return null; } const { commCommunityID, keyserverURL } = await blobResult.response.json(); const keyserverID = extractKeyserverIDFromID(commCommunityID); // The user is already in the community if (threadInfos[commCommunityID]) { return null; } const keyserverOverride = !keyserverInfos[keyserverID] ? { keyserverID, keyserverURL: keyserverURL.replace(/\/$/, ''), } : null; return { communityID: commCommunityID, keyserverOverride, joinStatus: 'inactive', }; })(); } const communitiesObj = await promiseAll(promises); const filteredCommunitiesObj = _pickBy(Boolean)(communitiesObj); const communityDatas: { ...CommunityDatas } = {}; let i = 0; for (const key in filteredCommunitiesObj) { const communityObject = filteredCommunitiesObj[key]; const communityID = communityObject.communityID; communityDatas[communityID] = { ...communityObject, batch: Math.floor(i++ / 5), }; } baseSetCommunitiesToAutoJoin({ communityDatas, curBatch: 0 }); })(); }, [ threadInfos, fid, isActive, fcCache, getAuthMetadata, keyserverInfos, canQuery, + invalidTokenLogOut, ]); const potentiallyIncrementBatch: ( ?CommunitiesToAutoJoin, ) => ?CommunitiesToAutoJoin = React.useCallback(input => { if (!input) { return input; } let shouldIncrementBatch = false; const { curBatch, communityDatas } = input; for (const communityToAutoJoin of Object.values(communityDatas)) { const { batch, joinStatus } = communityToAutoJoin; if (batch !== curBatch) { continue; } if (joinStatus !== 'joined') { // One of the current batch isn't complete yet return input; } // We have at least one complete in the current batch shouldIncrementBatch = true; } // If we get here, all of the current batch is complete if (shouldIncrementBatch) { return { communityDatas, curBatch: curBatch + 1 }; } return input; }, []); const setCommunitiesToAutoJoin: SetState = React.useCallback( next => { if (typeof next !== 'function') { baseSetCommunitiesToAutoJoin(potentiallyIncrementBatch(next)); return; } baseSetCommunitiesToAutoJoin(prev => { const result = next(prev); return potentiallyIncrementBatch(result); }); }, [potentiallyIncrementBatch], ); const joinHandlers = React.useMemo(() => { if (!communitiesToAutoJoin) { return null; } const { curBatch, communityDatas } = communitiesToAutoJoin; return Object.values(communityDatas).map(communityData => { const { batch, communityID, keyserverOverride, joinStatus } = communityData; if (batch !== curBatch || joinStatus === 'joined') { return null; } return ( ); }); }, [calendarQuery, communitiesToAutoJoin, setCommunitiesToAutoJoin]); return joinHandlers; } type JoinHandlerProps = { +communityID: string, +keyserverOverride: ?KeyserverOverride, +calendarQuery: () => CalendarQuery, +joinStatus: JoinStatus, +setCommunitiesToAutoJoin: SetState, }; function JoinHandler(props: JoinHandlerProps) { const { communityID, keyserverOverride, calendarQuery, joinStatus, setCommunitiesToAutoJoin, } = props; const [ongoingJoinData, setOngoingJoinData] = React.useState(null); const [step, setStep] = React.useState('inactive'); const joinCommunity = useJoinCommunity({ communityID, keyserverOverride, calendarQuery, ongoingJoinData, setOngoingJoinData, step, setStep, defaultSubscription: defaultThreadSubscription, }); const setJoinStatus = React.useCallback( (newJoinStatus: JoinStatus) => { setCommunitiesToAutoJoin(prev => { if (!prev) { return null; } return { ...prev, communityDatas: { ...prev.communityDatas, [communityID]: { ...prev.communityDatas[communityID], joinStatus: newJoinStatus, }, }, }; }); }, [communityID, setCommunitiesToAutoJoin], ); React.useEffect(() => { if (joinStatus !== 'inactive') { return; } void (async () => { try { setJoinStatus('joining'); await sleep(1000); await joinCommunity(); } finally { setJoinStatus('joined'); } })(); }, [joinStatus, communityID, setJoinStatus, joinCommunity]); return null; } export { BaseAutoJoinCommunityHandler }; diff --git a/lib/shared/invite-links.js b/lib/shared/invite-links.js index 922efab1f..6ac9dc74c 100644 --- a/lib/shared/invite-links.js +++ b/lib/shared/invite-links.js @@ -1,41 +1,43 @@ // @flow import type { AuthMetadata } from './identity-client-context.js'; import { downloadBlob } from '../utils/blob-service.js'; import { createDefaultHTTPRequestHeaders } from '../utils/services-utils.js'; function inviteLinkBlobHash(secret: string): string { return `invite_${secret}`; } export type KeyserverOverride = { +keyserverID: string, +keyserverURL: string, }; async function getKeyserverOverrideForAnInviteLink( secret: string, authMetadata?: AuthMetadata, ): Promise { const headers = authMetadata ? createDefaultHTTPRequestHeaders(authMetadata) : {}; const blobResult = await downloadBlob(inviteLinkBlobHash(secret), headers); - if (blobResult.result !== 'success') { + if (blobResult.result === 'error') { return null; + } else if (blobResult.result === 'invalid_csat') { + throw new Error('invalid_csat'); } const resultText = await blobResult.response.text(); const resultObject = JSON.parse(resultText); if (resultObject.keyserverID && resultObject.keyserverURL) { const keyserverURL: string = resultObject.keyserverURL; return { keyserverID: resultObject.keyserverID, keyserverURL: keyserverURL.replace(/\/$/, ''), }; } return null; } export { inviteLinkBlobHash, getKeyserverOverrideForAnInviteLink }; diff --git a/lib/utils/blob-service.js b/lib/utils/blob-service.js index 6347e0fd8..65e307a21 100644 --- a/lib/utils/blob-service.js +++ b/lib/utils/blob-service.js @@ -1,331 +1,334 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import { toBase64URL } from './base64.js'; import { httpResponseIsInvalidCSAT } from './services-utils.js'; import { replacePathParams, type URLPathParams } from './url-utils.js'; import { assertWithValidator } from './validation-utils.js'; import type { BlobServiceHTTPEndpoint } from '../facts/blob-service.js'; import blobServiceConfig from '../facts/blob-service.js'; import { type BlobInfo, type AssignHoldersRequest, type RemoveHoldersRequest, assignHoldersResponseValidator, removeHoldersResponseValidator, } from '../types/blob-service-types.js'; const BLOB_SERVICE_URI_PREFIX = 'comm-blob-service://'; function makeBlobServiceURI(blobHash: string): string { return `${BLOB_SERVICE_URI_PREFIX}${blobHash}`; } function isBlobServiceURI(uri: string): boolean { return uri.startsWith(BLOB_SERVICE_URI_PREFIX); } /** * Returns the base64url-encoded blob hash from a blob service URI. * Throws an error if the URI is not a blob service URI. */ function blobHashFromBlobServiceURI(uri: string): string { invariant(isBlobServiceURI(uri), 'Not a blob service URI'); return uri.slice(BLOB_SERVICE_URI_PREFIX.length); } /** * Returns the base64url-encoded blob hash from a blob service URI. * Returns null if the URI is not a blob service URI. */ function blobHashFromURI(uri: string): ?string { if (!isBlobServiceURI(uri)) { return null; } return blobHashFromBlobServiceURI(uri); } function makeBlobServiceEndpointURL( endpoint: BlobServiceHTTPEndpoint, params: URLPathParams = {}, ): string { const path = replacePathParams(endpoint.path, params); return `${blobServiceConfig.url}${path}`; } function getBlobFetchableURL(blobHash: string): string { return makeBlobServiceEndpointURL(blobServiceConfig.httpEndpoints.GET_BLOB, { blobHash, }); } /** * Generates random blob holder prefixed by current device ID if present */ function generateBlobHolder(deviceID?: ?string): string { const randomID = uuid.v4(); if (!deviceID) { return randomID; } const urlSafeDeviceID = toBase64URL(deviceID); return `${urlSafeDeviceID}:${uuid.v4()}`; } export type BlobOperationResult = | { +success: true, +response: Response, } | { +success: false, +reason: 'HASH_IN_USE' | 'INVALID_CSAT' | 'OTHER', +status: number, +statusText: string, }; export type BlobDownloadResult = | { +result: 'success', response: Response } + | { +result: 'invalid_csat' } | { +result: 'error', +status: number, +statusText: string }; async function downloadBlob( blobHash: string, headers: { [string]: string }, ): Promise { const blobURL = getBlobFetchableURL(blobHash); const response = await fetch(blobURL, { method: blobServiceConfig.httpEndpoints.GET_BLOB.method, headers, }); - if (response.status !== 200) { + if (httpResponseIsInvalidCSAT(response)) { + return { result: 'invalid_csat' }; + } else if (response.status !== 200) { const { status, statusText } = response; return { result: 'error', status, statusText }; } return { result: 'success', response }; } async function uploadBlob( blob: Blob | string, hash: string, headers: { [string]: string }, ): Promise { const formData = new FormData(); formData.append('blob_hash', hash); if (typeof blob === 'string') { formData.append('base64_data', blob); } else { formData.append('blob_data', blob); } const uploadBlobResponse = await fetch( makeBlobServiceEndpointURL(blobServiceConfig.httpEndpoints.UPLOAD_BLOB), { method: blobServiceConfig.httpEndpoints.UPLOAD_BLOB.method, body: formData, headers, }, ); if (!uploadBlobResponse.ok) { const { status, statusText } = uploadBlobResponse; let reason = 'OTHER'; if (status === 409) { reason = 'HASH_IN_USE'; } else if (httpResponseIsInvalidCSAT(uploadBlobResponse)) { reason = 'INVALID_CSAT'; } return { success: false, reason, status, statusText, }; } return { success: true, response: uploadBlobResponse }; } async function assignBlobHolder( blobInfo: BlobInfo, headers: { [string]: string }, ): Promise { const { blobHash, holder } = blobInfo; const response = await fetch( makeBlobServiceEndpointURL(blobServiceConfig.httpEndpoints.ASSIGN_HOLDER), { method: blobServiceConfig.httpEndpoints.ASSIGN_HOLDER.method, body: JSON.stringify({ holder, blob_hash: blobHash, }), headers: { ...headers, 'content-type': 'application/json', }, }, ); if (!response.ok) { const { status, statusText } = response; const reason = httpResponseIsInvalidCSAT(response) ? 'INVALID_CSAT' : 'OTHER'; return { success: false, reason, status, statusText, }; } return { success: true, response }; } async function removeBlobHolder( blobInfo: BlobInfo, headers: { [string]: string }, instantDelete?: boolean, ): Promise { const { blobHash, holder } = blobInfo; const endpoint = blobServiceConfig.httpEndpoints.DELETE_BLOB; const response = await fetch(makeBlobServiceEndpointURL(endpoint), { method: endpoint.method, body: JSON.stringify({ holder, blob_hash: blobHash, instant_delete: !!instantDelete, }), headers: { ...headers, 'content-type': 'application/json', }, }); if (!response.ok) { const { status, statusText } = response; const reason = httpResponseIsInvalidCSAT(response) ? 'INVALID_CSAT' : 'OTHER'; return { success: false, reason, status, statusText, }; } return { success: true, response }; } async function assignMultipleHolders( holders: $ReadOnlyArray, headers: { [string]: string }, ): Promise< | { +result: 'success' } | { +result: 'error', +status: number, +statusText: string } | { +failedRequests: $ReadOnlyArray, +result: 'failed_requests', }, > { const requestBody: AssignHoldersRequest = { requests: holders, }; const assignMultipleHoldersResponse = await fetch( makeBlobServiceEndpointURL( blobServiceConfig.httpEndpoints.ASSIGN_MULTIPLE_HOLDERS, ), { method: blobServiceConfig.httpEndpoints.ASSIGN_MULTIPLE_HOLDERS.method, headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }, ); if (!assignMultipleHoldersResponse.ok) { const { status, statusText } = assignMultipleHoldersResponse; return { result: 'error', status, statusText }; } const responseJson = await assignMultipleHoldersResponse.json(); const { results } = assertWithValidator( responseJson, assignHoldersResponseValidator, ); const failedRequests = results .filter(result => !result.success) .map(({ blobHash, holder }) => ({ blobHash, holder })); if (failedRequests.length !== 0) { return { result: 'failed_requests', failedRequests }; } return { result: 'success' }; } async function removeMultipleHolders( holders: $ReadOnlyArray, headers: { [string]: string }, instantDelete?: boolean, ): Promise< | { +result: 'success' } | { +result: 'error', +status: number, +statusText: string } | { +result: 'failed_requests', +failedRequests: $ReadOnlyArray, }, > { const requestBody: RemoveHoldersRequest = { requests: holders, instantDelete: !!instantDelete, }; const response = await fetch( makeBlobServiceEndpointURL( blobServiceConfig.httpEndpoints.REMOVE_MULTIPLE_HOLDERS, ), { method: blobServiceConfig.httpEndpoints.REMOVE_MULTIPLE_HOLDERS.method, headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }, ); if (!response.ok) { const { status, statusText } = response; return { result: 'error', status, statusText }; } const responseJson = await response.json(); const { failedRequests } = assertWithValidator( responseJson, removeHoldersResponseValidator, ); if (failedRequests.length !== 0) { return { result: 'failed_requests', failedRequests }; } return { result: 'success' }; } export { makeBlobServiceURI, isBlobServiceURI, blobHashFromURI, blobHashFromBlobServiceURI, generateBlobHolder, getBlobFetchableURL, makeBlobServiceEndpointURL, downloadBlob, uploadBlob, assignBlobHolder, removeBlobHolder, assignMultipleHolders, removeMultipleHolders, }; diff --git a/native/navigation/deep-links-context-provider.react.js b/native/navigation/deep-links-context-provider.react.js index 58d72fbca..c77f925f5 100644 --- a/native/navigation/deep-links-context-provider.react.js +++ b/native/navigation/deep-links-context-provider.react.js @@ -1,193 +1,202 @@ // @flow import { useNavigation } from '@react-navigation/native'; import * as Application from 'expo-application'; import invariant from 'invariant'; import * as React from 'react'; import { Linking, Platform } from 'react-native'; import { useVerifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; +import { useInvalidCSATLogOut } from 'lib/actions/user-actions.js'; import { parseInstallReferrerFromInviteLinkURL, parseDataFromDeepLink, type ParsedDeepLinkData, } from 'lib/facts/links.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { getKeyserverOverrideForAnInviteLink } from 'lib/shared/invite-links.js'; import type { KeyserverOverride } from 'lib/shared/invite-links.js'; import type { SetState } from 'lib/types/hook-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; -import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; +import { + errorMessageIsInvalidCSAT, + usingCommServicesAccessToken, +} from 'lib/utils/services-utils.js'; import { InviteLinkModalRouteName, SecondaryDeviceQRCodeScannerRouteName, } from './route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnFirstLaunchEffect } from '../utils/hooks.js'; type DeepLinksContextType = { +setCurrentLinkUrl: SetState, }; const defaultContext = { setCurrentLinkUrl: () => {}, }; const DeepLinksContext: React.Context = React.createContext(defaultContext); type Props = { +children: React.Node, }; function DeepLinksContextProvider(props: Props): React.Node { const { children } = props; const [currentLink, setCurrentLink] = React.useState(null); React.useEffect(() => { // This listener listens for an event where a user clicked a link when the // app was running const subscription = Linking.addEventListener('url', ({ url }) => setCurrentLink(url), ); // We're also checking if the app was opened by using a link. // In that case the listener won't be called and we're instead checking // if the initial URL is set. void (async () => { const initialURL = await Linking.getInitialURL(); if (initialURL) { setCurrentLink(initialURL); } })(); return () => { subscription.remove(); }; }, []); const checkInstallReferrer = React.useCallback(async () => { if (Platform.OS !== 'android') { return; } const installReferrer = await Application.getInstallReferrerAsync(); if (!installReferrer) { return; } const linkSecret = parseInstallReferrerFromInviteLinkURL(installReferrer); if (linkSecret) { setCurrentLink(linkSecret); } }, []); useOnFirstLaunchEffect('ANDROID_REFERRER', checkInstallReferrer); const [keyserverOverride, setKeyserverOverride] = React.useState(undefined); const inviteLinkSecret = React.useRef(null); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; + const invalidTokenLogOut = useInvalidCSATLogOut(); const loggedIn = useSelector(isLoggedIn); const dispatchActionPromise = useDispatchActionPromise(); const validateLink = useVerifyInviteLink(keyserverOverride); const navigation = useNavigation(); React.useEffect(() => { void (async () => { if (!loggedIn || !currentLink) { return; } // We're setting this to null so that we ensure that each link click // results in at most one validation and navigation. setCurrentLink(null); setKeyserverOverride(undefined); inviteLinkSecret.current = null; const parsedData: ParsedDeepLinkData = parseDataFromDeepLink(currentLink); if (!parsedData) { return; } if (parsedData.type === 'invite-link') { let authMetadata; if (usingCommServicesAccessToken) { authMetadata = await getAuthMetadata(); } const { secret } = parsedData.data; inviteLinkSecret.current = secret; try { const newKeyserverOverride = await getKeyserverOverrideForAnInviteLink(secret, authMetadata); setKeyserverOverride(newKeyserverOverride); } catch (e) { + if (errorMessageIsInvalidCSAT(e)) { + void invalidTokenLogOut(); + return; + } console.log('Error while downloading an invite link blob', e); navigation.navigate<'InviteLinkModal'>({ name: InviteLinkModalRouteName, params: { invitationDetails: { status: 'invalid', }, secret, }, }); } } else if (parsedData.type === 'qr-code') { navigation.navigate(SecondaryDeviceQRCodeScannerRouteName); } })(); - }, [currentLink, getAuthMetadata, loggedIn, navigation]); + }, [currentLink, getAuthMetadata, loggedIn, navigation, invalidTokenLogOut]); React.useEffect(() => { const secret = inviteLinkSecret.current; if (keyserverOverride === undefined || !secret) { return; } setKeyserverOverride(undefined); void (async () => { let result; try { const validateLinkPromise = validateLink({ secret }); void dispatchActionPromise( verifyInviteLinkActionTypes, validateLinkPromise, ); result = await validateLinkPromise; } catch (e) { console.log(e); result = { status: 'invalid', }; } navigation.navigate<'InviteLinkModal'>({ name: InviteLinkModalRouteName, params: { invitationDetails: result, secret, keyserverOverride, }, }); })(); }, [dispatchActionPromise, keyserverOverride, navigation, validateLink]); const contextValue = React.useMemo( () => ({ setCurrentLinkUrl: setCurrentLink, }), [], ); return ( {children} ); } export { DeepLinksContext, DeepLinksContextProvider }; diff --git a/web/invite-links/invite-link-handler.react.js b/web/invite-links/invite-link-handler.react.js index 21cd0d049..188b25a53 100644 --- a/web/invite-links/invite-link-handler.react.js +++ b/web/invite-links/invite-link-handler.react.js @@ -1,139 +1,155 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { useVerifyInviteLink, verifyInviteLinkActionTypes, } from 'lib/actions/link-actions.js'; +import { useInvalidCSATLogOut } from 'lib/actions/user-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { IdentityClientContext } from 'lib/shared/identity-client-context.js'; import { getKeyserverOverrideForAnInviteLink, type KeyserverOverride, } from 'lib/shared/invite-links.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; -import { usingCommServicesAccessToken } from 'lib/utils/services-utils.js'; +import { + usingCommServicesAccessToken, + errorMessageIsInvalidCSAT, +} from 'lib/utils/services-utils.js'; import AcceptInviteModal from './accept-invite-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; function InviteLinkHandler(): null { const inviteSecret = useSelector(state => state.navInfo.inviteSecret); const inviteLinkSecret = React.useRef(null); const [keyserverOverride, setKeyserverOverride] = React.useState(undefined); const loggedIn = useSelector(isLoggedIn); const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; + const invalidTokenLogOut = useInvalidCSATLogOut(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const { pushModal } = useModalContext(); React.useEffect(() => { void (async () => { if (!inviteSecret || !loggedIn) { return; } dispatch({ type: updateNavInfoActionType, payload: { inviteSecret: null }, }); setKeyserverOverride(undefined); inviteLinkSecret.current = inviteSecret; let authMetadata; if (usingCommServicesAccessToken) { authMetadata = await getAuthMetadata(); } try { const newKeyserverOverride = await getKeyserverOverrideForAnInviteLink( inviteSecret, authMetadata, ); setKeyserverOverride(newKeyserverOverride); } catch (e) { + if (errorMessageIsInvalidCSAT(e)) { + void invalidTokenLogOut(); + return; + } console.error('Error while downloading an invite link blob', e); pushModal( , ); } })(); - }, [dispatch, getAuthMetadata, inviteSecret, loggedIn, pushModal]); + }, [ + dispatch, + getAuthMetadata, + inviteSecret, + loggedIn, + pushModal, + invalidTokenLogOut, + ]); const validateLink = useVerifyInviteLink(keyserverOverride); const threadInfos = useSelector(threadInfoSelector); React.useEffect(() => { const secret = inviteLinkSecret.current; if (keyserverOverride === undefined || !secret) { return; } setKeyserverOverride(undefined); void (async () => { let result; try { const validateLinkPromise = validateLink({ secret }); void dispatchActionPromise( verifyInviteLinkActionTypes, validateLinkPromise, ); result = await validateLinkPromise; } catch (e) { console.error('Error while verifying an invite link', e); result = { status: 'invalid', }; } const threadID = result.thread?.id ?? result.community?.id; if ( threadID && result.status === 'already_joined' && threadInfos[threadID] ) { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadID, tab: 'chat', }, }); } pushModal( , ); })(); }, [ dispatch, dispatchActionPromise, keyserverOverride, pushModal, threadInfos, validateLink, ]); return null; } export default InviteLinkHandler;