diff --git a/lib/facts/blob-service.js b/lib/facts/blob-service.js --- a/lib/facts/blob-service.js +++ b/lib/facts/blob-service.js @@ -9,6 +9,8 @@ +UPLOAD_BLOB: { +path: '/blob', +method: 'PUT' }, +DELETE_BLOB: { +path: '/blob', +method: 'DELETE' }, +REMOVE_MULTIPLE_HOLDERS: { +path: '/holders', +method: 'DELETE' }, + +GET_MEDIA: { +path: '/media/:mediaID', +method: 'GET' }, + +UPLOAD_MEDIA: { +path: '/media', +method: 'POST' }, }; export type BlobServiceHTTPEndpoint = @@ -44,6 +46,8 @@ path: '/holders', method: 'DELETE', }, + GET_MEDIA: { path: '/media/:mediaID', method: 'GET' }, + UPLOAD_MEDIA: { path: '/media', method: 'POST' }, }); const config: BlobServiceConfig = { diff --git a/lib/types/blob-service-types.js b/lib/types/blob-service-types.js --- a/lib/types/blob-service-types.js +++ b/lib/types/blob-service-types.js @@ -76,3 +76,17 @@ tShape({ failedRequests: t.list(blobInfoValidator), }); + +export type UploadFarcasterMediaResponse = { + +mediaID: string, + +blobHash: string, + +contentType: ?string, + +metadata: ?string, +}; +export const uploadFarcasterMediaResponseValidator: TInterface = + tShape({ + mediaID: t.String, + blobHash: t.String, + contentType: t.maybe(t.String), + metadata: t.maybe(t.String), + }); diff --git a/lib/utils/blob-service-upload.js b/lib/utils/blob-service-upload.js --- a/lib/utils/blob-service-upload.js +++ b/lib/utils/blob-service-upload.js @@ -3,12 +3,16 @@ import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; +import { getMessageForException } from './errors.js'; import { createHTTPAuthorizationHeader } from './services-utils.js'; +import { assertWithValidator } from './validation-utils.js'; import type { MultimediaUploadCallbacks, BlobServiceUploadFile, } from '../actions/upload-actions.js'; import type { AuthMetadata } from '../shared/identity-client-context.js'; +import type { UploadFarcasterMediaResponse } from '../types/blob-service-types.js'; +import { uploadFarcasterMediaResponseValidator } from '../types/blob-service-types.js'; function blobServiceUploadHandler( url: string, @@ -90,6 +94,106 @@ return responsePromise; } +async function farcasterMediaUploadHandler( + url: string, + input: BlobServiceUploadFile, + authMetadata: AuthMetadata, + customMetadata: ?string, + options: MultimediaUploadCallbacks, +): Promise { + if (input.type !== 'file') { + throw new Error('Use file to upload blob to blob service!'); + } + invariant(input.file, 'file should be defined'); + + const formData = new FormData(); + if (customMetadata) { + formData.append('metadata', customMetadata); + } + formData.append('mime_type', input.file.type); + formData.append('file', input.file); + + const xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + xhr.open('POST', url); + + const authHeader = createHTTPAuthorizationHeader(authMetadata); + xhr.setRequestHeader('Authorization', authHeader); + + const { timeout, onProgress, abortHandler } = options ?? {}; + + if (timeout) { + xhr.timeout = timeout; + } + + if (onProgress) { + xhr.upload.onprogress = _throttle( + ({ loaded, total }) => onProgress(loaded / total), + 50, + ); + } + + let failed = false; + const responsePromise = new Promise( + (resolve, reject) => { + xhr.onload = () => { + if (failed) { + return; + } + if (xhr.status === 401 || xhr.status === 403) { + failed = true; + reject(new Error('invalid_csat')); + return; + } + + try { + const rawResponse = + typeof xhr.response === 'string' + ? JSON.parse(xhr.responseText) + : xhr.response; + const response = assertWithValidator( + rawResponse, + uploadFarcasterMediaResponseValidator, + ); + resolve(response); + } catch (e) { + reject( + `Invalid response: ${getMessageForException(e) ?? 'unknown error'}`, + ); + } + }; + xhr.onabort = () => { + failed = true; + reject(new Error('request aborted')); + }; + xhr.onerror = event => { + failed = true; + reject(event); + }; + if (timeout) { + xhr.ontimeout = event => { + failed = true; + reject(event); + }; + } + if (abortHandler) { + abortHandler(() => { + failed = true; + reject(new Error('request aborted')); + xhr.abort(); + }); + } + }, + ); + + if (!failed) { + xhr.send(formData); + } + + return responsePromise; +} + export type BlobServiceUploadHandler = typeof blobServiceUploadHandler; +export type FarcasterMediaUploadHandler = typeof farcasterMediaUploadHandler; -export { blobServiceUploadHandler }; +export { blobServiceUploadHandler, farcasterMediaUploadHandler }; diff --git a/native/avatars/avatar-hooks.js b/native/avatars/avatar-hooks.js --- a/native/avatars/avatar-hooks.js +++ b/native/avatars/avatar-hooks.js @@ -37,7 +37,7 @@ import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import Alert from '../utils/alert.js'; -import blobServiceUploadHandler from '../utils/blob-service-upload.js'; +import { blobServiceUploadHandler } from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; function displayAvatarUpdateFailureAlert(): void { diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -114,7 +114,7 @@ import { displayActionResultModal } from '../navigation/action-result-modal.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import { useSelector } from '../redux/redux-utils.js'; -import blobServiceUploadHandler from '../utils/blob-service-upload.js'; +import { blobServiceUploadHandler } from '../utils/blob-service-upload.js'; import { useStaffCanSee } from '../utils/staff-utils.js'; type MediaIDs = diff --git a/native/utils/blob-service-upload.js b/native/utils/blob-service-upload.js --- a/native/utils/blob-service-upload.js +++ b/native/utils/blob-service-upload.js @@ -3,10 +3,18 @@ import * as FileSystem from 'expo-file-system'; import { Platform } from 'react-native'; +import type { + BlobServiceUploadFile, + MultimediaUploadCallbacks, +} from 'lib/actions/upload-actions.js'; import { pathFromURI } from 'lib/media/file-utils.js'; +import type { AuthMetadata } from 'lib/shared/identity-client-context.js'; +import { uploadFarcasterMediaResponseValidator } from 'lib/types/blob-service-types.js'; +import type { UploadFarcasterMediaResponse } from 'lib/types/blob-service-types.js'; import type { BlobServiceUploadHandler } from 'lib/utils/blob-service-upload.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { createDefaultHTTPRequestHeaders } from 'lib/utils/services-utils.js'; +import { assertWithValidator } from 'lib/utils/validation-utils.js'; const blobServiceUploadHandler: BlobServiceUploadHandler = async ( url, @@ -68,4 +76,64 @@ } }; -export default blobServiceUploadHandler; +async function farcasterMediaUploadHandler( + url: string, + input: BlobServiceUploadFile, + authMetadata: AuthMetadata, + customMetadata: ?string, + options: MultimediaUploadCallbacks, +): Promise { + if (input.type !== 'uri') { + throw new Error('Wrong blob data type'); + } + + let path = input.uri; + if (Platform.OS === 'android') { + const resolvedPath = pathFromURI(path); + if (resolvedPath) { + path = resolvedPath; + } + } + + const headers = authMetadata && createDefaultHTTPRequestHeaders(authMetadata); + const optionalParams = customMetadata ? { metadata: customMetadata } : {}; + const uploadOptions = { + uploadType: FileSystem.FileSystemUploadType.MULTIPART, + fieldName: 'file', + httpMethod: 'POST', + parameters: { + ...optionalParams, + mime_type: input.mimeType, + }, + headers, + }; + + const uploadTask = FileSystem.createUploadTask( + url, + path, + uploadOptions, + uploadProgress => { + if (options?.onProgress) { + const { totalBytesSent, totalBytesExpectedToSend } = uploadProgress; + options.onProgress(totalBytesSent / totalBytesExpectedToSend); + } + }, + ); + if (options?.abortHandler) { + options.abortHandler(() => uploadTask.cancelAsync()); + } + try { + const result = await uploadTask.uploadAsync(); + const response = assertWithValidator( + JSON.parse(result.body), + uploadFarcasterMediaResponseValidator, + ); + return response; + } catch (e) { + throw new Error( + `Failed to upload farcaster media: ${getMessageForException(e) ?? 'unknown error'}`, + ); + } +} + +export { blobServiceUploadHandler, farcasterMediaUploadHandler };