Use streams to download attachments directly to disk

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
Scott Nonnenberg 2023-10-30 09:24:28 -07:00 committed by GitHub
parent 2da49456c6
commit 99b2bc304e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2297 additions and 356 deletions

View file

@ -37,6 +37,8 @@ const MIN_HEIGHT = 50;
// Used for display
export class AttachmentSizeError extends Error {}
export type AttachmentType = {
error?: boolean;
blurHash?: string;
@ -75,6 +77,7 @@ export type AttachmentType = {
key?: string;
data?: Uint8Array;
textAttachment?: TextAttachmentType;
wasTooBig?: boolean;
/** Legacy field. Used only for downloading old attachments */
id?: number;
@ -1008,9 +1011,9 @@ export const defaultBlurHash = (theme: ThemeType = ThemeType.light): string => {
};
export const canBeDownloaded = (
attachment: Pick<AttachmentType, 'key' | 'digest'>
attachment: Pick<AttachmentType, 'digest' | 'key' | 'wasTooBig'>
): boolean => {
return Boolean(attachment.key && attachment.digest);
return Boolean(attachment.digest && attachment.key && !attachment.wasTooBig);
};
export function getAttachmentSignature(attachment: AttachmentType): string {

View file

@ -9,14 +9,14 @@ export const KIBIBYTE = 1024;
const MEBIBYTE = 1024 * 1024;
const DEFAULT_MAX = 100 * MEBIBYTE;
export const getMaximumAttachmentSizeInKb = (
export const getMaximumOutgoingAttachmentSizeInKb = (
getValue: typeof RemoteConfig.getValue
): number => {
try {
return (
parseIntOrThrow(
getValue('global.attachments.maxBytes'),
'preProcessAttachment/maxAttachmentSize'
'getMaximumOutgoingAttachmentSizeInKb'
) / KIBIBYTE
);
} catch (error) {
@ -27,6 +27,22 @@ export const getMaximumAttachmentSizeInKb = (
}
};
export const getMaximumIncomingAttachmentSizeInKb = (
getValue: typeof RemoteConfig.getValue
): number => {
try {
return (
parseIntOrThrow(
getValue('global.attachments.maxReceiveBytes'),
'getMaximumIncomingAttachmentSizeInKb'
) / KIBIBYTE
);
} catch (_error) {
// TODO: DESKTOP-5913. We're not gonna log until the new flag is fully deployed
return getMaximumOutgoingAttachmentSizeInKb(getValue) * 1.25;
}
};
export function getRenderDetailsForLimit(limitKb: number): {
limit: number;
units: string;

View file

@ -34,6 +34,12 @@ export const GroupAvatarIcons = [
'surfboard',
] as const;
export type ContactAvatarType = {
path: string;
url?: string;
hash?: string;
};
type GroupAvatarIconType = typeof GroupAvatarIcons[number];
type PersonalAvatarIconType = typeof PersonalAvatarIcons[number];

View file

@ -2,33 +2,87 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ConversationAttributesType } from '../model-types.d';
import type { ContactAvatarType } from './Avatar';
import { computeHash } from '../Crypto';
export type BuildAvatarUpdaterOptions = Readonly<{
data?: Uint8Array;
newAvatar?: ContactAvatarType;
deleteAttachmentData: (path: string) => Promise<void>;
doesAttachmentExist: (path: string) => Promise<boolean>;
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
}>;
// This function is ready to handle raw avatar data as well as an avatar which has
// already been downloaded to disk.
// Scenarios that go to disk today:
// - During a contact sync (see ContactsParser.ts)
// Scenarios that stay in memory today:
// - models/Conversations/setProfileAvatar
function buildAvatarUpdater({ field }: { field: 'avatar' | 'profileAvatar' }) {
return async (
conversation: Readonly<ConversationAttributesType>,
data: Uint8Array,
{
data,
newAvatar,
deleteAttachmentData,
doesAttachmentExist,
writeNewAttachmentData,
}: BuildAvatarUpdaterOptions
): Promise<ConversationAttributesType> => {
if (!conversation) {
if (!conversation || (!data && !newAvatar)) {
return conversation;
}
const avatar = conversation[field];
const oldAvatar = conversation[field];
const newHash = data ? computeHash(data) : undefined;
const newHash = computeHash(data);
if (!oldAvatar || !oldAvatar.hash) {
if (newAvatar) {
return {
...conversation,
[field]: newAvatar,
};
}
if (data) {
return {
...conversation,
[field]: {
hash: newHash,
path: await writeNewAttachmentData(data),
},
};
}
throw new Error('buildAvatarUpdater: neither newAvatar or newData');
}
if (!avatar || !avatar.hash) {
const { hash, path } = oldAvatar;
const exists = await doesAttachmentExist(path);
if (!exists) {
window.SignalContext.log.warn(
`Conversation.buildAvatarUpdater: attachment ${path} did not exist`
);
}
if (exists) {
if (newAvatar && hash && hash === newAvatar.hash) {
await deleteAttachmentData(newAvatar.path);
return conversation;
}
if (data && hash && hash === newHash) {
return conversation;
}
}
await deleteAttachmentData(path);
if (newAvatar) {
return {
...conversation,
[field]: newAvatar,
};
}
if (data) {
return {
...conversation,
[field]: {
@ -38,27 +92,7 @@ function buildAvatarUpdater({ field }: { field: 'avatar' | 'profileAvatar' }) {
};
}
const { hash, path } = avatar;
const exists = await doesAttachmentExist(path);
if (!exists) {
window.SignalContext.log.warn(
`Conversation.buildAvatarUpdater: attachment ${path} did not exist`
);
}
if (exists && hash === newHash) {
return conversation;
}
await deleteAttachmentData(path);
return {
...conversation,
[field]: {
hash: newHash,
path: await writeNewAttachmentData(data),
},
};
throw new Error('buildAvatarUpdater: neither newAvatar or newData');
};
}

View file

@ -593,10 +593,18 @@ export const processNewAttachment = async (
isIncoming: true,
}
);
const onDiskAttachment = await migrateDataToFileSystem(rotatedAttachment, {
writeNewAttachmentData,
logger,
});
let onDiskAttachment = rotatedAttachment;
// If we rotated the attachment, then `data` will be the actual bytes of the attachment,
// in memory. We want that updated attachment to go back to disk.
if (rotatedAttachment.data) {
onDiskAttachment = await migrateDataToFileSystem(rotatedAttachment, {
writeNewAttachmentData,
logger,
});
}
const finalAttachment = await captureDimensionsAndScreenshot(
onDiskAttachment,
{

View file

@ -11,7 +11,7 @@ import { makeLookup } from '../util/makeLookup';
import { maybeParseUrl } from '../util/url';
import * as Bytes from '../Bytes';
import * as Errors from './errors';
import { deriveStickerPackKey, decryptAttachment } from '../Crypto';
import { deriveStickerPackKey, decryptAttachmentV1 } from '../Crypto';
import { IMAGE_WEBP } from './MIME';
import type { MIMEType } from './MIME';
import { sniffImageMimeType } from '../util/sniffImageMimeType';
@ -310,7 +310,10 @@ function getReduxStickerActions() {
function decryptSticker(packKey: string, ciphertext: Uint8Array): Uint8Array {
const binaryKey = Bytes.fromBase64(packKey);
const derivedKey = deriveStickerPackKey(binaryKey);
const plaintext = decryptAttachment(ciphertext, derivedKey);
// Note this download and decrypt in memory is okay because these files are maximum
// 300kb, enforced by the server.
const plaintext = decryptAttachmentV1(ciphertext, derivedKey);
return plaintext;
}