diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index 6b831d43f3..a2693b462b 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -1,10 +1,16 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { ipcMain } from 'electron'; +import { ipcMain, protocol } from 'electron'; +import { createReadStream } from 'node:fs'; +import { join, normalize } from 'node:path'; +import { Readable, Transform, PassThrough } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import z from 'zod'; import * as rimraf from 'rimraf'; import { getAllAttachments, + getAvatarsPath, getPath, getStickersPath, getTempPath, @@ -20,6 +26,11 @@ import type { MainSQL } from '../ts/sql/main'; import type { MessageAttachmentsCursorType } from '../ts/sql/Interface'; import * as Errors from '../ts/types/errors'; import { sleep } from '../ts/util/sleep'; +import { isPathInside } from '../ts/util/isPathInside'; +import { missingCaseError } from '../ts/util/missingCaseError'; +import { safeParseInteger } from '../ts/util/numbers'; +import { drop } from '../ts/util/drop'; +import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto'; let initialized = false; @@ -31,6 +42,14 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const INTERACTIVITY_DELAY = 50; +const dispositionSchema = z.enum([ + 'attachment', + 'temporary', + 'draft', + 'sticker', + 'avatarData', +]); + type DeleteOrphanedAttachmentsOptionsType = Readonly<{ orphanedAttachments: Set; sql: MainSQL; @@ -197,6 +216,7 @@ export function initialize({ const stickersDir = getStickersPath(configDir); const tempDir = getTempPath(configDir); const draftDir = getDraftPath(configDir); + const avatarDataDir = getAvatarsPath(configDir); ipcMain.handle(ERASE_TEMP_KEY, () => rimraf.sync(tempDir)); ipcMain.handle(ERASE_ATTACHMENTS_KEY, () => rimraf.sync(attachmentsDir)); @@ -209,4 +229,216 @@ export function initialize({ const duration = Date.now() - start; console.log(`cleanupOrphanedAttachments: took ${duration}ms`); }); + + protocol.handle('attachment', async req => { + const url = new URL(req.url); + if (url.host !== 'v1' && url.host !== 'v2') { + return new Response('Unknown host', { status: 404 }); + } + + // Disposition + let disposition: z.infer = 'attachment'; + const dispositionParam = url.searchParams.get('disposition'); + if (dispositionParam != null) { + disposition = dispositionSchema.parse(dispositionParam); + } + + let parentDir: string; + switch (disposition) { + case 'attachment': + parentDir = attachmentsDir; + break; + case 'temporary': + parentDir = tempDir; + break; + case 'draft': + parentDir = draftDir; + break; + case 'sticker': + parentDir = stickersDir; + break; + case 'avatarData': + parentDir = avatarDataDir; + break; + default: + throw missingCaseError(disposition); + } + + // Remove first slash + const path = normalize( + join(parentDir, ...url.pathname.slice(1).split(/\//g)) + ); + if (!isPathInside(path, parentDir)) { + return new Response('Access denied', { status: 401 }); + } + + // Get attachment size to trim the padding + const sizeParam = url.searchParams.get('size'); + let maybeSize: number | undefined; + if (sizeParam != null) { + const intValue = safeParseInteger(sizeParam); + if (intValue != null) { + maybeSize = intValue; + } + } + + // Legacy plaintext attachments + if (url.host === 'v1') { + return handleRangeRequest({ + request: req, + size: maybeSize, + plaintext: createReadStream(path), + }); + } + + // Encrypted attachments + + // Get AES+MAC key + const maybeKeysBase64 = url.searchParams.get('key'); + if (maybeKeysBase64 == null) { + return new Response('Missing key', { status: 400 }); + } + + // Size is required for trimming padding. + if (maybeSize == null) { + return new Response('Missing size', { status: 400 }); + } + + // Pacify typescript + const size = maybeSize; + const keysBase64 = maybeKeysBase64; + + const plaintext = new PassThrough(); + + async function runSafe(): Promise { + try { + await decryptAttachmentV2ToSink( + { + ciphertextPath: path, + idForLogging: 'attachment_channel', + keysBase64, + size, + + isLocal: true, + }, + plaintext + ); + } catch (error) { + plaintext.emit('error', error); + } + } + + drop(runSafe()); + + return handleRangeRequest({ + request: req, + size: maybeSize, + plaintext, + }); + }); +} + +type HandleRangeRequestOptionsType = Readonly<{ + request: Request; + size: number | undefined; + plaintext: Readable; +}>; + +function handleRangeRequest({ + request, + size, + plaintext, +}: HandleRangeRequestOptionsType): Response { + const url = new URL(request.url); + + // Get content-type + const contentType = url.searchParams.get('contentType'); + + const headers: HeadersInit = { + 'cache-control': 'no-cache, no-store', + 'content-type': contentType || 'application/octet-stream', + }; + + if (size != null) { + headers['content-length'] = size.toString(); + } + + const create200Response = (): Response => { + return new Response(Readable.toWeb(plaintext) as ReadableStream, { + status: 200, + headers, + }); + }; + + const range = request.headers.get('range'); + if (range == null) { + return create200Response(); + } + + const match = range.match(/^bytes=(\d+)-(\d+)?$/); + if (match == null) { + console.error(`attachment_channel: invalid range header: ${range}`); + return create200Response(); + } + + const startParam = safeParseInteger(match[1]); + if (startParam == null) { + console.error(`attachment_channel: invalid range header: ${range}`); + return create200Response(); + } + + let endParam: number | undefined; + if (match[2] != null) { + const intValue = safeParseInteger(match[2]); + if (intValue == null) { + console.error(`attachment_channel: invalid range header: ${range}`); + return create200Response(); + } + endParam = intValue; + } + + const start = Math.min(startParam, size || Infinity); + let end: number; + if (endParam === undefined) { + end = size || Infinity; + } else { + // Supplied range is inclusive + end = Math.min(endParam + 1, size || Infinity); + } + + let offset = 0; + const transform = new Transform({ + transform(data, _enc, callback) { + if (offset + data.byteLength >= start && offset <= end) { + this.push(data.subarray(Math.max(0, start - offset), end - offset)); + } + + offset += data.byteLength; + callback(); + }, + }); + + headers['content-range'] = + size === undefined + ? `bytes ${start}-${endParam === undefined ? '' : end - 1}/*` + : `bytes ${start}-${end - 1}/${size}`; + + if (endParam !== undefined || size !== undefined) { + headers['content-length'] = (end - start).toString(); + } + + drop( + (async () => { + try { + await pipeline(plaintext, transform); + } catch (error) { + transform.emit('error', error); + } + })() + ); + + return new Response(Readable.toWeb(transform) as ReadableStream, { + status: 206, + headers, + }); } diff --git a/app/attachments.ts b/app/attachments.ts index 5e3adaf1d8..c38c661014 100644 --- a/app/attachments.ts +++ b/app/attachments.ts @@ -1,12 +1,19 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { PassThrough } from 'node:stream'; import { join, relative, normalize } from 'path'; import fastGlob from 'fast-glob'; import fse from 'fs-extra'; import { map, isString } from 'lodash'; import normalizePath from 'normalize-path'; import { isPathInside } from '../ts/util/isPathInside'; +import { + generateKeys, + decryptAttachmentV2ToSink, + encryptAttachmentV2ToDisk, +} from '../ts/AttachmentCrypto'; +import type { LocalAttachmentV2Type } from '../ts/types/Attachment'; const PATH = 'attachments.noindex'; const AVATAR_PATH = 'avatars.noindex'; @@ -190,3 +197,57 @@ export const deleteAllDraftAttachments = async ({ console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`); }; + +export const readAndDecryptDataFromDisk = async ({ + absolutePath, + keysBase64, + size, +}: { + absolutePath: string; + keysBase64: string; + size: number; +}): Promise => { + const sink = new PassThrough(); + + const chunks = new Array(); + + sink.on('data', chunk => chunks.push(chunk)); + sink.resume(); + + await decryptAttachmentV2ToSink( + { + ciphertextPath: absolutePath, + idForLogging: 'attachments/readAndDecryptDataFromDisk', + keysBase64, + size, + isLocal: true, + }, + sink + ); + + return Buffer.concat(chunks); +}; + +export const writeNewAttachmentData = async ({ + data, + getAbsoluteAttachmentPath, +}: { + data: Uint8Array; + getAbsoluteAttachmentPath: (relativePath: string) => string; +}): Promise => { + const keys = generateKeys(); + + const { plaintextHash, path } = await encryptAttachmentV2ToDisk({ + plaintext: { data }, + getAbsoluteAttachmentPath, + keys, + }); + + return { + version: 2, + plaintextHash, + size: data.byteLength, + path, + localKey: Buffer.from(keys).toString('base64'), + }; +}; diff --git a/app/main.ts b/app/main.ts index 37a1bad0c7..09aaa05d6e 100644 --- a/app/main.ts +++ b/app/main.ts @@ -29,6 +29,7 @@ import { systemPreferences, Notification, safeStorage, + protocol as electronProtocol, } from 'electron'; import type { MenuItemConstructorOptions, Settings } from 'electron'; import { z } from 'zod'; @@ -1823,6 +1824,17 @@ if (DISABLE_GPU) { app.disableHardwareAcceleration(); } +// This has to run before the 'ready' event. +electronProtocol.registerSchemesAsPrivileged([ + { + scheme: 'attachment', + privileges: { + supportFetchAPI: true, + stream: true, + }, + }, +]); + // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. diff --git a/background.html b/background.html index 6061a35b1c..73798bc3ba 100644 --- a/background.html +++ b/background.html @@ -16,12 +16,12 @@ http-equiv="Content-Security-Policy" content="default-src 'none'; child-src 'self'; - connect-src 'self' https: wss:; + connect-src 'self' https: wss: attachment:; font-src 'self'; form-action 'self'; frame-src 'none'; - img-src 'self' blob: data: emoji:; - media-src 'self' blob:; + img-src 'self' blob: data: emoji: attachment:; + media-src 'self' blob: attachment:; object-src 'none'; script-src 'self' 'sha256-Qu05oqDmBO5fZacm7tr/oerJcqsW0G/XqP4PRCziovc=' 'sha256-eLeGwSfPmXJ+EUiLfIeXABvLiUqDbiKgNLpHITaabgQ='; style-src 'self' 'unsafe-inline';" diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index 8a1a0cdf11..19c730e11d 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -3,45 +3,44 @@ import { unlinkSync, createReadStream, createWriteStream } from 'fs'; import { open } from 'fs/promises'; -import { - createDecipheriv, - createCipheriv, - createHash, - createHmac, - randomBytes, -} from 'crypto'; -import type { Decipher, Hash, Hmac } from 'crypto'; +import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'; +import type { Hash } from 'crypto'; import { PassThrough, Transform, type Writable, Readable } from 'stream'; import { pipeline } from 'stream/promises'; import { ensureFile } from 'fs-extra'; import * as log from './logging/log'; -import { HashType, CipherType } from './types/Crypto'; -import { createName, getRelativePath } from './windows/attachments'; +import { + HashType, + CipherType, + IV_LENGTH, + KEY_LENGTH, + MAC_LENGTH, +} from './types/Crypto'; import { constantTimeEqual } from './Crypto'; +import { createName, getRelativePath } from './util/attachmentPath'; import { appendPaddingStream, logPadSize } from './util/logPadding'; import { prependStream } from './util/prependStream'; import { appendMacStream } from './util/appendMacStream'; -import { Environment } from './environment'; -import type { AttachmentType } from './types/Attachment'; -import type { ContextType } from './types/Message2'; +import { getIvAndDecipher } from './util/getIvAndDecipher'; +import { getMacAndUpdateHmac } from './util/getMacAndUpdateHmac'; +import { trimPadding } from './util/trimPadding'; import { strictAssert } from './util/assert'; import * as Errors from './types/errors'; import { isNotNil } from './util/isNotNil'; import { missingCaseError } from './util/missingCaseError'; +import { getEnvironment, Environment } from './environment'; // 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. -const IV_LENGTH = 16; -const KEY_LENGTH = 32; -const DIGEST_LENGTH = 32; +const DIGEST_LENGTH = MAC_LENGTH; const HEX_DIGEST_LENGTH = DIGEST_LENGTH * 2; -const ATTACHMENT_MAC_LENGTH = 32; +const ATTACHMENT_MAC_LENGTH = MAC_LENGTH; export class ReencyptedDigestMismatchError extends Error {} /** @private */ -export const KEY_SET_LENGTH = KEY_LENGTH + ATTACHMENT_MAC_LENGTH; +export const KEY_SET_LENGTH = KEY_LENGTH + MAC_LENGTH; export function _generateAttachmentIv(): Uint8Array { return randomBytes(IV_LENGTH); @@ -58,14 +57,31 @@ export type EncryptedAttachmentV2 = { ciphertextSize: number; }; +export type ReencryptedAttachmentV2 = { + path: string; + iv: Uint8Array; + plaintextHash: string; + + key: Uint8Array; +}; + export type DecryptedAttachmentV2 = { path: string; iv: Uint8Array; plaintextHash: string; }; +export type ReecryptedAttachmentV2 = { + key: Uint8Array; + mac: Uint8Array; + path: string; + iv: Uint8Array; + plaintextHash: string; +}; + export type PlaintextSourceType = | { data: Uint8Array } + | { stream: Readable } | { absolutePath: string }; export type HardcodedIVForEncryptionType = @@ -84,6 +100,7 @@ type EncryptAttachmentV2PropsType = { keys: Readonly; dangerousIv?: HardcodedIVForEncryptionType; dangerousTestOnlySkipPadding?: boolean; + getAbsoluteAttachmentPath: (relativePath: string) => string; }; export async function encryptAttachmentV2ToDisk( @@ -91,8 +108,7 @@ export async function encryptAttachmentV2ToDisk( ): Promise { // Create random output file const relativeTargetPath = getRelativePath(createName()); - const absoluteTargetPath = - window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath); + const absoluteTargetPath = args.getAbsoluteAttachmentPath(relativeTargetPath); await ensureFile(absoluteTargetPath); @@ -128,7 +144,7 @@ export async function encryptAttachmentV2({ if (dangerousIv) { if (dangerousIv.reason === 'test') { - if (window.getEnvironment() !== Environment.Test) { + if (getEnvironment() !== Environment.Test) { throw new Error( `${logId}: Used dangerousIv with reason test outside tests!` ); @@ -146,10 +162,7 @@ export async function encryptAttachmentV2({ } } - if ( - dangerousTestOnlySkipPadding && - window.getEnvironment() !== Environment.Test - ) { + if (dangerousTestOnlySkipPadding && getEnvironment() !== Environment.Test) { throw new Error( `${logId}: Used dangerousTestOnlySkipPadding outside tests!` ); @@ -160,12 +173,17 @@ export async function encryptAttachmentV2({ const digest = createHash(HashType.size256); let ciphertextSize: number | undefined; + let mac: Uint8Array | undefined; try { - const source = - 'data' in plaintext - ? Readable.from([Buffer.from(plaintext.data)]) - : createReadStream(plaintext.absolutePath); + let source: Readable; + if ('data' in plaintext) { + source = Readable.from([Buffer.from(plaintext.data)]); + } else if ('stream' in plaintext) { + source = plaintext.stream; + } else { + source = createReadStream(plaintext.absolutePath); + } await pipeline( [ @@ -174,7 +192,9 @@ export async function encryptAttachmentV2({ dangerousTestOnlySkipPadding ? undefined : appendPaddingStream(), createCipheriv(CipherType.AES256CBC, aesKey, iv), prependIv(iv), - appendMacStream(macKey), + appendMacStream(macKey, macValue => { + mac = macValue; + }), peekAndUpdateHash(digest), measureSize(size => { ciphertextSize = size; @@ -204,6 +224,7 @@ export async function encryptAttachmentV2({ ); strictAssert(ciphertextSize != null, 'Failed to measure ciphertext size!'); + strictAssert(mac != null, 'Failed to compute mac!'); if (dangerousIv?.reason === 'reencrypting-for-backup') { if (!constantTimeEqual(ourDigest, dangerousIv.digestToMatch)) { @@ -221,37 +242,102 @@ export async function encryptAttachmentV2({ }; } -type DecryptAttachmentOptionsType = Readonly<{ - ciphertextPath: string; - idForLogging: string; - aesKey: Readonly; - macKey: Readonly; - size: number; - theirDigest: Readonly; - outerEncryption?: { - aesKey: Readonly; - macKey: Readonly; - }; -}>; +type DecryptAttachmentToSinkOptionsType = Readonly< + { + ciphertextPath: string; + idForLogging: string; + size: number; + outerEncryption?: { + aesKey: Readonly; + macKey: Readonly; + }; + } & ( + | { + isLocal?: false; + theirDigest: Readonly; + } + | { + // No need to check integrity for already downloaded attachments + isLocal: true; + theirDigest?: undefined; + } + ) & + ( + | { + aesKey: Readonly; + macKey: Readonly; + } + | { + // The format used by most stored attachments + keysBase64: string; + } + ) +>; + +export type DecryptAttachmentOptionsType = DecryptAttachmentToSinkOptionsType & + Readonly<{ + getAbsoluteAttachmentPath: (relativePath: string) => string; + }>; export async function decryptAttachmentV2( options: DecryptAttachmentOptionsType ): Promise { - const { - idForLogging, - macKey, - aesKey, - ciphertextPath, - theirDigest, - outerEncryption, - } = options; - - const logId = `decryptAttachmentV2(${idForLogging})`; + const logId = `decryptAttachmentV2(${options.idForLogging})`; // Create random output file const relativeTargetPath = getRelativePath(createName()); const absoluteTargetPath = - window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath); + options.getAbsoluteAttachmentPath(relativeTargetPath); + + let writeFd; + try { + try { + await ensureFile(absoluteTargetPath); + writeFd = await open(absoluteTargetPath, 'w'); + } catch (cause) { + throw new Error(`${logId}: Failed to create write path`, { cause }); + } + + const result = await decryptAttachmentV2ToSink( + options, + writeFd.createWriteStream() + ); + + return { + ...result, + path: relativeTargetPath, + }; + } catch (error) { + log.error( + `${logId}: Failed to decrypt attachment to disk`, + Errors.toLogFormat(error) + ); + safeUnlinkSync(absoluteTargetPath); + throw error; + } finally { + await writeFd?.close(); + } +} + +export async function decryptAttachmentV2ToSink( + options: DecryptAttachmentToSinkOptionsType, + sink: Writable +): Promise> { + const { idForLogging, ciphertextPath, outerEncryption } = options; + + let aesKey: Uint8Array; + let macKey: Uint8Array; + + if ('aesKey' in options) { + ({ aesKey, macKey } = options); + } else { + const { keysBase64 } = options; + const keys = Buffer.from(keysBase64, 'base64'); + + ({ aesKey, macKey } = splitKeys(keys)); + } + + const logId = `decryptAttachmentV2(${idForLogging})`; const digest = createHash(HashType.size256); const hmac = createHmac(HashType.size256, macKey); @@ -276,7 +362,6 @@ export async function decryptAttachmentV2( : undefined; let readFd; - let writeFd; let iv: Uint8Array | undefined; try { try { @@ -284,12 +369,6 @@ export async function decryptAttachmentV2( } catch (cause) { throw new Error(`${logId}: Read path doesn't exist`, { cause }); } - try { - await ensureFile(absoluteTargetPath); - writeFd = await open(absoluteTargetPath, 'w'); - } catch (cause) { - throw new Error(`${logId}: Failed to create write path`, { cause }); - } await pipeline( [ @@ -305,18 +384,23 @@ export async function decryptAttachmentV2( }), trimPadding(options.size), peekAndUpdateHash(plaintextHash), - writeFd.createWriteStream(), + sink, ].filter(isNotNil) ); } catch (error) { + // These errors happen when canceling fetch from `attachment://` urls, + // ignore them to avoid noise in the logs. + if (error.name === 'AbortError') { + throw error; + } + log.error( `${logId}: Failed to decrypt attachment`, Errors.toLogFormat(error) ); - safeUnlinkSync(absoluteTargetPath); throw error; } finally { - await Promise.all([readFd?.close(), writeFd?.close()]); + await readFd?.close(); } const ourMac = hmac.digest(); @@ -343,7 +427,7 @@ export async function decryptAttachmentV2( if (!constantTimeEqual(ourMac, theirMac)) { throw new Error(`${logId}: Bad MAC`); } - if (!constantTimeEqual(ourDigest, theirDigest)) { + if (!options.isLocal && !constantTimeEqual(ourDigest, options.theirDigest)) { throw new Error(`${logId}: Bad digest`); } @@ -372,12 +456,64 @@ export async function decryptAttachmentV2( } return { - path: relativeTargetPath, iv, plaintextHash: ourPlaintextHash, }; } +export async function reencryptAttachmentV2( + options: DecryptAttachmentOptionsType +): Promise { + const { idForLogging } = options; + + const logId = `reencryptAttachmentV2(${idForLogging})`; + + // Create random output file + const relativeTargetPath = getRelativePath(createName()); + const absoluteTargetPath = + options.getAbsoluteAttachmentPath(relativeTargetPath); + + let writeFd; + try { + try { + await ensureFile(absoluteTargetPath); + writeFd = await open(absoluteTargetPath, 'w'); + } catch (cause) { + throw new Error(`${logId}: Failed to create write path`, { cause }); + } + + const keys = generateKeys(); + + const passthrough = new PassThrough(); + const [result] = await Promise.all([ + decryptAttachmentV2ToSink(options, passthrough), + await encryptAttachmentV2({ + keys, + plaintext: { + stream: passthrough, + }, + sink: createWriteStream(absoluteTargetPath), + getAbsoluteAttachmentPath: options.getAbsoluteAttachmentPath, + }), + ]); + + return { + ...result, + key: keys, + path: relativeTargetPath, + }; + } catch (error) { + log.error( + `${logId}: Failed to decrypt attachment`, + Errors.toLogFormat(error) + ); + safeUnlinkSync(absoluteTargetPath); + throw error; + } finally { + await writeFd?.close(); + } +} + /** * Splits the keys into aes and mac keys. */ @@ -396,6 +532,10 @@ export function splitKeys(keys: Uint8Array): AttachmentEncryptionKeysType { return { aesKey, macKey }; } +export function generateKeys(): Uint8Array { + return randomBytes(KEY_SET_LENGTH); +} + /** * Updates a hash of the stream without modifying it. */ @@ -412,122 +552,6 @@ function peekAndUpdateHash(hash: Hash) { }); } -/** - * Updates an hmac with the stream except for the last ATTACHMENT_MAC_LENGTH - * bytes. The last ATTACHMENT_MAC_LENGTH bytes are passed to the callback. - */ -export function getMacAndUpdateHmac( - hmac: Hmac, - onTheirMac: (theirMac: Uint8Array) => void -): Transform { - // Because we don't have a view of the entire stream, we don't know when we're - // at the end. We need to omit the last ATTACHMENT_MAC_LENGTH bytes from - // `hmac.update` so we only push what we know is not the mac. - let maybeMacBytes = Buffer.alloc(0); - - function updateWithKnownNonMacBytes() { - let knownNonMacBytes = null; - if (maybeMacBytes.byteLength > ATTACHMENT_MAC_LENGTH) { - knownNonMacBytes = maybeMacBytes.subarray(0, -ATTACHMENT_MAC_LENGTH); - maybeMacBytes = maybeMacBytes.subarray(-ATTACHMENT_MAC_LENGTH); - hmac.update(knownNonMacBytes); - } - return knownNonMacBytes; - } - - return new Transform({ - transform(chunk, _encoding, callback) { - try { - maybeMacBytes = Buffer.concat([maybeMacBytes, chunk]); - const knownNonMac = updateWithKnownNonMacBytes(); - callback(null, knownNonMac); - } catch (error) { - callback(error); - } - }, - flush(callback) { - try { - onTheirMac(maybeMacBytes); - callback(null, null); - } catch (error) { - callback(error); - } - }, - }); -} - -/** - * Gets the IV from the start of the stream and creates a decipher. - * Then deciphers the rest of the stream. - */ -export function getIvAndDecipher( - aesKey: Uint8Array, - onFoundIv?: (iv: Buffer) => void -): Transform { - let maybeIvBytes: Buffer | null = Buffer.alloc(0); - let decipher: Decipher | null = null; - return new Transform({ - transform(chunk, _encoding, callback) { - try { - // If we've already initialized the decipher, just pass the chunk through. - if (decipher != null) { - callback(null, decipher.update(chunk)); - return; - } - - // Wait until we have enough bytes to get the iv to initialize the - // decipher. - maybeIvBytes = Buffer.concat([maybeIvBytes, chunk]); - if (maybeIvBytes.byteLength < IV_LENGTH) { - callback(null, null); - return; - } - - // Once we have enough bytes, initialize the decipher and pass the - // remainder of the bytes through. - const iv = maybeIvBytes.subarray(0, IV_LENGTH); - const remainder = maybeIvBytes.subarray(IV_LENGTH); - onFoundIv?.(iv); - maybeIvBytes = null; // free memory - decipher = createDecipheriv(CipherType.AES256CBC, aesKey, iv); - callback(null, decipher.update(remainder)); - } catch (error) { - callback(error); - } - }, - flush(callback) { - try { - strictAssert(decipher != null, 'decipher must be set'); - callback(null, decipher.final()); - } catch (error) { - callback(error); - } - }, - }); -} - -/** - * Truncates the stream to the target size. - */ -function trimPadding(size: number) { - let total = 0; - return new Transform({ - transform(chunk, _encoding, callback) { - const chunkSize = chunk.byteLength; - const sizeLeft = size - total; - if (sizeLeft >= chunkSize) { - total += chunkSize; - callback(null, chunk); - } else if (sizeLeft > 0) { - total += sizeLeft; - callback(null, chunk.subarray(0, sizeLeft)); - } else { - callback(null, null); - } - }, - }); -} - export function measureSize(onComplete: (size: number) => void): Transform { let totalBytes = 0; const passthrough = new PassThrough(); @@ -568,62 +592,6 @@ function prependIv(iv: Uint8Array) { return prependStream(iv); } -/** - * Called during message schema migration. New messages downloaded should have - * plaintextHash added automatically during decryption / writing to file system. - */ -export async function addPlaintextHashToAttachment( - attachment: AttachmentType, - { getAbsoluteAttachmentPath }: ContextType -): Promise { - if (!attachment.path) { - return attachment; - } - - const plaintextHash = await getPlaintextHashForAttachmentOnDisk( - getAbsoluteAttachmentPath(attachment.path) - ); - - if (!plaintextHash) { - log.error('addPlaintextHashToAttachment: Failed to generate hash'); - return attachment; - } - - return { - ...attachment, - plaintextHash, - }; -} - -export async function getPlaintextHashForAttachmentOnDisk( - absolutePath: string -): Promise { - let readFd; - try { - try { - readFd = await open(absolutePath, 'r'); - } catch (error) { - log.error('addPlaintextHashToAttachment: Target path does not exist'); - return undefined; - } - const hash = createHash(HashType.size256); - await pipeline(readFd.createReadStream(), hash); - const plaintextHash = hash.digest('hex'); - if (!plaintextHash) { - log.error( - 'addPlaintextHashToAttachment: no hash generated from file; is the file empty?' - ); - return; - } - return plaintextHash; - } catch (error) { - log.error('addPlaintextHashToAttachment: error during file read', error); - return undefined; - } finally { - await readFd?.close(); - } -} - export function getPlaintextHashForInMemoryAttachment( data: Uint8Array ): string { diff --git a/ts/background.ts b/ts/background.ts index a17fd982dc..33958caf67 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -208,6 +208,7 @@ import type { ReadSyncTaskType } from './messageModifiers/ReadSyncs'; import { isEnabled } from './RemoteConfig'; import { AttachmentBackupManager } from './jobs/AttachmentBackupManager'; import { getConversationIdForLogging } from './util/idForLogging'; +import { encryptConversationAttachments } from './util/encryptConversationAttachments'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -960,12 +961,22 @@ export async function startApp(): Promise { window.i18n ); - if (newVersion) { + if (newVersion || window.storage.get('needOrphanedAttachmentCheck')) { + await window.storage.remove('needOrphanedAttachmentCheck'); await window.Signal.Data.cleanupOrphanedAttachments(); drop(window.Signal.Data.ensureFilePermissions()); } + if ( + newVersion && + lastVersion && + window.isBeforeVersion(lastVersion, 'v7.18.0-beta.1') + ) { + await encryptConversationAttachments(); + await Stickers.encryptLegacyStickers(); + } + setAppLoadingScreenMessage(window.i18n('icu:loading'), window.i18n); let isMigrationWithIndexComplete = false; diff --git a/ts/components/AddUserToAnotherGroupModal.tsx b/ts/components/AddUserToAnotherGroupModal.tsx index 5dd37f8ce5..765aa5fa1a 100644 --- a/ts/components/AddUserToAnotherGroupModal.tsx +++ b/ts/components/AddUserToAnotherGroupModal.tsx @@ -129,7 +129,7 @@ export function AddUserToAnotherGroupModal({ } return { - ...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'), + ...pick(convo, 'id', 'avatarUrl', 'title', 'unblurredAvatarUrl'), memberships, membersCount, disabledReason, diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index 90b8973175..546b85a5e9 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -65,7 +65,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ acceptedMessageRequest: isBoolean(overrideProps.acceptedMessageRequest) ? overrideProps.acceptedMessageRequest : true, - avatarPath: overrideProps.avatarPath || '', + avatarUrl: overrideProps.avatarUrl || '', badge: overrideProps.badge, blur: overrideProps.blur, color: overrideProps.color || AvatarColors[0], @@ -107,7 +107,7 @@ const TemplateSingle: StoryFn = (args: Props) => ( export const Default = Template.bind({}); Default.args = createProps({ - avatarPath: '/fixtures/giphy-GVNvOUpeYmI7e.gif', + avatarUrl: '/fixtures/giphy-GVNvOUpeYmI7e.gif', }); // eslint-disable-next-line @typescript-eslint/no-explicit-any Default.play = async (context: any) => { @@ -120,13 +120,13 @@ Default.play = async (context: any) => { export const WithBadge = Template.bind({}); WithBadge.args = createProps({ - avatarPath: '/fixtures/kitten-3-64-64.jpg', + avatarUrl: '/fixtures/kitten-3-64-64.jpg', badge: getFakeBadge(), }); export const WideImage = Template.bind({}); WideImage.args = createProps({ - avatarPath: '/fixtures/wide.jpg', + avatarUrl: '/fixtures/wide.jpg', }); export const OneWordName = Template.bind({}); @@ -186,12 +186,12 @@ BrokenColor.args = createProps({ export const BrokenAvatar = Template.bind({}); BrokenAvatar.args = createProps({ - avatarPath: 'badimage.png', + avatarUrl: 'badimage.png', }); export const BrokenAvatarForGroup = Template.bind({}); BrokenAvatarForGroup.args = createProps({ - avatarPath: 'badimage.png', + avatarUrl: 'badimage.png', conversationType: 'group', }); @@ -203,29 +203,29 @@ Loading.args = createProps({ export const BlurredBasedOnProps = TemplateSingle.bind({}); BlurredBasedOnProps.args = createProps({ acceptedMessageRequest: false, - avatarPath: '/fixtures/kitten-3-64-64.jpg', + avatarUrl: '/fixtures/kitten-3-64-64.jpg', }); export const ForceBlurred = TemplateSingle.bind({}); ForceBlurred.args = createProps({ - avatarPath: '/fixtures/kitten-3-64-64.jpg', + avatarUrl: '/fixtures/kitten-3-64-64.jpg', blur: AvatarBlur.BlurPicture, }); export const BlurredWithClickToView = TemplateSingle.bind({}); BlurredWithClickToView.args = createProps({ - avatarPath: '/fixtures/kitten-3-64-64.jpg', + avatarUrl: '/fixtures/kitten-3-64-64.jpg', blur: AvatarBlur.BlurPictureWithClickToView, }); export const StoryUnread = TemplateSingle.bind({}); StoryUnread.args = createProps({ - avatarPath: '/fixtures/kitten-3-64-64.jpg', + avatarUrl: '/fixtures/kitten-3-64-64.jpg', storyRing: HasStories.Unread, }); export const StoryRead = TemplateSingle.bind({}); StoryRead.args = createProps({ - avatarPath: '/fixtures/kitten-3-64-64.jpg', + avatarUrl: '/fixtures/kitten-3-64-64.jpg', storyRing: HasStories.Read, }); diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 5052acf657..913d5978ee 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -53,7 +53,7 @@ export enum AvatarSize { type BadgePlacementType = { bottom: number; right: number }; export type Props = { - avatarPath?: string; + avatarUrl?: string; blur?: AvatarBlur; color?: AvatarColorType; loading?: boolean; @@ -67,7 +67,7 @@ export type Props = { sharedGroupNames: ReadonlyArray; size: AvatarSize; title: string; - unblurredAvatarPath?: string; + unblurredAvatarUrl?: string; searchResult?: boolean; storyRing?: HasStories; @@ -107,7 +107,7 @@ const getDefaultBlur = ( export function Avatar({ acceptedMessageRequest, - avatarPath, + avatarUrl, badge, className, color = 'A200', @@ -123,15 +123,15 @@ export function Avatar({ size, theme, title, - unblurredAvatarPath, + unblurredAvatarUrl, searchResult, storyRing, blur = getDefaultBlur({ acceptedMessageRequest, - avatarPath, + avatarUrl, isMe, sharedGroupNames, - unblurredAvatarPath, + unblurredAvatarUrl, }), ...ariaProps }: Props): JSX.Element { @@ -139,15 +139,15 @@ export function Avatar({ useEffect(() => { setImageBroken(false); - }, [avatarPath]); + }, [avatarUrl]); useEffect(() => { - if (!avatarPath) { + if (!avatarUrl) { return noop; } const image = new Image(); - image.src = avatarPath; + image.src = avatarUrl; image.onerror = () => { log.warn('Avatar: Image failed to load; failing over to placeholder'); setImageBroken(true); @@ -156,10 +156,10 @@ export function Avatar({ return () => { image.onerror = noop; }; - }, [avatarPath]); + }, [avatarUrl]); const initials = getInitials(title); - const hasImage = !noteToSelf && avatarPath && !imageBroken; + const hasImage = !noteToSelf && avatarUrl && !imageBroken; const shouldUseInitials = !hasImage && conversationType === 'direct' && @@ -179,7 +179,7 @@ export function Avatar({ ); } else if (hasImage) { - assertDev(avatarPath, 'avatarPath should be defined here'); + assertDev(avatarUrl, 'avatarUrl should be defined here'); assertDev( blur !== AvatarBlur.BlurPictureWithClickToView || @@ -195,7 +195,7 @@ export function Avatar({
@@ -316,7 +316,7 @@ export function Avatar({ Boolean(storyRing) && 'module-Avatar--with-story', storyRing === HasStories.Unread && 'module-Avatar--with-story--unread', className, - avatarPath === SIGNAL_AVATAR_PATH + avatarUrl === SIGNAL_AVATAR_PATH ? 'module-Avatar--signal-official' : undefined )} diff --git a/ts/components/AvatarEditor.stories.tsx b/ts/components/AvatarEditor.stories.tsx index 6301b4c233..659d71412d 100644 --- a/ts/components/AvatarEditor.stories.tsx +++ b/ts/components/AvatarEditor.stories.tsx @@ -18,7 +18,7 @@ const i18n = setupI18n('en', enMessages); const createProps = (overrideProps: Partial = {}): PropsType => ({ avatarColor: overrideProps.avatarColor || AvatarColors[9], - avatarPath: overrideProps.avatarPath, + avatarUrl: overrideProps.avatarUrl, conversationId: '123', conversationTitle: overrideProps.conversationTitle || 'Default Title', deleteAvatarFromDisk: action('deleteAvatarFromDisk'), @@ -104,7 +104,7 @@ export function HasAvatar(): JSX.Element { return ( ); diff --git a/ts/components/AvatarEditor.tsx b/ts/components/AvatarEditor.tsx index 805621ad93..338a765f7b 100644 --- a/ts/components/AvatarEditor.tsx +++ b/ts/components/AvatarEditor.tsx @@ -24,7 +24,7 @@ import { missingCaseError } from '../util/missingCaseError'; export type PropsType = { avatarColor?: AvatarColorType; - avatarPath?: string; + avatarUrl?: string; avatarValue?: Uint8Array; conversationId?: string; conversationTitle?: string; @@ -46,7 +46,7 @@ enum EditMode { export function AvatarEditor({ avatarColor, - avatarPath, + avatarUrl, avatarValue, conversationId, conversationTitle, @@ -152,7 +152,7 @@ export function AvatarEditor({ }, []); const hasChanges = - initialAvatar !== avatarPreview || Boolean(pendingClear && avatarPath); + initialAvatar !== avatarPreview || Boolean(pendingClear && avatarUrl); let content: JSX.Element | undefined; @@ -162,7 +162,7 @@ export function AvatarEditor({
; + return ; } diff --git a/ts/components/AvatarLightbox.tsx b/ts/components/AvatarLightbox.tsx index 72174d136b..b010c10808 100644 --- a/ts/components/AvatarLightbox.tsx +++ b/ts/components/AvatarLightbox.tsx @@ -11,7 +11,7 @@ import type { LocalizerType } from '../types/Util'; export type PropsType = { avatarColor?: AvatarColorType; - avatarPath?: string; + avatarUrl?: string; conversationTitle?: string; i18n: LocalizerType; isGroup?: boolean; @@ -20,7 +20,7 @@ export type PropsType = { export function AvatarLightbox({ avatarColor, - avatarPath, + avatarUrl, conversationTitle, i18n, isGroup, @@ -43,7 +43,7 @@ export function AvatarLightbox({ > = {}): PropsType => ({ avatarColor: overrideProps.avatarColor, - avatarPath: overrideProps.avatarPath, + avatarUrl: overrideProps.avatarUrl, avatarValue: overrideProps.avatarValue, conversationTitle: overrideProps.conversationTitle, i18n, @@ -81,7 +81,7 @@ export function Value(): JSX.Element { export function Path(): JSX.Element { return ( ); } @@ -90,7 +90,7 @@ export function ValueAndPath(): JSX.Element { return ( diff --git a/ts/components/AvatarPreview.tsx b/ts/components/AvatarPreview.tsx index 99626093fe..18086874b8 100644 --- a/ts/components/AvatarPreview.tsx +++ b/ts/components/AvatarPreview.tsx @@ -15,7 +15,7 @@ import { imagePathToBytes } from '../util/imagePathToBytes'; export type PropsType = { avatarColor?: AvatarColorType; - avatarPath?: string; + avatarUrl?: string; avatarValue?: Uint8Array; conversationTitle?: string; i18n: LocalizerType; @@ -35,7 +35,7 @@ enum ImageStatus { export function AvatarPreview({ avatarColor = AvatarColors[0], - avatarPath, + avatarUrl, avatarValue, conversationTitle, i18n, @@ -48,15 +48,15 @@ export function AvatarPreview({ }: PropsType): JSX.Element { const [avatarPreview, setAvatarPreview] = useState(); - // Loads the initial avatarPath if one is provided, but only if we're in editable mode. - // If we're not editable, we assume that we either have an avatarPath or we show a + // Loads the initial avatarUrl if one is provided, but only if we're in editable mode. + // If we're not editable, we assume that we either have an avatarUrl or we show a // default avatar. useEffect(() => { if (!isEditable) { return; } - if (!avatarPath) { + if (!avatarUrl) { return noop; } @@ -64,7 +64,7 @@ export function AvatarPreview({ void (async () => { try { - const buffer = await imagePathToBytes(avatarPath); + const buffer = await imagePathToBytes(avatarUrl); if (shouldCancel) { return; } @@ -85,7 +85,7 @@ export function AvatarPreview({ return () => { shouldCancel = true; }; - }, [avatarPath, onAvatarLoaded, isEditable]); + }, [avatarUrl, onAvatarLoaded, isEditable]); // Ensures that when avatarValue changes we generate new URLs useEffect(() => { @@ -120,8 +120,8 @@ export function AvatarPreview({ } else if (objectUrl) { encodedPath = objectUrl; imageStatus = ImageStatus.HasImage; - } else if (avatarPath) { - encodedPath = encodeURI(avatarPath); + } else if (avatarUrl) { + encodedPath = avatarUrl; imageStatus = ImageStatus.HasImage; } else { imageStatus = ImageStatus.Nothing; diff --git a/ts/components/CallBackgroundBlur.tsx b/ts/components/CallBackgroundBlur.tsx index 4696c4c07b..0640111960 100644 --- a/ts/components/CallBackgroundBlur.tsx +++ b/ts/components/CallBackgroundBlur.tsx @@ -5,13 +5,13 @@ import React from 'react'; import classNames from 'classnames'; export type PropsType = { - avatarPath?: string; + avatarUrl?: string; children?: React.ReactNode; className?: string; }; export function CallBackgroundBlur({ - avatarPath, + avatarUrl, children, className, }: PropsType): JSX.Element { @@ -19,15 +19,15 @@ export function CallBackgroundBlur({
- {avatarPath && ( + {avatarUrl && (
)} diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 3506896095..711308c641 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -42,7 +42,7 @@ const i18n = setupI18n('en', enMessages); const getConversation = () => getDefaultConversation({ id: '3051234567', - avatarPath: undefined, + avatarUrl: undefined, color: AvatarColors[0], title: 'Rick Sanchez', name: 'Rick Sanchez', diff --git a/ts/components/CallNeedPermissionScreen.tsx b/ts/components/CallNeedPermissionScreen.tsx index 732fbf4e0a..d7b4468620 100644 --- a/ts/components/CallNeedPermissionScreen.tsx +++ b/ts/components/CallNeedPermissionScreen.tsx @@ -13,7 +13,7 @@ export type Props = { conversation: Pick< ConversationType, | 'acceptedMessageRequest' - | 'avatarPath' + | 'avatarUrl' | 'color' | 'isMe' | 'name' @@ -21,7 +21,7 @@ export type Props = { | 'profileName' | 'sharedGroupNames' | 'title' - | 'unblurredAvatarPath' + | 'unblurredAvatarUrl' >; i18n: LocalizerType; close: () => void; @@ -46,7 +46,7 @@ export function CallNeedPermissionScreen({
) : ( - +
{i18n('icu:calling__your-video-is-off')} @@ -453,10 +453,10 @@ export function CallScreen({ autoPlay /> ) : ( - + ; getIsSharingPhoneNumberWithEverybody: () => boolean; groupMembers?: Array< @@ -66,7 +66,7 @@ export type PropsType = { isConversationTooBigToRing: boolean; isCallFull?: boolean; me: Readonly< - Pick + Pick >; onCallCanceled: () => void; onJoinCall: () => void; @@ -285,7 +285,7 @@ export function CallingLobby({ ) : ( )} diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index fd5851c414..8ddce02a82 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -31,7 +31,7 @@ function createParticipant( sharingScreen: Boolean(participantProps.sharingScreen), videoAspectRatio: 1.3, ...getDefaultConversationWithServiceId({ - avatarPath: participantProps.avatarPath, + avatarUrl: participantProps.avatarUrl, color: sample(AvatarColors), isBlocked: Boolean(participantProps.isBlocked), name: participantProps.name, diff --git a/ts/components/CallingParticipantsList.tsx b/ts/components/CallingParticipantsList.tsx index 9a3e4755d5..7c8a64ae54 100644 --- a/ts/components/CallingParticipantsList.tsx +++ b/ts/components/CallingParticipantsList.tsx @@ -129,7 +129,7 @@ export const CallingParticipantsList = React.memo( acceptedMessageRequest={ participant.acceptedMessageRequest } - avatarPath={participant.avatarPath} + avatarUrl={participant.avatarUrl} badge={undefined} color={participant.color} conversationType="direct" diff --git a/ts/components/CallingPendingParticipants.tsx b/ts/components/CallingPendingParticipants.tsx index db93bab4af..7cd9facf49 100644 --- a/ts/components/CallingPendingParticipants.tsx +++ b/ts/components/CallingPendingParticipants.tsx @@ -244,7 +244,7 @@ export function CallingPendingParticipants({
- +
; i18n: LocalizerType; me: Pick; @@ -186,7 +186,7 @@ export function CallingPreCallInfo({ return (
diff --git a/ts/components/CallingRaisedHandsList.stories.tsx b/ts/components/CallingRaisedHandsList.stories.tsx index ef686843b4..ca242a8851 100644 --- a/ts/components/CallingRaisedHandsList.stories.tsx +++ b/ts/components/CallingRaisedHandsList.stories.tsx @@ -35,7 +35,7 @@ const i18n = setupI18n('en', enMessages); const conversation = getDefaultConversationWithServiceId({ id: '3051234567', - avatarPath: undefined, + avatarUrl: undefined, color: AvatarColors[0], title: 'Rick Sanchez', name: 'Rick Sanchez', diff --git a/ts/components/CallingRaisedHandsList.tsx b/ts/components/CallingRaisedHandsList.tsx index 9a0906fa2c..fa279860fa 100644 --- a/ts/components/CallingRaisedHandsList.tsx +++ b/ts/components/CallingRaisedHandsList.tsx @@ -101,7 +101,7 @@ export function CallingRaisedHandsList({
; export function ContactPill({ acceptedMessageRequest, - avatarPath, + avatarUrl, color, firstName, i18n, @@ -39,7 +39,7 @@ export function ContactPill({ profileName, sharedGroupNames, title, - unblurredAvatarPath, + unblurredAvatarUrl, onClickRemove, }: PropsType): JSX.Element { const removeLabel = i18n('icu:ContactPill--remove'); @@ -48,7 +48,7 @@ export function ContactPill({
({ ...(overrideProps ?? getDefaultConversation({ - avatarPath: gifUrl, + avatarUrl: gifUrl, firstName: 'John', id: 'abc123', isMe: false, diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 84ba517863..6b0ee86f76 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -275,7 +275,7 @@ const createConversation = ( : true, badges: [], isMe: overrideProps.isMe ?? false, - avatarPath: overrideProps.avatarPath ?? '', + avatarUrl: overrideProps.avatarUrl ?? '', id: overrideProps.id || '', isSelected: overrideProps.isSelected ?? false, title: overrideProps.title ?? 'Some Person', @@ -308,7 +308,7 @@ export const ConversationName = (): JSX.Element => renderConversation(); export const ConversationNameAndAvatar = (): JSX.Element => renderConversation({ - avatarPath: '/fixtures/kitten-1-64-64.jpg', + avatarUrl: '/fixtures/kitten-1-64-64.jpg', }); export const ConversationWithYourself = (): JSX.Element => diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index c14d12a18d..8f445df822 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -371,7 +371,7 @@ export function ConversationList({ case RowType.Conversation: { const itemProps = pick(row.conversation, [ 'acceptedMessageRequest', - 'avatarPath', + 'avatarUrl', 'badges', 'color', 'draftPreview', @@ -393,7 +393,7 @@ export function ConversationList({ 'title', 'type', 'typingContactIdTimestamps', - 'unblurredAvatarPath', + 'unblurredAvatarUrl', 'unreadCount', 'unreadMentionsCount', 'serviceId', diff --git a/ts/components/DirectCallRemoteParticipant.tsx b/ts/components/DirectCallRemoteParticipant.tsx index cabfb6bddb..d30e8fe60b 100644 --- a/ts/components/DirectCallRemoteParticipant.tsx +++ b/ts/components/DirectCallRemoteParticipant.tsx @@ -52,7 +52,7 @@ function renderAvatar( i18n: LocalizerType, { acceptedMessageRequest, - avatarPath, + avatarUrl, color, isMe, phoneNumber, @@ -62,7 +62,7 @@ function renderAvatar( }: Pick< ConversationType, | 'acceptedMessageRequest' - | 'avatarPath' + | 'avatarUrl' | 'color' | 'isMe' | 'phoneNumber' @@ -73,10 +73,10 @@ function renderAvatar( ): JSX.Element { return (
- + = React.memo( const { acceptedMessageRequest, addedTime, - avatarPath, + avatarUrl, color, demuxId, hasRemoteAudio, @@ -378,7 +378,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( noVideoNode = ( = React.memo( )} {noVideoNode && ( {noVideoNode} diff --git a/ts/components/GroupDialog.tsx b/ts/components/GroupDialog.tsx index bec67d4ce7..b18a08e856 100644 --- a/ts/components/GroupDialog.tsx +++ b/ts/components/GroupDialog.tsx @@ -110,7 +110,7 @@ function Contacts({