From 61548061b8f5a0fcbe928dd971c1d4a92e3de161 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:39:56 -0400 Subject: [PATCH] Support thumbnail export & import during backup of visual attachments --- app/attachment_channel.ts | 5 +- app/attachments.ts | 2 +- ts/AttachmentCrypto.ts | 50 +-- ts/Crypto.ts | 41 ++- ts/components/conversation/Image.tsx | 25 +- ts/components/conversation/ImageGrid.tsx | 11 +- ts/jobs/AttachmentBackupManager.ts | 287 ++++++++++++--- ts/jobs/AttachmentDownloadManager.ts | 241 +++++++++++-- ts/jobs/JobManager.ts | 31 +- ts/models/conversations.ts | 2 +- ts/services/backups/export.ts | 6 +- ts/services/backups/util/filePointers.ts | 8 +- ts/services/backups/util/mediaId.ts | 16 + ts/sql/Interface.ts | 1 + ts/sql/Server.ts | 21 +- ts/state/selectors/message.ts | 16 +- ts/test-both/helpers/fakeAttachment.ts | 3 +- ts/test-electron/Crypto_test.ts | 5 + ts/test-electron/backup/filePointer_test.ts | 11 + .../services/AttachmentBackupManager_test.ts | 153 ++++++-- .../AttachmentDownloadManager_test.ts | 332 +++++++++++++++--- .../util/downloadAttachment_test.ts | 98 ++++-- ts/textsecure/ContactsParser.ts | 2 +- ts/textsecure/MessageReceiver.ts | 6 +- ts/textsecure/downloadAttachment.ts | 177 +++++++--- ts/types/Attachment.ts | 28 +- ts/types/AttachmentBackup.ts | 17 +- ts/types/VisualAttachment.ts | 17 +- ts/util/downloadAttachment.ts | 39 +- ts/util/handleVideoAttachment.ts | 2 - 30 files changed, 1326 insertions(+), 327 deletions(-) diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index 1160d62098b..550415f43fa 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -327,7 +327,7 @@ export async function handleAttachmentRequest(req: Request): Promise { return new Response('Missing key', { status: 400 }); } - // Size is required for trimming padding. + // Size is required for trimming padding if (maybeSize == null) { return new Response('Missing size', { status: 400 }); } @@ -345,9 +345,8 @@ export async function handleAttachmentRequest(req: Request): Promise { ciphertextPath: path, idForLogging: 'attachment_channel', keysBase64, + type: 'local', size, - - isLocal: true, }, plaintext ); diff --git a/app/attachments.ts b/app/attachments.ts index c38c6610145..4dfb83104f1 100644 --- a/app/attachments.ts +++ b/app/attachments.ts @@ -220,7 +220,7 @@ export const readAndDecryptDataFromDisk = async ({ idForLogging: 'attachments/readAndDecryptDataFromDisk', keysBase64, size, - isLocal: true, + type: 'local', }, sink ); diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index 19c730e11d3..2d63e43c62c 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -29,6 +29,7 @@ import * as Errors from './types/errors'; import { isNotNil } from './util/isNotNil'; import { missingCaseError } from './util/missingCaseError'; import { getEnvironment, Environment } from './environment'; +import { toBase64 } from './Bytes'; // This file was split from ts/Crypto.ts because it pulls things in from node, and // too many things pull in Crypto.ts, so it broke storybook. @@ -37,7 +38,7 @@ const DIGEST_LENGTH = MAC_LENGTH; const HEX_DIGEST_LENGTH = DIGEST_LENGTH * 2; const ATTACHMENT_MAC_LENGTH = MAC_LENGTH; -export class ReencyptedDigestMismatchError extends Error {} +export class ReencryptedDigestMismatchError extends Error {} /** @private */ export const KEY_SET_LENGTH = KEY_LENGTH + MAC_LENGTH; @@ -59,10 +60,10 @@ export type EncryptedAttachmentV2 = { export type ReencryptedAttachmentV2 = { path: string; - iv: Uint8Array; + iv: string; plaintextHash: string; - - key: Uint8Array; + localKey: string; + version: 2; }; export type DecryptedAttachmentV2 = { @@ -71,14 +72,6 @@ export type DecryptedAttachmentV2 = { plaintextHash: string; }; -export type ReecryptedAttachmentV2 = { - key: Uint8Array; - mac: Uint8Array; - path: string; - iv: Uint8Array; - plaintextHash: string; -}; - export type PlaintextSourceType = | { data: Uint8Array } | { stream: Readable } @@ -228,7 +221,7 @@ export async function encryptAttachmentV2({ if (dangerousIv?.reason === 'reencrypting-for-backup') { if (!constantTimeEqual(ourDigest, dangerousIv.digestToMatch)) { - throw new ReencyptedDigestMismatchError( + throw new ReencryptedDigestMismatchError( `${logId}: iv was hardcoded for backup re-encryption, but digest does not match` ); } @@ -253,12 +246,13 @@ type DecryptAttachmentToSinkOptionsType = Readonly< }; } & ( | { - isLocal?: false; + type: 'standard'; theirDigest: Readonly; } | { - // No need to check integrity for already downloaded attachments - isLocal: true; + // No need to check integrity for locally reencrypted attachments, or for backup + // thumbnails (since we created it) + type: 'local' | 'backupThumbnail'; theirDigest?: undefined; } ) & @@ -427,8 +421,22 @@ export async function decryptAttachmentV2ToSink( if (!constantTimeEqual(ourMac, theirMac)) { throw new Error(`${logId}: Bad MAC`); } - if (!options.isLocal && !constantTimeEqual(ourDigest, options.theirDigest)) { - throw new Error(`${logId}: Bad digest`); + + const { type } = options; + switch (type) { + case 'local': + case 'backupThumbnail': + log.info( + `${logId}: skipping digest check since this is a ${type} attachment` + ); + break; + case 'standard': + if (!constantTimeEqual(ourDigest, options.theirDigest)) { + throw new Error(`${logId}: Bad digest`); + } + break; + default: + throw missingCaseError(type); } strictAssert( @@ -461,7 +469,7 @@ export async function decryptAttachmentV2ToSink( }; } -export async function reencryptAttachmentV2( +export async function decryptAndReencryptLocally( options: DecryptAttachmentOptionsType ): Promise { const { idForLogging } = options; @@ -499,8 +507,10 @@ export async function reencryptAttachmentV2( return { ...result, - key: keys, + localKey: toBase64(keys), + iv: toBase64(result.iv), path: relativeTargetPath, + version: 2, }; } catch (error) { log.error( diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 30fd2307bdb..72ad1a91827 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -221,7 +221,9 @@ const BACKUP_MATERIAL_INFO = '20231003_Signal_Backups_EncryptMessageBackup'; const BACKUP_MEDIA_ID_INFO = '20231003_Signal_Backups_Media_ID'; const BACKUP_MEDIA_ID_LEN = 15; -const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_Media_ID'; +const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_EncryptMedia'; +const BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO = + '20240513_Signal_Backups_EncryptThumbnail'; const BACKUP_MEDIA_AES_KEY_LEN = 32; const BACKUP_MEDIA_MAC_KEY_LEN = 32; const BACKUP_MEDIA_IV_LEN = 16; @@ -278,11 +280,11 @@ export function deriveBackupMediaKeyMaterial( mediaId: Uint8Array ): BackupMediaKeyMaterialType { if (backupKey.byteLength !== BACKUP_KEY_LEN) { - throw new Error('deriveMediaIdFromMediaName: invalid backup key length'); + throw new Error('deriveBackupMediaKeyMaterial: invalid backup key length'); } if (!mediaId.length) { - throw new Error('deriveMediaIdFromMediaName: mediaId missing'); + throw new Error('deriveBackupMediaKeyMaterial: mediaId missing'); } const hkdf = HKDF.new(3); @@ -302,6 +304,39 @@ export function deriveBackupMediaKeyMaterial( iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN), }; } + +export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( + backupKey: Uint8Array, + mediaId: Uint8Array +): BackupMediaKeyMaterialType { + if (backupKey.byteLength !== BACKUP_KEY_LEN) { + throw new Error( + 'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length' + ); + } + + if (!mediaId.length) { + throw new Error('deriveBackupMediaThumbnailKeyMaterial: mediaId missing'); + } + + const hkdf = HKDF.new(3); + const material = hkdf.deriveSecrets( + BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN, + Buffer.from(backupKey), + Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO), + Buffer.from(mediaId) + ); + + return { + aesKey: material.subarray(0, BACKUP_MEDIA_AES_KEY_LEN), + macKey: material.subarray( + BACKUP_MEDIA_AES_KEY_LEN, + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_MAC_KEY_LEN + ), + iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN), + }; +} + export function deriveStorageItemKey( storageServiceKey: Uint8Array, itemID: string diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index 6ab482d0826..ceb031f8590 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -156,14 +156,25 @@ export function Image({ }} > {pending ? ( - blurHash ? ( + url || blurHash ? (
- + {url ? ( + {alt} + ) : blurHash ? ( + + ) : undefined}
; + attachments: ReadonlyArray; bottomOverlay?: boolean; direction: DirectionType; isSticker?: boolean; @@ -158,7 +161,9 @@ export function ImageGrid({ playIconOverlay={isVideoAttachment(attachments[0])} height={height} width={width} - url={getUrl(attachments[0])} + url={ + getUrl(attachments[0]) ?? attachments[0].thumbnailFromBackup?.url + } tabIndex={tabIndex} onClick={onClick} onError={onError} diff --git a/ts/jobs/AttachmentBackupManager.ts b/ts/jobs/AttachmentBackupManager.ts index 15828c86c62..829e33f75a1 100644 --- a/ts/jobs/AttachmentBackupManager.ts +++ b/ts/jobs/AttachmentBackupManager.ts @@ -16,7 +16,10 @@ import { type JobManagerParamsType, type JobManagerJobResultType, } from './JobManager'; -import { deriveBackupMediaKeyMaterial } from '../Crypto'; +import { + deriveBackupMediaKeyMaterial, + deriveBackupMediaThumbnailInnerEncryptionKeyMaterial, +} from '../Crypto'; import { strictAssert } from '../util/assert'; import { type BackupsService, backupsService } from '../services/backups'; import { @@ -24,19 +27,39 @@ import { getAttachmentCiphertextLength, getAesCbcCiphertextLength, decryptAttachmentV2ToSink, - ReencyptedDigestMismatchError, + ReencryptedDigestMismatchError, } from '../AttachmentCrypto'; import { getBackupKey } from '../services/backups/crypto'; -import type { - AttachmentBackupJobType, - CoreAttachmentBackupJobType, +import { + type AttachmentBackupJobType, + type CoreAttachmentBackupJobType, + type StandardAttachmentBackupJobType, + type ThumbnailAttachmentBackupJobType, } from '../types/AttachmentBackup'; import { isInCall as isInCallSelector } from '../state/selectors/calling'; import { encryptAndUploadAttachment } from '../util/uploadAttachment'; -import { getMediaIdFromMediaName } from '../services/backups/util/mediaId'; -import { fromBase64 } from '../Bytes'; +import { + getMediaIdFromMediaName, + getMediaNameForAttachmentThumbnail, +} from '../services/backups/util/mediaId'; +import { fromBase64, toBase64 } from '../Bytes'; import type { WebAPIType } from '../textsecure/WebAPI'; -import { mightStillBeOnTransitTier } from '../types/Attachment'; +import { + type AttachmentType, + mightStillBeOnTransitTier, +} from '../types/Attachment'; +import { + type CreatedThumbnailType, + makeImageThumbnailForBackup, + makeVideoScreenshot, +} from '../types/VisualAttachment'; +import { missingCaseError } from '../util/missingCaseError'; +import { canAttachmentHaveThumbnail } from './AttachmentDownloadManager'; +import { + isImageTypeSupported, + isVideoTypeSupported, +} from '../util/GoogleChrome'; +import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl'; const MAX_CONCURRENT_JOBS = 3; const RETRY_CONFIG = { @@ -49,6 +72,11 @@ const RETRY_CONFIG = { maxBackoffTime: durations.HOUR, }, }; +const THUMBNAIL_RETRY_CONFIG = { + ...RETRY_CONFIG, + // Thumbnails are optional so we don't need to try indefinitely + maxAttempts: 3, +}; export class AttachmentBackupManager extends JobManager { private static _instance: AttachmentBackupManager | undefined; @@ -67,12 +95,39 @@ export class AttachmentBackupManager extends JobManager RETRY_CONFIG, + getRetryConfig: job => { + if (job.type === 'standard') { + return RETRY_CONFIG; + } + return THUMBNAIL_RETRY_CONFIG; + }, maxConcurrentJobs: MAX_CONCURRENT_JOBS, }; override logPrefix = 'AttachmentBackupManager'; + async addJobAndMaybeThumbnailJob( + job: CoreAttachmentBackupJobType + ): Promise { + await this.addJob(job); + if (job.type === 'standard') { + if (canAttachmentHaveThumbnail(job.data.contentType)) { + await this.addJob({ + type: 'thumbnail', + mediaName: getMediaNameForAttachmentThumbnail(job.mediaName), + receivedAt: job.receivedAt, + data: { + fullsizePath: job.data.path, + fullsizeSize: job.data.size, + contentType: job.data.contentType, + version: job.data.version, + localKey: job.data.localKey, + }, + }); + } + } + } + static get instance(): AttachmentBackupManager { if (!AttachmentBackupManager._instance) { AttachmentBackupManager._instance = new AttachmentBackupManager( @@ -82,6 +137,12 @@ export class AttachmentBackupManager extends JobManager { + return AttachmentBackupManager.instance.addJobAndMaybeThumbnailJob(job); + } + static async start(): Promise { log.info('AttachmentBackupManager/starting'); await AttachmentBackupManager.instance.start(); @@ -102,7 +163,7 @@ function getJobId(job: CoreAttachmentBackupJobType): string { } function getJobIdForLogging(job: CoreAttachmentBackupJobType): string { - return redactGenericText(job.mediaName); + return `${redactGenericText(job.mediaName)}.${job.type}`; } /** @@ -130,7 +191,7 @@ export async function runAttachmentBackupJob( encryptAndUploadAttachment, decryptAttachmentV2ToSink, } -): Promise { +): Promise> { const jobIdForLogging = getJobIdForLogging(job); const logId = `AttachmentBackupManager/runAttachmentBackupJob/${jobIdForLogging}`; try { @@ -147,7 +208,7 @@ export async function runAttachmentBackupJob( return { status: 'finished' }; } - if (error instanceof ReencyptedDigestMismatchError) { + if (error instanceof ReencryptedDigestMismatchError) { log.error( `${logId}: Unable to reencrypt to match same digest; content must have changed` ); @@ -163,26 +224,11 @@ async function runAttachmentBackupJobInner( dependencies: RunAttachmentBackupJobDependenciesType ): Promise { const jobIdForLogging = getJobIdForLogging(job); - const logId = `AttachmentBackupManager.UploadOrCopyToBackupTier(mediaName:${jobIdForLogging})`; + const logId = `AttachmentBackupManager.UploadOrCopyToBackupTier(${jobIdForLogging})`; log.info(`${logId}: starting`); - const { mediaName, type } = job; - - // TODO (DESKTOP-6913): generate & upload thumbnail - strictAssert( - type === 'standard', - 'Only standard uploads are currently supported' - ); - - const { path, transitCdnInfo, iv, digest, keys, size, version, localKey } = - job.data; - - const mediaId = getMediaIdFromMediaName(mediaName); - const backupKeyMaterial = deriveBackupMediaKeyMaterial( - getBackupKey(), - mediaId.bytes - ); + const mediaId = getMediaIdFromMediaName(job.mediaName); const { isInBackupTier } = await dependencies.backupsService.getBackupCdnInfo( mediaId.string @@ -193,6 +239,33 @@ async function runAttachmentBackupJobInner( return; } + const jobType = job.type; + + switch (jobType) { + case 'standard': + return backupStandardAttachment(job, dependencies); + case 'thumbnail': + return backupThumbnailAttachment(job, dependencies); + default: + throw missingCaseError(jobType); + } +} + +async function backupStandardAttachment( + job: StandardAttachmentBackupJobType, + dependencies: RunAttachmentBackupJobDependenciesType +) { + const jobIdForLogging = getJobIdForLogging(job); + const logId = `AttachmentBackupManager.backupStandardAttachment(${jobIdForLogging})`; + const { path, transitCdnInfo, iv, digest, keys, size, version, localKey } = + job.data; + + const mediaId = getMediaIdFromMediaName(job.mediaName); + const backupKeyMaterial = deriveBackupMediaKeyMaterial( + getBackupKey(), + mediaId.bytes + ); + if (transitCdnInfo) { const { cdnKey: transitCdnKey, @@ -244,7 +317,6 @@ async function runAttachmentBackupJobInner( version, localKey, size, - keys, iv, digest, @@ -263,12 +335,118 @@ async function runAttachmentBackupJobInner( }); } +async function backupThumbnailAttachment( + job: ThumbnailAttachmentBackupJobType, + dependencies: RunAttachmentBackupJobDependenciesType +) { + const jobIdForLogging = getJobIdForLogging(job); + const logId = `AttachmentBackupManager.backupThumbnailAttachment(${jobIdForLogging})`; + + const mediaId = getMediaIdFromMediaName(job.mediaName); + const backupKeyMaterial = deriveBackupMediaKeyMaterial( + getBackupKey(), + mediaId.bytes + ); + + const { fullsizePath, fullsizeSize, contentType, version, localKey } = + job.data; + + if (!canAttachmentHaveThumbnail(contentType)) { + log.error( + `${logId}: cannot generate thumbnail for contentType: ${contentType}` + ); + return; + } + + if (!fullsizePath) { + throw new AttachmentPermanentlyMissingError('No fullsizePath property'); + } + + const fullsizeAbsolutePath = + dependencies.getAbsoluteAttachmentPath(fullsizePath); + + if (!existsSync(fullsizeAbsolutePath)) { + throw new AttachmentPermanentlyMissingError( + 'No fullsize file at provided path' + ); + } + + let thumbnail: CreatedThumbnailType; + + const fullsizeUrl = getLocalAttachmentUrl({ + path: fullsizePath, + size: fullsizeSize, + contentType, + version, + localKey, + }); + + if (isVideoTypeSupported(contentType)) { + // TODO (DESKTOP-7204): pull screenshot path from attachments table if it already + // exists + const screenshotBlob = await makeVideoScreenshot({ + objectUrl: fullsizeUrl, + }); + const screenshotObjectUrl = URL.createObjectURL(screenshotBlob); + thumbnail = await makeImageThumbnailForBackup({ + objectUrl: screenshotObjectUrl, + }); + } else if (isImageTypeSupported(contentType)) { + thumbnail = await makeImageThumbnailForBackup({ + objectUrl: fullsizeUrl, + }); + } else { + log.error( + `${logId}: cannot generate thumbnail for contentType: ${contentType}` + ); + return; + } + + const { aesKey, macKey } = + deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( + getBackupKey(), + mediaId.bytes + ); + + log.info(`${logId}: uploading thumbnail to transit tier`); + const uploadResult = await uploadThumbnailToTransitTier({ + data: thumbnail.data, + keys: toBase64(Buffer.concat([aesKey, macKey])), + logPrefix: logId, + dependencies, + }); + + log.info(`${logId}: copying thumbnail to backup tier`); + await copyToBackupTier({ + cdnKey: uploadResult.cdnKey, + cdnNumber: uploadResult.cdnNumber, + size: thumbnail.data.byteLength, + mediaId: mediaId.string, + ...backupKeyMaterial, + dependencies, + }); +} + +type UploadToTransitTierArgsType = { + absolutePath: string; + iv: string; + digest: string; + keys: string; + version?: AttachmentType['version']; + localKey?: string; + size: number; + logPrefix: string; + dependencies: { + decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink; + encryptAndUploadAttachment: typeof encryptAndUploadAttachment; + }; +}; + type UploadResponseType = { cdnKey: string; cdnNumber: number; encrypted: EncryptedAttachmentV2; }; - async function uploadToTransitTier({ absolutePath, keys, @@ -279,20 +457,7 @@ async function uploadToTransitTier({ digest, logPrefix, dependencies, -}: { - absolutePath: string; - iv: string; - digest: string; - keys: string; - version?: 2; - localKey?: string; - size: number; - logPrefix: string; - dependencies: { - decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink; - encryptAndUploadAttachment: typeof encryptAndUploadAttachment; - }; -}): Promise { +}: UploadToTransitTierArgsType): Promise { try { if (version === 2) { strictAssert( @@ -311,7 +476,7 @@ async function uploadToTransitTier({ ciphertextPath: absolutePath, keysBase64: localKey, size, - isLocal: true, + type: 'local', }, sink ), @@ -350,6 +515,36 @@ async function uploadToTransitTier({ } } +async function uploadThumbnailToTransitTier({ + data, + keys, + logPrefix, + dependencies, +}: { + data: Uint8Array; + keys: string; + logPrefix: string; + dependencies: { + decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink; + encryptAndUploadAttachment: typeof encryptAndUploadAttachment; + }; +}): Promise { + try { + const uploadResult = await dependencies.encryptAndUploadAttachment({ + plaintext: { data }, + keys: fromBase64(keys), + uploadType: 'backup', + }); + return uploadResult; + } catch (error) { + log.error( + `${logPrefix}/uploadThumbnailToTransitTier: Error while encrypting and uploading`, + Errors.toLogFormat(error) + ); + throw error; + } +} + export const FILE_NOT_FOUND_ON_TRANSIT_TIER_STATUS = 410; async function copyToBackupTier({ diff --git a/ts/jobs/AttachmentDownloadManager.ts b/ts/jobs/AttachmentDownloadManager.ts index dc8c17ce5b3..2dbcb122c2a 100644 --- a/ts/jobs/AttachmentDownloadManager.ts +++ b/ts/jobs/AttachmentDownloadManager.ts @@ -12,15 +12,18 @@ import { } from '../types/AttachmentDownload'; import { AttachmentPermanentlyUndownloadableError, - downloadAttachment, + downloadAttachment as downloadAttachmentUtil, } from '../util/downloadAttachment'; import dataInterface from '../sql/Client'; import { getValue } from '../RemoteConfig'; import { isInCall as isInCallSelector } from '../state/selectors/calling'; -import { AttachmentSizeError, type AttachmentType } from '../types/Attachment'; +import { + AttachmentSizeError, + type AttachmentType, + AttachmentVariant, +} from '../types/Attachment'; import { __DEPRECATED$getMessageById } from '../messages/getMessageById'; -import type { MessageModel } from '../models/messages'; import { KIBIBYTE, getMaximumIncomingAttachmentSizeInKb, @@ -34,6 +37,11 @@ import { type JobManagerParamsType, type JobManagerJobResultType, } from './JobManager'; +import { + isImageTypeSupported, + isVideoTypeSupported, +} from '../util/GoogleChrome'; +import type { MIMEType } from '../types/MIME'; export enum AttachmentDownloadUrgency { IMMEDIATE = 'immediate', @@ -63,13 +71,19 @@ const DEFAULT_RETRY_CONFIG = { }; type AttachmentDownloadManagerParamsType = Omit< JobManagerParamsType, - 'getNextJobs' + 'getNextJobs' | 'runJob' > & { getNextJobs: (options: { limit: number; prioritizeMessageIds?: Array; timestamp?: number; }) => Promise>; + runDownloadAttachmentJob: (args: { + job: AttachmentDownloadJobType; + isLastAttempt: boolean; + options?: { isForCurrentlyVisibleMessage: boolean }; + dependencies: { downloadAttachment: typeof downloadAttachmentUtil }; + }) => Promise>; }; function getJobId(job: CoreAttachmentDownloadJobType): string { @@ -84,7 +98,7 @@ function getJobIdForLogging(job: CoreAttachmentDownloadJobType): string { } export class AttachmentDownloadManager extends JobManager { - private visibleTimelineMessages: Array = []; + private visibleTimelineMessages: Set = new Set(); private static _instance: AttachmentDownloadManager | undefined; override logPrefix = 'AttachmentDownloadManager'; @@ -93,7 +107,7 @@ export class AttachmentDownloadManager extends JobManager { const reduxState = window.reduxStore?.getState(); if (reduxState) { @@ -113,10 +127,23 @@ export class AttachmentDownloadManager extends JobManager { return params.getNextJobs({ limit, - prioritizeMessageIds: this.visibleTimelineMessages, + prioritizeMessageIds: [...this.visibleTimelineMessages], timestamp: Date.now(), }); }, + runJob: (job: AttachmentDownloadJobType, isLastAttempt: boolean) => { + const isForCurrentlyVisibleMessage = this.visibleTimelineMessages.has( + job.messageId + ); + return params.runDownloadAttachmentJob({ + job, + isLastAttempt, + options: { + isForCurrentlyVisibleMessage, + }, + dependencies: { downloadAttachment: downloadAttachmentUtil }, + }); + }, }); } @@ -168,7 +195,7 @@ export class AttachmentDownloadManager extends JobManager): void { - this.visibleTimelineMessages = messageIds; + this.visibleTimelineMessages = new Set(messageIds); } static get instance(): AttachmentDownloadManager { @@ -202,14 +229,17 @@ export class AttachmentDownloadManager extends JobManager { +async function runDownloadAttachmentJob({ + job, + isLastAttempt, + options, + dependencies, +}: { + job: AttachmentDownloadJobType; + isLastAttempt: boolean; + options?: { isForCurrentlyVisibleMessage: boolean }; + dependencies: { downloadAttachment: typeof downloadAttachmentUtil }; +}): Promise> { const jobIdForLogging = getJobIdForLogging(job); const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`; @@ -222,8 +252,24 @@ async function runDownloadAttachmentJob( try { log.info(`${logId}: Starting job`); - await runDownloadAttachmentJobInner(job, message); - return { status: 'finished' }; + + const result = await runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: + options?.isForCurrentlyVisibleMessage ?? false, + dependencies, + }); + + if (result.onlyAttemptedBackupThumbnail) { + return { + status: 'finished', + newJob: { ...job, attachment: result.attachmentWithThumbnail }, + }; + } + + return { + status: 'finished', + }; } catch (error) { log.error( `${logId}: Failed to download attachment, attempt ${job.attempts}:`, @@ -281,21 +327,33 @@ async function runDownloadAttachmentJob( } } -async function runDownloadAttachmentJobInner( - job: AttachmentDownloadJobType, - message: MessageModel -): Promise { - const { messageId, attachment, attachmentType: type } = job; +type DownloadAttachmentResultType = + | { + onlyAttemptedBackupThumbnail: false; + } + | { + onlyAttemptedBackupThumbnail: true; + attachmentWithThumbnail: AttachmentType; + }; + +export async function runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage, + dependencies, +}: { + job: AttachmentDownloadJobType; + isForCurrentlyVisibleMessage: boolean; + dependencies: { downloadAttachment: typeof downloadAttachmentUtil }; +}): Promise { + const { messageId, attachment, attachmentType } = job; const jobIdForLogging = getJobIdForLogging(job); - const logId = `AttachmentDownloadManager/runDownloadJobInner(${jobIdForLogging})`; + let logId = `AttachmentDownloadManager/runDownloadJobInner(${jobIdForLogging})`; if (!job || !attachment || !messageId) { throw new Error(`${logId}: Key information required for job was missing.`); } - log.info(`${logId}: starting`); - const maxInKib = getMaximumIncomingAttachmentSizeInKb(getValue); const maxTextAttachmentSizeInKib = getMaximumIncomingTextAttachmentSizeInKb(getValue); @@ -308,32 +366,123 @@ async function runDownloadAttachmentJobInner( `${logId}: Attachment was ${sizeInKib}kib, max is ${maxInKib}kib` ); } - if (type === 'long-message' && sizeInKib > maxTextAttachmentSizeInKib) { + if ( + attachmentType === 'long-message' && + sizeInKib > maxTextAttachmentSizeInKib + ) { throw new AttachmentSizeError( `${logId}: Text attachment was ${sizeInKib}kib, max is ${maxTextAttachmentSizeInKib}kib` ); } + const preferBackupThumbnail = + isForCurrentlyVisibleMessage && + mightHaveThumbnailOnBackupTier(job.attachment) && + // TODO (DESKTOP-7204): check if thumbnail exists on attachment, not on job + !job.attachment.thumbnailFromBackup; + + if (preferBackupThumbnail) { + logId += '.preferringBackupThumbnail'; + } + + if (preferBackupThumbnail) { + try { + const attachmentWithThumbnail = await downloadBackupThumbnail({ + attachment, + dependencies, + }); + await addAttachmentToMessage(messageId, attachmentWithThumbnail, logId, { + type: attachmentType, + }); + return { + onlyAttemptedBackupThumbnail: true, + attachmentWithThumbnail, + }; + } catch (e) { + log.warn(`${logId}: error when trying to download thumbnail`); + } + } + + // TODO (DESKTOP-7204): currently we only set pending state when downloading the + // full-size attachment await addAttachmentToMessage( - message.id, + messageId, { ...attachment, pending: true }, logId, - { type } + { type: attachmentType } ); - const downloaded = await downloadAttachment(attachment); + try { + const downloaded = await dependencies.downloadAttachment({ + attachment, + variant: AttachmentVariant.Default, + }); - const upgradedAttachment = - await window.Signal.Migrations.processNewAttachment(downloaded); + const upgradedAttachment = + await window.Signal.Migrations.processNewAttachment({ + ...omit(attachment, ['error', 'pending']), + ...downloaded, + }); - await addAttachmentToMessage( - message.id, - omit(upgradedAttachment, ['error', 'pending']), - logId, - { - type, + await addAttachmentToMessage(messageId, upgradedAttachment, logId, { + type: attachmentType, + }); + return { onlyAttemptedBackupThumbnail: false }; + } catch (error) { + if ( + !job.attachment.thumbnailFromBackup && + mightHaveThumbnailOnBackupTier(attachment) && + !preferBackupThumbnail + ) { + log.error( + `${logId}: failed to download fullsize attachment, falling back to thumbnail`, + Errors.toLogFormat(error) + ); + try { + const attachmentWithThumbnail = await downloadBackupThumbnail({ + attachment, + dependencies, + }); + await addAttachmentToMessage( + messageId, + omit(attachmentWithThumbnail, 'pending'), + logId, + { + type: attachmentType, + } + ); + return { + onlyAttemptedBackupThumbnail: false, + }; + } catch (thumbnailError) { + log.error( + `${logId}: fallback attempt to download thumbnail failed`, + Errors.toLogFormat(thumbnailError) + ); + } } - ); + throw error; + } +} + +async function downloadBackupThumbnail({ + attachment, + dependencies, +}: { + attachment: AttachmentType; + dependencies: { downloadAttachment: typeof downloadAttachmentUtil }; +}): Promise { + const downloadedThumbnail = await dependencies.downloadAttachment({ + attachment, + variant: AttachmentVariant.ThumbnailFromBackup, + }); + + const attachmentWithThumbnail = { + ...attachment, + thumbnailFromBackup: downloadedThumbnail, + }; + + return attachmentWithThumbnail; } function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType { @@ -354,3 +503,17 @@ function _markAttachmentAsTransientlyErrored( ): AttachmentType { return { ...attachment, pending: false, error: true }; } + +function mightHaveThumbnailOnBackupTier( + attachment: Pick +): boolean { + if (!attachment.backupLocator?.mediaName) { + return false; + } + + return canAttachmentHaveThumbnail(attachment.contentType); +} + +export function canAttachmentHaveThumbnail(contentType: MIMEType): boolean { + return isVideoTypeSupported(contentType) || isImageTypeSupported(contentType); +} diff --git a/ts/jobs/JobManager.ts b/ts/jobs/JobManager.ts index 620887db45e..9725f649352 100644 --- a/ts/jobs/JobManager.ts +++ b/ts/jobs/JobManager.ts @@ -44,7 +44,7 @@ export type JobManagerParamsType< runJob: ( job: JobType, isLastAttempt: boolean - ) => Promise; + ) => Promise>; shouldHoldOffOnStartingQueuedJobs?: () => boolean; getJobId: (job: CoreJobType) => string; getJobIdForLogging: (job: JobType) => string; @@ -55,9 +55,12 @@ export type JobManagerParamsType< maxConcurrentJobs: number; }; -export type JobManagerJobResultType = { status: 'retry' | 'finished' }; - -const TICK_INTERVAL = MINUTE; +const DEFAULT_TICK_INTERVAL = MINUTE; +export type JobManagerJobResultType = + | { + status: 'retry'; + } + | { status: 'finished'; newJob?: CoreJobType }; export abstract class JobManager { protected enabled: boolean = false; @@ -75,7 +78,7 @@ export abstract class JobManager { protected tickTimeout: NodeJS.Timeout | null = null; protected logPrefix = 'JobManager'; - + public tickInterval = DEFAULT_TICK_INTERVAL; constructor(readonly params: JobManagerParamsType) {} async start(): Promise { @@ -99,7 +102,7 @@ export abstract class JobManager { clearTimeoutIfNecessary(this.tickTimeout); this.tickTimeout = null; drop(this.maybeStartJobs()); - this.tickTimeout = setTimeout(() => this.tick(), TICK_INTERVAL); + this.tickTimeout = setTimeout(() => this.tick(), this.tickInterval); } // used in testing @@ -196,7 +199,6 @@ export abstract class JobManager { try { this._inMaybeStartJobs = true; - if (!this.enabled) { log.info(`${this.logPrefix}/_maybeStartJobs: not enabled, returning`); return; @@ -247,13 +249,15 @@ export abstract class JobManager { job.attempts + 1 >= (this.params.getRetryConfig(job).maxAttempts ?? Infinity); + let jobRunResult: JobManagerJobResultType | undefined; try { log.info(`${logId}: starting job`); this.addRunningJob(job); await this.params.saveJob({ ...job, active: true }); + const runJobPromise = this.params.runJob(job, isLastAttempt); this.handleJobStartPromises(job); - - const { status } = await this.params.runJob(job, isLastAttempt); + jobRunResult = await runJobPromise; + const { status } = jobRunResult; log.info(`${logId}: job completed with status: ${status}`); switch (status) { @@ -278,13 +282,20 @@ export abstract class JobManager { } } finally { this.removeRunningJob(job); + if (jobRunResult?.status === 'finished') { + if (jobRunResult.newJob) { + log.info( + `${logId}: adding new job as a result of this one completing` + ); + await this.addJob(jobRunResult.newJob); + } + } drop(this.maybeStartJobs()); } } private async retryJobLater(job: CoreJobType & JobManagerJobType) { const now = Date.now(); - await this.params.saveJob({ ...job, active: false, diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index d96bac78486..4f3c28b0783 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -5375,7 +5375,7 @@ export class ConversationModel extends window.Backbone size: avatar.size, getAbsoluteAttachmentPath, - isLocal: true, + type: 'local', }); try { diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 2ab44b9258a..a06f9bca0b5 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -182,7 +182,7 @@ export class BackupExportStream extends Readable { drop( (async () => { log.info('BackupExportStream: starting...'); - + drop(AttachmentBackupManager.stop()); await Data.pauseWriteAccess(); try { await this.unsafeRun(backupLevel); @@ -190,9 +190,11 @@ export class BackupExportStream extends Readable { this.emit('error', error); } finally { await Data.resumeWriteAccess(); + // TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction + await Data.clearAllAttachmentBackupJobs(); await Promise.all( this.attachmentBackupJobs.map(job => - AttachmentBackupManager.addJob(job) + AttachmentBackupManager.addJobAndMaybeThumbnailJob(job) ) ); drop(AttachmentBackupManager.start()); diff --git a/ts/services/backups/util/filePointers.ts b/ts/services/backups/util/filePointers.ts index 60a44478711..a8578777911 100644 --- a/ts/services/backups/util/filePointers.ts +++ b/ts/services/backups/util/filePointers.ts @@ -171,10 +171,6 @@ export function convertBackupMessageAttachmentToAttachment( async function generateNewEncryptionInfoForAttachment( attachment: Readonly ): Promise { - strictAssert( - attachment.version !== 2, - 'generateNewEncryptionInfoForAttachment can only be used on legacy attachments' - ); const fixedUpAttachment = { ...attachment }; // Since we are changing the encryption, we need to delete all encryption & location @@ -461,6 +457,8 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({ cdnKey, cdnNumber, uploadTimestamp, + version, + localKey, } = attachment; return { @@ -474,6 +472,8 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({ digest, iv, size, + version, + localKey, transitCdnInfo: cdnKey && cdnNumber != null ? { diff --git a/ts/services/backups/util/mediaId.ts b/ts/services/backups/util/mediaId.ts index 87e3fcb0eb7..3e2085477f8 100644 --- a/ts/services/backups/util/mediaId.ts +++ b/ts/services/backups/util/mediaId.ts @@ -26,6 +26,16 @@ export function getMediaIdForAttachment(attachment: AttachmentType): { return getMediaIdFromMediaName(mediaName); } +export function getMediaIdForAttachmentThumbnail(attachment: AttachmentType): { + string: string; + bytes: Uint8Array; +} { + const mediaName = getMediaNameForAttachmentThumbnail( + getMediaNameForAttachment(attachment) + ); + return getMediaIdFromMediaName(mediaName); +} + export function getMediaNameForAttachment(attachment: AttachmentType): string { if (attachment.backupLocator) { return attachment.backupLocator.mediaName; @@ -34,6 +44,12 @@ export function getMediaNameForAttachment(attachment: AttachmentType): string { return attachment.digest; } +export function getMediaNameForAttachmentThumbnail( + fullsizeMediaName: string +): string { + return Bytes.toBase64(Bytes.fromString(`${fullsizeMediaName}_thumbnail`)); +} + export function getBytesFromMediaIdString(mediaId: string): Uint8Array { return Bytes.fromBase64url(mediaId); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 1bafd57f660..e8daf2c6d38 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -793,6 +793,7 @@ export type DataInterface = { saveAttachmentBackupJob: (job: AttachmentBackupJobType) => Promise; markAllAttachmentBackupJobsInactive: () => Promise; removeAttachmentBackupJob: (job: AttachmentBackupJobType) => Promise; + clearAllAttachmentBackupJobs: () => Promise; clearAllBackupCdnObjectMetadata: () => Promise; saveBackupCdnObjectMetadata: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index ed5df88d4f1..282a2e96903 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -403,6 +403,7 @@ const dataInterface: ServerInterface = { saveAttachmentBackupJob, markAllAttachmentBackupJobsInactive, removeAttachmentBackupJob, + clearAllAttachmentBackupJobs, clearAllBackupCdnObjectMetadata, saveBackupCdnObjectMetadata, @@ -5014,6 +5015,11 @@ async function removeAttachmentDownloadJob( // Backup Attachments +async function clearAllAttachmentBackupJobs(): Promise { + const db = await getWritableInstance(); + db.prepare('DELETE FROM attachment_backup_jobs;').run(); +} + async function markAllAttachmentBackupJobsInactive(): Promise { const db = await getWritableInstance(); db.prepare( @@ -5068,7 +5074,9 @@ async function getNextAttachmentBackupJobs({ active = 0 AND (retryAfter is NULL OR retryAfter <= ${timestamp}) - ORDER BY receivedAt DESC + ORDER BY + -- type is "standard" or "thumbnail"; we prefer "standard" jobs + type ASC, receivedAt DESC LIMIT ${limit} `; const rows = db.prepare(query).all(params); @@ -6750,7 +6758,12 @@ function getExternalFilesForMessage(message: MessageType): Array { const files: Array = []; forEach(attachments, attachment => { - const { path: file, thumbnail, screenshot } = attachment; + const { + path: file, + thumbnail, + screenshot, + thumbnailFromBackup, + } = attachment; if (file) { files.push(file); } @@ -6762,6 +6775,10 @@ function getExternalFilesForMessage(message: MessageType): Array { if (screenshot && screenshot.path) { files.push(screenshot.path); } + + if (thumbnailFromBackup && thumbnailFromBackup.path) { + files.push(thumbnailFromBackup.path); + } }); if (quote && quote.attachments && quote.attachments.length) { diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 4f0d7d3ba6f..cb0ef8589dd 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -58,7 +58,10 @@ import type { AssertProps } from '../../types/Util'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { getMentionsRegex } from '../../types/Message'; import { SignalService as Proto } from '../../protobuf'; -import type { AttachmentType } from '../../types/Attachment'; +import type { + AttachmentForUIType, + AttachmentType, +} from '../../types/Attachment'; import { isVoiceMessage, canBeDownloaded, @@ -1821,12 +1824,13 @@ export function getPropsForEmbeddedContact( export function getPropsForAttachment( attachment: AttachmentType -): AttachmentType | undefined { +): AttachmentForUIType | undefined { if (!attachment) { return undefined; } - const { path, pending, size, screenshot, thumbnail } = attachment; + const { path, pending, size, screenshot, thumbnail, thumbnailFromBackup } = + attachment; return { ...attachment, @@ -1834,6 +1838,12 @@ export function getPropsForAttachment( isVoiceMessage: isVoiceMessage(attachment), pending, url: path ? getLocalAttachmentUrl(attachment) : undefined, + thumbnailFromBackup: thumbnailFromBackup?.path + ? { + ...thumbnailFromBackup, + url: getLocalAttachmentUrl(thumbnailFromBackup), + } + : undefined, screenshot: screenshot?.path ? { ...screenshot, diff --git a/ts/test-both/helpers/fakeAttachment.ts b/ts/test-both/helpers/fakeAttachment.ts index c9b734d1bca..a662db1545c 100644 --- a/ts/test-both/helpers/fakeAttachment.ts +++ b/ts/test-both/helpers/fakeAttachment.ts @@ -7,12 +7,13 @@ import type { AttachmentType, AttachmentDraftType, ThumbnailType, + AttachmentForUIType, } from '../../types/Attachment'; import { IMAGE_JPEG } from '../../types/MIME'; export const fakeAttachment = ( overrides: Partial = {} -): AttachmentType => ({ +): AttachmentForUIType => ({ contentType: IMAGE_JPEG, width: 800, height: 600, diff --git a/ts/test-electron/Crypto_test.ts b/ts/test-electron/Crypto_test.ts index fec55182c0d..0af074c4d90 100644 --- a/ts/test-electron/Crypto_test.ts +++ b/ts/test-electron/Crypto_test.ts @@ -577,6 +577,7 @@ describe('Crypto', () => { writeFileSync(ciphertextPath, encryptedAttachment.ciphertext); const decryptedAttachment = await decryptAttachmentV2({ + type: 'standard', ciphertextPath, idForLogging: 'test', ...splitKeys(keys), @@ -634,6 +635,7 @@ describe('Crypto', () => { ); const decryptedAttachment = await decryptAttachmentV2({ + type: 'standard', ciphertextPath, idForLogging: 'test', ...splitKeys(keys), @@ -931,6 +933,7 @@ describe('Crypto', () => { outerCiphertextPath = encryptResult.outerCiphertextPath; const decryptedAttachment = await decryptAttachmentV2({ + type: 'standard', ciphertextPath: outerCiphertextPath, idForLogging: 'test', ...splitKeys(innerKeys), @@ -986,6 +989,7 @@ describe('Crypto', () => { outerCiphertextPath = encryptResult.outerCiphertextPath; const decryptedAttachment = await decryptAttachmentV2({ + type: 'standard', ciphertextPath: outerCiphertextPath, idForLogging: 'test', ...splitKeys(innerKeys), @@ -1035,6 +1039,7 @@ describe('Crypto', () => { await assert.isRejected( decryptAttachmentV2({ + type: 'standard', ciphertextPath: outerCiphertextPath, idForLogging: 'test', ...splitKeys(innerKeys), diff --git a/ts/test-electron/backup/filePointer_test.ts b/ts/test-electron/backup/filePointer_test.ts index 93b639a1931..f452c41bdd1 100644 --- a/ts/test-electron/backup/filePointer_test.ts +++ b/ts/test-electron/backup/filePointer_test.ts @@ -17,6 +17,7 @@ import type { AttachmentType } from '../../types/Attachment'; import { strictAssert } from '../../util/assert'; import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId'; import { MASTER_KEY } from './helpers'; +import { getRandomBytes } from '../../Crypto'; describe('convertFilePointerToAttachment', () => { it('processes filepointer with attachmentLocator', () => { @@ -179,6 +180,8 @@ function composeAttachment( incrementalMac: 'incrementalMac', incrementalMacChunkSize: 1000, uploadTimestamp: 1234, + localKey: Bytes.toBase64(getRandomBytes(32)), + version: 2, ...overrides, }; } @@ -545,6 +548,12 @@ describe('getFilePointerForAttachment', () => { }); describe('getBackupJobForAttachmentAndFilePointer', async () => { + beforeEach(async () => { + await window.storage.put('masterKey', Bytes.toBase64(getRandomBytes(32))); + }); + afterEach(async () => { + await window.Signal.Data.removeAll(); + }); const attachment = composeAttachment(); it('returns null if filePointer does not have backupLocator', async () => { @@ -590,6 +599,8 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => { digest: 'digest', iv: 'iv', size: 100, + localKey: attachment.localKey, + version: attachment.version, transitCdnInfo: { cdnKey: 'cdnKey', cdnNumber: 2, diff --git a/ts/test-electron/services/AttachmentBackupManager_test.ts b/ts/test-electron/services/AttachmentBackupManager_test.ts index d21a855856e..461e040f77e 100644 --- a/ts/test-electron/services/AttachmentBackupManager_test.ts +++ b/ts/test-electron/services/AttachmentBackupManager_test.ts @@ -4,6 +4,8 @@ import * as sinon from 'sinon'; import { assert } from 'chai'; import { join } from 'path'; +import { createWriteStream } from 'fs'; +import { ensureFile } from 'fs-extra'; import * as Bytes from '../../Bytes'; import { @@ -14,28 +16,40 @@ import { import type { AttachmentBackupJobType, CoreAttachmentBackupJobType, + StandardAttachmentBackupJobType, + ThumbnailAttachmentBackupJobType, } from '../../types/AttachmentBackup'; import dataInterface from '../../sql/Client'; import { getRandomBytes } from '../../Crypto'; -import { VIDEO_MP4 } from '../../types/MIME'; +import { APPLICATION_OCTET_STREAM, VIDEO_MP4 } from '../../types/MIME'; +import { createName, getRelativePath } from '../../util/attachmentPath'; +import { + encryptAttachmentV2, + generateKeys, + safeUnlinkSync, +} from '../../AttachmentCrypto'; const TRANSIT_CDN = 2; const TRANSIT_CDN_FOR_NEW_UPLOAD = 42; const BACKUP_CDN = 3; + +const RELATIVE_ATTACHMENT_PATH = getRelativePath(createName()); +const LOCAL_ENCRYPTION_KEYS = Bytes.toBase64(generateKeys()); +const ATTACHMENT_SIZE = 3577986; + describe('AttachmentBackupManager/JobManager', () => { let backupManager: AttachmentBackupManager | undefined; let runJob: sinon.SinonSpy; let backupMediaBatch: sinon.SinonStub; let backupsService = {}; let encryptAndUploadAttachment: sinon.SinonStub; - let getAbsoluteAttachmentPath: sinon.SinonStub; let sandbox: sinon.SinonSandbox; let isInCall: sinon.SinonStub; function composeJob( index: number, overrides: Partial = {} - ): CoreAttachmentBackupJobType { + ): StandardAttachmentBackupJobType { const mediaName = `mediaName${index}`; return { @@ -43,17 +57,41 @@ describe('AttachmentBackupManager/JobManager', () => { type: 'standard', receivedAt: index, data: { - path: 'ghost-kitty.mp4', + path: RELATIVE_ATTACHMENT_PATH, contentType: VIDEO_MP4, keys: 'keys=', iv: 'iv==', digest: 'digest=', + version: 2, + localKey: LOCAL_ENCRYPTION_KEYS, transitCdnInfo: { cdnKey: 'transitCdnKey', cdnNumber: TRANSIT_CDN, uploadTimestamp: Date.now(), }, - size: 128, + size: ATTACHMENT_SIZE, + ...overrides, + }, + }; + } + + function composeThumbnailJob( + index: number, + overrides: Partial = {} + ): ThumbnailAttachmentBackupJobType { + const mediaName = `thumbnail${index}`; + + return { + mediaName, + type: 'thumbnail', + receivedAt: index, + data: { + fullsizePath: RELATIVE_ATTACHMENT_PATH, + fullsizeSize: ATTACHMENT_SIZE, + contentType: VIDEO_MP4, + version: 2, + localKey: LOCAL_ENCRYPTION_KEYS, + ...overrides, }, }; @@ -83,14 +121,9 @@ describe('AttachmentBackupManager/JobManager', () => { cdnKey: 'newKeyOnTransitTier', cdnNumber: TRANSIT_CDN_FOR_NEW_UPLOAD, }); + const decryptAttachmentV2ToSink = sinon.stub(); - getAbsoluteAttachmentPath = sandbox.stub().callsFake(path => { - if (path === 'ghost-kitty.mp4') { - return join(__dirname, '../../../fixtures/ghost-kitty.mp4'); - } - return getAbsoluteAttachmentPath.wrappedMethod(path); - }); - + const { getAbsoluteAttachmentPath } = window.Signal.Migrations; runJob = sandbox.stub().callsFake((job: AttachmentBackupJobType) => { return runAttachmentBackupJob(job, false, { // @ts-expect-error incomplete stubbing @@ -98,6 +131,7 @@ describe('AttachmentBackupManager/JobManager', () => { backupMediaBatch, getAbsoluteAttachmentPath, encryptAndUploadAttachment, + decryptAttachmentV2ToSink, }); }); @@ -106,18 +140,34 @@ describe('AttachmentBackupManager/JobManager', () => { shouldHoldOffOnStartingQueuedJobs: isInCall, runJob, }); + + const absolutePath = getAbsoluteAttachmentPath(RELATIVE_ATTACHMENT_PATH); + await ensureFile(absolutePath); + await encryptAttachmentV2({ + plaintext: { + absolutePath: join(__dirname, '../../../fixtures/ghost-kitty.mp4'), + }, + keys: Bytes.fromBase64(LOCAL_ENCRYPTION_KEYS), + sink: createWriteStream(absolutePath), + getAbsoluteAttachmentPath, + }); }); afterEach(async () => { sandbox.restore(); delete window.textsecure.server; + safeUnlinkSync( + window.Signal.Migrations.getAbsoluteAttachmentPath( + RELATIVE_ATTACHMENT_PATH + ) + ); await backupManager?.stop(); }); async function addJobs( num: number, - overrides: Partial = {} - ): Promise> { + overrides: Partial = {} + ): Promise> { const jobs = new Array(num) .fill(null) .map((_, idx) => composeJob(idx, overrides)); @@ -128,6 +178,20 @@ describe('AttachmentBackupManager/JobManager', () => { return jobs; } + async function addThumbnailJobs( + num: number, + overrides: Partial = {} + ): Promise> { + const jobs = new Array(num) + .fill(null) + .map((_, idx) => composeThumbnailJob(idx, overrides)); + for (const job of jobs) { + // eslint-disable-next-line no-await-in-loop + await backupManager?.addJob(job); + } + return jobs; + } + function waitForJobToBeStarted( job: CoreAttachmentBackupJobType, attempts: number = 0 @@ -156,22 +220,13 @@ describe('AttachmentBackupManager/JobManager', () => { }); } - it('saves jobs, removes jobs, and runs 3 jobs at a time in descending receivedAt order', async () => { + it('runs 3 jobs at a time in descending receivedAt order, fullsize first', async () => { const jobs = await addJobs(5); + const thumbnailJobs = await addThumbnailJobs(5); // Confirm they are saved to DB const allJobs = await getAllSavedJobs(); - assert.strictEqual(allJobs.length, 5); - assert.strictEqual( - JSON.stringify(allJobs.map(job => job.mediaName)), - JSON.stringify([ - 'mediaName4', - 'mediaName3', - 'mediaName2', - 'mediaName1', - 'mediaName0', - ]) - ); + assert.strictEqual(allJobs.length, 10); await backupManager?.start(); await waitForJobToBeStarted(jobs[2]); @@ -183,7 +238,21 @@ describe('AttachmentBackupManager/JobManager', () => { assert.strictEqual(runJob.callCount, 5); assertRunJobCalledWith([jobs[4], jobs[3], jobs[2], jobs[1], jobs[0]]); - await waitForJobToBeCompleted(jobs[0]); + await waitForJobToBeCompleted(thumbnailJobs[0]); + assert.strictEqual(runJob.callCount, 10); + + assertRunJobCalledWith([ + jobs[4], + jobs[3], + jobs[2], + jobs[1], + jobs[0], + thumbnailJobs[4], + thumbnailJobs[3], + thumbnailJobs[2], + thumbnailJobs[1], + thumbnailJobs[0], + ]); assert.strictEqual((await getAllSavedJobs()).length, 0); }); @@ -250,7 +319,11 @@ describe('AttachmentBackupManager/JobManager', () => { it('without transitCdnInfo, will permanently remove job if file not found at path', async () => { const [job] = await addJobs(1, { transitCdnInfo: undefined }); - getAbsoluteAttachmentPath.returns('no/file/here'); + safeUnlinkSync( + window.Signal.Migrations.getAbsoluteAttachmentPath( + RELATIVE_ATTACHMENT_PATH + ) + ); await backupManager?.start(); await waitForJobToBeCompleted(job); @@ -261,4 +334,28 @@ describe('AttachmentBackupManager/JobManager', () => { const allRemainingJobs = await getAllSavedJobs(); assert.strictEqual(allRemainingJobs.length, 0); }); + + describe('thumbnail backups', () => { + it('addJobAndMaybeThumbnailJob conditionally adds thumbnail job', async () => { + const jobForVisualAttachment = composeJob(0); + const jobForNonVisualAttachment = composeJob(1, { + contentType: APPLICATION_OCTET_STREAM, + }); + + await backupManager?.addJobAndMaybeThumbnailJob(jobForVisualAttachment); + await backupManager?.addJobAndMaybeThumbnailJob( + jobForNonVisualAttachment + ); + + const thumbnailMediaName = Bytes.toBase64( + Bytes.fromString(`${jobForVisualAttachment.mediaName}_thumbnail`) + ); + const allJobs = await getAllSavedJobs(); + assert.strictEqual(allJobs.length, 3); + assert.sameMembers( + allJobs.map(job => job.mediaName), + ['mediaName1', 'mediaName0', thumbnailMediaName] + ); + }); + }); }); diff --git a/ts/test-electron/services/AttachmentDownloadManager_test.ts b/ts/test-electron/services/AttachmentDownloadManager_test.ts index c3e34b39266..c03b0ea7599 100644 --- a/ts/test-electron/services/AttachmentDownloadManager_test.ts +++ b/ts/test-electron/services/AttachmentDownloadManager_test.ts @@ -5,17 +5,52 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import * as sinon from 'sinon'; import { assert } from 'chai'; -import * as MIME from '../../types/MIME'; +import { omit } from 'lodash'; +import * as MIME from '../../types/MIME'; import { AttachmentDownloadManager, AttachmentDownloadUrgency, + runDownloadAttachmentJobInner, type NewAttachmentDownloadJobType, } from '../../jobs/AttachmentDownloadManager'; import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload'; import dataInterface from '../../sql/Client'; import { MINUTE } from '../../util/durations'; import { type AciString } from '../../types/ServiceId'; +import { type AttachmentType, AttachmentVariant } from '../../types/Attachment'; +import { strictAssert } from '../../util/assert'; + +function composeJob({ + messageId, + receivedAt, + attachmentOverrides, +}: Pick & { + attachmentOverrides?: Partial; +}): AttachmentDownloadJobType { + const digest = `digestFor${messageId}`; + const size = 128; + const contentType = MIME.IMAGE_PNG; + return { + messageId, + receivedAt, + sentAt: receivedAt, + attachmentType: 'attachment', + digest, + size, + contentType, + active: false, + attempts: 0, + retryAfter: null, + lastAttemptTimestamp: null, + attachment: { + contentType, + size, + digest: `digestFor${messageId}`, + ...attachmentOverrides, + }, + }; +} describe('AttachmentDownloadManager/JobManager', () => { let downloadManager: AttachmentDownloadManager | undefined; @@ -24,36 +59,6 @@ describe('AttachmentDownloadManager/JobManager', () => { let clock: sinon.SinonFakeTimers; let isInCall: sinon.SinonStub; - function composeJob({ - messageId, - receivedAt, - }: Pick< - NewAttachmentDownloadJobType, - 'messageId' | 'receivedAt' - >): AttachmentDownloadJobType { - const digest = `digestFor${messageId}`; - const size = 128; - const contentType = MIME.IMAGE_PNG; - return { - messageId, - receivedAt, - sentAt: receivedAt, - attachmentType: 'attachment', - digest, - size, - contentType, - active: false, - attempts: 0, - retryAfter: null, - lastAttemptTimestamp: null, - attachment: { - contentType, - size, - digest: `digestFor${messageId}`, - }, - }; - } - beforeEach(async () => { await dataInterface.removeAll(); @@ -72,13 +77,13 @@ describe('AttachmentDownloadManager/JobManager', () => { downloadManager = new AttachmentDownloadManager({ ...AttachmentDownloadManager.defaultParams, shouldHoldOffOnStartingQueuedJobs: isInCall, - runJob, + runDownloadAttachmentJob: runJob, getRetryConfig: () => ({ maxAttempts: 5, backoffConfig: { - multiplier: 5, + multiplier: 2, firstBackoffs: [MINUTE], - maxBackoffTime: 30 * MINUTE, + maxBackoffTime: 10 * MINUTE, }, }), }); @@ -143,7 +148,7 @@ describe('AttachmentDownloadManager/JobManager', () => { .getCalls() .map( call => - `${call.args[0].messageId}${call.args[0].attachmentType}.${call.args[0].digest}` + `${call.args[0].job.messageId}${call.args[0].job.attachmentType}.${call.args[0].job.digest}` ) ), JSON.stringify( @@ -158,8 +163,13 @@ describe('AttachmentDownloadManager/JobManager', () => { // prior (unfinished) invocations can prevent subsequent calls after the clock is // ticked forward and make tests unreliable await dataInterface.getAllItems(); - await clock.tickAsync(ms); - await dataInterface.getAllItems(); + const now = Date.now(); + while (Date.now() < now + ms) { + // eslint-disable-next-line no-await-in-loop + await clock.tickAsync(downloadManager?.tickInterval ?? 1000); + // eslint-disable-next-line no-await-in-loop + await dataInterface.getAllItems(); + } } function getPromisesForAttempts( @@ -270,7 +280,7 @@ describe('AttachmentDownloadManager/JobManager', () => { const job0Attempts = getPromisesForAttempts(jobs[0], 1); const job1Attempts = getPromisesForAttempts(jobs[1], 5); - runJob.callsFake(async (job: AttachmentDownloadJobType) => { + runJob.callsFake(async ({ job }: { job: AttachmentDownloadJobType }) => { return new Promise<{ status: 'finished' | 'retry' }>(resolve => { Promise.resolve().then(() => { if (job.messageId === jobs[0].messageId) { @@ -299,16 +309,16 @@ describe('AttachmentDownloadManager/JobManager', () => { await job1Attempts[1].completed; assert.strictEqual(runJob.callCount, 3); - await advanceTime(5 * MINUTE); + await advanceTime(2 * MINUTE); await job1Attempts[2].completed; assert.strictEqual(runJob.callCount, 4); - await advanceTime(25 * MINUTE); + await advanceTime(4 * MINUTE); await job1Attempts[3].completed; assert.strictEqual(runJob.callCount, 5); - await advanceTime(30 * MINUTE); + await advanceTime(8 * MINUTE); await job1Attempts[4].completed; assert.strictEqual(runJob.callCount, 6); @@ -359,15 +369,15 @@ describe('AttachmentDownloadManager/JobManager', () => { await attempts[1].completed; assert.strictEqual(runJob.callCount, 5); - await advanceTime(5 * MINUTE); + await advanceTime(2 * MINUTE); await attempts[2].completed; assert.strictEqual(runJob.callCount, 6); - await advanceTime(25 * MINUTE); + await advanceTime(4 * MINUTE); await attempts[3].completed; assert.strictEqual(runJob.callCount, 7); - await advanceTime(30 * MINUTE); + await advanceTime(8 * MINUTE); await attempts[4].completed; assert.strictEqual(runJob.callCount, 8); @@ -375,3 +385,237 @@ describe('AttachmentDownloadManager/JobManager', () => { assert.isUndefined(await dataInterface.getAttachmentDownloadJob(jobs[0])); }); }); + +describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { + let sandbox: sinon.SinonSandbox; + let downloadAttachment: sinon.SinonStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + downloadAttachment = sandbox.stub().returns({ + path: '/path/to/file', + iv: Buffer.alloc(16), + plaintextHash: 'plaintextHash', + }); + }); + + afterEach(async () => { + sandbox.restore(); + }); + describe('visible message', () => { + it('will only download full-size if attachment not from backup', async () => { + const job = composeJob({ + messageId: '1', + receivedAt: 1, + }); + + const result = await runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: true, + dependencies: { downloadAttachment }, + }); + + assert.strictEqual(result.onlyAttemptedBackupThumbnail, false); + assert.strictEqual(downloadAttachment.callCount, 1); + assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.Default, + }); + }); + it('will download thumbnail if attachment is from backup', async () => { + const job = composeJob({ + messageId: '1', + receivedAt: 1, + attachmentOverrides: { + backupLocator: { + mediaName: 'medianame', + }, + }, + }); + + const result = await runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: true, + dependencies: { downloadAttachment }, + }); + + strictAssert( + result.onlyAttemptedBackupThumbnail === true, + 'only attempted backup thumbnail' + ); + assert.deepStrictEqual( + omit(result.attachmentWithThumbnail, 'thumbnailFromBackup'), + { + contentType: MIME.IMAGE_PNG, + size: 128, + digest: 'digestFor1', + backupLocator: { mediaName: 'medianame' }, + } + ); + assert.equal( + result.attachmentWithThumbnail.thumbnailFromBackup?.path, + '/path/to/file' + ); + assert.strictEqual(downloadAttachment.callCount, 1); + assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.ThumbnailFromBackup, + }); + }); + it('will download full size if thumbnail already backed up', async () => { + const job = composeJob({ + messageId: '1', + receivedAt: 1, + attachmentOverrides: { + backupLocator: { + mediaName: 'medianame', + }, + thumbnailFromBackup: { + path: '/path/to/thumbnail', + }, + }, + }); + + const result = await runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: true, + dependencies: { downloadAttachment }, + }); + assert.strictEqual(result.onlyAttemptedBackupThumbnail, false); + assert.strictEqual(downloadAttachment.callCount, 1); + assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.Default, + }); + }); + + it('will attempt to download full size if thumbnail fails', async () => { + downloadAttachment = sandbox.stub().callsFake(() => { + throw new Error('error while downloading'); + }); + + const job = composeJob({ + messageId: '1', + receivedAt: 1, + attachmentOverrides: { + backupLocator: { + mediaName: 'medianame', + }, + }, + }); + + await assert.isRejected( + runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: true, + dependencies: { downloadAttachment }, + }) + ); + + assert.strictEqual(downloadAttachment.callCount, 2); + assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.ThumbnailFromBackup, + }); + assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.Default, + }); + }); + }); + describe('message not visible', () => { + it('will only download full-size if message not visible', async () => { + const job = composeJob({ + messageId: '1', + receivedAt: 1, + attachmentOverrides: { + backupLocator: { + mediaName: 'medianame', + }, + }, + }); + + const result = await runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: false, + dependencies: { downloadAttachment }, + }); + assert.strictEqual(result.onlyAttemptedBackupThumbnail, false); + assert.strictEqual(downloadAttachment.callCount, 1); + assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.Default, + }); + }); + it('will fallback to thumbnail if main download fails and backuplocator exists', async () => { + downloadAttachment = sandbox.stub().callsFake(({ variant }) => { + if (variant === AttachmentVariant.Default) { + throw new Error('error while downloading'); + } + return { + path: '/path/to/thumbnail', + iv: Buffer.alloc(16), + plaintextHash: 'plaintextHash', + }; + }); + + const job = composeJob({ + messageId: '1', + receivedAt: 1, + attachmentOverrides: { + backupLocator: { + mediaName: 'medianame', + }, + }, + }); + + const result = await runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: false, + dependencies: { downloadAttachment }, + }); + assert.strictEqual(result.onlyAttemptedBackupThumbnail, false); + assert.strictEqual(downloadAttachment.callCount, 2); + assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.Default, + }); + assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.ThumbnailFromBackup, + }); + }); + + it("won't fallback to thumbnail if main download fails and no backup locator", async () => { + downloadAttachment = sandbox.stub().callsFake(({ variant }) => { + if (variant === AttachmentVariant.Default) { + throw new Error('error while downloading'); + } + return { + path: '/path/to/thumbnail', + iv: Buffer.alloc(16), + plaintextHash: 'plaintextHash', + }; + }); + + const job = composeJob({ + messageId: '1', + receivedAt: 1, + }); + + await assert.isRejected( + runDownloadAttachmentJobInner({ + job, + isForCurrentlyVisibleMessage: false, + dependencies: { downloadAttachment }, + }) + ); + + assert.strictEqual(downloadAttachment.callCount, 1); + assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { + attachment: job.attachment, + variant: AttachmentVariant.Default, + }); + }); + }); +}); diff --git a/ts/test-electron/util/downloadAttachment_test.ts b/ts/test-electron/util/downloadAttachment_test.ts index 59a8828cfaa..90539dd0ba0 100644 --- a/ts/test-electron/util/downloadAttachment_test.ts +++ b/ts/test-electron/util/downloadAttachment_test.ts @@ -13,11 +13,13 @@ import { HTTPError } from '../../textsecure/Errors'; import { getCdnNumberForBackupTier } from '../../textsecure/downloadAttachment'; import { MASTER_KEY } from '../backup/helpers'; import { getMediaIdFromMediaName } from '../../services/backups/util/mediaId'; +import { AttachmentVariant } from '../../types/Attachment'; describe('utils/downloadAttachment', () => { const baseAttachment = { size: 100, contentType: IMAGE_PNG, + digest: 'digest', }; let sandbox: sinon.SinonSandbox; @@ -37,14 +39,21 @@ describe('utils/downloadAttachment', () => { cdnKey: 'cdnKey', cdnNumber: 2, }; - await downloadAttachment(attachment, { - downloadAttachmentFromServer: stubDownload, + await downloadAttachment({ + attachment, + dependencies: { + downloadAttachmentFromServer: stubDownload, + }, }); assert.equal(stubDownload.callCount, 1); assert.deepEqual(stubDownload.getCall(0).args, [ fakeServer, attachment, - { mediaTier: MediaTier.STANDARD }, + { + mediaTier: MediaTier.STANDARD, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); }); @@ -60,8 +69,11 @@ describe('utils/downloadAttachment', () => { cdnNumber: 2, }; await assert.isRejected( - downloadAttachment(attachment, { - downloadAttachmentFromServer: stubDownload, + downloadAttachment({ + attachment, + dependencies: { + downloadAttachmentFromServer: stubDownload, + }, }), AttachmentPermanentlyUndownloadableError ); @@ -70,7 +82,11 @@ describe('utils/downloadAttachment', () => { assert.deepEqual(stubDownload.getCall(0).args, [ fakeServer, attachment, - { mediaTier: MediaTier.STANDARD }, + { + mediaTier: MediaTier.STANDARD, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); }); @@ -84,14 +100,21 @@ describe('utils/downloadAttachment', () => { mediaName: 'medianame', }, }; - await downloadAttachment(attachment, { - downloadAttachmentFromServer: stubDownload, + await downloadAttachment({ + attachment, + dependencies: { + downloadAttachmentFromServer: stubDownload, + }, }); assert.equal(stubDownload.callCount, 1); assert.deepEqual(stubDownload.getCall(0).args, [ fakeServer, attachment, - { mediaTier: MediaTier.BACKUP }, + { + mediaTier: MediaTier.BACKUP, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); }); @@ -109,19 +132,30 @@ describe('utils/downloadAttachment', () => { mediaName: 'medianame', }, }; - await downloadAttachment(attachment, { - downloadAttachmentFromServer: stubDownload, + await downloadAttachment({ + attachment, + dependencies: { + downloadAttachmentFromServer: stubDownload, + }, }); assert.equal(stubDownload.callCount, 2); assert.deepEqual(stubDownload.getCall(0).args, [ fakeServer, attachment, - { mediaTier: MediaTier.BACKUP }, + { + mediaTier: MediaTier.BACKUP, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); assert.deepEqual(stubDownload.getCall(1).args, [ fakeServer, attachment, - { mediaTier: MediaTier.STANDARD }, + { + mediaTier: MediaTier.STANDARD, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); }); @@ -139,19 +173,30 @@ describe('utils/downloadAttachment', () => { mediaName: 'medianame', }, }; - await downloadAttachment(attachment, { - downloadAttachmentFromServer: stubDownload, + await downloadAttachment({ + attachment, + dependencies: { + downloadAttachmentFromServer: stubDownload, + }, }); assert.equal(stubDownload.callCount, 2); assert.deepEqual(stubDownload.getCall(0).args, [ fakeServer, attachment, - { mediaTier: MediaTier.BACKUP }, + { + mediaTier: MediaTier.BACKUP, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); assert.deepEqual(stubDownload.getCall(1).args, [ fakeServer, attachment, - { mediaTier: MediaTier.STANDARD }, + { + mediaTier: MediaTier.STANDARD, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); }); @@ -170,8 +215,11 @@ describe('utils/downloadAttachment', () => { }; await assert.isRejected( - downloadAttachment(attachment, { - downloadAttachmentFromServer: stubDownload, + downloadAttachment({ + attachment, + dependencies: { + downloadAttachmentFromServer: stubDownload, + }, }), HTTPError ); @@ -179,12 +227,20 @@ describe('utils/downloadAttachment', () => { assert.deepEqual(stubDownload.getCall(0).args, [ fakeServer, attachment, - { mediaTier: MediaTier.BACKUP }, + { + mediaTier: MediaTier.BACKUP, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); assert.deepEqual(stubDownload.getCall(1).args, [ fakeServer, attachment, - { mediaTier: MediaTier.STANDARD }, + { + mediaTier: MediaTier.STANDARD, + variant: AttachmentVariant.Default, + logPrefix: '[REDACTED]est', + }, ]); }); }); diff --git a/ts/textsecure/ContactsParser.ts b/ts/textsecure/ContactsParser.ts index 208d794f17f..e2fee5e66dd 100644 --- a/ts/textsecure/ContactsParser.ts +++ b/ts/textsecure/ContactsParser.ts @@ -60,7 +60,7 @@ export async function parseContactsV2( ), keysBase64: attachment.localKey, size: attachment.size, - isLocal: true, + type: 'local', }, parseContactsTransform ); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 967912de861..a56d73d5ed2 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -3960,7 +3960,11 @@ export default class MessageReceiver options?: { timeout?: number; disableRetries?: boolean } ): Promise { const cleaned = processAttachment(attachment); - return downloadAttachment(this.server, cleaned, options); + const downloaded = await downloadAttachment(this.server, cleaned, options); + return { + ...cleaned, + ...downloaded, + }; } private async handleEndSession( diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts index 8d5311d8d4b..a677d371261 100644 --- a/ts/textsecure/downloadAttachment.ts +++ b/ts/textsecure/downloadAttachment.ts @@ -14,18 +14,21 @@ import { AttachmentSizeError, mightBeOnBackupTier, type AttachmentType, + AttachmentVariant, } from '../types/Attachment'; -import * as MIME from '../types/MIME'; import * as Bytes from '../Bytes'; import { deriveBackupMediaKeyMaterial, type BackupMediaKeyMaterialType, + deriveBackupMediaThumbnailInnerEncryptionKeyMaterial, } from '../Crypto'; import { - reencryptAttachmentV2, getAttachmentCiphertextLength, safeUnlinkSync, splitKeys, + type ReencryptedAttachmentV2, + decryptAndReencryptLocally, + measureSize, } from '../AttachmentCrypto'; import type { ProcessedAttachment } from './Types.d'; import type { WebAPIType } from './WebAPI'; @@ -33,7 +36,13 @@ import { createName, getRelativePath } from '../util/attachmentPath'; import { MediaTier } from '../types/AttachmentDownload'; import { getBackupKey } from '../services/backups/crypto'; import { backupsService } from '../services/backups'; -import { getMediaIdForAttachment } from '../services/backups/util/mediaId'; +import { + getMediaIdForAttachment, + getMediaIdForAttachmentThumbnail, +} from '../services/backups/util/mediaId'; +import { MAX_BACKUP_THUMBNAIL_SIZE } from '../types/VisualAttachment'; +import { missingCaseError } from '../util/missingCaseError'; +import { IV_LENGTH, MAC_LENGTH } from '../types/Crypto'; const DEFAULT_BACKUP_CDN_NUMBER = 3; @@ -43,7 +52,7 @@ export function getCdnKey(attachment: ProcessedAttachment): string { return cdnKey; } -function getBackupMediaKeyMaterial( +function getBackupMediaOuterEncryptionKeyMaterial( attachment: AttachmentType ): BackupMediaKeyMaterialType { const mediaId = getMediaIdForAttachment(attachment); @@ -51,6 +60,24 @@ function getBackupMediaKeyMaterial( return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); } +function getBackupThumbnailInnerEncryptionKeyMaterial( + attachment: AttachmentType +): BackupMediaKeyMaterialType { + const mediaId = getMediaIdForAttachmentThumbnail(attachment); + const backupKey = getBackupKey(); + return deriveBackupMediaThumbnailInnerEncryptionKeyMaterial( + backupKey, + mediaId.bytes + ); +} +function getBackupThumbnailOuterEncryptionKeyMaterial( + attachment: AttachmentType +): BackupMediaKeyMaterialType { + const mediaId = getMediaIdForAttachmentThumbnail(attachment); + const backupKey = getBackupKey(); + return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); +} + export async function getCdnNumberForBackupTier( attachment: ProcessedAttachment ): Promise { @@ -75,16 +102,17 @@ export async function getCdnNumberForBackupTier( export async function downloadAttachment( server: WebAPIType, attachment: ProcessedAttachment, - options?: { + options: { + variant?: AttachmentVariant; disableRetries?: boolean; timeout?: number; mediaTier?: MediaTier; logPrefix?: string; - } -): Promise { - const logId = `${options?.logPrefix}/downloadAttachment`; + } = { variant: AttachmentVariant.Default } +): Promise { + const logId = `downloadAttachment/${options.logPrefix ?? ''}`; - const { digest, key, size, contentType } = attachment; + const { digest, key, size } = attachment; strictAssert(digest, `${logId}: missing digest`); strictAssert(key, `${logId}: missing key`); @@ -94,8 +122,13 @@ export async function downloadAttachment( options?.mediaTier ?? (mightBeOnBackupTier(attachment) ? MediaTier.BACKUP : MediaTier.STANDARD); - let downloadedPath: string; + let downloadResult: Awaited>; + if (mediaTier === MediaTier.STANDARD) { + strictAssert( + options.variant !== AttachmentVariant.ThumbnailFromBackup, + 'Thumbnails can only be downloaded from backup tier' + ); const cdnKey = getCdnKey(attachment); const { cdnNumber } = attachment; @@ -104,9 +137,13 @@ export async function downloadAttachment( cdnNumber, options, }); - downloadedPath = await downloadToDisk({ downloadStream, size }); + downloadResult = await downloadToDisk({ downloadStream, size }); } else { - const mediaId = getMediaIdForAttachment(attachment); + const mediaId = + options.variant === AttachmentVariant.ThumbnailFromBackup + ? getMediaIdForAttachmentThumbnail(attachment) + : getMediaIdForAttachment(attachment); + const cdnNumber = await getCdnNumberForBackupTier(attachment); const cdnCredentials = await backupsService.credentials.getCDNReadCredentials(cdnNumber); @@ -122,51 +159,75 @@ export async function downloadAttachment( cdnNumber, options, }); - downloadedPath = await downloadToDisk({ + downloadResult = await downloadToDisk({ downloadStream, - size: getAttachmentCiphertextLength(size), + size: getAttachmentCiphertextLength( + options.variant === AttachmentVariant.ThumbnailFromBackup + ? // be generous, accept downloads of up to twice what we expect for thumbnail + MAX_BACKUP_THUMBNAIL_SIZE * 2 + : size + ), }); } + const { relativePath: downloadedRelativePath, downloadSize } = downloadResult; + const cipherTextAbsolutePath = - window.Signal.Migrations.getAbsoluteAttachmentPath(downloadedPath); + window.Signal.Migrations.getAbsoluteAttachmentPath(downloadedRelativePath); - const { aesKey, macKey } = splitKeys(Bytes.fromBase64(key)); - const { - path, - plaintextHash, - iv, - key: localKey, - } = await reencryptAttachmentV2({ - ciphertextPath: cipherTextAbsolutePath, - idForLogging: logId, - aesKey, - macKey, - size, - theirDigest: Bytes.fromBase64(digest), - outerEncryption: - mediaTier === 'backup' - ? getBackupMediaKeyMaterial(attachment) - : undefined, - getAbsoluteAttachmentPath: - window.Signal.Migrations.getAbsoluteAttachmentPath, - }); - - safeUnlinkSync(cipherTextAbsolutePath); - - return { - ...attachment, - path, - size, - contentType: contentType - ? MIME.stringToMIMEType(contentType) - : MIME.APPLICATION_OCTET_STREAM, - plaintextHash, - iv: Bytes.toBase64(iv), - - version: 2, - localKey: Bytes.toBase64(localKey), - }; + try { + switch (options.variant) { + case AttachmentVariant.Default: + case undefined: { + const { aesKey, macKey } = splitKeys(Bytes.fromBase64(key)); + return await decryptAndReencryptLocally({ + type: 'standard', + ciphertextPath: cipherTextAbsolutePath, + idForLogging: logId, + aesKey, + macKey, + size, + theirDigest: Bytes.fromBase64(digest), + outerEncryption: + mediaTier === 'backup' + ? getBackupMediaOuterEncryptionKeyMaterial(attachment) + : undefined, + getAbsoluteAttachmentPath: + window.Signal.Migrations.getAbsoluteAttachmentPath, + }); + } + case AttachmentVariant.ThumbnailFromBackup: { + strictAssert( + mediaTier === 'backup', + 'Thumbnail must be downloaded from backup tier' + ); + const thumbnailEncryptionKeys = + getBackupThumbnailInnerEncryptionKeyMaterial(attachment); + // backup thumbnails don't get trimmed, so we just calculate the size as the + // ciphertextSize, less IV and MAC + const calculatedSize = downloadSize - IV_LENGTH - MAC_LENGTH; + return { + ...(await decryptAndReencryptLocally({ + type: 'backupThumbnail', + ciphertextPath: cipherTextAbsolutePath, + idForLogging: logId, + size: calculatedSize, + ...thumbnailEncryptionKeys, + outerEncryption: + getBackupThumbnailOuterEncryptionKeyMaterial(attachment), + getAbsoluteAttachmentPath: + window.Signal.Migrations.getAbsoluteAttachmentPath, + })), + size: calculatedSize, + }; + } + default: { + throw missingCaseError(options.variant); + } + } + } finally { + safeUnlinkSync(cipherTextAbsolutePath); + } } async function downloadToDisk({ @@ -175,16 +236,24 @@ async function downloadToDisk({ }: { downloadStream: Readable; size: number; -}): Promise { +}): Promise<{ relativePath: string; downloadSize: number }> { const relativeTargetPath = getRelativePath(createName()); const absoluteTargetPath = window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath); await ensureFile(absoluteTargetPath); const writeStream = createWriteStream(absoluteTargetPath); const targetSize = getAttachmentCiphertextLength(size); + let downloadSize = 0; try { - await pipeline(downloadStream, checkSize(targetSize), writeStream); + await pipeline( + downloadStream, + checkSize(targetSize), + measureSize(bytesSeen => { + downloadSize = bytesSeen; + }), + writeStream + ); } catch (error) { try { safeUnlinkSync(absoluteTargetPath); @@ -198,7 +267,7 @@ async function downloadToDisk({ throw error; } - return relativeTargetPath; + return { relativePath: relativeTargetPath, downloadSize }; } // A simple transform that throws if it sees more than maxBytes on the stream. diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index a2650431607..4244b79729d 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -95,6 +95,10 @@ export type AttachmentType = { // See app/attachment_channel.ts version?: 1 | 2; localKey?: string; // AES + MAC + thumbnailFromBackup?: Pick< + AttachmentType, + 'path' | 'version' | 'plaintextHash' + >; /** Legacy field. Used only for downloading old attachments */ id?: number; @@ -121,6 +125,12 @@ export type AddressableAttachmentType = Readonly<{ data?: Uint8Array; }>; +export type AttachmentForUIType = AttachmentType & { + thumbnailFromBackup?: { + url?: string; + }; +}; + export type UploadedAttachmentType = Proto.IAttachmentPointer & Readonly<{ // Required fields @@ -225,6 +235,11 @@ export type ThumbnailType = AttachmentType & { copied?: boolean; }; +export enum AttachmentVariant { + Default = 'Default', + ThumbnailFromBackup = 'thumbnailFromBackup', +} + // // Incoming message attachment fields // { // id: string @@ -383,7 +398,8 @@ export function deleteData( throw new TypeError('deleteData: attachment is not valid'); } - const { path, thumbnail, screenshot } = attachment; + const { path, thumbnail, screenshot, thumbnailFromBackup } = attachment; + if (isString(path)) { await deleteOnDisk(path); } @@ -395,6 +411,10 @@ export function deleteData( if (screenshot && isString(screenshot.path)) { await deleteOnDisk(screenshot.path); } + + if (thumbnailFromBackup && isString(thumbnailFromBackup.path)) { + await deleteOnDisk(thumbnailFromBackup.path); + } }; } @@ -629,7 +649,7 @@ export function canDisplayImage( } export function getThumbnailUrl( - attachment: AttachmentType + attachment: AttachmentForUIType ): string | undefined { if (attachment.thumbnail) { return attachment.thumbnail.url; @@ -638,7 +658,7 @@ export function getThumbnailUrl( return getUrl(attachment); } -export function getUrl(attachment: AttachmentType): string | undefined { +export function getUrl(attachment: AttachmentForUIType): string | undefined { if (attachment.screenshot) { return attachment.screenshot.url; } @@ -647,7 +667,7 @@ export function getUrl(attachment: AttachmentType): string | undefined { return undefined; } - return attachment.url; + return attachment.url ?? attachment.thumbnailFromBackup?.url; } export function isImage(attachments?: ReadonlyArray): boolean { diff --git a/ts/types/AttachmentBackup.ts b/ts/types/AttachmentBackup.ts index 007c7c62239..ecd57f44160 100644 --- a/ts/types/AttachmentBackup.ts +++ b/ts/types/AttachmentBackup.ts @@ -11,7 +11,7 @@ export type CoreAttachmentBackupJobType = | StandardAttachmentBackupJobType | ThumbnailAttachmentBackupJobType; -type StandardAttachmentBackupJobType = { +export type StandardAttachmentBackupJobType = { type: 'standard'; mediaName: string; receivedAt: number; @@ -27,20 +27,21 @@ type StandardAttachmentBackupJobType = { uploadTimestamp?: number; }; size: number; - - version?: 2; + version?: 1 | 2; localKey?: string; }; }; -type ThumbnailAttachmentBackupJobType = { +export type ThumbnailAttachmentBackupJobType = { type: 'thumbnail'; mediaName: string; receivedAt: number; data: { fullsizePath: string | null; + fullsizeSize: number; contentType: MIMEType; - keys: string; + version?: 1 | 2; + localKey?: string; }; }; @@ -60,7 +61,7 @@ const standardBackupJobDataSchema = z.object({ uploadTimestamp: z.number().optional(), }) .optional(), - version: z.literal(2).optional(), + version: z.union([z.literal(1), z.literal(2)]).optional(), localKey: z.string().optional(), }), }); @@ -69,8 +70,10 @@ const thumbnailBackupJobDataSchema = z.object({ type: z.literal('thumbnail'), data: z.object({ fullsizePath: z.string(), + fullsizeSize: z.number(), contentType: MIMETypeSchema, - keys: z.string(), + version: z.union([z.literal(1), z.literal(2)]).optional(), + localKey: z.string().optional(), }), }); diff --git a/ts/types/VisualAttachment.ts b/ts/types/VisualAttachment.ts index 1a730ca9a17..111ce619944 100644 --- a/ts/types/VisualAttachment.ts +++ b/ts/types/VisualAttachment.ts @@ -12,9 +12,12 @@ import { canvasToBlob } from '../util/canvasToBlob'; import { KIBIBYTE } from './AttachmentSize'; import { explodePromise } from '../util/explodePromise'; import { SECOND } from '../util/durations'; +import * as logging from '../logging/log'; export { blobToArrayBuffer }; +export const MAX_BACKUP_THUMBNAIL_SIZE = 8 * KIBIBYTE; + export type GetImageDimensionsOptionsType = Readonly<{ objectUrl: string; logger: Pick; @@ -107,7 +110,6 @@ export type MakeImageThumbnailForBackupOptionsType = Readonly<{ maxDimension?: number; maxSize?: number; objectUrl: string; - logger: LoggerType; }>; // 0.7 quality seems to result in a good result in 1 interation for most images @@ -122,11 +124,10 @@ export type CreatedThumbnailType = { mimeType: MIMEType; }; -export function makeImageThumbnailForBackup({ +export async function makeImageThumbnailForBackup({ maxDimension = 256, - maxSize = 8 * KIBIBYTE, + maxSize = MAX_BACKUP_THUMBNAIL_SIZE, objectUrl, - logger, }: MakeImageThumbnailForBackupOptionsType): Promise { return new Promise((resolve, reject) => { const image = document.createElement('img'); @@ -174,7 +175,7 @@ export function makeImageThumbnailForBackup({ const duration = (performance.now() - start).toFixed(1); - const logMethod = blob.size > maxSize ? logger.warn : logger.info; + const logMethod = blob.size > maxSize ? logging.warn : logging.info; const sizeInKiB = blob.size / KIBIBYTE; logMethod( 'makeImageThumbnail: generated thumbnail of dimensions: ' + @@ -196,7 +197,7 @@ export function makeImageThumbnailForBackup({ }); image.addEventListener('error', error => { - logger.error('makeImageThumbnail error', toLogFormat(error)); + logging.error('makeImageThumbnail error', toLogFormat(error)); reject(error); }); @@ -207,7 +208,6 @@ export function makeImageThumbnailForBackup({ export type MakeVideoScreenshotOptionsType = Readonly<{ objectUrl: string; contentType?: MIMEType; - logger: Pick; }>; const MAKE_VIDEO_SCREENSHOT_TIMEOUT = 30 * SECOND; @@ -228,7 +228,6 @@ function captureScreenshot( export async function makeVideoScreenshot({ objectUrl, contentType = IMAGE_PNG, - logger, }: MakeVideoScreenshotOptionsType): Promise { const signal = AbortSignal.timeout(MAKE_VIDEO_SCREENSHOT_TIMEOUT); const video = document.createElement('video'); @@ -256,7 +255,7 @@ export async function makeVideoScreenshot({ await videoLoadedAndSeeked; return await captureScreenshot(video, contentType); } catch (error) { - logger.error('makeVideoScreenshot error:', toLogFormat(error)); + logging.error('makeVideoScreenshot error:', toLogFormat(error)); throw error; } finally { // hard reset the video element so it doesn't keep loading diff --git a/ts/util/downloadAttachment.ts b/ts/util/downloadAttachment.ts index 841fbbeac4e..3d38ed6ffeb 100644 --- a/ts/util/downloadAttachment.ts +++ b/ts/util/downloadAttachment.ts @@ -1,22 +1,35 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { type AttachmentType, mightBeOnBackupTier } from '../types/Attachment'; +import { + type AttachmentType, + mightBeOnBackupTier, + AttachmentVariant, +} from '../types/Attachment'; import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment'; import { MediaTier } from '../types/AttachmentDownload'; import * as log from '../logging/log'; import { redactGenericText } from './privacy'; import { HTTPError } from '../textsecure/Errors'; import { toLogFormat } from '../types/errors'; +import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto'; export class AttachmentPermanentlyUndownloadableError extends Error {} -export async function downloadAttachment( - attachmentData: AttachmentType, - dependencies = { downloadAttachmentFromServer: doDownloadAttachment } -): Promise { - const redactedDigest = redactGenericText(attachmentData.digest ?? ''); - const logId = `downloadAttachment(${redactedDigest})`; +export async function downloadAttachment({ + attachment, + variant = AttachmentVariant.Default, + dependencies = { downloadAttachmentFromServer: doDownloadAttachment }, +}: { + attachment: AttachmentType; + variant?: AttachmentVariant; + dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment }; +}): Promise { + const redactedDigest = redactGenericText(attachment.digest ?? ''); + const variantForLogging = + variant !== AttachmentVariant.Default ? `[${variant}]` : ''; + const dataId = `${redactedDigest}${variantForLogging}`; + const logId = `downloadAttachmentUtil(${dataId})`; const { server } = window.textsecure; if (!server) { @@ -25,12 +38,12 @@ export async function downloadAttachment( let migratedAttachment: AttachmentType; - const { id: legacyId } = attachmentData; + const { id: legacyId } = attachment; if (legacyId === undefined) { - migratedAttachment = attachmentData; + migratedAttachment = attachment; } else { migratedAttachment = { - ...attachmentData, + ...attachment, cdnId: String(legacyId), }; } @@ -41,7 +54,9 @@ export async function downloadAttachment( server, migratedAttachment, { + variant, mediaTier: MediaTier.BACKUP, + logPrefix: dataId, } ); } catch (error) { @@ -53,7 +68,7 @@ export async function downloadAttachment( // We also just log this error instead of throwing, since we want to still try to // find it on the attachment tier. log.error( - `${logId}: error when downloading from backup CDN`, + `${logId}: error when downloading from backup CDN; will try transit tier`, toLogFormat(error) ); } @@ -65,7 +80,9 @@ export async function downloadAttachment( server, migratedAttachment, { + variant, mediaTier: MediaTier.STANDARD, + logPrefix: dataId, } ); } catch (error) { diff --git a/ts/util/handleVideoAttachment.ts b/ts/util/handleVideoAttachment.ts index b98daf982de..c53a1ac00f0 100644 --- a/ts/util/handleVideoAttachment.ts +++ b/ts/util/handleVideoAttachment.ts @@ -4,7 +4,6 @@ import { blobToArrayBuffer } from 'blob-util'; import { v4 as generateUuid } from 'uuid'; -import * as log from '../logging/log'; import { makeVideoScreenshot } from '../types/VisualAttachment'; import { IMAGE_PNG, stringToMIMEType } from '../types/MIME'; import type { InMemoryAttachmentDraftType } from '../types/Attachment'; @@ -36,7 +35,6 @@ export async function handleVideoAttachment( const screenshotBlob = await makeVideoScreenshot({ objectUrl, contentType: screenshotContentType, - logger: log, }); attachment.screenshotData = new Uint8Array( await blobToArrayBuffer(screenshotBlob)