diff --git a/keyserver/src/services/blob.js b/keyserver/src/services/blob.js index 021167fef..0d725a6de 100644 --- a/keyserver/src/services/blob.js +++ b/keyserver/src/services/blob.js @@ -1,146 +1,149 @@ // @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 === '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); + const removeResult = await removeMultipleHolders(holders, headers); + if (removeResult.result === 'invalid_csat') { + await clearIdentityInfo(); + } } export { upload, uploadBlob, assignHolder, download, deleteBlob, uploadBlobKeyserverWrapper, removeBlobHolders, }; diff --git a/lib/actions/holder-actions.js b/lib/actions/holder-actions.js index d8f2e6ea5..fb6fbe6da 100644 --- a/lib/actions/holder-actions.js +++ b/lib/actions/holder-actions.js @@ -1,220 +1,234 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; +import { useInvalidCSATLogOut } from './user-actions.js'; import { type AuthMetadata, IdentityClientContext, } from '../shared/identity-client-context.js'; import type { BlobHashAndHolder, BlobOperation, } from '../types/holder-types.js'; import { toBase64URL } from '../utils/base64.js'; import { generateBlobHolder, assignMultipleHolders, removeMultipleHolders, } from '../utils/blob-service.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; import { createDefaultHTTPRequestHeaders } from '../utils/services-utils.js'; type MultipleBlobHolders = $ReadOnlyArray; export const storeEstablishedHolderActionType = 'STORE_ESTABLISHED_HOLDER'; export const processHoldersActionTypes = Object.freeze({ started: 'PROCESS_HOLDERS_STARTED', success: 'PROCESS_HOLDERS_SUCCESS', failed: 'PROCESS_HOLDERS_FAILED', }); export type ProcessHoldersStartedPayload = { +holdersToAdd: MultipleBlobHolders, +holdersToRemove: MultipleBlobHolders, }; export type ProcessHoldersFailedPayload = { +notAdded: MultipleBlobHolders, +notRemoved: MultipleBlobHolders, }; export type ProcessHoldersFinishedPayload = { +added: MultipleBlobHolders, +removed: MultipleBlobHolders, +notAdded: MultipleBlobHolders, +notRemoved: MultipleBlobHolders, }; type BlobServiceActionsResult = { +succeeded: MultipleBlobHolders, +failed: MultipleBlobHolders, }; async function performBlobServiceHolderActions( action: 'establish' | 'remove', inputs: MultipleBlobHolders, authMetadata: AuthMetadata, + handleInvalidCSAT?: () => Promise, ): Promise { if (inputs.length === 0) { return { succeeded: [], failed: [] }; } const defaultHeaders = createDefaultHTTPRequestHeaders(authMetadata); const blobServiceCall = action === 'establish' ? assignMultipleHolders : removeMultipleHolders; const requestInputs = inputs.map(({ blobHash, ...rest }) => ({ ...rest, blobHash: toBase64URL(blobHash), })); const response = await blobServiceCall(requestInputs, defaultHeaders); if (response.result === 'success') { return { succeeded: inputs, failed: [] }; } if (response.result === 'error') { return { succeeded: [], failed: inputs }; + } else if (response.result === 'invalid_csat') { + void handleInvalidCSAT?.(); + return { succeeded: [], failed: inputs }; } const failedRequestsSet = new Set( response.failedRequests.map(({ blobHash, holder }) => JSON.stringify({ blobHash, holder }), ), ); const succeeded = [], failed = []; for (const item of inputs) { const stringifiedItem = JSON.stringify({ blobHash: toBase64URL(item.blobHash), holder: item.holder, }); if (failedRequestsSet.has(stringifiedItem)) { failed.push(item); } else { succeeded.push(item); } } return { succeeded, failed }; } async function processHoldersAction( input: ProcessHoldersStartedPayload, authMetadata: AuthMetadata, + handleInvalidCSAT?: () => Promise, ): Promise { const [ { succeeded: added, failed: notAdded }, { succeeded: removed, failed: notRemoved }, ] = await Promise.all([ performBlobServiceHolderActions( 'establish', input.holdersToAdd, authMetadata, + handleInvalidCSAT, ), performBlobServiceHolderActions( 'remove', input.holdersToRemove, authMetadata, + handleInvalidCSAT, ), ]); return { added, notAdded, removed, notRemoved }; } function useClearAllHolders(): () => Promise { const dispatchActionPromise = useDispatchActionPromise(); const identityContext = React.useContext(IdentityClientContext); const getAuthMetadata = identityContext?.getAuthMetadata; const storedHolders = useSelector(state => state.holderStore.storedHolders); const holdersToRemove = React.useMemo( () => Object.entries(storedHolders) .filter(([, holderInfo]) => holderInfo.status !== 'PENDING_REMOVAL') .map(([blobHash, { holder }]) => ({ blobHash, holder })), [storedHolders], ); return React.useCallback(async () => { invariant(getAuthMetadata, 'Identity context not set'); const authMetadata = await getAuthMetadata(); const input = { holdersToRemove, holdersToAdd: [], }; const promise = processHoldersAction(input, authMetadata); void dispatchActionPromise( processHoldersActionTypes, promise, undefined, input, ); }, [dispatchActionPromise, getAuthMetadata, holdersToRemove]); } export type ProcessHolders = ( blobOperations: $ReadOnlyArray, ) => Promise; function useProcessBlobHolders(): ProcessHolders { const identityContext = React.useContext(IdentityClientContext); const getAuthMetadata = identityContext?.getAuthMetadata; + const invalidTokenLogOut = useInvalidCSATLogOut(); + const storedHolders = useSelector(state => state.holderStore.storedHolders); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( async (ops: $ReadOnlyArray) => { if (ops.length === 0) { return; } invariant(getAuthMetadata, 'Identity context not set'); const authMetadata = await getAuthMetadata(); const holdersToAdd = ops .map(({ blobHash, type }) => { const status = storedHolders[blobHash]?.status; if ( type !== 'establish_holder' || status === 'ESTABLISHED' || status === 'PENDING_ESTABLISHMENT' ) { return null; } return { blobHash, holder: generateBlobHolder(authMetadata.deviceID), }; }) .filter(Boolean); const holdersToRemove = ops .map(({ blobHash, type }) => { const holderInfo = storedHolders[blobHash]; if ( !holderInfo || type !== 'remove_holder' || holderInfo.status === 'PENDING_REMOVAL' ) { return null; } return { blobHash, holder: holderInfo.holder }; }) .filter(Boolean); const input = { holdersToAdd, holdersToRemove, }; - const promise = processHoldersAction(input, authMetadata); + const promise = processHoldersAction( + input, + authMetadata, + invalidTokenLogOut, + ); void dispatchActionPromise( processHoldersActionTypes, promise, undefined, input, ); }, - [dispatchActionPromise, getAuthMetadata, storedHolders], + [dispatchActionPromise, getAuthMetadata, invalidTokenLogOut, storedHolders], ); } export { processHoldersAction, useClearAllHolders, useProcessBlobHolders }; diff --git a/lib/handlers/holders-handler.react.js b/lib/handlers/holders-handler.react.js index c43a35424..90f163d13 100644 --- a/lib/handlers/holders-handler.react.js +++ b/lib/handlers/holders-handler.react.js @@ -1,78 +1,81 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { processHoldersAction, processHoldersActionTypes, type ProcessHoldersStartedPayload, } from '../actions/holder-actions.js'; +import { useInvalidCSATLogOut } from '../actions/user-actions.js'; import { isLoggedIn } from '../selectors/user-selectors.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import { useDispatchActionPromise } from '../utils/redux-promise-utils.js'; import { useSelector } from '../utils/redux-utils.js'; const retryInterval = 30000; // ms function HoldersHandler(): React.Node { const dispatchActionPromise = useDispatchActionPromise(); const identityContext = React.useContext(IdentityClientContext); const getAuthMetadata = identityContext?.getAuthMetadata; + const invalidTokenLogOut = useInvalidCSATLogOut(); + const loggedIn = useSelector(isLoggedIn); const storedHolders = useSelector(state => state.holderStore.storedHolders); const itemsToProcess = React.useMemo(() => { const holdersToAdd = [], holdersToRemove = []; for (const blobHash in storedHolders) { const { status, holder } = storedHolders[blobHash]; if (status === 'NOT_ESTABLISHED') { holdersToAdd.push({ blobHash, holder }); } else if (status === 'NOT_REMOVED') { holdersToRemove.push({ blobHash, holder }); } } return { holdersToAdd, holdersToRemove }; }, [storedHolders]); const performHoldersProcessing: ( input: ProcessHoldersStartedPayload, ) => Promise = React.useMemo( () => _throttle(async (input: ProcessHoldersStartedPayload) => { invariant(getAuthMetadata, 'Identity context not set'); const authMetadata = await getAuthMetadata(); void dispatchActionPromise( processHoldersActionTypes, - processHoldersAction(input, authMetadata), + processHoldersAction(input, authMetadata, invalidTokenLogOut), undefined, input, ); }, retryInterval), - [getAuthMetadata, dispatchActionPromise], + [getAuthMetadata, dispatchActionPromise, invalidTokenLogOut], ); const shouldStartProcessing = itemsToProcess.holdersToAdd.length !== 0 || itemsToProcess.holdersToRemove.length !== 0; React.useEffect(() => { if (!loggedIn || !shouldStartProcessing) { return; } void performHoldersProcessing(itemsToProcess); }, [ itemsToProcess, loggedIn, performHoldersProcessing, shouldStartProcessing, ]); } export { HoldersHandler }; diff --git a/lib/push/send-hooks.react.js b/lib/push/send-hooks.react.js index e69e95b9f..8b99e4a17 100644 --- a/lib/push/send-hooks.react.js +++ b/lib/push/send-hooks.react.js @@ -1,343 +1,346 @@ // @flow import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import uuid from 'uuid'; import type { LargeNotifData } from './crypto.js'; import { preparePushNotifs, prepareOwnDevicesPushNotifs, type PerUserTargetedNotifications, } from './send-utils.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import { NeynarClientContext } from '../components/neynar-client-provider.react.js'; import { usePeerOlmSessionsCreatorContext } from '../components/peer-olm-session-creator-provider.react.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { AuthMetadata } from '../shared/identity-client-context.js'; import { useTunnelbroker } from '../tunnelbroker/tunnelbroker-context.js'; import type { TargetedAPNsNotification, TargetedAndroidNotification, TargetedWebNotification, TargetedWNSNotification, NotificationsCreationData, EncryptedNotifUtilsAPI, } from '../types/notif-types.js'; import { deviceToTunnelbrokerMessageTypes } from '../types/tunnelbroker/messages.js'; import type { TunnelbrokerAPNsNotif, TunnelbrokerFCMNotif, TunnelbrokerWebPushNotif, TunnelbrokerWNSNotif, } from '../types/tunnelbroker/notif-types.js'; import { uploadBlob, assignMultipleHolders } from '../utils/blob-service.js'; import { getConfig } from '../utils/config.js'; import { getMessageForException } from '../utils/errors.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; import { createDefaultHTTPRequestHeaders } from '../utils/services-utils.js'; function apnsNotifToTunnelbrokerAPNsNotif( targetedNotification: TargetedAPNsNotification, ): TunnelbrokerAPNsNotif { const { deliveryID: deviceID, notification: { headers, ...payload }, } = targetedNotification; const newHeaders = { ...headers, 'apns-push-type': 'Alert', }; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_APNS_NOTIF, deviceID, headers: JSON.stringify(newHeaders), payload: JSON.stringify(payload), clientMessageID: uuid.v4(), }; } function androidNotifToTunnelbrokerFCMNotif( targetedNotification: TargetedAndroidNotification, ): TunnelbrokerFCMNotif { const { deliveryID: deviceID, notification: { data }, priority, } = targetedNotification; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_FCM_NOTIF, deviceID, clientMessageID: uuid.v4(), data: JSON.stringify(data), priority: priority === 'normal' ? 'NORMAL' : 'HIGH', }; } function webNotifToTunnelbrokerWebPushNotif( targetedNotification: TargetedWebNotification, ): TunnelbrokerWebPushNotif { const { deliveryID: deviceID, notification } = targetedNotification; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_WEB_PUSH_NOTIF, deviceID, clientMessageID: uuid.v4(), payload: JSON.stringify(notification), }; } function wnsNotifToTunnelbrokerWNSNofif( targetedNotification: TargetedWNSNotification, ): TunnelbrokerWNSNotif { const { deliveryID: deviceID, notification } = targetedNotification; return { type: deviceToTunnelbrokerMessageTypes.TUNNELBROKER_WNS_NOTIF, deviceID, clientMessageID: uuid.v4(), payload: JSON.stringify(notification), }; } function useSendPushNotifs(): ( notifCreationData: ?NotificationsCreationData, ) => Promise { const client = React.useContext(IdentityClientContext); invariant(client, 'Identity context should be set'); const { getAuthMetadata } = client; const rawMessageInfos = useSelector(state => state.messageStore.messages); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const userInfos = useSelector(state => state.userStore.userInfos); const { getENSNames } = React.useContext(ENSCacheContext); const getFCNames = React.useContext(NeynarClientContext)?.getFCNames; const { createOlmSessionsWithUser: olmSessionCreator } = usePeerOlmSessionsCreatorContext(); const { sendNotif } = useTunnelbroker(); const { encryptedNotifUtilsAPI } = getConfig(); return React.useCallback( async (notifCreationData: ?NotificationsCreationData) => { if (!notifCreationData) { return; } const authMetadata = await getAuthMetadata(); const { deviceID, userID: senderUserID } = authMetadata; if (!deviceID || !senderUserID) { return; } const senderDeviceDescriptor = { senderDeviceID: deviceID }; const senderInfo = { senderUserID, senderDeviceDescriptor, }; const { messageDatasWithMessageInfos, thickRawThreadInfos, rescindData, badgeUpdateData, } = notifCreationData; const pushNotifsPreparationInput = { encryptedNotifUtilsAPI, senderDeviceDescriptor, olmSessionCreator, messageInfos: rawMessageInfos, notifCreationData: messageDatasWithMessageInfos && thickRawThreadInfos ? { thickRawThreadInfos, messageDatasWithMessageInfos, } : null, auxUserInfos, userInfos, getENSNames, getFCNames, }; const ownDevicesPushNotifsPreparationInput = { encryptedNotifUtilsAPI, senderInfo, olmSessionCreator, auxUserInfos, rescindData, badgeUpdateData, }; const [preparedPushNotifs, preparedOwnDevicesPushNotifs] = await Promise.all([ preparePushNotifs(pushNotifsPreparationInput), prepareOwnDevicesPushNotifs(ownDevicesPushNotifsPreparationInput), ]); if (!preparedPushNotifs && !prepareOwnDevicesPushNotifs) { return; } let allPreparedPushNotifs: ?PerUserTargetedNotifications = preparedPushNotifs; if (preparedOwnDevicesPushNotifs && senderUserID) { allPreparedPushNotifs = { ...allPreparedPushNotifs, [senderUserID]: { targetedNotifications: preparedOwnDevicesPushNotifs, }, }; } if (preparedPushNotifs) { try { await uploadLargeNotifBlobs( preparedPushNotifs, authMetadata, encryptedNotifUtilsAPI, ); } catch (e) { console.log('Failed to upload blobs', e); } } const sendPromises = []; for (const userID in allPreparedPushNotifs) { for (const notif of allPreparedPushNotifs[userID] .targetedNotifications) { if (notif.targetedNotification.notification.encryptionFailed) { continue; } let tunnelbrokerNotif; if (notif.platform === 'ios' || notif.platform === 'macos') { tunnelbrokerNotif = apnsNotifToTunnelbrokerAPNsNotif( notif.targetedNotification, ); } else if (notif.platform === 'android') { tunnelbrokerNotif = androidNotifToTunnelbrokerFCMNotif( notif.targetedNotification, ); } else if (notif.platform === 'web') { tunnelbrokerNotif = webNotifToTunnelbrokerWebPushNotif( notif.targetedNotification, ); } else if (notif.platform === 'windows') { tunnelbrokerNotif = wnsNotifToTunnelbrokerWNSNofif( notif.targetedNotification, ); } else { continue; } sendPromises.push( (async () => { try { await sendNotif(tunnelbrokerNotif); } catch (e) { console.log( `Failed to send notification to device: ${ tunnelbrokerNotif.deviceID }. Details: ${getMessageForException(e) ?? ''}`, ); } })(), ); } } await Promise.all(sendPromises); }, [ getAuthMetadata, sendNotif, encryptedNotifUtilsAPI, olmSessionCreator, rawMessageInfos, auxUserInfos, userInfos, getENSNames, getFCNames, ], ); } async function uploadLargeNotifBlobs( pushNotifs: PerUserTargetedNotifications, authMetadata: AuthMetadata, encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, ): Promise { const largeNotifArray = values(pushNotifs) .map(({ largeNotifDataArray }) => largeNotifDataArray) .flat(); if (largeNotifArray.length === 0) { return; } const largeNotifsByHash: { +[blobHash: string]: $ReadOnlyArray, } = _groupBy(largeNotifData => largeNotifData.blobHash)(largeNotifArray); const uploads = Object.entries(largeNotifsByHash).map( ([blobHash, [{ encryptedCopyWithMessageInfos }]]) => ({ blobHash, encryptedCopyWithMessageInfos, }), ); const assignments = Object.entries(largeNotifsByHash) .map(([blobHash, largeNotifs]) => largeNotifs .map(({ blobHolders }) => blobHolders) .flat() .map(holder => ({ blobHash, holder })), ) .flat(); const authHeaders = createDefaultHTTPRequestHeaders(authMetadata); const uploadPromises = uploads.map( ({ blobHash, encryptedCopyWithMessageInfos }) => uploadBlob( encryptedNotifUtilsAPI.normalizeUint8ArrayForBlobUpload( encryptedCopyWithMessageInfos, ), blobHash, authHeaders, ), ); const assignmentPromise = assignMultipleHolders(assignments, authHeaders); const [uploadResults, assignmentResult] = await Promise.all([ Promise.all(uploadPromises), assignmentPromise, ]); for (const uploadResult of uploadResults) { if (uploadResult.success) { continue; } const { reason, statusText } = uploadResult; console.log( `Failed to upload. Reason: ${reason}, status text: ${statusText}`, ); } if (assignmentResult.result === 'success') { return; } if (assignmentResult.result === 'error') { const { statusText } = assignmentResult; console.log(`Failed to assign all holders. Status text: ${statusText}`); return; + } else if (assignmentResult.result === 'invalid_csat') { + console.log('Failed to assign all holders due to invalid CSAT.'); + return; } for (const [blobHash, holder] of assignmentResult.failedRequests) { console.log(`Assingnemt failed for holder: ${holder} and hash ${blobHash}`); } } export { useSendPushNotifs }; diff --git a/lib/utils/blob-service.js b/lib/utils/blob-service.js index 65e307a21..f7ed97436 100644 --- a/lib/utils/blob-service.js +++ b/lib/utils/blob-service.js @@ -1,334 +1,342 @@ // @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 (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: 'invalid_csat' } | { +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) { + if (httpResponseIsInvalidCSAT(assignMultipleHoldersResponse)) { + return { result: 'invalid_csat' }; + } 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: 'invalid_csat' } | { +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) { + if (httpResponseIsInvalidCSAT(response)) { + return { result: 'invalid_csat' }; + } 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, };