Support thumbnail export & import during backup of visual attachments
This commit is contained in:
parent
451ee56c92
commit
61548061b8
30 changed files with 1326 additions and 327 deletions
|
@ -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
|
||||
);
|
||||
|
|
|
@ -220,7 +220,7 @@ export const readAndDecryptDataFromDisk = async ({
|
|||
idForLogging: 'attachments/readAndDecryptDataFromDisk',
|
||||
keysBase64,
|
||||
size,
|
||||
isLocal: true,
|
||||
type: 'local',
|
||||
},
|
||||
sink
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
41
ts/Crypto.ts
41
ts/Crypto.ts
|
@ -221,7 +221,9 @@ const BACKUP_MATERIAL_INFO = '20231003_Signal_Backups_EncryptMessageBackup';
|
|||
|
||||
const BACKUP_MEDIA_ID_INFO = '20231003_Signal_Backups_Media_ID';
|
||||
const BACKUP_MEDIA_ID_LEN = 15;
|
||||
const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_Media_ID';
|
||||
const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_EncryptMedia';
|
||||
const BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO =
|
||||
'20240513_Signal_Backups_EncryptThumbnail';
|
||||
const BACKUP_MEDIA_AES_KEY_LEN = 32;
|
||||
const BACKUP_MEDIA_MAC_KEY_LEN = 32;
|
||||
const BACKUP_MEDIA_IV_LEN = 16;
|
||||
|
@ -278,11 +280,11 @@ export function deriveBackupMediaKeyMaterial(
|
|||
mediaId: Uint8Array
|
||||
): BackupMediaKeyMaterialType {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error('deriveMediaIdFromMediaName: invalid backup key length');
|
||||
throw new Error('deriveBackupMediaKeyMaterial: invalid backup key length');
|
||||
}
|
||||
|
||||
if (!mediaId.length) {
|
||||
throw new Error('deriveMediaIdFromMediaName: mediaId missing');
|
||||
throw new Error('deriveBackupMediaKeyMaterial: mediaId missing');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
|
@ -302,6 +304,39 @@ export function deriveBackupMediaKeyMaterial(
|
|||
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
|
||||
backupKey: Uint8Array,
|
||||
mediaId: Uint8Array
|
||||
): BackupMediaKeyMaterialType {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error(
|
||||
'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length'
|
||||
);
|
||||
}
|
||||
|
||||
if (!mediaId.length) {
|
||||
throw new Error('deriveBackupMediaThumbnailKeyMaterial: mediaId missing');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
const material = hkdf.deriveSecrets(
|
||||
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO),
|
||||
Buffer.from(mediaId)
|
||||
);
|
||||
|
||||
return {
|
||||
aesKey: material.subarray(0, BACKUP_MEDIA_AES_KEY_LEN),
|
||||
macKey: material.subarray(
|
||||
BACKUP_MEDIA_AES_KEY_LEN,
|
||||
BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_MAC_KEY_LEN
|
||||
),
|
||||
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveStorageItemKey(
|
||||
storageServiceKey: Uint8Array,
|
||||
itemID: string
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5375,7 +5375,7 @@ export class ConversationModel extends window.Backbone
|
|||
size: avatar.size,
|
||||
|
||||
getAbsoluteAttachmentPath,
|
||||
isLocal: true,
|
||||
type: 'local',
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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
|
||||
? {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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: (
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -60,7 +60,7 @@ export async function parseContactsV2(
|
|||
),
|
||||
keysBase64: attachment.localKey,
|
||||
size: attachment.size,
|
||||
isLocal: true,
|
||||
type: 'local',
|
||||
},
|
||||
parseContactsTransform
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue