diff --git a/keyserver/src/creators/farcaster-channel-tag-creator.js b/keyserver/src/creators/farcaster-channel-tag-creator.js index ef208c9ce..07ff1f519 100644 --- a/keyserver/src/creators/farcaster-channel-tag-creator.js +++ b/keyserver/src/creators/farcaster-channel-tag-creator.js @@ -1,174 +1,174 @@ // @flow import uuid from 'uuid'; import { farcasterChannelTagBlobHash } from 'lib/shared/community-utils.js'; import type { CreateOrUpdateFarcasterChannelTagRequest, CreateOrUpdateFarcasterChannelTagResponse, } from 'lib/types/community-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; +import type { BlobOperationResult } from 'lib/utils/blob-service.js'; import { ServerError } from 'lib/utils/errors.js'; import { dbQuery, SQL, MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, } from '../database/database.js'; import { fetchCommunityInfos } from '../fetchers/community-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { - uploadBlob, + uploadBlobKeyserverWrapper, assignHolder, download, deleteBlob, - type BlobOperationResult, type BlobDownloadResult, } from '../services/blob.js'; import { Viewer } from '../session/viewer.js'; import { thisKeyserverID } from '../user/identity.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; async function createOrUpdateFarcasterChannelTag( viewer: Viewer, request: CreateOrUpdateFarcasterChannelTagRequest, ): Promise { const permissionPromise = checkThreadPermission( viewer, request.commCommunityID, threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS, ); const [hasPermission, communityInfos, blobDownload, keyserverID] = await Promise.all([ permissionPromise, fetchCommunityInfos(viewer, [request.commCommunityID]), getFarcasterChannelTagBlob(request.farcasterChannelID), thisKeyserverID(), ]); if (!hasPermission) { throw new ServerError('invalid_credentials'); } if (communityInfos.length !== 1) { throw new ServerError('invalid_parameters'); } if (blobDownload.found) { throw new ServerError('already_in_use'); } const communityID = `${keyserverID}|${request.commCommunityID}`; const blobHolder = uuid.v4(); const blobResult = await uploadFarcasterChannelTagBlob( communityID, request.farcasterChannelID, blobHolder, ); if (!blobResult.success) { if (blobResult.reason === 'HASH_IN_USE') { throw new ServerError('already_in_use'); } else { throw new ServerError('unknown_error'); } } const query = SQL` START TRANSACTION; SELECT farcaster_channel_id, blob_holder INTO @currentFarcasterChannelID, @currentBlobHolder FROM communities WHERE id = ${request.commCommunityID} FOR UPDATE; UPDATE communities SET farcaster_channel_id = ${request.farcasterChannelID}, blob_holder = ${blobHolder} WHERE id = ${request.commCommunityID}; COMMIT; SELECT @currentFarcasterChannelID AS oldFarcasterChannelID, @currentBlobHolder AS oldBlobHolder; `; try { const [transactionResult] = await dbQuery(query, { multipleStatements: true, }); const selectResult = transactionResult.pop(); const [{ oldFarcasterChannelID, oldBlobHolder }] = selectResult; if (oldFarcasterChannelID && oldBlobHolder) { await deleteBlob( { hash: farcasterChannelTagBlobHash(oldFarcasterChannelID), holder: oldBlobHolder, }, true, ); } } catch (error) { await deleteBlob( { hash: farcasterChannelTagBlobHash(request.farcasterChannelID), holder: blobHolder, }, true, ); if (error.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } return { commCommunityID: request.commCommunityID, farcasterChannelID: request.farcasterChannelID, }; } function getFarcasterChannelTagBlob( secret: string, ): Promise { const hash = farcasterChannelTagBlobHash(secret); return download(hash); } async function uploadFarcasterChannelTagBlob( commCommunityID: string, farcasterChannelID: string, holder: string, ): Promise { const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const keyserverURL = baseDomain + basePath; const payload = { commCommunityID, farcasterChannelID, keyserverURL, }; const payloadString = JSON.stringify(payload); const hash = farcasterChannelTagBlobHash(farcasterChannelID); const blob = new Blob([payloadString]); - const uploadResult = await uploadBlob(blob, hash); + const uploadResult = await uploadBlobKeyserverWrapper(blob, hash); if (!uploadResult.success) { return uploadResult; } return await assignHolder({ holder, hash }); } export { createOrUpdateFarcasterChannelTag, uploadFarcasterChannelTagBlob }; diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js index 74cef9505..84f7ef46b 100644 --- a/keyserver/src/creators/invite-link-creator.js +++ b/keyserver/src/creators/invite-link-creator.js @@ -1,283 +1,283 @@ // @flow import Filter from 'bad-words'; import uuid from 'uuid'; import { inviteSecretRegex } from 'lib/shared/invite-links-constants.js'; import { inviteLinkBlobHash } from 'lib/shared/invite-links.js'; import { isStaff } from 'lib/shared/staff-utils.js'; import type { CreateOrUpdatePublicLinkRequest, InviteLink, } from 'lib/types/link-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; +import type { BlobOperationResult } from 'lib/utils/blob-service.js'; import { ServerError } from 'lib/utils/errors.js'; import { reservedUsernamesSet } from 'lib/utils/reserved-users.js'; import createIDs from './id-creator.js'; import { dbQuery, MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, SQL, } from '../database/database.js'; import { fetchPrimaryInviteLinks } from '../fetchers/link-fetchers.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { download, type BlobDownloadResult, assignHolder, - uploadBlob, + uploadBlobKeyserverWrapper, deleteBlob, - type BlobOperationResult, } from '../services/blob.js'; import { Viewer } from '../session/viewer.js'; import { thisKeyserverID } from '../user/identity.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; const badWordsFilter = new Filter(); async function createOrUpdatePublicLink( viewer: Viewer, request: CreateOrUpdatePublicLinkRequest, ): Promise { if (!inviteSecretRegex.test(request.name)) { throw new ServerError('invalid_characters'); } if (badWordsFilter.isProfane(request.name)) { throw new ServerError('offensive_words'); } if (!isStaff(viewer.id) && reservedUsernamesSet.has(request.name)) { throw new ServerError('link_reserved'); } const permissionPromise = checkThreadPermission( viewer, request.communityID, threadPermissions.MANAGE_INVITE_LINKS, ); const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer); const threadIDs = new Set([request.communityID]); if (request.threadID) { threadIDs.add(request.threadID); } const fetchThreadInfoPromise = fetchServerThreadInfos({ threadIDs, }); const blobDownloadPromise = getInviteLinkBlob(request.name); const canManageThreadLinksPromise = request.threadID ? checkThreadPermission( viewer, request.threadID, threadPermissions.MANAGE_INVITE_LINKS, ) : false; const [ hasPermission, existingPrimaryLinks, { threadInfos }, blobDownloadResult, canManageThreadLinks, ] = await Promise.all([ permissionPromise, existingPrimaryLinksPromise, fetchThreadInfoPromise, blobDownloadPromise, canManageThreadLinksPromise, ]); if (!hasPermission || (request.threadID && !canManageThreadLinks)) { throw new ServerError('invalid_credentials'); } if (blobDownloadResult.found) { throw new ServerError('already_in_use'); } const defaultRoleIDs: { [string]: string } = {}; for (const threadID of threadIDs) { const threadInfo = threadInfos[threadID]; if (!threadInfo) { throw new ServerError('invalid_parameters'); } const defaultRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].isDefault, ); if (!defaultRoleID) { throw new ServerError('invalid_parameters'); } defaultRoleIDs[threadID] = defaultRoleID; } const existingPrimaryLink = existingPrimaryLinks.find( link => link.communityID === request.communityID && link.primary && (request.threadID ? link.threadID === request.threadID : !link.threadID), ); const blobHolder = uuid.v4(); const blobResult = await uploadInviteLinkBlob(request.name, blobHolder); if (!blobResult.success) { if (blobResult.reason === 'HASH_IN_USE') { throw new ServerError('already_in_use'); } else { throw new ServerError('unknown_error'); } } if (existingPrimaryLink) { const query = SQL` UPDATE invite_links SET name = ${request.name}, blob_holder = ${blobHolder} WHERE \`primary\` = 1 AND community = ${request.communityID} `; if (request.threadID) { query.append(SQL`AND thread = ${request.threadID}`); } else { query.append(SQL`AND thread IS NULL`); } try { await dbQuery(query); const holder = existingPrimaryLink.blobHolder; if (holder) { await deleteBlob( { hash: inviteLinkBlobHash(existingPrimaryLink.name), holder, }, true, ); } } catch (e) { await deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, role: defaultRoleIDs[request.communityID], communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } const [id] = await createIDs('invite_links', 1); const row = [ id, request.name, true, request.communityID, defaultRoleIDs[request.communityID], blobHolder, request.threadID ?? null, request.threadID ? defaultRoleIDs[request.threadID] : null, ]; const createLinkQuery = SQL` INSERT INTO invite_links(id, name, \`primary\`, community, role, blob_holder, thread, thread_role) SELECT ${row} WHERE NOT EXISTS ( SELECT i.id FROM invite_links i WHERE i.\`primary\` = 1 AND i.community = ${request.communityID} `; if (request.threadID) { createLinkQuery.append(SQL`AND thread = ${request.threadID}`); } else { createLinkQuery.append(SQL`AND thread IS NULL`); } createLinkQuery.append(SQL`)`); let result = null; const deleteIDs = SQL` DELETE FROM ids WHERE id = ${id} `; try { result = (await dbQuery(createLinkQuery))[0]; } catch (e) { await Promise.all([ dbQuery(deleteIDs), deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ), ]); if (e.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } if (result.affectedRows === 0) { await Promise.all([ dbQuery(deleteIDs), deleteBlob( { hash: inviteLinkBlobHash(request.name), holder: blobHolder, }, true, ), ]); throw new ServerError('invalid_parameters'); } return { name: request.name, primary: true, role: defaultRoleIDs[request.communityID], communityID: request.communityID, expirationTime: null, limitOfUses: null, numberOfUses: 0, }; } function getInviteLinkBlob(secret: string): Promise { const hash = inviteLinkBlobHash(secret); return download(hash); } async function uploadInviteLinkBlob( linkSecret: string, holder: string, ): Promise { const keyserverID = await thisKeyserverID(); const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const keyserverURL = baseDomain + basePath; const payload = { keyserverID, keyserverURL, }; const payloadString = JSON.stringify(payload); const key = inviteLinkBlobHash(linkSecret); const blob = new Blob([payloadString]); - const uploadResult = await uploadBlob(blob, key); + const uploadResult = await uploadBlobKeyserverWrapper(blob, key); if (!uploadResult.success) { return uploadResult; } return await assignHolder({ holder, hash: key }); } export { createOrUpdatePublicLink, uploadInviteLinkBlob, getInviteLinkBlob }; diff --git a/keyserver/src/push/encrypted-notif-utils-api.js b/keyserver/src/push/encrypted-notif-utils-api.js index ebecee59f..68fbbb7a4 100644 --- a/keyserver/src/push/encrypted-notif-utils-api.js +++ b/keyserver/src/push/encrypted-notif-utils-api.js @@ -1,74 +1,76 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import type { EncryptedNotifUtilsAPI } from 'lib/types/notif-types.js'; import { blobServiceUpload } from './utils.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'; const encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI = { encryptSerializedNotifPayload: async ( cryptoID: string, unencryptedPayload: string, encryptedPayloadSizeValidator?: ( encryptedPayload: string, type: '1' | '0', ) => boolean, ) => { let dbPersistCondition; if (encryptedPayloadSizeValidator) { dbPersistCondition = ({ serializedPayload, }: { +[string]: EncryptResult, }) => encryptedPayloadSizeValidator( serializedPayload.body, serializedPayload.type ? '1' : '0', ); } const { encryptedMessages: { serializedPayload }, dbPersistConditionViolated, encryptionOrder, } = await encryptAndUpdateOlmSession( cryptoID, 'notifications', { serializedPayload: unencryptedPayload, }, dbPersistCondition, ); return { encryptedData: serializedPayload, sizeLimitViolated: dbPersistConditionViolated, encryptionOrder, }; }, uploadLargeNotifPayload: blobServiceUpload, getNotifByteSize: (serializedPayload: string) => Buffer.byteLength(serializedPayload), getEncryptedNotifHash: async (serializedNotification: string) => getOlmUtility().sha256(serializedNotification), getBlobHash: async (blob: Uint8Array) => { return getOlmUtility().sha256(new Uint8Array(blob.buffer)); }, generateAESKey: async () => { const aesKeyBytes = await generateKey(); return Buffer.from(aesKeyBytes).toString('base64'); }, encryptWithAESKey: async (encryptionKey: string, unencryptedData: string) => { const encryptionKeyBytes = new Uint8Array( Buffer.from(encryptionKey, 'base64'), ); const unencryptedDataBytes = new TextEncoder().encode(unencryptedData); return await encrypt(encryptionKeyBytes, unencryptedDataBytes); }, + normalizeUint8ArrayForBlobUpload: (uint8Array: Uint8Array) => + new Blob([uint8Array]), }; export default encryptedNotifUtilsAPI; diff --git a/keyserver/src/services/blob.js b/keyserver/src/services/blob.js index e281cda73..d33261974 100644 --- a/keyserver/src/services/blob.js +++ b/keyserver/src/services/blob.js @@ -1,174 +1,149 @@ // @flow import blobService from 'lib/facts/blob-service.js'; import { getBlobFetchableURL, makeBlobServiceEndpointURL, } from 'lib/utils/blob-service.js'; +import { + uploadBlob, + 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, }; -export type BlobOperationResult = - | { - +success: true, - } - | { - +success: false, - +reason: 'HASH_IN_USE' | 'OTHER', - +status: number, - +statusText: string, - }; - -async function uploadBlob( - blob: Blob, - hash: string, -): Promise { - const formData = new FormData(); - formData.append('blob_hash', hash); - formData.append('blob_data', blob); - - const headers = await createRequestHeaders(false); - const uploadBlobResponse = await fetch( - makeBlobServiceEndpointURL(blobService.httpEndpoints.UPLOAD_BLOB), - { - method: blobService.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 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 }), - uploadBlob(blob, hash), + 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, }); } -export { upload, uploadBlob, assignHolder, download, deleteBlob }; +export { + upload, + uploadBlob, + assignHolder, + download, + deleteBlob, + uploadBlobKeyserverWrapper, +}; diff --git a/lib/facts/blob-service.js b/lib/facts/blob-service.js index de1405916..9c4ffb606 100644 --- a/lib/facts/blob-service.js +++ b/lib/facts/blob-service.js @@ -1,43 +1,47 @@ // @flow import { isDev } from '../utils/dev-utils.js'; -type BlobServicePath = '/blob/:blobHash' | '/blob'; +type BlobServicePath = '/blob/:blobHash' | '/blob' | '/holders'; export type BlobServiceHTTPEndpoint = { +path: BlobServicePath, +method: 'GET' | 'POST' | 'PUT' | 'DELETE', }; type BlobServiceConfig = { +url: string, +httpEndpoints: { +[endpoint: string]: BlobServiceHTTPEndpoint }, }; const httpEndpoints = Object.freeze({ GET_BLOB: { path: '/blob/:blobHash', method: 'GET', }, ASSIGN_HOLDER: { path: '/blob', method: 'POST', }, + ASSIGN_MULTIPLE_HOLDERS: { + path: '/holders', + method: 'POST', + }, UPLOAD_BLOB: { path: '/blob', method: 'PUT', }, DELETE_BLOB: { path: '/blob', method: 'DELETE', }, }); const config: BlobServiceConfig = { url: isDev ? 'https://blob.staging.commtechnologies.org' : 'https://blob.commtechnologies.org', httpEndpoints, }; export default config; diff --git a/lib/push/android-notif-creators.js b/lib/push/android-notif-creators.js index 55addcf16..d48bf8b27 100644 --- a/lib/push/android-notif-creators.js +++ b/lib/push/android-notif-creators.js @@ -1,471 +1,473 @@ // @flow import invariant from 'invariant'; import t, { type TInterface } from 'tcomb'; import { prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareLargeNotifData, + type LargeNotifEncryptionResult, type LargeNotifData, + generateBlobHolders, } from './crypto.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { PlatformDetails } from '../types/device-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type RawMessageInfo, rawMessageInfoValidator, } from '../types/message-types.js'; import { type AndroidVisualNotification, type NotificationTargetDevice, type TargetedAndroidNotification, type ResolvedNotifTexts, resolvedNotifTextsValidator, type SenderDeviceDescriptor, senderDeviceDescriptorValidator, type EncryptedNotifUtilsAPI, type AndroidBadgeOnlyNotification, } from '../types/notif-types.js'; import { tID, tPlatformDetails, tShape } from '../utils/validation-utils.js'; export const fcmMaxNotificationPayloadByteSize = 4000; export type CommonNativeNotifInputData = $ReadOnly<{ +senderDeviceDescriptor: SenderDeviceDescriptor, +notifTexts: ResolvedNotifTexts, +newRawMessageInfos: $ReadOnlyArray, +threadID: string, +collapseKey: ?string, +badgeOnly: boolean, +unreadCount?: number, +platformDetails: PlatformDetails, }>; export const commonNativeNotifInputDataValidator: TInterface = tShape({ senderDeviceDescriptor: senderDeviceDescriptorValidator, notifTexts: resolvedNotifTextsValidator, newRawMessageInfos: t.list(rawMessageInfoValidator), threadID: tID, collapseKey: t.maybe(t.String), badgeOnly: t.Boolean, unreadCount: t.maybe(t.Number), platformDetails: tPlatformDetails, }); export type AndroidNotifInputData = $ReadOnly<{ ...CommonNativeNotifInputData, +notifID: string, }>; export const androidNotifInputDataValidator: TInterface = tShape({ ...commonNativeNotifInputDataValidator.meta.props, notifID: t.String, }); async function createAndroidVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: AndroidNotifInputData, devices: $ReadOnlyArray, largeNotifToEncryptionResultPromises?: { - [string]: Promise, + [string]: Promise, }, ): Promise<{ +targetedNotifications: $ReadOnlyArray, +largeNotifData?: LargeNotifData, }> { const { senderDeviceDescriptor, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, notifID, } = inputData; const canDecryptNonCollapsibleTextNotifs = hasMinCodeVersion( platformDetails, { native: 228 }, ); const isNonCollapsibleTextNotif = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllNotifTypes = hasMinCodeVersion(platformDetails, { native: 267, }); const shouldBeEncrypted = canDecryptAllNotifTypes || (canDecryptNonCollapsibleTextNotifs && isNonCollapsibleTextNotif); const { merged, ...rest } = notifTexts; const notification = { data: { ...rest, threadID, }, }; if (unreadCount !== undefined && unreadCount !== null) { notification.data = { ...notification.data, badge: unreadCount.toString(), }; } let id; if (collapseKey && canDecryptAllNotifTypes) { id = notifID; notification.data = { ...notification.data, collapseKey, }; } else if (collapseKey) { id = collapseKey; } else { id = notifID; } notification.data = { ...notification.data, id, badgeOnly: badgeOnly ? '1' : '0', }; const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, data: { ...notification.data, messageInfos }, }; const priority = 'high'; if (!shouldBeEncrypted) { const notificationToSend = encryptedNotifUtilsAPI.getNotifByteSize( JSON.stringify(copyWithMessageInfos), ) <= fcmMaxNotificationPayloadByteSize ? copyWithMessageInfos : notification; return { targetedNotifications: devices.map(({ deliveryID }) => ({ priority, notification: notificationToSend, deliveryID, })), }; } const notificationsSizeValidator = (notif: AndroidVisualNotification) => { const serializedNotif = JSON.stringify(notif); return ( !serializedNotif || encryptedNotifUtilsAPI.getNotifByteSize(serializedNotif) <= fcmMaxNotificationPayloadByteSize ); }; const notifsWithMessageInfos = await prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, copyWithMessageInfos, notificationsSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cryptoID, deliveryID }) => ({ cryptoID, deliveryID })); if (devicesWithExcessiveSizeNoHolders.length === 0) { return { targetedNotifications: notifsWithMessageInfos.map( ({ notification: notif, deliveryID, encryptionOrder }) => ({ priority, notification: notif, deliveryID, encryptionOrder, }), ), }; } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); let blobHash, blobHolders, encryptionKey, blobUploadError, encryptedCopyWithMessageInfos; const copyWithMessageInfosDataBlob = JSON.stringify( copyWithMessageInfos.data, ); + if ( canQueryBlobService && largeNotifToEncryptionResultPromises && largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob] ) { const largeNotifData = await largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob]; blobHash = largeNotifData.blobHash; encryptionKey = largeNotifData.encryptionKey; - blobHolders = largeNotifData.blobHolders; + blobHolders = generateBlobHolders(devicesWithExcessiveSizeNoHolders.length); encryptedCopyWithMessageInfos = largeNotifData.encryptedCopyWithMessageInfos; } else if (canQueryBlobService && largeNotifToEncryptionResultPromises) { largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob] = prepareLargeNotifData( copyWithMessageInfosDataBlob, - devicesWithExcessiveSizeNoHolders.length, encryptedNotifUtilsAPI, ); const largeNotifData = await largeNotifToEncryptionResultPromises[copyWithMessageInfosDataBlob]; blobHash = largeNotifData.blobHash; encryptionKey = largeNotifData.encryptionKey; - blobHolders = largeNotifData.blobHolders; + blobHolders = generateBlobHolders(devicesWithExcessiveSizeNoHolders.length); encryptedCopyWithMessageInfos = largeNotifData.encryptedCopyWithMessageInfos; } else if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await encryptedNotifUtilsAPI.uploadLargeNotifPayload( copyWithMessageInfosDataBlob, devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${notifID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSizeNoHolders.length ) { notification.data = { ...notification.data, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesWithExcessiveSize, notification, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map(({ notification: notif, deliveryID, encryptionOrder }) => ({ priority, notification: notif, deliveryID, encryptionOrder, })); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deliveryID, encryptionOrder }) => ({ priority, notification: notif, deliveryID, encryptionOrder, }), ); const targetedNotifications = [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; if ( !encryptedCopyWithMessageInfos || !blobHash || !blobHolders || !encryptionKey ) { return { targetedNotifications }; } return { targetedNotifications, largeNotifData: { blobHash, blobHolders, encryptionKey, encryptedCopyWithMessageInfos, }, }; } type AndroidNotificationRescindInputData = { +senderDeviceDescriptor: SenderDeviceDescriptor, +threadID: string, +rescindID?: string, +badge?: string, +platformDetails: PlatformDetails, }; async function createAndroidNotificationRescind( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: AndroidNotificationRescindInputData, devices: $ReadOnlyArray, ): Promise<{ +targetedNotifications: $ReadOnlyArray, }> { const { senderDeviceDescriptor, platformDetails, threadID, rescindID, badge, } = inputData; let notification = { data: { rescind: 'true', setUnreadStatus: 'true', threadID, }, }; if (rescindID && badge) { notification = { ...notification, data: { ...notification.data, badge, rescindID, }, }; } const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 233 }); if (!shouldBeEncrypted) { return { targetedNotifications: devices.map(({ deliveryID }) => ({ notification, deliveryID, priority: 'normal', })), }; } const notifications = await prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); return { targetedNotifications: notifications.map( ({ deliveryID, notification: notif }) => ({ deliveryID, notification: notif, priority: 'normal', }), ), }; } type SenderDescriptorWithPlatformDetails = { +senderDeviceDescriptor: SenderDeviceDescriptor, +platformDetails: PlatformDetails, }; type AndroidBadgeOnlyNotificationInputData = $ReadOnly< | { ...SenderDescriptorWithPlatformDetails, +badge: string, } | { ...SenderDescriptorWithPlatformDetails, +threadID: string }, >; async function createAndroidBadgeOnlyNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: AndroidBadgeOnlyNotificationInputData, devices: $ReadOnlyArray, ): Promise<{ +targetedNotifications: $ReadOnlyArray, }> { const { senderDeviceDescriptor, platformDetails, badge, threadID } = inputData; let notificationData = { badgeOnly: '1' }; if (badge) { notificationData = { ...notificationData, badge, }; } else { invariant( threadID, 'Either badge or threadID must be present in badge only notif', ); notificationData = { ...notificationData, threadID, }; } const notification: AndroidBadgeOnlyNotification = { data: notificationData }; const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 222 }); if (!shouldBeEncrypted) { return { targetedNotifications: devices.map(({ deliveryID }) => ({ notification, deliveryID, priority: 'normal', })), }; } const notifications = await prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, ); return { targetedNotifications: notifications.map( ({ notification: notif, deliveryID, encryptionOrder }) => ({ priority: 'normal', notification: notif, deliveryID, encryptionOrder, }), ), }; } export { createAndroidVisualNotification, createAndroidBadgeOnlyNotification, createAndroidNotificationRescind, }; diff --git a/lib/push/apns-notif-creators.js b/lib/push/apns-notif-creators.js index f31d71fdf..29eef1cca 100644 --- a/lib/push/apns-notif-creators.js +++ b/lib/push/apns-notif-creators.js @@ -1,577 +1,579 @@ // @flow import invariant from 'invariant'; import t, { type TInterface } from 'tcomb'; import { type CommonNativeNotifInputData, commonNativeNotifInputDataValidator, } from './android-notif-creators.js'; import { prepareEncryptedAPNsVisualNotifications, prepareEncryptedAPNsSilentNotifications, prepareLargeNotifData, type LargeNotifData, + type LargeNotifEncryptionResult, + generateBlobHolders, } from './crypto.js'; import { getAPNsNotificationTopic } from '../shared/notif-utils.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { PlatformDetails } from '../types/device-types.js'; import { messageTypes } from '../types/message-types-enum.js'; import { type NotificationTargetDevice, type EncryptedNotifUtilsAPI, type TargetedAPNsNotification, type APNsVisualNotification, type APNsNotificationHeaders, type SenderDeviceDescriptor, } from '../types/notif-types.js'; import { tShape } from '../utils/validation-utils.js'; export const apnMaxNotificationPayloadByteSize = 4096; export type APNsNotifInputData = $ReadOnly<{ ...CommonNativeNotifInputData, +uniqueID: string, }>; export const apnsNotifInputDataValidator: TInterface = tShape({ ...commonNativeNotifInputDataValidator.meta.props, uniqueID: t.String, }); async function createAPNsVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: APNsNotifInputData, devices: $ReadOnlyArray, largeNotifToEncryptionResultPromises?: { - [string]: Promise, + [string]: Promise, }, ): Promise<{ +targetedNotifications: $ReadOnlyArray, +largeNotifData?: LargeNotifData, }> { const { senderDeviceDescriptor, notifTexts, newRawMessageInfos, threadID, collapseKey, badgeOnly, unreadCount, platformDetails, uniqueID, } = inputData; const canDecryptNonCollapsibleTextIOSNotifs = hasMinCodeVersion( platformDetails, { native: 222 }, ); const isNonCollapsibleTextNotification = newRawMessageInfos.every( newRawMessageInfo => newRawMessageInfo.type === messageTypes.TEXT, ) && !collapseKey; const canDecryptAllIOSNotifs = hasMinCodeVersion(platformDetails, { native: 267, }); const canDecryptIOSNotif = platformDetails.platform === 'ios' && (canDecryptAllIOSNotifs || (isNonCollapsibleTextNotification && canDecryptNonCollapsibleTextIOSNotifs)); const canDecryptMacOSNotifs = platformDetails.platform === 'macos' && hasMinCodeVersion(platformDetails, { web: 47, majorDesktop: 9, }); let apsDictionary = { 'thread-id': threadID, }; if (unreadCount !== undefined && unreadCount !== null) { apsDictionary = { ...apsDictionary, badge: unreadCount, }; } const { merged, ...rest } = notifTexts; // We don't include alert's body on macos because we // handle displaying the notification ourselves and // we don't want macOS to display it automatically. if (!badgeOnly && platformDetails.platform !== 'macos') { apsDictionary = { ...apsDictionary, alert: merged, sound: 'default', }; } if (hasMinCodeVersion(platformDetails, { native: 198 })) { apsDictionary = { ...apsDictionary, 'mutable-content': 1, }; } let notificationPayload = { ...rest, id: uniqueID, threadID, }; let notificationHeaders: APNsNotificationHeaders = { 'apns-topic': getAPNsNotificationTopic(platformDetails), 'apns-id': uniqueID, 'apns-push-type': 'alert', }; if (collapseKey && (canDecryptAllIOSNotifs || canDecryptMacOSNotifs)) { notificationPayload = { ...notificationPayload, collapseID: collapseKey, }; } else if (collapseKey) { notificationHeaders = { ...notificationHeaders, 'apns-collapse-id': collapseKey, }; } const notification = { ...notificationPayload, headers: notificationHeaders, aps: apsDictionary, }; const messageInfos = JSON.stringify(newRawMessageInfos); const copyWithMessageInfos = { ...notification, messageInfos, }; const notificationSizeValidator = (notif: APNsVisualNotification) => { const { headers, ...notifSansHeaders } = notif; return ( encryptedNotifUtilsAPI.getNotifByteSize( JSON.stringify(notifSansHeaders), ) <= apnMaxNotificationPayloadByteSize ); }; const serializeAPNsNotif = (notif: APNsVisualNotification) => { const { headers, ...notifSansHeaders } = notif; return JSON.stringify(notifSansHeaders); }; const shouldBeEncrypted = canDecryptIOSNotif || canDecryptMacOSNotifs; if (!shouldBeEncrypted) { const notificationToSend = notificationSizeValidator(copyWithMessageInfos) ? copyWithMessageInfos : notification; const targetedNotifications = devices.map(({ deliveryID }) => ({ notification: notificationToSend, deliveryID, })); return { targetedNotifications, }; } // The `messageInfos` field in notification payload is // not used on MacOS so we can return early. if (platformDetails.platform === 'macos') { const macOSNotifsWithoutMessageInfos = await prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, platformDetails.codeVersion, ); const targetedNotifications = macOSNotifsWithoutMessageInfos.map( ({ notification: notif, deliveryID }) => ({ notification: notif, deliveryID, }), ); return { targetedNotifications }; } const notifsWithMessageInfos = await prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, copyWithMessageInfos, platformDetails.codeVersion, notificationSizeValidator, ); const devicesWithExcessiveSizeNoHolders = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => payloadSizeExceeded) .map(({ cryptoID, deliveryID }) => ({ cryptoID, deliveryID, })); if (devicesWithExcessiveSizeNoHolders.length === 0) { const targetedNotifications = notifsWithMessageInfos.map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); return { targetedNotifications, }; } const canQueryBlobService = hasMinCodeVersion(platformDetails, { native: 331, }); let blobHash, blobHolders, encryptionKey, blobUploadError, encryptedCopyWithMessageInfos; const copyWithMessageInfosBlob = serializeAPNsNotif(copyWithMessageInfos); if ( canQueryBlobService && largeNotifToEncryptionResultPromises && largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob] ) { const largeNotifData = await largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob]; blobHash = largeNotifData.blobHash; encryptionKey = largeNotifData.encryptionKey; - blobHolders = largeNotifData.blobHolders; + blobHolders = generateBlobHolders(devicesWithExcessiveSizeNoHolders.length); encryptedCopyWithMessageInfos = largeNotifData.encryptedCopyWithMessageInfos; } else if (canQueryBlobService && largeNotifToEncryptionResultPromises) { largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob] = prepareLargeNotifData( copyWithMessageInfosBlob, - devicesWithExcessiveSizeNoHolders.length, + encryptedNotifUtilsAPI, ); const largeNotifData = await largeNotifToEncryptionResultPromises[copyWithMessageInfosBlob]; blobHash = largeNotifData.blobHash; encryptionKey = largeNotifData.encryptionKey; - blobHolders = largeNotifData.blobHolders; + blobHolders = generateBlobHolders(devicesWithExcessiveSizeNoHolders.length); encryptedCopyWithMessageInfos = largeNotifData.encryptedCopyWithMessageInfos; } else if (canQueryBlobService) { ({ blobHash, blobHolders, encryptionKey, blobUploadError } = await encryptedNotifUtilsAPI.uploadLargeNotifPayload( copyWithMessageInfosBlob, devicesWithExcessiveSizeNoHolders.length, )); } if (blobUploadError) { console.warn( `Failed to upload payload of notification: ${uniqueID} ` + `due to error: ${blobUploadError}`, ); } let devicesWithExcessiveSize = devicesWithExcessiveSizeNoHolders; let notificationWithBlobMetadata = notification; if ( blobHash && encryptionKey && blobHolders && blobHolders.length === devicesWithExcessiveSize.length ) { notificationWithBlobMetadata = { ...notification, blobHash, encryptionKey, }; devicesWithExcessiveSize = blobHolders.map((holder, idx) => ({ ...devicesWithExcessiveSize[idx], blobHolder: holder, })); } const notifsWithoutMessageInfos = await prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesWithExcessiveSize, notificationWithBlobMetadata, platformDetails.codeVersion, ); const targetedNotifsWithMessageInfos = notifsWithMessageInfos .filter(({ payloadSizeExceeded }) => !payloadSizeExceeded) .map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); const targetedNotifsWithoutMessageInfos = notifsWithoutMessageInfos.map( ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }) => ({ notification: notif, deliveryID, encryptedPayloadHash, encryptionOrder, }), ); const targetedNotifications = [ ...targetedNotifsWithMessageInfos, ...targetedNotifsWithoutMessageInfos, ]; if ( !encryptedCopyWithMessageInfos || !blobHolders || !blobHash || !encryptionKey ) { return { targetedNotifications }; } return { targetedNotifications, largeNotifData: { blobHash, blobHolders, encryptionKey, encryptedCopyWithMessageInfos, }, }; } type APNsNotificationRescindInputData = { +senderDeviceDescriptor: SenderDeviceDescriptor, +rescindID?: string, +badge?: number, +threadID: string, +platformDetails: PlatformDetails, }; async function createAPNsNotificationRescind( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: APNsNotificationRescindInputData, devices: $ReadOnlyArray, ): Promise<{ +targetedNotifications: $ReadOnlyArray, }> { const { badge, rescindID, threadID, platformDetails, senderDeviceDescriptor, } = inputData; const apnsTopic = getAPNsNotificationTopic(platformDetails); let notification; if ( rescindID && badge !== null && badge !== undefined && hasMinCodeVersion(platformDetails, { native: 198 }) ) { notification = { headers: { 'apns-topic': apnsTopic, 'apns-push-type': 'alert', }, aps: { 'mutable-content': 1, 'badge': badge, }, threadID, notificationId: rescindID, backgroundNotifType: 'CLEAR', setUnreadStatus: true, }; } else if (rescindID && badge !== null && badge !== undefined) { notification = { headers: { 'apns-topic': apnsTopic, 'apns-push-type': 'background', 'apns-priority': 5, }, aps: { 'mutable-content': 1, 'badge': badge, }, threadID, notificationId: rescindID, backgroundNotifType: 'CLEAR', setUnreadStatus: true, }; } else { notification = { headers: { 'apns-topic': apnsTopic, 'apns-push-type': 'alert', }, aps: { 'mutable-content': 1, }, threadID, backgroundNotifType: 'CLEAR', setUnreadStatus: true, }; } const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 233 }); if (!shouldBeEncrypted) { return { targetedNotifications: devices.map(({ deliveryID }) => ({ notification, deliveryID, })), }; } const notifications = await prepareEncryptedAPNsSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, platformDetails.codeVersion, ); return { targetedNotifications: notifications.map( ({ deliveryID, notification: notif }) => ({ deliveryID, notification: notif, }), ), }; } type SenderDescriptorWithPlatformDetails = { +senderDeviceDescriptor: SenderDeviceDescriptor, +platformDetails: PlatformDetails, }; type APNsBadgeOnlyNotificationInputData = $ReadOnly< | { ...SenderDescriptorWithPlatformDetails, +badge: string, } | { ...SenderDescriptorWithPlatformDetails, +threadID: string }, >; async function createAPNsBadgeOnlyNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, inputData: APNsBadgeOnlyNotificationInputData, devices: $ReadOnlyArray, ): Promise<{ +targetedNotifications: $ReadOnlyArray, +largeNotifData?: LargeNotifData, }> { const { senderDeviceDescriptor, platformDetails, threadID, badge } = inputData; const shouldBeEncrypted = hasMinCodeVersion(platformDetails, { native: 222, web: 47, majorDesktop: 9, }); const headers: APNsNotificationHeaders = { 'apns-topic': getAPNsNotificationTopic(platformDetails), 'apns-push-type': 'alert', }; let notification; if (shouldBeEncrypted && threadID) { notification = { headers, threadID, aps: { 'mutable-content': 1, }, }; } else if (shouldBeEncrypted && badge !== undefined && badge !== null) { notification = { headers, aps: { 'badge': badge, 'mutable-content': 1, }, }; } else { invariant( badge !== null && badge !== undefined, 'badge update must contain either badge count or threadID', ); notification = { headers, aps: { badge, }, }; } if (!shouldBeEncrypted) { return { targetedNotifications: devices.map(({ deliveryID }) => ({ deliveryID, notification, })), }; } const notifications = await prepareEncryptedAPNsSilentNotifications( encryptedNotifUtilsAPI, senderDeviceDescriptor, devices, notification, platformDetails.codeVersion, ); return { targetedNotifications: notifications.map( ({ deliveryID, notification: notif }) => ({ deliveryID, notification: notif, }), ), }; } export { createAPNsBadgeOnlyNotification, createAPNsNotificationRescind, createAPNsVisualNotification, }; diff --git a/lib/push/crypto.js b/lib/push/crypto.js index a3ff0ffe8..c50fb87f1 100644 --- a/lib/push/crypto.js +++ b/lib/push/crypto.js @@ -1,653 +1,660 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import type { PlainTextWebNotification, PlainTextWebNotificationPayload, WebNotification, PlainTextWNSNotification, WNSNotification, AndroidVisualNotification, AndroidVisualNotificationPayload, AndroidBadgeOnlyNotification, AndroidNotificationRescind, NotificationTargetDevice, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, APNsVisualNotification, APNsNotificationRescind, APNsBadgeOnlyNotification, } from '../types/notif-types.js'; +import { toBase64URL } from '../utils/base64.js'; async function encryptAndroidNotificationPayload( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, unencryptedPayload: T, payloadSizeValidator?: ( | T | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '1' | '0', }>, ) => boolean, ): Promise<{ +resultPayload: | T | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '1' | '0', }>, +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: string, type: '1' | '0') => payloadSizeValidator({ encryptedPayload: serializedPayload, type, ...senderDeviceDescriptor, }); } const { encryptedData: serializedPayload, sizeLimitViolated: dbPersistConditionViolated, encryptionOrder, } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, dbPersistCondition, ); return { resultPayload: { ...senderDeviceDescriptor, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', }, 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 encryptAPNsVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: APNsVisualNotification, notificationSizeValidator?: APNsVisualNotification => boolean, codeVersion?: ?number, blobHolder?: ?string, ): Promise<{ +notification: APNsVisualNotification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }> { const { id, headers, aps: { badge, alert, sound }, ...rest } = notification; invariant( !headers['apns-collapse-id'], `Collapse ID can't be directly stored in APNsVisualNotification object due ` + `to security reasons. Please put it in payload property`, ); let unencryptedPayload = { ...rest, merged: alert, badge, }; if (blobHolder) { unencryptedPayload = { ...unencryptedPayload, blobHolder }; } try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let encryptedNotifAps = { 'mutable-content': 1, sound }; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotifAps = { ...encryptedNotifAps, alert: { body: 'ENCRYPTED' }, }; } let dbPersistCondition; if (notificationSizeValidator) { dbPersistCondition = (encryptedPayload: string, type: '0' | '1') => notificationSizeValidator({ ...senderDeviceDescriptor, id, headers, encryptedPayload, type, aps: encryptedNotifAps, }); } const { encryptedData: serializedPayload, sizeLimitViolated: dbPersistConditionViolated, encryptionOrder, } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, dbPersistCondition, ); const encryptedPayloadHash = await encryptedNotifUtilsAPI.getEncryptedNotifHash( serializedPayload.body, ); return { notification: { ...senderDeviceDescriptor, id, headers, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', aps: encryptedNotifAps, }, payloadSizeExceeded: !!dbPersistConditionViolated, encryptedPayloadHash, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed', e); const unencryptedNotification = { ...notification, encryptionFailed: '1' }; return { notification: unencryptedNotification, payloadSizeExceeded: notificationSizeValidator ? notificationSizeValidator(unencryptedNotification) : false, }; } } async function encryptAPNsSilentNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: APNsNotificationRescind | APNsBadgeOnlyNotification, codeVersion?: ?number, ): Promise<{ +notification: APNsNotificationRescind | APNsBadgeOnlyNotification, +encryptedPayloadHash?: string, +encryptionOrder?: number, }> { const { headers, aps: { badge }, ...rest } = notification; let unencryptedPayload = { ...rest, }; if (badge !== null && badge !== undefined) { unencryptedPayload = { ...unencryptedPayload, badge, aps: {} }; } try { const unencryptedSerializedPayload = JSON.stringify(unencryptedPayload); let encryptedNotifAps = { 'mutable-content': 1 }; if (codeVersion && codeVersion >= 254 && codeVersion % 2 === 0) { encryptedNotifAps = { ...encryptedNotifAps, alert: { body: 'ENCRYPTED' }, }; } const { encryptedData: serializedPayload, encryptionOrder } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, ); const encryptedPayloadHash = await encryptedNotifUtilsAPI.getEncryptedNotifHash( serializedPayload.body, ); return { notification: { ...senderDeviceDescriptor, headers, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', aps: encryptedNotifAps, }, encryptedPayloadHash, encryptionOrder, }; } catch (e) { console.log('Notification encryption failed', e); const unencryptedNotification = { ...notification, encryptionFailed: '1' }; return { notification: unencryptedNotification, }; } } async function encryptAndroidVisualNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, cryptoID: string, notification: AndroidVisualNotification, notificationSizeValidator?: AndroidVisualNotification => boolean, blobHolder?: ?string, ): Promise<{ +notification: AndroidVisualNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }> { const { id, ...rest } = notification.data; let unencryptedData = {}; if (id) { unencryptedData = { id }; } let unencryptedPayload = rest; if (blobHolder) { unencryptedPayload = { ...unencryptedPayload, blobHolder }; } let payloadSizeValidator; if (notificationSizeValidator) { payloadSizeValidator = ( payload: | AndroidVisualNotificationPayload | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '0' | '1', }>, ) => { return notificationSizeValidator({ data: { ...unencryptedData, ...payload }, }); }; } const { resultPayload, payloadSizeExceeded, encryptionOrder } = await encryptAndroidNotificationPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, unencryptedPayload, payloadSizeValidator, ); return { notification: { data: { ...unencryptedData, ...resultPayload, }, }, payloadSizeExceeded, encryptionOrder, }; } async function encryptAndroidSilentNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, ): Promise { // We don't validate payload size for rescind // since they are expected to be small and // never exceed any FCM limit const { ...unencryptedPayload } = notification.data; const { resultPayload } = await encryptAndroidNotificationPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, unencryptedPayload, ); if (resultPayload.encryptedPayload) { return { data: { ...resultPayload }, }; } if (resultPayload.rescind) { return { data: { ...resultPayload }, }; } return { data: { ...resultPayload, }, }; } async function encryptBasicPayload( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, basicPayload: T, ): Promise< | $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '1' | '0', +encryptionOrder?: number, }> | { ...T, +encryptionFailed: '1' }, > { const unencryptedSerializedPayload = JSON.stringify(basicPayload); if (!unencryptedSerializedPayload) { return { ...basicPayload, encryptionFailed: '1' }; } try { const { encryptedData: serializedPayload, encryptionOrder } = await encryptedNotifUtilsAPI.encryptSerializedNotifPayload( cryptoID, unencryptedSerializedPayload, ); return { ...senderDeviceDescriptor, encryptedPayload: serializedPayload.body, type: serializedPayload.type ? '1' : '0', encryptionOrder, }; } catch (e) { console.log('Notification encryption failed', e); return { ...basicPayload, encryptionFailed: '1', }; } } async function encryptWebNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: PlainTextWebNotification, ): Promise<{ +notification: WebNotification, +encryptionOrder?: number }> { const { id, ...payloadSansId } = notification; const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, payloadSansId, ); return { notification: { id, ...encryptionResult }, encryptionOrder, }; } async function encryptWNSNotification( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, cryptoID: string, senderDeviceDescriptor: SenderDeviceDescriptor, notification: PlainTextWNSNotification, ): Promise<{ +notification: WNSNotification, +encryptionOrder?: number }> { const { encryptionOrder, ...encryptionResult } = await encryptBasicPayload( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { notification: { ...encryptionResult }, encryptionOrder, }; } function prepareEncryptedAPNsVisualNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: APNsVisualNotification, codeVersion?: ?number, notificationSizeValidator?: APNsVisualNotification => boolean, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: APNsVisualNotification, +payloadSizeExceeded: boolean, +encryptedPayloadHash?: string, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ cryptoID, deliveryID, blobHolder }) => { const notif = await encryptAPNsVisualNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, notificationSizeValidator, codeVersion, blobHolder, ); return { cryptoID, deliveryID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAPNsSilentNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: APNsNotificationRescind | APNsBadgeOnlyNotification, codeVersion?: ?number, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: APNsNotificationRescind | APNsBadgeOnlyNotification, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const { notification: notif } = await encryptAPNsSilentNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, codeVersion, ); return { cryptoID, deliveryID, notification: notif }; }); return Promise.all(notificationPromises); } function prepareEncryptedAndroidVisualNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: AndroidVisualNotification, notificationSizeValidator?: ( notification: AndroidVisualNotification, ) => boolean, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: AndroidVisualNotification, +payloadSizeExceeded: boolean, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map( async ({ deliveryID, cryptoID, blobHolder }) => { const notif = await encryptAndroidVisualNotification( encryptedNotifUtilsAPI, senderDeviceDescriptor, cryptoID, notification, notificationSizeValidator, blobHolder, ); return { deliveryID, cryptoID, ...notif }; }, ); return Promise.all(notificationPromises); } function prepareEncryptedAndroidSilentNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, ): Promise< $ReadOnlyArray<{ +cryptoID: string, +deliveryID: string, +notification: AndroidNotificationRescind | AndroidBadgeOnlyNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const notif = await encryptAndroidSilentNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { deliveryID, cryptoID, notification: notif }; }); return Promise.all(notificationPromises); } function prepareEncryptedWebNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: PlainTextWebNotification, ): Promise< $ReadOnlyArray<{ +deliveryID: string, +notification: WebNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const notif = await encryptWebNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { ...notif, deliveryID }; }); return Promise.all(notificationPromises); } function prepareEncryptedWNSNotifications( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devices: $ReadOnlyArray, notification: PlainTextWNSNotification, ): Promise< $ReadOnlyArray<{ +deliveryID: string, +notification: WNSNotification, +encryptionOrder?: number, }>, > { const notificationPromises = devices.map(async ({ deliveryID, cryptoID }) => { const notif = await encryptWNSNotification( encryptedNotifUtilsAPI, cryptoID, senderDeviceDescriptor, notification, ); return { ...notif, deliveryID }; }); return Promise.all(notificationPromises); } -export type LargeNotifData = { - +blobHolders: $ReadOnlyArray, +export type LargeNotifEncryptionResult = { +blobHash: string, +encryptionKey: string, +encryptedCopyWithMessageInfos: Uint8Array, }; +export type LargeNotifData = $ReadOnly<{ + ...LargeNotifEncryptionResult, + +blobHolders: $ReadOnlyArray, +}>; + +function generateBlobHolders(numberOfDevices: number): $ReadOnlyArray { + return Array.from({ length: numberOfDevices }, () => uuid.v4()); +} + async function prepareLargeNotifData( copyWithMessageInfos: string, - numberOfDevices: number, encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, -): Promise { +): Promise { const encryptionKey = await encryptedNotifUtilsAPI.generateAESKey(); - - const blobHolders = Array.from({ length: numberOfDevices }, () => uuid.v4()); const encryptedCopyWithMessageInfos = await encryptedNotifUtilsAPI.encryptWithAESKey( encryptionKey, copyWithMessageInfos, ); const blobHash = await encryptedNotifUtilsAPI.getBlobHash( encryptedCopyWithMessageInfos, ); + const blobHashBase64url = toBase64URL(blobHash); return { - blobHolders, - blobHash, + blobHash: blobHashBase64url, encryptedCopyWithMessageInfos, encryptionKey, }; } export { prepareEncryptedAPNsVisualNotifications, prepareEncryptedAPNsSilentNotifications, prepareEncryptedAndroidVisualNotifications, prepareEncryptedAndroidSilentNotifications, prepareEncryptedWebNotifications, prepareEncryptedWNSNotifications, prepareLargeNotifData, + generateBlobHolders, }; diff --git a/lib/push/send-hooks.react.js b/lib/push/send-hooks.react.js index 9edc946d9..6644dec8e 100644 --- a/lib/push/send-hooks.react.js +++ b/lib/push/send-hooks.react.js @@ -1,241 +1,337 @@ // @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 { thickRawThreadInfosSelector } from '../selectors/thread-selectors.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 thickRawThreadInfos = useSelector(thickRawThreadInfosSelector); 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 { deviceID, userID: senderUserID } = await getAuthMetadata(); + const authMetadata = await getAuthMetadata(); + const { deviceID, userID: senderUserID } = authMetadata; + if (!deviceID || !senderUserID) { return; } const senderDeviceDescriptor = { senderDeviceID: deviceID }; const senderInfo = { senderUserID, senderDeviceDescriptor, }; const { messageDatasWithMessageInfos, rescindData, badgeUpdateData } = notifCreationData; const pushNotifsPreparationInput = { encryptedNotifUtilsAPI, senderDeviceDescriptor, olmSessionCreator, messageInfos: rawMessageInfos, thickRawThreadInfos, auxUserInfos, messageDatasWithMessageInfos, 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, thickRawThreadInfos, 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.success) { + return; + } + + if (assignmentResult.error) { + const { statusText } = assignmentResult; + console.log(`Failed to assign all holders. Status text: ${statusText}`); + return; + } + + for (const [blobHash, holder] of assignmentResult.failedAssignments) { + console.log(`Assingnemt failed for holder: ${holder} and hash ${blobHash}`); + } +} export { useSendPushNotifs }; diff --git a/lib/push/send-utils.js b/lib/push/send-utils.js index dfc3bf973..d801666e3 100644 --- a/lib/push/send-utils.js +++ b/lib/push/send-utils.js @@ -1,1315 +1,1315 @@ // @flow import _pickBy from 'lodash/fp/pickBy.js'; import uuidv4 from 'uuid/v4.js'; import { createAndroidVisualNotification, createAndroidBadgeOnlyNotification, createAndroidNotificationRescind, } from './android-notif-creators.js'; import { createAPNsVisualNotification, createAPNsBadgeOnlyNotification, createAPNsNotificationRescind, } from './apns-notif-creators.js'; -import type { LargeNotifData } from './crypto.js'; +import type { LargeNotifEncryptionResult, LargeNotifData } from './crypto'; import { stringToVersionKey, getDevicesByPlatform, generateNotifUserInfoPromise, userAllowsNotif, } from './utils.js'; import { createWebNotification } from './web-notif-creators.js'; import { createWNSNotification } from './wns-notif-creators.js'; import { hasPermission } from '../permissions/minimally-encoded-thread-permissions.js'; import { createMessageInfo, shimUnsupportedRawMessageInfos, sortMessageInfoList, } from '../shared/message-utils.js'; import { pushTypes } from '../shared/messages/message-spec.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import { notifTextsForMessageInfo, getNotifCollapseKey, } from '../shared/notif-utils.js'; import { isMemberActive, threadInfoFromRawThreadInfo, } from '../shared/thread-utils.js'; import { hasMinCodeVersion } from '../shared/version-utils.js'; import type { AuxUserInfos } from '../types/aux-user-types.js'; import type { PlatformDetails, Platform } from '../types/device-types.js'; import { identityDeviceTypeToPlatform, type IdentityPlatformDetails, } from '../types/identity-service-types.js'; import { type MessageData, type RawMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ResolvedNotifTexts, NotificationTargetDevice, TargetedNotificationWithPlatform, SenderDeviceDescriptor, EncryptedNotifUtilsAPI, } from '../types/notif-types.js'; import type { ThreadSubscription } from '../types/subscription-types.js'; import type { ThickRawThreadInfos } from '../types/thread-types.js'; import type { UserInfos } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; import type { DeviceSessionCreationRequest } from '../utils/crypto-utils.js'; import { type GetENSNames } from '../utils/ens-helpers.js'; import { type GetFCNames } from '../utils/farcaster-helpers.js'; import { values } from '../utils/objects.js'; import { promiseAll } from '../utils/promises.js'; export type Device = { +platformDetails: PlatformDetails, +deliveryID: string, +cryptoID: string, }; export type ThreadSubscriptionWithRole = $ReadOnly<{ ...ThreadSubscription, +role: ?string, }>; export type PushUserInfo = { +devices: $ReadOnlyArray, +messageInfos: RawMessageInfo[], +messageDatas: MessageData[], +subscriptions?: { +[threadID: string]: ThreadSubscriptionWithRole, }, }; export type PushInfo = { +[userID: string]: PushUserInfo }; export type CollapsableNotifInfo = { collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], }; export type FetchCollapsableNotifsResult = { +[userID: string]: $ReadOnlyArray, }; function identityPlatformDetailsToPlatformDetails( identityPlatformDetails: IdentityPlatformDetails, ): PlatformDetails { const { deviceType, ...rest } = identityPlatformDetails; return { ...rest, platform: identityDeviceTypeToPlatform[deviceType], }; } async function getPushUserInfo( messageInfos: { +[id: string]: RawMessageInfo }, thickRawThreadInfos: ThickRawThreadInfos, auxUserInfos: AuxUserInfos, messageDataWithMessageInfos: ?$ReadOnlyArray<{ +messageData: MessageData, +rawMessageInfo: RawMessageInfo, }>, ): Promise<{ +pushInfos: ?PushInfo, +rescindInfos: ?PushInfo, }> { if (!messageDataWithMessageInfos) { return { pushInfos: null, rescindInfos: null }; } const threadsToMessageIndices: Map = new Map(); const newMessageInfos: RawMessageInfo[] = []; const messageDatas: MessageData[] = []; let nextNewMessageIndex = 0; for (const messageDataWithInfo of messageDataWithMessageInfos) { const { messageData, rawMessageInfo } = messageDataWithInfo; const threadID = messageData.threadID; let messageIndices = threadsToMessageIndices.get(threadID); if (!messageIndices) { messageIndices = []; threadsToMessageIndices.set(threadID, messageIndices); } const newMessageIndex = nextNewMessageIndex++; messageIndices.push(newMessageIndex); messageDatas.push(messageData); newMessageInfos.push(rawMessageInfo); } const pushUserThreadInfos: { [userID: string]: { devices: $ReadOnlyArray, threadsWithSubscriptions: { [threadID: string]: ThreadSubscriptionWithRole, }, }, } = {}; for (const threadID of threadsToMessageIndices.keys()) { const threadInfo = thickRawThreadInfos[threadID]; for (const memberInfo of threadInfo.members) { if ( !isMemberActive(memberInfo) || !hasPermission(memberInfo.permissions, 'visible') ) { continue; } if (pushUserThreadInfos[memberInfo.id]) { pushUserThreadInfos[memberInfo.id].threadsWithSubscriptions[threadID] = { ...memberInfo.subscription, role: memberInfo.role }; continue; } const devicesPlatformDetails = auxUserInfos[memberInfo.id].devicesPlatformDetails; if (!devicesPlatformDetails) { continue; } const devices = Object.entries(devicesPlatformDetails).map( ([deviceID, identityPlatformDetails]) => ({ platformDetails: identityPlatformDetailsToPlatformDetails( identityPlatformDetails, ), deliveryID: deviceID, cryptoID: deviceID, }), ); pushUserThreadInfos[memberInfo.id] = { devices, threadsWithSubscriptions: { [threadID]: { ...memberInfo.subscription, role: memberInfo.role }, }, }; } } const userPushInfoPromises: { [string]: Promise } = {}; const userRescindInfoPromises: { [string]: Promise } = {}; for (const userID in pushUserThreadInfos) { const pushUserThreadInfo = pushUserThreadInfos[userID]; userPushInfoPromises[userID] = (async () => { const pushInfosWithoutSubscriptions = await generateNotifUserInfoPromise({ pushType: pushTypes.NOTIF, devices: pushUserThreadInfo.devices, newMessageInfos, messageDatas, threadsToMessageIndices, threadIDs: Object.keys(pushUserThreadInfo.threadsWithSubscriptions), userNotMemberOfSubthreads: new Set(), fetchMessageInfoByID: (messageID: string) => (async () => messageInfos[messageID])(), userID, }); if (!pushInfosWithoutSubscriptions) { return null; } return { ...pushInfosWithoutSubscriptions, subscriptions: pushUserThreadInfo.threadsWithSubscriptions, }; })(); userRescindInfoPromises[userID] = (async () => { const pushInfosWithoutSubscriptions = await generateNotifUserInfoPromise({ pushType: pushTypes.RESCIND, devices: pushUserThreadInfo.devices, newMessageInfos, messageDatas, threadsToMessageIndices, threadIDs: Object.keys(pushUserThreadInfo.threadsWithSubscriptions), userNotMemberOfSubthreads: new Set(), fetchMessageInfoByID: (messageID: string) => (async () => messageInfos[messageID])(), userID, }); if (!pushInfosWithoutSubscriptions) { return null; } return { ...pushInfosWithoutSubscriptions, subscriptions: pushUserThreadInfo.threadsWithSubscriptions, }; })(); } const [pushInfo, rescindInfo] = await Promise.all([ promiseAll(userPushInfoPromises), promiseAll(userRescindInfoPromises), ]); return { pushInfos: _pickBy(Boolean)(pushInfo), rescindInfos: _pickBy(Boolean)(rescindInfo), }; } type SenderInfo = { +senderUserID: string, +senderDeviceDescriptor: SenderDeviceDescriptor, }; type OwnDevicesPushInfo = { +devices: $ReadOnlyArray, }; function getOwnDevicesPushInfo( senderInfo: SenderInfo, auxUserInfos: AuxUserInfos, ): ?OwnDevicesPushInfo { const { senderUserID, senderDeviceDescriptor: { senderDeviceID }, } = senderInfo; if (!senderDeviceID) { return null; } const senderDevicesWithPlatformDetails = auxUserInfos[senderUserID].devicesPlatformDetails; if (!senderDevicesWithPlatformDetails) { return null; } const devices = Object.entries(senderDevicesWithPlatformDetails) .filter(([deviceID]) => deviceID !== senderDeviceID) .map(([deviceID, identityPlatformDetails]) => ({ platformDetails: identityPlatformDetailsToPlatformDetails( identityPlatformDetails, ), deliveryID: deviceID, cryptoID: deviceID, })); return { devices }; } function pushInfoToCollapsableNotifInfo(pushInfo: PushInfo): { +usersToCollapseKeysToInfo: { +[string]: { +[string]: CollapsableNotifInfo }, }, +usersToCollapsableNotifInfo: { +[string]: $ReadOnlyArray, }, } { const usersToCollapseKeysToInfo: { [string]: { [string]: CollapsableNotifInfo }, } = {}; const usersToCollapsableNotifInfo: { [string]: Array } = {}; for (const userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let i = 0; i < pushInfo[userID].messageInfos.length; i++) { const rawMessageInfo = pushInfo[userID].messageInfos[i]; const messageData = pushInfo[userID].messageDatas[i]; const collapseKey = getNotifCollapseKey(rawMessageInfo, messageData); if (!collapseKey) { const collapsableNotifInfo: CollapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = ({ collapseKey, existingMessageInfos: [], newMessageInfos: [], }: CollapsableNotifInfo); } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } return { usersToCollapseKeysToInfo, usersToCollapsableNotifInfo, }; } function mergeUserToCollapsableInfo( usersToCollapseKeysToInfo: { +[string]: { +[string]: CollapsableNotifInfo }, }, usersToCollapsableNotifInfo: { +[string]: $ReadOnlyArray, }, ): { +[string]: $ReadOnlyArray } { const mergedUsersToCollapsableInfo: { [string]: Array, } = {}; for (const userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; mergedUsersToCollapsableInfo[userID] = [ ...usersToCollapsableNotifInfo[userID], ]; for (const collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; mergedUsersToCollapsableInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return mergedUsersToCollapsableInfo; } async function buildNotifText( rawMessageInfos: $ReadOnlyArray, userID: string, threadInfos: { +[id: string]: ThreadInfo }, subscriptions: ?{ +[threadID: string]: ThreadSubscriptionWithRole }, userInfos: UserInfos, getENSNames: ?GetENSNames, getFCNames: ?GetFCNames, ): Promise, +badgeOnly: boolean, }> { if (!subscriptions) { return null; } const hydrateMessageInfo = (rawMessageInfo: RawMessageInfo) => createMessageInfo(rawMessageInfo, userID, userInfos, threadInfos); const newMessageInfos = []; const newRawMessageInfos = []; for (const rawMessageInfo of rawMessageInfos) { const newMessageInfo = hydrateMessageInfo(rawMessageInfo); if (newMessageInfo) { newMessageInfos.push(newMessageInfo); newRawMessageInfos.push(rawMessageInfo); } } if (newMessageInfos.length === 0) { return null; } const [{ threadID }] = newMessageInfos; const threadInfo = threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; const subscription = subscriptions[threadID]; if (!subscription) { return null; } const username = userInfos[userID] && userInfos[userID].username; const { notifAllowed, badgeOnly } = await userAllowsNotif({ subscription, userID, newMessageInfos, userInfos, username, getENSNames, }); if (!notifAllowed) { return null; } const notifTargetUserInfo = { id: userID, username }; const notifTexts = await notifTextsForMessageInfo( newMessageInfos, threadInfo, parentThreadInfo, notifTargetUserInfo, getENSNames, getFCNames, ); if (!notifTexts) { return null; } return { notifTexts, newRawMessageInfos, badgeOnly }; } type BuildNotifsForPlatformInput< PlatformType: Platform, NotifCreatorinputBase, TargetedNotificationType, NotifCreatorInput: { +platformDetails: PlatformDetails, ... }, > = { +platform: PlatformType, +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +notifCreatorCallback: ( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, input: NotifCreatorInput, devices: $ReadOnlyArray, largeNotifToEncryptionResultPromises?: { - [string]: Promise, + [string]: Promise, }, ) => Promise<{ +targetedNotifications: $ReadOnlyArray, +largeNotifData?: LargeNotifData, }>, +notifCreatorInputBase: NotifCreatorinputBase, +transformInputBase: ( inputBase: NotifCreatorinputBase, platformDetails: PlatformDetails, ) => NotifCreatorInput, +versionToDevices: $ReadOnlyMap< string, $ReadOnlyArray, >, }; async function buildNotifsForPlatform< PlatformType: Platform, NotifCreatorinputBase, TargetedNotificationType, NotifCreatorInput: { +platformDetails: PlatformDetails, ... }, >( input: BuildNotifsForPlatformInput< PlatformType, NotifCreatorinputBase, TargetedNotificationType, NotifCreatorInput, >, largeNotifToEncryptionResultPromises?: { - [string]: Promise, + [string]: Promise, }, ): Promise<{ +targetedNotificationsWithPlatform: $ReadOnlyArray<{ +platform: PlatformType, +targetedNotification: TargetedNotificationType, }>, +largeNotifDataArray?: $ReadOnlyArray, }> { const { encryptedNotifUtilsAPI, versionToDevices, notifCreatorCallback, notifCreatorInputBase, platform, transformInputBase, } = input; const promises: Array< Promise<{ +targetedNotificationsWithPlatform: $ReadOnlyArray<{ +platform: PlatformType, +targetedNotification: TargetedNotificationType, }>, +largeNotifData?: LargeNotifData, }>, > = []; for (const [versionKey, devices] of versionToDevices) { const { codeVersion, stateVersion, majorDesktopVersion } = stringToVersionKey(versionKey); const platformDetails = { platform, codeVersion, stateVersion, majorDesktopVersion, }; const inputData = transformInputBase( notifCreatorInputBase, platformDetails, ); promises.push( (async () => { const { targetedNotifications, largeNotifData } = await notifCreatorCallback( encryptedNotifUtilsAPI, inputData, devices, largeNotifToEncryptionResultPromises, ); const targetedNotificationsWithPlatform = targetedNotifications.map( targetedNotification => ({ platform, targetedNotification, }), ); return { targetedNotificationsWithPlatform, largeNotifData }; })(), ); } const results = await Promise.all(promises); const targetedNotifsWithPlatform = results .map( ({ targetedNotificationsWithPlatform }) => targetedNotificationsWithPlatform, ) .flat(); const largeNotifDataArray = results .map(({ largeNotifData }) => largeNotifData) .filter(Boolean); return { largeNotifDataArray, targetedNotificationsWithPlatform: targetedNotifsWithPlatform, }; } type BuildNotifsForUserDevicesInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderDeviceDescriptor: SenderDeviceDescriptor, +rawMessageInfos: $ReadOnlyArray, +userID: string, +threadInfos: { +[id: string]: ThreadInfo }, +subscriptions: ?{ +[threadID: string]: ThreadSubscriptionWithRole }, +userInfos: UserInfos, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, +devicesByPlatform: $ReadOnlyMap< Platform, $ReadOnlyMap>, >, }; async function buildNotifsForUserDevices( inputData: BuildNotifsForUserDevicesInputData, largeNotifToEncryptionResultPromises: { - [string]: Promise, + [string]: Promise, }, ): Promise, +largeNotifDataArray?: $ReadOnlyArray, }> { const { encryptedNotifUtilsAPI, senderDeviceDescriptor, rawMessageInfos, userID, threadInfos, subscriptions, userInfos, getENSNames, getFCNames, devicesByPlatform, } = inputData; const notifTextWithNewRawMessageInfos = await buildNotifText( rawMessageInfos, userID, threadInfos, subscriptions, userInfos, getENSNames, getFCNames, ); if (!notifTextWithNewRawMessageInfos) { return null; } const { notifTexts, newRawMessageInfos, badgeOnly } = notifTextWithNewRawMessageInfos; const [{ threadID }] = newRawMessageInfos; const promises: Array< Promise<{ +targetedNotificationsWithPlatform: $ReadOnlyArray, +largeNotifDataArray?: $ReadOnlyArray, }>, > = []; const iosVersionToDevices = devicesByPlatform.get('ios'); if (iosVersionToDevices) { promises.push( buildNotifsForPlatform( { platform: 'ios', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsVisualNotification, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, newRawMessageInfos: shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ), platformDetails, }), notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, collapseKey: undefined, badgeOnly, uniqueID: uuidv4(), }, versionToDevices: iosVersionToDevices, }, largeNotifToEncryptionResultPromises, ), ); } const androidVersionToDevices = devicesByPlatform.get('android'); if (androidVersionToDevices) { promises.push( buildNotifsForPlatform( { platform: 'android', encryptedNotifUtilsAPI, notifCreatorCallback: createAndroidVisualNotification, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, newRawMessageInfos: shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ), platformDetails, }), notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, collapseKey: undefined, badgeOnly, notifID: uuidv4(), }, versionToDevices: androidVersionToDevices, }, largeNotifToEncryptionResultPromises, ), ); } const macosVersionToDevices = devicesByPlatform.get('macos'); if (macosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'macos', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsVisualNotification, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, newRawMessageInfos: shimUnsupportedRawMessageInfos( newRawMessageInfos, platformDetails, ), platformDetails, }), notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, collapseKey: undefined, badgeOnly, uniqueID: uuidv4(), }, versionToDevices: macosVersionToDevices, }), ); } const windowsVersionToDevices = devicesByPlatform.get('windows'); if (windowsVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'windows', encryptedNotifUtilsAPI, notifCreatorCallback: createWNSNotification, notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: windowsVersionToDevices, }), ); } const webVersionToDevices = devicesByPlatform.get('web'); if (webVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'web', encryptedNotifUtilsAPI, notifCreatorCallback: createWebNotification, notifCreatorInputBase: { senderDeviceDescriptor, notifTexts, threadID, id: uuidv4(), }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: webVersionToDevices, }), ); } const results = await Promise.all(promises); const targetedNotifsWithPlatform = results .map( ({ targetedNotificationsWithPlatform }) => targetedNotificationsWithPlatform, ) .flat(); const largeNotifDataArray = results .map(({ largeNotifDataArray: array }) => array) .filter(Boolean) .flat(); return { largeNotifDataArray, targetedNotificationsWithPlatform: targetedNotifsWithPlatform, }; } async function buildRescindsForOwnDevices( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devicesByPlatform: $ReadOnlyMap< Platform, $ReadOnlyMap>, >, rescindData: { +threadID: string }, ): Promise<$ReadOnlyArray> { const { threadID } = rescindData; const promises: Array< Promise<{ +targetedNotificationsWithPlatform: $ReadOnlyArray, ... }>, > = []; const iosVersionToDevices = devicesByPlatform.get('ios'); if (iosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'ios', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsNotificationRescind, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: iosVersionToDevices, }), ); } const androidVersionToDevices = devicesByPlatform.get('android'); if (androidVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'android', encryptedNotifUtilsAPI, notifCreatorCallback: createAndroidNotificationRescind, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: androidVersionToDevices, }), ); } const results = await Promise.all(promises); const targetedNotifsWithPlatform = results .map( ({ targetedNotificationsWithPlatform }) => targetedNotificationsWithPlatform, ) .flat(); return targetedNotifsWithPlatform; } async function buildBadgeUpdatesForOwnDevices( encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, senderDeviceDescriptor: SenderDeviceDescriptor, devicesByPlatform: $ReadOnlyMap< Platform, $ReadOnlyMap>, >, badgeUpdateData: { +threadID: string }, ): Promise<$ReadOnlyArray> { const { threadID } = badgeUpdateData; const promises: Array< Promise<{ +targetedNotificationsWithPlatform: $ReadOnlyArray, ... }>, > = []; const iosVersionToDevices = devicesByPlatform.get('ios'); if (iosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'ios', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsBadgeOnlyNotification, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: iosVersionToDevices, }), ); } const androidVersionToDevices = devicesByPlatform.get('android'); if (androidVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'android', encryptedNotifUtilsAPI, notifCreatorCallback: createAndroidBadgeOnlyNotification, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: androidVersionToDevices, }), ); } const macosVersionToDevices = devicesByPlatform.get('macos'); if (macosVersionToDevices) { promises.push( buildNotifsForPlatform({ platform: 'macos', encryptedNotifUtilsAPI, notifCreatorCallback: createAPNsBadgeOnlyNotification, notifCreatorInputBase: { senderDeviceDescriptor, threadID, }, transformInputBase: (inputBase, platformDetails) => ({ ...inputBase, platformDetails, }), versionToDevices: macosVersionToDevices, }), ); } const results = await Promise.all(promises); const targetedNotifsWithPlatform = results .map( ({ targetedNotificationsWithPlatform }) => targetedNotificationsWithPlatform, ) .flat(); return targetedNotifsWithPlatform; } export type PerUserTargetedNotifications = { +[userID: string]: { +targetedNotifications: $ReadOnlyArray, +largeNotifDataArray?: $ReadOnlyArray, }, }; type BuildNotifsFromPushInfoInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderDeviceDescriptor: SenderDeviceDescriptor, +pushInfo: PushInfo, +thickRawThreadInfos: ThickRawThreadInfos, +userInfos: UserInfos, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function buildNotifsFromPushInfo( inputData: BuildNotifsFromPushInfoInputData, ): Promise { const { encryptedNotifUtilsAPI, senderDeviceDescriptor, pushInfo, thickRawThreadInfos, userInfos, getENSNames, getFCNames, } = inputData; const threadIDs = new Set(); for (const userID in pushInfo) { for (const rawMessageInfo of pushInfo[userID].messageInfos) { const threadID = rawMessageInfo.threadID; threadIDs.add(threadID); const messageSpec = messageSpecs[rawMessageInfo.type]; if (messageSpec.threadIDs) { for (const id of messageSpec.threadIDs(rawMessageInfo)) { threadIDs.add(id); } } } } const perUserBuildNotifsResultPromises: { [userID: string]: Promise<{ +targetedNotifications: $ReadOnlyArray, +largeNotifDataArray?: $ReadOnlyArray, }>, } = {}; const { usersToCollapsableNotifInfo, usersToCollapseKeysToInfo } = pushInfoToCollapsableNotifInfo(pushInfo); const mergedUsersToCollapsableInfo = mergeUserToCollapsableInfo( usersToCollapseKeysToInfo, usersToCollapsableNotifInfo, ); const largeNotifToEncryptionResultPromises: { - [string]: Promise, + [string]: Promise, } = {}; for (const userID in mergedUsersToCollapsableInfo) { const threadInfos = Object.fromEntries( [...threadIDs].map(threadID => [ threadID, threadInfoFromRawThreadInfo( thickRawThreadInfos[threadID], userID, userInfos, ), ]), ); const devicesByPlatform = getDevicesByPlatform(pushInfo[userID].devices); const singleNotificationPromises = []; for (const notifInfo of mergedUsersToCollapsableInfo[userID]) { singleNotificationPromises.push( // We always pass one element array here // because coalescing is not supported for // notifications generated on the client buildNotifsForUserDevices( { encryptedNotifUtilsAPI, senderDeviceDescriptor, rawMessageInfos: notifInfo.newMessageInfos, userID, threadInfos, subscriptions: pushInfo[userID].subscriptions, userInfos, getENSNames, getFCNames, devicesByPlatform, }, largeNotifToEncryptionResultPromises, ), ); } perUserBuildNotifsResultPromises[userID] = (async () => { const singleNotificationResults = ( await Promise.all(singleNotificationPromises) ).filter(Boolean); const targetedNotifsWithPlatform = singleNotificationResults .map( ({ targetedNotificationsWithPlatform }) => targetedNotificationsWithPlatform, ) .flat(); const largeNotifDataArray = singleNotificationResults .map(({ largeNotifDataArray: array }) => array) .filter(Boolean) .flat(); - console.log(largeNotifDataArray); + return { targetedNotifications: targetedNotifsWithPlatform, largeNotifDataArray, }; })(); } return promiseAll(perUserBuildNotifsResultPromises); } async function createOlmSessionWithDevices( userDevices: { +[userID: string]: $ReadOnlyArray, }, olmSessionCreator: ( userID: string, devices: $ReadOnlyArray, ) => Promise, ): Promise { const { initializeCryptoAccount, isNotificationsSessionInitializedWithDevices, } = getConfig().olmAPI; await initializeCryptoAccount(); const deviceIDsToSessionPresence = await isNotificationsSessionInitializedWithDevices( values(userDevices).flat(), ); const olmSessionCreationPromises = []; for (const userID in userDevices) { const devices = userDevices[userID] .filter(deviceID => !deviceIDsToSessionPresence[deviceID]) .map(deviceID => ({ deviceID, })); olmSessionCreationPromises.push(olmSessionCreator(userID, devices)); } try { // The below is equvialent to // Promise.allSettled(olmSessionCreationPromises), which appears to be // undefined in Android (at least on debug builds) as of Sept 2024 await Promise.all( olmSessionCreationPromises.map(async promise => { try { const result = await promise; return ({ status: 'fulfilled', value: result, }: $SettledPromiseResult); } catch (e) { return ({ status: 'rejected', reason: e, }: $SettledPromiseResult); } }), ); } catch (e) { // session creation may fail for some devices // but we should still pursue notification // delivery for others console.log('Olm session creation failed', e); } } function filterDevicesSupportingDMNotifs< T: { +devices: $ReadOnlyArray, ... }, >(devicesContainer: T): T { return { ...devicesContainer, devices: devicesContainer.devices.filter(({ platformDetails }) => hasMinCodeVersion(platformDetails, { native: 393, web: 115, majorDesktop: 14, }), ), }; } function filterDevicesSupportingDMNotifsForUsers< T: { +devices: $ReadOnlyArray, ... }, >(userToDevicesContainer: { +[userID: string]: T }): { +[userID: string]: T } { const result: { [userID: string]: T } = {}; for (const userID in userToDevicesContainer) { const devicesContainer = userToDevicesContainer[userID]; const filteredDevicesContainer = filterDevicesSupportingDMNotifs(devicesContainer); if (filteredDevicesContainer.devices.length === 0) { continue; } result[userID] = filteredDevicesContainer; } return result; } type PreparePushNotifsInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderDeviceDescriptor: SenderDeviceDescriptor, +olmSessionCreator: ( userID: string, devices: $ReadOnlyArray, ) => Promise, +messageInfos: { +[id: string]: RawMessageInfo }, +thickRawThreadInfos: ThickRawThreadInfos, +auxUserInfos: AuxUserInfos, +messageDatasWithMessageInfos: ?$ReadOnlyArray<{ +messageData: MessageData, +rawMessageInfo: RawMessageInfo, }>, +userInfos: UserInfos, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function preparePushNotifs( inputData: PreparePushNotifsInputData, ): Promise { const { encryptedNotifUtilsAPI, senderDeviceDescriptor, olmSessionCreator, messageDatasWithMessageInfos, messageInfos, auxUserInfos, thickRawThreadInfos, userInfos, getENSNames, getFCNames, } = inputData; const { pushInfos } = await getPushUserInfo( messageInfos, thickRawThreadInfos, auxUserInfos, messageDatasWithMessageInfos, ); if (!pushInfos) { return null; } const filteredPushInfos = filterDevicesSupportingDMNotifsForUsers(pushInfos); const userDevices: { [userID: string]: $ReadOnlyArray, } = {}; for (const userID in filteredPushInfos) { userDevices[userID] = filteredPushInfos[userID].devices.map( device => device.cryptoID, ); } await createOlmSessionWithDevices(userDevices, olmSessionCreator); return await buildNotifsFromPushInfo({ encryptedNotifUtilsAPI, senderDeviceDescriptor, pushInfo: filteredPushInfos, thickRawThreadInfos, userInfos, getENSNames, getFCNames, }); } type PrepareOwnDevicesPushNotifsInputData = { +encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI, +senderInfo: SenderInfo, +olmSessionCreator: ( userID: string, devices: $ReadOnlyArray, ) => Promise, +auxUserInfos: AuxUserInfos, +rescindData?: { threadID: string }, +badgeUpdateData?: { threadID: string }, }; async function prepareOwnDevicesPushNotifs( inputData: PrepareOwnDevicesPushNotifsInputData, ): Promise> { const { encryptedNotifUtilsAPI, senderInfo, olmSessionCreator, auxUserInfos, rescindData, badgeUpdateData, } = inputData; const ownDevicesPushInfo = getOwnDevicesPushInfo(senderInfo, auxUserInfos); if (!ownDevicesPushInfo) { return null; } const filteredownDevicesPushInfos = filterDevicesSupportingDMNotifs(ownDevicesPushInfo); const { senderUserID, senderDeviceDescriptor } = senderInfo; const userDevices: { +[userID: string]: $ReadOnlyArray, } = { [senderUserID]: filteredownDevicesPushInfos.devices.map( device => device.cryptoID, ), }; await createOlmSessionWithDevices(userDevices, olmSessionCreator); const devicesByPlatform = getDevicesByPlatform( filteredownDevicesPushInfos.devices, ); if (rescindData) { return await buildRescindsForOwnDevices( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesByPlatform, rescindData, ); } else if (badgeUpdateData) { return await buildBadgeUpdatesForOwnDevices( encryptedNotifUtilsAPI, senderDeviceDescriptor, devicesByPlatform, badgeUpdateData, ); } else { return null; } } export { preparePushNotifs, prepareOwnDevicesPushNotifs, generateNotifUserInfoPromise, pushInfoToCollapsableNotifInfo, mergeUserToCollapsableInfo, }; diff --git a/lib/types/notif-types.js b/lib/types/notif-types.js index bf2cef497..9eb8b0b22 100644 --- a/lib/types/notif-types.js +++ b/lib/types/notif-types.js @@ -1,429 +1,430 @@ // @flow import type { EncryptResult } from '@commapp/olm'; import t, { type TInterface, type TUnion } from 'tcomb'; import type { MessageData, RawMessageInfo } from './message-types.js'; import type { EntityText, ThreadEntity } from '../utils/entity-text.js'; import { tShape } from '../utils/validation-utils.js'; export type NotifTexts = { +merged: string | EntityText, +body: string | EntityText, +title: string | ThreadEntity, +prefix?: string | EntityText, }; export type ResolvedNotifTexts = { +merged: string, +body: string, +title: string, +prefix?: string, }; export const resolvedNotifTextsValidator: TInterface = tShape({ merged: t.String, body: t.String, title: t.String, prefix: t.maybe(t.String), }); export type NotificationsCreationData = | { +messageDatasWithMessageInfos: ?$ReadOnlyArray<{ +messageData: MessageData, +rawMessageInfo: RawMessageInfo, }>, } | { +rescindData: { threadID: string }, } | { +badgeUpdateData: { threadID: string } }; export type SenderDeviceDescriptor = | { +keyserverID: string } | { +senderDeviceID: string }; export const senderDeviceDescriptorValidator: TUnion = t.union([ tShape({ keyserverID: t.String }), tShape({ senderDeviceID: t.String }), ]); // Web notifs types export type PlainTextWebNotificationPayload = { +body: string, +prefix?: string, +title: string, +unreadCount?: number, +threadID: string, +encryptionFailed?: '1', }; export type PlainTextWebNotification = $ReadOnly<{ +id: string, ...PlainTextWebNotificationPayload, }>; export type EncryptedWebNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +id: string, +encryptedPayload: string, +type: '0' | '1', }>; export type WebNotification = | PlainTextWebNotification | EncryptedWebNotification; // WNS notifs types export type PlainTextWNSNotification = { +body: string, +prefix?: string, +title: string, +unreadCount?: number, +threadID: string, +encryptionFailed?: '1', }; export type EncryptedWNSNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +encryptedPayload: string, +type: '0' | '1', }>; export type WNSNotification = | PlainTextWNSNotification | EncryptedWNSNotification; // Android notifs types export type AndroidVisualNotificationPayloadBase = $ReadOnly<{ +badge?: string, +body: string, +title: string, +prefix?: string, +threadID: string, +collapseID?: string, +badgeOnly?: '0' | '1', +encryptionFailed?: '1', }>; type AndroidSmallVisualNotificationPayload = $ReadOnly<{ ...AndroidVisualNotificationPayloadBase, +messageInfos?: string, }>; type AndroidLargeVisualNotificationPayload = $ReadOnly<{ ...AndroidVisualNotificationPayloadBase, +blobHash: string, +encryptionKey: string, }>; export type AndroidVisualNotificationPayload = | AndroidSmallVisualNotificationPayload | AndroidLargeVisualNotificationPayload; type EncryptedThinThreadPayload = { +keyserverID: string, +encryptedPayload: string, +type: '0' | '1', }; type EncryptedThickThreadPayload = { +senderDeviceID: string, +encryptedPayload: string, +type: '0' | '1', }; export type AndroidVisualNotification = { +data: $ReadOnly<{ +id?: string, ... | AndroidSmallVisualNotificationPayload | AndroidLargeVisualNotificationPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }>, }; type AndroidThinThreadRescindPayload = { +badge: string, +rescind: 'true', +rescindID?: string, +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, }; type AndroidThickThreadRescindPayload = { +rescind: 'true', +setUnreadStatus: 'true', +threadID: string, +encryptionFailed?: string, }; export type AndroidNotificationRescind = { +data: | AndroidThinThreadRescindPayload | AndroidThickThreadRescindPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }; type AndroidKeyserverBadgeOnlyPayload = { +badge: string, +badgeOnly: '1', +encryptionFailed?: string, }; type AndroidThickThreadBadgeOnlyPayload = { +threadID: string, +badgeOnly: '1', +encryptionFailed?: string, }; export type AndroidBadgeOnlyNotification = { +data: | AndroidKeyserverBadgeOnlyPayload | AndroidThickThreadBadgeOnlyPayload | EncryptedThinThreadPayload | EncryptedThickThreadPayload, }; type AndroidNotificationWithPriority = | { +notification: AndroidVisualNotification, +priority: 'high', } | { +notification: AndroidBadgeOnlyNotification | AndroidNotificationRescind, +priority: 'normal', }; // APNs notifs types export type APNsNotificationTopic = | 'app.comm.macos' | 'app.comm' | 'org.squadcal.app'; export type APNsNotificationHeaders = { +'apns-priority'?: 1 | 5 | 10, +'apns-id'?: string, +'apns-expiration'?: number, +'apns-topic': APNsNotificationTopic, +'apns-collapse-id'?: string, +'apns-push-type': 'background' | 'alert' | 'voip', }; type EncryptedAPNsSilentNotificationsAps = { +'mutable-content': number, +'alert'?: { body: 'ENCRYPTED' }, }; export type EncryptedAPNsSilentNotification = $ReadOnly<{ ...SenderDeviceDescriptor, +headers: APNsNotificationHeaders, +encryptedPayload: string, +type: '1' | '0', +aps: EncryptedAPNsSilentNotificationsAps, }>; type EncryptedAPNsVisualNotificationAps = $ReadOnly<{ ...EncryptedAPNsSilentNotificationsAps, +sound?: string, }>; export type EncryptedAPNsVisualNotification = $ReadOnly<{ ...EncryptedAPNsSilentNotification, +aps: EncryptedAPNsVisualNotificationAps, +id: string, }>; type APNsVisualNotificationPayloadBase = { +aps: { +'badge'?: string | number, +'alert'?: string | { +body?: string, ... }, +'thread-id': string, +'mutable-content'?: number, +'sound'?: string, }, +body: string, +title: string, +prefix?: string, +threadID: string, +collapseID?: string, +encryptionFailed?: '1', }; type APNsSmallVisualNotificationPayload = $ReadOnly<{ ...APNsVisualNotificationPayloadBase, +messageInfos?: string, }>; type APNsLargeVisualNotificationPayload = $ReadOnly<{ ...APNsVisualNotificationPayloadBase, +blobHash: string, +encryptionKey: string, }>; export type APNsVisualNotification = | $ReadOnly<{ +headers: APNsNotificationHeaders, +id: string, ... | APNsSmallVisualNotificationPayload | APNsLargeVisualNotificationPayload, }> | EncryptedAPNsVisualNotification; type APNsLegacyRescindPayload = { +backgroundNotifType: 'CLEAR', +notificationId: string, +setUnreadStatus: true, +threadID: string, +aps: { +'badge': string | number, +'content-available': number, }, }; type APNsKeyserverRescindPayload = { +backgroundNotifType: 'CLEAR', +notificationId: string, +setUnreadStatus: true, +threadID: string, +aps: { +'badge': string | number, +'mutable-content': number, }, }; type APNsThickThreadRescindPayload = { +backgroundNotifType: 'CLEAR', +setUnreadStatus: true, +threadID: string, +aps: { +'mutable-content': number, }, }; export type APNsNotificationRescind = | $ReadOnly<{ +headers: APNsNotificationHeaders, +encryptionFailed?: '1', ... | APNsLegacyRescindPayload | APNsKeyserverRescindPayload | APNsThickThreadRescindPayload, }> | EncryptedAPNsSilentNotification; type APNsLegacyBadgeOnlyNotification = { +aps: { +badge: string | number, }, }; type APNsKeyserverBadgeOnlyNotification = { +aps: { +'badge': string | number, +'mutable-content': number, }, }; type APNsThickThreadBadgeOnlyNotification = { +aps: { +'mutable-content': number, }, +threadID: string, }; export type APNsBadgeOnlyNotification = | $ReadOnly<{ +headers: APNsNotificationHeaders, +encryptionFailed?: '1', ... | APNsLegacyBadgeOnlyNotification | APNsKeyserverBadgeOnlyNotification | APNsThickThreadBadgeOnlyNotification, }> | EncryptedAPNsSilentNotification; export type APNsNotification = | APNsVisualNotification | APNsNotificationRescind | APNsBadgeOnlyNotification; export type TargetedAPNsNotification = { +notification: APNsNotification, +deliveryID: string, +encryptedPayloadHash?: string, +encryptionOrder?: number, }; export type TargetedAndroidNotification = $ReadOnly<{ ...AndroidNotificationWithPriority, +deliveryID: string, +encryptionOrder?: number, }>; export type TargetedWebNotification = { +notification: WebNotification, +deliveryID: string, +encryptionOrder?: number, }; export type TargetedWNSNotification = { +notification: WNSNotification, +deliveryID: string, +encryptionOrder?: number, }; export type NotificationTargetDevice = { +cryptoID: string, +deliveryID: string, +blobHolder?: string, }; export type TargetedNotificationWithPlatform = | { +platform: 'ios' | 'macos', +targetedNotification: TargetedAPNsNotification, } | { +platform: 'android', +targetedNotification: TargetedAndroidNotification } | { +platform: 'web', +targetedNotification: TargetedWebNotification } | { +platform: 'windows', +targetedNotification: TargetedWNSNotification }; export type EncryptedNotifUtilsAPI = { +encryptSerializedNotifPayload: ( cryptoID: string, unencryptedPayload: string, encryptedPayloadSizeValidator?: ( encryptedPayload: string, type: '1' | '0', ) => boolean, ) => Promise<{ +encryptedData: EncryptResult, +sizeLimitViolated?: boolean, +encryptionOrder?: number, }>, +uploadLargeNotifPayload: ( payload: string, numberOfHolders: number, ) => Promise< | { +blobHolders: $ReadOnlyArray, +blobHash: string, +encryptionKey: string, } | { +blobUploadError: string }, >, +getNotifByteSize: (serializedNotification: string) => number, +getEncryptedNotifHash: (serializedNotification: string) => Promise, +getBlobHash: (blob: Uint8Array) => Promise, +generateAESKey: () => Promise, +encryptWithAESKey: ( encryptionKey: string, unencrypotedData: string, ) => Promise, + +normalizeUint8ArrayForBlobUpload: (array: Uint8Array) => string | Blob, }; diff --git a/lib/utils/__mocks__/config.js b/lib/utils/__mocks__/config.js index d82c72f25..916c65953 100644 --- a/lib/utils/__mocks__/config.js +++ b/lib/utils/__mocks__/config.js @@ -1,62 +1,63 @@ // @flow import { type Config } from '../config.js'; const getConfig = (): Config => ({ resolveKeyserverSessionInvalidationUsingNativeCredentials: null, setSessionIDOnRequest: true, calendarRangeInactivityLimit: null, platformDetails: { platform: 'web', codeVersion: 70, stateVersion: 50, }, authoritativeKeyserverID: '123', olmAPI: { initializeCryptoAccount: jest.fn(), getUserPublicKey: jest.fn(), encrypt: jest.fn(), encryptAndPersist: jest.fn(), encryptNotification: jest.fn(), decrypt: jest.fn(), decryptAndPersist: jest.fn(), contentInboundSessionCreator: jest.fn(), contentOutboundSessionCreator: jest.fn(), keyserverNotificationsSessionCreator: jest.fn(), notificationsOutboundSessionCreator: jest.fn(), isContentSessionInitialized: jest.fn(), isDeviceNotificationsSessionInitialized: jest.fn(), isNotificationsSessionInitializedWithDevices: jest.fn(), getOneTimeKeys: jest.fn(), validateAndUploadPrekeys: jest.fn(), signMessage: jest.fn(), verifyMessage: jest.fn(), markPrekeysAsPublished: jest.fn(), }, sqliteAPI: { getAllInboundP2PMessages: jest.fn(), removeInboundP2PMessages: jest.fn(), processDBStoreOperations: jest.fn(), getAllOutboundP2PMessages: jest.fn(), markOutboundP2PMessageAsSent: jest.fn(), removeOutboundP2PMessage: jest.fn(), resetOutboundP2PMessagesForDevice: jest.fn(), getRelatedMessages: jest.fn(), getOutboundP2PMessagesByID: jest.fn(), searchMessages: jest.fn(), fetchMessages: jest.fn(), }, encryptedNotifUtilsAPI: { generateAESKey: jest.fn(), encryptWithAESKey: jest.fn(), encryptSerializedNotifPayload: jest.fn(), uploadLargeNotifPayload: jest.fn(), getEncryptedNotifHash: jest.fn(), getBlobHash: jest.fn(), getNotifByteSize: jest.fn(), + normalizeUint8ArrayForBlobUpload: jest.fn(), }, }); const hasConfig = (): boolean => true; export { getConfig, hasConfig }; diff --git a/lib/utils/blob-service.js b/lib/utils/blob-service.js index be0aaec56..505f11c04 100644 --- a/lib/utils/blob-service.js +++ b/lib/utils/blob-service.js @@ -1,75 +1,167 @@ // @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'; 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 }; +} + export { makeBlobServiceURI, isBlobServiceURI, blobHashFromURI, blobHashFromBlobServiceURI, generateBlobHolder, getBlobFetchableURL, makeBlobServiceEndpointURL, + uploadBlob, + assignMultipleHolders, }; diff --git a/native/push/encrypted-notif-utils-api.js b/native/push/encrypted-notif-utils-api.js index a49064013..1dd5f33d2 100644 --- a/native/push/encrypted-notif-utils-api.js +++ b/native/push/encrypted-notif-utils-api.js @@ -1,61 +1,63 @@ // @flow import type { EncryptedNotifUtilsAPI } from 'lib/types/notif-types.js'; import { getConfig } from 'lib/utils/config.js'; import { commUtilsModule } from '../native-modules.js'; import { encrypt, generateKey } from '../utils/aes-crypto-module.js'; const encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI = { encryptSerializedNotifPayload: async ( cryptoID: string, unencryptedPayload: string, encryptedPayloadSizeValidator?: ( encryptedPayload: string, type: '1' | '0', ) => boolean, ) => { const { encryptNotification } = getConfig().olmAPI; const { message: body, messageType: type } = await encryptNotification( unencryptedPayload, cryptoID, ); return { encryptedData: { body, type }, sizeLimitViolated: encryptedPayloadSizeValidator ? !encryptedPayloadSizeValidator(body, type ? '1' : '0') : false, }; }, uploadLargeNotifPayload: async () => ({ blobUploadError: 'not_implemented' }), getNotifByteSize: (serializedNotification: string) => { return commUtilsModule.encodeStringToUTF8ArrayBuffer(serializedNotification) .byteLength; }, getEncryptedNotifHash: async (serializedNotification: string) => { const notifAsArrayBuffer = commUtilsModule.encodeStringToUTF8ArrayBuffer( serializedNotification, ); return commUtilsModule.sha256(notifAsArrayBuffer); }, getBlobHash: async (blob: Uint8Array) => { return commUtilsModule.sha256(blob.buffer); }, generateAESKey: async () => { const aesKeyBytes = await generateKey(); return await commUtilsModule.base64EncodeBuffer(aesKeyBytes.buffer); }, encryptWithAESKey: async (encryptionKey: string, unencryptedData: string) => { const [encryptionKeyBytes, unencryptedDataBytes] = await Promise.all([ commUtilsModule.base64DecodeBuffer(encryptionKey), commUtilsModule.encodeStringToUTF8ArrayBuffer(unencryptedData), ]); return await encrypt( new Uint8Array(encryptionKeyBytes), new Uint8Array(unencryptedDataBytes), ); }, + normalizeUint8ArrayForBlobUpload: (array: Uint8Array) => + commUtilsModule.base64EncodeBuffer(array.buffer), }; export default encryptedNotifUtilsAPI; diff --git a/web/push-notif/encrypted-notif-utils-api.js b/web/push-notif/encrypted-notif-utils-api.js index 1066b14d8..c02e919c5 100644 --- a/web/push-notif/encrypted-notif-utils-api.js +++ b/web/push-notif/encrypted-notif-utils-api.js @@ -1,61 +1,63 @@ // @flow import { generateKeyCommon, encryptCommon, } from 'lib/media/aes-crypto-utils-common.js'; import type { EncryptedNotifUtilsAPI } from 'lib/types/notif-types.js'; import { getConfig } from 'lib/utils/config.js'; const encryptedNotifUtilsAPI: EncryptedNotifUtilsAPI = { encryptSerializedNotifPayload: async ( cryptoID: string, unencryptedPayload: string, encryptedPayloadSizeValidator?: ( encryptedPayload: string, type: '1' | '0', ) => boolean, ) => { const { encryptNotification } = getConfig().olmAPI; const { message: body, messageType: type } = await encryptNotification( unencryptedPayload, cryptoID, ); return { encryptedData: { body, type }, sizeLimitViolated: encryptedPayloadSizeValidator ? !encryptedPayloadSizeValidator(body, type ? '1' : '0') : false, }; }, uploadLargeNotifPayload: async () => ({ blobUploadError: 'not_implemented' }), getNotifByteSize: (serializedNotification: string) => { return new Blob([serializedNotification]).size; }, getEncryptedNotifHash: async (serializedNotification: string) => { const notificationBytes = new TextEncoder().encode(serializedNotification); const hashBytes = await crypto.subtle.digest('SHA-256', notificationBytes); return btoa(String.fromCharCode(...new Uint8Array(hashBytes))); }, getBlobHash: async (blob: Uint8Array) => { const hashBytes = await crypto.subtle.digest('SHA-256', blob.buffer); return btoa(String.fromCharCode(...new Uint8Array(hashBytes))); }, generateAESKey: async () => { const aesKeyBytes = await generateKeyCommon(crypto); return Buffer.from(aesKeyBytes).toString('base64'); }, encryptWithAESKey: async (encryptionKey: string, unencryptedData: string) => { const encryptionKeyBytes = new Uint8Array( Buffer.from(encryptionKey, 'base64'), ); const unencryptedDataBytes = new TextEncoder().encode(unencryptedData); return await encryptCommon( crypto, encryptionKeyBytes, unencryptedDataBytes, ); }, + normalizeUint8ArrayForBlobUpload: (uint8Array: Uint8Array) => + new Blob([uint8Array]), }; export default encryptedNotifUtilsAPI;