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

View file

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

View file

@ -29,6 +29,7 @@ import * as Errors from './types/errors';
import { isNotNil } from './util/isNotNil';
import { missingCaseError } from './util/missingCaseError';
import { getEnvironment, Environment } from './environment';
import { toBase64 } from './Bytes';
// This file was split from ts/Crypto.ts because it pulls things in from node, and
// too many things pull in Crypto.ts, so it broke storybook.
@ -37,7 +38,7 @@ const DIGEST_LENGTH = MAC_LENGTH;
const HEX_DIGEST_LENGTH = DIGEST_LENGTH * 2;
const ATTACHMENT_MAC_LENGTH = MAC_LENGTH;
export class ReencyptedDigestMismatchError extends Error {}
export class ReencryptedDigestMismatchError extends Error {}
/** @private */
export const KEY_SET_LENGTH = KEY_LENGTH + MAC_LENGTH;
@ -59,10 +60,10 @@ export type EncryptedAttachmentV2 = {
export type ReencryptedAttachmentV2 = {
path: string;
iv: Uint8Array;
iv: string;
plaintextHash: string;
key: Uint8Array;
localKey: string;
version: 2;
};
export type DecryptedAttachmentV2 = {
@ -71,14 +72,6 @@ export type DecryptedAttachmentV2 = {
plaintextHash: string;
};
export type ReecryptedAttachmentV2 = {
key: Uint8Array;
mac: Uint8Array;
path: string;
iv: Uint8Array;
plaintextHash: string;
};
export type PlaintextSourceType =
| { data: Uint8Array }
| { stream: Readable }
@ -228,7 +221,7 @@ export async function encryptAttachmentV2({
if (dangerousIv?.reason === 'reencrypting-for-backup') {
if (!constantTimeEqual(ourDigest, dangerousIv.digestToMatch)) {
throw new ReencyptedDigestMismatchError(
throw new ReencryptedDigestMismatchError(
`${logId}: iv was hardcoded for backup re-encryption, but digest does not match`
);
}
@ -253,12 +246,13 @@ type DecryptAttachmentToSinkOptionsType = Readonly<
};
} & (
| {
isLocal?: false;
type: 'standard';
theirDigest: Readonly<Uint8Array>;
}
| {
// No need to check integrity for already downloaded attachments
isLocal: true;
// No need to check integrity for locally reencrypted attachments, or for backup
// thumbnails (since we created it)
type: 'local' | 'backupThumbnail';
theirDigest?: undefined;
}
) &
@ -427,8 +421,22 @@ export async function decryptAttachmentV2ToSink(
if (!constantTimeEqual(ourMac, theirMac)) {
throw new Error(`${logId}: Bad MAC`);
}
if (!options.isLocal && !constantTimeEqual(ourDigest, options.theirDigest)) {
throw new Error(`${logId}: Bad digest`);
const { type } = options;
switch (type) {
case 'local':
case 'backupThumbnail':
log.info(
`${logId}: skipping digest check since this is a ${type} attachment`
);
break;
case 'standard':
if (!constantTimeEqual(ourDigest, options.theirDigest)) {
throw new Error(`${logId}: Bad digest`);
}
break;
default:
throw missingCaseError(type);
}
strictAssert(
@ -461,7 +469,7 @@ export async function decryptAttachmentV2ToSink(
};
}
export async function reencryptAttachmentV2(
export async function decryptAndReencryptLocally(
options: DecryptAttachmentOptionsType
): Promise<ReencryptedAttachmentV2> {
const { idForLogging } = options;
@ -499,8 +507,10 @@ export async function reencryptAttachmentV2(
return {
...result,
key: keys,
localKey: toBase64(keys),
iv: toBase64(result.iv),
path: relativeTargetPath,
version: 2,
};
} catch (error) {
log.error(

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_LEN = 15;
const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_Media_ID';
const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_EncryptMedia';
const BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO =
'20240513_Signal_Backups_EncryptThumbnail';
const BACKUP_MEDIA_AES_KEY_LEN = 32;
const BACKUP_MEDIA_MAC_KEY_LEN = 32;
const BACKUP_MEDIA_IV_LEN = 16;
@ -278,11 +280,11 @@ export function deriveBackupMediaKeyMaterial(
mediaId: Uint8Array
): BackupMediaKeyMaterialType {
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
throw new Error('deriveMediaIdFromMediaName: invalid backup key length');
throw new Error('deriveBackupMediaKeyMaterial: invalid backup key length');
}
if (!mediaId.length) {
throw new Error('deriveMediaIdFromMediaName: mediaId missing');
throw new Error('deriveBackupMediaKeyMaterial: mediaId missing');
}
const hkdf = HKDF.new(3);
@ -302,6 +304,39 @@ export function deriveBackupMediaKeyMaterial(
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
};
}
export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
backupKey: Uint8Array,
mediaId: Uint8Array
): BackupMediaKeyMaterialType {
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
throw new Error(
'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length'
);
}
if (!mediaId.length) {
throw new Error('deriveBackupMediaThumbnailKeyMaterial: mediaId missing');
}
const hkdf = HKDF.new(3);
const material = hkdf.deriveSecrets(
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN,
Buffer.from(backupKey),
Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO),
Buffer.from(mediaId)
);
return {
aesKey: material.subarray(0, BACKUP_MEDIA_AES_KEY_LEN),
macKey: material.subarray(
BACKUP_MEDIA_AES_KEY_LEN,
BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_MAC_KEY_LEN
),
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
};
}
export function deriveStorageItemKey(
storageServiceKey: Uint8Array,
itemID: string

View file

@ -156,14 +156,25 @@ export function Image({
}}
>
{pending ? (
blurHash ? (
url || blurHash ? (
<div className="module-image__download-pending">
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
{url ? (
<img
onError={onError}
className="module-image__image"
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"

View file

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

View file

@ -16,7 +16,10 @@ import {
type JobManagerParamsType,
type JobManagerJobResultType,
} from './JobManager';
import { deriveBackupMediaKeyMaterial } from '../Crypto';
import {
deriveBackupMediaKeyMaterial,
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial,
} from '../Crypto';
import { strictAssert } from '../util/assert';
import { type BackupsService, backupsService } from '../services/backups';
import {
@ -24,19 +27,39 @@ import {
getAttachmentCiphertextLength,
getAesCbcCiphertextLength,
decryptAttachmentV2ToSink,
ReencyptedDigestMismatchError,
ReencryptedDigestMismatchError,
} from '../AttachmentCrypto';
import { getBackupKey } from '../services/backups/crypto';
import type {
AttachmentBackupJobType,
CoreAttachmentBackupJobType,
import {
type AttachmentBackupJobType,
type CoreAttachmentBackupJobType,
type StandardAttachmentBackupJobType,
type ThumbnailAttachmentBackupJobType,
} from '../types/AttachmentBackup';
import { isInCall as isInCallSelector } from '../state/selectors/calling';
import { encryptAndUploadAttachment } from '../util/uploadAttachment';
import { getMediaIdFromMediaName } from '../services/backups/util/mediaId';
import { fromBase64 } from '../Bytes';
import {
getMediaIdFromMediaName,
getMediaNameForAttachmentThumbnail,
} from '../services/backups/util/mediaId';
import { fromBase64, toBase64 } from '../Bytes';
import type { WebAPIType } from '../textsecure/WebAPI';
import { mightStillBeOnTransitTier } from '../types/Attachment';
import {
type AttachmentType,
mightStillBeOnTransitTier,
} from '../types/Attachment';
import {
type CreatedThumbnailType,
makeImageThumbnailForBackup,
makeVideoScreenshot,
} from '../types/VisualAttachment';
import { missingCaseError } from '../util/missingCaseError';
import { canAttachmentHaveThumbnail } from './AttachmentDownloadManager';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl';
const MAX_CONCURRENT_JOBS = 3;
const RETRY_CONFIG = {
@ -49,6 +72,11 @@ const RETRY_CONFIG = {
maxBackoffTime: durations.HOUR,
},
};
const THUMBNAIL_RETRY_CONFIG = {
...RETRY_CONFIG,
// Thumbnails are optional so we don't need to try indefinitely
maxAttempts: 3,
};
export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobType> {
private static _instance: AttachmentBackupManager | undefined;
@ -67,12 +95,39 @@ export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobT
},
getJobId,
getJobIdForLogging,
getRetryConfig: () => RETRY_CONFIG,
getRetryConfig: job => {
if (job.type === 'standard') {
return RETRY_CONFIG;
}
return THUMBNAIL_RETRY_CONFIG;
},
maxConcurrentJobs: MAX_CONCURRENT_JOBS,
};
override logPrefix = 'AttachmentBackupManager';
async addJobAndMaybeThumbnailJob(
job: CoreAttachmentBackupJobType
): Promise<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 {
if (!AttachmentBackupManager._instance) {
AttachmentBackupManager._instance = new AttachmentBackupManager(
@ -82,6 +137,12 @@ export class AttachmentBackupManager extends JobManager<CoreAttachmentBackupJobT
return AttachmentBackupManager._instance;
}
static addJobAndMaybeThumbnailJob(
job: CoreAttachmentBackupJobType
): Promise<void> {
return AttachmentBackupManager.instance.addJobAndMaybeThumbnailJob(job);
}
static async start(): Promise<void> {
log.info('AttachmentBackupManager/starting');
await AttachmentBackupManager.instance.start();
@ -102,7 +163,7 @@ function getJobId(job: CoreAttachmentBackupJobType): string {
}
function getJobIdForLogging(job: CoreAttachmentBackupJobType): string {
return redactGenericText(job.mediaName);
return `${redactGenericText(job.mediaName)}.${job.type}`;
}
/**
@ -130,7 +191,7 @@ export async function runAttachmentBackupJob(
encryptAndUploadAttachment,
decryptAttachmentV2ToSink,
}
): Promise<JobManagerJobResultType> {
): Promise<JobManagerJobResultType<CoreAttachmentBackupJobType>> {
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentBackupManager/runAttachmentBackupJob/${jobIdForLogging}`;
try {
@ -147,7 +208,7 @@ export async function runAttachmentBackupJob(
return { status: 'finished' };
}
if (error instanceof ReencyptedDigestMismatchError) {
if (error instanceof ReencryptedDigestMismatchError) {
log.error(
`${logId}: Unable to reencrypt to match same digest; content must have changed`
);
@ -163,26 +224,11 @@ async function runAttachmentBackupJobInner(
dependencies: RunAttachmentBackupJobDependenciesType
): Promise<void> {
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentBackupManager.UploadOrCopyToBackupTier(mediaName:${jobIdForLogging})`;
const logId = `AttachmentBackupManager.UploadOrCopyToBackupTier(${jobIdForLogging})`;
log.info(`${logId}: starting`);
const { mediaName, type } = job;
// TODO (DESKTOP-6913): generate & upload thumbnail
strictAssert(
type === 'standard',
'Only standard uploads are currently supported'
);
const { path, transitCdnInfo, iv, digest, keys, size, version, localKey } =
job.data;
const mediaId = getMediaIdFromMediaName(mediaName);
const backupKeyMaterial = deriveBackupMediaKeyMaterial(
getBackupKey(),
mediaId.bytes
);
const mediaId = getMediaIdFromMediaName(job.mediaName);
const { isInBackupTier } = await dependencies.backupsService.getBackupCdnInfo(
mediaId.string
@ -193,6 +239,33 @@ async function runAttachmentBackupJobInner(
return;
}
const jobType = job.type;
switch (jobType) {
case 'standard':
return backupStandardAttachment(job, dependencies);
case 'thumbnail':
return backupThumbnailAttachment(job, dependencies);
default:
throw missingCaseError(jobType);
}
}
async function backupStandardAttachment(
job: StandardAttachmentBackupJobType,
dependencies: RunAttachmentBackupJobDependenciesType
) {
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentBackupManager.backupStandardAttachment(${jobIdForLogging})`;
const { path, transitCdnInfo, iv, digest, keys, size, version, localKey } =
job.data;
const mediaId = getMediaIdFromMediaName(job.mediaName);
const backupKeyMaterial = deriveBackupMediaKeyMaterial(
getBackupKey(),
mediaId.bytes
);
if (transitCdnInfo) {
const {
cdnKey: transitCdnKey,
@ -244,7 +317,6 @@ async function runAttachmentBackupJobInner(
version,
localKey,
size,
keys,
iv,
digest,
@ -263,12 +335,118 @@ async function runAttachmentBackupJobInner(
});
}
async function backupThumbnailAttachment(
job: ThumbnailAttachmentBackupJobType,
dependencies: RunAttachmentBackupJobDependenciesType
) {
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentBackupManager.backupThumbnailAttachment(${jobIdForLogging})`;
const mediaId = getMediaIdFromMediaName(job.mediaName);
const backupKeyMaterial = deriveBackupMediaKeyMaterial(
getBackupKey(),
mediaId.bytes
);
const { fullsizePath, fullsizeSize, contentType, version, localKey } =
job.data;
if (!canAttachmentHaveThumbnail(contentType)) {
log.error(
`${logId}: cannot generate thumbnail for contentType: ${contentType}`
);
return;
}
if (!fullsizePath) {
throw new AttachmentPermanentlyMissingError('No fullsizePath property');
}
const fullsizeAbsolutePath =
dependencies.getAbsoluteAttachmentPath(fullsizePath);
if (!existsSync(fullsizeAbsolutePath)) {
throw new AttachmentPermanentlyMissingError(
'No fullsize file at provided path'
);
}
let thumbnail: CreatedThumbnailType;
const fullsizeUrl = getLocalAttachmentUrl({
path: fullsizePath,
size: fullsizeSize,
contentType,
version,
localKey,
});
if (isVideoTypeSupported(contentType)) {
// TODO (DESKTOP-7204): pull screenshot path from attachments table if it already
// exists
const screenshotBlob = await makeVideoScreenshot({
objectUrl: fullsizeUrl,
});
const screenshotObjectUrl = URL.createObjectURL(screenshotBlob);
thumbnail = await makeImageThumbnailForBackup({
objectUrl: screenshotObjectUrl,
});
} else if (isImageTypeSupported(contentType)) {
thumbnail = await makeImageThumbnailForBackup({
objectUrl: fullsizeUrl,
});
} else {
log.error(
`${logId}: cannot generate thumbnail for contentType: ${contentType}`
);
return;
}
const { aesKey, macKey } =
deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
getBackupKey(),
mediaId.bytes
);
log.info(`${logId}: uploading thumbnail to transit tier`);
const uploadResult = await uploadThumbnailToTransitTier({
data: thumbnail.data,
keys: toBase64(Buffer.concat([aesKey, macKey])),
logPrefix: logId,
dependencies,
});
log.info(`${logId}: copying thumbnail to backup tier`);
await copyToBackupTier({
cdnKey: uploadResult.cdnKey,
cdnNumber: uploadResult.cdnNumber,
size: thumbnail.data.byteLength,
mediaId: mediaId.string,
...backupKeyMaterial,
dependencies,
});
}
type UploadToTransitTierArgsType = {
absolutePath: string;
iv: string;
digest: string;
keys: string;
version?: AttachmentType['version'];
localKey?: string;
size: number;
logPrefix: string;
dependencies: {
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
};
};
type UploadResponseType = {
cdnKey: string;
cdnNumber: number;
encrypted: EncryptedAttachmentV2;
};
async function uploadToTransitTier({
absolutePath,
keys,
@ -279,20 +457,7 @@ async function uploadToTransitTier({
digest,
logPrefix,
dependencies,
}: {
absolutePath: string;
iv: string;
digest: string;
keys: string;
version?: 2;
localKey?: string;
size: number;
logPrefix: string;
dependencies: {
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
};
}): Promise<UploadResponseType> {
}: UploadToTransitTierArgsType): Promise<UploadResponseType> {
try {
if (version === 2) {
strictAssert(
@ -311,7 +476,7 @@ async function uploadToTransitTier({
ciphertextPath: absolutePath,
keysBase64: localKey,
size,
isLocal: true,
type: 'local',
},
sink
),
@ -350,6 +515,36 @@ async function uploadToTransitTier({
}
}
async function uploadThumbnailToTransitTier({
data,
keys,
logPrefix,
dependencies,
}: {
data: Uint8Array;
keys: string;
logPrefix: string;
dependencies: {
decryptAttachmentV2ToSink: typeof decryptAttachmentV2ToSink;
encryptAndUploadAttachment: typeof encryptAndUploadAttachment;
};
}): Promise<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;
async function copyToBackupTier({

View file

@ -12,15 +12,18 @@ import {
} from '../types/AttachmentDownload';
import {
AttachmentPermanentlyUndownloadableError,
downloadAttachment,
downloadAttachment as downloadAttachmentUtil,
} from '../util/downloadAttachment';
import dataInterface from '../sql/Client';
import { getValue } from '../RemoteConfig';
import { isInCall as isInCallSelector } from '../state/selectors/calling';
import { AttachmentSizeError, type AttachmentType } from '../types/Attachment';
import {
AttachmentSizeError,
type AttachmentType,
AttachmentVariant,
} from '../types/Attachment';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import type { MessageModel } from '../models/messages';
import {
KIBIBYTE,
getMaximumIncomingAttachmentSizeInKb,
@ -34,6 +37,11 @@ import {
type JobManagerParamsType,
type JobManagerJobResultType,
} from './JobManager';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { MIMEType } from '../types/MIME';
export enum AttachmentDownloadUrgency {
IMMEDIATE = 'immediate',
@ -63,13 +71,19 @@ const DEFAULT_RETRY_CONFIG = {
};
type AttachmentDownloadManagerParamsType = Omit<
JobManagerParamsType<CoreAttachmentDownloadJobType>,
'getNextJobs'
'getNextJobs' | 'runJob'
> & {
getNextJobs: (options: {
limit: number;
prioritizeMessageIds?: Array<string>;
timestamp?: number;
}) => Promise<Array<AttachmentDownloadJobType>>;
runDownloadAttachmentJob: (args: {
job: AttachmentDownloadJobType;
isLastAttempt: boolean;
options?: { isForCurrentlyVisibleMessage: boolean };
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}) => Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>>;
};
function getJobId(job: CoreAttachmentDownloadJobType): string {
@ -84,7 +98,7 @@ function getJobIdForLogging(job: CoreAttachmentDownloadJobType): string {
}
export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownloadJobType> {
private visibleTimelineMessages: Array<string> = [];
private visibleTimelineMessages: Set<string> = new Set();
private static _instance: AttachmentDownloadManager | undefined;
override logPrefix = 'AttachmentDownloadManager';
@ -93,7 +107,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
saveJob: dataInterface.saveAttachmentDownloadJob,
removeJob: dataInterface.removeAttachmentDownloadJob,
getNextJobs: dataInterface.getNextAttachmentDownloadJobs,
runJob: runDownloadAttachmentJob,
runDownloadAttachmentJob,
shouldHoldOffOnStartingQueuedJobs: () => {
const reduxState = window.reduxStore?.getState();
if (reduxState) {
@ -113,10 +127,23 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
getNextJobs: ({ limit }) => {
return params.getNextJobs({
limit,
prioritizeMessageIds: this.visibleTimelineMessages,
prioritizeMessageIds: [...this.visibleTimelineMessages],
timestamp: Date.now(),
});
},
runJob: (job: AttachmentDownloadJobType, isLastAttempt: boolean) => {
const isForCurrentlyVisibleMessage = this.visibleTimelineMessages.has(
job.messageId
);
return params.runDownloadAttachmentJob({
job,
isLastAttempt,
options: {
isForCurrentlyVisibleMessage,
},
dependencies: { downloadAttachment: downloadAttachmentUtil },
});
},
});
}
@ -168,7 +195,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
}
updateVisibleTimelineMessages(messageIds: Array<string>): void {
this.visibleTimelineMessages = messageIds;
this.visibleTimelineMessages = new Set(messageIds);
}
static get instance(): AttachmentDownloadManager {
@ -202,14 +229,17 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
);
}
}
// TODO (DESKTOP-6913): if a prioritized job is selected, we will to update the
// in-memory job with that information so we can handle it differently, including
// e.g. downloading a thumbnail before the full-size version
async function runDownloadAttachmentJob(
job: AttachmentDownloadJobType,
isLastAttempt: boolean
): Promise<JobManagerJobResultType> {
async function runDownloadAttachmentJob({
job,
isLastAttempt,
options,
dependencies,
}: {
job: AttachmentDownloadJobType;
isLastAttempt: boolean;
options?: { isForCurrentlyVisibleMessage: boolean };
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}): Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>> {
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`;
@ -222,8 +252,24 @@ async function runDownloadAttachmentJob(
try {
log.info(`${logId}: Starting job`);
await runDownloadAttachmentJobInner(job, message);
return { status: 'finished' };
const result = await runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage:
options?.isForCurrentlyVisibleMessage ?? false,
dependencies,
});
if (result.onlyAttemptedBackupThumbnail) {
return {
status: 'finished',
newJob: { ...job, attachment: result.attachmentWithThumbnail },
};
}
return {
status: 'finished',
};
} catch (error) {
log.error(
`${logId}: Failed to download attachment, attempt ${job.attempts}:`,
@ -281,21 +327,33 @@ async function runDownloadAttachmentJob(
}
}
async function runDownloadAttachmentJobInner(
job: AttachmentDownloadJobType,
message: MessageModel
): Promise<void> {
const { messageId, attachment, attachmentType: type } = job;
type DownloadAttachmentResultType =
| {
onlyAttemptedBackupThumbnail: false;
}
| {
onlyAttemptedBackupThumbnail: true;
attachmentWithThumbnail: AttachmentType;
};
export async function runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage,
dependencies,
}: {
job: AttachmentDownloadJobType;
isForCurrentlyVisibleMessage: boolean;
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}): Promise<DownloadAttachmentResultType> {
const { messageId, attachment, attachmentType } = job;
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentDownloadManager/runDownloadJobInner(${jobIdForLogging})`;
let logId = `AttachmentDownloadManager/runDownloadJobInner(${jobIdForLogging})`;
if (!job || !attachment || !messageId) {
throw new Error(`${logId}: Key information required for job was missing.`);
}
log.info(`${logId}: starting`);
const maxInKib = getMaximumIncomingAttachmentSizeInKb(getValue);
const maxTextAttachmentSizeInKib =
getMaximumIncomingTextAttachmentSizeInKb(getValue);
@ -308,32 +366,123 @@ async function runDownloadAttachmentJobInner(
`${logId}: Attachment was ${sizeInKib}kib, max is ${maxInKib}kib`
);
}
if (type === 'long-message' && sizeInKib > maxTextAttachmentSizeInKib) {
if (
attachmentType === 'long-message' &&
sizeInKib > maxTextAttachmentSizeInKib
) {
throw new AttachmentSizeError(
`${logId}: Text attachment was ${sizeInKib}kib, max is ${maxTextAttachmentSizeInKib}kib`
);
}
const preferBackupThumbnail =
isForCurrentlyVisibleMessage &&
mightHaveThumbnailOnBackupTier(job.attachment) &&
// TODO (DESKTOP-7204): check if thumbnail exists on attachment, not on job
!job.attachment.thumbnailFromBackup;
if (preferBackupThumbnail) {
logId += '.preferringBackupThumbnail';
}
if (preferBackupThumbnail) {
try {
const attachmentWithThumbnail = await downloadBackupThumbnail({
attachment,
dependencies,
});
await addAttachmentToMessage(messageId, attachmentWithThumbnail, logId, {
type: attachmentType,
});
return {
onlyAttemptedBackupThumbnail: true,
attachmentWithThumbnail,
};
} catch (e) {
log.warn(`${logId}: error when trying to download thumbnail`);
}
}
// TODO (DESKTOP-7204): currently we only set pending state when downloading the
// full-size attachment
await addAttachmentToMessage(
message.id,
messageId,
{ ...attachment, pending: true },
logId,
{ type }
{ type: attachmentType }
);
const downloaded = await downloadAttachment(attachment);
try {
const downloaded = await dependencies.downloadAttachment({
attachment,
variant: AttachmentVariant.Default,
});
const upgradedAttachment =
await window.Signal.Migrations.processNewAttachment(downloaded);
const upgradedAttachment =
await window.Signal.Migrations.processNewAttachment({
...omit(attachment, ['error', 'pending']),
...downloaded,
});
await addAttachmentToMessage(
message.id,
omit(upgradedAttachment, ['error', 'pending']),
logId,
{
type,
await addAttachmentToMessage(messageId, upgradedAttachment, logId, {
type: attachmentType,
});
return { onlyAttemptedBackupThumbnail: false };
} catch (error) {
if (
!job.attachment.thumbnailFromBackup &&
mightHaveThumbnailOnBackupTier(attachment) &&
!preferBackupThumbnail
) {
log.error(
`${logId}: failed to download fullsize attachment, falling back to thumbnail`,
Errors.toLogFormat(error)
);
try {
const attachmentWithThumbnail = await downloadBackupThumbnail({
attachment,
dependencies,
});
await addAttachmentToMessage(
messageId,
omit(attachmentWithThumbnail, 'pending'),
logId,
{
type: attachmentType,
}
);
return {
onlyAttemptedBackupThumbnail: false,
};
} catch (thumbnailError) {
log.error(
`${logId}: fallback attempt to download thumbnail failed`,
Errors.toLogFormat(thumbnailError)
);
}
}
);
throw error;
}
}
async function downloadBackupThumbnail({
attachment,
dependencies,
}: {
attachment: AttachmentType;
dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}): Promise<AttachmentType> {
const downloadedThumbnail = await dependencies.downloadAttachment({
attachment,
variant: AttachmentVariant.ThumbnailFromBackup,
});
const attachmentWithThumbnail = {
...attachment,
thumbnailFromBackup: downloadedThumbnail,
};
return attachmentWithThumbnail;
}
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
@ -354,3 +503,17 @@ function _markAttachmentAsTransientlyErrored(
): AttachmentType {
return { ...attachment, pending: false, error: true };
}
function mightHaveThumbnailOnBackupTier(
attachment: Pick<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: (
job: JobType,
isLastAttempt: boolean
) => Promise<JobManagerJobResultType>;
) => Promise<JobManagerJobResultType<CoreJobType>>;
shouldHoldOffOnStartingQueuedJobs?: () => boolean;
getJobId: (job: CoreJobType) => string;
getJobIdForLogging: (job: JobType) => string;
@ -55,9 +55,12 @@ export type JobManagerParamsType<
maxConcurrentJobs: number;
};
export type JobManagerJobResultType = { status: 'retry' | 'finished' };
const TICK_INTERVAL = MINUTE;
const DEFAULT_TICK_INTERVAL = MINUTE;
export type JobManagerJobResultType<CoreJobType> =
| {
status: 'retry';
}
| { status: 'finished'; newJob?: CoreJobType };
export abstract class JobManager<CoreJobType> {
protected enabled: boolean = false;
@ -75,7 +78,7 @@ export abstract class JobManager<CoreJobType> {
protected tickTimeout: NodeJS.Timeout | null = null;
protected logPrefix = 'JobManager';
public tickInterval = DEFAULT_TICK_INTERVAL;
constructor(readonly params: JobManagerParamsType<CoreJobType>) {}
async start(): Promise<void> {
@ -99,7 +102,7 @@ export abstract class JobManager<CoreJobType> {
clearTimeoutIfNecessary(this.tickTimeout);
this.tickTimeout = null;
drop(this.maybeStartJobs());
this.tickTimeout = setTimeout(() => this.tick(), TICK_INTERVAL);
this.tickTimeout = setTimeout(() => this.tick(), this.tickInterval);
}
// used in testing
@ -196,7 +199,6 @@ export abstract class JobManager<CoreJobType> {
try {
this._inMaybeStartJobs = true;
if (!this.enabled) {
log.info(`${this.logPrefix}/_maybeStartJobs: not enabled, returning`);
return;
@ -247,13 +249,15 @@ export abstract class JobManager<CoreJobType> {
job.attempts + 1 >=
(this.params.getRetryConfig(job).maxAttempts ?? Infinity);
let jobRunResult: JobManagerJobResultType<CoreJobType> | undefined;
try {
log.info(`${logId}: starting job`);
this.addRunningJob(job);
await this.params.saveJob({ ...job, active: true });
const runJobPromise = this.params.runJob(job, isLastAttempt);
this.handleJobStartPromises(job);
const { status } = await this.params.runJob(job, isLastAttempt);
jobRunResult = await runJobPromise;
const { status } = jobRunResult;
log.info(`${logId}: job completed with status: ${status}`);
switch (status) {
@ -278,13 +282,20 @@ export abstract class JobManager<CoreJobType> {
}
} finally {
this.removeRunningJob(job);
if (jobRunResult?.status === 'finished') {
if (jobRunResult.newJob) {
log.info(
`${logId}: adding new job as a result of this one completing`
);
await this.addJob(jobRunResult.newJob);
}
}
drop(this.maybeStartJobs());
}
}
private async retryJobLater(job: CoreJobType & JobManagerJobType) {
const now = Date.now();
await this.params.saveJob({
...job,
active: false,

View file

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

View file

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

View file

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

View file

@ -26,6 +26,16 @@ export function getMediaIdForAttachment(attachment: AttachmentType): {
return getMediaIdFromMediaName(mediaName);
}
export function getMediaIdForAttachmentThumbnail(attachment: AttachmentType): {
string: string;
bytes: Uint8Array;
} {
const mediaName = getMediaNameForAttachmentThumbnail(
getMediaNameForAttachment(attachment)
);
return getMediaIdFromMediaName(mediaName);
}
export function getMediaNameForAttachment(attachment: AttachmentType): string {
if (attachment.backupLocator) {
return attachment.backupLocator.mediaName;
@ -34,6 +44,12 @@ export function getMediaNameForAttachment(attachment: AttachmentType): string {
return attachment.digest;
}
export function getMediaNameForAttachmentThumbnail(
fullsizeMediaName: string
): string {
return Bytes.toBase64(Bytes.fromString(`${fullsizeMediaName}_thumbnail`));
}
export function getBytesFromMediaIdString(mediaId: string): Uint8Array {
return Bytes.fromBase64url(mediaId);
}

View file

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

View file

@ -403,6 +403,7 @@ const dataInterface: ServerInterface = {
saveAttachmentBackupJob,
markAllAttachmentBackupJobsInactive,
removeAttachmentBackupJob,
clearAllAttachmentBackupJobs,
clearAllBackupCdnObjectMetadata,
saveBackupCdnObjectMetadata,
@ -5014,6 +5015,11 @@ async function removeAttachmentDownloadJob(
// Backup Attachments
async function clearAllAttachmentBackupJobs(): Promise<void> {
const db = await getWritableInstance();
db.prepare('DELETE FROM attachment_backup_jobs;').run();
}
async function markAllAttachmentBackupJobsInactive(): Promise<void> {
const db = await getWritableInstance();
db.prepare<EmptyQuery>(
@ -5068,7 +5074,9 @@ async function getNextAttachmentBackupJobs({
active = 0
AND
(retryAfter is NULL OR retryAfter <= ${timestamp})
ORDER BY receivedAt DESC
ORDER BY
-- type is "standard" or "thumbnail"; we prefer "standard" jobs
type ASC, receivedAt DESC
LIMIT ${limit}
`;
const rows = db.prepare(query).all(params);
@ -6750,7 +6758,12 @@ function getExternalFilesForMessage(message: MessageType): Array<string> {
const files: Array<string> = [];
forEach(attachments, attachment => {
const { path: file, thumbnail, screenshot } = attachment;
const {
path: file,
thumbnail,
screenshot,
thumbnailFromBackup,
} = attachment;
if (file) {
files.push(file);
}
@ -6762,6 +6775,10 @@ function getExternalFilesForMessage(message: MessageType): Array<string> {
if (screenshot && screenshot.path) {
files.push(screenshot.path);
}
if (thumbnailFromBackup && thumbnailFromBackup.path) {
files.push(thumbnailFromBackup.path);
}
});
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 { getMentionsRegex } from '../../types/Message';
import { SignalService as Proto } from '../../protobuf';
import type { AttachmentType } from '../../types/Attachment';
import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import {
isVoiceMessage,
canBeDownloaded,
@ -1821,12 +1824,13 @@ export function getPropsForEmbeddedContact(
export function getPropsForAttachment(
attachment: AttachmentType
): AttachmentType | undefined {
): AttachmentForUIType | undefined {
if (!attachment) {
return undefined;
}
const { path, pending, size, screenshot, thumbnail } = attachment;
const { path, pending, size, screenshot, thumbnail, thumbnailFromBackup } =
attachment;
return {
...attachment,
@ -1834,6 +1838,12 @@ export function getPropsForAttachment(
isVoiceMessage: isVoiceMessage(attachment),
pending,
url: path ? getLocalAttachmentUrl(attachment) : undefined,
thumbnailFromBackup: thumbnailFromBackup?.path
? {
...thumbnailFromBackup,
url: getLocalAttachmentUrl(thumbnailFromBackup),
}
: undefined,
screenshot: screenshot?.path
? {
...screenshot,

View file

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

View file

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

View file

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

View file

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

View file

@ -5,17 +5,52 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import * as sinon from 'sinon';
import { assert } from 'chai';
import * as MIME from '../../types/MIME';
import { omit } from 'lodash';
import * as MIME from '../../types/MIME';
import {
AttachmentDownloadManager,
AttachmentDownloadUrgency,
runDownloadAttachmentJobInner,
type NewAttachmentDownloadJobType,
} from '../../jobs/AttachmentDownloadManager';
import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
import dataInterface from '../../sql/Client';
import { MINUTE } from '../../util/durations';
import { type AciString } from '../../types/ServiceId';
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
function composeJob({
messageId,
receivedAt,
attachmentOverrides,
}: Pick<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', () => {
let downloadManager: AttachmentDownloadManager | undefined;
@ -24,36 +59,6 @@ describe('AttachmentDownloadManager/JobManager', () => {
let clock: sinon.SinonFakeTimers;
let isInCall: sinon.SinonStub;
function composeJob({
messageId,
receivedAt,
}: Pick<
NewAttachmentDownloadJobType,
'messageId' | 'receivedAt'
>): AttachmentDownloadJobType {
const digest = `digestFor${messageId}`;
const size = 128;
const contentType = MIME.IMAGE_PNG;
return {
messageId,
receivedAt,
sentAt: receivedAt,
attachmentType: 'attachment',
digest,
size,
contentType,
active: false,
attempts: 0,
retryAfter: null,
lastAttemptTimestamp: null,
attachment: {
contentType,
size,
digest: `digestFor${messageId}`,
},
};
}
beforeEach(async () => {
await dataInterface.removeAll();
@ -72,13 +77,13 @@ describe('AttachmentDownloadManager/JobManager', () => {
downloadManager = new AttachmentDownloadManager({
...AttachmentDownloadManager.defaultParams,
shouldHoldOffOnStartingQueuedJobs: isInCall,
runJob,
runDownloadAttachmentJob: runJob,
getRetryConfig: () => ({
maxAttempts: 5,
backoffConfig: {
multiplier: 5,
multiplier: 2,
firstBackoffs: [MINUTE],
maxBackoffTime: 30 * MINUTE,
maxBackoffTime: 10 * MINUTE,
},
}),
});
@ -143,7 +148,7 @@ describe('AttachmentDownloadManager/JobManager', () => {
.getCalls()
.map(
call =>
`${call.args[0].messageId}${call.args[0].attachmentType}.${call.args[0].digest}`
`${call.args[0].job.messageId}${call.args[0].job.attachmentType}.${call.args[0].job.digest}`
)
),
JSON.stringify(
@ -158,8 +163,13 @@ describe('AttachmentDownloadManager/JobManager', () => {
// prior (unfinished) invocations can prevent subsequent calls after the clock is
// ticked forward and make tests unreliable
await dataInterface.getAllItems();
await clock.tickAsync(ms);
await dataInterface.getAllItems();
const now = Date.now();
while (Date.now() < now + ms) {
// eslint-disable-next-line no-await-in-loop
await clock.tickAsync(downloadManager?.tickInterval ?? 1000);
// eslint-disable-next-line no-await-in-loop
await dataInterface.getAllItems();
}
}
function getPromisesForAttempts(
@ -270,7 +280,7 @@ describe('AttachmentDownloadManager/JobManager', () => {
const job0Attempts = getPromisesForAttempts(jobs[0], 1);
const job1Attempts = getPromisesForAttempts(jobs[1], 5);
runJob.callsFake(async (job: AttachmentDownloadJobType) => {
runJob.callsFake(async ({ job }: { job: AttachmentDownloadJobType }) => {
return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
Promise.resolve().then(() => {
if (job.messageId === jobs[0].messageId) {
@ -299,16 +309,16 @@ describe('AttachmentDownloadManager/JobManager', () => {
await job1Attempts[1].completed;
assert.strictEqual(runJob.callCount, 3);
await advanceTime(5 * MINUTE);
await advanceTime(2 * MINUTE);
await job1Attempts[2].completed;
assert.strictEqual(runJob.callCount, 4);
await advanceTime(25 * MINUTE);
await advanceTime(4 * MINUTE);
await job1Attempts[3].completed;
assert.strictEqual(runJob.callCount, 5);
await advanceTime(30 * MINUTE);
await advanceTime(8 * MINUTE);
await job1Attempts[4].completed;
assert.strictEqual(runJob.callCount, 6);
@ -359,15 +369,15 @@ describe('AttachmentDownloadManager/JobManager', () => {
await attempts[1].completed;
assert.strictEqual(runJob.callCount, 5);
await advanceTime(5 * MINUTE);
await advanceTime(2 * MINUTE);
await attempts[2].completed;
assert.strictEqual(runJob.callCount, 6);
await advanceTime(25 * MINUTE);
await advanceTime(4 * MINUTE);
await attempts[3].completed;
assert.strictEqual(runJob.callCount, 7);
await advanceTime(30 * MINUTE);
await advanceTime(8 * MINUTE);
await attempts[4].completed;
assert.strictEqual(runJob.callCount, 8);
@ -375,3 +385,237 @@ describe('AttachmentDownloadManager/JobManager', () => {
assert.isUndefined(await dataInterface.getAttachmentDownloadJob(jobs[0]));
});
});
describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
let sandbox: sinon.SinonSandbox;
let downloadAttachment: sinon.SinonStub;
beforeEach(async () => {
sandbox = sinon.createSandbox();
downloadAttachment = sandbox.stub().returns({
path: '/path/to/file',
iv: Buffer.alloc(16),
plaintextHash: 'plaintextHash',
});
});
afterEach(async () => {
sandbox.restore();
});
describe('visible message', () => {
it('will only download full-size if attachment not from backup', async () => {
const job = composeJob({
messageId: '1',
receivedAt: 1,
});
const result = await runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: true,
dependencies: { downloadAttachment },
});
assert.strictEqual(result.onlyAttemptedBackupThumbnail, false);
assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.Default,
});
});
it('will download thumbnail if attachment is from backup', async () => {
const job = composeJob({
messageId: '1',
receivedAt: 1,
attachmentOverrides: {
backupLocator: {
mediaName: 'medianame',
},
},
});
const result = await runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: true,
dependencies: { downloadAttachment },
});
strictAssert(
result.onlyAttemptedBackupThumbnail === true,
'only attempted backup thumbnail'
);
assert.deepStrictEqual(
omit(result.attachmentWithThumbnail, 'thumbnailFromBackup'),
{
contentType: MIME.IMAGE_PNG,
size: 128,
digest: 'digestFor1',
backupLocator: { mediaName: 'medianame' },
}
);
assert.equal(
result.attachmentWithThumbnail.thumbnailFromBackup?.path,
'/path/to/file'
);
assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.ThumbnailFromBackup,
});
});
it('will download full size if thumbnail already backed up', async () => {
const job = composeJob({
messageId: '1',
receivedAt: 1,
attachmentOverrides: {
backupLocator: {
mediaName: 'medianame',
},
thumbnailFromBackup: {
path: '/path/to/thumbnail',
},
},
});
const result = await runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: true,
dependencies: { downloadAttachment },
});
assert.strictEqual(result.onlyAttemptedBackupThumbnail, false);
assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.Default,
});
});
it('will attempt to download full size if thumbnail fails', async () => {
downloadAttachment = sandbox.stub().callsFake(() => {
throw new Error('error while downloading');
});
const job = composeJob({
messageId: '1',
receivedAt: 1,
attachmentOverrides: {
backupLocator: {
mediaName: 'medianame',
},
},
});
await assert.isRejected(
runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: true,
dependencies: { downloadAttachment },
})
);
assert.strictEqual(downloadAttachment.callCount, 2);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.ThumbnailFromBackup,
});
assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.Default,
});
});
});
describe('message not visible', () => {
it('will only download full-size if message not visible', async () => {
const job = composeJob({
messageId: '1',
receivedAt: 1,
attachmentOverrides: {
backupLocator: {
mediaName: 'medianame',
},
},
});
const result = await runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: false,
dependencies: { downloadAttachment },
});
assert.strictEqual(result.onlyAttemptedBackupThumbnail, false);
assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.Default,
});
});
it('will fallback to thumbnail if main download fails and backuplocator exists', async () => {
downloadAttachment = sandbox.stub().callsFake(({ variant }) => {
if (variant === AttachmentVariant.Default) {
throw new Error('error while downloading');
}
return {
path: '/path/to/thumbnail',
iv: Buffer.alloc(16),
plaintextHash: 'plaintextHash',
};
});
const job = composeJob({
messageId: '1',
receivedAt: 1,
attachmentOverrides: {
backupLocator: {
mediaName: 'medianame',
},
},
});
const result = await runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: false,
dependencies: { downloadAttachment },
});
assert.strictEqual(result.onlyAttemptedBackupThumbnail, false);
assert.strictEqual(downloadAttachment.callCount, 2);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.Default,
});
assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.ThumbnailFromBackup,
});
});
it("won't fallback to thumbnail if main download fails and no backup locator", async () => {
downloadAttachment = sandbox.stub().callsFake(({ variant }) => {
if (variant === AttachmentVariant.Default) {
throw new Error('error while downloading');
}
return {
path: '/path/to/thumbnail',
iv: Buffer.alloc(16),
plaintextHash: 'plaintextHash',
};
});
const job = composeJob({
messageId: '1',
receivedAt: 1,
});
await assert.isRejected(
runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: false,
dependencies: { downloadAttachment },
})
);
assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment,
variant: AttachmentVariant.Default,
});
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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