Support thumbnail export & import during backup of visual attachments

This commit is contained in:
trevor-signal 2024-07-16 16:39:56 -04:00 committed by GitHub
parent 451ee56c92
commit 61548061b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1326 additions and 327 deletions

View file

@ -327,7 +327,7 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
return new Response('Missing key', { status: 400 }); return new Response('Missing key', { status: 400 });
} }
// Size is required for trimming padding. // Size is required for trimming padding
if (maybeSize == null) { if (maybeSize == null) {
return new Response('Missing size', { status: 400 }); return new Response('Missing size', { status: 400 });
} }
@ -345,9 +345,8 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
ciphertextPath: path, ciphertextPath: path,
idForLogging: 'attachment_channel', idForLogging: 'attachment_channel',
keysBase64, keysBase64,
type: 'local',
size, size,
isLocal: true,
}, },
plaintext plaintext
); );

View file

@ -220,7 +220,7 @@ export const readAndDecryptDataFromDisk = async ({
idForLogging: 'attachments/readAndDecryptDataFromDisk', idForLogging: 'attachments/readAndDecryptDataFromDisk',
keysBase64, keysBase64,
size, size,
isLocal: true, type: 'local',
}, },
sink sink
); );

View file

@ -29,6 +29,7 @@ import * as Errors from './types/errors';
import { isNotNil } from './util/isNotNil'; import { isNotNil } from './util/isNotNil';
import { missingCaseError } from './util/missingCaseError'; import { missingCaseError } from './util/missingCaseError';
import { getEnvironment, Environment } from './environment'; 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 // 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. // 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 HEX_DIGEST_LENGTH = DIGEST_LENGTH * 2;
const ATTACHMENT_MAC_LENGTH = MAC_LENGTH; const ATTACHMENT_MAC_LENGTH = MAC_LENGTH;
export class ReencyptedDigestMismatchError extends Error {} export class ReencryptedDigestMismatchError extends Error {}
/** @private */ /** @private */
export const KEY_SET_LENGTH = KEY_LENGTH + MAC_LENGTH; export const KEY_SET_LENGTH = KEY_LENGTH + MAC_LENGTH;
@ -59,10 +60,10 @@ export type EncryptedAttachmentV2 = {
export type ReencryptedAttachmentV2 = { export type ReencryptedAttachmentV2 = {
path: string; path: string;
iv: Uint8Array; iv: string;
plaintextHash: string; plaintextHash: string;
localKey: string;
key: Uint8Array; version: 2;
}; };
export type DecryptedAttachmentV2 = { export type DecryptedAttachmentV2 = {
@ -71,14 +72,6 @@ export type DecryptedAttachmentV2 = {
plaintextHash: string; plaintextHash: string;
}; };
export type ReecryptedAttachmentV2 = {
key: Uint8Array;
mac: Uint8Array;
path: string;
iv: Uint8Array;
plaintextHash: string;
};
export type PlaintextSourceType = export type PlaintextSourceType =
| { data: Uint8Array } | { data: Uint8Array }
| { stream: Readable } | { stream: Readable }
@ -228,7 +221,7 @@ export async function encryptAttachmentV2({
if (dangerousIv?.reason === 'reencrypting-for-backup') { if (dangerousIv?.reason === 'reencrypting-for-backup') {
if (!constantTimeEqual(ourDigest, dangerousIv.digestToMatch)) { if (!constantTimeEqual(ourDigest, dangerousIv.digestToMatch)) {
throw new ReencyptedDigestMismatchError( throw new ReencryptedDigestMismatchError(
`${logId}: iv was hardcoded for backup re-encryption, but digest does not match` `${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<Uint8Array>; theirDigest: Readonly<Uint8Array>;
} }
| { | {
// No need to check integrity for already downloaded attachments // No need to check integrity for locally reencrypted attachments, or for backup
isLocal: true; // thumbnails (since we created it)
type: 'local' | 'backupThumbnail';
theirDigest?: undefined; theirDigest?: undefined;
} }
) & ) &
@ -427,8 +421,22 @@ export async function decryptAttachmentV2ToSink(
if (!constantTimeEqual(ourMac, theirMac)) { if (!constantTimeEqual(ourMac, theirMac)) {
throw new Error(`${logId}: Bad MAC`); 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( strictAssert(
@ -461,7 +469,7 @@ export async function decryptAttachmentV2ToSink(
}; };
} }
export async function reencryptAttachmentV2( export async function decryptAndReencryptLocally(
options: DecryptAttachmentOptionsType options: DecryptAttachmentOptionsType
): Promise<ReencryptedAttachmentV2> { ): Promise<ReencryptedAttachmentV2> {
const { idForLogging } = options; const { idForLogging } = options;
@ -499,8 +507,10 @@ export async function reencryptAttachmentV2(
return { return {
...result, ...result,
key: keys, localKey: toBase64(keys),
iv: toBase64(result.iv),
path: relativeTargetPath, path: relativeTargetPath,
version: 2,
}; };
} catch (error) { } catch (error) {
log.error( log.error(

View file

@ -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_INFO = '20231003_Signal_Backups_Media_ID';
const BACKUP_MEDIA_ID_LEN = 15; 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_AES_KEY_LEN = 32;
const BACKUP_MEDIA_MAC_KEY_LEN = 32; const BACKUP_MEDIA_MAC_KEY_LEN = 32;
const BACKUP_MEDIA_IV_LEN = 16; const BACKUP_MEDIA_IV_LEN = 16;
@ -278,11 +280,11 @@ export function deriveBackupMediaKeyMaterial(
mediaId: Uint8Array mediaId: Uint8Array
): BackupMediaKeyMaterialType { ): BackupMediaKeyMaterialType {
if (backupKey.byteLength !== BACKUP_KEY_LEN) { 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) { if (!mediaId.length) {
throw new Error('deriveMediaIdFromMediaName: mediaId missing'); throw new Error('deriveBackupMediaKeyMaterial: mediaId missing');
} }
const hkdf = HKDF.new(3); 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), 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( export function deriveStorageItemKey(
storageServiceKey: Uint8Array, storageServiceKey: Uint8Array,
itemID: string itemID: string

View file

@ -156,14 +156,25 @@ export function Image({
}} }}
> >
{pending ? ( {pending ? (
blurHash ? ( url || blurHash ? (
<div className="module-image__download-pending"> <div className="module-image__download-pending">
<Blurhash {url ? (
hash={blurHash} <img
width={width} onError={onError}
height={height} className="module-image__image"
style={{ display: 'block' }} alt={alt}
/> height={height}
width={width}
src={url}
/>
) : blurHash ? (
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : undefined}
<div className="module-image__download-pending--spinner-container"> <div className="module-image__download-pending--spinner-container">
<div <div
className="module-image__download-pending--spinner" className="module-image__download-pending--spinner"

View file

@ -4,7 +4,10 @@
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { AttachmentType } from '../../types/Attachment'; import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import { import {
areAllAttachmentsVisual, areAllAttachmentsVisual,
getAlt, getAlt,
@ -21,7 +24,7 @@ import type { LocalizerType, ThemeType } from '../../types/Util';
export type DirectionType = 'incoming' | 'outgoing'; export type DirectionType = 'incoming' | 'outgoing';
export type Props = { export type Props = {
attachments: ReadonlyArray<AttachmentType>; attachments: ReadonlyArray<AttachmentForUIType>;
bottomOverlay?: boolean; bottomOverlay?: boolean;
direction: DirectionType; direction: DirectionType;
isSticker?: boolean; isSticker?: boolean;
@ -158,7 +161,9 @@ export function ImageGrid({
playIconOverlay={isVideoAttachment(attachments[0])} playIconOverlay={isVideoAttachment(attachments[0])}
height={height} height={height}
width={width} width={width}
url={getUrl(attachments[0])} url={
getUrl(attachments[0]) ?? attachments[0].thumbnailFromBackup?.url
}
tabIndex={tabIndex} tabIndex={tabIndex}
onClick={onClick} onClick={onClick}
onError={onError} onError={onError}

View file

@ -16,7 +16,10 @@ import {
type JobManagerParamsType, type JobManagerParamsType,
type JobManagerJobResultType, type JobManagerJobResultType,
} from './JobManager'; } from './JobManager';
import { deriveBackupMediaKeyMaterial } from '../Crypto'; import {
deriveBackupMediaKeyMaterial,
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial,
} from '../Crypto';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { type BackupsService, backupsService } from '../services/backups'; import { type BackupsService, backupsService } from '../services/backups';
import { import {
@ -24,19 +27,39 @@ import {
getAttachmentCiphertextLength, getAttachmentCiphertextLength,
getAesCbcCiphertextLength, getAesCbcCiphertextLength,
decryptAttachmentV2ToSink, decryptAttachmentV2ToSink,
ReencyptedDigestMismatchError, ReencryptedDigestMismatchError,
} from '../AttachmentCrypto'; } from '../AttachmentCrypto';
import { getBackupKey } from '../services/backups/crypto'; import { getBackupKey } from '../services/backups/crypto';
import type { import {
AttachmentBackupJobType, type AttachmentBackupJobType,
CoreAttachmentBackupJobType, type CoreAttachmentBackupJobType,
type StandardAttachmentBackupJobType,
type ThumbnailAttachmentBackupJobType,
} from '../types/AttachmentBackup'; } from '../types/AttachmentBackup';
import { isInCall as isInCallSelector } from '../state/selectors/calling'; import { isInCall as isInCallSelector } from '../state/selectors/calling';
import { encryptAndUploadAttachment } from '../util/uploadAttachment'; import { encryptAndUploadAttachment } from '../util/uploadAttachment';
import { getMediaIdFromMediaName } from '../services/backups/util/mediaId'; import {
import { fromBase64 } from '../Bytes'; getMediaIdFromMediaName,
getMediaNameForAttachmentThumbnail,
} from '../services/backups/util/mediaId';
import { fromBase64, toBase64 } from '../Bytes';
import type { WebAPIType } from '../textsecure/WebAPI'; 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 MAX_CONCURRENT_JOBS = 3;
const RETRY_CONFIG = { const RETRY_CONFIG = {
@ -49,6 +72,11 @@ const RETRY_CONFIG = {
maxBackoffTime: durations.HOUR, 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<CoreAttachmentBackupJobType> { export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobType> {
private static _instance: AttachmentBackupManager | undefined; private static _instance: AttachmentBackupManager | undefined;
@ -67,12 +95,39 @@ export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobT
}, },
getJobId, getJobId,
getJobIdForLogging, getJobIdForLogging,
getRetryConfig: () => RETRY_CONFIG, getRetryConfig: job => {
if (job.type === 'standard') {
return RETRY_CONFIG;
}
return THUMBNAIL_RETRY_CONFIG;
},
maxConcurrentJobs: MAX_CONCURRENT_JOBS, maxConcurrentJobs: MAX_CONCURRENT_JOBS,
}; };
override logPrefix = 'AttachmentBackupManager'; override logPrefix = 'AttachmentBackupManager';
async addJobAndMaybeThumbnailJob(
job: CoreAttachmentBackupJobType
): Promise<void> {
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 { static get instance(): AttachmentBackupManager {
if (!AttachmentBackupManager._instance) { if (!AttachmentBackupManager._instance) {
AttachmentBackupManager._instance = new AttachmentBackupManager( AttachmentBackupManager._instance = new AttachmentBackupManager(
@ -82,6 +137,12 @@ export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobT
return AttachmentBackupManager._instance; return AttachmentBackupManager._instance;
} }
static addJobAndMaybeThumbnailJob(
job: CoreAttachmentBackupJobType
): Promise<void> {
return AttachmentBackupManager.instance.addJobAndMaybeThumbnailJob(job);
}
static async start(): Promise<void> { static async start(): Promise<void> {
log.info('AttachmentBackupManager/starting'); log.info('AttachmentBackupManager/starting');
await AttachmentBackupManager.instance.start(); await AttachmentBackupManager.instance.start();
@ -102,7 +163,7 @@ function getJobId(job: CoreAttachmentBackupJobType): string {
} }
function getJobIdForLogging(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, encryptAndUploadAttachment,
decryptAttachmentV2ToSink, decryptAttachmentV2ToSink,
} }
): Promise<JobManagerJobResultType> { ): Promise<JobManagerJobResultType<CoreAttachmentBackupJobType>> {
const jobIdForLogging = getJobIdForLogging(job); const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentBackupManager/runAttachmentBackupJob/${jobIdForLogging}`; const logId = `AttachmentBackupManager/runAttachmentBackupJob/${jobIdForLogging}`;
try { try {
@ -147,7 +208,7 @@ export async function runAttachmentBackupJob(
return { status: 'finished' }; return { status: 'finished' };
} }
if (error instanceof ReencyptedDigestMismatchError) { if (error instanceof ReencryptedDigestMismatchError) {
log.error( log.error(
`${logId}: Unable to reencrypt to match same digest; content must have changed` `${logId}: Unable to reencrypt to match same digest; content must have changed`
); );
@ -163,26 +224,11 @@ async function runAttachmentBackupJobInner(
dependencies: RunAttachmentBackupJobDependenciesType dependencies: RunAttachmentBackupJobDependenciesType
): Promise<void> { ): Promise<void> {
const jobIdForLogging = getJobIdForLogging(job); const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentBackupManager.UploadOrCopyToBackupTier(mediaName:${jobIdForLogging})`; const logId = `AttachmentBackupManager.UploadOrCopyToBackupTier(${jobIdForLogging})`;
log.info(`${logId}: starting`); log.info(`${logId}: starting`);
const { mediaName, type } = job; const mediaId = getMediaIdFromMediaName(job.mediaName);
// 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 { isInBackupTier } = await dependencies.backupsService.getBackupCdnInfo( const { isInBackupTier } = await dependencies.backupsService.getBackupCdnInfo(
mediaId.string mediaId.string
@ -193,6 +239,33 @@ async function runAttachmentBackupJobInner(
return; 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) { if (transitCdnInfo) {
const { const {
cdnKey: transitCdnKey, cdnKey: transitCdnKey,
@ -244,7 +317,6 @@ async function runAttachmentBackupJobInner(
version, version,
localKey, localKey,
size, size,
keys, keys,
iv, iv,
digest, 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 = { type UploadResponseType = {
cdnKey: string; cdnKey: string;
cdnNumber: number; cdnNumber: number;
encrypted: EncryptedAttachmentV2; encrypted: EncryptedAttachmentV2;
}; };
async function uploadToTransitTier({ async function uploadToTransitTier({
absolutePath, absolutePath,
keys, keys,
@ -279,20 +457,7 @@ async function uploadToTransitTier({
digest, digest,
logPrefix, logPrefix,
dependencies, dependencies,
}: { }: UploadToTransitTierArgsType): Promise<UploadResponseType> {
absolutePath: string;
iv: string;
digest: string;
keys: string;
version?: 2;
localKey?: string;
size: number;
logPrefix: string;
dependencies: {
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
};
}): Promise<UploadResponseType> {
try { try {
if (version === 2) { if (version === 2) {
strictAssert( strictAssert(
@ -311,7 +476,7 @@ async function uploadToTransitTier({
ciphertextPath: absolutePath, ciphertextPath: absolutePath,
keysBase64: localKey, keysBase64: localKey,
size, size,
isLocal: true, type: 'local',
}, },
sink 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<UploadResponseType> {
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; export const FILE_NOT_FOUND_ON_TRANSIT_TIER_STATUS = 410;
async function copyToBackupTier({ async function copyToBackupTier({

View file

@ -12,15 +12,18 @@ import {
} from '../types/AttachmentDownload'; } from '../types/AttachmentDownload';
import { import {
AttachmentPermanentlyUndownloadableError, AttachmentPermanentlyUndownloadableError,
downloadAttachment, downloadAttachment as downloadAttachmentUtil,
} from '../util/downloadAttachment'; } from '../util/downloadAttachment';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
import { getValue } from '../RemoteConfig'; import { getValue } from '../RemoteConfig';
import { isInCall as isInCallSelector } from '../state/selectors/calling'; 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 { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import type { MessageModel } from '../models/messages';
import { import {
KIBIBYTE, KIBIBYTE,
getMaximumIncomingAttachmentSizeInKb, getMaximumIncomingAttachmentSizeInKb,
@ -34,6 +37,11 @@ import {
type JobManagerParamsType, type JobManagerParamsType,
type JobManagerJobResultType, type JobManagerJobResultType,
} from './JobManager'; } from './JobManager';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { MIMEType } from '../types/MIME';
export enum AttachmentDownloadUrgency { export enum AttachmentDownloadUrgency {
IMMEDIATE = 'immediate', IMMEDIATE = 'immediate',
@ -63,13 +71,19 @@ const DEFAULT_RETRY_CONFIG = {
}; };
type AttachmentDownloadManagerParamsType = Omit< type AttachmentDownloadManagerParamsType = Omit<
JobManagerParamsType<CoreAttachmentDownloadJobType>, JobManagerParamsType<CoreAttachmentDownloadJobType>,
'getNextJobs' 'getNextJobs' | 'runJob'
> & { > & {
getNextJobs: (options: { getNextJobs: (options: {
limit: number; limit: number;
prioritizeMessageIds?: Array<string>; prioritizeMessageIds?: Array<string>;
timestamp?: number; timestamp?: number;
}) => Promise<Array<AttachmentDownloadJobType>>; }) => Promise<Array<AttachmentDownloadJobType>>;
runDownloadAttachmentJob: (args: {
job: AttachmentDownloadJobType;
isLastAttempt: boolean;
options?: { isForCurrentlyVisibleMessage: boolean };
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}) => Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>>;
}; };
function getJobId(job: CoreAttachmentDownloadJobType): string { function getJobId(job: CoreAttachmentDownloadJobType): string {
@ -84,7 +98,7 @@ function getJobIdForLogging(job: CoreAttachmentDownloadJobType): string {
} }
export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownloadJobType> { export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownloadJobType> {
private visibleTimelineMessages: Array<string> = []; private visibleTimelineMessages: Set<string> = new Set();
private static _instance: AttachmentDownloadManager | undefined; private static _instance: AttachmentDownloadManager | undefined;
override logPrefix = 'AttachmentDownloadManager'; override logPrefix = 'AttachmentDownloadManager';
@ -93,7 +107,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
saveJob: dataInterface.saveAttachmentDownloadJob, saveJob: dataInterface.saveAttachmentDownloadJob,
removeJob: dataInterface.removeAttachmentDownloadJob, removeJob: dataInterface.removeAttachmentDownloadJob,
getNextJobs: dataInterface.getNextAttachmentDownloadJobs, getNextJobs: dataInterface.getNextAttachmentDownloadJobs,
runJob: runDownloadAttachmentJob, runDownloadAttachmentJob,
shouldHoldOffOnStartingQueuedJobs: () => { shouldHoldOffOnStartingQueuedJobs: () => {
const reduxState = window.reduxStore?.getState(); const reduxState = window.reduxStore?.getState();
if (reduxState) { if (reduxState) {
@ -113,10 +127,23 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
getNextJobs: ({ limit }) => { getNextJobs: ({ limit }) => {
return params.getNextJobs({ return params.getNextJobs({
limit, limit,
prioritizeMessageIds: this.visibleTimelineMessages, prioritizeMessageIds: [...this.visibleTimelineMessages],
timestamp: Date.now(), 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<CoreAttachmentDownload
} }
updateVisibleTimelineMessages(messageIds: Array<string>): void { updateVisibleTimelineMessages(messageIds: Array<string>): void {
this.visibleTimelineMessages = messageIds; this.visibleTimelineMessages = new Set(messageIds);
} }
static get instance(): AttachmentDownloadManager { static get instance(): AttachmentDownloadManager {
@ -202,14 +229,17 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
); );
} }
} }
async function runDownloadAttachmentJob({
// TODO (DESKTOP-6913): if a prioritized job is selected, we will to update the job,
// in-memory job with that information so we can handle it differently, including isLastAttempt,
// e.g. downloading a thumbnail before the full-size version options,
async function runDownloadAttachmentJob( dependencies,
job: AttachmentDownloadJobType, }: {
isLastAttempt: boolean job: AttachmentDownloadJobType;
): Promise<JobManagerJobResultType> { isLastAttempt: boolean;
options?: { isForCurrentlyVisibleMessage: boolean };
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}): Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>> {
const jobIdForLogging = getJobIdForLogging(job); const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`; const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`;
@ -222,8 +252,24 @@ async function runDownloadAttachmentJob(
try { try {
log.info(`${logId}: Starting job`); 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) { } catch (error) {
log.error( log.error(
`${logId}: Failed to download attachment, attempt ${job.attempts}:`, `${logId}: Failed to download attachment, attempt ${job.attempts}:`,
@ -281,21 +327,33 @@ async function runDownloadAttachmentJob(
} }
} }
async function runDownloadAttachmentJobInner( type DownloadAttachmentResultType =
job: AttachmentDownloadJobType, | {
message: MessageModel onlyAttemptedBackupThumbnail: false;
): Promise<void> { }
const { messageId, attachment, attachmentType: type } = job; | {
onlyAttemptedBackupThumbnail: true;
attachmentWithThumbnail: AttachmentType;
};
export async function runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage,
dependencies,
}: {
job: AttachmentDownloadJobType;
isForCurrentlyVisibleMessage: boolean;
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}): Promise<DownloadAttachmentResultType> {
const { messageId, attachment, attachmentType } = job;
const jobIdForLogging = getJobIdForLogging(job); const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentDownloadManager/runDownloadJobInner(${jobIdForLogging})`; let logId = `AttachmentDownloadManager/runDownloadJobInner(${jobIdForLogging})`;
if (!job || !attachment || !messageId) { if (!job || !attachment || !messageId) {
throw new Error(`${logId}: Key information required for job was missing.`); throw new Error(`${logId}: Key information required for job was missing.`);
} }
log.info(`${logId}: starting`);
const maxInKib = getMaximumIncomingAttachmentSizeInKb(getValue); const maxInKib = getMaximumIncomingAttachmentSizeInKb(getValue);
const maxTextAttachmentSizeInKib = const maxTextAttachmentSizeInKib =
getMaximumIncomingTextAttachmentSizeInKb(getValue); getMaximumIncomingTextAttachmentSizeInKb(getValue);
@ -308,32 +366,123 @@ async function runDownloadAttachmentJobInner(
`${logId}: Attachment was ${sizeInKib}kib, max is ${maxInKib}kib` `${logId}: Attachment was ${sizeInKib}kib, max is ${maxInKib}kib`
); );
} }
if (type === 'long-message' && sizeInKib > maxTextAttachmentSizeInKib) { if (
attachmentType === 'long-message' &&
sizeInKib > maxTextAttachmentSizeInKib
) {
throw new AttachmentSizeError( throw new AttachmentSizeError(
`${logId}: Text attachment was ${sizeInKib}kib, max is ${maxTextAttachmentSizeInKib}kib` `${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( await addAttachmentToMessage(
message.id, messageId,
{ ...attachment, pending: true }, { ...attachment, pending: true },
logId, logId,
{ type } { type: attachmentType }
); );
const downloaded = await downloadAttachment(attachment); try {
const downloaded = await dependencies.downloadAttachment({
attachment,
variant: AttachmentVariant.Default,
});
const upgradedAttachment = const upgradedAttachment =
await window.Signal.Migrations.processNewAttachment(downloaded); await window.Signal.Migrations.processNewAttachment({
...omit(attachment, ['error', 'pending']),
...downloaded,
});
await addAttachmentToMessage( await addAttachmentToMessage(messageId, upgradedAttachment, logId, {
message.id, type: attachmentType,
omit(upgradedAttachment, ['error', 'pending']), });
logId, return { onlyAttemptedBackupThumbnail: false };
{ } catch (error) {
type, 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<AttachmentType> {
const downloadedThumbnail = await dependencies.downloadAttachment({
attachment,
variant: AttachmentVariant.ThumbnailFromBackup,
});
const attachmentWithThumbnail = {
...attachment,
thumbnailFromBackup: downloadedThumbnail,
};
return attachmentWithThumbnail;
} }
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType { function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
@ -354,3 +503,17 @@ function _markAttachmentAsTransientlyErrored(
): AttachmentType { ): AttachmentType {
return { ...attachment, pending: false, error: true }; return { ...attachment, pending: false, error: true };
} }
function mightHaveThumbnailOnBackupTier(
attachment: Pick<AttachmentType, 'backupLocator' | 'contentType'>
): boolean {
if (!attachment.backupLocator?.mediaName) {
return false;
}
return canAttachmentHaveThumbnail(attachment.contentType);
}
export function canAttachmentHaveThumbnail(contentType: MIMEType): boolean {
return isVideoTypeSupported(contentType) || isImageTypeSupported(contentType);
}

View file

@ -44,7 +44,7 @@ export type JobManagerParamsType<
runJob: ( runJob: (
job: JobType, job: JobType,
isLastAttempt: boolean isLastAttempt: boolean
) => Promise<JobManagerJobResultType>; ) => Promise<JobManagerJobResultType<CoreJobType>>;
shouldHoldOffOnStartingQueuedJobs?: () => boolean; shouldHoldOffOnStartingQueuedJobs?: () => boolean;
getJobId: (job: CoreJobType) => string; getJobId: (job: CoreJobType) => string;
getJobIdForLogging: (job: JobType) => string; getJobIdForLogging: (job: JobType) => string;
@ -55,9 +55,12 @@ export type JobManagerParamsType<
maxConcurrentJobs: number; maxConcurrentJobs: number;
}; };
export type JobManagerJobResultType = { status: 'retry' | 'finished' }; const DEFAULT_TICK_INTERVAL = MINUTE;
export type JobManagerJobResultType<CoreJobType> =
const TICK_INTERVAL = MINUTE; | {
status: 'retry';
}
| { status: 'finished'; newJob?: CoreJobType };
export abstract class JobManager<CoreJobType> { export abstract class JobManager<CoreJobType> {
protected enabled: boolean = false; protected enabled: boolean = false;
@ -75,7 +78,7 @@ export abstract class JobManager<CoreJobType> {
protected tickTimeout: NodeJS.Timeout | null = null; protected tickTimeout: NodeJS.Timeout | null = null;
protected logPrefix = 'JobManager'; protected logPrefix = 'JobManager';
public tickInterval = DEFAULT_TICK_INTERVAL;
constructor(readonly params: JobManagerParamsType<CoreJobType>) {} constructor(readonly params: JobManagerParamsType<CoreJobType>) {}
async start(): Promise<void> { async start(): Promise<void> {
@ -99,7 +102,7 @@ export abstract class JobManager<CoreJobType> {
clearTimeoutIfNecessary(this.tickTimeout); clearTimeoutIfNecessary(this.tickTimeout);
this.tickTimeout = null; this.tickTimeout = null;
drop(this.maybeStartJobs()); drop(this.maybeStartJobs());
this.tickTimeout = setTimeout(() => this.tick(), TICK_INTERVAL); this.tickTimeout = setTimeout(() => this.tick(), this.tickInterval);
} }
// used in testing // used in testing
@ -196,7 +199,6 @@ export abstract class JobManager<CoreJobType> {
try { try {
this._inMaybeStartJobs = true; this._inMaybeStartJobs = true;
if (!this.enabled) { if (!this.enabled) {
log.info(`${this.logPrefix}/_maybeStartJobs: not enabled, returning`); log.info(`${this.logPrefix}/_maybeStartJobs: not enabled, returning`);
return; return;
@ -247,13 +249,15 @@ export abstract class JobManager<CoreJobType> {
job.attempts + 1 >= job.attempts + 1 >=
(this.params.getRetryConfig(job).maxAttempts ?? Infinity); (this.params.getRetryConfig(job).maxAttempts ?? Infinity);
let jobRunResult: JobManagerJobResultType<CoreJobType> | undefined;
try { try {
log.info(`${logId}: starting job`); log.info(`${logId}: starting job`);
this.addRunningJob(job); this.addRunningJob(job);
await this.params.saveJob({ ...job, active: true }); await this.params.saveJob({ ...job, active: true });
const runJobPromise = this.params.runJob(job, isLastAttempt);
this.handleJobStartPromises(job); this.handleJobStartPromises(job);
jobRunResult = await runJobPromise;
const { status } = await this.params.runJob(job, isLastAttempt); const { status } = jobRunResult;
log.info(`${logId}: job completed with status: ${status}`); log.info(`${logId}: job completed with status: ${status}`);
switch (status) { switch (status) {
@ -278,13 +282,20 @@ export abstract class JobManager<CoreJobType> {
} }
} finally { } finally {
this.removeRunningJob(job); 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()); drop(this.maybeStartJobs());
} }
} }
private async retryJobLater(job: CoreJobType & JobManagerJobType) { private async retryJobLater(job: CoreJobType & JobManagerJobType) {
const now = Date.now(); const now = Date.now();
await this.params.saveJob({ await this.params.saveJob({
...job, ...job,
active: false, active: false,

View file

@ -5375,7 +5375,7 @@ export class ConversationModel extends window.Backbone
size: avatar.size, size: avatar.size,
getAbsoluteAttachmentPath, getAbsoluteAttachmentPath,
isLocal: true, type: 'local',
}); });
try { try {

View file

@ -182,7 +182,7 @@ export class BackupExportStream extends Readable {
drop( drop(
(async () => { (async () => {
log.info('BackupExportStream: starting...'); log.info('BackupExportStream: starting...');
drop(AttachmentBackupManager.stop());
await Data.pauseWriteAccess(); await Data.pauseWriteAccess();
try { try {
await this.unsafeRun(backupLevel); await this.unsafeRun(backupLevel);
@ -190,9 +190,11 @@ export class BackupExportStream extends Readable {
this.emit('error', error); this.emit('error', error);
} finally { } finally {
await Data.resumeWriteAccess(); await Data.resumeWriteAccess();
// TODO (DESKTOP-7344): Clear & add backup jobs in a single transaction
await Data.clearAllAttachmentBackupJobs();
await Promise.all( await Promise.all(
this.attachmentBackupJobs.map(job => this.attachmentBackupJobs.map(job =>
AttachmentBackupManager.addJob(job) AttachmentBackupManager.addJobAndMaybeThumbnailJob(job)
) )
); );
drop(AttachmentBackupManager.start()); drop(AttachmentBackupManager.start());

View file

@ -171,10 +171,6 @@ export function convertBackupMessageAttachmentToAttachment(
async function generateNewEncryptionInfoForAttachment( async function generateNewEncryptionInfoForAttachment(
attachment: Readonly<LocallySavedAttachment> attachment: Readonly<LocallySavedAttachment>
): Promise<AttachmentReadyForBackup> { ): Promise<AttachmentReadyForBackup> {
strictAssert(
attachment.version !== 2,
'generateNewEncryptionInfoForAttachment can only be used on legacy attachments'
);
const fixedUpAttachment = { ...attachment }; const fixedUpAttachment = { ...attachment };
// Since we are changing the encryption, we need to delete all encryption & location // Since we are changing the encryption, we need to delete all encryption & location
@ -461,6 +457,8 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
cdnKey, cdnKey,
cdnNumber, cdnNumber,
uploadTimestamp, uploadTimestamp,
version,
localKey,
} = attachment; } = attachment;
return { return {
@ -474,6 +472,8 @@ export async function maybeGetBackupJobForAttachmentAndFilePointer({
digest, digest,
iv, iv,
size, size,
version,
localKey,
transitCdnInfo: transitCdnInfo:
cdnKey && cdnNumber != null cdnKey && cdnNumber != null
? { ? {

View file

@ -26,6 +26,16 @@ export function getMediaIdForAttachment(attachment: AttachmentType): {
return getMediaIdFromMediaName(mediaName); 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 { export function getMediaNameForAttachment(attachment: AttachmentType): string {
if (attachment.backupLocator) { if (attachment.backupLocator) {
return attachment.backupLocator.mediaName; return attachment.backupLocator.mediaName;
@ -34,6 +44,12 @@ export function getMediaNameForAttachment(attachment: AttachmentType): string {
return attachment.digest; return attachment.digest;
} }
export function getMediaNameForAttachmentThumbnail(
fullsizeMediaName: string
): string {
return Bytes.toBase64(Bytes.fromString(`${fullsizeMediaName}_thumbnail`));
}
export function getBytesFromMediaIdString(mediaId: string): Uint8Array { export function getBytesFromMediaIdString(mediaId: string): Uint8Array {
return Bytes.fromBase64url(mediaId); return Bytes.fromBase64url(mediaId);
} }

View file

@ -793,6 +793,7 @@ export type DataInterface = {
saveAttachmentBackupJob: (job: AttachmentBackupJobType) => Promise<void>; saveAttachmentBackupJob: (job: AttachmentBackupJobType) => Promise<void>;
markAllAttachmentBackupJobsInactive: () => Promise<void>; markAllAttachmentBackupJobsInactive: () => Promise<void>;
removeAttachmentBackupJob: (job: AttachmentBackupJobType) => Promise<void>; removeAttachmentBackupJob: (job: AttachmentBackupJobType) => Promise<void>;
clearAllAttachmentBackupJobs: () => Promise<void>;
clearAllBackupCdnObjectMetadata: () => Promise<void>; clearAllBackupCdnObjectMetadata: () => Promise<void>;
saveBackupCdnObjectMetadata: ( saveBackupCdnObjectMetadata: (

View file

@ -403,6 +403,7 @@ const dataInterface: ServerInterface = {
saveAttachmentBackupJob, saveAttachmentBackupJob,
markAllAttachmentBackupJobsInactive, markAllAttachmentBackupJobsInactive,
removeAttachmentBackupJob, removeAttachmentBackupJob,
clearAllAttachmentBackupJobs,
clearAllBackupCdnObjectMetadata, clearAllBackupCdnObjectMetadata,
saveBackupCdnObjectMetadata, saveBackupCdnObjectMetadata,
@ -5014,6 +5015,11 @@ async function removeAttachmentDownloadJob(
// Backup Attachments // Backup Attachments
async function clearAllAttachmentBackupJobs(): Promise<void> {
const db = await getWritableInstance();
db.prepare('DELETE FROM attachment_backup_jobs;').run();
}
async function markAllAttachmentBackupJobsInactive(): Promise<void> { async function markAllAttachmentBackupJobsInactive(): Promise<void> {
const db = await getWritableInstance(); const db = await getWritableInstance();
db.prepare<EmptyQuery>( db.prepare<EmptyQuery>(
@ -5068,7 +5074,9 @@ async function getNextAttachmentBackupJobs({
active = 0 active = 0
AND AND
(retryAfter is NULL OR retryAfter <= ${timestamp}) (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} LIMIT ${limit}
`; `;
const rows = db.prepare(query).all(params); const rows = db.prepare(query).all(params);
@ -6750,7 +6758,12 @@ function getExternalFilesForMessage(message: MessageType): Array<string> {
const files: Array<string> = []; const files: Array<string> = [];
forEach(attachments, attachment => { forEach(attachments, attachment => {
const { path: file, thumbnail, screenshot } = attachment; const {
path: file,
thumbnail,
screenshot,
thumbnailFromBackup,
} = attachment;
if (file) { if (file) {
files.push(file); files.push(file);
} }
@ -6762,6 +6775,10 @@ function getExternalFilesForMessage(message: MessageType): Array<string> {
if (screenshot && screenshot.path) { if (screenshot && screenshot.path) {
files.push(screenshot.path); files.push(screenshot.path);
} }
if (thumbnailFromBackup && thumbnailFromBackup.path) {
files.push(thumbnailFromBackup.path);
}
}); });
if (quote && quote.attachments && quote.attachments.length) { if (quote && quote.attachments && quote.attachments.length) {

View file

@ -58,7 +58,10 @@ import type { AssertProps } from '../../types/Util';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getMentionsRegex } from '../../types/Message'; import { getMentionsRegex } from '../../types/Message';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import type { AttachmentType } from '../../types/Attachment'; import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import { import {
isVoiceMessage, isVoiceMessage,
canBeDownloaded, canBeDownloaded,
@ -1821,12 +1824,13 @@ export function getPropsForEmbeddedContact(
export function getPropsForAttachment( export function getPropsForAttachment(
attachment: AttachmentType attachment: AttachmentType
): AttachmentType | undefined { ): AttachmentForUIType | undefined {
if (!attachment) { if (!attachment) {
return undefined; return undefined;
} }
const { path, pending, size, screenshot, thumbnail } = attachment; const { path, pending, size, screenshot, thumbnail, thumbnailFromBackup } =
attachment;
return { return {
...attachment, ...attachment,
@ -1834,6 +1838,12 @@ export function getPropsForAttachment(
isVoiceMessage: isVoiceMessage(attachment), isVoiceMessage: isVoiceMessage(attachment),
pending, pending,
url: path ? getLocalAttachmentUrl(attachment) : undefined, url: path ? getLocalAttachmentUrl(attachment) : undefined,
thumbnailFromBackup: thumbnailFromBackup?.path
? {
...thumbnailFromBackup,
url: getLocalAttachmentUrl(thumbnailFromBackup),
}
: undefined,
screenshot: screenshot?.path screenshot: screenshot?.path
? { ? {
...screenshot, ...screenshot,

View file

@ -7,12 +7,13 @@ import type {
AttachmentType, AttachmentType,
AttachmentDraftType, AttachmentDraftType,
ThumbnailType, ThumbnailType,
AttachmentForUIType,
} from '../../types/Attachment'; } from '../../types/Attachment';
import { IMAGE_JPEG } from '../../types/MIME'; import { IMAGE_JPEG } from '../../types/MIME';
export const fakeAttachment = ( export const fakeAttachment = (
overrides: Partial<AttachmentType> = {} overrides: Partial<AttachmentType> = {}
): AttachmentType => ({ ): AttachmentForUIType => ({
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
width: 800, width: 800,
height: 600, height: 600,

View file

@ -577,6 +577,7 @@ describe('Crypto', () => {
writeFileSync(ciphertextPath, encryptedAttachment.ciphertext); writeFileSync(ciphertextPath, encryptedAttachment.ciphertext);
const decryptedAttachment = await decryptAttachmentV2({ const decryptedAttachment = await decryptAttachmentV2({
type: 'standard',
ciphertextPath, ciphertextPath,
idForLogging: 'test', idForLogging: 'test',
...splitKeys(keys), ...splitKeys(keys),
@ -634,6 +635,7 @@ describe('Crypto', () => {
); );
const decryptedAttachment = await decryptAttachmentV2({ const decryptedAttachment = await decryptAttachmentV2({
type: 'standard',
ciphertextPath, ciphertextPath,
idForLogging: 'test', idForLogging: 'test',
...splitKeys(keys), ...splitKeys(keys),
@ -931,6 +933,7 @@ describe('Crypto', () => {
outerCiphertextPath = encryptResult.outerCiphertextPath; outerCiphertextPath = encryptResult.outerCiphertextPath;
const decryptedAttachment = await decryptAttachmentV2({ const decryptedAttachment = await decryptAttachmentV2({
type: 'standard',
ciphertextPath: outerCiphertextPath, ciphertextPath: outerCiphertextPath,
idForLogging: 'test', idForLogging: 'test',
...splitKeys(innerKeys), ...splitKeys(innerKeys),
@ -986,6 +989,7 @@ describe('Crypto', () => {
outerCiphertextPath = encryptResult.outerCiphertextPath; outerCiphertextPath = encryptResult.outerCiphertextPath;
const decryptedAttachment = await decryptAttachmentV2({ const decryptedAttachment = await decryptAttachmentV2({
type: 'standard',
ciphertextPath: outerCiphertextPath, ciphertextPath: outerCiphertextPath,
idForLogging: 'test', idForLogging: 'test',
...splitKeys(innerKeys), ...splitKeys(innerKeys),
@ -1035,6 +1039,7 @@ describe('Crypto', () => {
await assert.isRejected( await assert.isRejected(
decryptAttachmentV2({ decryptAttachmentV2({
type: 'standard',
ciphertextPath: outerCiphertextPath, ciphertextPath: outerCiphertextPath,
idForLogging: 'test', idForLogging: 'test',
...splitKeys(innerKeys), ...splitKeys(innerKeys),

View file

@ -17,6 +17,7 @@ import type { AttachmentType } from '../../types/Attachment';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId'; import type { GetBackupCdnInfoType } from '../../services/backups/util/mediaId';
import { MASTER_KEY } from './helpers'; import { MASTER_KEY } from './helpers';
import { getRandomBytes } from '../../Crypto';
describe('convertFilePointerToAttachment', () => { describe('convertFilePointerToAttachment', () => {
it('processes filepointer with attachmentLocator', () => { it('processes filepointer with attachmentLocator', () => {
@ -179,6 +180,8 @@ function composeAttachment(
incrementalMac: 'incrementalMac', incrementalMac: 'incrementalMac',
incrementalMacChunkSize: 1000, incrementalMacChunkSize: 1000,
uploadTimestamp: 1234, uploadTimestamp: 1234,
localKey: Bytes.toBase64(getRandomBytes(32)),
version: 2,
...overrides, ...overrides,
}; };
} }
@ -545,6 +548,12 @@ describe('getFilePointerForAttachment', () => {
}); });
describe('getBackupJobForAttachmentAndFilePointer', async () => { describe('getBackupJobForAttachmentAndFilePointer', async () => {
beforeEach(async () => {
await window.storage.put('masterKey', Bytes.toBase64(getRandomBytes(32)));
});
afterEach(async () => {
await window.Signal.Data.removeAll();
});
const attachment = composeAttachment(); const attachment = composeAttachment();
it('returns null if filePointer does not have backupLocator', async () => { it('returns null if filePointer does not have backupLocator', async () => {
@ -590,6 +599,8 @@ describe('getBackupJobForAttachmentAndFilePointer', async () => {
digest: 'digest', digest: 'digest',
iv: 'iv', iv: 'iv',
size: 100, size: 100,
localKey: attachment.localKey,
version: attachment.version,
transitCdnInfo: { transitCdnInfo: {
cdnKey: 'cdnKey', cdnKey: 'cdnKey',
cdnNumber: 2, cdnNumber: 2,

View file

@ -4,6 +4,8 @@
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { assert } from 'chai'; import { assert } from 'chai';
import { join } from 'path'; import { join } from 'path';
import { createWriteStream } from 'fs';
import { ensureFile } from 'fs-extra';
import * as Bytes from '../../Bytes'; import * as Bytes from '../../Bytes';
import { import {
@ -14,28 +16,40 @@ import {
import type { import type {
AttachmentBackupJobType, AttachmentBackupJobType,
CoreAttachmentBackupJobType, CoreAttachmentBackupJobType,
StandardAttachmentBackupJobType,
ThumbnailAttachmentBackupJobType,
} from '../../types/AttachmentBackup'; } from '../../types/AttachmentBackup';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { getRandomBytes } from '../../Crypto'; 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 = 2;
const TRANSIT_CDN_FOR_NEW_UPLOAD = 42; const TRANSIT_CDN_FOR_NEW_UPLOAD = 42;
const BACKUP_CDN = 3; const BACKUP_CDN = 3;
const RELATIVE_ATTACHMENT_PATH = getRelativePath(createName());
const LOCAL_ENCRYPTION_KEYS = Bytes.toBase64(generateKeys());
const ATTACHMENT_SIZE = 3577986;
describe('AttachmentBackupManager/JobManager', () => { describe('AttachmentBackupManager/JobManager', () => {
let backupManager: AttachmentBackupManager | undefined; let backupManager: AttachmentBackupManager | undefined;
let runJob: sinon.SinonSpy; let runJob: sinon.SinonSpy;
let backupMediaBatch: sinon.SinonStub; let backupMediaBatch: sinon.SinonStub;
let backupsService = {}; let backupsService = {};
let encryptAndUploadAttachment: sinon.SinonStub; let encryptAndUploadAttachment: sinon.SinonStub;
let getAbsoluteAttachmentPath: sinon.SinonStub;
let sandbox: sinon.SinonSandbox; let sandbox: sinon.SinonSandbox;
let isInCall: sinon.SinonStub; let isInCall: sinon.SinonStub;
function composeJob( function composeJob(
index: number, index: number,
overrides: Partial<CoreAttachmentBackupJobType['data']> = {} overrides: Partial<CoreAttachmentBackupJobType['data']> = {}
): CoreAttachmentBackupJobType { ): StandardAttachmentBackupJobType {
const mediaName = `mediaName${index}`; const mediaName = `mediaName${index}`;
return { return {
@ -43,17 +57,41 @@ describe('AttachmentBackupManager/JobManager', () => {
type: 'standard', type: 'standard',
receivedAt: index, receivedAt: index,
data: { data: {
path: 'ghost-kitty.mp4', path: RELATIVE_ATTACHMENT_PATH,
contentType: VIDEO_MP4, contentType: VIDEO_MP4,
keys: 'keys=', keys: 'keys=',
iv: 'iv==', iv: 'iv==',
digest: 'digest=', digest: 'digest=',
version: 2,
localKey: LOCAL_ENCRYPTION_KEYS,
transitCdnInfo: { transitCdnInfo: {
cdnKey: 'transitCdnKey', cdnKey: 'transitCdnKey',
cdnNumber: TRANSIT_CDN, cdnNumber: TRANSIT_CDN,
uploadTimestamp: Date.now(), uploadTimestamp: Date.now(),
}, },
size: 128, size: ATTACHMENT_SIZE,
...overrides,
},
};
}
function composeThumbnailJob(
index: number,
overrides: Partial<ThumbnailAttachmentBackupJobType['data']> = {}
): 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, ...overrides,
}, },
}; };
@ -83,14 +121,9 @@ describe('AttachmentBackupManager/JobManager', () => {
cdnKey: 'newKeyOnTransitTier', cdnKey: 'newKeyOnTransitTier',
cdnNumber: TRANSIT_CDN_FOR_NEW_UPLOAD, cdnNumber: TRANSIT_CDN_FOR_NEW_UPLOAD,
}); });
const decryptAttachmentV2ToSink = sinon.stub();
getAbsoluteAttachmentPath = sandbox.stub().callsFake(path => { const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
if (path === 'ghost-kitty.mp4') {
return join(__dirname, '../../../fixtures/ghost-kitty.mp4');
}
return getAbsoluteAttachmentPath.wrappedMethod(path);
});
runJob = sandbox.stub().callsFake((job: AttachmentBackupJobType) => { runJob = sandbox.stub().callsFake((job: AttachmentBackupJobType) => {
return runAttachmentBackupJob(job, false, { return runAttachmentBackupJob(job, false, {
// @ts-expect-error incomplete stubbing // @ts-expect-error incomplete stubbing
@ -98,6 +131,7 @@ describe('AttachmentBackupManager/JobManager', () => {
backupMediaBatch, backupMediaBatch,
getAbsoluteAttachmentPath, getAbsoluteAttachmentPath,
encryptAndUploadAttachment, encryptAndUploadAttachment,
decryptAttachmentV2ToSink,
}); });
}); });
@ -106,18 +140,34 @@ describe('AttachmentBackupManager/JobManager', () => {
shouldHoldOffOnStartingQueuedJobs: isInCall, shouldHoldOffOnStartingQueuedJobs: isInCall,
runJob, 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 () => { afterEach(async () => {
sandbox.restore(); sandbox.restore();
delete window.textsecure.server; delete window.textsecure.server;
safeUnlinkSync(
window.Signal.Migrations.getAbsoluteAttachmentPath(
RELATIVE_ATTACHMENT_PATH
)
);
await backupManager?.stop(); await backupManager?.stop();
}); });
async function addJobs( async function addJobs(
num: number, num: number,
overrides: Partial<CoreAttachmentBackupJobType['data']> = {} overrides: Partial<StandardAttachmentBackupJobType['data']> = {}
): Promise<Array<CoreAttachmentBackupJobType>> { ): Promise<Array<StandardAttachmentBackupJobType>> {
const jobs = new Array(num) const jobs = new Array(num)
.fill(null) .fill(null)
.map((_, idx) => composeJob(idx, overrides)); .map((_, idx) => composeJob(idx, overrides));
@ -128,6 +178,20 @@ describe('AttachmentBackupManager/JobManager', () => {
return jobs; return jobs;
} }
async function addThumbnailJobs(
num: number,
overrides: Partial<ThumbnailAttachmentBackupJobType['data']> = {}
): Promise<Array<ThumbnailAttachmentBackupJobType>> {
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( function waitForJobToBeStarted(
job: CoreAttachmentBackupJobType, job: CoreAttachmentBackupJobType,
attempts: number = 0 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 jobs = await addJobs(5);
const thumbnailJobs = await addThumbnailJobs(5);
// Confirm they are saved to DB // Confirm they are saved to DB
const allJobs = await getAllSavedJobs(); const allJobs = await getAllSavedJobs();
assert.strictEqual(allJobs.length, 5); assert.strictEqual(allJobs.length, 10);
assert.strictEqual(
JSON.stringify(allJobs.map(job => job.mediaName)),
JSON.stringify([
'mediaName4',
'mediaName3',
'mediaName2',
'mediaName1',
'mediaName0',
])
);
await backupManager?.start(); await backupManager?.start();
await waitForJobToBeStarted(jobs[2]); await waitForJobToBeStarted(jobs[2]);
@ -183,7 +238,21 @@ describe('AttachmentBackupManager/JobManager', () => {
assert.strictEqual(runJob.callCount, 5); assert.strictEqual(runJob.callCount, 5);
assertRunJobCalledWith([jobs[4], jobs[3], jobs[2], jobs[1], jobs[0]]); 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); 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 () => { it('without transitCdnInfo, will permanently remove job if file not found at path', async () => {
const [job] = await addJobs(1, { transitCdnInfo: undefined }); const [job] = await addJobs(1, { transitCdnInfo: undefined });
getAbsoluteAttachmentPath.returns('no/file/here'); safeUnlinkSync(
window.Signal.Migrations.getAbsoluteAttachmentPath(
RELATIVE_ATTACHMENT_PATH
)
);
await backupManager?.start(); await backupManager?.start();
await waitForJobToBeCompleted(job); await waitForJobToBeCompleted(job);
@ -261,4 +334,28 @@ describe('AttachmentBackupManager/JobManager', () => {
const allRemainingJobs = await getAllSavedJobs(); const allRemainingJobs = await getAllSavedJobs();
assert.strictEqual(allRemainingJobs.length, 0); 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]
);
});
});
}); });

View file

@ -5,17 +5,52 @@
/* eslint-disable @typescript-eslint/no-floating-promises */ /* eslint-disable @typescript-eslint/no-floating-promises */
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { assert } from 'chai'; import { assert } from 'chai';
import * as MIME from '../../types/MIME'; import { omit } from 'lodash';
import * as MIME from '../../types/MIME';
import { import {
AttachmentDownloadManager, AttachmentDownloadManager,
AttachmentDownloadUrgency, AttachmentDownloadUrgency,
runDownloadAttachmentJobInner,
type NewAttachmentDownloadJobType, type NewAttachmentDownloadJobType,
} from '../../jobs/AttachmentDownloadManager'; } from '../../jobs/AttachmentDownloadManager';
import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload'; import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { MINUTE } from '../../util/durations'; import { MINUTE } from '../../util/durations';
import { type AciString } from '../../types/ServiceId'; import { type AciString } from '../../types/ServiceId';
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
function composeJob({
messageId,
receivedAt,
attachmentOverrides,
}: Pick<NewAttachmentDownloadJobType, 'messageId' | 'receivedAt'> & {
attachmentOverrides?: Partial<AttachmentType>;
}): 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', () => { describe('AttachmentDownloadManager/JobManager', () => {
let downloadManager: AttachmentDownloadManager | undefined; let downloadManager: AttachmentDownloadManager | undefined;
@ -24,36 +59,6 @@ describe('AttachmentDownloadManager/JobManager', () => {
let clock: sinon.SinonFakeTimers; let clock: sinon.SinonFakeTimers;
let isInCall: sinon.SinonStub; 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 () => { beforeEach(async () => {
await dataInterface.removeAll(); await dataInterface.removeAll();
@ -72,13 +77,13 @@ describe('AttachmentDownloadManager/JobManager', () => {
downloadManager = new AttachmentDownloadManager({ downloadManager = new AttachmentDownloadManager({
...AttachmentDownloadManager.defaultParams, ...AttachmentDownloadManager.defaultParams,
shouldHoldOffOnStartingQueuedJobs: isInCall, shouldHoldOffOnStartingQueuedJobs: isInCall,
runJob, runDownloadAttachmentJob: runJob,
getRetryConfig: () => ({ getRetryConfig: () => ({
maxAttempts: 5, maxAttempts: 5,
backoffConfig: { backoffConfig: {
multiplier: 5, multiplier: 2,
firstBackoffs: [MINUTE], firstBackoffs: [MINUTE],
maxBackoffTime: 30 * MINUTE, maxBackoffTime: 10 * MINUTE,
}, },
}), }),
}); });
@ -143,7 +148,7 @@ describe('AttachmentDownloadManager/JobManager', () => {
.getCalls() .getCalls()
.map( .map(
call => 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( JSON.stringify(
@ -158,8 +163,13 @@ describe('AttachmentDownloadManager/JobManager', () => {
// prior (unfinished) invocations can prevent subsequent calls after the clock is // prior (unfinished) invocations can prevent subsequent calls after the clock is
// ticked forward and make tests unreliable // ticked forward and make tests unreliable
await dataInterface.getAllItems(); await dataInterface.getAllItems();
await clock.tickAsync(ms); const now = Date.now();
await dataInterface.getAllItems(); 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( function getPromisesForAttempts(
@ -270,7 +280,7 @@ describe('AttachmentDownloadManager/JobManager', () => {
const job0Attempts = getPromisesForAttempts(jobs[0], 1); const job0Attempts = getPromisesForAttempts(jobs[0], 1);
const job1Attempts = getPromisesForAttempts(jobs[1], 5); const job1Attempts = getPromisesForAttempts(jobs[1], 5);
runJob.callsFake(async (job: AttachmentDownloadJobType) => { runJob.callsFake(async ({ job }: { job: AttachmentDownloadJobType }) => {
return new Promise<{ status: 'finished' | 'retry' }>(resolve => { return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (job.messageId === jobs[0].messageId) { if (job.messageId === jobs[0].messageId) {
@ -299,16 +309,16 @@ describe('AttachmentDownloadManager/JobManager', () => {
await job1Attempts[1].completed; await job1Attempts[1].completed;
assert.strictEqual(runJob.callCount, 3); assert.strictEqual(runJob.callCount, 3);
await advanceTime(5 * MINUTE); await advanceTime(2 * MINUTE);
await job1Attempts[2].completed; await job1Attempts[2].completed;
assert.strictEqual(runJob.callCount, 4); assert.strictEqual(runJob.callCount, 4);
await advanceTime(25 * MINUTE); await advanceTime(4 * MINUTE);
await job1Attempts[3].completed; await job1Attempts[3].completed;
assert.strictEqual(runJob.callCount, 5); assert.strictEqual(runJob.callCount, 5);
await advanceTime(30 * MINUTE); await advanceTime(8 * MINUTE);
await job1Attempts[4].completed; await job1Attempts[4].completed;
assert.strictEqual(runJob.callCount, 6); assert.strictEqual(runJob.callCount, 6);
@ -359,15 +369,15 @@ describe('AttachmentDownloadManager/JobManager', () => {
await attempts[1].completed; await attempts[1].completed;
assert.strictEqual(runJob.callCount, 5); assert.strictEqual(runJob.callCount, 5);
await advanceTime(5 * MINUTE); await advanceTime(2 * MINUTE);
await attempts[2].completed; await attempts[2].completed;
assert.strictEqual(runJob.callCount, 6); assert.strictEqual(runJob.callCount, 6);
await advanceTime(25 * MINUTE); await advanceTime(4 * MINUTE);
await attempts[3].completed; await attempts[3].completed;
assert.strictEqual(runJob.callCount, 7); assert.strictEqual(runJob.callCount, 7);
await advanceTime(30 * MINUTE); await advanceTime(8 * MINUTE);
await attempts[4].completed; await attempts[4].completed;
assert.strictEqual(runJob.callCount, 8); assert.strictEqual(runJob.callCount, 8);
@ -375,3 +385,237 @@ describe('AttachmentDownloadManager/JobManager', () => {
assert.isUndefined(await dataInterface.getAttachmentDownloadJob(jobs[0])); 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,
});
});
});
});

View file

@ -13,11 +13,13 @@ import { HTTPError } from '../../textsecure/Errors';
import { getCdnNumberForBackupTier } from '../../textsecure/downloadAttachment'; import { getCdnNumberForBackupTier } from '../../textsecure/downloadAttachment';
import { MASTER_KEY } from '../backup/helpers'; import { MASTER_KEY } from '../backup/helpers';
import { getMediaIdFromMediaName } from '../../services/backups/util/mediaId'; import { getMediaIdFromMediaName } from '../../services/backups/util/mediaId';
import { AttachmentVariant } from '../../types/Attachment';
describe('utils/downloadAttachment', () => { describe('utils/downloadAttachment', () => {
const baseAttachment = { const baseAttachment = {
size: 100, size: 100,
contentType: IMAGE_PNG, contentType: IMAGE_PNG,
digest: 'digest',
}; };
let sandbox: sinon.SinonSandbox; let sandbox: sinon.SinonSandbox;
@ -37,14 +39,21 @@ describe('utils/downloadAttachment', () => {
cdnKey: 'cdnKey', cdnKey: 'cdnKey',
cdnNumber: 2, cdnNumber: 2,
}; };
await downloadAttachment(attachment, { await downloadAttachment({
downloadAttachmentFromServer: stubDownload, attachment,
dependencies: {
downloadAttachmentFromServer: stubDownload,
},
}); });
assert.equal(stubDownload.callCount, 1); assert.equal(stubDownload.callCount, 1);
assert.deepEqual(stubDownload.getCall(0).args, [ assert.deepEqual(stubDownload.getCall(0).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.STANDARD }, {
mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
}); });
@ -60,8 +69,11 @@ describe('utils/downloadAttachment', () => {
cdnNumber: 2, cdnNumber: 2,
}; };
await assert.isRejected( await assert.isRejected(
downloadAttachment(attachment, { downloadAttachment({
downloadAttachmentFromServer: stubDownload, attachment,
dependencies: {
downloadAttachmentFromServer: stubDownload,
},
}), }),
AttachmentPermanentlyUndownloadableError AttachmentPermanentlyUndownloadableError
); );
@ -70,7 +82,11 @@ describe('utils/downloadAttachment', () => {
assert.deepEqual(stubDownload.getCall(0).args, [ assert.deepEqual(stubDownload.getCall(0).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.STANDARD }, {
mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
}); });
@ -84,14 +100,21 @@ describe('utils/downloadAttachment', () => {
mediaName: 'medianame', mediaName: 'medianame',
}, },
}; };
await downloadAttachment(attachment, { await downloadAttachment({
downloadAttachmentFromServer: stubDownload, attachment,
dependencies: {
downloadAttachmentFromServer: stubDownload,
},
}); });
assert.equal(stubDownload.callCount, 1); assert.equal(stubDownload.callCount, 1);
assert.deepEqual(stubDownload.getCall(0).args, [ assert.deepEqual(stubDownload.getCall(0).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.BACKUP }, {
mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
}); });
@ -109,19 +132,30 @@ describe('utils/downloadAttachment', () => {
mediaName: 'medianame', mediaName: 'medianame',
}, },
}; };
await downloadAttachment(attachment, { await downloadAttachment({
downloadAttachmentFromServer: stubDownload, attachment,
dependencies: {
downloadAttachmentFromServer: stubDownload,
},
}); });
assert.equal(stubDownload.callCount, 2); assert.equal(stubDownload.callCount, 2);
assert.deepEqual(stubDownload.getCall(0).args, [ assert.deepEqual(stubDownload.getCall(0).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.BACKUP }, {
mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
assert.deepEqual(stubDownload.getCall(1).args, [ assert.deepEqual(stubDownload.getCall(1).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.STANDARD }, {
mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
}); });
@ -139,19 +173,30 @@ describe('utils/downloadAttachment', () => {
mediaName: 'medianame', mediaName: 'medianame',
}, },
}; };
await downloadAttachment(attachment, { await downloadAttachment({
downloadAttachmentFromServer: stubDownload, attachment,
dependencies: {
downloadAttachmentFromServer: stubDownload,
},
}); });
assert.equal(stubDownload.callCount, 2); assert.equal(stubDownload.callCount, 2);
assert.deepEqual(stubDownload.getCall(0).args, [ assert.deepEqual(stubDownload.getCall(0).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.BACKUP }, {
mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
assert.deepEqual(stubDownload.getCall(1).args, [ assert.deepEqual(stubDownload.getCall(1).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.STANDARD }, {
mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
}); });
@ -170,8 +215,11 @@ describe('utils/downloadAttachment', () => {
}; };
await assert.isRejected( await assert.isRejected(
downloadAttachment(attachment, { downloadAttachment({
downloadAttachmentFromServer: stubDownload, attachment,
dependencies: {
downloadAttachmentFromServer: stubDownload,
},
}), }),
HTTPError HTTPError
); );
@ -179,12 +227,20 @@ describe('utils/downloadAttachment', () => {
assert.deepEqual(stubDownload.getCall(0).args, [ assert.deepEqual(stubDownload.getCall(0).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.BACKUP }, {
mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
assert.deepEqual(stubDownload.getCall(1).args, [ assert.deepEqual(stubDownload.getCall(1).args, [
fakeServer, fakeServer,
attachment, attachment,
{ mediaTier: MediaTier.STANDARD }, {
mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default,
logPrefix: '[REDACTED]est',
},
]); ]);
}); });
}); });

View file

@ -60,7 +60,7 @@ export async function parseContactsV2(
), ),
keysBase64: attachment.localKey, keysBase64: attachment.localKey,
size: attachment.size, size: attachment.size,
isLocal: true, type: 'local',
}, },
parseContactsTransform parseContactsTransform
); );

View file

@ -3960,7 +3960,11 @@ export default class MessageReceiver
options?: { timeout?: number; disableRetries?: boolean } options?: { timeout?: number; disableRetries?: boolean }
): Promise<AttachmentType> { ): Promise<AttachmentType> {
const cleaned = processAttachment(attachment); const cleaned = processAttachment(attachment);
return downloadAttachment(this.server, cleaned, options); const downloaded = await downloadAttachment(this.server, cleaned, options);
return {
...cleaned,
...downloaded,
};
} }
private async handleEndSession( private async handleEndSession(

View file

@ -14,18 +14,21 @@ import {
AttachmentSizeError, AttachmentSizeError,
mightBeOnBackupTier, mightBeOnBackupTier,
type AttachmentType, type AttachmentType,
AttachmentVariant,
} from '../types/Attachment'; } from '../types/Attachment';
import * as MIME from '../types/MIME';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { import {
deriveBackupMediaKeyMaterial, deriveBackupMediaKeyMaterial,
type BackupMediaKeyMaterialType, type BackupMediaKeyMaterialType,
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial,
} from '../Crypto'; } from '../Crypto';
import { import {
reencryptAttachmentV2,
getAttachmentCiphertextLength, getAttachmentCiphertextLength,
safeUnlinkSync, safeUnlinkSync,
splitKeys, splitKeys,
type ReencryptedAttachmentV2,
decryptAndReencryptLocally,
measureSize,
} from '../AttachmentCrypto'; } from '../AttachmentCrypto';
import type { ProcessedAttachment } from './Types.d'; import type { ProcessedAttachment } from './Types.d';
import type { WebAPIType } from './WebAPI'; import type { WebAPIType } from './WebAPI';
@ -33,7 +36,13 @@ import { createName, getRelativePath } from '../util/attachmentPath';
import { MediaTier } from '../types/AttachmentDownload'; import { MediaTier } from '../types/AttachmentDownload';
import { getBackupKey } from '../services/backups/crypto'; import { getBackupKey } from '../services/backups/crypto';
import { backupsService } from '../services/backups'; 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; const DEFAULT_BACKUP_CDN_NUMBER = 3;
@ -43,7 +52,7 @@ export function getCdnKey(attachment: ProcessedAttachment): string {
return cdnKey; return cdnKey;
} }
function getBackupMediaKeyMaterial( function getBackupMediaOuterEncryptionKeyMaterial(
attachment: AttachmentType attachment: AttachmentType
): BackupMediaKeyMaterialType { ): BackupMediaKeyMaterialType {
const mediaId = getMediaIdForAttachment(attachment); const mediaId = getMediaIdForAttachment(attachment);
@ -51,6 +60,24 @@ function getBackupMediaKeyMaterial(
return deriveBackupMediaKeyMaterial(backupKey, mediaId.bytes); 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( export async function getCdnNumberForBackupTier(
attachment: ProcessedAttachment attachment: ProcessedAttachment
): Promise<number> { ): Promise<number> {
@ -75,16 +102,17 @@ export async function getCdnNumberForBackupTier(
export async function downloadAttachment( export async function downloadAttachment(
server: WebAPIType, server: WebAPIType,
attachment: ProcessedAttachment, attachment: ProcessedAttachment,
options?: { options: {
variant?: AttachmentVariant;
disableRetries?: boolean; disableRetries?: boolean;
timeout?: number; timeout?: number;
mediaTier?: MediaTier; mediaTier?: MediaTier;
logPrefix?: string; logPrefix?: string;
} } = { variant: AttachmentVariant.Default }
): Promise<AttachmentType> { ): Promise<ReencryptedAttachmentV2 & { size?: number }> {
const logId = `${options?.logPrefix}/downloadAttachment`; const logId = `downloadAttachment/${options.logPrefix ?? ''}`;
const { digest, key, size, contentType } = attachment; const { digest, key, size } = attachment;
strictAssert(digest, `${logId}: missing digest`); strictAssert(digest, `${logId}: missing digest`);
strictAssert(key, `${logId}: missing key`); strictAssert(key, `${logId}: missing key`);
@ -94,8 +122,13 @@ export async function downloadAttachment(
options?.mediaTier ?? options?.mediaTier ??
(mightBeOnBackupTier(attachment) ? MediaTier.BACKUP : MediaTier.STANDARD); (mightBeOnBackupTier(attachment) ? MediaTier.BACKUP : MediaTier.STANDARD);
let downloadedPath: string; let downloadResult: Awaited<ReturnType<typeof downloadToDisk>>;
if (mediaTier === MediaTier.STANDARD) { if (mediaTier === MediaTier.STANDARD) {
strictAssert(
options.variant !== AttachmentVariant.ThumbnailFromBackup,
'Thumbnails can only be downloaded from backup tier'
);
const cdnKey = getCdnKey(attachment); const cdnKey = getCdnKey(attachment);
const { cdnNumber } = attachment; const { cdnNumber } = attachment;
@ -104,9 +137,13 @@ export async function downloadAttachment(
cdnNumber, cdnNumber,
options, options,
}); });
downloadedPath = await downloadToDisk({ downloadStream, size }); downloadResult = await downloadToDisk({ downloadStream, size });
} else { } else {
const mediaId = getMediaIdForAttachment(attachment); const mediaId =
options.variant === AttachmentVariant.ThumbnailFromBackup
? getMediaIdForAttachmentThumbnail(attachment)
: getMediaIdForAttachment(attachment);
const cdnNumber = await getCdnNumberForBackupTier(attachment); const cdnNumber = await getCdnNumberForBackupTier(attachment);
const cdnCredentials = const cdnCredentials =
await backupsService.credentials.getCDNReadCredentials(cdnNumber); await backupsService.credentials.getCDNReadCredentials(cdnNumber);
@ -122,51 +159,75 @@ export async function downloadAttachment(
cdnNumber, cdnNumber,
options, options,
}); });
downloadedPath = await downloadToDisk({ downloadResult = await downloadToDisk({
downloadStream, 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 = const cipherTextAbsolutePath =
window.Signal.Migrations.getAbsoluteAttachmentPath(downloadedPath); window.Signal.Migrations.getAbsoluteAttachmentPath(downloadedRelativePath);
const { aesKey, macKey } = splitKeys(Bytes.fromBase64(key)); try {
const { switch (options.variant) {
path, case AttachmentVariant.Default:
plaintextHash, case undefined: {
iv, const { aesKey, macKey } = splitKeys(Bytes.fromBase64(key));
key: localKey, return await decryptAndReencryptLocally({
} = await reencryptAttachmentV2({ type: 'standard',
ciphertextPath: cipherTextAbsolutePath, ciphertextPath: cipherTextAbsolutePath,
idForLogging: logId, idForLogging: logId,
aesKey, aesKey,
macKey, macKey,
size, size,
theirDigest: Bytes.fromBase64(digest), theirDigest: Bytes.fromBase64(digest),
outerEncryption: outerEncryption:
mediaTier === 'backup' mediaTier === 'backup'
? getBackupMediaKeyMaterial(attachment) ? getBackupMediaOuterEncryptionKeyMaterial(attachment)
: undefined, : undefined,
getAbsoluteAttachmentPath: getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath, window.Signal.Migrations.getAbsoluteAttachmentPath,
}); });
}
safeUnlinkSync(cipherTextAbsolutePath); case AttachmentVariant.ThumbnailFromBackup: {
strictAssert(
return { mediaTier === 'backup',
...attachment, 'Thumbnail must be downloaded from backup tier'
path, );
size, const thumbnailEncryptionKeys =
contentType: contentType getBackupThumbnailInnerEncryptionKeyMaterial(attachment);
? MIME.stringToMIMEType(contentType) // backup thumbnails don't get trimmed, so we just calculate the size as the
: MIME.APPLICATION_OCTET_STREAM, // ciphertextSize, less IV and MAC
plaintextHash, const calculatedSize = downloadSize - IV_LENGTH - MAC_LENGTH;
iv: Bytes.toBase64(iv), return {
...(await decryptAndReencryptLocally({
version: 2, type: 'backupThumbnail',
localKey: Bytes.toBase64(localKey), 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({ async function downloadToDisk({
@ -175,16 +236,24 @@ async function downloadToDisk({
}: { }: {
downloadStream: Readable; downloadStream: Readable;
size: number; size: number;
}): Promise<string> { }): Promise<{ relativePath: string; downloadSize: number }> {
const relativeTargetPath = getRelativePath(createName()); const relativeTargetPath = getRelativePath(createName());
const absoluteTargetPath = const absoluteTargetPath =
window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath); window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath);
await ensureFile(absoluteTargetPath); await ensureFile(absoluteTargetPath);
const writeStream = createWriteStream(absoluteTargetPath); const writeStream = createWriteStream(absoluteTargetPath);
const targetSize = getAttachmentCiphertextLength(size); const targetSize = getAttachmentCiphertextLength(size);
let downloadSize = 0;
try { try {
await pipeline(downloadStream, checkSize(targetSize), writeStream); await pipeline(
downloadStream,
checkSize(targetSize),
measureSize(bytesSeen => {
downloadSize = bytesSeen;
}),
writeStream
);
} catch (error) { } catch (error) {
try { try {
safeUnlinkSync(absoluteTargetPath); safeUnlinkSync(absoluteTargetPath);
@ -198,7 +267,7 @@ async function downloadToDisk({
throw error; throw error;
} }
return relativeTargetPath; return { relativePath: relativeTargetPath, downloadSize };
} }
// A simple transform that throws if it sees more than maxBytes on the stream. // A simple transform that throws if it sees more than maxBytes on the stream.

View file

@ -95,6 +95,10 @@ export type AttachmentType = {
// See app/attachment_channel.ts // See app/attachment_channel.ts
version?: 1 | 2; version?: 1 | 2;
localKey?: string; // AES + MAC localKey?: string; // AES + MAC
thumbnailFromBackup?: Pick<
AttachmentType,
'path' | 'version' | 'plaintextHash'
>;
/** Legacy field. Used only for downloading old attachments */ /** Legacy field. Used only for downloading old attachments */
id?: number; id?: number;
@ -121,6 +125,12 @@ export type AddressableAttachmentType = Readonly<{
data?: Uint8Array; data?: Uint8Array;
}>; }>;
export type AttachmentForUIType = AttachmentType & {
thumbnailFromBackup?: {
url?: string;
};
};
export type UploadedAttachmentType = Proto.IAttachmentPointer & export type UploadedAttachmentType = Proto.IAttachmentPointer &
Readonly<{ Readonly<{
// Required fields // Required fields
@ -225,6 +235,11 @@ export type ThumbnailType = AttachmentType & {
copied?: boolean; copied?: boolean;
}; };
export enum AttachmentVariant {
Default = 'Default',
ThumbnailFromBackup = 'thumbnailFromBackup',
}
// // Incoming message attachment fields // // Incoming message attachment fields
// { // {
// id: string // id: string
@ -383,7 +398,8 @@ export function deleteData(
throw new TypeError('deleteData: attachment is not valid'); throw new TypeError('deleteData: attachment is not valid');
} }
const { path, thumbnail, screenshot } = attachment; const { path, thumbnail, screenshot, thumbnailFromBackup } = attachment;
if (isString(path)) { if (isString(path)) {
await deleteOnDisk(path); await deleteOnDisk(path);
} }
@ -395,6 +411,10 @@ export function deleteData(
if (screenshot && isString(screenshot.path)) { if (screenshot && isString(screenshot.path)) {
await deleteOnDisk(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( export function getThumbnailUrl(
attachment: AttachmentType attachment: AttachmentForUIType
): string | undefined { ): string | undefined {
if (attachment.thumbnail) { if (attachment.thumbnail) {
return attachment.thumbnail.url; return attachment.thumbnail.url;
@ -638,7 +658,7 @@ export function getThumbnailUrl(
return getUrl(attachment); return getUrl(attachment);
} }
export function getUrl(attachment: AttachmentType): string | undefined { export function getUrl(attachment: AttachmentForUIType): string | undefined {
if (attachment.screenshot) { if (attachment.screenshot) {
return attachment.screenshot.url; return attachment.screenshot.url;
} }
@ -647,7 +667,7 @@ export function getUrl(attachment: AttachmentType): string | undefined {
return undefined; return undefined;
} }
return attachment.url; return attachment.url ?? attachment.thumbnailFromBackup?.url;
} }
export function isImage(attachments?: ReadonlyArray<AttachmentType>): boolean { export function isImage(attachments?: ReadonlyArray<AttachmentType>): boolean {

View file

@ -11,7 +11,7 @@ export type CoreAttachmentBackupJobType =
| StandardAttachmentBackupJobType | StandardAttachmentBackupJobType
| ThumbnailAttachmentBackupJobType; | ThumbnailAttachmentBackupJobType;
type StandardAttachmentBackupJobType = { export type StandardAttachmentBackupJobType = {
type: 'standard'; type: 'standard';
mediaName: string; mediaName: string;
receivedAt: number; receivedAt: number;
@ -27,20 +27,21 @@ type StandardAttachmentBackupJobType = {
uploadTimestamp?: number; uploadTimestamp?: number;
}; };
size: number; size: number;
version?: 1 | 2;
version?: 2;
localKey?: string; localKey?: string;
}; };
}; };
type ThumbnailAttachmentBackupJobType = { export type ThumbnailAttachmentBackupJobType = {
type: 'thumbnail'; type: 'thumbnail';
mediaName: string; mediaName: string;
receivedAt: number; receivedAt: number;
data: { data: {
fullsizePath: string | null; fullsizePath: string | null;
fullsizeSize: number;
contentType: MIMEType; contentType: MIMEType;
keys: string; version?: 1 | 2;
localKey?: string;
}; };
}; };
@ -60,7 +61,7 @@ const standardBackupJobDataSchema = z.object({
uploadTimestamp: z.number().optional(), uploadTimestamp: z.number().optional(),
}) })
.optional(), .optional(),
version: z.literal(2).optional(), version: z.union([z.literal(1), z.literal(2)]).optional(),
localKey: z.string().optional(), localKey: z.string().optional(),
}), }),
}); });
@ -69,8 +70,10 @@ const thumbnailBackupJobDataSchema = z.object({
type: z.literal('thumbnail'), type: z.literal('thumbnail'),
data: z.object({ data: z.object({
fullsizePath: z.string(), fullsizePath: z.string(),
fullsizeSize: z.number(),
contentType: MIMETypeSchema, contentType: MIMETypeSchema,
keys: z.string(), version: z.union([z.literal(1), z.literal(2)]).optional(),
localKey: z.string().optional(),
}), }),
}); });

View file

@ -12,9 +12,12 @@ import { canvasToBlob } from '../util/canvasToBlob';
import { KIBIBYTE } from './AttachmentSize'; import { KIBIBYTE } from './AttachmentSize';
import { explodePromise } from '../util/explodePromise'; import { explodePromise } from '../util/explodePromise';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import * as logging from '../logging/log';
export { blobToArrayBuffer }; export { blobToArrayBuffer };
export const MAX_BACKUP_THUMBNAIL_SIZE = 8 * KIBIBYTE;
export type GetImageDimensionsOptionsType = Readonly<{ export type GetImageDimensionsOptionsType = Readonly<{
objectUrl: string; objectUrl: string;
logger: Pick<LoggerType, 'error'>; logger: Pick<LoggerType, 'error'>;
@ -107,7 +110,6 @@ export type MakeImageThumbnailForBackupOptionsType = Readonly<{
maxDimension?: number; maxDimension?: number;
maxSize?: number; maxSize?: number;
objectUrl: string; objectUrl: string;
logger: LoggerType;
}>; }>;
// 0.7 quality seems to result in a good result in 1 interation for most images // 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; mimeType: MIMEType;
}; };
export function makeImageThumbnailForBackup({ export async function makeImageThumbnailForBackup({
maxDimension = 256, maxDimension = 256,
maxSize = 8 * KIBIBYTE, maxSize = MAX_BACKUP_THUMBNAIL_SIZE,
objectUrl, objectUrl,
logger,
}: MakeImageThumbnailForBackupOptionsType): Promise<CreatedThumbnailType> { }: MakeImageThumbnailForBackupOptionsType): Promise<CreatedThumbnailType> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const image = document.createElement('img'); const image = document.createElement('img');
@ -174,7 +175,7 @@ export function makeImageThumbnailForBackup({
const duration = (performance.now() - start).toFixed(1); 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; const sizeInKiB = blob.size / KIBIBYTE;
logMethod( logMethod(
'makeImageThumbnail: generated thumbnail of dimensions: ' + 'makeImageThumbnail: generated thumbnail of dimensions: ' +
@ -196,7 +197,7 @@ export function makeImageThumbnailForBackup({
}); });
image.addEventListener('error', error => { image.addEventListener('error', error => {
logger.error('makeImageThumbnail error', toLogFormat(error)); logging.error('makeImageThumbnail error', toLogFormat(error));
reject(error); reject(error);
}); });
@ -207,7 +208,6 @@ export function makeImageThumbnailForBackup({
export type MakeVideoScreenshotOptionsType = Readonly<{ export type MakeVideoScreenshotOptionsType = Readonly<{
objectUrl: string; objectUrl: string;
contentType?: MIMEType; contentType?: MIMEType;
logger: Pick<LoggerType, 'error'>;
}>; }>;
const MAKE_VIDEO_SCREENSHOT_TIMEOUT = 30 * SECOND; const MAKE_VIDEO_SCREENSHOT_TIMEOUT = 30 * SECOND;
@ -228,7 +228,6 @@ function captureScreenshot(
export async function makeVideoScreenshot({ export async function makeVideoScreenshot({
objectUrl, objectUrl,
contentType = IMAGE_PNG, contentType = IMAGE_PNG,
logger,
}: MakeVideoScreenshotOptionsType): Promise<Blob> { }: MakeVideoScreenshotOptionsType): Promise<Blob> {
const signal = AbortSignal.timeout(MAKE_VIDEO_SCREENSHOT_TIMEOUT); const signal = AbortSignal.timeout(MAKE_VIDEO_SCREENSHOT_TIMEOUT);
const video = document.createElement('video'); const video = document.createElement('video');
@ -256,7 +255,7 @@ export async function makeVideoScreenshot({
await videoLoadedAndSeeked; await videoLoadedAndSeeked;
return await captureScreenshot(video, contentType); return await captureScreenshot(video, contentType);
} catch (error) { } catch (error) {
logger.error('makeVideoScreenshot error:', toLogFormat(error)); logging.error('makeVideoScreenshot error:', toLogFormat(error));
throw error; throw error;
} finally { } finally {
// hard reset the video element so it doesn't keep loading // hard reset the video element so it doesn't keep loading

View file

@ -1,22 +1,35 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment';
import { MediaTier } from '../types/AttachmentDownload'; import { MediaTier } from '../types/AttachmentDownload';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { redactGenericText } from './privacy'; import { redactGenericText } from './privacy';
import { HTTPError } from '../textsecure/Errors'; import { HTTPError } from '../textsecure/Errors';
import { toLogFormat } from '../types/errors'; import { toLogFormat } from '../types/errors';
import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto';
export class AttachmentPermanentlyUndownloadableError extends Error {} export class AttachmentPermanentlyUndownloadableError extends Error {}
export async function downloadAttachment( export async function downloadAttachment({
attachmentData: AttachmentType, attachment,
dependencies = { downloadAttachmentFromServer: doDownloadAttachment } variant = AttachmentVariant.Default,
): Promise<AttachmentType> { dependencies = { downloadAttachmentFromServer: doDownloadAttachment },
const redactedDigest = redactGenericText(attachmentData.digest ?? ''); }: {
const logId = `downloadAttachment(${redactedDigest})`; attachment: AttachmentType;
variant?: AttachmentVariant;
dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment };
}): Promise<ReencryptedAttachmentV2> {
const redactedDigest = redactGenericText(attachment.digest ?? '');
const variantForLogging =
variant !== AttachmentVariant.Default ? `[${variant}]` : '';
const dataId = `${redactedDigest}${variantForLogging}`;
const logId = `downloadAttachmentUtil(${dataId})`;
const { server } = window.textsecure; const { server } = window.textsecure;
if (!server) { if (!server) {
@ -25,12 +38,12 @@ export async function downloadAttachment(
let migratedAttachment: AttachmentType; let migratedAttachment: AttachmentType;
const { id: legacyId } = attachmentData; const { id: legacyId } = attachment;
if (legacyId === undefined) { if (legacyId === undefined) {
migratedAttachment = attachmentData; migratedAttachment = attachment;
} else { } else {
migratedAttachment = { migratedAttachment = {
...attachmentData, ...attachment,
cdnId: String(legacyId), cdnId: String(legacyId),
}; };
} }
@ -41,7 +54,9 @@ export async function downloadAttachment(
server, server,
migratedAttachment, migratedAttachment,
{ {
variant,
mediaTier: MediaTier.BACKUP, mediaTier: MediaTier.BACKUP,
logPrefix: dataId,
} }
); );
} catch (error) { } 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 // We also just log this error instead of throwing, since we want to still try to
// find it on the attachment tier. // find it on the attachment tier.
log.error( log.error(
`${logId}: error when downloading from backup CDN`, `${logId}: error when downloading from backup CDN; will try transit tier`,
toLogFormat(error) toLogFormat(error)
); );
} }
@ -65,7 +80,9 @@ export async function downloadAttachment(
server, server,
migratedAttachment, migratedAttachment,
{ {
variant,
mediaTier: MediaTier.STANDARD, mediaTier: MediaTier.STANDARD,
logPrefix: dataId,
} }
); );
} catch (error) { } catch (error) {

View file

@ -4,7 +4,6 @@
import { blobToArrayBuffer } from 'blob-util'; import { blobToArrayBuffer } from 'blob-util';
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import * as log from '../logging/log';
import { makeVideoScreenshot } from '../types/VisualAttachment'; import { makeVideoScreenshot } from '../types/VisualAttachment';
import { IMAGE_PNG, stringToMIMEType } from '../types/MIME'; import { IMAGE_PNG, stringToMIMEType } from '../types/MIME';
import type { InMemoryAttachmentDraftType } from '../types/Attachment'; import type { InMemoryAttachmentDraftType } from '../types/Attachment';
@ -36,7 +35,6 @@ export async function handleVideoAttachment(
const screenshotBlob = await makeVideoScreenshot({ const screenshotBlob = await makeVideoScreenshot({
objectUrl, objectUrl,
contentType: screenshotContentType, contentType: screenshotContentType,
logger: log,
}); });
attachment.screenshotData = new Uint8Array( attachment.screenshotData = new Uint8Array(
await blobToArrayBuffer(screenshotBlob) await blobToArrayBuffer(screenshotBlob)