diff --git a/keyserver/src/services/blob.js b/keyserver/src/services/blob.js index d33261974..161a48845 100644 --- a/keyserver/src/services/blob.js +++ b/keyserver/src/services/blob.js @@ -1,149 +1,157 @@ // @flow import blobService from 'lib/facts/blob-service.js'; +import type { BlobHashAndHolder } from 'lib/types/holder-types.js'; import { getBlobFetchableURL, makeBlobServiceEndpointURL, } 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 { 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, holder } = params; const headers = await createRequestHeaders(); const assignHolderResponse = await fetch( makeBlobServiceEndpointURL(blobService.httpEndpoints.ASSIGN_HOLDER), { method: blobService.httpEndpoints.ASSIGN_HOLDER.method, body: JSON.stringify({ holder, blob_hash: hash, }), headers, }, ); if (!assignHolderResponse.ok) { const { status, statusText } = assignHolderResponse; return { success: false, reason: 'OTHER', status, statusText }; } return { success: true }; } async function uploadBlobKeyserverWrapper( blob: Blob, hash: string, ): Promise { const authHeaders = await createRequestHeaders(false); return uploadBlob(blob, hash, authHeaders); } 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, } | { +found: true, +blob: Blob, }; async function download(hash: string): Promise { const url = getBlobFetchableURL(hash); const headers = await createRequestHeaders(); const response = await fetch(url, { method: blobService.httpEndpoints.GET_BLOB.method, headers, }); if (!response.ok) { return { found: false }; } const blob = await response.blob(); return { found: true, blob }; } async function deleteBlob(params: BlobDescriptor, instant?: boolean) { const { hash, holder } = params; const endpoint = blobService.httpEndpoints.DELETE_BLOB; const url = makeBlobServiceEndpointURL(endpoint); const headers = await createRequestHeaders(); await fetch(url, { method: endpoint.method, body: JSON.stringify({ holder, blob_hash: hash, instant_delete: !!instant, }), headers, }); } +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/utils/blob-service.js b/lib/utils/blob-service.js index 505f11c04..ee4a8872c 100644 --- a/lib/utils/blob-service.js +++ b/lib/utils/blob-service.js @@ -1,167 +1,208 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import { toBase64URL } from './base64.js'; import { replacePathParams, type URLPathParams } from './url-utils.js'; import type { BlobServiceHTTPEndpoint } from '../facts/blob-service.js'; import blobServiceConfig from '../facts/blob-service.js'; +import type { BlobHashAndHolder } from '../types/holder-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, } | { +success: false, +reason: 'HASH_IN_USE' | 'OTHER', +status: number, +statusText: string, }; 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; const reason = status === 409 ? 'HASH_IN_USE' : 'OTHER'; return { success: false, reason, status, statusText, }; } return { success: true }; } async function assignMultipleHolders( holders: $ReadOnlyArray<{ +blobHash: string, +holder: string }>, headers: { [string]: string }, ): Promise< | { +success: true } | { +error: true, status: number, statusText: string } | { +failedAssignments: $ReadOnlyArray<{ +blobHash: string, +holder: string, }>, }, > { 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({ requests: holders, }), }, ); if (!assignMultipleHoldersResponse.ok) { const { status, statusText } = assignMultipleHoldersResponse; return { error: true, status, statusText }; } const { results } = await assignMultipleHoldersResponse.json(); const failedRequests = results .filter(result => !result.success) .map(({ blobHash, holder }) => ({ blobHash, holder })); if (failedRequests.length !== 0) { return { failedAssignments: failedRequests }; } return { success: true }; } +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 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({ + requests: holders, + instantDelete: !!instantDelete, + }), + }, + ); + + if (!response.ok) { + const { status, statusText } = response; + return { result: 'error', status, statusText }; + } + + const { failedRequests } = await response.json(); + if (failedRequests.length !== 0) { + return { result: 'failed_requests', failedRequests }; + } + + return { result: 'success' }; +} + export { makeBlobServiceURI, isBlobServiceURI, blobHashFromURI, blobHashFromBlobServiceURI, generateBlobHolder, getBlobFetchableURL, makeBlobServiceEndpointURL, uploadBlob, assignMultipleHolders, + removeMultipleHolders, };