diff --git a/keyserver/src/push/crypto.js b/keyserver/src/push/crypto.js index 3544896bf..012a32d6f 100644 --- a/keyserver/src/push/crypto.js +++ b/keyserver/src/push/crypto.js @@ -1,387 +1,417 @@ // @flow import apn from '@parse/node-apn'; +import crypto from 'crypto'; import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep.js'; import type { PlainTextWebNotification, WebNotification, } from 'lib/types/notif-types.js'; +import { toBase64URL } from 'lib/utils/base64.js'; import type { AndroidNotification, AndroidNotificationPayload, AndroidNotificationRescind, NotificationTargetDevice, } from './types.js'; import { encryptAndUpdateOlmSession } from '../updaters/olm-session-updater.js'; +import { encrypt, generateKey } from '../utils/aes-crypto-utils.js'; import { getOlmUtility } from '../utils/olm-utils.js'; async function encryptIOSNotification( cookieID: string, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, ): Promise<{ +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }> { invariant( !notification.collapseId, 'Collapsible notifications encryption currently not implemented', ); const encryptedNotification = new apn.Notification(); encryptedNotification.id = notification.id; encryptedNotification.payload.id = notification.id; encryptedNotification.topic = notification.topic; encryptedNotification.sound = notification.aps.sound; encryptedNotification.pushType = 'alert'; encryptedNotification.mutableContent = true; const { id, ...payloadSansId } = notification.payload; const unencryptedPayload = { ...payloadSansId, badge: notification.aps.badge.toString(), merged: notification.body, }; try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let dbPersistCondition; if (notificationSizeValidator) { dbPersistCondition = ({ serializedPayload }) => { const notifCopy = _cloneDeep(encryptedNotification); notifCopy.payload.encryptedPayload = serializedPayload.body; return notificationSizeValidator(notifCopy); }; } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); encryptedNotification.payload.encryptedPayload = serializedPayload.body; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotification.aps = { alert: { body: 'ENCRYPTED' }, ...encryptedNotification.aps, }; } const encryptedPayloadHash = getOlmUtility().sha256(serializedPayload.body); return { notification: encryptedNotification, payloadSizeExceeded: !!dbPersistConditionViolated, encryptedPayloadHash, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); encryptedNotification.body = notification.body; encryptedNotification.threadId = notification.payload.threadID; invariant( typeof notification.aps.badge === 'number', 'Unencrypted notification must have badge as a number', ); encryptedNotification.badge = notification.aps.badge; encryptedNotification.payload = { ...encryptedNotification.payload, ...notification.payload, encryptionFailed: 1, }; return { notification: encryptedNotification, payloadSizeExceeded: notificationSizeValidator ? notificationSizeValidator(_cloneDeep(encryptedNotification)) : false, }; } } async function encryptAndroidNotificationPayload( cookieID: string, unencryptedPayload: T, payloadSizeValidator?: (T | { +encryptedPayload: string }) => boolean, ): Promise<{ +resultPayload: T | { +encryptedPayload: string }, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); if (!unencryptedSerializedPayload) { return { resultPayload: unencryptedPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(unencryptedPayload) : false, }; } let dbPersistCondition; if (payloadSizeValidator) { dbPersistCondition = ({ serializedPayload }) => payloadSizeValidator({ encryptedPayload: serializedPayload.body }); } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }, dbPersistCondition, ); return { resultPayload: { encryptedPayload: serializedPayload.body }, payloadSizeExceeded: !!dbPersistConditionViolated, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); const resultPayload = { encryptionFailed: '1', ...unencryptedPayload, }; return { resultPayload, payloadSizeExceeded: payloadSizeValidator ? payloadSizeValidator(resultPayload) : false, }; } } async function encryptAndroidNotification( cookieID: string, notification: AndroidNotification, notificationSizeValidator?: AndroidNotification => boolean, ): Promise<{ +notification: AndroidNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { const { id, badgeOnly, ...unencryptedPayload } = notification.data; let payloadSizeValidator; if (notificationSizeValidator) { payloadSizeValidator = ( payload: AndroidNotificationPayload | { +encryptedPayload: string }, ) => { return notificationSizeValidator({ data: { id, badgeOnly, ...payload } }); }; } const { resultPayload, payloadSizeExceeded, encryptionOrder } = await encryptAndroidNotificationPayload( cookieID, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { id, badgeOnly, ...resultPayload, }, }, payloadSizeExceeded, encryptionOrder, }; } async function encryptAndroidNotificationRescind( cookieID: string, notification: AndroidNotificationRescind, ): Promise { // We don't validate payload size for rescind // since they are expected to be small and // never exceed any FCM limit const { resultPayload } = await encryptAndroidNotificationPayload( cookieID, notification.data, ); return { data: resultPayload, }; } async function encryptWebNotification( cookieID: string, notification: PlainTextWebNotification, ): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { const { id, ...payloadSansId } = notification; const unencryptedSerializedPayload = JSON.stringify(payloadSansId); try { const { encryptedMessages: { serializedPayload }, encryptionOrder, } = await encryptAndUpdateOlmSession(cookieID, 'notifications', { serializedPayload: unencryptedSerializedPayload, }); return { notification: { id, encryptedPayload: serializedPayload.body }, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed: ' + e); return { notification: { id, encryptionFailed: '1', ...payloadSansId, }, }; } } function prepareEncryptedIOSNotifications( devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, notificationSizeValidator?: apn.Notification => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ cookieID, deviceToken }) => { const notif = await encryptIOSNotification( cookieID, notification, codeVersion, notificationSizeValidator, ); return { cookieID, deviceToken, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedIOSNotificationRescind( devices: $ReadOnlyArray, notification: apn.Notification, codeVersion?: ?number, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: apn.Notification, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const { notification: notif } = await encryptIOSNotification( cookieID, notification, codeVersion, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotifications( devices: $ReadOnlyArray, notification: AndroidNotification, notificationSizeValidator?: (notification: AndroidNotification) => boolean, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidNotification( cookieID, notification, notificationSizeValidator, ); return { deviceToken, cookieID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidNotificationRescinds( devices: $ReadOnlyArray, notification: AndroidNotificationRescind, ): Promise< $ReadOnlyArray<{ +cookieID: string, +deviceToken: string, +notification: AndroidNotificationRescind, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptAndroidNotificationRescind( cookieID, notification, ); return { deviceToken, cookieID, notification: notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedWebNotifications( devices: $ReadOnlyArray, notification: PlainTextWebNotification, ): Promise< $ReadOnlyArray<{ +deviceToken: string, +notification: WebNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deviceToken, cookieID }) => { const notif = await encryptWebNotification(cookieID, notification); return { ...notif, deviceToken }; }, ); return Promise.all(notificationPromises); } +async function encryptBlobPayload(payload: string): Promise<{ + +encryptionKey: string, + +encryptedPayload: Blob, + +encryptedPayloadHash: string, +}> { + const encryptionKey = await generateKey(); + const encryptedPayload = await encrypt( + encryptionKey, + new TextEncoder().encode(payload), + ); + const encryptedPayloadBuffer = Buffer.from(encryptedPayload); + const blobHashBase64 = await crypto + .createHash('sha256') + .update(encryptedPayloadBuffer) + .digest('base64'); + const blobHash = toBase64URL(blobHashBase64); + + const payloadBlob = new Blob([encryptedPayloadBuffer]); + const encryptionKeyString = Buffer.from(encryptionKey).toString('base64'); + return { + encryptionKey: encryptionKeyString, + encryptedPayload: payloadBlob, + encryptedPayloadHash: blobHash, + }; +} + export { prepareEncryptedIOSNotifications, prepareEncryptedIOSNotificationRescind, prepareEncryptedAndroidNotifications, prepareEncryptedAndroidNotificationRescinds, prepareEncryptedWebNotifications, + encryptBlobPayload, }; diff --git a/keyserver/src/push/utils.js b/keyserver/src/push/utils.js index 0c5ab92b7..16df511c4 100644 --- a/keyserver/src/push/utils.js +++ b/keyserver/src/push/utils.js @@ -1,405 +1,420 @@ // @flow import type { ResponseFailure } from '@parse/node-apn'; import type { FirebaseApp, FirebaseError } from 'firebase-admin'; import invariant from 'invariant'; import nodeFetch from 'node-fetch'; import type { Response } from 'node-fetch'; +import uuid from 'uuid'; import webpush from 'web-push'; import type { PlatformDetails } from 'lib/types/device-types.js'; import { threadSubscriptions } from 'lib/types/subscription-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; +import { encryptBlobPayload } from './crypto.js'; import { getAPNPushProfileForCodeVersion, getFCMPushProfileForCodeVersion, getAPNProvider, getFCMProvider, ensureWebPushInitialized, getWNSToken, } from './providers.js'; import type { TargetedAPNsNotification, TargetedAndroidNotification, TargetedWebNotification, TargetedWNSNotification, } from './types.js'; import { dbQuery, SQL } from '../database/database.js'; import { upload } from '../services/blob.js'; const fcmTokenInvalidationErrors = new Set([ 'messaging/registration-token-not-registered', 'messaging/invalid-registration-token', ]); const fcmMaxNotificationPayloadByteSize = 4000; const apnTokenInvalidationErrorCode = 410; const apnBadRequestErrorCode = 400; const apnBadTokenErrorString = 'BadDeviceToken'; const apnMaxNotificationPayloadByteSize = 4096; const webInvalidTokenErrorCodes = [404, 410]; const wnsInvalidTokenErrorCodes = [404, 410]; const wnsMaxNotificationPayloadByteSize = 5000; type APNPushResult = | { +success: true } | { +errors: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; async function apnPush({ targetedNotifications, platformDetails, }: { +targetedNotifications: $ReadOnlyArray, +platformDetails: PlatformDetails, }): Promise { const pushProfile = getAPNPushProfileForCodeVersion(platformDetails); const apnProvider = await getAPNProvider(pushProfile); if (!apnProvider && process.env.NODE_ENV === 'development') { console.log(`no keyserver/secrets/${pushProfile}.json so ignoring notifs`); return { success: true }; } invariant(apnProvider, `keyserver/secrets/${pushProfile}.json should exist`); const results = await Promise.all( targetedNotifications.map(({ notification, deviceToken }) => { return apnProvider.send(notification, deviceToken); }), ); const mergedResults = { sent: [], failed: [] }; for (const result of results) { mergedResults.sent.push(...result.sent); mergedResults.failed.push(...result.failed); } const errors = []; const invalidTokens = []; for (const error of mergedResults.failed) { errors.push(error); /* eslint-disable eqeqeq */ if ( error.status == apnTokenInvalidationErrorCode || (error.status == apnBadRequestErrorCode && error.response.reason === apnBadTokenErrorString) ) { invalidTokens.push(error.device); } /* eslint-enable eqeqeq */ } if (invalidTokens.length > 0) { return { errors, invalidTokens }; } else if (errors.length > 0) { return { errors }; } else { return { success: true }; } } type FCMPushResult = { +success?: true, +fcmIDs?: $ReadOnlyArray, +errors?: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; async function fcmPush({ targetedNotifications, collapseKey, codeVersion, }: { +targetedNotifications: $ReadOnlyArray, +codeVersion: ?number, +collapseKey?: ?string, }): Promise { const pushProfile = getFCMPushProfileForCodeVersion(codeVersion); const fcmProvider = await getFCMProvider(pushProfile); if (!fcmProvider && process.env.NODE_ENV === 'development') { console.log(`no keyserver/secrets/${pushProfile}.json so ignoring notifs`); return { success: true }; } invariant(fcmProvider, `keyserver/secrets/${pushProfile}.json should exist`); const options: Object = { priority: 'high', }; if (collapseKey) { options.collapseKey = collapseKey; } // firebase-admin is extremely barebones and has a lot of missing or poorly // thought-out functionality. One of the issues is that if you send a // multicast messages and one of the device tokens is invalid, the resultant // won't explain which of the device tokens is invalid. So we're forced to // avoid the multicast functionality and call it once per deviceToken. const promises = []; for (const { notification, deviceToken } of targetedNotifications) { promises.push( fcmSinglePush(fcmProvider, notification, deviceToken, options), ); } const pushResults = await Promise.all(promises); const errors = []; const ids = []; const invalidTokens = []; for (let i = 0; i < pushResults.length; i++) { const pushResult = pushResults[i]; for (const error of pushResult.errors) { errors.push(error); if (fcmTokenInvalidationErrors.has(error.errorInfo.code)) { invalidTokens.push(targetedNotifications[i].deviceToken); } } for (const id of pushResult.fcmIDs) { ids.push(id); } } const result = {}; if (ids.length > 0) { result.fcmIDs = ids; } if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return { ...result }; } async function fcmSinglePush( provider: FirebaseApp, notification: Object, deviceToken: string, options: Object, ) { try { const deliveryResult = await provider .messaging() .sendToDevice(deviceToken, notification, options); const errors = []; const ids = []; for (const fcmResult of deliveryResult.results) { if (fcmResult.error) { errors.push(fcmResult.error); } else if (fcmResult.messageId) { ids.push(fcmResult.messageId); } } return { fcmIDs: ids, errors }; } catch (e) { return { fcmIDs: [], errors: [e] }; } } async function getUnreadCounts( userIDs: string[], ): Promise<{ [userID: string]: number }> { const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const notificationExtractString = `$.${threadSubscriptions.home}`; const query = SQL` SELECT user, COUNT(thread) AS unread_count FROM memberships WHERE user IN (${userIDs}) AND last_message > last_read_message AND role > 0 AND JSON_EXTRACT(permissions, ${visPermissionExtractString}) AND JSON_EXTRACT(subscription, ${notificationExtractString}) GROUP BY user `; const [result] = await dbQuery(query); const usersToUnreadCounts = {}; for (const row of result) { usersToUnreadCounts[row.user.toString()] = row.unread_count; } for (const userID of userIDs) { if (usersToUnreadCounts[userID] === undefined) { usersToUnreadCounts[userID] = 0; } } return usersToUnreadCounts; } export type WebPushError = { +statusCode: number, +headers: { +[string]: string }, +body: string, }; type WebPushResult = { +success?: true, +errors?: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; async function webPush( targetedNotifications: $ReadOnlyArray, ): Promise { await ensureWebPushInitialized(); const pushResults = await Promise.all( targetedNotifications.map( async ({ notification, deviceToken: deviceTokenString }) => { const deviceToken: PushSubscriptionJSON = JSON.parse(deviceTokenString); const notificationString = JSON.stringify(notification); try { await webpush.sendNotification(deviceToken, notificationString); } catch (error) { return { error }; } return {}; }, ), ); const errors = []; const invalidTokens = []; const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); for (let i = 0; i < pushResults.length; i++) { const pushResult = pushResults[i]; if (pushResult.error) { errors.push(pushResult.error); if (webInvalidTokenErrorCodes.includes(pushResult.error.statusCode)) { invalidTokens.push(deviceTokens[i]); } } } const result = {}; if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return { ...result }; } export type WNSPushError = any | string | Response; type WNSPushResult = { +success?: true, +wnsIDs?: $ReadOnlyArray, +errors?: $ReadOnlyArray, +invalidTokens?: $ReadOnlyArray, }; async function wnsPush( targetedNotifications: $ReadOnlyArray, ): Promise { const token = await getWNSToken(); if (!token && process.env.NODE_ENV === 'development') { console.log(`no keyserver/secrets/wns_config.json so ignoring notifs`); return { success: true }; } invariant(token, `keyserver/secrets/wns_config.json should exist`); const pushResults = targetedNotifications.map(async targetedNotification => { const notificationString = JSON.stringify( targetedNotification.notification, ); try { return await wnsSinglePush( token, notificationString, targetedNotification.deviceToken, ); } catch (error) { return { error }; } }); const errors = []; const notifIDs = []; const invalidTokens = []; const deviceTokens = targetedNotifications.map( ({ deviceToken }) => deviceToken, ); for (let i = 0; i < pushResults.length; i++) { const pushResult = await pushResults[i]; if (pushResult.error) { errors.push(pushResult.error); if ( pushResult.error === 'invalidDomain' || wnsInvalidTokenErrorCodes.includes(pushResult.error?.status) ) { invalidTokens.push(deviceTokens[i]); } } else { notifIDs.push(pushResult.wnsID); } } const result = {}; if (notifIDs.length > 0) { result.wnsIDs = notifIDs; } if (errors.length > 0) { result.errors = errors; } else { result.success = true; } if (invalidTokens.length > 0) { result.invalidTokens = invalidTokens; } return { ...result }; } async function wnsSinglePush(token: string, notification: string, url: string) { const parsedURL = new URL(url); const domain = parsedURL.hostname.split('.').slice(-3); if ( domain[0] !== 'notify' || domain[1] !== 'windows' || domain[2] !== 'com' ) { return { error: 'invalidDomain' }; } try { const result = await nodeFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-WNS-Type': 'wns/raw', 'Authorization': `Bearer ${token}`, }, body: notification, }); if (!result.ok) { return { error: result }; } const wnsID = result.headers.get('X-WNS-MSG-ID'); invariant(wnsID, 'Missing WNS ID'); return { wnsID }; } catch (err) { return { error: err }; } } async function blobServiceUpload(payload: string): Promise< | { +blobHash: string, +encryptionKey: string, } | { +blobUploadError: string }, > { - return upload(payload); + const blobHolder = uuid.v4(); + try { + const { encryptionKey, encryptedPayload, encryptedPayloadHash } = + await encryptBlobPayload(payload); + await upload(encryptedPayload, encryptedPayloadHash, blobHolder); + return { + blobHash: encryptedPayloadHash, + encryptionKey, + }; + } catch (e) { + return { + blobUploadError: e.message, + }; + } } export { apnPush, blobServiceUpload, fcmPush, webPush, wnsPush, getUnreadCounts, apnMaxNotificationPayloadByteSize, fcmMaxNotificationPayloadByteSize, wnsMaxNotificationPayloadByteSize, }; diff --git a/keyserver/src/services/blob.js b/keyserver/src/services/blob.js index 976eb0f0f..271a2285d 100644 --- a/keyserver/src/services/blob.js +++ b/keyserver/src/services/blob.js @@ -1,95 +1,61 @@ // @flow -import crypto from 'crypto'; -import uuid from 'uuid'; - import blobService from 'lib/facts/blob-service.js'; -import { toBase64URL } from 'lib/utils/base64.js'; import { makeBlobServiceEndpointURL } from 'lib/utils/blob-service.js'; import { getMessageForException } from 'lib/utils/errors.js'; -import { encrypt, generateKey } from '../utils/aes-crypto-utils.js'; - -async function upload(payload: string): Promise< - | { - +blobHash: string, - +encryptionKey: string, - } - | { +blobUploadError: string }, -> { - const encryptionKey = await generateKey(); - const encryptedPayloadBuffer = Buffer.from( - await encrypt(encryptionKey, new TextEncoder().encode(payload)), - ); - - const blobHolder = uuid.v4(); - const blobHashBase64 = await crypto - .createHash('sha256') - .update(encryptedPayloadBuffer) - .digest('base64'); - - const blobHash = toBase64URL(blobHashBase64); - +async function upload(blob: Blob, hash: string, holder: string): Promise { const formData = new FormData(); - const payloadBlob = new Blob([encryptedPayloadBuffer]); - - formData.append('blob_hash', blobHash); - formData.append('blob_data', payloadBlob); + formData.append('blob_hash', hash); + formData.append('blob_data', blob); const assignHolderPromise = fetch( makeBlobServiceEndpointURL(blobService.httpEndpoints.ASSIGN_HOLDER), { method: blobService.httpEndpoints.ASSIGN_HOLDER.method, body: JSON.stringify({ - holder: blobHolder, - blob_hash: blobHash, + holder, + blob_hash: hash, }), headers: { 'content-type': 'application/json', }, }, ); const uploadHolderPromise = fetch( makeBlobServiceEndpointURL(blobService.httpEndpoints.UPLOAD_BLOB), { method: blobService.httpEndpoints.UPLOAD_BLOB.method, body: formData, }, ); + let assignHolderResponse, uploadBlobResponse; try { - const [assignHolderResponse, uploadBlobResponse] = await Promise.all([ + [assignHolderResponse, uploadBlobResponse] = await Promise.all([ assignHolderPromise, uploadHolderPromise, ]); - - if (!assignHolderResponse.ok) { - const { status, statusText } = assignHolderResponse; - return { - blobUploadError: `Holder assignment failed with HTTP ${status}: ${statusText}`, - }; - } - - if (!uploadBlobResponse.ok) { - const { status, statusText } = uploadBlobResponse; - return { - blobUploadError: `Payload upload failed with HTTP ${status}: ${statusText}`, - }; - } } catch (e) { - return { - blobUploadError: `Payload upload failed with: ${ + throw new Error( + `Payload upload failed with: ${ getMessageForException(e) ?? 'unknown error' }`, - }; + ); } - const encryptionKeyString = Buffer.from(encryptionKey).toString('base64'); - return { - blobHash, - encryptionKey: encryptionKeyString, - }; + if (!assignHolderResponse.ok) { + const { status, statusText } = assignHolderResponse; + throw new Error( + `Holder assignment failed with HTTP ${status}: ${statusText}`, + ); + } + + if (!uploadBlobResponse.ok) { + const { status, statusText } = uploadBlobResponse; + throw new Error(`Payload upload failed with HTTP ${status}: ${statusText}`); + } } export { upload };