diff --git a/lib/actions/upload-actions.js b/lib/actions/upload-actions.js index f7c0119ef..dd48a3ff3 100644 --- a/lib/actions/upload-actions.js +++ b/lib/actions/upload-actions.js @@ -1,335 +1,335 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import uuid from 'uuid'; import { storeEstablishedHolderActionType } from './holder-actions.js'; import blobService from '../facts/blob-service.js'; import type { CallSingleKeyserverEndpoint } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { extractKeyserverIDFromIDOptional } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import { type PerformHTTPMultipartUpload } from '../keyserver-conn/multipart-upload.js'; import { mediaConfig } from '../media/file-utils.js'; import { IdentityClientContext } from '../shared/identity-client-context.js'; import type { AuthMetadata } from '../shared/identity-client-context.js'; import type { UploadMultimediaResult, Dimensions } from '../types/media-types'; import type { Dispatch } from '../types/redux-types.js'; import { toBase64URL } from '../utils/base64.js'; import { blobServiceUploadHandler, type BlobServiceUploadHandler, } from '../utils/blob-service-upload.js'; import { makeBlobServiceEndpointURL, makeBlobServiceURI, generateBlobHolder, } from '../utils/blob-service.js'; import { getMessageForException } from '../utils/errors.js'; import { useDispatch } from '../utils/redux-utils.js'; import { handleHTTPResponseError, createDefaultHTTPRequestHeaders, } from '../utils/services-utils.js'; export type MultimediaUploadCallbacks = Partial<{ +onProgress: (percent: number) => void, +abortHandler: (abort: () => void) => void, +performHTTPMultipartUpload: PerformHTTPMultipartUpload, +blobServiceUploadHandler: BlobServiceUploadHandler, +timeout: ?number, }>; export type MultimediaUploadExtras = $ReadOnly< Partial<{ ...Dimensions, +loop: boolean, +encryptionKey: string, +thumbHash: ?string, }>, >; const uploadMultimedia = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (( multimedia: Object, extras: MultimediaUploadExtras, callbacks?: MultimediaUploadCallbacks, ) => Promise) => async (multimedia, extras, callbacks) => { const onProgress = callbacks && callbacks.onProgress; const abortHandler = callbacks && callbacks.abortHandler; const performHTTPMultipartUpload = callbacks && callbacks.performHTTPMultipartUpload; const stringExtras: { [string]: string } = {}; if (extras.height !== null && extras.height !== undefined) { stringExtras.height = extras.height.toString(); } if (extras.width !== null && extras.width !== undefined) { stringExtras.width = extras.width.toString(); } if (extras.loop) { stringExtras.loop = '1'; } if (extras.encryptionKey) { stringExtras.encryptionKey = extras.encryptionKey; } if (extras.thumbHash) { stringExtras.thumbHash = extras.thumbHash; } // also pass MIME type if available if (multimedia.type && typeof multimedia.type === 'string') { stringExtras.mimeType = multimedia.type; } const response = await callSingleKeyserverEndpoint( 'upload_multimedia', { ...stringExtras, multimedia: [multimedia], }, { onProgress, abortHandler, performHTTPMultipartUpload: performHTTPMultipartUpload ? performHTTPMultipartUpload : true, }, ); const [uploadResult] = response.results; return { id: uploadResult.id, uri: uploadResult.uri, dimensions: uploadResult.dimensions, mediaType: uploadResult.mediaType, loop: uploadResult.loop, }; }; export type DeleteUploadInput = { +id: string, +keyserverOrThreadID: string, }; const updateMultimediaMessageMediaActionType = 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA'; const deleteUpload = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): ((input: DeleteUploadInput) => Promise) => async input => { const { id, keyserverOrThreadID } = input; const keyserverID: string = extractKeyserverIDFromIDOptional(keyserverOrThreadID) ?? keyserverOrThreadID; const requests = { [keyserverID]: { id } }; await callKeyserverEndpoint('delete_upload', requests); }; function useDeleteUpload(): (input: DeleteUploadInput) => Promise { return useKeyserverCall(deleteUpload); } export type BlobServiceUploadFile = | { +type: 'file', +file: File } | { +type: 'uri', +uri: string, +filename: string, +mimeType: string, }; export type BlobServiceUploadInput = { +blobInput: BlobServiceUploadFile, +blobHash: string, +encryptionKey: string, +dimensions: ?Dimensions, +thumbHash?: ?string, +loop?: boolean, }; export type BlobServiceUploadResult = $ReadOnly<{ ...UploadMultimediaResult, +blobHolder: string, }>; export type BlobServiceUploadAction = (input: { +uploadInput: BlobServiceUploadInput, // use `null` to skip metadata upload to keyserver +keyserverOrThreadID: ?string, +callbacks?: MultimediaUploadCallbacks, }) => Promise; const blobServiceUpload = ( callKeyserverEndpoint: CallKeyserverEndpoint, dispatch: Dispatch, authMetadata: AuthMetadata, ): BlobServiceUploadAction => async input => { const { uploadInput, callbacks, keyserverOrThreadID } = input; const { encryptionKey, loop, dimensions, thumbHash, blobInput } = uploadInput; - const blobHolder = await generateBlobHolder(); + const blobHolder = generateBlobHolder(authMetadata.deviceID); const blobHash = toBase64URL(uploadInput.blobHash); const defaultHeaders = createDefaultHTTPRequestHeaders(authMetadata); // 1. Assign new holder for blob with given blobHash let blobAlreadyExists: boolean; try { const assignHolderEndpoint = blobService.httpEndpoints.ASSIGN_HOLDER; const assignHolderResponse = await fetch( makeBlobServiceEndpointURL(assignHolderEndpoint), { method: assignHolderEndpoint.method, body: JSON.stringify({ holder: blobHolder, blob_hash: blobHash, }), headers: { ...defaultHeaders, 'content-type': 'application/json', }, }, ); handleHTTPResponseError(assignHolderResponse); const { data_exists: dataExistsResponse } = await assignHolderResponse.json(); blobAlreadyExists = dataExistsResponse; } catch (e) { throw new Error( `Failed to assign holder: ${ getMessageForException(e) ?? 'unknown error' }`, ); } // 2. Upload blob contents if blob doesn't exist if (!blobAlreadyExists) { const uploadEndpoint = blobService.httpEndpoints.UPLOAD_BLOB; let blobServiceUploadCallback = blobServiceUploadHandler; if (callbacks && callbacks.blobServiceUploadHandler) { blobServiceUploadCallback = callbacks.blobServiceUploadHandler; } try { await blobServiceUploadCallback( makeBlobServiceEndpointURL(uploadEndpoint), uploadEndpoint.method, { blobHash, blobInput, }, authMetadata, { ...callbacks }, ); } catch (e) { throw new Error( `Failed to upload blob: ${ getMessageForException(e) ?? 'unknown error' }`, ); } } // 3. Optionally upload metadata to keyserver let maybeKeyserverID; if (keyserverOrThreadID) { maybeKeyserverID = extractKeyserverIDFromIDOptional(keyserverOrThreadID) ?? keyserverOrThreadID; } const mimeType = blobInput.type === 'file' ? blobInput.file.type : blobInput.mimeType; if (!maybeKeyserverID) { dispatch({ type: storeEstablishedHolderActionType, payload: { blobHash, holder: blobHolder, }, }); if (!dimensions) { throw new Error('dimensions are required for non-keyserver uploads'); } const mediaType = mediaConfig[mimeType]?.mediaType; if (mediaType !== 'photo' && mediaType !== 'video') { throw new Error(`mediaType for ${mimeType} should be photo or video`); } return { id: uuid.v4(), uri: makeBlobServiceURI(blobHash), mediaType, dimensions, loop: loop ?? false, blobHolder, }; } // for Flow const keyserverID: string = maybeKeyserverID; const requests = { [keyserverID]: { blobHash, blobHolder, encryptionKey, filename: blobInput.type === 'file' ? blobInput.file.name : blobInput.filename, mimeType, loop, thumbHash, ...dimensions, }, }; const responses = await callKeyserverEndpoint( 'upload_media_metadata', requests, ); const response = responses[keyserverID]; return { id: response.id, uri: response.uri, mediaType: response.mediaType, dimensions: response.dimensions, loop: response.loop, blobHolder, }; }; function useBlobServiceUpload(): BlobServiceUploadAction { const identityContext = React.useContext(IdentityClientContext); invariant(identityContext, 'Identity context should be set'); const { getAuthMetadata } = identityContext; const dispatch = useDispatch(); const blobUploadAction = React.useCallback( ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): BlobServiceUploadAction => async input => { const authMetadata = await getAuthMetadata(); const authenticatedUploadAction = blobServiceUpload( callSingleKeyserverEndpoint, dispatch, authMetadata, ); return authenticatedUploadAction(input); }, [dispatch, getAuthMetadata], ); return useKeyserverCall(blobUploadAction); } export { uploadMultimedia, useBlobServiceUpload, updateMultimediaMessageMediaActionType, useDeleteUpload, }; diff --git a/lib/utils/blob-service.js b/lib/utils/blob-service.js index 322afcc31..be0aaec56 100644 --- a/lib/utils/blob-service.js +++ b/lib/utils/blob-service.js @@ -1,78 +1,75 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import { toBase64URL } from './base64.js'; -import { getContentSigningKey } from './crypto-utils.js'; import { replacePathParams, type URLPathParams } from './url-utils.js'; import type { BlobServiceHTTPEndpoint } from '../facts/blob-service.js'; import blobServiceConfig from '../facts/blob-service.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 + * Generates random blob holder prefixed by current device ID if present */ -async function generateBlobHolder(): Promise { +function generateBlobHolder(deviceID?: ?string): string { const randomID = uuid.v4(); - try { - const deviceID = await getContentSigningKey(); - const urlSafeDeviceID = toBase64URL(deviceID); - return `${urlSafeDeviceID}:${randomID}`; - } catch { + if (!deviceID) { return randomID; } + const urlSafeDeviceID = toBase64URL(deviceID); + return `${urlSafeDeviceID}:${uuid.v4()}`; } export { makeBlobServiceURI, isBlobServiceURI, blobHashFromURI, blobHashFromBlobServiceURI, generateBlobHolder, getBlobFetchableURL, makeBlobServiceEndpointURL, };