// Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable max-classes-per-file */ import moment from 'moment'; import { isNumber, padStart, isFunction, isUndefined, isString, omit, partition, } from 'lodash'; import { blobToArrayBuffer } from 'blob-util'; import type { LinkPreviewForUIType } from './message/LinkPreviews'; import type { LoggerType } from './Logging'; import * as logging from '../logging/log'; import * as MIME from './MIME'; import { toLogFormat } from './errors'; import { SignalService } from '../protobuf'; import { isImageTypeSupported, isVideoTypeSupported, } from '../util/GoogleChrome'; import type { LocalizerType, WithRequiredProperties } from './Util'; import { ThemeType } from './Util'; import * as GoogleChrome from '../util/GoogleChrome'; import { ReadStatus } from '../messages/MessageReadStatus'; import type { MessageStatusType } from '../components/conversation/Message'; import { strictAssert } from '../util/assert'; import type { SignalService as Proto } from '../protobuf'; import { isMoreRecentThan } from '../util/timestamp'; import { DAY } from '../util/durations'; import { getMessageQueueTime } from '../util/getMessageQueueTime'; import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl'; import type { ReencryptionInfo } from '../AttachmentCrypto'; import { redactGenericText } from '../util/privacy'; const MAX_WIDTH = 300; const MAX_HEIGHT = MAX_WIDTH * 1.5; const MIN_WIDTH = 200; const MIN_HEIGHT = 50; // Used for display export class AttachmentSizeError extends Error {} // Used for downlaods export class AttachmentPermanentlyUndownloadableError extends Error { constructor(message: string) { super(`AttachmentPermanentlyUndownloadableError: ${message}`); } } type ScreenshotType = Omit & { height: number; width: number; path: string; size?: number; }; export type AttachmentType = { error?: boolean; blurHash?: string; caption?: string; clientUuid?: string; contentType: MIME.MIMEType; digest?: string; fileName?: string; plaintextHash?: string; uploadTimestamp?: number; /** Not included in protobuf, needs to be pulled from flags */ isVoiceMessage?: boolean; /** For messages not already on disk, this will be a data url */ url?: string; size: number; pending?: boolean; width?: number; height?: number; path?: string; screenshot?: ScreenshotType; screenshotData?: Uint8Array; // Legacy Draft screenshotPath?: string; flags?: number; thumbnail?: ThumbnailType; isCorrupted?: boolean; cdnNumber?: number; cdnId?: string; cdnKey?: string; downloadPath?: string; key?: string; iv?: string; data?: Uint8Array; textAttachment?: TextAttachmentType; wasTooBig?: boolean; // If `true` backfill is unavailable backfillError?: boolean; totalDownloaded?: number; incrementalMac?: string; chunkSize?: number; backupLocator?: { mediaName: string; cdnNumber?: number; }; // 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; /** Legacy field, used long ago for migrating attachments to disk. */ schemaVersion?: number; } & ( | { isReencryptableToSameDigest?: true; } | { isReencryptableToSameDigest: false; reencryptionInfo?: ReencryptionInfo; } ); export type LocalAttachmentV2Type = Readonly<{ version: 2; path: string; localKey: string; plaintextHash: string; size: number; }>; export type AddressableAttachmentType = Readonly<{ version?: 1 | 2; path: string; localKey?: string; size?: number; contentType: MIME.MIMEType; // In-memory data, for outgoing attachments that are not saved to disk. data?: Uint8Array; }>; export type AttachmentForUIType = AttachmentType & { isPermanentlyUndownloadable: boolean; thumbnailFromBackup?: { url?: string; }; }; export type UploadedAttachmentType = Proto.IAttachmentPointer & Readonly<{ // Required fields cdnKey: string; iv: Uint8Array; key: Uint8Array; size: number; digest: Uint8Array; contentType: string; plaintextHash: string; isReencryptableToSameDigest: true; }>; export type AttachmentWithHydratedData = AttachmentType & { data: Uint8Array; }; export enum TextAttachmentStyleType { DEFAULT = 0, REGULAR = 1, BOLD = 2, SERIF = 3, SCRIPT = 4, CONDENSED = 5, } export type TextAttachmentType = { text?: string | null; textStyle?: number | null; textForegroundColor?: number | null; textBackgroundColor?: number | null; preview?: LinkPreviewForUIType; gradient?: { startColor?: number | null; endColor?: number | null; angle?: number | null; colors?: ReadonlyArray | null; positions?: ReadonlyArray | null; } | null; color?: number | null; }; export type BaseAttachmentDraftType = { blurHash?: string; contentType: MIME.MIMEType; screenshotContentType?: string; size: number; flags?: number; }; // An ephemeral attachment type, used between user's request to add the attachment as // a draft and final save on disk and in conversation.draftAttachments. export type InMemoryAttachmentDraftType = | ({ data: Uint8Array; clientUuid: string; pending: false; screenshotData?: Uint8Array; fileName?: string; path?: string; } & BaseAttachmentDraftType) | { contentType: MIME.MIMEType; clientUuid: string; fileName?: string; path?: string; pending: true; size: number; }; // What's stored in conversation.draftAttachments export type AttachmentDraftType = | ({ url?: string; screenshot?: ScreenshotType; // Legacy field screenshotPath?: string; pending: false; // Old draft attachments may have a caption, though they are no longer editable // because we removed the caption editor. caption?: string; fileName?: string; path: string; width?: number; height?: number; clientUuid: string; version?: 2; localKey?: string; } & BaseAttachmentDraftType) | { clientUuid: string; contentType: MIME.MIMEType; fileName?: string; path?: string; pending: true; size: number; }; export type ThumbnailType = AttachmentType & { // Only used when quote needed to make an in-memory thumbnail objectUrl?: string; // Whether the thumbnail has been copied from the original (quoted) message copied?: boolean; }; export enum AttachmentVariant { Default = 'Default', ThumbnailFromBackup = 'thumbnailFromBackup', } // // Incoming message attachment fields // { // id: string // contentType: MIMEType // data: Uint8Array // digest: Uint8Array // fileName?: string // flags: null // key: Uint8Array // size: integer // thumbnail: Uint8Array // } // // Outgoing message attachment fields // { // contentType: MIMEType // data: Uint8Array // fileName: string // size: integer // } // Returns true if `rawAttachment` is a valid attachment based on our current schema. // Over time, we can expand this definition to become more narrow, e.g. require certain // fields, etc. export function isValid( rawAttachment?: Pick ): rawAttachment is AttachmentType { // NOTE: We cannot use `_.isPlainObject` because `rawAttachment` is // deserialized by protobuf: if (!rawAttachment) { return false; } return true; } const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D'; const UNICODE_RIGHT_TO_LEFT_OVERRIDE = '\u202E'; const UNICODE_REPLACEMENT_CHARACTER = '\uFFFD'; const INVALID_CHARACTERS_PATTERN = new RegExp( `[${UNICODE_LEFT_TO_RIGHT_OVERRIDE}${UNICODE_RIGHT_TO_LEFT_OVERRIDE}]`, 'g' ); // NOTE: Expose synchronous version to do property-based testing using `testcheck`, // which currently doesn’t support async testing: // https://github.com/leebyron/testcheck-js/issues/45 export function _replaceUnicodeOrderOverridesSync( attachment: AttachmentType ): AttachmentType { if (!isString(attachment.fileName)) { return attachment; } const normalizedFilename = attachment.fileName.replace( INVALID_CHARACTERS_PATTERN, UNICODE_REPLACEMENT_CHARACTER ); const newAttachment = { ...attachment, fileName: normalizedFilename }; return newAttachment; } export const replaceUnicodeOrderOverrides = async ( attachment: AttachmentType ): Promise => { return _replaceUnicodeOrderOverridesSync(attachment); }; // \u202A-\u202E is LRE, RLE, PDF, LRO, RLO // \u2066-\u2069 is LRI, RLI, FSI, PDI // \u200E is LRM // \u200F is RLM // \u061C is ALM const V2_UNWANTED_UNICODE = /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g; export async function replaceUnicodeV2( attachment: AttachmentType ): Promise { if (!isString(attachment.fileName)) { return attachment; } const fileName = attachment.fileName.replace( V2_UNWANTED_UNICODE, UNICODE_REPLACEMENT_CHARACTER ); return { ...attachment, fileName, }; } export function removeSchemaVersion({ attachment, logger, }: { attachment: AttachmentType; logger: LoggerType; }): AttachmentType { if (!isValid(attachment)) { logger.error( 'Attachment.removeSchemaVersion: Invalid input attachment:', attachment ); return attachment; } return omit(attachment, 'schemaVersion'); } export function hasData(attachment: AttachmentType): boolean { return attachment.data instanceof Uint8Array; } export function loadData( readAttachmentV2Data: ( attachment: Partial ) => Promise ): ( attachment: Partial ) => Promise { if (!isFunction(readAttachmentV2Data)) { throw new TypeError("'readAttachmentData' must be a function"); } return async attachment => { if (!isValid(attachment)) { throw new TypeError("'attachment' is not valid"); } const isAlreadyLoaded = Boolean(attachment.data); if (isAlreadyLoaded) { return attachment as AttachmentWithHydratedData; } if (!isString(attachment.path)) { throw new TypeError("'attachment.path' is required"); } const data = await readAttachmentV2Data(attachment); return { ...attachment, data, size: data.byteLength }; }; } export function deleteData({ deleteOnDisk, deleteDownloadOnDisk, }: { deleteOnDisk: (path: string) => Promise; deleteDownloadOnDisk: (path: string) => Promise; }): (attachment?: AttachmentType) => Promise { if (!isFunction(deleteOnDisk)) { throw new TypeError('deleteData: deleteOnDisk must be a function'); } return async (attachment?: AttachmentType): Promise => { if (!isValid(attachment)) { throw new TypeError('deleteData: attachment is not valid'); } const { path, downloadPath, thumbnail, screenshot, thumbnailFromBackup } = attachment; if (isString(path)) { await deleteOnDisk(path); } if (isString(downloadPath)) { await deleteDownloadOnDisk(downloadPath); } if (thumbnail && isString(thumbnail.path)) { await deleteOnDisk(thumbnail.path); } if (screenshot && isString(screenshot.path)) { await deleteOnDisk(screenshot.path); } if (thumbnailFromBackup && isString(thumbnailFromBackup.path)) { await deleteOnDisk(thumbnailFromBackup.path); } }; } const THUMBNAIL_SIZE = 150; const THUMBNAIL_CONTENT_TYPE = MIME.IMAGE_PNG; export async function captureDimensionsAndScreenshot( attachment: AttachmentType, params: { writeNewAttachmentData: ( data: Uint8Array ) => Promise; makeObjectUrl: ( data: Uint8Array | ArrayBuffer, contentType: MIME.MIMEType ) => string; revokeObjectUrl: (path: string) => void; getImageDimensions: (params: { objectUrl: string; logger: LoggerType; }) => Promise<{ width: number; height: number; }>; makeImageThumbnail: (params: { size: number; objectUrl: string; contentType: MIME.MIMEType; logger: LoggerType; }) => Promise; makeVideoScreenshot: (params: { objectUrl: string; contentType: MIME.MIMEType; logger: LoggerType; }) => Promise; logger: LoggerType; } ): Promise { const { contentType } = attachment; const { writeNewAttachmentData, makeObjectUrl, revokeObjectUrl, getImageDimensions: getImageDimensionsFromURL, makeImageThumbnail, makeVideoScreenshot, logger, } = params; if ( !GoogleChrome.isImageTypeSupported(contentType) && !GoogleChrome.isVideoTypeSupported(contentType) ) { return attachment; } // If the attachment hasn't been downloaded yet, we won't have a path if (!attachment.path) { return attachment; } const localUrl = getLocalAttachmentUrl(attachment); if (GoogleChrome.isImageTypeSupported(contentType)) { try { const { width, height } = await getImageDimensionsFromURL({ objectUrl: localUrl, logger, }); const thumbnailBuffer = await blobToArrayBuffer( await makeImageThumbnail({ size: THUMBNAIL_SIZE, objectUrl: localUrl, contentType: THUMBNAIL_CONTENT_TYPE, logger, }) ); const thumbnail = await writeNewAttachmentData( new Uint8Array(thumbnailBuffer) ); return { ...attachment, width, height, thumbnail: { ...thumbnail, contentType: THUMBNAIL_CONTENT_TYPE, width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE, }, }; } catch (error) { logger.error( 'captureDimensionsAndScreenshot:', 'error processing image; skipping screenshot generation', toLogFormat(error) ); return attachment; } } let screenshotObjectUrl: string | undefined; try { const screenshotBuffer = await blobToArrayBuffer( await makeVideoScreenshot({ objectUrl: localUrl, contentType: THUMBNAIL_CONTENT_TYPE, logger, }) ); screenshotObjectUrl = makeObjectUrl( screenshotBuffer, THUMBNAIL_CONTENT_TYPE ); const { width, height } = await getImageDimensionsFromURL({ objectUrl: screenshotObjectUrl, logger, }); const screenshot = await writeNewAttachmentData( new Uint8Array(screenshotBuffer) ); const thumbnailBuffer = await blobToArrayBuffer( await makeImageThumbnail({ size: THUMBNAIL_SIZE, objectUrl: screenshotObjectUrl, contentType: THUMBNAIL_CONTENT_TYPE, logger, }) ); const thumbnail = await writeNewAttachmentData( new Uint8Array(thumbnailBuffer) ); return { ...attachment, screenshot: { ...screenshot, contentType: THUMBNAIL_CONTENT_TYPE, width, height, }, thumbnail: { ...thumbnail, contentType: THUMBNAIL_CONTENT_TYPE, width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE, }, width, height, }; } catch (error) { logger.error( 'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation', toLogFormat(error) ); return attachment; } finally { if (screenshotObjectUrl !== undefined) { revokeObjectUrl(screenshotObjectUrl); } } } // UI-focused functions export function getExtensionForDisplay({ fileName, contentType, }: { fileName?: string; contentType: MIME.MIMEType; }): string | undefined { if (fileName && fileName.indexOf('.') >= 0) { const lastPeriod = fileName.lastIndexOf('.'); const extension = fileName.slice(lastPeriod + 1); if (extension.length) { return extension; } } if (!contentType) { return undefined; } const slash = contentType.indexOf('/'); if (slash >= 0) { return contentType.slice(slash + 1); } return undefined; } export function isAudio(attachments?: ReadonlyArray): boolean { return Boolean( attachments && attachments[0] && attachments[0].contentType && !attachments[0].isCorrupted && MIME.isAudio(attachments[0].contentType) ); } export function isPlayed( direction: 'outgoing' | 'incoming', status: MessageStatusType | undefined, readStatus: ReadStatus | undefined ): boolean { if (direction === 'outgoing') { return status === 'viewed'; } return readStatus === ReadStatus.Viewed; } export function canRenderAudio( attachments?: ReadonlyArray ): boolean { const firstAttachment = attachments && attachments[0]; if (!firstAttachment) { return false; } return ( isAudio(attachments) && (isDownloaded(firstAttachment) || isDownloadable(firstAttachment)) ); } export function canDisplayImage( attachments?: ReadonlyArray ): boolean { const { height, width } = attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 }; return Boolean( height && height > 0 && height <= 4096 && width && width > 0 && width <= 4096 ); } export function getThumbnailUrl( attachment: AttachmentForUIType ): string | undefined { if (attachment.thumbnail) { return attachment.thumbnail.url; } return getUrl(attachment); } export function getUrl(attachment: AttachmentForUIType): string | undefined { if (attachment.screenshot) { return attachment.screenshot.url; } if (isVideoAttachment(attachment)) { return undefined; } return attachment.url ?? attachment.thumbnailFromBackup?.url; } export function isImage(attachments?: ReadonlyArray): boolean { return Boolean( attachments && attachments[0] && attachments[0].contentType && isImageTypeSupported(attachments[0].contentType) ); } export function isImageAttachment( attachment?: Pick ): boolean { return Boolean( attachment && attachment.contentType && isImageTypeSupported(attachment.contentType) ); } export function canBeTranscoded( attachment?: Pick ): boolean { return Boolean( attachment && isImageAttachment(attachment) && !MIME.isGif(attachment.contentType) ); } export function hasImage(attachments?: ReadonlyArray): boolean { return Boolean( attachments && attachments[0] && (attachments[0].url || attachments[0].pending || attachments[0].blurHash) ); } export function isVideo( attachments?: ReadonlyArray> ): boolean { if (!attachments || attachments.length === 0) { return false; } return isVideoAttachment(attachments[0]); } export function isVideoAttachment( attachment?: Pick ): boolean { if (!attachment || !attachment.contentType) { return false; } return isVideoTypeSupported(attachment.contentType); } export function isGIF(attachments?: ReadonlyArray): boolean { if (!attachments || attachments.length !== 1) { return false; } const [attachment] = attachments; const flag = SignalService.AttachmentPointer.Flags.GIF; const hasFlag = // eslint-disable-next-line no-bitwise !isUndefined(attachment.flags) && (attachment.flags & flag) === flag; return hasFlag && isVideoAttachment(attachment); } function resolveNestedAttachment< T extends Pick, >(attachment?: T): T | AttachmentType | undefined { if (attachment?.textAttachment?.preview?.image) { return attachment.textAttachment.preview.image; } return attachment; } export function isIncremental( attachment: Pick ): boolean { return Boolean(attachment.incrementalMac && attachment.chunkSize); } export function isDownloaded( attachment?: Pick ): boolean { const resolved = resolveNestedAttachment(attachment); return Boolean(resolved && (resolved.path || resolved.textAttachment)); } export function isReadyToView( attachment?: Pick< AttachmentType, 'incrementalMac' | 'chunkSize' | 'path' | 'textAttachment' > ): boolean { const fullyDownloaded = isDownloaded(attachment); if (fullyDownloaded) { return fullyDownloaded; } const resolved = resolveNestedAttachment(attachment); return Boolean( resolved && (resolved.path || resolved.textAttachment || isIncremental(resolved)) ); } export function hasNotResolved(attachment?: AttachmentType): boolean { const resolved = resolveNestedAttachment(attachment); return Boolean(resolved && !resolved.url && !resolved.textAttachment); } export function isDownloading(attachment?: AttachmentType): boolean { const resolved = resolveNestedAttachment(attachment); return Boolean(resolved && resolved.pending); } export function hasFailed(attachment?: AttachmentType): boolean { const resolved = resolveNestedAttachment(attachment); return Boolean(resolved && resolved.error); } export function hasVideoBlurHash( attachments?: ReadonlyArray ): boolean { const firstAttachment = attachments ? attachments[0] : null; return Boolean(firstAttachment && firstAttachment.blurHash); } export function hasVideoScreenshot( attachments?: ReadonlyArray ): string | null | undefined { const firstAttachment = attachments ? attachments[0] : null; return ( firstAttachment && firstAttachment.screenshot && firstAttachment.screenshot.url ); } type DimensionsType = { height: number; width: number; }; export function getImageDimensions( attachment: Pick, forcedWidth?: number ): DimensionsType { const { height, width } = attachment; if (!height || !width) { return { height: MIN_HEIGHT, width: MIN_WIDTH, }; } const aspectRatio = height / width; const targetWidth = forcedWidth || Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH); const candidateHeight = Math.round(targetWidth * aspectRatio); return { width: targetWidth, height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT), }; } export function areAllAttachmentsVisual( attachments?: ReadonlyArray ): boolean { if (!attachments) { return false; } const max = attachments.length; for (let i = 0; i < max; i += 1) { const attachment = attachments[i]; if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) { return false; } } return true; } export function getGridDimensions( attachments?: ReadonlyArray ): null | DimensionsType { if (!attachments || !attachments.length) { return null; } if (!isImage(attachments) && !isVideo(attachments)) { return null; } if (attachments.length === 1) { return getImageDimensions(attachments[0]); } if (attachments.length === 2) { // A B return { height: 150, width: 300, }; } if (attachments.length === 3) { // A A B // A A C return { height: 200, width: 300, }; } if (attachments.length === 4) { // A B // C D return { height: 300, width: 300, }; } // A A A B B B // A A A B B B // A A A B B B // C C D D E E // C C D D E E return { height: 250, width: 300, }; } export function getAlt( attachment: AttachmentType, i18n: LocalizerType ): string { if (isVideoAttachment(attachment)) { return i18n('icu:videoAttachmentAlt'); } return i18n('icu:imageAttachmentAlt'); } // Migration-related attachment stuff export const isVisualMedia = (attachment: AttachmentType): boolean => { const { contentType } = attachment; if (isUndefined(contentType)) { return false; } if (isVoiceMessage(attachment)) { return false; } return MIME.isImage(contentType) || MIME.isVideo(contentType); }; export const isFile = (attachment: AttachmentType): boolean => { const { contentType } = attachment; if (isUndefined(contentType)) { return false; } if (isVisualMedia(attachment)) { return false; } if (isVoiceMessage(attachment)) { return false; } return true; }; export const isVoiceMessage = ( attachment: Pick ): boolean => { const flag = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE; const hasFlag = // eslint-disable-next-line no-bitwise !isUndefined(attachment.flags) && (attachment.flags & flag) === flag; if (hasFlag) { return true; } const isLegacyAndroidVoiceMessage = !isUndefined(attachment.contentType) && MIME.isAudio(attachment.contentType) && !attachment.fileName; if (isLegacyAndroidVoiceMessage) { return true; } return false; }; export const save = async ({ attachment, index, readAttachmentData, saveAttachmentToDisk, timestamp, baseDir, }: { attachment: AttachmentType; index?: number; readAttachmentData: ( attachment: Partial ) => Promise; saveAttachmentToDisk: (options: { data: Uint8Array; name: string; baseDir?: string; }) => Promise<{ name: string; fullPath: string } | null>; timestamp?: number; /** * Base directory for saving the attachment. * If omitted, a dialog will be opened to let the user choose a directory */ baseDir?: string; }): Promise => { let data: Uint8Array; if (attachment.path) { data = await readAttachmentData(attachment); } else if (attachment.data) { data = attachment.data; } else { throw new Error('Attachment had neither path nor data'); } const name = getSuggestedFilename({ attachment, timestamp, index }); const result = await saveAttachmentToDisk({ data, name, baseDir, }); if (!result) { return null; } return result.fullPath; }; export const getSuggestedFilename = ({ attachment, timestamp, index, }: { attachment: AttachmentType; timestamp?: number | Date; index?: number; }): string => { const { fileName } = attachment; if (fileName && (!isNumber(index) || index === 1)) { return fileName; } const prefix = 'signal'; const suffix = timestamp ? moment(timestamp).format('-YYYY-MM-DD-HHmmss') : ''; const fileType = getFileExtension(attachment); const extension = fileType ? `.${fileType}` : ''; const indexSuffix = isNumber(index) && index > 1 ? `_${padStart(index.toString(), 3, '0')}` : ''; return `${prefix}${suffix}${indexSuffix}${extension}`; }; export const getFileExtension = ( attachment: AttachmentType ): string | undefined => { if (!attachment.contentType) { return undefined; } switch (attachment.contentType) { case 'video/quicktime': return 'mov'; case 'audio/mpeg': return 'mp3'; default: return attachment.contentType.split('/')[1]; } }; export const defaultBlurHash = (theme: ThemeType = ThemeType.light): string => { if (theme === ThemeType.dark) { return 'L05OQnoffQofoffQfQfQfQfQfQfQ'; } return 'L1Q]+w-;fQ-;~qfQfQfQfQfQfQfQ'; }; export const canBeDownloaded = ( attachment: Pick ): boolean => { return Boolean(attachment.digest && attachment.key && !attachment.wasTooBig); }; export function getAttachmentSignature(attachment: AttachmentType): string { strictAssert(attachment.digest, 'attachment missing digest'); return attachment.digest; } export function getAttachmentSignatureSafe( attachment: AttachmentType ): string | undefined { try { return getAttachmentSignature(attachment); } catch { return undefined; } } type RequiredPropertiesForDecryption = 'key' | 'digest'; type RequiredPropertiesForReencryption = 'path' | 'key' | 'digest' | 'iv'; type DecryptableAttachment = WithRequiredProperties< AttachmentType, RequiredPropertiesForDecryption >; export type AttachmentWithNewReencryptionInfoType = Omit< AttachmentType, 'isReencryptableToSameDigest' > & { isReencryptableToSameDigest: false; reencryptionInfo: ReencryptionInfo; }; type AttachmentReencryptableToExistingDigestType = Omit< WithRequiredProperties, 'isReencryptableToSameDigest' > & { isReencryptableToSameDigest: true }; export type ReencryptableAttachment = | AttachmentWithNewReencryptionInfoType | AttachmentReencryptableToExistingDigestType; export type AttachmentDownloadableFromTransitTier = WithRequiredProperties< DecryptableAttachment, 'cdnKey' | 'cdnNumber' >; export type AttachmentDownloadableFromBackupTier = WithRequiredProperties< DecryptableAttachment, 'backupLocator' >; export type LocallySavedAttachment = WithRequiredProperties< AttachmentType, 'path' >; export function isDecryptable( attachment: AttachmentType ): attachment is DecryptableAttachment { return Boolean(attachment.key) && Boolean(attachment.digest); } export function hasAllOriginalEncryptionInfo( attachment: AttachmentType ): attachment is WithRequiredProperties< AttachmentType, 'iv' | 'key' | 'digest' > { return ( Boolean(attachment.iv) && Boolean(attachment.key) && Boolean(attachment.digest) ); } export function isReencryptableToSameDigest( attachment: AttachmentType ): attachment is AttachmentReencryptableToExistingDigestType { return ( hasAllOriginalEncryptionInfo(attachment) && Boolean(attachment.isReencryptableToSameDigest) ); } export function isReencryptableWithNewEncryptionInfo( attachment: AttachmentType ): attachment is AttachmentWithNewReencryptionInfoType { return ( attachment.isReencryptableToSameDigest === false && Boolean(attachment.reencryptionInfo) ); } // Extend range in case the attachment is actually still there (this function is meant to // be optimistic) const BUFFER_TIME_ON_TRANSIT_TIER = 5 * DAY; export function mightStillBeOnTransitTier( attachment: Pick ): boolean { if (!attachment.cdnKey) { return false; } if (attachment.cdnNumber == null) { return false; } if (!attachment.uploadTimestamp) { // Let's be conservative and still assume it might be downloadable return true; } if ( isMoreRecentThan( attachment.uploadTimestamp, getMessageQueueTime() + BUFFER_TIME_ON_TRANSIT_TIER ) ) { return true; } return false; } export function mightBeOnBackupTier( attachment: Pick ): boolean { return Boolean(attachment.backupLocator?.mediaName); } export function isDownloadableFromTransitTier( attachment: AttachmentType ): attachment is AttachmentDownloadableFromTransitTier { if (!isDecryptable(attachment)) { return false; } if (attachment.cdnKey && attachment.cdnNumber != null) { return true; } return false; } export function isDownloadableFromBackupTier( attachment: AttachmentType ): attachment is AttachmentDownloadableFromBackupTier { if (!attachment.key || !attachment.digest) { return false; } if (attachment.backupLocator?.mediaName) { return true; } return false; } export function isDownloadable(attachment: AttachmentType): boolean { return ( isDownloadableFromTransitTier(attachment) || isDownloadableFromBackupTier(attachment) ); } export function isAttachmentLocallySaved( attachment: AttachmentType ): attachment is LocallySavedAttachment { return Boolean(attachment.path); } export function getAttachmentIdForLogging(attachment: AttachmentType): string { const { digest } = attachment; if (typeof digest === 'string') { return redactGenericText(digest); } return '[MissingDigest]'; } // We now partition out the bodyAttachment on receipt, but older // messages may still have a bodyAttachment in the normal attachments field export function partitionBodyAndNormalAttachments< T extends Pick, >( { attachments, existingBodyAttachment, }: { attachments: ReadonlyArray; existingBodyAttachment?: T; }, { logId, logger = logging }: { logId: string; logger?: LoggerType } ): { bodyAttachment: T | undefined; attachments: Array; } { const [bodyAttachments, normalAttachments] = partition( attachments, attachment => MIME.isLongMessage(attachment.contentType) ); if (bodyAttachments.length > 1) { logger.warn( `${logId}: Received more than one long message attachment, ` + `dropping ${bodyAttachments.length - 1}` ); } if (bodyAttachments.length > 0) { if (existingBodyAttachment) { logger.warn(`${logId}: there is already an existing body attachment`); } else { logger.info( `${logId}: Moving a long message attachment to message.bodyAttachment` ); } } return { bodyAttachment: existingBodyAttachment ?? bodyAttachments[0], attachments: normalAttachments, }; }